Files
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

506 lines
18 KiB
TypeScript

/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
type ForkCopyableUnmapped,
forkCopyableKindSchema,
} from '@/lib/api/contracts/workspace-fork'
import type { DbOrTx } from '@/lib/db/types'
const {
mockUpsertEdgeMappings,
mockDeleteEdgeMappingsByChildResources,
mockCopyForkResourceContainers,
mockPlanForkMappedKbDocumentCopies,
mockPlanForkFileCopies,
} = vi.hoisted(() => ({
mockUpsertEdgeMappings: vi.fn(),
mockDeleteEdgeMappingsByChildResources: vi.fn(),
mockCopyForkResourceContainers: vi.fn(),
mockPlanForkMappedKbDocumentCopies: vi.fn(),
mockPlanForkFileCopies: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({
upsertEdgeMappings: mockUpsertEdgeMappings,
deleteEdgeMappingsByChildResources: mockDeleteEdgeMappingsByChildResources,
resourceTypeToForkKind: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/copy/copy-resources', () => ({
copyForkResourceContainers: mockCopyForkResourceContainers,
planForkMappedKbDocumentCopies: mockPlanForkMappedKbDocumentCopies,
copyForkResourceContent: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/copy/copy-files', () => ({
planForkFileCopies: mockPlanForkFileCopies,
executeForkFileBlobCopies: vi.fn(),
}))
import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage'
import type { ForkMappingUpsert } from '@/ee/workspace-forking/lib/mapping/mapping-store'
import {
augmentForkResolver,
buildPromoteCopySelection,
copyPromoteUnmappedResources,
FORK_COPYABLE_KIND_TO_SELECTION_KEY,
hasPromoteCopySelection,
persistPromoteCopiedMappings,
} from '@/ee/workspace-forking/lib/promote/copy-unmapped'
import { isForkCopyableKind } from '@/ee/workspace-forking/lib/promote/promote-plan'
import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references'
const candidates: ForkCopyableUnmapped[] = [
{
kind: 'knowledge-base',
sourceId: 'kb-1',
label: 'KB One',
parentId: null,
parentLabel: null,
referenced: true,
},
{
kind: 'table',
sourceId: 'tbl-1',
label: 'Table One',
parentId: null,
parentLabel: null,
referenced: true,
},
{
kind: 'custom-tool',
sourceId: 'ct-1',
label: 'Tool One',
parentId: null,
parentLabel: null,
referenced: true,
},
{
kind: 'skill',
sourceId: 'sk-1',
label: 'Skill One',
parentId: null,
parentLabel: null,
referenced: true,
},
{
kind: 'file',
sourceId: 'workspace/SRC/a.png',
label: 'a.png',
parentId: 'fld-1',
parentLabel: 'Images',
referenced: true,
},
// An UNREFERENCED candidate (new in the source, used by no synced workflow): selectable for
// copy exactly like a referenced one - the server treats the two identically.
{
kind: 'table',
sourceId: 'tbl-unref',
label: 'Scratch table',
parentId: null,
parentLabel: null,
referenced: false,
},
]
describe('buildPromoteCopySelection', () => {
it('groups requested ids into the selection by kind and records willResolve keys', () => {
const { selection, willResolve } = buildPromoteCopySelection(
{ knowledgeBases: ['kb-1'], tables: ['tbl-1'], customTools: ['ct-1'], skills: ['sk-1'] },
candidates
)
expect(selection.knowledgeBases).toEqual(['kb-1'])
expect(selection.tables).toEqual(['tbl-1'])
expect(selection.customTools).toEqual(['ct-1'])
expect(selection.skills).toEqual(['sk-1'])
expect(willResolve.has('knowledge-base:kb-1')).toBe(true)
expect(willResolve.has('skill:sk-1')).toBe(true)
})
it('ignores a requested id that is not an actual copy candidate (security)', () => {
const { selection, willResolve } = buildPromoteCopySelection(
{ knowledgeBases: ['kb-1', 'kb-not-a-candidate'] },
candidates
)
expect(selection.knowledgeBases).toEqual(['kb-1'])
expect(willResolve.has('knowledge-base:kb-not-a-candidate')).toBe(false)
})
it('groups requested file storage keys (security: only actual candidates)', () => {
const { selection, willResolve } = buildPromoteCopySelection(
{ files: ['workspace/SRC/a.png', 'workspace/SRC/not-referenced.png'] },
candidates
)
expect(selection.files).toEqual(['workspace/SRC/a.png'])
expect(willResolve.has('file:workspace/SRC/a.png')).toBe(true)
expect(willResolve.has('file:workspace/SRC/not-referenced.png')).toBe(false)
})
it('returns an empty selection when nothing is requested', () => {
const { selection, willResolve } = buildPromoteCopySelection(undefined, candidates)
expect(hasPromoteCopySelection(selection)).toBe(false)
expect(willResolve.size).toBe(0)
})
it('accepts an UNREFERENCED candidate exactly like a referenced one', () => {
// The client keeps unreferenced candidates default-unselected, but once the user opts in the
// server validates + copies them through the same path. Its willResolve key matches no
// unmapped reference (nothing references it), so the pre-copy gate is unaffected.
const { selection, willResolve } = buildPromoteCopySelection(
{ tables: ['tbl-unref'] },
candidates
)
expect(selection.tables).toEqual(['tbl-unref'])
expect(willResolve.has('table:tbl-unref')).toBe(true)
})
it('copy-vs-map: maps win - a mapped resource is absent from the candidates, so a copy request for it is dropped', () => {
// Reconciliation precedence at the server boundary: a resource the user mapped resolves to a
// target, so the plan never lists it in `copyableUnmapped`. Even if a (stale) client still
// requests it for copy, only the genuinely-unmapped candidates survive - the map wins.
const onlyTableUnmapped: ForkCopyableUnmapped[] = [
{
kind: 'table',
sourceId: 'tbl-1',
label: 'Table One',
parentId: null,
parentLabel: null,
referenced: true,
},
]
const { selection, willResolve } = buildPromoteCopySelection(
// kb-1 + the file were mapped (so absent from candidates); only the table remains copyable.
{
knowledgeBases: ['kb-1'],
tables: ['tbl-1'],
files: ['workspace/SRC/a.png'],
},
onlyTableUnmapped
)
expect(selection.knowledgeBases).toEqual([])
expect(selection.files).toEqual([])
expect(selection.tables).toEqual(['tbl-1'])
expect(willResolve.has('knowledge-base:kb-1')).toBe(false)
expect(willResolve.has('file:workspace/SRC/a.png')).toBe(false)
expect(willResolve.has('table:tbl-1')).toBe(true)
})
})
describe('hasPromoteCopySelection', () => {
it('is true only when at least one copyable kind has ids', () => {
expect(
hasPromoteCopySelection({
customTools: [],
skills: [],
tables: [],
knowledgeBases: ['kb-1'],
files: [],
mcpServers: [],
})
).toBe(true)
expect(
hasPromoteCopySelection({
customTools: [],
skills: [],
tables: [],
knowledgeBases: [],
files: [],
mcpServers: [],
})
).toBe(false)
expect(
hasPromoteCopySelection({
customTools: [],
skills: [],
tables: [],
knowledgeBases: [],
files: ['workspace/SRC/file.png'],
mcpServers: [],
})
).toBe(true)
})
})
describe('augmentForkResolver', () => {
it('resolves a just-copied resource via the extra map, else falls through to the base', () => {
const base = (kind: ForkRemapKind, id: string) =>
kind === 'credential' && id === 'cred-src' ? 'cred-dst' : null
const extra = new Map<ForkRemapKind, Map<string, string>>([
['knowledge-base', new Map([['kb-src', 'kb-dst']])],
])
const resolver = augmentForkResolver(base, extra)
expect(resolver('knowledge-base', 'kb-src')).toBe('kb-dst')
expect(resolver('credential', 'cred-src')).toBe('cred-dst')
expect(resolver('table', 'tbl-x')).toBeNull()
})
})
describe('persistPromoteCopiedMappings', () => {
const tx = {} as DbOrTx
const entry: ForkMappingUpsert = {
resourceType: 'knowledge_base',
parentResourceId: 'src-kb',
childResourceId: 'dst-kb',
}
beforeEach(() => {
vi.clearAllMocks()
})
it('pull keeps the source(parent)->target(child) orientation as-is', async () => {
await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'pull', [entry])
expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [entry])
expect(mockDeleteEdgeMappingsByChildResources).not.toHaveBeenCalled()
})
it('push swaps to target(parent)->source(child) and deletes the prior row keyed on the source child', async () => {
await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'push', [entry])
// Delete keys on the source child resource (the swapped child id = the original parent id).
expect(mockDeleteEdgeMappingsByChildResources).toHaveBeenCalledWith(tx, 'edge-child', [
{ resourceType: 'knowledge_base', childResourceId: 'src-kb' },
])
// The swap flips parent/child: the new copy (dst) becomes the parent side on push.
expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [
{ resourceType: 'knowledge_base', parentResourceId: 'dst-kb', childResourceId: 'src-kb' },
])
})
it('push skips an entry with a null child id (the narrowing guard, no bogus mapping)', async () => {
await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'push', [
{ resourceType: 'knowledge_base', parentResourceId: 'src-kb', childResourceId: null },
])
expect(mockDeleteEdgeMappingsByChildResources).not.toHaveBeenCalled()
expect(mockUpsertEdgeMappings).not.toHaveBeenCalled()
})
it('returns without writing when there are no entries', async () => {
await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'push', [])
expect(mockDeleteEdgeMappingsByChildResources).not.toHaveBeenCalled()
expect(mockUpsertEdgeMappings).not.toHaveBeenCalled()
})
})
describe('copyPromoteUnmappedResources - files + folder content-refs', () => {
const tx = {} as DbOrTx
// Only edge.childWorkspaceId is read by the copy path.
const edge = { childWorkspaceId: 'edge-child' } as unknown as ForkEdge
// The promote-built persisted-pair resolver; the copy must forward it verbatim so copied
// tables' workflow-group outputs land on the same block ids the workflow writes assign.
const resolveBlockId = (workflowId: string, blockId: string) => `${workflowId}:${blockId}`
beforeEach(() => {
vi.clearAllMocks()
mockCopyForkResourceContainers.mockResolvedValue({
idMap: new Map(),
mappingEntries: [],
contentPlan: {
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'target-ws',
userId: 'user-1',
tables: [],
knowledgeBases: [],
skills: [],
documents: [],
},
names: {
tables: [],
knowledgeBases: [],
customTools: [],
skills: [],
mcpServers: [],
workflowMcpServers: [],
},
})
mockPlanForkMappedKbDocumentCopies.mockResolvedValue({
documents: [],
docIdMap: new Map(),
mappingEntries: [],
})
})
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']]),
idMap: new Map([['file-src', 'file-dst']]),
blobTasks: [
{
sourceKey: 'workspace/SRC/a.png',
targetKey: 'workspace/DST/a.png',
context: 'workspace',
fileName: 'a.png',
contentType: 'image/png',
userId: 'user-1',
workspaceId: 'target-ws',
},
],
})
const result = await copyPromoteUnmappedResources({
tx,
edge,
sourceWorkspaceId: 'src-ws',
targetWorkspaceId: 'target-ws',
direction: 'pull',
userId: 'user-1',
now: new Date(),
selection: {
customTools: [],
skills: [],
tables: [],
knowledgeBases: [],
files: ['workspace/SRC/a.png'],
},
workflowIdMap: new Map(),
folderIdMap: new Map([['fld-src', 'fld-dst']]),
resolver: () => null,
resolveBlockId,
referencedDocumentIds: [],
})
// planForkFileCopies is invoked by storage key (the sync references key files by key).
expect(mockPlanForkFileCopies).toHaveBeenCalledWith(
expect.objectContaining({ fileKeys: ['workspace/SRC/a.png'] })
)
// blobTasks bubble up for the post-commit blob duplication.
expect(result.blobTasks).toHaveLength(1)
// The copied file resolves by storage key for the subblock remap.
expect(result.copyIdMapByKind.get('file')).toEqual(
new Map([['workspace/SRC/a.png', 'workspace/DST/a.png']])
)
// The file mapping is persisted (pull keeps source(parent)->target(child) orientation) so a
// re-sync resolves the copy instead of re-copying it.
expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [
{
resourceType: 'file',
parentResourceId: 'workspace/SRC/a.png',
childResourceId: 'workspace/DST/a.png',
},
])
// The folder map AND the file key/id maps reach the in-content rewriter.
expect(result.contentRefMaps.folders).toEqual({ 'fld-src': 'fld-dst' })
expect(result.contentRefMaps.fileKeys).toEqual({ 'workspace/SRC/a.png': 'workspace/DST/a.png' })
expect(result.contentRefMaps.fileIds).toEqual({ 'file-src': 'file-dst' })
})
it('persists container mapping entries for copied resources (idempotency for unreferenced copies)', async () => {
// An UNREFERENCED table selected for copy flows through the same container pipeline; its
// 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']])]]),
mappingEntries: [
{ resourceType: 'table', parentResourceId: 'tbl-unref', childResourceId: 'tbl-copy' },
],
contentPlan: {
sourceWorkspaceId: 'src-ws',
childWorkspaceId: 'target-ws',
userId: 'user-1',
tables: [{ sourceId: 'tbl-unref', childId: 'tbl-copy' }],
knowledgeBases: [],
skills: [],
documents: [],
},
names: {
tables: ['Scratch table'],
knowledgeBases: [],
customTools: [],
skills: [],
mcpServers: [],
workflowMcpServers: [],
},
})
mockPlanForkFileCopies.mockResolvedValue({
keyMap: new Map<string, string>(),
idMap: new Map<string, string>(),
blobTasks: [],
})
await copyPromoteUnmappedResources({
tx,
edge,
sourceWorkspaceId: 'src-ws',
targetWorkspaceId: 'target-ws',
direction: 'pull',
userId: 'user-1',
now: new Date(),
selection: {
customTools: [],
skills: [],
tables: ['tbl-unref'],
knowledgeBases: [],
files: [],
},
workflowIdMap: new Map(),
folderIdMap: new Map(),
resolver: () => null,
resolveBlockId,
referencedDocumentIds: [],
})
expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [
{ resourceType: 'table', parentResourceId: 'tbl-unref', childResourceId: 'tbl-copy' },
])
})
it('threads the plan-provided referencedDocumentIds into both doc-copy paths (no in-tx re-scan)', async () => {
await copyPromoteUnmappedResources({
tx,
edge,
sourceWorkspaceId: 'src-ws',
targetWorkspaceId: 'target-ws',
direction: 'pull',
userId: 'user-1',
now: new Date(),
selection: {
customTools: [],
skills: [],
tables: [],
knowledgeBases: ['kb-1'],
files: [],
mcpServers: [],
},
workflowIdMap: new Map(),
folderIdMap: new Map(),
resolver: () => null,
resolveBlockId,
// The doc ids come straight from the promote plan's references; the copy must forward them,
// not re-scan every source workflow state inside the locked tx.
referencedDocumentIds: ['doc-1', 'doc-2'],
})
expect(mockCopyForkResourceContainers).toHaveBeenCalledWith(
expect.objectContaining({
referencedDocumentIds: ['doc-1', 'doc-2'],
// Workflow-publishing MCP servers are fork-create-only; a sync always passes the
// shared pipeline's slot empty. External MCP servers flow through the selection.
selection: expect.objectContaining({ mcpServers: [], workflowMcpServers: [] }),
// The promote-built block-id resolver reaches the table remap unchanged, so copied
// tables' workflow-group outputs use the persisted-pair ids, not the derive.
resolveBlockId,
})
)
expect(mockPlanForkMappedKbDocumentCopies).toHaveBeenCalledWith(
expect.objectContaining({ referencedDocumentIds: ['doc-1', 'doc-2'] })
)
})
})
describe('fork copyable kind drift', () => {
it('FORK_COPYABLE_KIND_TO_SELECTION_KEY covers exactly the contract copyable kinds', () => {
expect(Object.keys(FORK_COPYABLE_KIND_TO_SELECTION_KEY).sort()).toEqual(
[...forkCopyableKindSchema.options].sort()
)
})
it('isForkCopyableKind matches the contract copyable kinds and excludes the rest', () => {
for (const kind of forkCopyableKindSchema.options) {
expect(isForkCopyableKind(kind)).toBe(true)
}
const nonCopyable: ForkRemapKind[] = ['credential', 'env-var', 'knowledge-document']
for (const kind of nonCopyable) {
expect(isForkCopyableKind(kind)).toBe(false)
}
})
})