From 24659ac61c4c05a956ca1a5f53bb279f18c64823 Mon Sep 17 00:00:00 2001 From: Marco Link Date: Thu, 18 Jun 2026 11:31:10 +0200 Subject: [PATCH] fix(apply): update existing draft on create conflict [HK-0000] --- .../apply-changeset/actions/create-entity.ts | 49 +++++++++ .../tasks/create-add-entities-task.test.ts | 101 ++++++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/src/engine/apply-changeset/actions/create-entity.ts b/src/engine/apply-changeset/actions/create-entity.ts index 5bcbee69..f0a27280 100644 --- a/src/engine/apply-changeset/actions/create-entity.ts +++ b/src/engine/apply-changeset/actions/create-entity.ts @@ -7,6 +7,44 @@ type CreateEntityParams = BaseActionParams & { item: AddedChangesetItem } +const isVersionMismatchError = (error: any) => error.response?.data?.sys?.id?.includes('VersionMismatch') + +const getContentTypeId = (entry: EntryProps) => entry.sys.contentType.sys.id + +const updateExistingDraftEntry = async ({ + client, + item, + environmentId, + logger, +}: Omit) => { + const data = item.data as EntryProps + const existingEntry = await client.cma.entries.get({ entryId: item.entity.sys.id, environment: environmentId }) + const contentType = getContentTypeId(data) + + if (getContentTypeId(existingEntry) !== contentType) { + throw new Error( + `Entry ${item.entity.sys.id} already exists in the target environment with a different content type.`, + ) + } + if (existingEntry.sys.publishedVersion) { + throw new Error(`Entry ${item.entity.sys.id} already exists as a published entry in the target environment.`) + } + + logger.info( + `entry ${item.entity.sys.id} already exists on environment: ${environmentId}. Updating existing draft entry instead.`, + ) + + return client.cma.entries.update({ + environment: environmentId, + entryId: item.entity.sys.id, + contentType, + entry: { + ...existingEntry, + fields: data.fields, + }, + }) +} + export const createEntity = async ({ client, item, @@ -27,6 +65,17 @@ export const createEntity = async ({ logger.info(`entry ${item.entity.sys.id} successfully published on environment: ${environmentId}`) return createdEntry } catch (error: any) { + if (isVersionMismatchError(error)) { + try { + const updatedEntry = await updateExistingDraftEntry({ client, item, environmentId, logger }) + + task.output = `✨ successfully updated existing draft ${updatedEntry.sys.id}` + return updatedEntry + } catch (fallbackError: any) { + logger.error(`add entry ${item.entity.sys.id} fallback update failed with ${fallbackError}`) + } + } + task.output = `🚨 failed to create ${item.entity.sys.id}` logger.error(`add entry ${item.entity.sys.id} failed with ${error}`) responseCollector.add(error.code, error) diff --git a/test/unit/engine/apply-changeset/tasks/create-add-entities-task.test.ts b/test/unit/engine/apply-changeset/tasks/create-add-entities-task.test.ts index e9aac724..49361ef0 100644 --- a/test/unit/engine/apply-changeset/tasks/create-add-entities-task.test.ts +++ b/test/unit/engine/apply-changeset/tasks/create-add-entities-task.test.ts @@ -79,6 +79,107 @@ describe('applyAddEntitiesTask', () => { expect(spy.called).to.be.equal(2) }) + it('updates an existing draft entry when create fails with a version mismatch', async () => { + const addedChangesetItem: AddedChangesetItem = createLinkObject('added-entry', 'add', 'Entry') + + addedChangesetItem.data = { + sys: { + space: { sys: { type: 'Link', linkType: 'Space', id: '529ziq3ce86u' } }, + id: 'added-entry', + type: 'Entry', + createdAt: '2023-05-17T10:36:22.538Z', + updatedAt: '2023-05-17T10:36:40.280Z', + environment: { sys: { id: 'master', type: 'Link', linkType: 'Environment' } }, + revision: 1, + version: 1, + contentType: { sys: { type: 'Link', linkType: 'ContentType', id: 'lesson' } }, + }, + fields: { + title: { 'en-US': 'Source title' }, + }, + } + + const existingEntry = { + sys: { + id: 'added-entry', + version: 4, + contentType: { sys: { type: 'Link', linkType: 'ContentType', id: 'lesson' } }, + }, + fields: { + title: { 'en-US': 'Target draft title' }, + }, + } + const updatedEntry = { + ...existingEntry, + sys: { + ...existingEntry.sys, + version: 5, + }, + fields: addedChangesetItem.data.fields, + } + + class Spy { + called = 0 + + callCreate = (): any => { + this.called++ + throw { + response: { + data: { + sys: { id: 'VersionMismatch' }, + }, + }, + } + } + callGet = (args: any): any => { + expect(args.environment).to.be.equal('qa') + expect(args.entryId).to.be.equal('added-entry') + + this.called++ + return existingEntry + } + callUpdate = (args: any): any => { + expect(args.environment).to.be.equal('qa') + expect(args.entryId).to.be.equal('added-entry') + expect(args.contentType).to.be.equal('lesson') + expect(args.entry.sys.version).to.be.equal(existingEntry.sys.version) + expect(args.entry.fields).to.deep.equal(addedChangesetItem.data.fields) + + this.called++ + return updatedEntry + } + callPublish = (args: any): any => { + expect(args.environment).to.be.equal('qa') + expect(args.entryId).to.be.equal('added-entry') + expect(args.entryVersion).to.be.equal(updatedEntry.sys.version) + + this.called++ + return updatedEntry + } + } + + const spy = new Spy() + + context.client.cma.entries.create = spy.callCreate + context.client.cma.entries.get = spy.callGet + context.client.cma.entries.update = spy.callUpdate + context.client.cma.entries.publish = spy.callPublish + context.changeset.items.push(addedChangesetItem) + + const task = initializeTask(ApplyChangesetTasks.createAddEntitiesTask(), context) + let error = null + try { + await task.run() + } catch (err) { + error = err + } + + expect(error).to.be.null + expect(task.tasks[0].output).to.be.equal('✨ successfully published added-entry') + expect(context.processedEntities.entries.added).to.deep.equal(['added-entry']) + expect(spy.called).to.be.equal(4) + }) + it('reports on error for failing entry publishing', async () => { const addedChangesetItem: AddedChangesetItem = createLinkObject('added-entry', 'add', 'Entry')