Files
simstudioai--sim/apps/sim/ee/workspace-forking/lib/copy/copy-resources.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

1019 lines
40 KiB
TypeScript

import { db } from '@sim/db'
import {
customTools,
document,
embedding,
knowledgeBase,
knowledgeBaseTagDefinitions,
mcpServers,
skill,
userTableDefinitions,
userTableRows,
workflowMcpServer,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, asc, eq, gt, inArray, isNull, type SQL, sql } from 'drizzle-orm'
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
import type { DbOrTx } from '@/lib/db/types'
import type { TableSchema } from '@/lib/table/types'
import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager'
import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service'
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
import { isRecord } from '@/lib/workflows/persistence/remap-internal-ids'
import type {
ForkMappingUpsert,
ForkResourceType,
} from '@/ee/workspace-forking/lib/mapping/mapping-store'
import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
import {
type ForkContentRefMaps,
rewriteForkContentRefs,
rewriteForkResourceUrls,
} from '@/ee/workspace-forking/lib/remap/remap-content-refs'
import {
type ForkReferenceResolver,
rewriteEnvRefsInText,
} from '@/ee/workspace-forking/lib/remap/remap-references'
import { remapForkTableWorkflowGroups } from '@/ee/workspace-forking/lib/remap/remap-table-groups'
const logger = createLogger('WorkspaceForkCopyResources')
/** Page size for the post-transaction bulk content copy (keyset-paginated). */
const CONTENT_PAGE = 500
/**
* Max documents copied concurrently within one KB page. Bounds fan-out (blob copy + per-doc
* embedding paging) so a large page doesn't issue every request at once; the keyset loop still
* processes one page at a time, so peak concurrency stays at this cap regardless of KB size.
*/
const KB_DOCUMENT_COPY_CONCURRENCY = 5
/**
* Max copied skill bodies rewritten concurrently within one keyset page. Bounds the per-skill
* re-read + UPDATE fan-out so a page of copied skills doesn't issue every write at once; the keyset
* loop still processes one page at a time, so peak concurrency stays at this cap.
*/
const SKILL_REWRITE_CONCURRENCY = 5
export interface CopyResourcesParams {
tx: DbOrTx
sourceWorkspaceId: string
childWorkspaceId: string
userId: string
now: Date
/** Source resource ids selected for copy, by kind. */
selection: {
customTools: string[]
skills: string[]
/** External MCP servers, copied as config rows (fork-only; sync resolves them by mapping). */
mcpServers: string[]
workflowMcpServers: string[]
tables: string[]
knowledgeBases: string[]
}
/** source workflow id -> child workflow id, for table workflow-group remap. */
workflowIdMap: Map<string, string>
/**
* Source KB-document ids referenced by the copied workflows (document-selector values +
* nested `documentId` tool params). Documents in this set whose parent KB is being copied
* get a placeholder row + a persisted `knowledge_document` id map inside the transaction, so
* the reference remaps to the copied document instead of being cleared. Defaults to none.
*/
referencedDocumentIds?: string[]
/**
* Resolve a source env-var name to its target name, so a copied custom tool's `code` (which
* embeds `{{ENV}}` refs) is rewritten when a sync renames an env var. Provided by promote (the
* plan resolver); omitted by fork-create, which preserves env names verbatim (no rewrite).
*/
resolveEnvName?: (key: string) => string | null | undefined
/**
* Resolve a source block id to its target block id for copied tables' workflow-group
* `outputs[].blockId`. Promote passes the SAME persisted-pair resolver its workflow writes
* use (on push the parent keeps its ORIGINAL block ids, never the derive); fork-create
* omits it, defaulting to the deterministic derive (a fresh child has no pairs).
*/
resolveBlockId?: ForkBlockIdResolver
}
export interface ForkContentPlanEntry {
sourceId: string
childId: string
}
/**
* A KB to copy post-commit, plus the source-document -> child-document id map for the
* documents that were pre-created as placeholders in the transaction (referenced by copied
* workflows). The content phase fills those exact child ids (and copies the rest fresh) so a
* remapped `document-selector` reference can never dangle.
*/
export interface ForkContentKbEntry extends ForkContentPlanEntry {
documentIdMap: Record<string, string>
}
/**
* A copied skill whose body's in-content references (`sim:` links + embedded file URLs) are
* rewritten post-commit. Only the child id is carried: the child row is inserted in the fork tx
* with the SOURCE body copied IN-DB (never materialized in app memory or embedded in the job
* payload), and the content phase RE-READS the body keyset-paginated to rewrite it best-effort so
* it points at the copied child resources (an unmapped target degrades to a graceful broken link,
* never an FK/subblock reference).
*/
export interface ForkContentSkillEntry {
childId: string
}
/**
* A single source document copied into an EXISTING (already-mapped, not copied this sync) target
* KB - the sync-only "document into mapped KB" path (U-docs). The placeholder row is inserted in
* the promote tx at `childDocId` under `childKnowledgeBaseId`; the content phase fills its
* embeddings (and re-keys its blob) best-effort. Distinct from {@link ForkContentKbEntry}, which
* copies a whole KB's documents - here only the referenced documents are placed, since the KB
* itself already exists in the target with its own documents.
*/
export interface ForkContentDocumentEntry {
sourceDocId: string
childDocId: string
childKnowledgeBaseId: string
/** Source blob fields captured at placeholder time, for the post-commit blob re-key. */
storageKey: string | null
filename: string
mimeType: string
}
/** Bulk content to copy AFTER the fork transaction commits (best-effort, batched). */
export interface ForkContentPlan {
sourceWorkspaceId: string
childWorkspaceId: string
/** Initiating user, recorded as the owner of copied KB-document blob bindings in the child. */
userId: string
tables: ForkContentPlanEntry[]
knowledgeBases: ForkContentKbEntry[]
skills: ForkContentSkillEntry[]
/** Documents copied into an already-existing target KB (sync-only; empty at fork create). */
documents: ForkContentDocumentEntry[]
}
/**
* A resource whose post-commit content fill failed, so every reference to it must be cleared.
* A table carries its child definition id; a KB carries its child id (dropping it cascade-removes
* its documents + embeddings) and the child ids of the document placeholders pre-created in the
* fork tx, so `document-selector` references clear too. A standalone `knowledge-document` (a doc
* copied into an existing target KB) carries just its child id - dropping that one row (its
* embeddings cascade) without touching the existing KB. A `file` carries the COPIED child storage
* key whose blob duplication failed: its `file-upload` references are cleared so no block points at
* a missing object. Unlike the others, a failed file drops no row - the metadata row is left so the
* user can re-upload the blob.
*/
export type ForkFailedResource =
| { kind: 'table'; childId: string }
| { kind: 'knowledge-base'; childId: string; documentChildIds: string[] }
| { kind: 'knowledge-document'; childId: string }
| { kind: 'file'; childKey: string }
/** Display names of the copied resources, by kind, for the fork activity report. */
export interface ForkCopiedResourceNames {
tables: string[]
knowledgeBases: string[]
customTools: string[]
skills: string[]
mcpServers: string[]
workflowMcpServers: string[]
}
export interface CopyResourcesResult {
/** source resource id -> child resource id, keyed by fork resource type. */
idMap: Map<ForkResourceType, Map<string, string>>
/** Identity mapping rows to persist for every copied resource. */
mappingEntries: ForkMappingUpsert[]
/** Heavy row/document/embedding content to copy post-commit. */
contentPlan: ForkContentPlan
/** Names of the copied resources, by kind, for the fork report breakdown. */
names: ForkCopiedResourceNames
}
function setId(idMap: Map<ForkResourceType, Map<string, string>>, type: ForkResourceType) {
let map = idMap.get(type)
if (!map) {
map = new Map()
idMap.set(type, map)
}
return map
}
/**
* Child `skill` insert whose `content` is a correlated subquery (copied server-side from the source
* row) rather than a materialized string, so the fork tx never pulls skill bodies into app memory -
* see the skeleton skill copy in {@link copyForkResourceContainers}.
*/
type SkillSkeletonInsert = Omit<typeof skill.$inferInsert, 'content'> & { content: SQL }
/**
* Copy the selected resources' **container rows** into the child workspace inside
* the fork transaction: custom tools, skills, and MCP server configs (each a
* single row), plus table definitions and knowledge-base rows with their tag
* definitions (bounded per KB) but without their bulk rows / documents /
* embeddings. This keeps the fork transaction bounded to
* O(selected resources) single-row writes. The heavy content (table rows, KB
* documents + embeddings) is returned as a {@link ForkContentPlan} for
* {@link copyForkResourceContent} to copy best-effort after commit. Secrets are
* never copied: MCP OAuth tokens are omitted (re-auth required) and KB connectors
* are not copied (the child is a content snapshot without live sync).
*/
export async function copyForkResourceContainers(
params: CopyResourcesParams
): Promise<CopyResourcesResult> {
const { tx, sourceWorkspaceId, childWorkspaceId, userId, now, selection, workflowIdMap } = params
const referencedDocumentIds = params.referencedDocumentIds ?? []
const resolveEnvName = params.resolveEnvName
const idMap = new Map<ForkResourceType, Map<string, string>>()
const mappingEntries: ForkMappingUpsert[] = []
const contentPlan: ForkContentPlan = {
sourceWorkspaceId,
childWorkspaceId,
userId,
tables: [],
knowledgeBases: [],
skills: [],
documents: [],
}
const names: ForkCopiedResourceNames = {
tables: [],
knowledgeBases: [],
customTools: [],
skills: [],
mcpServers: [],
workflowMcpServers: [],
}
const record = (type: ForkResourceType, sourceId: string, childId: string) => {
setId(idMap, type).set(sourceId, childId)
mappingEntries.push({
resourceType: type,
parentResourceId: sourceId,
childResourceId: childId,
})
}
if (selection.customTools.length > 0) {
const rows = await tx
.select()
.from(customTools)
.where(
and(
inArray(customTools.id, selection.customTools),
eq(customTools.workspaceId, sourceWorkspaceId)
)
)
const inserts: (typeof customTools.$inferInsert)[] = []
for (const row of rows) {
const childId = generateId()
inserts.push({
...row,
id: childId,
workspaceId: childWorkspaceId,
userId,
// The code column is copied verbatim, so a `{{ENV}}` ref in it would stay stale when a
// sync renames the env var. Rewrite those refs through the env-name resolver (subblock
// values are already remapped by the reference transform). Fork-create passes no resolver,
// preserving env names by default.
...(resolveEnvName && typeof row.code === 'string'
? { code: rewriteEnvRefsInText(row.code, resolveEnvName) }
: {}),
createdAt: now,
updatedAt: now,
})
record('custom_tool', row.id, childId)
names.customTools.push(row.title)
}
if (inserts.length > 0) await tx.insert(customTools).values(inserts)
}
if (selection.skills.length > 0) {
// Select every skill column EXCEPT `content`: the body (capped at 50 KB each, up to 2000 skills)
// is copied server-side via the correlated subquery below, so it is never materialized in app
// memory while the fork tx holds its locks - nor carried in the background-job payload.
const rows = await tx
.select({
id: skill.id,
workspaceId: skill.workspaceId,
userId: skill.userId,
name: skill.name,
description: skill.description,
createdAt: skill.createdAt,
updatedAt: skill.updatedAt,
})
.from(skill)
.where(and(inArray(skill.id, selection.skills), eq(skill.workspaceId, sourceWorkspaceId)))
const inserts: SkillSkeletonInsert[] = []
for (const row of rows) {
const childId = generateId()
inserts.push({
...row,
id: childId,
workspaceId: childWorkspaceId,
userId,
// Copy the body straight from the source row in-DB (never through app memory). Re-read and
// rewritten post-commit (see copyForkResourceContent), out of the locked fork tx.
content: sql`(SELECT ${skill.content} FROM ${skill} WHERE ${skill.id} = ${row.id})`,
createdAt: now,
updatedAt: now,
})
record('skill', row.id, childId)
contentPlan.skills.push({ childId })
names.skills.push(row.name)
}
if (inserts.length > 0) await tx.insert(skill).values(inserts)
}
if (selection.mcpServers.length > 0) {
const rows = await tx
.select()
.from(mcpServers)
.where(
and(
inArray(mcpServers.id, selection.mcpServers),
eq(mcpServers.workspaceId, sourceWorkspaceId),
isNull(mcpServers.deletedAt)
)
)
// Copy external MCP servers as CONFIG rows: transport/url/headers (and pre-registered OAuth
// client info) verbatim - the forking admin can already read them in the source. OAuth
// tokens (`mcp_server_oauth`) are never copied: an oauth-auth server lands disconnected in
// the child until re-authorized. Runtime status resets to a clean slate - the tool cache is
// workspace-keyed, so the child's first tool-selector open / execution re-discovers tools
// fresh; subblock tool SELECTIONS remap onto the copied server id (tool names carry over).
// `{{ENV}}` refs in the url/headers are rewritten through the env-name resolver when a sync
// renames an env var (fork passes no resolver - names preserve verbatim), mirroring the
// custom-tool `code` rewrite.
const rewriteEnv = (value: string): string =>
resolveEnvName ? rewriteEnvRefsInText(value, resolveEnvName) : value
const inserts: (typeof mcpServers.$inferInsert)[] = []
for (const row of rows) {
const childId = generateId()
const headers = isRecord(row.headers)
? Object.fromEntries(
Object.entries(row.headers).map(([key, value]) => [
key,
typeof value === 'string' ? rewriteEnv(value) : value,
])
)
: row.headers
inserts.push({
...row,
id: childId,
workspaceId: childWorkspaceId,
createdBy: userId,
url: typeof row.url === 'string' ? rewriteEnv(row.url) : row.url,
headers,
connectionStatus: 'disconnected',
lastConnected: null,
lastError: null,
statusConfig: { consecutiveFailures: 0, lastSuccessfulDiscovery: null },
toolCount: 0,
lastToolsRefresh: null,
totalRequests: 0,
lastUsed: null,
deletedAt: null,
createdAt: now,
updatedAt: now,
})
record('mcp_server', row.id, childId)
names.mcpServers.push(row.name)
}
if (inserts.length > 0) await tx.insert(mcpServers).values(inserts)
}
if (selection.workflowMcpServers.length > 0) {
const rows = await tx
.select()
.from(workflowMcpServer)
.where(
and(
inArray(workflowMcpServer.id, selection.workflowMcpServers),
eq(workflowMcpServer.workspaceId, sourceWorkspaceId),
isNull(workflowMcpServer.deletedAt)
)
)
// Copy workflow-publishing MCP servers as config-only shells: the server definition
// (name/description/visibility) with NO `workflow_mcp_tool` rows attached - the child's
// attachments are seeded by the chat/attachment carry-over and re-derived on deploy. The
// shell copy IS recorded in the fork resource map (`workflow_mcp_server` identity), so a
// later sync can mirror `workflow_mcp_tool` attachments onto the mapped counterpart.
const inserts: (typeof workflowMcpServer.$inferInsert)[] = []
for (const row of rows) {
const childId = generateId()
inserts.push({
...row,
id: childId,
workspaceId: childWorkspaceId,
createdBy: userId,
deletedAt: null,
createdAt: now,
updatedAt: now,
})
record('workflow_mcp_server', row.id, childId)
names.workflowMcpServers.push(row.name)
}
if (inserts.length > 0) await tx.insert(workflowMcpServer).values(inserts)
}
if (selection.tables.length > 0) {
const definitions = await tx
.select()
.from(userTableDefinitions)
.where(
and(
inArray(userTableDefinitions.id, selection.tables),
eq(userTableDefinitions.workspaceId, sourceWorkspaceId),
isNull(userTableDefinitions.archivedAt)
)
)
const inserts: (typeof userTableDefinitions.$inferInsert)[] = []
for (const definition of definitions) {
const childTableId = generateId()
const remappedSchema = remapForkTableWorkflowGroups(
definition.schema as TableSchema,
workflowIdMap,
params.resolveBlockId
)
inserts.push({
...definition,
id: childTableId,
workspaceId: childWorkspaceId,
schema: remappedSchema,
createdBy: userId,
rowsVersion: 0,
// Start at 0 - the post-commit content copy raises it to the rows actually
// copied, so a failed/partial copy never advertises the source's count.
rowCount: 0,
archivedAt: null,
createdAt: now,
updatedAt: now,
})
record('table', definition.id, childTableId)
contentPlan.tables.push({ sourceId: definition.id, childId: childTableId })
names.tables.push(definition.name)
}
if (inserts.length > 0) await tx.insert(userTableDefinitions).values(inserts)
}
if (selection.knowledgeBases.length > 0) {
const bases = await tx
.select()
.from(knowledgeBase)
.where(
and(
inArray(knowledgeBase.id, selection.knowledgeBases),
eq(knowledgeBase.workspaceId, sourceWorkspaceId),
isNull(knowledgeBase.deletedAt)
)
)
const inserts: (typeof knowledgeBase.$inferInsert)[] = []
const kbEntryBySourceId = new Map<string, ForkContentKbEntry>()
for (const base of bases) {
const childKbId = generateId()
inserts.push({
...base,
id: childKbId,
workspaceId: childWorkspaceId,
userId,
deletedAt: null,
createdAt: now,
updatedAt: now,
})
record('knowledge_base', base.id, childKbId)
const entry: ForkContentKbEntry = { sourceId: base.id, childId: childKbId, documentIdMap: {} }
contentPlan.knowledgeBases.push(entry)
kbEntryBySourceId.set(base.id, entry)
names.knowledgeBases.push(base.name)
}
if (inserts.length > 0) await tx.insert(knowledgeBase).values(inserts)
// Copy each source KB's tag definitions to its child so tagged documents keep a working tag
// schema: the copied documents carry tag VALUES in their slot columns, and both tag-filter
// search and documentTags writes resolve display names through these definition rows (a copy
// without them 400s / throws on every defined tag). Fresh ids, child KB id, all other columns
// verbatim - nothing persists a tag-definition id (workflow state, documents, and fork
// mappings all reference tags by display name / slot), so no id map is recorded.
if (kbEntryBySourceId.size > 0) {
const tagDefinitions = await tx
.select()
.from(knowledgeBaseTagDefinitions)
.where(
inArray(knowledgeBaseTagDefinitions.knowledgeBaseId, Array.from(kbEntryBySourceId.keys()))
)
const tagDefinitionInserts: (typeof knowledgeBaseTagDefinitions.$inferInsert)[] = []
for (const definition of tagDefinitions) {
const childKbId = kbEntryBySourceId.get(definition.knowledgeBaseId)?.childId
if (!childKbId) continue
tagDefinitionInserts.push({
...definition,
id: generateId(),
knowledgeBaseId: childKbId,
})
}
if (tagDefinitionInserts.length > 0) {
await tx.insert(knowledgeBaseTagDefinitions).values(tagDefinitionInserts)
}
}
// Pre-create placeholder document rows for the documents the copied workflows
// reference, at child ids generated inside the transaction, so each
// `document-selector` reference can be remapped to a valid copied document rather
// than cleared. Only documents whose parent KB is in this copy (FK-safe: the KB
// rows were just inserted above) are placed; the heavy content (embeddings + blob)
// is filled at these exact ids by the post-commit content phase.
await createForkDocumentPlaceholders({
tx,
kbIdMap: idMap.get('knowledge_base') ?? new Map(),
kbEntryBySourceId,
referencedDocumentIds,
record,
})
}
return { idMap, mappingEntries, contentPlan, names }
}
/**
* Insert placeholder {@link document} rows in the child KBs for the referenced documents
* whose parent KB is being copied, recording the `knowledge_document` source->child id map.
* Each placeholder is the source document's metadata row at a fresh child id; its embeddings
* and (re-keyed) blob are filled by {@link copyForkResourceContent} after commit. Documents
* whose parent KB is not copied are skipped, leaving their references to be cleared.
*/
async function createForkDocumentPlaceholders(params: {
tx: DbOrTx
kbIdMap: Map<string, string>
kbEntryBySourceId: Map<string, ForkContentKbEntry>
referencedDocumentIds: string[]
record: (type: ForkResourceType, sourceId: string, childId: string) => void
}): Promise<void> {
const { tx, kbIdMap, kbEntryBySourceId, referencedDocumentIds, record } = params
if (referencedDocumentIds.length === 0 || kbIdMap.size === 0) return
const docs = await tx
.select()
.from(document)
.where(
and(
inArray(document.id, referencedDocumentIds),
inArray(document.knowledgeBaseId, Array.from(kbIdMap.keys())),
isNull(document.deletedAt),
isNull(document.archivedAt)
)
)
const inserts: (typeof document.$inferInsert)[] = []
for (const doc of docs) {
const childKbId = kbIdMap.get(doc.knowledgeBaseId)
const kbEntry = kbEntryBySourceId.get(doc.knowledgeBaseId)
if (!childKbId || !kbEntry) continue
const childDocId = generateId()
inserts.push({
...doc,
id: childDocId,
knowledgeBaseId: childKbId,
connectorId: null,
deletedAt: null,
archivedAt: null,
})
record('knowledge_document', doc.id, childDocId)
kbEntry.documentIdMap[doc.id] = childDocId
}
if (inserts.length > 0) await tx.insert(document).values(inserts)
}
/**
* Plan the copy of documents referenced by the synced workflows whose parent knowledge base is
* ALREADY mapped to an existing target KB (not copied this sync) but the document itself is not
* mapped. For each, insert a placeholder document row into that existing target KB and return:
* - `documents`: content entries for the post-commit embeddings/blob fill,
* - `docIdMap`: source->child document id map (to augment the resolver so the `document-selector`
* reference remaps to the copy instead of being cleared),
* - `mappingEntries`: `knowledge_document` rows to persist (so a re-sync resolves the copy).
*
* FK-safe: the target KB is one the resolver returns (existence-checked via `validTargetIdsByKind`),
* and the placeholder insert is a bounded in-tx write with no object-storage I/O. Documents whose
* parent KB is being copied THIS sync are handled by {@link createForkDocumentPlaceholders} under
* that copied KB and are excluded here via `alreadyCopiedSourceDocIds`. A referenced document whose
* parent KB is not mapped at all is left untouched, so its reference is cleared as before.
*/
export async function planForkMappedKbDocumentCopies(params: {
tx: DbOrTx
resolver: ForkReferenceResolver
referencedDocumentIds: string[]
alreadyCopiedSourceDocIds: Set<string>
}): Promise<{
documents: ForkContentDocumentEntry[]
docIdMap: Map<string, string>
mappingEntries: ForkMappingUpsert[]
}> {
const { tx, resolver, referencedDocumentIds, alreadyCopiedSourceDocIds } = params
const documents: ForkContentDocumentEntry[] = []
const docIdMap = new Map<string, string>()
const mappingEntries: ForkMappingUpsert[] = []
// A doc whose parent KB is being copied this sync is placed under that copied KB (skip via
// alreadyCopiedSourceDocIds); a doc that already resolves was mapped by a prior sync (skip via
// the resolver). Everything else is a candidate to copy into its (existing) mapped target KB.
const candidateIds = referencedDocumentIds.filter(
(id) => !alreadyCopiedSourceDocIds.has(id) && resolver('knowledge-document', id) == null
)
if (candidateIds.length === 0) return { documents, docIdMap, mappingEntries }
const docs = await tx
.select()
.from(document)
.where(
and(
inArray(document.id, candidateIds),
isNull(document.deletedAt),
isNull(document.archivedAt)
)
)
const inserts: (typeof document.$inferInsert)[] = []
for (const doc of docs) {
// The parent KB must already exist in the target. The resolver returns a target KB id only
// for a mapped, still-existing KB (validTargetIdsByKind), so this is FK-safe; a doc whose KB
// isn't mapped resolves null here and is left for its reference to be cleared.
const targetKbId = resolver('knowledge-base', doc.knowledgeBaseId)
if (targetKbId == null) continue
const childDocId = generateId()
inserts.push({
...doc,
id: childDocId,
knowledgeBaseId: targetKbId,
connectorId: null,
deletedAt: null,
archivedAt: null,
})
docIdMap.set(doc.id, childDocId)
mappingEntries.push({
resourceType: 'knowledge_document',
parentResourceId: doc.id,
childResourceId: childDocId,
})
documents.push({
sourceDocId: doc.id,
childDocId,
childKnowledgeBaseId: targetKbId,
storageKey: doc.storageKey,
filename: doc.filename,
mimeType: doc.mimeType,
})
}
if (inserts.length > 0) await tx.insert(document).values(inserts)
return { documents, docIdMap, mappingEntries }
}
/**
* Recursively rewrite the in-workspace resource URLs stored in a copied table row's `data` jsonb
* (column -> value), so resource-chip cells keep resolving after a cross-workspace copy. Fast-rejects
* a string without `/workspace/` before any regex, and returns the same reference when nothing
* changed (so only rows with a rewritten cell allocate a new `data` object).
*/
function remapTableRowResourceUrls(value: unknown, maps: ForkContentRefMaps): unknown {
if (typeof value === 'string') {
if (!value.includes('/workspace/')) return value
return rewriteForkResourceUrls(value, maps)
}
if (Array.isArray(value)) {
let changed = false
const next = value.map((item) => {
const remapped = remapTableRowResourceUrls(item, maps)
if (remapped !== item) changed = true
return remapped
})
return changed ? next : value
}
if (isRecord(value)) {
let changed = false
const next: Record<string, unknown> = {}
for (const [key, item] of Object.entries(value)) {
const remapped = remapTableRowResourceUrls(item, maps)
if (remapped !== item) changed = true
next[key] = remapped
}
return changed ? next : value
}
return value
}
/**
* Copy the heavy resource content described by a {@link ForkContentPlan} AFTER the
* fork transaction has committed: table rows, and KB documents + embeddings. Reads
* and writes are keyset-paginated so peak memory is bounded to one page, and each
* resource is copied in its own short statements (never one long transaction).
* Best-effort: a failure on one resource is logged and the others continue - the
* fork itself (workflows + container rows) already succeeded. Copied skill bodies are
* RE-READ (keyset-paginated) and rewritten here too (`contentRefMaps`), out of the locked
* fork tx; that rewrite is an in-content link fixup, so a failure degrades to a broken link
* and never fails a resource.
*/
export async function copyForkResourceContent(params: {
contentPlan: ForkContentPlan
/** In-content reference maps for rewriting copied skill bodies post-commit (best-effort). */
contentRefMaps?: ForkContentRefMaps
requestId?: string
}): Promise<{ copied: number; failed: number; failures: ForkFailedResource[] }> {
const { contentPlan, contentRefMaps, requestId = 'unknown' } = params
const { childWorkspaceId, userId } = contentPlan
let copiedResources = 0
let failedResources = 0
const failures: ForkFailedResource[] = []
for (const table of contentPlan.tables) {
try {
let copied = 0
let afterId: string | null = null
for (;;) {
const where: SQL<unknown> | undefined =
afterId === null
? eq(userTableRows.tableId, table.sourceId)
: and(eq(userTableRows.tableId, table.sourceId), gt(userTableRows.id, afterId))
const rows = await db
.select()
.from(userTableRows)
.where(where)
.orderBy(asc(userTableRows.id))
.limit(CONTENT_PAGE)
if (rows.length === 0) break
await db.insert(userTableRows).values(
rows.map((row) => ({
...row,
id: generateId(),
tableId: table.childId,
workspaceId: childWorkspaceId,
// Repoint resource-chip URLs in cell data at the child copies (no-op when no maps).
data: contentRefMaps ? remapTableRowResourceUrls(row.data, contentRefMaps) : row.data,
}))
)
copied += rows.length
afterId = rows[rows.length - 1].id
if (rows.length < CONTENT_PAGE) break
}
await db
.update(userTableDefinitions)
.set({ rowCount: copied })
.where(eq(userTableDefinitions.id, table.childId))
copiedResources += 1
} catch (error) {
failedResources += 1
failures.push({ kind: 'table', childId: table.childId })
logger.warn(`[${requestId}] Failed to copy table rows during fork`, {
sourceTableId: table.sourceId,
error: getErrorMessage(error),
})
}
}
for (const kb of contentPlan.knowledgeBases) {
try {
let afterDocId: string | null = null
for (;;) {
// Only copy LIVE documents - exclude soft-deleted and archived rows, matching
// how the rest of the KB system treats them as gone (chunks/tags/search filter
// both). A fork must not resurrect documents removed from the source base.
const liveDocs = and(
eq(document.knowledgeBaseId, kb.sourceId),
isNull(document.deletedAt),
isNull(document.archivedAt)
)
const where: SQL<unknown> | undefined =
afterDocId === null ? liveDocs : and(liveDocs, gt(document.id, afterDocId))
const docs = await db
.select()
.from(document)
.where(where)
.orderBy(asc(document.id))
.limit(CONTENT_PAGE)
if (docs.length === 0) break
// Copy the page's documents with bounded concurrency. The mapper never rejects
// (it captures its error), so all in-flight work settles before this resolves - no
// orphaned writes survive a failure - and a captured error is rethrown after to keep
// the KB ALL-OR-NOTHING (any failed doc fails the whole KB -> cleanup below).
const docErrors = await mapWithConcurrency(
docs,
KB_DOCUMENT_COPY_CONCURRENCY,
async (doc): Promise<unknown> => {
try {
// Referenced documents were pre-created as placeholders in the fork tx at this
// exact child id, so a remapped `document-selector` reference can't dangle; the
// rest get a fresh id here. Copy the blob to a child-scoped KB key so the copy
// never shares the source's object (best-effort - keeps the source key on failure).
const placeholderId = kb.documentIdMap[doc.id]
const childDocId = placeholderId ?? generateId()
const blob = await copyKbDocumentBlob(doc, childWorkspaceId, userId, requestId)
if (placeholderId) {
if (blob) {
await db
.update(document)
.set({ storageKey: blob.storageKey, fileUrl: blob.fileUrl })
.where(eq(document.id, childDocId))
}
} else {
await db.insert(document).values({
...doc,
id: childDocId,
knowledgeBaseId: kb.childId,
connectorId: null,
deletedAt: null,
archivedAt: null,
...(blob ? { storageKey: blob.storageKey, fileUrl: blob.fileUrl } : {}),
})
}
await copyDocumentEmbeddings(doc.id, childDocId, kb.childId)
return null
} catch (error) {
return error
}
}
)
const docError = docErrors.find((error) => error != null)
if (docError) throw docError
afterDocId = docs[docs.length - 1].id
if (docs.length < CONTENT_PAGE) break
}
copiedResources += 1
} catch (error) {
failedResources += 1
failures.push({
kind: 'knowledge-base',
childId: kb.childId,
documentChildIds: Object.values(kb.documentIdMap),
})
logger.warn(`[${requestId}] Failed to copy knowledge base content during fork`, {
sourceKnowledgeBaseId: kb.sourceId,
error: getErrorMessage(error),
})
}
}
// Fill the documents copied into an already-existing target KB (sync-only U-docs path). The
// placeholder rows were inserted in the promote tx; here we re-key each blob and copy its
// embeddings into the existing KB. A per-document failure drops just that placeholder (its
// embeddings cascade) and clears its `document-selector` references - the existing KB and its
// own documents are never touched.
for (const docEntry of contentPlan.documents) {
try {
const blob = await copyKbDocumentBlob(
{
storageKey: docEntry.storageKey,
filename: docEntry.filename,
mimeType: docEntry.mimeType,
},
childWorkspaceId,
userId,
requestId
)
if (blob) {
await db
.update(document)
.set({ storageKey: blob.storageKey, fileUrl: blob.fileUrl })
.where(eq(document.id, docEntry.childDocId))
}
await copyDocumentEmbeddings(
docEntry.sourceDocId,
docEntry.childDocId,
docEntry.childKnowledgeBaseId
)
copiedResources += 1
} catch (error) {
failedResources += 1
failures.push({ kind: 'knowledge-document', childId: docEntry.childDocId })
logger.warn(`[${requestId}] Failed to copy document into mapped KB during sync`, {
sourceDocumentId: docEntry.sourceDocId,
error: getErrorMessage(error),
})
}
}
// Rewrite copied skill bodies out of the locked fork tx (the rows were inserted with the source
// body via an in-DB copy). RE-READ each child body keyset-paginated - it is never carried in the
// job payload - remap its `sim:` links + embedded file URLs to the child resources, and write it
// back. An in-content link fixup: a per-skill failure degrades to a broken link and is never
// counted as a failed resource (unmapped targets are left as graceful broken links).
if (contentRefMaps && contentPlan.skills.length > 0) {
const childSkillIds = contentPlan.skills.map((entry) => entry.childId)
let afterId: string | null = null
for (;;) {
const where: SQL<unknown> | undefined =
afterId === null
? inArray(skill.id, childSkillIds)
: and(inArray(skill.id, childSkillIds), gt(skill.id, afterId))
const rows = await db
.select({ id: skill.id, content: skill.content })
.from(skill)
.where(where)
.orderBy(asc(skill.id))
.limit(CONTENT_PAGE)
if (rows.length === 0) break
// Bounded fan-out: the mapper never rejects (it captures its own error), so mapWithConcurrency
// settles the whole page. A rewrite is a best-effort link fixup, so a per-skill failure is
// logged and the body keeps its source links rather than failing a resource.
await mapWithConcurrency(rows, SKILL_REWRITE_CONCURRENCY, async (row): Promise<void> => {
try {
const rewritten = rewriteForkContentRefs(row.content, contentRefMaps)
if (rewritten !== row.content) {
await db.update(skill).set({ content: rewritten }).where(eq(skill.id, row.id))
}
} catch (error) {
logger.warn(
`[${requestId}] Failed to rewrite copied skill content; keeping source links`,
{
childSkillId: row.id,
error: getErrorMessage(error),
}
)
}
})
afterId = rows[rows.length - 1].id
if (rows.length < CONTENT_PAGE) break
}
}
return { copied: copiedResources, failed: failedResources, failures }
}
async function copyDocumentEmbeddings(
sourceDocumentId: string,
childDocumentId: string,
childKnowledgeBaseId: string
): Promise<void> {
let afterId: string | null = null
for (;;) {
const where: SQL<unknown> | undefined =
afterId === null
? eq(embedding.documentId, sourceDocumentId)
: and(eq(embedding.documentId, sourceDocumentId), gt(embedding.id, afterId))
const rows = await db
.select()
.from(embedding)
.where(where)
.orderBy(asc(embedding.id))
.limit(CONTENT_PAGE)
if (rows.length === 0) break
await db.insert(embedding).values(
rows.map((row) => ({
...row,
id: generateId(),
documentId: childDocumentId,
knowledgeBaseId: childKnowledgeBaseId,
}))
)
afterId = rows[rows.length - 1].id
if (rows.length < CONTENT_PAGE) break
}
}
/**
* Duplicate a KB document's stored blob to a fresh child-scoped KB storage key so the copied
* document never points at the source's object. The child key is written with a `file_metadata`
* ownership binding owned by the CHILD workspace (mirroring the canonical KB upload), so
* `verifyKBFileAccess` grants a child-workspace member - without it the copied object is
* download-denied (no binding = deny). Returns the new `storageKey` + serve `fileUrl`, or null
* when there is no internal blob to copy (external/`data:` docs have a null `storageKey`) or the
* copy fails - best-effort, so a single blob failure never aborts the KB.
*/
async function copyKbDocumentBlob(
doc: { storageKey: string | null; filename: string; mimeType: string },
childWorkspaceId: string,
userId: string,
requestId: string
): Promise<{ storageKey: string; fileUrl: string } | null> {
if (!doc.storageKey) return null
try {
const buffer = await downloadFile({
key: doc.storageKey,
context: 'knowledge-base',
maxBytes: MAX_FILE_SIZE,
})
const targetKey = generateKnowledgeBaseFileKey(doc.filename)
await uploadFile({
file: buffer,
fileName: doc.filename,
contentType: doc.mimeType,
context: 'knowledge-base',
customKey: targetKey,
preserveKey: true,
metadata: {
userId,
workspaceId: childWorkspaceId,
originalName: doc.filename,
},
})
return { storageKey: targetKey, fileUrl: `/api/files/serve/${encodeURIComponent(targetKey)}` }
} catch (error) {
logger.warn(`[${requestId}] Failed to copy KB document blob during fork; keeping source key`, {
sourceStorageKey: doc.storageKey,
error: getErrorMessage(error),
})
return null
}
}