Send only unsaved links in a new card's included, not resident saved ones - #6161
Conversation
…d ones 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GsYGsuCqHJz9GAti4jheG
lukemelia
left a comment
There was a problem hiding this comment.
[Claude Code 🤖] Review focused on enumerating the consumers of withIncluded serialization and on pinning the new test to the filter; the base PR of this stack was reviewed separately.
Bottom line: no blocking issues. withIncluded: true has exactly one call site (persistAndUpdate in store.ts), which serializes both creates and PATCHes — and the realm's PATCH handler retains and co-creates lid members with the same machinery as POST, so the filter is semantics-preserving on both verbs. The new test fails with the filter hunk removed ("a resident saved link is not inlined") — it pins the change.
- Extract the lid-retention predicate to a single home — inline comment on
serializeCardincard-service.ts. Non-blocking.
Contract note: after this change the write document reads coherently — included on a write is a co-creation manifest (lid-bearing resources), references stay in relationships, and the client sends exactly what the realm retains. The realm's silent discard of non-lid members becomes pure backward-compatibility tolerance; a follow-up could warn on (or eventually reject) them, so the next client-side drift fails loudly instead of re-inflating payloads unnoticed.
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…-included-to-lids # Conflicts: # packages/host/tests/integration/store-test.gts
habdelra
left a comment
There was a problem hiding this comment.
[Claude Code 🤖] Reviewed the second commit only (the diff against its stacked base): the includedScope skip in LinksTo/LinksToMany, the CardService default flip, and the new tests. I traced the emitted relationship shapes and the realm's own create/patch retention loops against the new scopes; I did not run the host suite (CI is still queued).
No blocking issues. The retention rule the client now applies matches what the realm keeps — serializeCardResource emits id and lid mutually exclusively, so the realm's typeof resource.lid !== 'string' skip discards exactly the saved members this change stops sending, and the skip branch's relationship output is identical to the full branch's for both the id and lid spellings.
Recommendations:
- Narrow
CardService#serializeCard's parameter toOmit<SerializeOpts, 'includedScope'>— the spread silently discards a caller-supplied scope. See the thread oncard-service.ts. - Confirm the intent of no longer co-creating unsaved cards reachable only through a saved link, and say it in the
includedScopedoc comment. See the thread oncard-serialization.ts. - Collapse the now-threefold reference-relationship spelling (and the duplicated skip predicate) behind two helpers. See the thread on
card-api.gts. - Drop the first new test as subsumed, and pin the
'all'vs'none'relationship equivalence instead — that equivalence is what makes the default flip safe for the patch / copy / file-def / AI-tool callers, and nothing asserts it. See the thread onstore-test.gts.
The PR description still describes the earlier post-serialization filter and calls skipping the serializer walk "a possible follow-up in the base card API" — the merged shape of this branch does that skip. Worth refreshing before it becomes the merge commit message.
Adjacent, out of scope: the PATCH path's included.length === 0 && isEqual(primaryResource, original) short-circuit in realm.ts was previously near-unreachable from the host, because any resident saved link made included non-empty. With this change it starts firing on genuinely no-op saves, which is the behavior it was written for — worth knowing it is now live rather than dormant.
Generated by Claude Code
Preview deploymentsHost Test Results 1 files ±0 1 suites ±0 2h 20m 28s ⏱️ -54s Results for commit 0209738. ± Comparison against earlier commit 95da05c. Realm Server Test Results 1 files ± 0 235 suites ±0 1h 20m 19s ⏱️ - 4m 28s Results for commit 0209738. ± Comparison against earlier commit 95da05c. |
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) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
|
[Claude Code 🤖] Pushed
On the adjacent note: agreed, and it survives the card-operations refactor — the no-op short-circuit is now |
Problem
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 bylid), and discards the already-saved ones. So every new-card save from a busy tab serialises, transmits, and then has the server throw away hundreds of KB of resident linked cards.Fix
Push the realm's write-retention rule into the serializer, as an
includedScopeoption onSerializeOpts:'all'— every resident link target rides along inincluded[]. The default, and what a directserializeCardcall gets.'local'— only the local (unsaved,lid-bearing) targets the card links to directly. The write shape:includedis a co-creation manifest and saved targets are reference-only.'none'— no link targets at all.An excluded target isn't serialized and its own graph isn't traversed, so the cost is skipped rather than filtered away afterwards: a new card linking into a large saved graph now serialises none of that graph on save. Its relationship entry is unchanged —
links.self+idfor a saved target,lidfor a local one, which is exactly what the full walk's tail emits.CardService#serializeCardmaps the caller's intent onto a scope. The new-card save asks forwithLocalResourcesIncludedand gets'local'; every other caller gets'none', which is the document they already received, since the service deletedincludedfrom it regardless.The document sent on a new-card save now equals what the realm stores.
What a write co-creates
Under
'local', an unsaved card reachable only through a saved link is no longer co-created. Nothing the write persists could reference it: the saved link's own file isn't rewritten, and the write's response names only the primary card, so the co-created card's id would never reach the client — the realm would write an orphan file.Relationship to #6160
#6160 (merged) fixed the "Card size exceeds maximum" failure this inflation caused, by measuring the size limit per-file. This change removes the wasted payload itself. They are separate because this one changes the serialised document shape, where #6160 only changed how the limit is measured.
Test
Integration | Storegains a case covering all three scopes: which targets reachincludedunder each, thatwithLocalResourcesIncludedmaps onto'local', and — the assertion with the widest blast radius — that the primary resource is deep-equal under all three scopes. That equivalence is what makes'none'safe as the default for the patch, copy, file-def and AI-tool callers, and it now fails loudly if either branch drifts.Verification
ember-tsc: clean locallymainmerged forward rather than the base the earlier green run used🤖 Generated with Claude Code