Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 66 additions & 59 deletions packages/base/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CardT extends LinkableDefConstructor> implements Field<CardT> {
readonly fieldType = 'linksTo';
private cardThunk: () => CardT;
Expand Down Expand Up @@ -1522,24 +1551,22 @@ class LinksTo<CardT extends LinkableDefConstructor> implements Field<CardT> {
`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,
),
},
};
}
Expand All @@ -1556,21 +1583,11 @@ class LinksTo<CardT extends LinkableDefConstructor> implements Field<CardT> {
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 (
Expand Down Expand Up @@ -2094,19 +2111,19 @@ class LinksToMany<FieldT extends LinkableDefConstructor> 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;
}

Expand Down Expand Up @@ -2134,21 +2151,11 @@ class LinksToMany<FieldT extends LinkableDefConstructor> 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 };
Expand Down
16 changes: 16 additions & 0 deletions packages/base/card-serialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, typeof BaseDef>;
}
Expand Down
18 changes: 16 additions & 2 deletions packages/host/app/services/card-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SerializeOpts, 'includedScope'> & {
withLocalResourcesIncluded?: true;
},
): Promise<LooseSingleCardDocument> {
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;
Expand Down
2 changes: 1 addition & 1 deletion packages/host/app/services/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
83 changes: 83 additions & 0 deletions packages/host/tests/integration/store-test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading