chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
import { chat } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId, generateShortId } from '@sim/utils/id'
|
||||
import { randomInt } from '@sim/utils/random'
|
||||
import { and, inArray, isNull } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { isRecord } from '@/lib/workflows/persistence/remap-internal-ids'
|
||||
|
||||
const logger = createLogger('WorkspaceForkCopyChats')
|
||||
|
||||
/** Attempts at a random chat identifier before falling back to a long random suffix. */
|
||||
const IDENTIFIER_ATTEMPTS = 5
|
||||
|
||||
export interface ForkChatCopyPair {
|
||||
sourceWorkflowId: string
|
||||
targetWorkflowId: string
|
||||
/** The target workflow's display name, for the generated chat identifier. */
|
||||
workflowName: string
|
||||
}
|
||||
|
||||
/** Lowercase a display name into the chat identifier charset (`[a-z0-9-]`), bounded. */
|
||||
function slugifyForIdentifier(value: string): string {
|
||||
const slug = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 24)
|
||||
.replace(/-+$/g, '')
|
||||
return slug || 'chat'
|
||||
}
|
||||
|
||||
/**
|
||||
* `{workspace}-{workflow}-{randomnum}` in the identifier charset. Six digits: concurrent forks
|
||||
* of one parent share the workspace/workflow slugs and can't see each other's uncommitted rows,
|
||||
* so the number is the only collision guard across simultaneous transactions.
|
||||
*/
|
||||
function buildIdentifierCandidate(workspaceSlug: string, workflowName: string): string {
|
||||
const random = randomInt(100000, 1000000)
|
||||
return `${workspaceSlug}-${slugifyForIdentifier(workflowName)}-${random}`
|
||||
}
|
||||
|
||||
/** Remap each output config's `blockId` onto the target workflow's block ids. */
|
||||
function remapChatOutputConfigs(
|
||||
value: unknown,
|
||||
targetWorkflowId: string,
|
||||
resolveBlockId: (targetWorkflowId: string, sourceBlockId: string) => string
|
||||
): unknown {
|
||||
if (!Array.isArray(value)) return value
|
||||
return value.map((entry) => {
|
||||
if (!isRecord(entry) || typeof entry.blockId !== 'string') return entry
|
||||
return { ...entry, blockId: resolveBlockId(targetWorkflowId, entry.blockId) }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry chat deployments onto the target side of a fork or sync: each LIVE source chat whose
|
||||
* target workflow has NO chat row at all (live or archived) is copied with a freshly generated
|
||||
* identifier - `{target-workspace}-{workflow}-{randomnum}` - so the copy serves at its own URL
|
||||
* immediately once the workflow deploys. Config copies verbatim (title, customizations, auth
|
||||
* incl. the hashed password and allowed emails); `outputConfigs` block ids are remapped through
|
||||
* the SAME block-id resolver the workflow write used, so the outputs bind to the target's
|
||||
* blocks.
|
||||
*
|
||||
* Targets with ANY existing chat row are left completely untouched ("maintained"): an already
|
||||
* carried-over chat keeps its identifier and config on every subsequent sync, and a chat the
|
||||
* target side deliberately archived is never resurrected. Bounded by the synced workflow count;
|
||||
* identifiers are collision-checked against live chats and fall back to a long random suffix.
|
||||
*/
|
||||
export async function copyForkChatDeployments(params: {
|
||||
tx: DbOrTx
|
||||
pairs: ForkChatCopyPair[]
|
||||
/** The TARGET workspace's display name, the identifier's first segment. */
|
||||
targetWorkspaceName: string
|
||||
userId: string
|
||||
now: Date
|
||||
resolveBlockId: (targetWorkflowId: string, sourceBlockId: string) => string
|
||||
requestId?: string
|
||||
}): Promise<{ created: number }> {
|
||||
const { tx, pairs, targetWorkspaceName, userId, now, resolveBlockId, requestId } = params
|
||||
if (pairs.length === 0) return { created: 0 }
|
||||
|
||||
const pairBySource = new Map(pairs.map((pair) => [pair.sourceWorkflowId, pair]))
|
||||
const sourceChats = await tx
|
||||
.select()
|
||||
.from(chat)
|
||||
.where(and(inArray(chat.workflowId, [...pairBySource.keys()]), isNull(chat.archivedAt)))
|
||||
if (sourceChats.length === 0) return { created: 0 }
|
||||
|
||||
// A target workflow with ANY chat row (live or archived) keeps it: live means the carry-over
|
||||
// already happened (or the target made its own); archived means the target deliberately
|
||||
// retired it - recreating would resurrect against their intent.
|
||||
const candidateTargetIds = [
|
||||
...new Set(
|
||||
sourceChats
|
||||
.map((row) => pairBySource.get(row.workflowId)?.targetWorkflowId)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
),
|
||||
]
|
||||
const existingTargetRows = await tx
|
||||
.select({ workflowId: chat.workflowId })
|
||||
.from(chat)
|
||||
.where(inArray(chat.workflowId, candidateTargetIds))
|
||||
const targetsWithChat = new Set(existingTargetRows.map((row) => row.workflowId))
|
||||
|
||||
const toCopy = sourceChats.filter((row) => {
|
||||
const pair = pairBySource.get(row.workflowId)
|
||||
return pair && !targetsWithChat.has(pair.targetWorkflowId)
|
||||
})
|
||||
if (toCopy.length === 0) return { created: 0 }
|
||||
|
||||
// Generate identifiers, retrying collisions against LIVE chats (the unique index is partial
|
||||
// on `archived_at IS NULL`, so archived identifiers are reusable) and against this batch.
|
||||
const workspaceSlug = slugifyForIdentifier(targetWorkspaceName)
|
||||
const identifierByChatId = new Map<string, string>()
|
||||
let pending = toCopy.map((row) => ({
|
||||
chatId: row.id,
|
||||
workflowName: pairBySource.get(row.workflowId)?.workflowName ?? 'chat',
|
||||
}))
|
||||
for (let attempt = 0; attempt < IDENTIFIER_ATTEMPTS && pending.length > 0; attempt++) {
|
||||
const claimed = new Set(identifierByChatId.values())
|
||||
const candidates = pending.map((entry) => {
|
||||
let candidate = buildIdentifierCandidate(workspaceSlug, entry.workflowName)
|
||||
while (claimed.has(candidate)) {
|
||||
candidate = buildIdentifierCandidate(workspaceSlug, entry.workflowName)
|
||||
}
|
||||
claimed.add(candidate)
|
||||
return { ...entry, candidate }
|
||||
})
|
||||
const taken = new Set(
|
||||
(
|
||||
await tx
|
||||
.select({ identifier: chat.identifier })
|
||||
.from(chat)
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
chat.identifier,
|
||||
candidates.map((entry) => entry.candidate)
|
||||
),
|
||||
isNull(chat.archivedAt)
|
||||
)
|
||||
)
|
||||
).map((row) => row.identifier)
|
||||
)
|
||||
pending = []
|
||||
for (const entry of candidates) {
|
||||
if (taken.has(entry.candidate)) pending.push(entry)
|
||||
else identifierByChatId.set(entry.chatId, entry.candidate)
|
||||
}
|
||||
}
|
||||
for (const entry of pending) {
|
||||
// Exhausted the friendly attempts: a long random suffix is effectively collision-free
|
||||
// (the global unique index still backstops it).
|
||||
identifierByChatId.set(
|
||||
entry.chatId,
|
||||
`${workspaceSlug}-${slugifyForIdentifier(entry.workflowName)}-${generateShortId(10)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '0')}`
|
||||
)
|
||||
}
|
||||
|
||||
const inserts: (typeof chat.$inferInsert)[] = []
|
||||
for (const row of toCopy) {
|
||||
const pair = pairBySource.get(row.workflowId)
|
||||
const identifier = identifierByChatId.get(row.id)
|
||||
if (!pair || !identifier) continue
|
||||
inserts.push({
|
||||
...row,
|
||||
id: generateId(),
|
||||
workflowId: pair.targetWorkflowId,
|
||||
userId,
|
||||
identifier,
|
||||
outputConfigs: remapChatOutputConfigs(
|
||||
row.outputConfigs,
|
||||
pair.targetWorkflowId,
|
||||
resolveBlockId
|
||||
),
|
||||
archivedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
if (inserts.length > 0) {
|
||||
await tx.insert(chat).values(inserts)
|
||||
logger.info(`[${requestId ?? 'unknown'}] Carried ${inserts.length} chat deployment(s)`, {
|
||||
identifiers: inserts.map((row) => row.identifier),
|
||||
})
|
||||
}
|
||||
return { created: inserts.length }
|
||||
}
|
||||
Reference in New Issue
Block a user