From f1e8d7147db37d35a1a6dbd14a266d56d32d6c12 Mon Sep 17 00:00:00 2001 From: Frank Midigo Date: Fri, 30 Oct 2026 11:05:57 +0300 Subject: [PATCH 1/4] Keep target collection ids when deploying to a different project Sandbox merges took the local collections as-is, so every cross-project deploy sent freshly minted ids for collections the target already had, and Lightning rejected the update with a 500. Match them to the target's collections by name, as same-project deploys already do. Fixes #1606 --- .changeset/keep-collection-ids.md | 6 ++++++ packages/project/src/merge/merge-project.ts | 2 +- .../project/test/merge/merge-project.test.ts | 18 +++++++++++++----- 3 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 .changeset/keep-collection-ids.md diff --git a/.changeset/keep-collection-ids.md b/.changeset/keep-collection-ids.md new file mode 100644 index 000000000..4990cbe62 --- /dev/null +++ b/.changeset/keep-collection-ids.md @@ -0,0 +1,6 @@ +--- +'@openfn/project': patch +'@openfn/cli': patch +--- + +Fix deploying to a different project failing once that project has collections diff --git a/packages/project/src/merge/merge-project.ts b/packages/project/src/merge/merge-project.ts index 566049bf4..8e1642990 100644 --- a/packages/project/src/merge/merge-project.ts +++ b/packages/project/src/merge/merge-project.ts @@ -170,7 +170,7 @@ export function merge( source.credentials, target.credentials ), - collections: source.collections, + collections: mergeCollections(source.collections, target.collections), channels: mergeChannels(source.channels, target.channels), } : { diff --git a/packages/project/test/merge/merge-project.test.ts b/packages/project/test/merge/merge-project.test.ts index 8671afeb1..766494f6a 100644 --- a/packages/project/test/merge/merge-project.test.ts +++ b/packages/project/test/merge/merge-project.test.ts @@ -384,7 +384,7 @@ test('replace mode: undefined local collections leave the target collections alo t.deepEqual(result.collections, targetCollections); }); -test('sandbox mode: source collections fully replace target collections', (t) => { +test('sandbox mode: source collections replace target collections, keeping target uuid on a name match', (t) => { const wf = { steps: [ { id: 'x', name: 'X', adaptor: 'common', expression: 'fn(s => s)' }, @@ -395,17 +395,25 @@ test('sandbox mode: source collections fully replace target collections', (t) => const targetCollections = [ { uuid: 'remote-uuid-1', name: 'target-collection' }, + { uuid: 'remote-uuid-2', name: 'shared-collection' }, + ]; + // the source's own uuids belong to a different project + const sourceCollections = [ + { name: 'source-collection' }, + { uuid: 'source-uuid', name: 'shared-collection' }, ]; - const sourceCollections = [{ name: 'source-collection' }]; const target = createProject(wf_a, 'a', { collections: targetCollections }); const source = createProject(wf_b, 'b', { collections: sourceCollections }); const result = merge(source, target, { mode: SANDBOX_MERGE }); - // b's collections are wholly replaced by a's - sandbox mode does a raw - // swap here, unlike replace mode's name-matching mergeCollections - t.deepEqual(result.collections, sourceCollections); + // a shared collection must keep the target's uuid, or the deploy tries to + // create a duplicate and Lightning rejects it + t.deepEqual(result.collections, [ + { name: 'source-collection', uuid: undefined }, + { name: 'shared-collection', uuid: 'remote-uuid-2' }, + ]); }); test('sandbox mode: target channels are preserved when source has none', (t) => { From 7db63f0d49c463f33f24537521a4c23e12b168ea Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Mon, 2 Nov 2026 10:15:31 +0000 Subject: [PATCH 2/4] add integration test --- .../cli/test/sync/cross-project.test.ts | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/integration-tests/cli/test/sync/cross-project.test.ts b/integration-tests/cli/test/sync/cross-project.test.ts index d70a2c573..9629737a6 100644 --- a/integration-tests/cli/test/sync/cross-project.test.ts +++ b/integration-tests/cli/test/sync/cross-project.test.ts @@ -3,6 +3,7 @@ // the target is made to look exactly like the workspace import test from 'ava'; +import { randomUUID } from 'node:crypto'; import { generateProject } from '@openfn/project'; import createLightningServer from '@openfn/lightning-mock'; import { setup } from './helpers'; @@ -27,7 +28,7 @@ const code = (server: any, project: any) => ]) ); -// a project's workflows on a server, as { name: uuid } +// get a project's workflows on a server, as { name: uuid } const uuids = (server: any, project: any) => Object.fromEntries( ( @@ -37,6 +38,14 @@ const uuids = (server: any, project: any) => ).map((wf) => [wf.name, wf.id]) ); +// get a project's collections on a server, as { name: uuid } +const collectionUuids = (server: any, project: any) => + Object.fromEntries( + (server.state.projects[project.openfn.uuid].collections as any[]).map( + (c) => [c.name, c.id] + ) + ); + // The workspace is pulled from cloud (Alpha and Beta). Prod has its own version of // Alpha, and a workflow of its own (Gamma) const genProjects = () => ({ @@ -92,6 +101,40 @@ test('workflows deployed to a different project get new uuids', async (t) => { t.not(prodUuids.Alpha, cloudUuids.Alpha); }); +test('a collection deployed to a different project keeps the target uuid', async (t) => { + const { run } = initWorkspace(t); + + // both projects have a "patients" collection, each with its own uuid + const cloud = generateProject( + 'cloud', + ['@name Alpha trigger-job(expression="fn(a)")'], + { openfnUuid: true } + ); + cloud.collections = [{ uuid: randomUUID(), name: 'patients' }]; + lightning.addProject(cloud.serialize('state', { format: 'json' })); + + const prod = generateProject( + 'prod', + ['@name Alpha trigger-job(expression="fn(old)")'], + { openfnUuid: true } + ); + prod.collections = [{ uuid: randomUUID(), name: 'patients' }]; + lightning.addProject(prod.serialize('state', { format: 'json' })); + + const cloudUuids = collectionUuids(lightning, cloud); + const prodPatients = collectionUuids(lightning, prod).patients; + t.not(cloudUuids.patients, prodPatients); + + await run(`openfn project pull ${cloud.openfn.uuid}`); + await run(`openfn project deploy ${prod.openfn.uuid} --force`); + + // matched by name, so prod keeps its own uuid rather than taking cloud's + t.deepEqual(collectionUuids(lightning, prod), { patients: prodPatients }); + + // and cloud is untouched + t.deepEqual(collectionUuids(lightning, cloud), cloudUuids); +}); + test('deploy to a different project without a prompt will not remove workflows unless forced', async (t) => { const { run } = initWorkspace(t); const { cloud, prod } = genProjects(); From 5c2deb0ee78acddacd31c4c0e66096b93ad4ee70 Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Mon, 2 Nov 2026 10:44:22 +0000 Subject: [PATCH 3/4] versions --- .changeset/keep-collection-ids.md | 6 ------ integration-tests/cli/CHANGELOG.md | 8 ++++++++ integration-tests/cli/package.json | 2 +- packages/cli/CHANGELOG.md | 8 ++++++++ packages/cli/package.json | 2 +- packages/lightning-mock/CHANGELOG.md | 7 +++++++ packages/lightning-mock/package.json | 2 +- packages/project/CHANGELOG.md | 6 ++++++ packages/project/package.json | 2 +- 9 files changed, 33 insertions(+), 10 deletions(-) delete mode 100644 .changeset/keep-collection-ids.md diff --git a/.changeset/keep-collection-ids.md b/.changeset/keep-collection-ids.md deleted file mode 100644 index 4990cbe62..000000000 --- a/.changeset/keep-collection-ids.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@openfn/project': patch -'@openfn/cli': patch ---- - -Fix deploying to a different project failing once that project has collections diff --git a/integration-tests/cli/CHANGELOG.md b/integration-tests/cli/CHANGELOG.md index 16c0aea5f..999c451c3 100644 --- a/integration-tests/cli/CHANGELOG.md +++ b/integration-tests/cli/CHANGELOG.md @@ -1,5 +1,13 @@ # @openfn/integration-tests-cli +## 1.0.39 + +### Patch Changes + +- Updated dependencies [f1e8d71] + - @openfn/project@0.23.2 + - @openfn/lightning-mock@2.5.9 + ## 1.0.38 ### Patch Changes diff --git a/integration-tests/cli/package.json b/integration-tests/cli/package.json index 585a3764c..f5ecc408c 100644 --- a/integration-tests/cli/package.json +++ b/integration-tests/cli/package.json @@ -1,7 +1,7 @@ { "name": "@openfn/integration-tests-cli", "private": true, - "version": "1.0.38", + "version": "1.0.39", "description": "CLI integration tests", "author": "Open Function Group ", "license": "ISC", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index d59f4e702..65eec90ba 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,13 @@ # @openfn/cli +## 1.45.1 + +### Patch Changes + +- f1e8d71: Fix deploying to a different project failing once that project has collections +- Updated dependencies [f1e8d71] + - @openfn/project@0.23.2 + ## 1.45.0 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index fbd6169ed..0e8f76a3c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openfn/cli", - "version": "1.45.0", + "version": "1.45.1", "description": "CLI devtools for the OpenFn toolchain", "repository": { "url": "https://github.com/OpenFn/kit" diff --git a/packages/lightning-mock/CHANGELOG.md b/packages/lightning-mock/CHANGELOG.md index 89e2f6e1b..3e82279e6 100644 --- a/packages/lightning-mock/CHANGELOG.md +++ b/packages/lightning-mock/CHANGELOG.md @@ -1,5 +1,12 @@ # @openfn/lightning-mock +## 2.5.9 + +### Patch Changes + +- Updated dependencies [f1e8d71] + - @openfn/project@0.23.2 + ## 2.5.8 ### Patch Changes diff --git a/packages/lightning-mock/package.json b/packages/lightning-mock/package.json index d932ed00d..2579de12e 100644 --- a/packages/lightning-mock/package.json +++ b/packages/lightning-mock/package.json @@ -1,6 +1,6 @@ { "name": "@openfn/lightning-mock", - "version": "2.5.8", + "version": "2.5.9", "private": true, "description": "A mock Lightning server", "main": "dist/index.js", diff --git a/packages/project/CHANGELOG.md b/packages/project/CHANGELOG.md index ccada9449..e65750ac9 100644 --- a/packages/project/CHANGELOG.md +++ b/packages/project/CHANGELOG.md @@ -1,5 +1,11 @@ # @openfn/project +## 0.23.2 + +### Patch Changes + +- f1e8d71: Fix deploying to a different project failing once that project has collections + ## 0.23.1 ### Patch Changes diff --git a/packages/project/package.json b/packages/project/package.json index a8889f0bf..49483fd7c 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -1,6 +1,6 @@ { "name": "@openfn/project", - "version": "0.23.1", + "version": "0.23.2", "description": "Read, serialize, replicate and sync OpenFn projects", "author": "Open Function Group ", "license": "ISC", From 6596f7a6d15d55e0759392a54516487fff567c8c Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Mon, 2 Nov 2026 10:56:27 +0000 Subject: [PATCH 4/4] add extra test --- .../cli/test/sync/cross-project.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/integration-tests/cli/test/sync/cross-project.test.ts b/integration-tests/cli/test/sync/cross-project.test.ts index 9629737a6..752d3019c 100644 --- a/integration-tests/cli/test/sync/cross-project.test.ts +++ b/integration-tests/cli/test/sync/cross-project.test.ts @@ -135,6 +135,41 @@ test('a collection deployed to a different project keeps the target uuid', async t.deepEqual(collectionUuids(lightning, cloud), cloudUuids); }); +test('a collection the workspace does not have is removed from the target on deploy', async (t) => { + const { run } = initWorkspace(t); + + const cloud = generateProject( + 'cloud', + ['@name Alpha trigger-job(expression="fn(a)")'], + { openfnUuid: true } + ); + cloud.collections = [{ uuid: randomUUID(), name: 'patients' }]; + lightning.addProject(cloud.serialize('state', { format: 'json' })); + + // prod has a collection of its own, which the workspace doesn't know about + const prod = generateProject( + 'prod', + ['@name Alpha trigger-job(expression="fn(old)")'], + { openfnUuid: true } + ); + prod.collections = [ + { uuid: randomUUID(), name: 'patients' }, + { uuid: randomUUID(), name: 'legacy' }, + ]; + lightning.addProject(prod.serialize('state', { format: 'json' })); + + const cloudUuids = collectionUuids(lightning, cloud); + + await run(`openfn project pull ${cloud.openfn.uuid}`); + await run(`openfn project deploy ${prod.openfn.uuid} --force`); + + // The legacy collection should now be removed from prod + t.deepEqual(Object.keys(collectionUuids(lightning, prod)), ['patients']); + + // and cloud is untouched + t.deepEqual(collectionUuids(lightning, cloud), cloudUuids); +}); + test('deploy to a different project without a prompt will not remove workflows unless forced', async (t) => { const { run } = initWorkspace(t); const { cloud, prod } = genProjects();