@@ -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'
5860import { DEFAULT_TABLE_VIEW_NAME } from '@/lib/table/constants'
5961import { nKeysBetween } from '@/lib/table/order-key'
6062import {
@@ -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
95100const logger = createLogger ( 'WorkspaceForkCopyResources' )
96101
@@ -99,6 +104,8 @@ const CONTENT_PAGE = 500
99104const PROVENANCE_CONTENT_PAGE = 8
100105const MAX_FORK_PROVENANCE_ENTRIES = 10_000
101106const 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
103110function 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 */
360379type 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 )
0 commit comments