From 31fd49cad763d3e2eba56bfbef9ce0d434810438 Mon Sep 17 00:00:00 2001 From: ylm Date: Wed, 16 Sep 2026 17:25:31 -0400 Subject: [PATCH 1/7] Send only unsaved links in a new card's `included`, not resident saved ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a new card is written, the serializer inlines into `included[]` every linked card the store has resident — saved or not. The realm keeps only the primary card plus any brand-new, unsaved links being created in the same request (identified by `lid`) and discards the already-saved ones. Filter the serialized `included` to the `lid`-bearing members in the one code path that retains it (the new-card save). The document sent now equals what the realm stores, which cuts wire bytes and serialisation work on every new-card save from a busy tab, and keeps co-creation of unsaved links intact. The per-file size check added upstream already fixed the "card size exceeds maximum" failure this inflation caused; this change removes the wasted payload itself. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014GsYGsuCqHJz9GAti4jheG --- packages/host/app/services/card-service.ts | 15 +++++++++ .../host/tests/integration/store-test.gts | 33 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/packages/host/app/services/card-service.ts b/packages/host/app/services/card-service.ts index eefc6e9dacf..1b5cf49f92e 100644 --- a/packages/host/app/services/card-service.ts +++ b/packages/host/app/services/card-service.ts @@ -246,6 +246,21 @@ export default class CardService extends Service { }); if (!opts?.withIncluded) { delete serialized.included; + } else if (serialized.included) { + // The realm writes only the primary card and any brand-new, unsaved + // links it is being asked to create in the same request — identified by + // `lid` — and discards every already-saved link it finds in `included`. + // The serializer, though, inlines every linked card the store has + // resident, saved or not. Drop the saved ones here so the document we + // send equals what the realm keeps rather than inflating the payload + // (and its serialisation) with cards the tab merely happens to have + // loaded. + serialized.included = serialized.included.filter( + (resource) => typeof (resource as { lid?: unknown }).lid === 'string', + ); + if (serialized.included.length === 0) { + delete serialized.included; + } } return serialized; } diff --git a/packages/host/tests/integration/store-test.gts b/packages/host/tests/integration/store-test.gts index cb54cf2d578..3217bb315c5 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -2240,6 +2240,39 @@ module('Integration | Store', function (hooks) { } }); + test('withIncluded serialization inlines only unsaved (lid) links, not resident saved ones', async function (assert) { + let cardService = getService('card-service') as any; + + // A saved link the store has resident: the realm already holds it and + // discards it from `included` on write, so it should not be inlined. + let saved = new PersonDef({ name: 'Saved' }); + await (storeService as any).persistAndUpdate(saved); + assert.ok((saved as any).id, 'the linked card is saved'); + + // An unsaved link created alongside this card: the realm creates it from + // its `lid` in the same request, so it must ride along in `included`. + let unsaved = new PersonDef({ name: 'Unsaved' }); + + let instance = new PersonDef({ name: 'Consumer' }); + (instance as any).bestFriend = saved; + (instance as any).friends = [unsaved]; + + let doc = await cardService.serializeCard(instance, { + useAbsoluteURL: true, + withIncluded: true, + }); + let included = (doc.included ?? []) as any[]; + + assert.notOk( + included.find((resource) => resource.id === (saved as any).id), + 'a resident saved link is not inlined into included', + ); + assert.ok( + included.find((resource) => resource.lid === (unsaved as any)[localId]), + 'an unsaved link is inlined into included by lid so the realm can co-create it', + ); + }); + test('a save overlapping a create PATCHes instead of issuing a second POST', async function (assert) { // Driven through `persistAndUpdate` rather than `save`, because the // autosave queue awaits the in-flight mutation before it saves at all — From 168b5d04a28cfc127fad9648c979d2cb817b73f4 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 16 Sep 2026 17:59:19 -0400 Subject: [PATCH 2/7] Name the write-shaped included scope: withLocalResourcesIncluded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit withIncluded read as a generic serializer toggle while delivering only the lid-bearing subset. The new name uses the platform's own vocabulary (lid = local id): what rides along in included is exactly the local resources — instances that exist only in this tab and are co-created by the write. Co-Authored-By: Claude Fable 5 --- packages/host/app/services/card-service.ts | 4 ++-- packages/host/app/services/store.ts | 2 +- packages/host/tests/integration/store-test.gts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/host/app/services/card-service.ts b/packages/host/app/services/card-service.ts index 1b5cf49f92e..e93d010018e 100644 --- a/packages/host/app/services/card-service.ts +++ b/packages/host/app/services/card-service.ts @@ -235,7 +235,7 @@ export default class CardService extends Service { async serializeCard( card: CardDef, - opts?: SerializeOpts & { withIncluded?: true }, + opts?: SerializeOpts & { withLocalResourcesIncluded?: true }, ): Promise { let api = await this.getAPI(); if (opts?.includeComputeds) { @@ -244,7 +244,7 @@ export default class CardService extends Service { let serialized = api.serializeCard(card, { ...opts, }); - if (!opts?.withIncluded) { + if (!opts?.withLocalResourcesIncluded) { delete serialized.included; } else if (serialized.included) { // The realm writes only the primary card and any brand-new, unsaved diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 7ab7320cbf5..28e77e957db 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -3582,7 +3582,7 @@ export default class StoreService extends Service implements StoreInterface { // relativeTo because its up to the realm server to assign us an ID, so // URL's should be absolute useAbsoluteURL: true, - withIncluded: true, + withLocalResourcesIncluded: true, omitQueryFields: true, }); diff --git a/packages/host/tests/integration/store-test.gts b/packages/host/tests/integration/store-test.gts index 3217bb315c5..cec3d5a2139 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -2240,7 +2240,7 @@ module('Integration | Store', function (hooks) { } }); - test('withIncluded serialization inlines only unsaved (lid) links, not resident saved ones', async function (assert) { + test('withLocalResourcesIncluded serialization inlines only unsaved (lid) links, not resident saved ones', async function (assert) { let cardService = getService('card-service') as any; // A saved link the store has resident: the realm already holds it and @@ -2259,7 +2259,7 @@ module('Integration | Store', function (hooks) { let doc = await cardService.serializeCard(instance, { useAbsoluteURL: true, - withIncluded: true, + withLocalResourcesIncluded: true, }); let included = (doc.included ?? []) as any[]; From 27300614e77003f8ee6527abaa1c93d29de5c360 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 16 Sep 2026 18:13:47 -0400 Subject: [PATCH 3/7] Skip serializing excluded link targets via an includedScope option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write path filtered included after api.serializeCard had already walked and serialized every resident link target — the expensive part of a save from a busy tab. SerializeOpts now carries includedScope ('all', the default and prior behavior; 'local' — only unsaved, lid-bearing targets, the write's co-creation manifest; 'none'), enforced where the work happens: an excluded target contributes only its relationship entry, and neither it nor its own linked graph is serialized. The card-service wrapper maps withLocalResourcesIncluded onto 'local' and its absence onto 'none', replacing the post-serialization filter. One deliberate behavior shift: an unsaved card reachable only through a saved link no longer rides along in this card's included — its co-creation belongs to the dirty saved card's own save, not to a write that merely references that card. Co-Authored-By: Claude Fable 5 --- packages/base/card-api.gts | 47 ++++++++++++++ packages/base/card-serialization.ts | 9 +++ packages/host/app/services/card-service.ts | 24 +++---- .../host/tests/integration/store-test.gts | 64 +++++++++++++++++++ 4 files changed, 129 insertions(+), 15 deletions(-) diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 3a3693f6ece..e6b6b3ce09c 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -1544,6 +1544,32 @@ class LinksTo implements Field { }; } + // A target the includedScope excludes needs only its relationship entry — + // the same shapes the visited branches above emit — so the recursive + // serialization of the target (and the traversal of its own linked graph) + // is skipped entirely. + let includedScope = opts?.includedScope ?? 'all'; + if ( + (value.id && includedScope !== 'all') || + (!value.id && includedScope === 'none') + ) { + return { + relationships: { + [this.name]: value.id + ? { + links: { self: makeRelativeURL(value.id, opts) }, + data: { type: relationshipType, id: value.id }, + } + : { + data: { + type: relationshipType, + lid: (value as CardDef)[localId], + }, + }, + }, + }; + } + visited.add(value.id ?? (value as CardDef)[localId]); let serialized = callSerializeHook( @@ -2110,6 +2136,27 @@ class LinksToMany implements Field< return; } + // Same includedScope skip as linksTo: an excluded target contributes + // its relationship entry only, with no recursive serialization. + let includedScope = opts?.includedScope ?? 'all'; + if ( + (value.id && includedScope !== 'all') || + (!value.id && includedScope === 'none') + ) { + relationships[`${this.name}.${i}`] = value.id + ? { + links: { self: makeRelativeURL(value.id, opts) }, + data: { type: relationshipType, id: value.id }, + } + : { + data: { + type: relationshipType, + lid: (value as CardDef)[localId], + }, + }; + return; + } + visited.add(value.id ?? (value as CardDef)[localId]); let serialized: JSONAPIResource & ResourceID = callSerializeHook( this.card, diff --git a/packages/base/card-serialization.ts b/packages/base/card-serialization.ts index 6dd656ab75d..97a8e83405c 100644 --- a/packages/base/card-serialization.ts +++ b/packages/base/card-serialization.ts @@ -75,6 +75,15 @@ export interface SerializeOpts { useAbsoluteURL?: boolean; omitFields?: [typeof BaseDef]; omitQueryFields?: boolean; + // How much of the linked graph rides along in `included[]`. 'all' (the + // default) serializes every resident link target — the read/copy shape. + // 'local' serializes only local (unsaved, lid-bearing) targets — the write + // shape, where `included` is a co-creation manifest and saved targets are + // reference-only. 'none' serializes no link targets at all. A skipped + // target contributes its relationship entry only, and its own linked graph + // is not traversed — which is the point: a new card linking into a large + // saved graph serializes none of that graph on save. + includedScope?: 'all' | 'local' | 'none'; maybeRelativeReference?: (possibleReference: string) => string; overrides?: Map; } diff --git a/packages/host/app/services/card-service.ts b/packages/host/app/services/card-service.ts index e93d010018e..30f49b8ad17 100644 --- a/packages/host/app/services/card-service.ts +++ b/packages/host/app/services/card-service.ts @@ -241,26 +241,20 @@ export default class CardService extends Service { if (opts?.includeComputeds) { await this.settleQueryBackedFields(api, card); } + // The scope pushes the realm's write-retention rule into the serializer + // itself: the realm keeps only the primary card plus the local (unsaved, + // `lid`-bearing) links it co-creates, and discards every already-saved + // link in `included` — so saved link targets are never serialized here at + // all, and a card linking into a large resident graph pays nothing for + // it on save. let serialized = api.serializeCard(card, { ...opts, + includedScope: opts?.withLocalResourcesIncluded ? 'local' : 'none', }); if (!opts?.withLocalResourcesIncluded) { + // includedScope 'none' builds no included; the delete guards the + // contract against any custom serialize hook that pushes one anyway. delete serialized.included; - } else if (serialized.included) { - // The realm writes only the primary card and any brand-new, unsaved - // links it is being asked to create in the same request — identified by - // `lid` — and discards every already-saved link it finds in `included`. - // The serializer, though, inlines every linked card the store has - // resident, saved or not. Drop the saved ones here so the document we - // send equals what the realm keeps rather than inflating the payload - // (and its serialisation) with cards the tab merely happens to have - // loaded. - serialized.included = serialized.included.filter( - (resource) => typeof (resource as { lid?: unknown }).lid === 'string', - ); - if (serialized.included.length === 0) { - delete serialized.included; - } } return serialized; } diff --git a/packages/host/tests/integration/store-test.gts b/packages/host/tests/integration/store-test.gts index cec3d5a2139..60c996e9b5a 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -2273,6 +2273,70 @@ module('Integration | Store', function (hooks) { ); }); + test('includedScope controls which link targets the serializer builds', async function (assert) { + // The write path's hot-path saving lives in the serializer itself: an + // excluded target contributes only its relationship entry, and neither it + // nor its own linked graph is serialized. + let cardService = getService('card-service') as any; + let api = await cardService.getAPI(); + + let saved = new PersonDef({ name: 'Saved' }); + await (storeService as any).persistAndUpdate(saved); + let unsaved = new PersonDef({ name: 'Unsaved' }); + let instance = new PersonDef({ name: 'Consumer' }); + (instance as any).bestFriend = saved; + (instance as any).friends = [unsaved]; + + let all = api.serializeCard(instance, { + useAbsoluteURL: true, + includedScope: 'all', + }); + assert.ok( + (all.included ?? []).find((r: any) => r.id === (saved as any).id), + "'all' serializes saved targets into included", + ); + assert.ok( + (all.included ?? []).find( + (r: any) => r.lid === (unsaved as any)[localId], + ), + "'all' serializes local targets into included", + ); + + let local = api.serializeCard(instance, { + useAbsoluteURL: true, + includedScope: 'local', + }); + assert.notOk( + (local.included ?? []).find((r: any) => r.id === (saved as any).id), + "'local' does not serialize saved targets", + ); + assert.ok( + (local.included ?? []).find( + (r: any) => r.lid === (unsaved as any)[localId], + ), + "'local' serializes local targets — the write's co-creation manifest", + ); + assert.ok( + (local.data.relationships?.bestFriend as any)?.links?.self, + "'local' still emits the saved target's reference relationship", + ); + + let none = api.serializeCard(instance, { + useAbsoluteURL: true, + includedScope: 'none', + }); + assert.strictEqual( + none.included, + undefined, + "'none' builds no included at all", + ); + assert.strictEqual( + (none.data.relationships?.['friends.0'] as any)?.data?.lid, + (unsaved as any)[localId], + "'none' still emits the local target's lid relationship", + ); + }); + test('a save overlapping a create PATCHes instead of issuing a second POST', async function (assert) { // Driven through `persistAndUpdate` rather than `save`, because the // autosave queue awaits the in-flight mutation before it saves at all — From a3cac316b19b51f13adaf0d2d1e8e47941721037 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 17 Sep 2026 10:09:00 +0200 Subject: [PATCH 4/7] Spell a reference-only link relationship once per field class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A link target contributes its relationship entry alone — with no resource in `included[]` — when the walk has already serialized it or when the includedScope excludes it. Both singular and plural linksTo wrote that entry out longhand in each of those branches and again at the tail of a full serialization, six sites that had to move together. Collapse them onto two module-level helpers: one that answers whether the scope excludes a target, one that builds the reference relationship. Co-Authored-By: Claude Opus 5 (1M context) --- packages/base/card-api.gts | 162 ++++++++++++++----------------------- 1 file changed, 61 insertions(+), 101 deletions(-) diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index e6b6b3ce09c..0ca5b0540ec 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -1370,6 +1370,35 @@ function serializeNonPresentLink( }; } +// Whether the `includedScope` keeps this link target's resource out of +// `included[]`. 'all' keeps every resident target, 'local' keeps only the +// unsaved (`lid`-bearing) ones — the write shape, where `included` is a +// co-creation manifest — and 'none' keeps none. +function isExcludedByIncludedScope( + value: CardDef, + includedScope: SerializeOpts['includedScope'] = 'all', +): boolean { + return value.id ? includedScope !== 'all' : includedScope === 'none'; +} + +// The relationship entry that stands in for a link target whose own resource +// is not inlined: a saved target by `links.self` + `id`, an unsaved one by +// `lid`. Every reference-only path — an already-visited target, a +// scope-excluded one, and the tail of a full serialization — emits exactly +// this, so the spellings cannot drift apart. +function referenceRelationship( + value: CardDef, + relationshipType: string, + opts?: SerializeOpts, +): Relationship { + return value.id + ? { + links: { self: makeRelativeURL(value.id, opts) }, + data: { type: relationshipType, id: value.id }, + } + : { data: { type: relationshipType, lid: value[localId] } }; +} + class LinksTo implements Field { readonly fieldType = 'linksTo'; private cardThunk: () => CardT; @@ -1522,50 +1551,22 @@ class LinksTo implements Field { `linksTo field '${this.name}' cannot serialize a FileDef without an id`, ); } - if (visited.has(value.id)) { - return { - relationships: { - [this.name]: { - links: { - self: makeRelativeURL(value.id, opts), - }, - data: { type: relationshipType, id: value.id }, - }, - }, - }; - } - if (visited.has((value as CardDef)[localId])) { - return { - relationships: { - [this.name]: { - data: { type: relationshipType, lid: (value as CardDef)[localId] }, - }, - }, - }; - } - - // A target the includedScope excludes needs only its relationship entry — - // the same shapes the visited branches above emit — so the recursive - // serialization of the target (and the traversal of its own linked graph) + // A target already serialized on this walk, or one the includedScope + // excludes, needs only its relationship entry — so the recursive + // serialization of the target, and the traversal of its own linked graph, // is skipped entirely. - let includedScope = opts?.includedScope ?? 'all'; if ( - (value.id && includedScope !== 'all') || - (!value.id && includedScope === 'none') + visited.has(value.id) || + visited.has((value as CardDef)[localId]) || + isExcludedByIncludedScope(value as CardDef, opts?.includedScope) ) { return { relationships: { - [this.name]: value.id - ? { - links: { self: makeRelativeURL(value.id, opts) }, - data: { type: relationshipType, id: value.id }, - } - : { - data: { - type: relationshipType, - lid: (value as CardDef)[localId], - }, - }, + [this.name]: referenceRelationship( + value as CardDef, + relationshipType, + opts, + ), }, }; } @@ -1582,21 +1583,11 @@ class LinksTo implements Field { if (serialized) { let resource: JSONAPIResource = { relationships: { - [this.name]: { - ...(value.id - ? { - links: { - self: makeRelativeURL(value.id, opts), - }, - data: { type: relationshipType, id: value.id }, - } - : { - data: { - type: relationshipType, - lid: (value as CardDef)[localId], - }, - }), - }, + [this.name]: referenceRelationship( + value as CardDef, + relationshipType, + opts, + ), }, }; if ( @@ -2120,40 +2111,19 @@ class LinksToMany implements Field< `linksToMany field '${this.name}' cannot serialize a FileDef without an id`, ); } - if (visited.has(value.id)) { - relationships[`${this.name}.${i}`] = { - links: { - self: makeRelativeURL(value.id, opts), - }, - data: { type: relationshipType, id: value.id }, - }; - return; - } - if (visited.has((value as CardDef)[localId])) { - relationships[`${this.name}.${i}`] = { - data: { type: relationshipType, lid: (value as CardDef)[localId] }, - }; - return; - } - - // Same includedScope skip as linksTo: an excluded target contributes - // its relationship entry only, with no recursive serialization. - let includedScope = opts?.includedScope ?? 'all'; + // Same reference-only rule as linksTo: a target already serialized on + // this walk, or one the includedScope excludes, contributes its + // relationship entry and nothing else. if ( - (value.id && includedScope !== 'all') || - (!value.id && includedScope === 'none') + visited.has(value.id) || + visited.has((value as CardDef)[localId]) || + isExcludedByIncludedScope(value as CardDef, opts?.includedScope) ) { - relationships[`${this.name}.${i}`] = value.id - ? { - links: { self: makeRelativeURL(value.id, opts) }, - data: { type: relationshipType, id: value.id }, - } - : { - data: { - type: relationshipType, - lid: (value as CardDef)[localId], - }, - }; + relationships[`${this.name}.${i}`] = referenceRelationship( + value as CardDef, + relationshipType, + opts, + ); return; } @@ -2181,21 +2151,11 @@ class LinksToMany implements Field< doc.included.push(serialized); } - relationships[`${this.name}.${i}`] = { - ...(value.id - ? { - links: { - self: makeRelativeURL(value.id, opts), - }, - data: { type: relationshipType, id: value.id }, - } - : { - data: { - type: relationshipType, - lid: (value as CardDef)[localId], - }, - }), - }; + relationships[`${this.name}.${i}`] = referenceRelationship( + value as CardDef, + relationshipType, + opts, + ); }); return { relationships }; From 2c92832013dc6cf9ff673094db4bf9247e008a6b Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 17 Sep 2026 10:09:11 +0200 Subject: [PATCH 5/7] Keep includedScope out of the card service's serialize signature `CardService#serializeCard` decides the scope from the caller's stated intent and overwrites whatever the options carried, so a caller-supplied `includedScope` was accepted by the type and then silently discarded. Narrow the parameter to reject it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/host/app/services/card-service.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/host/app/services/card-service.ts b/packages/host/app/services/card-service.ts index 85a24b70788..b1c9df874b3 100644 --- a/packages/host/app/services/card-service.ts +++ b/packages/host/app/services/card-service.ts @@ -233,9 +233,14 @@ export default class CardService extends Service { return; } + // `includedScope` is this method's to decide — a caller states its intent + // with `withLocalResourcesIncluded` and the scope follows — so the parameter + // does not accept one rather than silently discarding it in the spread below. async serializeCard( card: CardDef, - opts?: SerializeOpts & { withLocalResourcesIncluded?: true }, + opts?: Omit & { + withLocalResourcesIncluded?: true; + }, ): Promise { let api = await this.getAPI(); if (opts?.includeComputeds) { From 7a2a49c52e438bb857ca77c21a62721bd43d18cc Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 17 Sep 2026 10:09:30 +0200 Subject: [PATCH 6/7] State what a 'local' scope leaves out, and stop naming a copy consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An excluded target is not traversed, so 'local' reaches the local targets a card links to directly, not every local target in its graph: an unsaved card hanging off a saved link is not co-created by the write. Say so, with the reason it cannot be — nothing the write persists could reference it. 'all' is what a direct `serializeCard` call gets; describe it that way rather than naming a copy path, which serializes through the card service and so asks for no linked resources at all. Co-Authored-By: Claude Opus 5 (1M context) --- packages/base/card-serialization.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/base/card-serialization.ts b/packages/base/card-serialization.ts index 97a8e83405c..60e1e2f40e9 100644 --- a/packages/base/card-serialization.ts +++ b/packages/base/card-serialization.ts @@ -75,14 +75,21 @@ export interface SerializeOpts { useAbsoluteURL?: boolean; omitFields?: [typeof BaseDef]; omitQueryFields?: boolean; - // How much of the linked graph rides along in `included[]`. 'all' (the - // default) serializes every resident link target — the read/copy shape. - // 'local' serializes only local (unsaved, lid-bearing) targets — the write - // shape, where `included` is a co-creation manifest and saved targets are - // reference-only. 'none' serializes no link targets at all. A skipped - // target contributes its relationship entry only, and its own linked graph - // is not traversed — which is the point: a new card linking into a large - // saved graph serializes none of that graph on save. + // How much of the linked graph rides along in `included[]`. 'all' — the + // default, and what a direct `serializeCard` call gets — serializes every + // resident link target. 'local' serializes only the local (unsaved, + // `lid`-bearing) targets reachable without crossing an excluded one: the + // write shape, where `included` is a co-creation manifest and saved targets + // are reference-only. 'none' serializes no link targets at all. + // + // An excluded target contributes its relationship entry only, and its own + // linked graph is not traversed — which is the point: a new card linking + // into a large saved graph serializes none of that graph on save. Under + // 'local' that also bounds what a write co-creates to the local targets it + // reaches directly. An unsaved card hanging off a saved link is not + // co-created, because nothing the write persists could reference it: the + // saved link's own file is not rewritten, and the write's response names + // only the primary card, so its id would never reach the client. includedScope?: 'all' | 'local' | 'none'; maybeRelativeReference?: (possibleReference: string) => string; overrides?: Map; From baefce2b5c47f7a5b0d4f88fe743390896d12f1d Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 17 Sep 2026 10:09:39 +0200 Subject: [PATCH 7/7] Pin the primary resource against the scope that produced it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every caller of the card service's serialize but the write path now asks for no linked resources, which is safe only because an excluded target emits the same reference relationship a full walk's tail does. Assert that equivalence directly — the primary resource is deep-equal under all three scopes — so a later edit to one branch cannot drift from the other unnoticed. Fold the card service's own case into the same test: the write path's stated intent maps onto the 'local' scope, and nothing else about the document changes. Co-Authored-By: Claude Opus 5 (1M context) --- .../host/tests/integration/store-test.gts | 74 ++++++++----------- 1 file changed, 30 insertions(+), 44 deletions(-) diff --git a/packages/host/tests/integration/store-test.gts b/packages/host/tests/integration/store-test.gts index f75bdb65707..40747ba3582 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -2240,43 +2240,10 @@ module('Integration | Store', function (hooks) { } }); - test('withLocalResourcesIncluded serialization inlines only unsaved (lid) links, not resident saved ones', async function (assert) { - let cardService = getService('card-service') as any; - - // A saved link the store has resident: the realm already holds it and - // discards it from `included` on write, so it should not be inlined. - let saved = new PersonDef({ name: 'Saved' }); - await (storeService as any).persistAndUpdate(saved); - assert.ok((saved as any).id, 'the linked card is saved'); - - // An unsaved link created alongside this card: the realm creates it from - // its `lid` in the same request, so it must ride along in `included`. - let unsaved = new PersonDef({ name: 'Unsaved' }); - - let instance = new PersonDef({ name: 'Consumer' }); - (instance as any).bestFriend = saved; - (instance as any).friends = [unsaved]; - - let doc = await cardService.serializeCard(instance, { - useAbsoluteURL: true, - withLocalResourcesIncluded: true, - }); - let included = (doc.included ?? []) as any[]; - - assert.notOk( - included.find((resource) => resource.id === (saved as any).id), - 'a resident saved link is not inlined into included', - ); - assert.ok( - included.find((resource) => resource.lid === (unsaved as any)[localId]), - 'an unsaved link is inlined into included by lid so the realm can co-create it', - ); - }); - test('includedScope controls which link targets the serializer builds', async function (assert) { - // The write path's hot-path saving lives in the serializer itself: an - // excluded target contributes only its relationship entry, and neither it - // nor its own linked graph is serialized. + // The write path's saving lives in the serializer itself: an excluded + // target contributes only its relationship entry, and neither it nor its + // own linked graph is serialized. let cardService = getService('card-service') as any; let api = await cardService.getAPI(); @@ -2316,10 +2283,6 @@ module('Integration | Store', function (hooks) { ), "'local' serializes local targets — the write's co-creation manifest", ); - assert.ok( - (local.data.relationships?.bestFriend as any)?.links?.self, - "'local' still emits the saved target's reference relationship", - ); let none = api.serializeCard(instance, { useAbsoluteURL: true, @@ -2330,10 +2293,33 @@ module('Integration | Store', function (hooks) { undefined, "'none' builds no included at all", ); - assert.strictEqual( - (none.data.relationships?.['friends.0'] as any)?.data?.lid, - (unsaved as any)[localId], - "'none' still emits the local target's lid relationship", + + // What makes the narrower scopes safe wherever they replace 'all': the + // primary resource is identical under every scope, because an excluded + // target emits the same reference relationship the full walk's tail does + // — `links.self` + `id` for a saved target, `lid` for a local one. Only + // `included` differs. + assert.deepEqual( + local.data, + all.data, + "'local' leaves the primary resource identical to 'all'", + ); + assert.deepEqual( + none.data, + all.data, + "'none' leaves the primary resource identical to 'all'", + ); + + // The one write-path caller asks for the write shape by intent, and + // CardService maps that intent onto the 'local' scope. + let viaCardService = await cardService.serializeCard(instance, { + useAbsoluteURL: true, + withLocalResourcesIncluded: true, + }); + assert.deepEqual( + viaCardService, + local, + "withLocalResourcesIncluded serializes at the 'local' scope", ); });