Skip to content

Commit 884d43f

Browse files
committed
fix(tables): preserve foreign key integrity
1 parent b734463 commit 884d43f

18 files changed

Lines changed: 2007 additions & 170 deletions

apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts

Lines changed: 414 additions & 0 deletions
Large diffs are not rendered by default.

apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts

Lines changed: 187 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ import {
5555
rebindKnowledgeDocumentSecretProvenance,
5656
replaceKnowledgeDocumentSecretProvenanceInTx,
5757
} from '@/lib/knowledge/secret-provenance'
58+
import { getColumnId } from '@/lib/table/column-keys'
59+
import { collectColumnReferencedTableIds } from '@/lib/table/column-types/registry.server'
5860
import { DEFAULT_TABLE_VIEW_NAME } from '@/lib/table/constants'
5961
import { nKeysBetween } from '@/lib/table/order-key'
6062
import {
@@ -90,7 +92,10 @@ import {
9092
type ForkReferenceResolver,
9193
rewriteEnvRefsInText,
9294
} from '@/ee/workspace-forking/lib/remap/remap-references'
93-
import { remapForkTableWorkflowGroups } from '@/ee/workspace-forking/lib/remap/remap-table-groups'
95+
import {
96+
remapForkTableReferences,
97+
remapForkTableWorkflowGroups,
98+
} from '@/ee/workspace-forking/lib/remap/remap-table-groups'
9499

95100
const logger = createLogger('WorkspaceForkCopyResources')
96101

@@ -99,6 +104,8 @@ const CONTENT_PAGE = 500
99104
const PROVENANCE_CONTENT_PAGE = 8
100105
const MAX_FORK_PROVENANCE_ENTRIES = 10_000
101106
const MAX_FORK_PROVENANCE_BYTES = 8 * 1024 * 1024
107+
/** Matches the fork contract's per-resource selection ceiling after dependencies are expanded. */
108+
export const MAX_FORK_TABLES_WITH_DEPENDENCIES = 2_000
102109

103110
function isForkProvenancePageWithinBudget(sidecars: readonly { entries: unknown }[]): boolean {
104111
let entries = 0
@@ -216,6 +223,11 @@ export interface CopyResourcesParams {
216223
* plan resolver); omitted by fork-create, which preserves env names verbatim (no rewrite).
217224
*/
218225
resolveEnvName?: (key: string) => string | null | undefined
226+
/**
227+
* Detect whether a referenced source table already maps to a target during promote. Row-level
228+
* mappings do not exist yet, so the copy fails instead of inventing target row identities.
229+
*/
230+
resolveMappedTableReference?: (sourceTableId: string) => string | null | undefined
219231
/**
220232
* Resolve a source block id to its target block id for copied tables' workflow-group
221233
* `outputs[].blockId`. Promote passes the SAME persisted-pair resolver its workflow writes
@@ -237,6 +249,13 @@ export interface ForkContentPlanEntry {
237249
childId: string
238250
}
239251

252+
export interface ForkContentTableEntry extends ForkContentPlanEntry {
253+
/** Copied tables this table's reference columns require to remain available. */
254+
dependsOnChildIds?: string[]
255+
/** Stable column id to copied target-table id, used to derive copied referenced-row ids. */
256+
referenceColumnTargetTableIds?: Record<string, string>
257+
}
258+
240259
/**
241260
* A KB to copy post-commit, plus the source-document -> child-document id map for the
242261
* documents that were pre-created as placeholders in the transaction (referenced by copied
@@ -288,7 +307,7 @@ export interface ForkContentPlan {
288307
childWorkspaceId: string
289308
/** Initiating user, recorded as the owner of copied KB-document blob bindings in the child. */
290309
userId: string
291-
tables: ForkContentPlanEntry[]
310+
tables: ForkContentTableEntry[]
292311
knowledgeBases: ForkContentKbEntry[]
293312
skills: ForkContentSkillEntry[]
294313
/** Documents copied into an already-existing target KB (sync-only; empty at fork create). */
@@ -359,6 +378,102 @@ function setId(idMap: Map<ForkResourceType, Map<string, string>>, type: ForkReso
359378
*/
360379
type SkillSkeletonInsert = Omit<typeof skill.$inferInsert, 'content'> & { content: SQL }
361380

381+
/** Derives the copied row identity without retaining an unbounded source-row map in memory. */
382+
function deriveCopiedTableRowId(childTableId: string, sourceRowId: string): string {
383+
return `row_${sha256Hex(`table-row:${childTableId}:${sourceRowId}`).slice(0, 32)}`
384+
}
385+
386+
/** Rewrites reference cells through the same deterministic identity used by copied target rows. */
387+
function remapCopiedReferenceCells(
388+
data: unknown,
389+
referenceColumnTargetTableIds: Readonly<Record<string, string>> | undefined
390+
): unknown {
391+
if (!referenceColumnTargetTableIds || !isRecordLike(data)) return data
392+
let remapped: Record<string, unknown> | undefined
393+
for (const [columnId, childTableId] of Object.entries(referenceColumnTargetTableIds)) {
394+
const sourceRowId = data[columnId]
395+
if (typeof sourceRowId !== 'string' || sourceRowId.length === 0) continue
396+
remapped ??= { ...data }
397+
remapped[columnId] = deriveCopiedTableRowId(childTableId, sourceRowId)
398+
}
399+
return remapped ?? data
400+
}
401+
402+
/**
403+
* Loads the selected tables plus the transitive closure of tables named by their reference
404+
* columns. Each layer is workspace-scoped and active-only; an unavailable dependency fails the
405+
* copy instead of persisting a source-workspace table id into the child schema.
406+
*/
407+
async function loadTableDefinitionsWithDependencies(
408+
tx: DbOrTx,
409+
sourceWorkspaceId: string,
410+
selectedTableIds: readonly string[],
411+
resolveMappedTableReference?: (sourceTableId: string) => string | null | undefined
412+
): Promise<Array<typeof userTableDefinitions.$inferSelect>> {
413+
const orderedIds = [...new Set(selectedTableIds)]
414+
if (orderedIds.length > MAX_FORK_TABLES_WITH_DEPENDENCIES) {
415+
throw new Error(
416+
`Cannot copy more than ${MAX_FORK_TABLES_WITH_DEPENDENCIES} tables including referenced dependencies`
417+
)
418+
}
419+
const scheduledIds = new Set(orderedIds)
420+
const dependencyIds = new Set<string>()
421+
const definitionsById = new Map<string, typeof userTableDefinitions.$inferSelect>()
422+
let pendingIds = [...orderedIds]
423+
424+
while (pendingIds.length > 0) {
425+
const batchIds = pendingIds
426+
const batchIdSet = new Set(batchIds)
427+
pendingIds = []
428+
const rows = await tx
429+
.select()
430+
.from(userTableDefinitions)
431+
.where(
432+
and(
433+
inArray(userTableDefinitions.id, batchIds),
434+
eq(userTableDefinitions.workspaceId, sourceWorkspaceId),
435+
isNull(userTableDefinitions.archivedAt)
436+
)
437+
)
438+
const batchRows = rows.filter((row) => batchIdSet.has(row.id))
439+
440+
for (const row of batchRows) {
441+
definitionsById.set(row.id, row)
442+
const referencedIds = collectColumnReferencedTableIds((row.schema as TableSchema).columns)
443+
for (const referencedId of referencedIds) {
444+
dependencyIds.add(referencedId)
445+
if (scheduledIds.has(referencedId)) continue
446+
const mappedTableId = resolveMappedTableReference?.(referencedId)
447+
if (mappedTableId) {
448+
throw new Error(
449+
`Referenced table ${referencedId} is mapped to ${mappedTableId}, but referenced row mappings are unavailable`
450+
)
451+
}
452+
if (scheduledIds.size >= MAX_FORK_TABLES_WITH_DEPENDENCIES) {
453+
throw new Error(
454+
`Cannot copy more than ${MAX_FORK_TABLES_WITH_DEPENDENCIES} tables including referenced dependencies`
455+
)
456+
}
457+
scheduledIds.add(referencedId)
458+
orderedIds.push(referencedId)
459+
pendingIds.push(referencedId)
460+
}
461+
}
462+
463+
const missingDependencyId = batchIds.find(
464+
(id) => dependencyIds.has(id) && !definitionsById.has(id)
465+
)
466+
if (missingDependencyId) {
467+
throw new Error(`Referenced table ${missingDependencyId} is unavailable for copy`)
468+
}
469+
}
470+
471+
return orderedIds.flatMap((id) => {
472+
const definition = definitionsById.get(id)
473+
return definition ? [definition] : []
474+
})
475+
}
476+
362477
/**
363478
* Copy the selected resources' **container rows** into the child workspace inside
364479
* the fork transaction: custom tools, skills, and MCP server configs (each a
@@ -627,16 +742,12 @@ export async function copyForkResourceContainers(
627742
}
628743

629744
if (selection.tables.length > 0) {
630-
const definitions = await tx
631-
.select()
632-
.from(userTableDefinitions)
633-
.where(
634-
and(
635-
inArray(userTableDefinitions.id, selection.tables),
636-
eq(userTableDefinitions.workspaceId, sourceWorkspaceId),
637-
isNull(userTableDefinitions.archivedAt)
638-
)
639-
)
745+
const definitions = await loadTableDefinitionsWithDependencies(
746+
tx,
747+
sourceWorkspaceId,
748+
selection.tables,
749+
params.resolveMappedTableReference
750+
)
640751
const sourceViews =
641752
definitions.length > 0
642753
? await tx
@@ -671,12 +782,22 @@ export async function copyForkResourceContainers(
671782

672783
const inserts: (typeof userTableDefinitions.$inferInsert)[] = []
673784
const viewInserts: (typeof tableViews.$inferInsert)[] = []
785+
const tableIdMap = new Map(
786+
definitions.map((definition) => [definition.id, generateId()] as const)
787+
)
788+
for (const [sourceTableId, childTableId] of tableIdMap) {
789+
record('table', sourceTableId, childTableId)
790+
}
674791
for (const definition of definitions) {
675-
const childTableId = generateId()
676-
const remappedSchema = remapForkTableWorkflowGroups(
677-
definition.schema as TableSchema,
678-
workflowIdMap,
679-
params.resolveBlockId
792+
const childTableId = tableIdMap.get(definition.id)
793+
if (!childTableId) throw new Error(`Missing copied table identity for ${definition.id}`)
794+
const remappedSchema = remapForkTableReferences(
795+
remapForkTableWorkflowGroups(
796+
definition.schema as TableSchema,
797+
workflowIdMap,
798+
params.resolveBlockId
799+
),
800+
tableIdMap
680801
)
681802
inserts.push({
682803
...definition,
@@ -733,8 +854,27 @@ export async function copyForkResourceContainers(
733854
updatedAt: now,
734855
})
735856
}
736-
record('table', definition.id, childTableId)
737-
contentPlan.tables.push({ sourceId: definition.id, childId: childTableId })
857+
const dependsOnChildIds = collectColumnReferencedTableIds(
858+
(definition.schema as TableSchema).columns
859+
).flatMap((sourceId) => {
860+
const dependencyId = tableIdMap.get(sourceId)
861+
return dependencyId && dependencyId !== childTableId ? [dependencyId] : []
862+
})
863+
const referenceColumnTargetTableIds = Object.fromEntries(
864+
(definition.schema as TableSchema).columns.flatMap((column) => {
865+
const [sourceTargetId] = collectColumnReferencedTableIds([column])
866+
const childTargetId = sourceTargetId ? tableIdMap.get(sourceTargetId) : undefined
867+
return childTargetId ? [[getColumnId(column), childTargetId]] : []
868+
})
869+
)
870+
contentPlan.tables.push({
871+
sourceId: definition.id,
872+
childId: childTableId,
873+
...(dependsOnChildIds.length > 0 ? { dependsOnChildIds } : {}),
874+
...(Object.keys(referenceColumnTargetTableIds).length > 0
875+
? { referenceColumnTargetTableIds }
876+
: {}),
877+
})
738878
names.tables.push(definition.name)
739879
}
740880
if (inserts.length > 0) await tx.insert(userTableDefinitions).values(inserts)
@@ -1245,14 +1385,17 @@ export async function copyForkResourceContent(params: {
12451385
return {
12461386
row: {
12471387
...row,
1248-
id: generateId(),
1388+
id: deriveCopiedTableRowId(table.childId, row.id),
12491389
tableId: table.childId,
12501390
workspaceId: childWorkspaceId,
12511391
orderKey: row.orderKey ?? mintedKeys[mintedIdx++] ?? null,
12521392
secretProvenanceVersion:
12531393
classification.mode === 'legacy' ? null : TABLE_ROW_SECRET_PROVENANCE_VERSION,
12541394
// Repoint resource-chip URLs in cell data at the child copies (no-op when no maps).
1255-
data: contentRefMaps ? remapTableRowResourceUrls(row.data, contentRefMaps) : row.data,
1395+
data: remapCopiedReferenceCells(
1396+
contentRefMaps ? remapTableRowResourceUrls(row.data, contentRefMaps) : row.data,
1397+
table.referenceColumnTargetTableIds
1398+
),
12561399
},
12571400
provenance: classification.mode === 'tracked' ? classification : undefined,
12581401
}
@@ -1295,6 +1438,29 @@ export async function copyForkResourceContent(params: {
12951438
}
12961439
}
12971440

1441+
const failedTableIds = new Set(
1442+
failures.flatMap((failure) => (failure.kind === 'table' ? [failure.childId] : []))
1443+
)
1444+
let foundFailedDependent = true
1445+
while (foundFailedDependent) {
1446+
foundFailedDependent = false
1447+
for (const table of contentPlan.tables) {
1448+
if (failedTableIds.has(table.childId)) continue
1449+
if (!table.dependsOnChildIds?.some((dependencyId) => failedTableIds.has(dependencyId))) {
1450+
continue
1451+
}
1452+
failedTableIds.add(table.childId)
1453+
failures.push({ kind: 'table', childId: table.childId })
1454+
copiedResources -= 1
1455+
failedResources += 1
1456+
foundFailedDependent = true
1457+
logger.warn(`[${requestId}] Failed copied table because a referenced table copy failed`, {
1458+
sourceTableId: table.sourceId,
1459+
childTableId: table.childId,
1460+
})
1461+
}
1462+
}
1463+
12981464
for (const kb of contentPlan.knowledgeBases) {
12991465
try {
13001466
await logSkippedConnectorDocuments(kb)

apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,9 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => {
272272
})
273273

274274
it('threads push orientation through the shared container and mapping boundaries', async () => {
275+
const resolver = vi.fn((kind: ForkRemapKind, sourceId: string) =>
276+
kind === 'table' && sourceId === 'mapped-table' ? 'target-table' : null
277+
)
275278
await copyPromoteUnmappedResources({
276279
tx,
277280
edge,
@@ -290,7 +293,7 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => {
290293
},
291294
workflowIdMap: new Map(),
292295
folderIdMap: new Map(),
293-
resolver: () => null,
296+
resolver,
294297
resolveBlockId,
295298
referencedDocumentIds: [],
296299
})
@@ -303,6 +306,9 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => {
303306
},
304307
})
305308
)
309+
const containerParams = mockCopyForkResourceContainers.mock.calls.at(-1)?.[0]
310+
expect(containerParams?.resolveMappedTableReference('mapped-table')).toBe('target-table')
311+
expect(resolver).toHaveBeenCalledWith('table', 'mapped-table')
306312
expect(mockPersistCopiedResourceMappings).toHaveBeenCalledWith(
307313
expect.objectContaining({
308314
edgeChildWorkspaceId: 'edge-child',

apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ export async function copyPromoteUnmappedResources(params: {
232232
// A sync can rename env vars, so a copied custom tool's `code` must have its `{{ENV}}` refs
233233
// rewritten through the same plan resolver that remaps subblock-value env refs.
234234
resolveEnvName: (key) => resolver('env-var', key),
235+
resolveMappedTableReference: (sourceTableId) => resolver('table', sourceTableId),
235236
resolveBlockId,
236237
documentMappingContext: {
237238
edgeChildWorkspaceId: edge.childWorkspaceId,

apps/sim/ee/workspace-forking/lib/remap/remap-table-groups.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { remapColumnReferencedTableIds } from '@/lib/table/column-types/registry.server'
12
import type { TableSchema } from '@/lib/table/types'
23
import {
34
deriveForkBlockId,
@@ -60,3 +61,14 @@ export function remapForkTableWorkflowGroups(
6061

6162
return { ...schema, columns, workflowGroups: remappedGroups }
6263
}
64+
65+
/** Rewrites copied reference columns to the copied target table identities. */
66+
export function remapForkTableReferences(
67+
schema: TableSchema,
68+
tableIdMap: ReadonlyMap<string, string>
69+
): TableSchema {
70+
const columns = remapColumnReferencedTableIds(schema.columns, tableIdMap)
71+
return columns.some((column, index) => column !== schema.columns[index])
72+
? { ...schema, columns }
73+
: schema
74+
}

0 commit comments

Comments
 (0)