From e342b682e69fc7c2ce086c9a992387eb450ded05 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Fri, 17 Jul 2026 10:17:21 -0400 Subject: [PATCH 1/4] Preserve compound field data when the child definition is unresolvable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The definition-driven file serializer walks a card's stored Definition to normalize each field on every server-side instance write (POST, PATCH, /_atomic). The definition store only carries exported classes, so a compound field typed with an unexported FieldDef has a fieldOf/ancestorOf code ref whose child definition can't be looked up. The serializer treated that as "no fields": containsMany entries serialized to {}, contains values were dropped, and relationships pathing through such a field were dropped — any edit or sync of such a card silently wiped its data on disk. Unresolvable-child values now pass through verbatim. The PATCH handler also merged patches over the index's pristine_doc. The index is downstream of the file and can lag it, so a merge over stale index state silently reverts fields the index hasn't caught up on. The merge base is now the stored source file, read inside the same per-realm write lock as the write. Co-Authored-By: Claude Fable 5 --- .../tests/atomic-endpoints-test.ts | 128 +++++++++ .../realm-server/tests/card-endpoints-test.ts | 248 ++++++++++++++++++ packages/runtime-common/file-serializer.ts | 33 ++- packages/runtime-common/realm.ts | 46 ++-- 4 files changed, 434 insertions(+), 21 deletions(-) diff --git a/packages/realm-server/tests/atomic-endpoints-test.ts b/packages/realm-server/tests/atomic-endpoints-test.ts index 3662a17326a..8a36bb1f009 100644 --- a/packages/realm-server/tests/atomic-endpoints-test.ts +++ b/packages/realm-server/tests/atomic-endpoints-test.ts @@ -909,6 +909,134 @@ module(basename(import.meta.filename), function () { testRealmAdapter.write = originalWrite; } }); + + test('waitForIndex write preserves compound field values backed by an unexported FieldDef', async function (assert) { + let logSource = ` + import { + contains, + containsMany, + field, + CardDef, + FieldDef, + } from "@cardstack/base/card-api"; + import StringField from "@cardstack/base/string"; + import DatetimeField from "@cardstack/base/datetime"; + + class Entry extends FieldDef { + @field kind = contains(StringField); + @field at = contains(DatetimeField); + @field headline = contains(StringField); + } + + export class Log extends CardDef { + @field logTitle = contains(StringField); + @field entries = containsMany(Entry); + } + `.trim(); + let moduleDoc = { + 'atomic:operations': [ + { + op: 'add', + href: 'log.gts', + data: { + type: 'source', + attributes: { + content: logSource, + }, + meta: {}, + }, + }, + ], + }; + await request + .post('/_atomic?waitForIndex=true') + .set('Accept', SupportedMimeType.JSONAPI) + .set( + 'Authorization', + `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`, + ) + .send(JSON.stringify(moduleDoc)) + .expect(201); + + // Instance pushed as a raw `source` resource — the shape realm + // sync tools send — so the handler re-serializes it through + // fileSerialization before writing to disk. + let instanceContent = JSON.stringify( + { + data: { + type: 'card', + attributes: { + logTitle: 'Probe', + entries: [ + { + kind: 'phase', + at: '2026-07-17T02:45:29.259Z', + headline: 'probe entry', + }, + ], + }, + meta: { + adoptsFrom: { + module: '../log', + name: 'Log', + }, + }, + }, + }, + null, + 2, + ); + let instanceDoc = { + 'atomic:operations': [ + { + op: 'add', + href: 'Log/log-1.json', + data: { + type: 'source', + attributes: { + content: instanceContent, + }, + meta: {}, + }, + }, + ], + }; + let response = await request + .post('/_atomic?waitForIndex=true') + .set('Accept', SupportedMimeType.JSONAPI) + .set( + 'Authorization', + `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`, + ) + .send(JSON.stringify(instanceDoc)); + assert.strictEqual( + response.status, + 201, + `expected 201, got ${response.status}: ${JSON.stringify(response.body)}`, + ); + + let sourceResponse = await request + .get('/Log/log-1.json') + .set('Accept', SupportedMimeType.CardSource); + assert.strictEqual(sourceResponse.status, 200); + let storedDoc = JSON.parse(sourceResponse.text); + assert.deepEqual( + storedDoc.data.attributes?.entries, + [ + { + kind: 'phase', + at: '2026-07-17T02:45:29.259Z', + headline: 'probe entry', + }, + ], + 'containsMany compound entries survive the waitForIndex write', + ); + assert.strictEqual( + storedDoc.data.attributes?.logTitle, + 'Probe', + 'top-level attribute survives the waitForIndex write', + ); + }); }); module('error handling', function (hooks) { setupPermissionedRealmCached(hooks, { diff --git a/packages/realm-server/tests/card-endpoints-test.ts b/packages/realm-server/tests/card-endpoints-test.ts index 05b094d0f91..3b546731a6c 100644 --- a/packages/realm-server/tests/card-endpoints-test.ts +++ b/packages/realm-server/tests/card-endpoints-test.ts @@ -3948,6 +3948,254 @@ module(basename(import.meta.filename), function () { assert.strictEqual(response.status, 200, 'HTTP 200 status'); }); }); + + module( + 'compound fields backed by an unexported FieldDef', + function (hooks) { + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + fileSystem: { + 'log.gts': ` + import { + contains, + containsMany, + field, + linksTo, + CardDef, + FieldDef, + } from "@cardstack/base/card-api"; + import StringField from "@cardstack/base/string"; + import DatetimeField from "@cardstack/base/datetime"; + + class Entry extends FieldDef { + @field kind = contains(StringField); + @field at = contains(DatetimeField); + @field headline = contains(StringField); + @field author = linksTo(() => Author); + } + + export class Author extends CardDef { + @field name = contains(StringField); + } + + export class Log extends CardDef { + @field logTitle = contains(StringField); + @field entries = containsMany(Entry); + @field latest = contains(Entry); + @field owner = linksTo(() => Author); + } + `, + 'author-1.json': { + data: { + attributes: { + name: 'Probe Author', + }, + meta: { + adoptsFrom: { + module: rri('./log'), + name: 'Author', + }, + }, + }, + }, + 'log-1.json': { + data: { + attributes: { + logTitle: 'Probe', + entries: [ + { + kind: 'phase', + at: '2026-07-17T02:45:29.259Z', + headline: 'probe entry', + }, + ], + latest: { + kind: 'wrap-up', + at: '2026-07-17T03:00:00.000Z', + headline: 'latest entry', + }, + }, + relationships: { + // `owner` resolves through the exported Log definition; + // `entries.0.author` paths through the unexported Entry. + // Having both ensures the serializer's relationship pass + // produces a non-empty result, so a dropped nested + // relationship can't hide behind the whole original + // relationships object being carried over unprocessed. + owner: { + links: { + self: './author-1', + }, + }, + 'entries.0.author': { + links: { + self: './author-1', + }, + }, + }, + meta: { + adoptsFrom: { + module: rri('./log'), + name: 'Log', + }, + }, + }, + }, + }, + onRealmSetup, + }); + + test('PATCH preserves compound field values on disk', async function (assert) { + let response = await request + .patch('/log-1') + .send({ + data: { + type: 'card', + attributes: { + logTitle: 'Probe (edited)', + entries: [ + { + kind: 'phase', + at: '2026-07-17T02:45:29.259Z', + headline: 'probe entry', + }, + ], + latest: { + kind: 'wrap-up', + at: '2026-07-17T03:00:00.000Z', + headline: 'latest entry', + }, + }, + meta: { + adoptsFrom: { + module: rri('./log'), + name: 'Log', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json'); + + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + + let cardFile = join( + dir.name, + 'realm_server_1', + 'test', + 'log-1.json', + ); + assert.ok(existsSync(cardFile), 'card json exists on disk'); + let card = readJSONSync(cardFile); + assert.deepEqual( + card.data.attributes?.entries, + [ + { + kind: 'phase', + at: '2026-07-17T02:45:29.259Z', + headline: 'probe entry', + }, + ], + 'containsMany compound entries persisted to disk', + ); + assert.deepEqual( + card.data.attributes?.latest, + { + kind: 'wrap-up', + at: '2026-07-17T03:00:00.000Z', + headline: 'latest entry', + }, + 'contains compound value persisted to disk', + ); + assert.deepEqual( + card.data.relationships?.['entries.0.author'], + { + links: { + self: './author-1', + }, + }, + 'relationship nested in compound entry persisted to disk', + ); + assert.deepEqual( + card.data.relationships?.owner, + { + links: { + self: './author-1', + }, + }, + 'relationship on the card itself persisted to disk', + ); + assert.strictEqual( + card.data.attributes?.logTitle, + 'Probe (edited)', + 'patched top-level attribute persisted to disk', + ); + }); + + test('PATCH merges against the stored file even when the index lags it', async function (assert) { + let cardFile = join( + dir.name, + 'realm_server_1', + 'test', + 'log-1.json', + ); + // Edit the stored file directly — no realm write, no file + // watcher in this fixture — so the index still reflects the + // original content. A PATCH of an unrelated field must keep + // this edit: the merge base is the stored file, and using the + // (lagging) index instead would silently revert it. + let onDisk = readJSONSync(cardFile); + onDisk.data.attributes.cardInfo = { notes: 'edited on disk' }; + writeFileSync(cardFile, JSON.stringify(onDisk, null, 2)); + + let response = await request + .patch('/log-1') + .send({ + data: { + type: 'card', + attributes: { + logTitle: 'Patched after disk edit', + }, + meta: { + adoptsFrom: { + module: rri('./log'), + name: 'Log', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json'); + + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + + let card = readJSONSync(cardFile); + assert.strictEqual( + card.data.attributes?.logTitle, + 'Patched after disk edit', + 'patched attribute persisted to disk', + ); + assert.strictEqual( + card.data.attributes?.cardInfo?.notes, + 'edited on disk', + 'file-only change survives a PATCH of an unrelated field', + ); + assert.deepEqual( + card.data.attributes?.entries, + [ + { + kind: 'phase', + at: '2026-07-17T02:45:29.259Z', + headline: 'probe entry', + }, + ], + 'compound entries survive a PATCH of an unrelated field', + ); + }); + }, + ); }); module('card DELETE request', function (_hooks) { diff --git a/packages/runtime-common/file-serializer.ts b/packages/runtime-common/file-serializer.ts index 3d803ba134b..c37c3ee3fb8 100644 --- a/packages/runtime-common/file-serializer.ts +++ b/packages/runtime-common/file-serializer.ts @@ -142,6 +142,14 @@ export default async function serialize({ // `fieldOrCard` when fetching the child definition; otherwise nested // fields on the polymorphic subtype would be missing from the child // definition lookup and the values would silently drop. +// +// A compound field's child definition is not always resolvable: when +// the field's declared type is an unexported class, its `fieldOrCard` +// is a `fieldOf`/`ancestorOf` ref and the definition store only holds +// entries for exports. Values under such a field can't be walked +// schema-aware, so they pass through verbatim — this serializer's +// output overwrites the stored source, and unnormalized inner code +// refs are recoverable where dropped field data is not. async function processAttributes({ attributes, definition, @@ -220,7 +228,7 @@ async function processAttributes({ virtualNetwork, ); if (!childDef) { - return {}; + return item; } return await processAttributes({ attributes: item, @@ -252,6 +260,7 @@ async function processAttributes({ virtualNetwork, ); if (!childDef) { + result[fieldName] = fieldValue; continue; } result[fieldName] = await processAttributes({ @@ -382,6 +391,18 @@ async function processRelationships({ virtualNetwork, ); + if (fieldDefinition === 'unresolvable') { + // The path crosses a compound field whose child definition can't + // be resolved (an unexported class). We can't tell what kind of + // relationship this is, but we know the card declares the holder + // field — preserve the relationship in normalized form rather + // than dropping it. + result[relationshipKey] = Array.isArray(value) + ? value.map((entry) => normalizeRelationship(entry)) + : normalizeRelationship(value); + continue; + } + if (!fieldDefinition || fieldDefinition.isComputed) { continue; } @@ -420,6 +441,12 @@ async function processRelationships({ // `meta.fields` sub-tree at the root; we walk it in parallel with the // path segments so polymorphic per-segment overrides drive the // definition lookup. +// +// Returns `undefined` when a segment does not exist on its definition +// (or descends past a primitive) — the path names nothing the card +// declares. Returns `'unresolvable'` when a segment exists but its +// child definition can't be resolved (an unexported class), so the +// caller can preserve the value rather than treat it as unknown. async function resolveDottedFieldDef( rootDefinition: Definition, dottedPath: string, @@ -427,7 +454,7 @@ async function resolveDottedFieldDef( relativeTo: URL, definitionLookup: DefinitionLookup, virtualNetwork: VirtualNetwork, -): Promise { +): Promise { let segments = dottedPath.split('.'); let current: Pick = rootDefinition; let currentMeta = metaFields; @@ -454,7 +481,7 @@ async function resolveDottedFieldDef( virtualNetwork, ); if (!next) { - return undefined; + return 'unresolvable'; } current = next; currentMeta = isMeta(metaForSeg) ? metaForSeg.fields : undefined; diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 562fb3457c7..27df64bd7c8 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -1938,7 +1938,7 @@ export class Realm { // // HTTP route handlers in this file that need their READ to be inside the // same critical section as the write (the `/_atomic` precheck and - // `patchCardInstance`'s indexEntry read) take the lock themselves at the + // `patchCardInstance`'s existing-file read) take the lock themselves at the // handler boundary and invoke `_batchWriteUnlocked` directly — re-entering // through the public methods would deadlock (a second // `pg_advisory_xact_lock` on the same key would block on its own pinned @@ -4757,32 +4757,42 @@ export class Realm { } } - // CS-11125: serialize concurrent PATCHes against the same realm so the - // indexEntry read, merge, and write are all inside one critical + // Serialize concurrent PATCHes against the same realm so the + // existing-file read, merge, and write are all inside one critical // section. Without the lock, two replicas could both read the same - // `original` from boxel_index, compute independent merges, and the - // second writer's merge would silently lose the first's changes. - // writeMany below uses the default `waitForIndex: true`, so once the - // lock releases the index reflects the just-committed state and the - // next waiter's `indexEntry` read sees it. + // `original`, compute independent merges, and the second writer's + // merge would silently lose the first's changes. // // Inside the lock we invoke `_batchWriteUnlocked` rather than the // public `writeMany` — re-entering the lock through the public method // would block on a different pinned pool connection. return await this.#dbAdapter.withWriteLock(this.url, async () => { let primarySerialization: LooseSingleCardDocument | undefined; - let indexEntry = await this.#realmIndexQueryEngine.instance(url, { - includeErrors: true, - }); - if (!indexEntry) { + // The merge base is the stored source file, not the index. The + // index is downstream of the file and can lag it — a backlogged or + // in-flight index job would hand us a stale `pristine_doc`, and + // merging the patch over stale state silently reverts every field + // the index hasn't caught up on. The file is written inside this + // same write lock, so it is always current. + let existingFile = await this.readFileAsText(`${localPath}.json`); + if (!existingFile) { return notFound(request, requestContext); } - let original = cloneDeep( - indexEntry.instance ?? { - type: 'card', - meta: { adoptsFrom: patch.meta.adoptsFrom }, - }, - ) as CardResource; + let original: CardResource; + try { + let existingDoc = JSON.parse(existingFile.content); + if (!isCardResource(existingDoc?.data)) { + throw new Error('stored file is not a card document'); + } + original = cloneDeep(existingDoc.data) as CardResource; + } catch (err: any) { + return systemError({ + requestContext, + message: `cannot apply patch, the existing file for ${instanceURL} is not a valid card document: ${err.message}`, + additionalError: err, + id: instanceURL, + }); + } original.meta ??= { adoptsFrom: patch.meta.adoptsFrom }; original.meta.adoptsFrom = original.meta.adoptsFrom ?? patch.meta.adoptsFrom; From c1b89704b0a0be5ed1a8a81acdd5ef149926f4df Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Fri, 17 Jul 2026 10:40:36 -0400 Subject: [PATCH 2/4] Adjust PATCH tests for the stored-file merge base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the stored file as the merge base, a PATCH writes only the fields the file and the patch specify — the index's schema-defaulted nulls are no longer materialized into the file. File-shape expectations drop the materialized cardInfo, and the no-op tests prime once so their assertions measure the canonical-form steady state. Co-Authored-By: Claude Fable 5 --- .../realm-server/tests/card-endpoints-test.ts | 47 ++++++++++++++++--- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/realm-server/tests/card-endpoints-test.ts b/packages/realm-server/tests/card-endpoints-test.ts index 3b546731a6c..872fa94c2f1 100644 --- a/packages/realm-server/tests/card-endpoints-test.ts +++ b/packages/realm-server/tests/card-endpoints-test.ts @@ -2457,6 +2457,8 @@ module(basename(import.meta.filename), function () { let cardFile = join(dir.name, 'realm_server_1', 'test', entry); assert.ok(existsSync(cardFile), 'card json exists'); let card = readJSONSync(cardFile); + // The stored file carries only the fields the file and the patch + // actually specify — unset fields are not materialized into it. assert.deepEqual( card, { @@ -2464,7 +2466,6 @@ module(basename(import.meta.filename), function () { type: 'card', attributes: { firstName: 'Van Gogh', - cardInfo, }, meta: { adoptsFrom: { @@ -2556,13 +2557,14 @@ module(basename(import.meta.filename), function () { let cardFile = join(dir.name, 'realm_server_1', 'test', entry); assert.ok(existsSync(cardFile), 'card json exists on disk'); let card = readJSONSync(cardFile); + // Only the values the patch specifies land on disk; unset nested + // fields (cardThumbnailURL) are not materialized into the file. assert.deepEqual( card.data.attributes?.cardInfo, { name: 'Mango Card', notes: 'a friendly dog', summary: 'good boy', - cardThumbnailURL: null, }, 'nested cardInfo values persisted to disk by file-serializer', ); @@ -2575,6 +2577,25 @@ module(basename(import.meta.filename), function () { 'test', 'person-1.json', ); + // A PATCH stores the file in canonical serialized form (e.g. the + // adoptsFrom module ref is written without its executable + // extension), so a first PATCH of a hand-authored fixture may + // rewrite it once. Prime with one PATCH so the no-op assertions + // below measure the steady state. + await request + .patch('/person-1') + .send({ + data: { + type: 'card', + meta: { + adoptsFrom: { + module: rri('./person'), + name: 'Person', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json'); let initialStat = statSync(cardFile); let initialResponse = await request @@ -2693,6 +2714,23 @@ module(basename(import.meta.filename), function () { }); test('no-op PATCH response carries an ETag matching the existing one', async function (assert) { + // Prime once so the stored file is in canonical serialized form; + // the no-op assertions below measure the steady state (see the + // no-op lastModified test). + await request + .patch('/person-1') + .send({ + data: { + type: 'card', + meta: { + adoptsFrom: { + module: rri('./person'), + name: 'Person', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json'); let initialResponse = await request .get('/person-1') .set('Accept', 'application/vnd.card+json'); @@ -2722,7 +2760,7 @@ module(basename(import.meta.filename), function () { ); }); - test('patches card when index entry is an error using pristine doc', async function (assert) { + test('patches card when index entry is an error', async function (assert) { let cardURL = `${testRealmHref}person-1`; let errorDoc = { message: 'render failed', @@ -2975,7 +3013,6 @@ module(basename(import.meta.filename), function () { type: 'card', attributes: { firstName: 'Paper', - cardInfo, }, relationships: { friend: { @@ -3511,7 +3548,6 @@ module(basename(import.meta.filename), function () { type: 'card', attributes: { firstName: 'Paper', - cardInfo, }, relationships: { friend: { @@ -3762,7 +3798,6 @@ module(basename(import.meta.filename), function () { type: 'card', attributes: { firstName: 'Paper', - cardInfo, }, relationships: { friend: { From 78f80435ab713b8119eb9372082217c75dfa9217 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Fri, 17 Jul 2026 11:04:19 -0400 Subject: [PATCH 3/4] Expand to-many data form under unresolvable fields to indexed link keys normalizeRelationship only derives links from a single-resource data object, so a JSON:API to-many `data: [...]` relationship under an unresolvable compound field was preserved as an empty object and its targets lost. Expand it into the indexed .N keys to-many relationships are stored under, one entry per resource identifier. Co-Authored-By: Claude Fable 5 --- .../realm-server/tests/card-endpoints-test.ts | 19 +++++++++++++++ packages/runtime-common/file-serializer.ts | 24 +++++++++++++++---- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/realm-server/tests/card-endpoints-test.ts b/packages/realm-server/tests/card-endpoints-test.ts index 872fa94c2f1..b6cd899a87b 100644 --- a/packages/realm-server/tests/card-endpoints-test.ts +++ b/packages/realm-server/tests/card-endpoints-test.ts @@ -4000,6 +4000,7 @@ module(basename(import.meta.filename), function () { containsMany, field, linksTo, + linksToMany, CardDef, FieldDef, } from "@cardstack/base/card-api"; @@ -4011,6 +4012,7 @@ module(basename(import.meta.filename), function () { @field at = contains(DatetimeField); @field headline = contains(StringField); @field author = linksTo(() => Author); + @field coauthors = linksToMany(() => Author); } export class Author extends CardDef { @@ -4105,6 +4107,14 @@ module(basename(import.meta.filename), function () { headline: 'latest entry', }, }, + relationships: { + // JSON:API to-many `data: [...]` form nested under + // the unexported compound field; must survive as + // indexed `.N` link keys on disk. + 'entries.0.coauthors': { + data: [{ id: './author-1', type: 'card' }], + }, + }, meta: { adoptsFrom: { module: rri('./log'), @@ -4154,6 +4164,15 @@ module(basename(import.meta.filename), function () { }, 'relationship nested in compound entry persisted to disk', ); + assert.deepEqual( + card.data.relationships?.['entries.0.coauthors.0'], + { + links: { + self: './author-1', + }, + }, + 'to-many data form nested in compound entry persisted as indexed link keys', + ); assert.deepEqual( card.data.relationships?.owner, { diff --git a/packages/runtime-common/file-serializer.ts b/packages/runtime-common/file-serializer.ts index c37c3ee3fb8..8231cb23d4e 100644 --- a/packages/runtime-common/file-serializer.ts +++ b/packages/runtime-common/file-serializer.ts @@ -395,11 +395,25 @@ async function processRelationships({ // The path crosses a compound field whose child definition can't // be resolved (an unexported class). We can't tell what kind of // relationship this is, but we know the card declares the holder - // field — preserve the relationship in normalized form rather - // than dropping it. - result[relationshipKey] = Array.isArray(value) - ? value.map((entry) => normalizeRelationship(entry)) - : normalizeRelationship(value); + // field — preserve the relationship rather than dropping it. + if (Array.isArray(value)) { + result[relationshipKey] = value.map((entry) => + normalizeRelationship(entry), + ); + } else if (Array.isArray(value.data)) { + // JSON:API to-many `data: [...]` form. `normalizeRelationship` + // only derives links from a single-resource `data`, so expand to + // the indexed keys to-many relationships are stored under, one + // entry per resource identifier, before normalizing each. + value.data.forEach((item, index) => { + result[`${relationshipKey}.${index}`] = normalizeRelationship({ + ...(value.meta ? { meta: value.meta } : {}), + data: item, + }); + }); + } else { + result[relationshipKey] = normalizeRelationship(value); + } continue; } From dce7f34de174fad0101fe280d5e1b249e07175f2 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Fri, 17 Jul 2026 20:55:11 -0400 Subject: [PATCH 4/4] Fan out declared to-many data form; drop dead merge fallbacks; align tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declared-linksToMany fan-out had the same flattening the unresolvable branch fixed: normalizeRelationship deletes array-valued data without deriving links, so entries with no relationship-level links lost their targets. Derive each indexed entry from its own resource identifier instead. The merge-base fallbacks for a missing meta/adoptsFrom are dead code behind the isCardResource guard, which requires both. A PATCH against a corrupt stored file now has a test pinning the fail-without- overwrite behavior. Host test expectations updated for the stored-file merge base: files keep their author's key order (the index's jsonb round-trip alphabetized them) and stay sparse. The linksToMany-removal test previously patched the same single pet its fixture started with — a semantic no-op that only wrote because the index form differed cosmetically; it now starts with two pets and removes one. Co-Authored-By: Claude Fable 5 --- .../host/tests/integration/realm-test.gts | 11 ++---- .../tools/check-correctness-test.gts | 4 +- .../realm-server/tests/card-endpoints-test.ts | 39 ++++++++++++++++++- .../tests/card-source-endpoints-test.ts | 1 - packages/runtime-common/file-serializer.ts | 27 +++++++++---- packages/runtime-common/realm.ts | 3 -- 6 files changed, 63 insertions(+), 22 deletions(-) diff --git a/packages/host/tests/integration/realm-test.gts b/packages/host/tests/integration/realm-test.gts index 4ba4d56d6f2..9ffc4f22909 100644 --- a/packages/host/tests/integration/realm-test.gts +++ b/packages/host/tests/integration/realm-test.gts @@ -988,11 +988,8 @@ module('Integration | realm', function (hooks) { data: { type: 'card', attributes: { - email: null, - posts: null, firstName: 'Van Gogh', lastName: 'Abdel-Rahman', - cardInfo, }, meta: { adoptsFrom: { @@ -1170,7 +1167,6 @@ module('Integration | realm', function (hooks) { ], sponsors: ['Burton'], posts: [], - cardInfo, }, meta: { adoptsFrom: { @@ -1234,6 +1230,9 @@ module('Integration | realm', function (hooks) { 'pets.0': { links: { self: `${testRealmURL}dir/van-gogh` }, }, + 'pets.1': { + links: { self: `${testRealmURL}dir/mango` }, + }, friend: { links: { self: `${testRealmURL}dir/friend` } }, }, meta: { @@ -1388,7 +1387,7 @@ module('Integration | realm', function (hooks) { { data: { type: 'card', - attributes: { firstName: 'Jackie', cardInfo }, + attributes: { firstName: 'Jackie' }, relationships: { 'pets.0': { links: { self: `./dir/van-gogh` } }, friend: { links: { self: `./dir/friend` } }, @@ -2331,7 +2330,6 @@ module('Integration | realm', function (hooks) { type: 'card', attributes: { firstName: 'Mango', - cardInfo, }, relationships: { owner: { @@ -2495,7 +2493,6 @@ module('Integration | realm', function (hooks) { model: 'C300', year: '2024', }, - cardInfo, }, meta: { adoptsFrom: { diff --git a/packages/host/tests/integration/tools/check-correctness-test.gts b/packages/host/tests/integration/tools/check-correctness-test.gts index 8b23293de9d..02579e1cc55 100644 --- a/packages/host/tests/integration/tools/check-correctness-test.gts +++ b/packages/host/tests/integration/tools/check-correctness-test.gts @@ -147,11 +147,11 @@ module('Integration | tools | check-correctness', function (hooks) { fileIdentifier: `${cardId}.json`, codeBlocks: [ `╔═══ SEARCH ════╗ + "name": "Bill", "hasError": true, - "name": "Bill" ╠═══════════════╣ + "name": "Billy", "hasError": false, - "name": "Billy" ╚═══ REPLACE ═══╝`, ], roomId, diff --git a/packages/realm-server/tests/card-endpoints-test.ts b/packages/realm-server/tests/card-endpoints-test.ts index b6cd899a87b..05abf1174b9 100644 --- a/packages/realm-server/tests/card-endpoints-test.ts +++ b/packages/realm-server/tests/card-endpoints-test.ts @@ -6,7 +6,8 @@ import { join, basename } from 'path'; import type { RealmHttpServer as Server } from '../server.ts'; import type { DirResult } from 'tmp'; import fsExtra from 'fs-extra'; -const { existsSync, readJSONSync, statSync, writeFileSync } = fsExtra; +const { existsSync, readFileSync, readJSONSync, statSync, writeFileSync } = + fsExtra; import type { Realm, Relationship, @@ -4248,6 +4249,42 @@ module(basename(import.meta.filename), function () { 'compound entries survive a PATCH of an unrelated field', ); }); + + test('PATCH against a corrupt stored file fails without overwriting it', async function (assert) { + let cardFile = join( + dir.name, + 'realm_server_1', + 'test', + 'log-1.json', + ); + let corruptContent = 'not a card document {'; + writeFileSync(cardFile, corruptContent); + + let response = await request + .patch('/log-1') + .send({ + data: { + type: 'card', + attributes: { + logTitle: 'Should not land', + }, + meta: { + adoptsFrom: { + module: rri('./log'), + name: 'Log', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json'); + + assert.strictEqual(response.status, 500, 'HTTP 500 status'); + assert.strictEqual( + readFileSync(cardFile, 'utf8'), + corruptContent, + 'the stored file is left untouched', + ); + }); }, ); }); diff --git a/packages/realm-server/tests/card-source-endpoints-test.ts b/packages/realm-server/tests/card-source-endpoints-test.ts index 801ce4ebcc1..e7c4f49242e 100644 --- a/packages/realm-server/tests/card-source-endpoints-test.ts +++ b/packages/realm-server/tests/card-source-endpoints-test.ts @@ -983,7 +983,6 @@ module(basename(import.meta.filename), function () { attributes: { field1: 'a', field2a: 'c', - cardInfo, }, meta: { adoptsFrom: { diff --git a/packages/runtime-common/file-serializer.ts b/packages/runtime-common/file-serializer.ts index 8231cb23d4e..8f578ce118a 100644 --- a/packages/runtime-common/file-serializer.ts +++ b/packages/runtime-common/file-serializer.ts @@ -284,6 +284,13 @@ async function processAttributes({ // that. Otherwise fall back to the field's declared `fieldOrCard` // type. Either source's CodeRef may be relative; resolve to absolute // before looking up. +// +// Returns undefined ONLY for a code ref that can't name an export (a +// `fieldOf`/`ancestorOf` ref for an unexported class) — the callers' +// preserve-verbatim handling is scoped to exactly that case. A +// resolved ref whose definition is genuinely missing never gets here: +// `lookupDefinition` throws for it, failing the write loudly instead +// of silently preserving under a stale schema. async function resolveChildDef( fieldDefinition: FieldDefinition, polymorphicAdoptsFrom: CodeRef | undefined, @@ -428,21 +435,25 @@ async function processRelationships({ continue; } - const processedValue = normalizeRelationship(value); - if ( fieldDefinition.type === 'linksToMany' && value.data && Array.isArray(value.data) ) { - value.data.forEach((_, index) => { - result[`${relationshipKey}.${index}`] = { - links: processedValue.links, - meta: processedValue.meta, - }; + // Fan the JSON:API to-many `data: [...]` form out into indexed + // keys, deriving each entry from its own resource identifier — + // normalizeRelationship only converts a single-resource `data`, + // so normalizing the array-valued relationship as one unit would + // lose every target that has no relationship-level links. + value.data.forEach((item, index) => { + result[`${relationshipKey}.${index}`] = normalizeRelationship({ + ...(value.links ? { links: value.links } : {}), + ...(value.meta ? { meta: value.meta } : {}), + data: item, + }); }); } else { - result[relationshipKey] = processedValue; + result[relationshipKey] = normalizeRelationship(value); } } diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 27df64bd7c8..1290c50ab36 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -4793,9 +4793,6 @@ export class Realm { id: instanceURL, }); } - original.meta ??= { adoptsFrom: patch.meta.adoptsFrom }; - original.meta.adoptsFrom = - original.meta.adoptsFrom ?? patch.meta.adoptsFrom; delete original.meta.lastModified; let originalClone = cloneDeep(original);