mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(fork): preserve folder structure across a fork edge for files, tables, and knowledge bases (#6752)
* fix(fork): carry folder structure across a fork edge for files, tables, and KBs Only workflow folders were mirrored into the target workspace on fork create and on sync. Copied files, tables, and knowledge bases were written with a hardcoded `folderId: null`, so a push or pull flattened them all into the target root and lost the source's grouping — visible as a fork sync that drops folder structure when copying files to the parent. `resolveForkFolderMapping` already did the real work (prune to folders holding copied content plus ancestors, reuse same-named target folders, remap parentId), but was pinned to `resourceType: 'workflow'` on both reads and on the folder-ceiling check. Parameterize it by resource type and run it per family, threading the resulting map into each copy instead of nulling. The four folder-bearing families own disjoint trees and folder ids are globally unique, so the per-family maps merge cleanly for the `sim:folder/<id>` content rewrite, which previously resolved only for workflow folders. Existing forks are healed on their next sync rather than by a migration: `rehomeFlattenedForkResources` re-homes mapped files/tables/KBs whose target `folder_id` is still NULL — the exact signature of the old flattening — so a placement chosen in the target is never overwritten and the pass converges to a no-op. `BlobCopyTask.targetFolderId` is optional so tasks queued by an earlier deploy replay at the root exactly as before. * refactor(fork): page the re-home lookups and reuse the plan's identity rows Self-review of the folder-transit change surfaced two scaling problems in the re-home pass, both of which grow with the size of the fork edge rather than the size of the sync: - The resource lookups built `IN (...)` lists straight from the edge's mapping rows, so a large fork could hand Postgres a list approaching the bind-parameter ceiling and a pathological query plan. Page them at 500, matching the paging the rest of the fork copy already uses. - The pass re-read the whole edge mapping via `getEdgeMappingRows`, which the promote plan had already loaded in the same transaction — a second full load of identical rows. Expose them on `ForkPromotePlan` and pass them in, which also drops a mock from the re-home tests. Also tally moved rows from `returning()` rather than the planned batch size, so the log line reports what the `folder_id IS NULL` guard actually wrote instead of what was attempted. * fix(fork): drop the sync-time re-home pass, keep folder transit forward-only Review surfaced three findings and every one of them was in the re-home pass, none in the forward-looking fix: - It keyed mapping orientation off `direction`, but the promote route resolves the edge from whichever workspace the caller is acting in, so a caller in the PARENT pushing to its child is `direction: 'push'` with the parent as source. The plan derives this as `sourceWorkspaceId === edge.parentWorkspaceId` for exactly that reason. - Moving a file into a mirrored folder can violate `workspace_files_workspace_folder_name_active_unique`, which would abort the whole promote transaction and take the workflow sync down with it. - `folder_id IS NULL` cannot distinguish "flattened by the old copy" from "the user moved this to the root", so the pass re-applied on every sync and would fight a deliberate placement indefinitely. The first two are fixable; the third is not without a one-time marker per edge, which means a migration. A heal that re-applies forever is worse than no heal, so remove the pass entirely rather than ship it half-right. Folder structure now transits correctly from this point forward, which is the actual reported bug; healing already-flattened resources can be a separate change with a marker to make it run exactly once. Reverts the `ForkPromotePlan.mappingRows` field with it — it existed only to feed this pass.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { folder as folderTable } from '@sim/db/schema'
|
||||
import {
|
||||
dbChainMockFns,
|
||||
resetDbChainMock,
|
||||
@@ -237,4 +238,95 @@ describe('planForkFileCopies', () => {
|
||||
})
|
||||
expect(tx.insert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mirrors the source file-folder subtree and places each copy inside it', async () => {
|
||||
const sourceMeta = {
|
||||
id: 'wf_src1',
|
||||
key: 'workspace/src-ws/1-abc-a.txt',
|
||||
userId: 'uploader-1',
|
||||
workspaceId: 'src-ws',
|
||||
folderId: 'child-folder',
|
||||
context: 'workspace',
|
||||
chatId: null,
|
||||
originalName: 'a.txt',
|
||||
displayName: null,
|
||||
contentType: 'text/plain',
|
||||
size: 4321,
|
||||
deletedAt: null,
|
||||
uploadedAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
contentUpdatedAt: new Date('2026-01-01'),
|
||||
}
|
||||
// A two-level source tree; only the branch holding the copied file is mirrored.
|
||||
const sourceFolders = [
|
||||
{
|
||||
id: 'root-folder',
|
||||
name: 'Reports',
|
||||
parentId: null,
|
||||
workspaceId: 'src-ws',
|
||||
resourceType: 'file',
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
id: 'child-folder',
|
||||
name: 'Q1',
|
||||
parentId: 'root-folder',
|
||||
workspaceId: 'src-ws',
|
||||
resourceType: 'file',
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
id: 'unrelated',
|
||||
name: 'Archive',
|
||||
parentId: null,
|
||||
workspaceId: 'src-ws',
|
||||
resourceType: 'file',
|
||||
deletedAt: null,
|
||||
},
|
||||
]
|
||||
const insertedFolders: Array<Record<string, unknown>> = []
|
||||
let folderSelectCall = 0
|
||||
const tx = {
|
||||
select: vi.fn(() => ({
|
||||
from: (table: unknown) => ({
|
||||
where: () => {
|
||||
if (table !== folderTable) return Promise.resolve([sourceMeta])
|
||||
// First folder read is the source tree; the second is the (empty) target tree.
|
||||
return Promise.resolve(folderSelectCall++ === 0 ? sourceFolders : [])
|
||||
},
|
||||
}),
|
||||
})),
|
||||
insert: vi.fn(() => ({
|
||||
values: (rows: Array<Record<string, unknown>>) => {
|
||||
insertedFolders.push(...rows)
|
||||
return Promise.resolve()
|
||||
},
|
||||
})),
|
||||
} as unknown as DbOrTx
|
||||
|
||||
const result = await planForkFileCopies({
|
||||
tx,
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
childWorkspaceId: 'child-ws',
|
||||
userId: 'user-1',
|
||||
fileIds: ['wf_src1'],
|
||||
now: new Date('2026-02-01'),
|
||||
})
|
||||
|
||||
// The file's folder and its ancestor are recreated; the unrelated branch is pruned.
|
||||
expect(insertedFolders).toHaveLength(2)
|
||||
const byName = new Map(insertedFolders.map((row) => [row.name, row]))
|
||||
expect(byName.has('Archive')).toBe(false)
|
||||
const newRoot = byName.get('Reports')!
|
||||
const newChild = byName.get('Q1')!
|
||||
expect(newRoot).toMatchObject({ parentId: null, workspaceId: 'child-ws' })
|
||||
// Nesting survives: the copied child points at the copied parent, not the source's.
|
||||
expect(newChild.parentId).toBe(newRoot.id)
|
||||
expect(newChild.id).not.toBe('child-folder')
|
||||
|
||||
// The copied file lands in the mirrored folder rather than the target root.
|
||||
expect(result.blobTasks[0].targetFolderId).toBe(newChild.id)
|
||||
expect(result.folderIdMap.get('child-folder')).toBe(newChild.id)
|
||||
expect(result.folderIdMap.get('root-folder')).toBe(newRoot.id)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '@/lib/uploads/core/storage-service'
|
||||
import type { StorageContext } from '@/lib/uploads/shared/types'
|
||||
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
|
||||
import { resolveForkFolderMapping } from '@/ee/workspace-forking/lib/copy/copy-workflows'
|
||||
import {
|
||||
type ForkContentRefMaps,
|
||||
rewriteForkContentRefs,
|
||||
@@ -55,6 +56,13 @@ export interface BlobCopyTask {
|
||||
displayName: string | null
|
||||
userId: string
|
||||
workspaceId: string
|
||||
/**
|
||||
* Target file-folder id, already created inside the copy transaction by
|
||||
* {@link resolveForkFolderMapping}. Optional because tasks queued by an earlier deploy have
|
||||
* no such field: those replay as `undefined` and finalize at the target root, exactly as
|
||||
* they did before folder structure transited a fork edge.
|
||||
*/
|
||||
targetFolderId?: string | null
|
||||
}
|
||||
|
||||
export interface PlanForkFileCopiesResult {
|
||||
@@ -74,6 +82,11 @@ export interface PlanForkFileCopiesResult {
|
||||
idMap: Map<string, string>
|
||||
/** Blob duplications plus deferred metadata to finalize after the fork transaction commits. */
|
||||
blobTasks: BlobCopyTask[]
|
||||
/**
|
||||
* source file-folder id -> target file-folder id for the mirrored subtree. Merged into the
|
||||
* content-ref maps so `sim:folder/<id>` mentions inside copied bodies resolve to the copy.
|
||||
*/
|
||||
folderIdMap: Map<string, string>
|
||||
}
|
||||
|
||||
async function getFinalizedFileCopies(
|
||||
@@ -124,7 +137,9 @@ export async function planForkFileCopies(params: {
|
||||
const keyMap = new Map<string, string>()
|
||||
const idMap = new Map<string, string>()
|
||||
const blobTasks: BlobCopyTask[] = []
|
||||
if (fileIds.length === 0 && fileKeys.length === 0) return { keyMap, idMap, blobTasks }
|
||||
let folderIdMap = new Map<string, string>()
|
||||
if (fileIds.length === 0 && fileKeys.length === 0)
|
||||
return { keyMap, idMap, blobTasks, folderIdMap }
|
||||
|
||||
// Match by id and/or storage key (OR'd) so either selection shape resolves to the same
|
||||
// source rows. Batch the metadata read (one query for all selected files): non-deleted,
|
||||
@@ -148,6 +163,19 @@ export async function planForkFileCopies(params: {
|
||||
)
|
||||
)
|
||||
|
||||
// Mirror the file-folder subtree holding the selected files (plus ancestors) into the target
|
||||
// and place each copy inside it. Scoped to `resourceType: 'file'`: file folders are a tree of
|
||||
// their own, disjoint from the workflow folders the workflow copy mirrors.
|
||||
folderIdMap = await resolveForkFolderMapping({
|
||||
tx,
|
||||
sourceWorkspaceId,
|
||||
targetWorkspaceId: childWorkspaceId,
|
||||
userId,
|
||||
now: params.now,
|
||||
resourceType: 'file',
|
||||
contentFolderIds: metas.map((meta) => meta.folderId),
|
||||
})
|
||||
|
||||
for (const meta of metas) {
|
||||
const childFileId = generateId()
|
||||
// Use the canonical workspace-file key (`workspace/{id}/...`) so the file-serve
|
||||
@@ -168,10 +196,13 @@ export async function planForkFileCopies(params: {
|
||||
displayName: meta.displayName,
|
||||
userId,
|
||||
workspaceId: childWorkspaceId,
|
||||
// An unmapped folder (pruned, or archived mid-copy) re-roots the file, matching how a
|
||||
// copied workflow falls back to the target root.
|
||||
targetFolderId: meta.folderId ? (folderIdMap.get(meta.folderId) ?? null) : null,
|
||||
})
|
||||
}
|
||||
|
||||
return { keyMap, idMap, blobTasks }
|
||||
return { keyMap, idMap, blobTasks, folderIdMap }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -269,7 +300,7 @@ export async function executeForkFileBlobCopies(
|
||||
key: task.targetKey,
|
||||
userId: task.userId,
|
||||
workspaceId: task.workspaceId,
|
||||
folderId: null,
|
||||
folderId: task.targetFolderId ?? null,
|
||||
context: task.context,
|
||||
chatId: null,
|
||||
originalName: task.fileName,
|
||||
@@ -312,7 +343,7 @@ export async function executeForkFileBlobCopies(
|
||||
.update(workspaceFiles)
|
||||
.set({
|
||||
userId: task.userId,
|
||||
folderId: null,
|
||||
folderId: task.targetFolderId ?? null,
|
||||
context: task.context,
|
||||
chatId: null,
|
||||
originalName: task.fileName,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { folder as folderTable } from '@sim/db/schema'
|
||||
import { sha256Hex } from '@sim/security/hash'
|
||||
import {
|
||||
dbChainMockFns,
|
||||
@@ -1343,12 +1344,31 @@ describe('copyForkResourceContainers skill copy', () => {
|
||||
|
||||
describe('copyForkResourceContainers knowledge-base tag definitions', () => {
|
||||
/** Sequential tx mock: each select resolves the next queued row set; inserts are captured per call. */
|
||||
function makeKbTx(selects: Array<Array<Record<string, unknown>>>) {
|
||||
/**
|
||||
* Sequential tx mock over the KB-copy selects, with the folder-mirroring reads served
|
||||
* separately: the copy resolves the source KB folder subtree before inserting, and dispatching
|
||||
* on the queried table keeps the queue positional over the KB selects alone instead of
|
||||
* silently shifting whenever that mapping issues a query.
|
||||
*/
|
||||
function makeKbTx(
|
||||
selects: Array<Array<Record<string, unknown>>>,
|
||||
sourceFolders: Array<Record<string, unknown>> = []
|
||||
) {
|
||||
let call = 0
|
||||
// The mapper reads the source tree first, then the target's; serving the same rows to both
|
||||
// would make every source folder look already-present and suppress the mirroring.
|
||||
let folderCall = 0
|
||||
const inserts: Array<Array<Record<string, unknown>>> = []
|
||||
const tx = {
|
||||
select: () => ({
|
||||
from: () => ({ where: () => Promise.resolve(selects[call++] ?? []) }),
|
||||
from: (table: unknown) => ({
|
||||
where: () => {
|
||||
if (table === folderTable) {
|
||||
return Promise.resolve(folderCall++ === 0 ? sourceFolders : [])
|
||||
}
|
||||
return Promise.resolve(selects[call++] ?? [])
|
||||
},
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (rows: Array<Record<string, unknown>>) => {
|
||||
@@ -1437,6 +1457,45 @@ describe('copyForkResourceContainers knowledge-base tag definitions', () => {
|
||||
// Only the KB row itself is inserted - no empty tag-definition insert.
|
||||
expect(inserts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('mirrors the source knowledge-base folder and copies the KB into it, not the target root', async () => {
|
||||
const foldered = { ...sourceBase, folderId: 'kb-folder' }
|
||||
const { tx, inserts } = makeKbTx(
|
||||
[[foldered], []],
|
||||
[
|
||||
{
|
||||
id: 'kb-folder',
|
||||
name: 'Policies',
|
||||
parentId: null,
|
||||
workspaceId: 'src-ws',
|
||||
resourceType: 'knowledge_base',
|
||||
deletedAt: null,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
await copyForkResourceContainers({
|
||||
tx,
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
childWorkspaceId: 'child-ws',
|
||||
userId: 'user-1',
|
||||
now: new Date(),
|
||||
selection: kbSelection,
|
||||
workflowIdMap: new Map(),
|
||||
documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true },
|
||||
})
|
||||
|
||||
// insert #0 is the mirrored folder, #1 the KB row placed inside it.
|
||||
const newFolder = inserts[0][0]
|
||||
expect(newFolder).toMatchObject({
|
||||
name: 'Policies',
|
||||
workspaceId: 'child-ws',
|
||||
resourceType: 'knowledge_base',
|
||||
})
|
||||
// A fresh id: reusing the source's would point the child KB at a folder it cannot see.
|
||||
expect(newFolder.id).not.toBe('kb-folder')
|
||||
expect(inserts[1][0].folderId).toBe(newFolder.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('planForkMappedKbDocumentCopies', () => {
|
||||
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
recordKnowledgeBaseFileOwnership,
|
||||
} from '@/lib/uploads/server/metadata'
|
||||
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
|
||||
import { resolveForkFolderMapping } from '@/ee/workspace-forking/lib/copy/copy-workflows'
|
||||
import {
|
||||
deleteCopiedResourceMappingsByTargets,
|
||||
type ForkMappingUpsert,
|
||||
@@ -333,6 +334,11 @@ export interface CopyResourcesResult {
|
||||
contentPlan: ForkContentPlan
|
||||
/** Names of the copied resources, by kind, for the fork report breakdown. */
|
||||
names: ForkCopiedResourceNames
|
||||
/**
|
||||
* source folder id -> target folder id for every family mirrored here (tables, knowledge
|
||||
* bases). Merged by the caller with the workflow and file maps for content-ref rewriting.
|
||||
*/
|
||||
folderIdMap: Map<string, string>
|
||||
}
|
||||
|
||||
function setId(idMap: Map<ForkResourceType, Map<string, string>>, type: ForkResourceType) {
|
||||
@@ -371,6 +377,12 @@ export async function copyForkResourceContainers(
|
||||
const resolveEnvName = params.resolveEnvName
|
||||
const idMap = new Map<ForkResourceType, Map<string, string>>()
|
||||
const mappingEntries: ForkMappingUpsert[] = []
|
||||
/**
|
||||
* Mirrored folder ids across every family copied here. Table and knowledge-base folders live
|
||||
* in disjoint trees, and folder ids are globally unique, so merging them into one map is
|
||||
* unambiguous and lets callers rewrite `sim:folder/<id>` refs in a single pass.
|
||||
*/
|
||||
const folderIdMap = new Map<string, string>()
|
||||
const contentPlan: ForkContentPlan = {
|
||||
sourceWorkspaceId,
|
||||
childWorkspaceId,
|
||||
@@ -618,6 +630,17 @@ export async function copyForkResourceContainers(
|
||||
isNull(userTableDefinitions.archivedAt)
|
||||
)
|
||||
)
|
||||
const tableFolderIdMap = await resolveForkFolderMapping({
|
||||
tx,
|
||||
sourceWorkspaceId,
|
||||
targetWorkspaceId: childWorkspaceId,
|
||||
userId,
|
||||
now,
|
||||
resourceType: 'table',
|
||||
contentFolderIds: definitions.map((definition) => definition.folderId),
|
||||
})
|
||||
for (const [source, target] of tableFolderIdMap) folderIdMap.set(source, target)
|
||||
|
||||
const inserts: (typeof userTableDefinitions.$inferInsert)[] = []
|
||||
for (const definition of definitions) {
|
||||
const childTableId = generateId()
|
||||
@@ -631,13 +654,13 @@ export async function copyForkResourceContainers(
|
||||
id: childTableId,
|
||||
workspaceId: childWorkspaceId,
|
||||
/**
|
||||
* Folders never transit a fork edge. `folder_id` is a global id with no workspace in
|
||||
* it, so the spread above would leave the child's table pointing at a folder owned by
|
||||
* the SOURCE workspace — invisible in the fork, and mutated from under it if the
|
||||
* source later deletes that folder (`ON DELETE SET NULL`). Forked tables land at the
|
||||
* root, like forked files already do.
|
||||
* `folder_id` is a global id with no workspace in it, so the spread above would leave
|
||||
* the child's table pointing at a folder owned by the SOURCE workspace — invisible in
|
||||
* the fork, and mutated from under it if the source later deletes that folder
|
||||
* (`ON DELETE SET NULL`). Remap it onto the mirrored target subtree instead; an
|
||||
* unmapped folder re-roots the table.
|
||||
*/
|
||||
folderId: null,
|
||||
folderId: definition.folderId ? (tableFolderIdMap.get(definition.folderId) ?? null) : null,
|
||||
schema: remappedSchema,
|
||||
createdBy: userId,
|
||||
rowsVersion: 0,
|
||||
@@ -674,6 +697,17 @@ export async function copyForkResourceContainers(
|
||||
isNull(knowledgeBase.deletedAt)
|
||||
)
|
||||
)
|
||||
const kbFolderIdMap = await resolveForkFolderMapping({
|
||||
tx,
|
||||
sourceWorkspaceId,
|
||||
targetWorkspaceId: childWorkspaceId,
|
||||
userId,
|
||||
now,
|
||||
resourceType: 'knowledge_base',
|
||||
contentFolderIds: bases.map((base) => base.folderId),
|
||||
})
|
||||
for (const [source, target] of kbFolderIdMap) folderIdMap.set(source, target)
|
||||
|
||||
const inserts: (typeof knowledgeBase.$inferInsert)[] = []
|
||||
const kbEntryBySourceId = new Map<string, ForkContentKbEntry>()
|
||||
for (const base of bases) {
|
||||
@@ -682,8 +716,8 @@ export async function copyForkResourceContainers(
|
||||
...base,
|
||||
id: childKbId,
|
||||
workspaceId: childWorkspaceId,
|
||||
/** Same reasoning as the table copy above: folders do not transit a fork edge. */
|
||||
folderId: null,
|
||||
/** Same reasoning as the table copy above: remapped, never carried across verbatim. */
|
||||
folderId: base.folderId ? (kbFolderIdMap.get(base.folderId) ?? null) : null,
|
||||
userId,
|
||||
deletedAt: null,
|
||||
createdAt: now,
|
||||
@@ -741,7 +775,7 @@ export async function copyForkResourceContainers(
|
||||
})
|
||||
}
|
||||
|
||||
return { idMap, mappingEntries, contentPlan, names }
|
||||
return { idMap, mappingEntries, contentPlan, names, folderIdMap }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import type { FolderResourceType } from '@/lib/api/contracts/folders'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { assertFolderCollectionHasRoom } from '@/lib/folders/queries'
|
||||
import { remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
|
||||
@@ -44,12 +45,17 @@ interface ResolveForkFolderMappingParams {
|
||||
userId: string
|
||||
now: Date
|
||||
/**
|
||||
* Source folder ids that will directly hold copied content (workflows); null entries
|
||||
* Which folder tree to mirror. `folder` rows are one table discriminated by this column, and
|
||||
* the four folder-bearing families (`workflow`, `file`, `knowledge_base`, `table`) each own a
|
||||
* disjoint tree, so a mapping run is always scoped to exactly one of them - reading across
|
||||
* types would alias unrelated same-named folders onto each other.
|
||||
*/
|
||||
resourceType: FolderResourceType
|
||||
/**
|
||||
* Source folder ids that will directly hold copied content of `resourceType`; null entries
|
||||
* (root-placed content) are ignored. A source folder is copied into the target only when
|
||||
* its subtree contains at least one of these, so a fork/sync never creates folders that
|
||||
* would end up empty. Copied workspace FILES never influence this set: their folders are a
|
||||
* separate tree (`folder` rows with `resourceType = 'file'`, which this copy only ever reads
|
||||
* as `'workflow'`) and are flattened to root by the copy.
|
||||
* would end up empty.
|
||||
*/
|
||||
contentFolderIds: ReadonlyArray<string | null>
|
||||
}
|
||||
@@ -61,8 +67,11 @@ interface ResolveForkFolderMappingParams {
|
||||
* parent are reused instead of duplicated. Folders whose subtree holds no copied content are
|
||||
* pruned - never created - though a pruned folder still maps onto an existing target folder
|
||||
* when one matches, so previously-synced content refs keep resolving. Returns a map from
|
||||
* source folder id to target folder id; a copied workflow whose folder is absent from the
|
||||
* source folder id to target folder id; copied content whose folder is absent from the
|
||||
* map is placed at the target's root (see {@link copyWorkflowStateIntoTarget}).
|
||||
*
|
||||
* Call once per folder-bearing family being copied; the returned maps are disjoint (folder ids
|
||||
* are globally unique) and safe to merge for content-reference rewriting.
|
||||
*/
|
||||
export async function resolveForkFolderMapping({
|
||||
tx,
|
||||
@@ -70,6 +79,7 @@ export async function resolveForkFolderMapping({
|
||||
targetWorkspaceId,
|
||||
userId,
|
||||
now,
|
||||
resourceType,
|
||||
contentFolderIds,
|
||||
}: ResolveForkFolderMappingParams): Promise<Map<string, string>> {
|
||||
const map = new Map<string, string>()
|
||||
@@ -80,7 +90,7 @@ export async function resolveForkFolderMapping({
|
||||
.where(
|
||||
and(
|
||||
eq(folderTable.workspaceId, sourceWorkspaceId),
|
||||
eq(folderTable.resourceType, 'workflow'),
|
||||
eq(folderTable.resourceType, resourceType),
|
||||
isNull(folderTable.deletedAt)
|
||||
)
|
||||
)
|
||||
@@ -107,7 +117,7 @@ export async function resolveForkFolderMapping({
|
||||
.where(
|
||||
and(
|
||||
eq(folderTable.workspaceId, targetWorkspaceId),
|
||||
eq(folderTable.resourceType, 'workflow'),
|
||||
eq(folderTable.resourceType, resourceType),
|
||||
isNull(folderTable.deletedAt)
|
||||
)
|
||||
)
|
||||
@@ -172,7 +182,7 @@ export async function resolveForkFolderMapping({
|
||||
* transaction's `lock_timeout`, which the fork sets deliberately, so an ordinary
|
||||
* concurrent `createFolder` can still slip a row in between the count and the insert.
|
||||
*/
|
||||
await assertFolderCollectionHasRoom(targetWorkspaceId, 'workflow', tx, {
|
||||
await assertFolderCollectionHasRoom(targetWorkspaceId, resourceType, tx, {
|
||||
additionalRows: newFolders.length,
|
||||
})
|
||||
await tx.insert(folderTable).values(newFolders)
|
||||
|
||||
@@ -134,10 +134,12 @@ describe('createFork storage headroom gate', () => {
|
||||
keyMap: new Map(),
|
||||
idMap: new Map(),
|
||||
blobTasks: [],
|
||||
folderIdMap: new Map(),
|
||||
})
|
||||
mockCopyForkResourceContainers.mockResolvedValue({
|
||||
idMap: new Map(),
|
||||
mappingEntries: [],
|
||||
folderIdMap: new Map(),
|
||||
contentPlan: {
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
childWorkspaceId: 'child-ws',
|
||||
@@ -224,6 +226,7 @@ describe('createFork storage headroom gate', () => {
|
||||
keyMap: new Map([['workspace/src-ws/a.png', 'workspace/child/a.png']]),
|
||||
idMap: new Map([['file-1', 'file-1-copy']]),
|
||||
blobTasks: [],
|
||||
folderIdMap: new Map(),
|
||||
})
|
||||
|
||||
await createFork(forkParams({ files: ['file-1'] }))
|
||||
|
||||
@@ -228,14 +228,15 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
|
||||
// feeds the post-commit content-ref rewrite (`sim:folder/<id>` mentions in skill/file bodies).
|
||||
// Scoped to the folders that will actually receive a copied workflow (plus ancestors): a
|
||||
// fork copies only DEPLOYED workflows, so folders holding none would be created empty in
|
||||
// the child and are pruned instead. Copied files don't extend this set - they use the
|
||||
// separate workspace-file-folder entity and land at the child's root.
|
||||
const folderIdMap = await resolveForkFolderMapping({
|
||||
// the child and are pruned instead. The file/table/knowledge-base trees are mirrored
|
||||
// separately by their own copies and merged in below.
|
||||
const workflowFolderIdMap = await resolveForkFolderMapping({
|
||||
tx,
|
||||
sourceWorkspaceId: source.id,
|
||||
targetWorkspaceId: childWorkspaceId,
|
||||
userId,
|
||||
now,
|
||||
resourceType: 'workflow',
|
||||
contentFolderIds: deployedWorkflows
|
||||
.filter((wf) => workflowIdMap.has(wf.id))
|
||||
.map((wf) => wf.folderId),
|
||||
@@ -264,6 +265,17 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
|
||||
})
|
||||
forkedResourceNames = resourceResult.names
|
||||
|
||||
/**
|
||||
* Every mirrored folder tree in one map. The four families own disjoint trees and folder ids
|
||||
* are globally unique, so the union is unambiguous: a `sim:folder/<id>` ref in copied content
|
||||
* resolves regardless of which family's folder it names.
|
||||
*/
|
||||
const folderIdMap = new Map<string, string>([
|
||||
...workflowFolderIdMap,
|
||||
...fileResult.folderIdMap,
|
||||
...resourceResult.folderIdMap,
|
||||
])
|
||||
|
||||
const resolveCopied = (kind: ForkRemapKind, sourceId: string): string | null => {
|
||||
if (kind === 'file') return fileResult.keyMap.get(sourceId) ?? null
|
||||
const resourceType = FORK_KIND_TO_RESOURCE_TYPE[kind]
|
||||
|
||||
@@ -244,6 +244,7 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => {
|
||||
vi.clearAllMocks()
|
||||
mockCopyForkResourceContainers.mockResolvedValue({
|
||||
idMap: new Map(),
|
||||
folderIdMap: new Map(),
|
||||
mappingEntries: [],
|
||||
contentPlan: {
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
@@ -313,6 +314,7 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => {
|
||||
it('copies selected files (keyMap + blobTasks), persists the file mapping, and threads file + folder content-ref maps', async () => {
|
||||
mockPlanForkFileCopies.mockResolvedValue({
|
||||
keyMap: new Map([['workspace/SRC/a.png', 'workspace/DST/a.png']]),
|
||||
folderIdMap: new Map(),
|
||||
idMap: new Map([['file-src', 'file-dst']]),
|
||||
blobTasks: [
|
||||
{
|
||||
@@ -386,6 +388,7 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => {
|
||||
// mapping row is what makes the next sync resolve the copy instead of re-offering it.
|
||||
mockCopyForkResourceContainers.mockResolvedValue({
|
||||
idMap: new Map([['table', new Map([['tbl-unref', 'tbl-copy']])]]),
|
||||
folderIdMap: new Map(),
|
||||
mappingEntries: [
|
||||
{ resourceType: 'table', parentResourceId: 'tbl-unref', childResourceId: 'tbl-copy' },
|
||||
],
|
||||
@@ -409,6 +412,7 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => {
|
||||
})
|
||||
mockPlanForkFileCopies.mockResolvedValue({
|
||||
keyMap: new Map<string, string>(),
|
||||
folderIdMap: new Map(),
|
||||
idMap: new Map<string, string>(),
|
||||
blobTasks: [],
|
||||
})
|
||||
|
||||
@@ -173,7 +173,11 @@ export async function copyPromoteUnmappedResources(params: {
|
||||
now: Date
|
||||
selection: PromoteCopySelection
|
||||
workflowIdMap: Map<string, string>
|
||||
/** source folder id -> target folder id, so copied skill/markdown bodies rewrite `sim:folder/<id>`. */
|
||||
/**
|
||||
* source workflow-folder id -> target folder id, so copied skill/markdown bodies rewrite
|
||||
* `sim:folder/<id>`. The file / table / knowledge-base trees are mirrored by the copies run
|
||||
* here and unioned onto this map before the content rewrite.
|
||||
*/
|
||||
folderIdMap: Map<string, string>
|
||||
/** Base resolver (persisted mappings + env identity), used to detect already-mapped KBs (U-docs). */
|
||||
resolver: ForkReferenceResolver
|
||||
@@ -251,6 +255,7 @@ export async function copyPromoteUnmappedResources(params: {
|
||||
keyMap: new Map<string, string>(),
|
||||
idMap: new Map<string, string>(),
|
||||
blobTasks: [] as BlobCopyTask[],
|
||||
folderIdMap: new Map<string, string>(),
|
||||
}
|
||||
|
||||
// U-docs: documents referenced under an already-mapped (not copied this sync) KB. Skip any doc
|
||||
@@ -304,7 +309,9 @@ export async function copyPromoteUnmappedResources(params: {
|
||||
const contentRefMaps = serializeContentRefMaps({
|
||||
workspaceId: { from: sourceWorkspaceId, to: targetWorkspaceId },
|
||||
workflows: workflowIdMap,
|
||||
folders: folderIdMap,
|
||||
// Workflow folders (mapped by the caller) unioned with the file / table / knowledge-base
|
||||
// folders this copy mirrored, so a `sim:folder/<id>` ref resolves whichever tree it names.
|
||||
folders: new Map([...folderIdMap, ...fileResult.folderIdMap, ...result.folderIdMap]),
|
||||
fileKeys: fileResult.keyMap,
|
||||
fileIds: fileResult.idMap,
|
||||
skills: result.idMap.get('skill'),
|
||||
|
||||
@@ -557,6 +557,7 @@ export async function promoteFork(params: PromoteForkParams): Promise<PromoteFor
|
||||
targetWorkspaceId,
|
||||
userId,
|
||||
now,
|
||||
resourceType: 'workflow',
|
||||
contentFolderIds: plan.items.map((item) => item.sourceMeta.folderId),
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user