Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions apps/sim/ee/workspace-forking/lib/copy/copy-files.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* @vitest-environment node
*/
import { folder as folderTable } from '@sim/db/schema'
import {
dbChainMockFns,
resetDbChainMock,
Expand Down Expand Up @@ -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)
})
})
39 changes: 35 additions & 4 deletions apps/sim/ee/workspace-forking/lib/copy/copy-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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 }
}

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
63 changes: 61 additions & 2 deletions apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* @vitest-environment node
*/

import { folder as folderTable } from '@sim/db/schema'
import { sha256Hex } from '@sim/security/hash'
import {
dbChainMockFns,
Expand Down Expand Up @@ -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>>) => {
Expand Down Expand Up @@ -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', () => {
Expand Down
52 changes: 43 additions & 9 deletions apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -741,7 +775,7 @@ export async function copyForkResourceContainers(
})
}

return { idMap, mappingEntries, contentPlan, names }
return { idMap, mappingEntries, contentPlan, names, folderIdMap }
}

/**
Expand Down
Loading
Loading