Files
simstudioai--sim/apps/sim/ee/workspace-forking/lib/copy/copy-chats.ts
T
wehub-resource-sync d25d482dc2
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

191 lines
7.3 KiB
TypeScript

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 }
}