diff --git a/packages/host/app/services/card-service.ts b/packages/host/app/services/card-service.ts index ce3ea6bc9e2..66c69152a7b 100644 --- a/packages/host/app/services/card-service.ts +++ b/packages/host/app/services/card-service.ts @@ -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); @@ -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', + ), + ]; + for (let resource of resources) { + this.validateSizeLimit( + url, + JSON.stringify({ data: resource }, null, 2), + 'card', + ); + } + } + private validateSizeLimit( url: string, content: string, diff --git a/packages/host/tests/integration/store-test.gts b/packages/host/tests/integration/store-test.gts index 797e8d57f39..116ffd9a1ef 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -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', + ); + } 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 —