diff --git a/packages/host/tests/integration/realm-test.gts b/packages/host/tests/integration/realm-test.gts index 4ba4d56d6f..9ffc4f2290 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 8b23293de9..02579e1cc5 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/atomic-endpoints-test.ts b/packages/realm-server/tests/atomic-endpoints-test.ts index 3662a17326..8a36bb1f00 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 05b094d0f9..05abf1174b 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, @@ -2457,6 +2458,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 +2467,6 @@ module(basename(import.meta.filename), function () { type: 'card', attributes: { firstName: 'Van Gogh', - cardInfo, }, meta: { adoptsFrom: { @@ -2556,13 +2558,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 +2578,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 +2715,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 +2761,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 +3014,6 @@ module(basename(import.meta.filename), function () { type: 'card', attributes: { firstName: 'Paper', - cardInfo, }, relationships: { friend: { @@ -3511,7 +3549,6 @@ module(basename(import.meta.filename), function () { type: 'card', attributes: { firstName: 'Paper', - cardInfo, }, relationships: { friend: { @@ -3762,7 +3799,6 @@ module(basename(import.meta.filename), function () { type: 'card', attributes: { firstName: 'Paper', - cardInfo, }, relationships: { friend: { @@ -3948,6 +3984,309 @@ 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, + linksToMany, + 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); + @field coauthors = linksToMany(() => 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', + }, + }, + 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'), + 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?.['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, + { + 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', + ); + }); + + 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', + ); + }); + }, + ); }); module('card DELETE request', function (_hooks) { diff --git a/packages/realm-server/tests/card-source-endpoints-test.ts b/packages/realm-server/tests/card-source-endpoints-test.ts index 801ce4ebcc..e7c4f49242 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 3d803ba134..8f578ce118 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({ @@ -275,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, @@ -382,6 +398,32 @@ 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 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; + } + if (!fieldDefinition || fieldDefinition.isComputed) { continue; } @@ -393,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); } } @@ -420,6 +466,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 +479,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 +506,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 562fb3457c..1290c50ab3 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,35 +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; - original.meta ??= { adoptsFrom: patch.meta.adoptsFrom }; - original.meta.adoptsFrom = - original.meta.adoptsFrom ?? patch.meta.adoptsFrom; + 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, + }); + } delete original.meta.lastModified; let originalClone = cloneDeep(original);