diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 3a3693f6ece..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,24 +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])) { + // 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. + if ( + visited.has(value.id) || + visited.has((value as CardDef)[localId]) || + isExcludedByIncludedScope(value as CardDef, opts?.includedScope) + ) { return { relationships: { - [this.name]: { - data: { type: relationshipType, lid: (value as CardDef)[localId] }, - }, + [this.name]: referenceRelationship( + value as CardDef, + relationshipType, + opts, + ), }, }; } @@ -1556,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 ( @@ -2094,19 +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] }, - }; + // 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 ( + visited.has(value.id) || + visited.has((value as CardDef)[localId]) || + isExcludedByIncludedScope(value as CardDef, opts?.includedScope) + ) { + relationships[`${this.name}.${i}`] = referenceRelationship( + value as CardDef, + relationshipType, + opts, + ); return; } @@ -2134,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 }; diff --git a/packages/base/card-serialization.ts b/packages/base/card-serialization.ts index 6dd656ab75d..60e1e2f40e9 100644 --- a/packages/base/card-serialization.ts +++ b/packages/base/card-serialization.ts @@ -75,6 +75,22 @@ export interface SerializeOpts { useAbsoluteURL?: boolean; omitFields?: [typeof BaseDef]; omitQueryFields?: boolean; + // 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; } diff --git a/packages/host/app/services/card-service.ts b/packages/host/app/services/card-service.ts index 66c69152a7b..b1c9df874b3 100644 --- a/packages/host/app/services/card-service.ts +++ b/packages/host/app/services/card-service.ts @@ -233,18 +233,32 @@ 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 & { withIncluded?: true }, + opts?: Omit & { + withLocalResourcesIncluded?: true; + }, ): Promise { let api = await this.getAPI(); 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?.withIncluded) { + 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; } return serialized; 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 116ffd9a1ef..40747ba3582 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -2240,6 +2240,89 @@ module('Integration | Store', function (hooks) { } }); + test('includedScope controls which link targets the serializer builds', async function (assert) { + // 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(); + + 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", + ); + + let none = api.serializeCard(instance, { + useAbsoluteURL: true, + includedScope: 'none', + }); + assert.strictEqual( + none.included, + undefined, + "'none' builds no included at all", + ); + + // 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", + ); + }); + test('an oversized unsaved link created alongside a card still fails the size check', async function (assert) { // The counterpart that makes the per-resource filter load-bearing: a // `lid`-bearing side-load becomes its own file on the realm, so it must