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
48 changes: 47 additions & 1 deletion packages/host/app/services/card-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ export default class CardService extends Service {
typeof requestInit.body === 'string'
? requestInit.body
: JSON.stringify(requestInit.body, null, 2);
this.validateSizeLimit(urlString, jsonString, 'card');
this.validateCardWriteSize(urlString, jsonString);
}

let response = await this.network.authedFetch(url, requestInit);
Expand Down Expand Up @@ -429,6 +429,52 @@ export default class CardService extends Service {
return response.json();
}

// The 512 KB ceiling is a per-card-*file* limit, but a card write POSTs a
// document whose `included[]` inlines every linked card the tab happens to
// have resident — and the realm discards every included member that has no
// `lid` (it keeps only the primary card plus any brand-new, unsaved links it
// is being asked to create in the same request; see the realm's card POST
// handler). Measuring the concatenated body would therefore fail a tiny card
// because of cards it merely links to, so validate each resource that will
// actually become a file on its own — mirroring the realm's own per-file
// `assertWriteSize` — rather than the whole request body.
//
// The client is deliberately the stricter of the two on one edge: the realm
// additionally skips (without error) any resource whose `meta.realmURL`
// names a different realm, so a foreign-realm `lid` side-load is measured
// here but silently dropped there. That shape is a request defect the realm
// currently swallows — a co-create the write will never perform — so
// refusing it client-side over size is acceptable, and matching the skip
// would mean reproducing a silent-drop behavior rather than a contract.
private validateCardWriteSize(url: string, body: string) {
let doc: LooseSingleCardDocument | undefined;
try {
doc = JSON.parse(body);
} catch {
// Not a JSON document we can split into resources; fall back to holding
// the whole body to the limit rather than letting an unmeasured write by.
}
if (!doc || typeof doc !== 'object' || !doc.data) {
this.validateSizeLimit(url, body, 'card');
return;
}
// The primary card is always written; an included member is written only
// when it carries a `lid` (an unsaved link created alongside this card).
let resources = [
doc.data,
...(doc.included ?? []).filter(
(resource) => typeof (resource as { lid?: unknown }).lid === 'string',
),
Comment on lines +461 to +467

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] lid is only half of what the realm skips — it also drops any resource whose meta.realmURL isn't this realm, on both write paths (realm.ts, the card POST handler's resource.meta.realmURL && ensureTrailingSlash(...) !== this.url test in the same loop as the lid test; and namesForeignRealm in card-operations/executors.ts, in both the create and the update side-load loops). So a lid-bearing side-load naming another realm is measured here and discarded there — the client can refuse a write the realm would have accepted.

Narrow in practice, so non-blocking, but the comment above states this mirrors the realm's per-file assertWriteSize, and the next person will build on that. Either add the realm check to the filter, or say in the comment that the client is deliberately the stricter of the two.

Regression class: new logic in this PR, not a pre-existing bug.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Went with the documented-stricter option, in a59c285: the comment now states the foreign-realm lid edge explicitly — measured here, silently skipped (never written) by the realm — and why the client does not reproduce that skip: it is a request defect the realm swallows, not a contract. Matching it would mean faithfully reproducing a silent drop.

];
for (let resource of resources) {
this.validateSizeLimit(
url,
JSON.stringify({ data: resource }, null, 2),
'card',
);
}
}

private validateSizeLimit(
url: string,
content: string,
Expand Down
70 changes: 70 additions & 0 deletions packages/host/tests/integration/store-test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -2201,6 +2201,76 @@ module('Integration | Store', function (hooks) {
);
});

test('a small new card saves even when its resident linked graph would overflow the size limit if inlined', async function (assert) {
// Regression: the client size check used to measure the whole POST body,
// `included[]` and all. A new card that links to already-saved cards the
// tab has loaded serialises those cards into `included` — which the realm
// discards on write — so a tiny card could fail "Card size exceeds maximum"
// purely because of how much of the realm the tab happened to have resident.
let environmentService = getService('environment-service') as any;
let originalMaxSize = environmentService.cardSizeLimitBytes;
try {
// A saved, resident linked card large enough that inlining it into
// `included` would blow the limit, while the new card's own document is
// tiny. Saved under the realm's default ceiling before the client limit
// is lowered; the realm keeps its own (unchanged) ceiling throughout.
let bigFriend = new PersonDef({ name: 'x'.repeat(6000) });
let savedFriend = await (storeService as any).persistAndUpdate(bigFriend);
assert.true(isCardInstance(savedFriend), 'the large linked card saved');

environmentService.cardSizeLimitBytes = 2500;

let instance = new PersonDef({ name: 'Small' });
(instance as any).bestFriend = bigFriend;

let result = await (storeService as any).persistAndUpdate(instance);
assert.true(
isCardInstance(result),
"a new card whose own document is under the limit saves regardless of how large a graph it links to — the check measures what the realm stores, not the tab's loaded `included`",
);
let cardPath = `${(instance as any).id.substring(
testRealmURL.length,
)}.json`;
assert.ok(
await testRealmAdapter.openFile(cardPath),
'the realm holds the created card',
);
Comment on lines +2226 to +2237

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] This pins only the permissive half of the new filter. Replace the filter body with () => false — or drop the doc.included term from resources entirely — and this test still passes, because a saved link has no lid either way. Nothing in the suite fails if the lid-bearing side-load stops being measured, which is the case where the client check has to keep holding: an oversized brand-new link created alongside the primary becomes its own file on the realm and gets a 413 there.

Ask: add the counterpart — a small new card whose unsaved link is over the (lowered) ceiling, asserting persistAndUpdate returns a card error naming the size limit. That's what makes the filter's predicate load-bearing in CI rather than just in the comment.

Non-blocking on the fix itself; blocking on the PR's test claim, since as written the new test can't distinguish this fix from having deleted the included measurement outright.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Counterpart test added in a59c285: a card saved alongside an oversized unsaved link is refused, asserting the error names the size limit — the case where dropping the lid measurement would sail through client-side. Ran the mutation you described mentally rather than locally (the author opted to let CI arbitrate this round); if the new test comes back green on CI alongside the existing one, both halves of the filter are pinned.

} finally {
environmentService.cardSizeLimitBytes = originalMaxSize;
}
});

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
// keep being measured. If the check stopped measuring included members
// entirely, this save would sail through client-side and 413 on the
// realm instead.
let environmentService = getService('environment-service') as any;
let originalMaxSize = environmentService.cardSizeLimitBytes;
try {
environmentService.cardSizeLimitBytes = 2500;

let bigUnsaved = new PersonDef({ name: 'x'.repeat(6000) });
let instance = new PersonDef({ name: 'Small' });
(instance as any).bestFriend = bigUnsaved;

let result = await (storeService as any).persistAndUpdate(instance);
assert.false(
isCardInstance(result),
'the save is refused: the unsaved link is co-created as its own file and is over the ceiling',
);
assert.ok(
String((result as any)?.message).includes(
'exceeds maximum allowed size',
),
`the error names the size limit (got: ${(result as any)?.message})`,
);
} finally {
environmentService.cardSizeLimitBytes = originalMaxSize;
}
});

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 —
Expand Down
Loading