From 6a758cc4b24ad70695b12f9f42f10bc877b8d45b Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 24 Jun 2026 20:23:05 +0800 Subject: [PATCH] fix(CS-11713): fully replace array attributes in store.patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation: a card app lets the AI assistant plan and mutate a containsMany of itinerary stops. Asking it to reduce the stops got stuck — the assistant replies that the stops have been reduced (e.g. "Day 2 trimmed to a relaxed 4-stop flow"), but the containsMany field is never actually patched (or only the first day shrank while later days kept stale stops). After switching the merge logic to mergeWith, the same prompts now mutate the stops correctly. Root cause: StoreService.patch merged patch.attributes with lodash `merge`, which merges arrays by index and never shortens the destination array. A containsMany (or any array attribute) could grow or change in place via a patch but never shrink — a shorter array left the old trailing items behind. Because stops is one flat array (each carries a `day`), reducing every day reliably trimmed the low indices (Day 1) but left the high-index tail (Day 2+) untouched. This only bit patch-based writes: the AI patchCardInstance tool and any PatchCardInstanceCommand caller. A component-side `model.field = [...]` assignment was never affected, because it replaces the whole field value. Fix: merge attributes with mergeWith and a customizer that returns the source array, so a patched array fully replaces the existing one — matching PatchCardInstanceCommand's documented contract ("attributes specified will be fully replaced; display the full array in the patch code"). Non-array values still deep-merge (nested patches like `cardInfo: { name }` keep their siblings); the relationships and meta merge paths are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/host/app/services/store.ts | 8 +++- .../commands/patch-instance-test.gts | 48 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 6a649e8f23f..9b30e9e03ac 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -15,7 +15,7 @@ import { task } from 'ember-concurrency'; import { cloneDeep } from 'lodash-es'; import { isEqual } from 'lodash-es'; -import { merge } from 'lodash-es'; +import { merge, mergeWith } from 'lodash-es'; import { TrackedObject, TrackedMap } from 'tracked-built-ins'; @@ -926,7 +926,11 @@ export default class StoreService extends Service implements StoreInterface { omitQueryFields: true, }); if (patch.attributes) { - doc.data.attributes = merge(doc.data.attributes, patch.attributes); + doc.data.attributes = mergeWith( + doc.data.attributes, + patch.attributes, + (_dest, src) => (Array.isArray(src) ? src : undefined), + ); } if (patch.relationships) { let mergedRel = mergeRelationships( diff --git a/packages/host/tests/integration/commands/patch-instance-test.gts b/packages/host/tests/integration/commands/patch-instance-test.gts index b52a7abea47..cef5e908158 100644 --- a/packages/host/tests/integration/commands/patch-instance-test.gts +++ b/packages/host/tests/integration/commands/patch-instance-test.gts @@ -215,6 +215,54 @@ module('Integration | commands | patch-instance', function (hooks) { ); }); + test('patching a containsMany field with a shorter array fully replaces it (no stale trailing items)', async function (assert) { + let patchInstanceCommand = new PatchCardInstanceCommand( + commandService.commandContext, + { + cardType: PersonDef, + }, + ); + let url = new URL(`${testRealmURL}Person/hassan`); + let saves = 0; + this.onSave((saveURL) => { + if (saveURL.href === url.href) { + saves++; + } + }); + + await patchInstanceCommand.execute({ + cardId: `${testRealmURL}Person/hassan`, + patch: { attributes: { nickNames: ['Paper', 'Pinky', 'Pix'] } }, + }); + await waitUntil(() => saves > 0, { + timeout: saveWaitTimeoutMs, + timeoutMessage: 'timed out waiting for the first save', + }); + + let savesAfterGrow = saves; + await patchInstanceCommand.execute({ + cardId: `${testRealmURL}Person/hassan`, + patch: { attributes: { nickNames: ['Paper'] } }, + }); + await waitUntil(() => saves > savesAfterGrow, { + timeout: saveWaitTimeoutMs, + timeoutMessage: 'timed out waiting for the second save', + }); + + let result = await indexQuery.instance(url); + let instance = + result && result.type === 'instance' ? result.instance : undefined; + assert.ok(instance, 'instance payload is present'); + if (!instance) { + throw new Error('expected instance payload'); + } + assert.deepEqual( + instance.attributes?.nickNames, + ['Paper'], + 'the containsMany array was fully replaced, not index-merged', + ); + }); + test('can patch a linksTo field', async function (assert) { let patchInstanceCommand = new PatchCardInstanceCommand( commandService.commandContext,