From fd3deb1d93e7a702e628c1603cf8bae9c794cb78 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 05:33:46 +0000 Subject: [PATCH 1/5] fix(metadata-protocol)!: refuse a keyed seed whose externalId names no field of its object The save door, the publish promotion and the restore doors refuse an upsert/update/ignore seed whose externalId (each composite member; the default `name` when undeclared) is not a field of the seed's object, 400 VALIDATION_ERROR at externalId. The boot loader never refuses: it warns once for a keyed dataset whose every record keys empty. Claude-Session: https://claude.ai/code/session_01FEkQ5pEjzdJdM36Fgh7YyY Co-Authored-By: Claude --- .changeset/28952-seed-external-id-refusal.md | 21 + .../src/protocol-authoring-gate.ts | 5 +- .../src/protocol-save-door-refusals.ts | 81 +++- .../protocol.seed-external-id-refusal.test.ts | 373 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 73 ++++ .../src/seed-loader-empty-key-warn.test.ts | 179 +++++++++ packages/metadata-protocol/src/seed-loader.ts | 20 +- .../seed-external-id-refusal.dogfood.test.ts | 120 ++++++ 8 files changed, 869 insertions(+), 3 deletions(-) create mode 100644 .changeset/28952-seed-external-id-refusal.md create mode 100644 packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts create mode 100644 packages/metadata-protocol/src/seed-loader-empty-key-warn.test.ts create mode 100644 packages/qa/dogfood/test/seed-external-id-refusal.dogfood.test.ts diff --git a/.changeset/28952-seed-external-id-refusal.md b/.changeset/28952-seed-external-id-refusal.md new file mode 100644 index 00000000000..24b5aeae191 --- /dev/null +++ b/.changeset/28952-seed-external-id-refusal.md @@ -0,0 +1,21 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +fix(metadata-protocol)!: a keyed seed whose `externalId` names no field of its object is refused at save and at publish, and the boot loader warns once for a keyed dataset whose every record keys empty (#28952) + +Clause-②: no (narrowing) — the metadata save door (`saveMetaItem`, draft and publish mode), the publish promotion (`publishMetaItem`, `publishPackageDrafts`) and the restore doors refuse a `seed` in `upsert`, `update` or `ignore` mode whose `externalId` (each member of a composite; an undeclared one is the default `name`) is not a field of the seed's object, which they stored and published before. Nothing is widened. +Metadata-protocol: narrowed + + + +**BREAKING** accept-set narrowing at the metadata save and publish doors (`@objectstack/metadata-protocol`). + +- **Before.** A seed with `mode: 'upsert'` and `externalId: 'no_such_field'` was saved (`_diagnostics.valid: true`) and published. The loader keys every existing row and every incoming record on that field, which none of them carries, so every key was empty and nothing matched: each publish answered `seedApplied {"success":true,"inserted":1,"updated":0}` and inserted every record again, with no probe issue and no log line. +- **Now.** The save is refused, `400 VALIDATION_ERROR`, one finding per missing member at `externalId` (`externalId.N` for a composite member), naming the field and the object; nothing is stored. A seed draft stored before this change, or one whose object was still a draft at its save, is refused when it is published, before any seed row is written, and a package publish aborts on it. An object drafted in the same package batch is judged as it will be published. +- **What counts as a field.** The object's fields as the engine registers them (extension fields included) and the columns the registry injects (`id`, the audit and tenant columns). +- **Not judged.** `insert` and `replace` seeds, which never match by key; a draft seed whose object is itself still a draft (judged at its publish); a seed whose object is neither registered nor drafted (the load reports it); a `migrate-stored` re-save. +- **The boot loader never refuses.** For a keyed dataset whose every record has no value for the key, it now logs one `warn` naming the object and the key, and loads exactly as before: a replay of such a stored seed still inserts every record again (`upsert`, `ignore`) or skips it (`update`). +- **Who sees it.** A package author whose seed key names a field the object does not have (a typo, or a field since renamed). Measured: no seed shipped in this repository's examples or in hotcrm names such a key. + +**The remedy.** Set `externalId` to a field the object declares, or use `mode: 'insert'` for rows with no natural key. diff --git a/packages/metadata-protocol/src/protocol-authoring-gate.ts b/packages/metadata-protocol/src/protocol-authoring-gate.ts index 771bca034f8..73b029ba085 100644 --- a/packages/metadata-protocol/src/protocol-authoring-gate.ts +++ b/packages/metadata-protocol/src/protocol-authoring-gate.ts @@ -90,6 +90,7 @@ export interface AuthoringGateHost { runAuthoringGate: ObjectStackProtocolImplementation['runAuthoringGate']; tenancyPostureInForce: ObjectStackProtocolImplementation['tenancyPostureInForce']; unknownDatasourceSaveRefusal: ObjectStackProtocolImplementation['unknownDatasourceSaveRefusal']; + seedExternalIdSaveRefusal: ObjectStackProtocolImplementation['seedExternalIdSaveRefusal']; } /** The body of `ObjectStackProtocolImplementation#restoredBodyDerivation`; the method's docblock there is its contract. */ @@ -123,7 +124,7 @@ export function restoredBodyWriter( /** The body of `ObjectStackProtocolImplementation#refuseRestoredBodyTheSaveDoorRefuses`; the method's docblock there is its contract. */ export async function refuseRestoredBodyTheSaveDoorRefuses( - this: Pick, + this: Pick, type: string, name: string, body: unknown, @@ -140,6 +141,8 @@ export async function refuseRestoredBodyTheSaveDoorRefuses( if (bindingRefusal) throw bindingRefusal; const datasourceRefusal = await this.unknownDatasourceSaveRefusal(type, body, name); if (datasourceRefusal) throw datasourceRefusal; + const seedKeyRefusal = await this.seedExternalIdSaveRefusal(type, body, name); + if (seedKeyRefusal) throw seedKeyRefusal; const repo = this.getOverlayRepo(); const credentialsRefRefusal = await datasourceCredentialsRefSaveRefusal({ headAt: (...a) => repo.headAt(...a), type, name, item: body, diff --git a/packages/metadata-protocol/src/protocol-save-door-refusals.ts b/packages/metadata-protocol/src/protocol-save-door-refusals.ts index 4c0f162f9ef..8353d0538bd 100644 --- a/packages/metadata-protocol/src/protocol-save-door-refusals.ts +++ b/packages/metadata-protocol/src/protocol-save-door-refusals.ts @@ -62,7 +62,7 @@ import { import { resolveItemLock, withItemLockFamily } from './item-lock.js'; import type { ItemAddress } from './item-lock.js'; import { itemDiscriminator } from '@objectstack/metadata-core'; -import { externalBindingDatasourceRefusal } from '@objectstack/spec/data'; +import { externalBindingDatasourceRefusal, resolveInjectedSystemColumns } from '@objectstack/spec/data'; import { METADATA_ITEM_NAME_PATTERN } from '@objectstack/spec/shared'; import { defaultRecordPageObject } from '@objectstack/spec/ui'; import { MetadataProtectionFields, validateObjectNamespacePrefix } from '@objectstack/spec/kernel'; @@ -677,3 +677,82 @@ export function objectNamespacePrefixViolation( if (typeof packageId !== 'string' || packageId === '') return null; return validateObjectNamespacePrefix(name, this.engine?.registry?.getPackage?.(packageId)?.manifest?.namespace); } + +/** The seed modes whose load matches incoming records to existing rows by the dataset's `externalId`. */ +const KEYED_SEED_MODES: ReadonlySet = new Set(['upsert', 'update', 'ignore']); + +/** + * The body of `ObjectStackProtocolImplementation#seedExternalIdSaveRefusal`; + * the method's docblock there is its contract. Written here, beside the other + * refusals the save door and the publish promotion ask in turn, rather than + * moved: its reads are the engine's registered object and, on a draft save, + * the object drafts. + */ +export async function seedExternalIdSaveRefusal( + this: Pick, + singularType: string, + item: unknown, + name: string, + pending?: 'drafts' | ReadonlyArray, +): Promise<(Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] }) | undefined> { + if (singularType !== 'seed') return undefined; + if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined; + const seed = item as { object?: unknown; mode?: unknown; externalId?: unknown }; + if (typeof seed.object !== 'string' || seed.object === '') return undefined; + const objectName = seed.object; + // `SeedSchema`'s defaults, as the loader applies them (`mode: 'upsert'`, + // `externalId: 'name'`): a body read before the parse filled them keys the + // same way a parsed one does. + const mode = typeof seed.mode === 'string' ? seed.mode : 'upsert'; + if (!KEYED_SEED_MODES.has(mode)) return undefined; + const declared = seed.externalId !== undefined; + const externalId = declared ? seed.externalId : 'name'; + const composite = Array.isArray(externalId); + const members = (composite ? externalId : [externalId]) as unknown[]; + // The object as the publish will have it: a draft of it in the same + // package batch, else the engine's registered definition (its fields as + // the runtime serves them, extension fields and injected columns + // included). On a draft save, an object that is itself still a draft may + // gain the field before it is published, so the seed is judged at its + // publish instead. An object resolved by neither is not judged here: + // the load reports an object it cannot find. + const pendingObject = Array.isArray(pending) + ? pending.find((o) => (o as { name?: unknown } | null)?.name === objectName) as { fields?: unknown } | undefined + : undefined; + if (!pendingObject && pending === 'drafts' + && (await this.listDrafts({ type: 'object' })).drafts.some((d) => d.name === objectName)) { + return undefined; + } + const registered = (this.engine?.registry as { getObject?(name: string): unknown } | undefined) + ?.getObject?.(objectName) as { fields?: unknown } | undefined; + if (!pendingObject && !registered) return undefined; + const fieldNames = new Set(resolveInjectedSystemColumns(pendingObject ?? registered).names); + for (const def of [registered, pendingObject]) { + const fields = def?.fields; + if (fields && typeof fields === 'object' && !Array.isArray(fields)) { + for (const key of Object.keys(fields)) fieldNames.add(key); + } + } + const missing = members + .map((member, index) => ({ member, index })) + .filter(({ member }) => typeof member === 'string' && !fieldNames.has(member)); + if (missing.length === 0) return undefined; + const spelled = missing.map(({ member }) => `'${String(member)}'`).join(', '); + const err = new Error( + `Correct \`externalId\` on seed '${name}' and send it again. Nothing was written. ` + + (declared + ? `${spelled} ${missing.length === 1 ? 'is not a field' : 'are not fields'} of object '${objectName}', ` + : `It is not declared, so it defaults to 'name', and object '${objectName}' has no field 'name', `) + + `so a load in \`${mode}\` mode could never match a row it wrote before: each publish and each replay would ` + + (mode === 'update' ? 'skip every record. ' : 'insert every record again. ') + + `Name ${composite ? 'only fields' : 'a field'} the object declares, or use \`mode: 'insert'\` for rows with no natural key.`, + ) as Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] }; + err.code = 'VALIDATION_ERROR'; + err.status = 400; + err.issues = missing.map(({ member, index }) => ({ + path: composite ? `externalId.${index}` : 'externalId', + message: `'${String(member)}' is not a field of object '${objectName}'.`, + code: 'custom', + })); + return err; +} diff --git a/packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts b/packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts new file mode 100644 index 00000000000..32cc0b82cb7 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts @@ -0,0 +1,373 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28952] The metadata save doors refuse a keyed `seed` whose `externalId` + * names no field of the seed's object. + * + * Measured on `origin/main` before this file: a seed saved with + * `mode: 'upsert'` and `externalId: 'no_such_field'` was stored in draft and + * in publish mode and promoted by `publishPackageDrafts`. The loader keys each + * existing row and each incoming record by that field, which no row carries, + * so every key was empty, nothing matched, and every publish inserted each + * record again with a success receipt. + * + * Pinned here, through `saveMetaItem`, `publishMetaItem`, + * `publishPackageDrafts` and the restore door: + * - the refusal: `400 VALIDATION_ERROR`, one finding per missing member at + * `externalId` (`externalId.N` for a composite member), nothing stored + * (save), nothing promoted and no seed row written (publish of a draft + * stored before the rule; the batch aborts); + * - an undeclared `externalId` is `SeedSchema`'s default, `name`, and is + * judged as such; + * - an object drafted in the same package batch is judged as it will be + * published; + * - controls: a declared field, an injected column (`id`), every member of + * a composite declared, `insert` and `replace` (they never match), a + * draft seed whose object is itself still a draft, an object neither + * registered nor drafted, and a `migrate-stored` re-save. + * + * Each refusal is asserted by its code, its status, its path and its + * message's first sentence, spelled out here. + * + * Harness: the stateful `sys_metadata` double of + * `protocol.unknown-datasource-refusal.test.ts`, kept local, with a registry + * that answers registered objects and a log of every data-table insert. + */ +import { describe, expect, it } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; +} + +const PKG = 'app.seedkey'; + +/** ADR-0048 overlay key — (type, name, org, state, package_id). */ +const keyOf = (w: Record) => + `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; + +/** Top-level eq + `$or` + `$in` + explicit-NULL, the subset these paths emit. */ +function matchesWhere(r: Row, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + if (k === '$or') { + if (!(v as Array>).some((c) => matchesWhere(r, c))) return false; + continue; + } + if (v === undefined) continue; + const actual = (r as unknown as Record)[k]; + if (v !== null && typeof v === 'object' && Array.isArray((v as { $in?: unknown }).$in)) { + if (!(v as { $in: unknown[] }).$in.includes(actual)) return false; + continue; + } + if (v === null ? actual !== null && actual !== undefined : actual !== v) return false; + } + return true; +} + +function makeSession(opts: { objects?: Record> } = {}) { + const rows = new Map(); + const history: Array> = []; + /** Every insert into an app table (not a `sys_*` platform table): a seed row written. */ + const dataInserts: string[] = []; + let nextId = 0; + const findRow = (w: Record): { key: string; row: Row } | null => { + if (w.id !== undefined) { + for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r }; + return null; + } + for (const [k, r] of rows) if (matchesWhere(r, w)) return { key: k, row: r }; + return null; + }; + const engine: any = { + async findOne(table: string, o: { where: Record }) { + assertEngineFindOnePredicate(table, o); + if (table === 'sys_metadata_history') { + return history.find((h) => Object.entries(o.where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`history double: unsupported operator ${k}`); + return v === undefined || h[k] === v; + })) ?? null; + } + if (table !== 'sys_metadata') return null; + return findRow(o.where)?.row ?? null; + }, + async find(table: string, o?: { where?: Record; limit?: number }) { + if (table !== 'sys_metadata') return []; + // `check:objectql-double-limit` — the caller's bound, applied after the filter. + const matched = Array.from(rows.values()).filter((r) => matchesWhere(r, o?.where ?? {})); + return typeof o?.limit === 'number' ? matched.slice(0, o.limit) : matched; + }, + async insert(table: string, data: Record) { + nextId += 1; + if (table === 'sys_metadata_history') { + history.push({ ...data, id: `h_${nextId}` }); + return { id: `h_${nextId}` }; + } + if (table !== 'sys_metadata') { + if (!table.startsWith('sys_')) dataInserts.push(table); + return Array.isArray(data) ? data.map((d, i) => ({ ...d, id: `d_${nextId}_${i}` })) : { ...data, id: `d_${nextId}` }; + } + const row = { ...(data as unknown as Row), id: `r_${nextId}` }; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(_t: string, data: Record, o: { where: Record }) { + assertEngineUpdateDispatch(data, o); + const found = findRow(o.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as unknown as Row) }; + rows.delete(found.key); + rows.set(keyOf(merged), merged); + return { id: found.row.id }; + }, + async delete(_t: string, o: { where: Record }) { + assertEngineDeleteDispatch(o); + const found = findRow(o.where); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + async transaction(cb: (ctx: any, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + listItems: () => [], + getItem: () => undefined, + getObject: (name: string) => opts.objects?.[name], + getAllObjects: () => Object.values(opts.objects ?? {}), + // An installed authored base that declares no namespace. + getPackage: (id: string) => (id === PKG ? { manifest: { id } } : undefined), + getArtifactItem: () => undefined, + isPackageDisabled: () => false, + }, + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; + return { protocol, rows, dataInserts }; +} + +const refusalOf = (run: Promise) => run.then(() => null, (e: any) => e); + +/** The stored rows of one item, as `state|package|externalId`. */ +const stored = (rows: Map, type: string, name: string) => + [...rows.values()] + .filter((r) => r.type === type && r.name === name) + .map((r) => `${r.state}|${r.package_id ?? '-'}|${JSON.stringify(JSON.parse(r.metadata).externalId ?? null)}`) + .sort(); + +/** A schema-valid object body with `name` and `code` text fields (or the given ones). */ +const object = (name: string, fields: string[] = ['name', 'code']) => ({ + name, + label: 'Seed Key', + sharingModel: 'private', + fields: Object.fromEntries(fields.map((f) => [f, { type: 'text', label: f }])), +}); + +/** The registered (served) form: the authored fields plus the columns the registry injects. */ +const registered = (name: string, fields: string[] = ['name', 'code']) => ({ + ...object(name, fields), + fields: { + ...object(name, fields).fields, + id: { type: 'text' }, created_at: { type: 'datetime' }, updated_at: { type: 'datetime' }, + }, +}); + +/** A seed body; `externalId` omitted when `undefined`. */ +const seed = (target: string, externalId: string | string[] | undefined, mode = 'upsert') => ({ + object: target, + mode, + ...(externalId !== undefined ? { externalId } : {}), + records: [{ name: 'G1', code: 'g', organization_id: 'org_seedkey' }], +}); + +/** Plant a seed draft as a draft stored before the rule sits at rest. */ +function plantSeedDraft(rows: Map, name: string, body: Record, packageId: string | null = PKG) { + const data = { + type: 'seed', name, organization_id: null, package_id: packageId, + state: 'draft', metadata: JSON.stringify(body), + }; + rows.set(keyOf(data), { ...data, id: `planted_${name}` }); +} + +const firstSentence = (seedName: string) => `Correct \`externalId\` on seed '${seedName}' and send it again. Nothing was written.`; + +const expectRefusal = (err: any, seedName: string, findings: Array<{ path: string; member: string; object: string }>) => { + expect({ code: err?.code, status: err?.status }, String(err?.message)).toEqual({ code: 'VALIDATION_ERROR', status: 400 }); + expect(String(err.message).startsWith(firstSentence(seedName)), String(err.message)).toBe(true); + expect(String(err.message).length).toBeLessThanOrEqual(500); + expect(err.issues).toEqual(findings.map((f) => ({ + path: f.path, code: 'custom', message: `'${f.member}' is not a field of object '${f.object}'.`, + }))); +}; + +const OBJECTS = { seedkey_other: registered('seedkey_other') }; + +describe('[#28952] the seed save door — an `externalId` that names no field of the object', () => { + it.each([ + ['a draft save into a package', { mode: 'draft', packageId: PKG }], + ['a draft save naming no package', { mode: 'draft' }], + ['a publish save', {}], + ])('%s is refused 400 at `externalId`, naming the field, and nothing is stored or loaded', async (_label, saveOpts) => { + const { protocol, rows, dataInserts } = makeSession({ objects: OBJECTS }); + + const err = await refusalOf(protocol.saveMetaItem({ + type: 'seed', name: 'r_ghost', item: seed('seedkey_other', 'no_such_field'), ...saveOpts, + })); + + expectRefusal(err, 'r_ghost', [{ path: 'externalId', member: 'no_such_field', object: 'seedkey_other' }]); + expect(String(err.message)).toContain("'no_such_field' is not a field of object 'seedkey_other'"); + expect(stored(rows, 'seed', 'r_ghost')).toEqual([]); + expect(dataInserts).toEqual([]); + }); + + it.each([['upsert'], ['ignore'], ['update']])('mode `%s` is refused the same way', async (mode) => { + const { protocol } = makeSession({ objects: OBJECTS }); + const err = await refusalOf(protocol.saveMetaItem({ + type: 'seed', name: 'r_ghost', item: seed('seedkey_other', 'no_such_field', mode), mode: 'draft', + })); + expectRefusal(err, 'r_ghost', [{ path: 'externalId', member: 'no_such_field', object: 'seedkey_other' }]); + }); + + it('a composite `externalId` with one member the object lacks is refused at that member', async () => { + const { protocol, rows } = makeSession({ objects: OBJECTS }); + + const err = await refusalOf(protocol.saveMetaItem({ + type: 'seed', name: 'r_pair', item: seed('seedkey_other', ['code', 'no_such_field']), mode: 'draft', + })); + + expectRefusal(err, 'r_pair', [{ path: 'externalId.1', member: 'no_such_field', object: 'seedkey_other' }]); + expect(stored(rows, 'seed', 'r_pair')).toEqual([]); + }); + + it("an undeclared `externalId` is the default 'name', refused on an object with no `name` field", async () => { + const { protocol } = makeSession({ objects: { seedkey_coded: registered('seedkey_coded', ['code']) } }); + + const err = await refusalOf(protocol.saveMetaItem({ + type: 'seed', name: 'r_default', item: { object: 'seedkey_coded', records: [{ code: 'g' }] }, mode: 'draft', + })); + + expectRefusal(err, 'r_default', [{ path: 'externalId', member: 'name', object: 'seedkey_coded' }]); + expect(String(err.message)).toContain("It is not declared, so it defaults to 'name'"); + }); +}); + +describe('[#28952] the publish doors — a seed draft stored before the rule', () => { + it('publishMetaItem refuses it, the draft stays a draft, and no seed row is written', async () => { + const { protocol, rows, dataInserts } = makeSession({ objects: OBJECTS }); + plantSeedDraft(rows, 'r_ghost', seed('seedkey_other', 'no_such_field'), null); + + const err = await refusalOf(protocol.publishMetaItem({ type: 'seed', name: 'r_ghost' })); + + expectRefusal(err, 'r_ghost', [{ path: 'externalId', member: 'no_such_field', object: 'seedkey_other' }]); + expect(stored(rows, 'seed', 'r_ghost')).toEqual(['draft|-|"no_such_field"']); + expect(dataInserts).toEqual([]); + }); + + it('publishPackageDrafts answers `refused`, names it, and promotes and loads nothing of the batch', async () => { + const { protocol, rows, dataInserts } = makeSession({ objects: OBJECTS }); + plantSeedDraft(rows, 'r_ghost', seed('seedkey_other', 'no_such_field')); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res).toMatchObject({ outcome: 'refused', success: false, publishedCount: 0, published: [] }); + const causal = res.failed.find((f: any) => f.name === 'r_ghost'); + expect(causal, JSON.stringify(res.failed)).toMatchObject({ type: 'seed', code: 'VALIDATION_ERROR' }); + expect(JSON.stringify(causal)).toContain("'no_such_field' is not a field of object 'seedkey_other'"); + expect(stored(rows, 'seed', 'r_ghost')).toEqual([`draft|${PKG}|"no_such_field"`]); + expect(dataInserts).toEqual([]); + }); + + it('an object drafted in the same batch is judged as it will be published: lacking the field, the batch is refused', async () => { + const { protocol, rows, dataInserts } = makeSession(); + await protocol.saveMetaItem({ type: 'object', name: 'seedkey_new', item: object('seedkey_new'), mode: 'draft', packageId: PKG }); + // The object is still a draft, so the seed's draft save cannot judge it… + const saved = await protocol.saveMetaItem({ + type: 'seed', name: 'r_new', item: seed('seedkey_new', 'no_such_field'), mode: 'draft', packageId: PKG, + }); + expect(saved.success).toBe(true); + + // …and its publish, beside that object draft, does. + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res).toMatchObject({ outcome: 'refused', success: false, publishedCount: 0 }); + const causal = res.failed.find((f: any) => f.name === 'r_new'); + expect(causal, JSON.stringify(res.failed)).toMatchObject({ type: 'seed', code: 'VALIDATION_ERROR' }); + expect(JSON.stringify(causal)).toContain("'no_such_field' is not a field of object 'seedkey_new'"); + expect(stored(rows, 'seed', 'r_new')).toEqual([`draft|${PKG}|"no_such_field"`]); + expect(dataInserts).toEqual([]); + }); +}); + +describe('[#28952] the restore door — a seed version whose `externalId` names no field', () => { + it('is refused with the save door\'s envelope', async () => { + const { protocol } = makeSession({ objects: OBJECTS }); + const err = await refusalOf(protocol.refuseRestoredBodyTheSaveDoorRefuses( + 'seed', 'r_ghost', seed('seedkey_other', 'no_such_field'), null, 'protocol.restoreMetaItem', + )); + expectRefusal(err, 'r_ghost', [{ path: 'externalId', member: 'no_such_field', object: 'seedkey_other' }]); + }); +}); + +describe('[#28952] controls — an `externalId` the object has is not refused', () => { + it.each([ + ["`externalId: 'name'`", 'name'], + ["`externalId: 'code'`", 'code'], + ['an injected column, `id`', 'id'], + ['a composite whose every member is a field', ['name', 'code']], + ["undeclared (the default 'name')", undefined], + ])('%s: saves as a draft, publishes, and saves live', async (_label, externalId) => { + const { protocol, rows, dataInserts } = makeSession({ objects: OBJECTS }); + await protocol.saveMetaItem({ type: 'seed', name: 'r_ok', item: seed('seedkey_other', externalId), mode: 'draft', packageId: PKG }); + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + expect(res, JSON.stringify(res.failed)).toMatchObject({ outcome: 'published', publishedCount: 1 }); + // The publish loaded the seed: the double records the row it wrote. + expect(dataInserts).toEqual(['seedkey_other']); + expect((await protocol.saveMetaItem({ type: 'seed', name: 'r_ok2', item: seed('seedkey_other', externalId) })).success).toBe(true); + expect(stored(rows, 'seed', 'r_ok')).toEqual([`active|${PKG}|${JSON.stringify(externalId ?? null)}`]); + }); + + it.each([['insert'], ['replace']])('mode `%s` never matches by `externalId`, so it is not judged', async (mode) => { + const { protocol, rows } = makeSession({ objects: OBJECTS }); + const res = await protocol.saveMetaItem({ type: 'seed', name: 'r_ins', item: seed('seedkey_other', 'no_such_field', mode), mode: 'draft' }); + expect(res.success).toBe(true); + expect(stored(rows, 'seed', 'r_ins')).toEqual(['draft|-|"no_such_field"']); + }); + + it('an object drafted in the same batch that declares the field publishes with the seed', async () => { + const { protocol, rows } = makeSession(); + await protocol.saveMetaItem({ type: 'object', name: 'seedkey_new', item: object('seedkey_new'), mode: 'draft', packageId: PKG }); + await protocol.saveMetaItem({ type: 'seed', name: 'r_new', item: seed('seedkey_new', 'code'), mode: 'draft', packageId: PKG }); + + const res = await protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res, JSON.stringify(res.failed)).toMatchObject({ outcome: 'published', publishedCount: 2 }); + expect(stored(rows, 'seed', 'r_new')).toEqual([`active|${PKG}|"code"`]); + }); + + it('an object neither registered nor drafted is not judged here (the load reports it)', async () => { + const { protocol, rows } = makeSession(); + const res = await protocol.saveMetaItem({ type: 'seed', name: 'r_far', item: seed('seedkey_elsewhere', 'no_such_field'), mode: 'draft' }); + expect(res.success).toBe(true); + expect(stored(rows, 'seed', 'r_far')).toEqual(['draft|-|"no_such_field"']); + }); + + it('a `migrate-stored` re-save of a stored seed is not judged', async () => { + const { protocol, rows } = makeSession({ objects: OBJECTS }); + const res = await protocol.saveMetaItem({ + type: 'seed', name: 'r_old', item: seed('seedkey_other', 'no_such_field', 'insert'), mode: 'draft', + }); + expect(res.success).toBe(true); + const migrated = await protocol.saveMetaItem({ + type: 'seed', name: 'r_old', item: seed('seedkey_other', 'no_such_field'), mode: 'draft', source: 'migrate-stored', + }); + expect(migrated.success).toBe(true); + expect(stored(rows, 'seed', 'r_old')).toEqual(['draft|-|"no_such_field"']); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 2d7039bbfa5..96642a86ae5 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -352,6 +352,7 @@ import { permissionSetNameCollisionRefusal, refuseUngrammaticalMetaItemName, saveWriteBinding, + seedExternalIdSaveRefusal, unknownDatasourceSaveRefusal, } from './protocol-save-door-refusals.js'; // [#25467 cut 7] Package install and update. @@ -7525,6 +7526,7 @@ export class ObjectStackProtocolImplementation implements permissionSetNameCollisionRefusal: (...a) => this.permissionSetNameCollisionRefusal(...a), runAuthoringGate: (...a) => this.runAuthoringGate(...a), unknownDatasourceSaveRefusal: (...a) => this.unknownDatasourceSaveRefusal(...a), + seedExternalIdSaveRefusal: (...a) => this.seedExternalIdSaveRefusal(...a), }; return refuseRestoredBodyTheSaveDoorRefuses.call(host, type, name, body, binding, source); } @@ -20915,6 +20917,51 @@ export class ObjectStackProtocolImplementation implements return unknownDatasourceSaveRefusal.call(host, singularType, item, name, pending, writeFace); } + /** + * [#28952] The refusal of a `seed` whose `externalId` names no field of + * the seed's object: a load in a keyed mode (`upsert`, `update`, + * `ignore`) keys each existing row and each incoming record by that + * field, and a field the object lacks keys every one of them empty, so + * nothing ever matches and every publish and every replay inserts each + * record again (or, in `update` mode, skips it) with a success receipt. + * Each member of a composite `externalId` is judged; an undeclared one is + * `SeedSchema`'s default, `name`. `insert` and `replace` never match, so + * they are not judged. + * + * The object is judged as the publish will have it: a draft of it in the + * same package batch (`pending` as the batch's object declarations), else + * the engine's registered definition, whose fields include extension + * fields; the columns the registry injects (`id`, the audit and tenant + * columns) count. Not refused when the object cannot be resolved: on a + * draft save, an object that is itself still a draft (`pending` as + * `'drafts'`) is judged at the seed's publish; an object resolved by + * neither is reported by the load. + * + * Asked in draft and in publish mode by {@link saveMetaItem} after the + * schema gate (except a `migrate-stored` re-save, the platform healing + * its own storage), again by {@link promoteDraftForPublish} on the draft + * body, so a seed stored before this refusal or whose object lost the + * field is refused when it is published and a batch publish aborts on + * it, and by the restore doors ({@link refuseRestoredBodyTheSaveDoorRefuses}). + * Answered `400 VALIDATION_ERROR`, one finding per missing member at + * `externalId` (`externalId.N` for a composite member). The boot loader + * never refuses; it warns once for a keyed dataset whose every record + * keys empty ({@link SeedLoaderService}). + */ + private seedExternalIdSaveRefusal( + singularType: string, + item: unknown, + name: string, + pending?: 'drafts' | ReadonlyArray, + ): Promise<(Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] }) | undefined> { + const readEngine = () => this.engine; + const host: ThisParameterType = { + get engine() { return readEngine(); }, + listDrafts: (...a) => this.listDrafts(...a), + }; + return seedExternalIdSaveRefusal.call(host, singularType, item, name, pending); + } + /** * [#25211] Every object this environment holds that binds `datasource` * with `external`, for {@link externalBindingSaveRefusal}'s datasource @@ -22586,6 +22633,19 @@ export class ObjectStackProtocolImplementation implements if (datasourceRefusal) throw datasourceRefusal; } + // [#28952] A keyed `seed` whose `externalId` names no field of its + // object is refused, in draft and in publish mode, after the schema and + // before the authoring gate and every write: every load of it would + // insert each record again. A draft seed whose object is itself still a + // draft is judged at its publish. A `migrate-stored` re-save rewrites an + // existing row and is not judged. See {@link seedExternalIdSaveRefusal}. + if (writeSource !== 'migrate-stored') { + const seedKeyRefusal = await this.seedExternalIdSaveRefusal( + singularType, request.item, request.name, mode === 'draft' ? 'drafts' : undefined, + ); + if (seedKeyRefusal) throw seedKeyRefusal; + } + // [#27762] A `datasource` body may name, in `external.credentialsRef`, only the reference its own stored row // holds: refused 400 before the authoring gate and every write. See {@link datasourceCredentialsRefSaveRefusal}. { @@ -24448,6 +24508,19 @@ export class ObjectStackProtocolImplementation implements ); if (datasourceRefusal) throw datasourceRefusal; } + // [#28952] …and a seed draft whose `externalId` names no field of its + // object by the time it is published: a draft saved before this + // refusal, one whose object was still a draft at its save, or one whose + // object has since lost the field. Judged on the draft body, before + // `repo.promoteDraft` writes and before any seed row is loaded, so a + // batch publish aborts on it too; an object draft of the same batch is + // judged as it will be published. See {@link seedExternalIdSaveRefusal}. + if (draftForGate) { + const seedKeyRefusal = await this.seedExternalIdSaveRefusal( + singularType, draftForGate.body, request.name, request.pending?.objects, + ); + if (seedKeyRefusal) throw seedKeyRefusal; + } // [#27818] …and a datasource draft whose `external.credentialsRef` the active row it is promoted into // (`promoteInto`) does not hold now: refused before `repo.promoteDraft` writes. See {@link datasourceCredentialsRefSaveRefusal}. if (draftForGate) { diff --git a/packages/metadata-protocol/src/seed-loader-empty-key-warn.test.ts b/packages/metadata-protocol/src/seed-loader-empty-key-warn.test.ts new file mode 100644 index 00000000000..3abe76c0a10 --- /dev/null +++ b/packages/metadata-protocol/src/seed-loader-empty-key-warn.test.ts @@ -0,0 +1,179 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { SeedLoaderService } from './seed-loader.js'; +import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; + +/** + * [#28952] A keyed dataset whose every record keys empty. + * + * The save door and the publish promotion refuse a seed whose `externalId` + * names no field of its object (`protocol.seed-external-id-refusal.test.ts`). + * The boot loader never refuses: a stored seed must not fail a boot. What it + * owes instead is one `warn` per dataset when no record carries a value for + * the key, because such a load can match no existing row. Its writes are + * unchanged, and that is pinned here too: replayed, such a dataset inserts + * every record again (`upsert`), which is the duplication the doors now stop + * at authoring. + * + * Uses the faithful engine of `seed-loader-composite-external-id.test.ts` + * (filters `where`, mints ids), kept local. + */ + +function createLogger() { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; +} + +function createFaithfulEngine(): { engine: IDataEngine; store: Record } { + const store: Record = {}; + let idCounter = 0; + + const engine = { + find: vi.fn(async (objectName: string, query?: any) => { + let records = store[objectName] || []; + if (query?.where) { + records = records.filter((r) => + Object.entries(query.where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return r[k] === v; }), + ); + } + if (typeof query?.limit === 'number') records = records.slice(0, query.limit); + return records; + }), + findOne: vi.fn(async (objectName: string, query?: any) => { + assertEngineFindOnePredicate(objectName, query); + const rows = await (engine.find as any)(objectName, { ...query, limit: 1 }); + return rows[0] ?? null; + }), + insert: vi.fn(async (objectName: string, data: any) => { + if (!store[objectName]) store[objectName] = []; + if (Array.isArray(data)) { + const records = data.map((d) => ({ id: `gen-${++idCounter}`, ...d })); + store[objectName].push(...records); + return records; + } + const record = { id: `gen-${++idCounter}`, ...data }; + store[objectName].push(record); + return record; + }), + update: vi.fn(async (objectName: string, data: any) => { + assertEngineUpdateDispatch(data, undefined); + const records = store[objectName] || []; + const idx = records.findIndex((r) => r.id === data.id); + if (idx >= 0) { + records[idx] = { ...records[idx], ...data }; + return records[idx]; + } + return data; + }), + delete: vi.fn(async (_objectName: string, options?: any) => { + assertEngineDeleteDispatch(options); + return { deleted: 1 }; + }), + count: vi.fn(async (objectName: string) => (store[objectName] || []).length), + aggregate: vi.fn(async () => []), + } as unknown as IDataEngine; + + return { engine, store }; +} + +function createMetadata(): IMetadataService { + const objects: Record = { + demo_other: { name: 'demo_other', fields: { name: { type: 'text' }, code: { type: 'text' } } }, + }; + return { + getObject: vi.fn(async (name: string) => objects[name]), + listObjects: vi.fn(async () => Object.values(objects)), + register: vi.fn(async () => {}), + get: vi.fn(async (_t: string, name: string) => objects[name]), + list: vi.fn(async () => []), + unregister: vi.fn(async () => {}), + exists: vi.fn(async () => false), + listNames: vi.fn(async () => []), + } as unknown as IMetadataService; +} + +const CONFIG = { + dryRun: false, + haltOnError: false, + multiPass: true, + defaultMode: 'upsert', + batchSize: 1000, + transaction: false, + organizationId: 'org_demo', +} as any; + +const dataset = (externalId: string | string[], mode = 'upsert', records: Record[] = [{ name: 'G1', code: 'g' }]) => + ({ object: 'demo_other', externalId, mode, env: ['prod', 'dev', 'test'], records }); + +/** The loader's empty-key warnings, by their message. */ +const emptyKeyWarnings = (logger: ReturnType) => + logger.warn.mock.calls.map((c) => String(c[0])).filter((m) => m.includes('no record carries a value for it')); + +describe('[#28952] the boot loader — a keyed dataset whose every record keys empty', () => { + it('warns once per dataset, naming the object and the key, and never refuses; a replay inserts every record again', async () => { + const { engine, store } = createFaithfulEngine(); + const metadata = createMetadata(); + const seeds = [dataset('no_such_field')] as any[]; + + const logger = createLogger(); + const first = await new SeedLoaderService(engine, metadata, logger).load({ seeds, config: CONFIG }); + expect(first.success).toBe(true); + expect(first.results[0]).toMatchObject({ object: 'demo_other', inserted: 1 }); + const warned = emptyKeyWarnings(logger); + expect(warned).toHaveLength(1); + expect(warned[0]).toContain("Seed dataset for 'demo_other' keys every record on externalId 'no_such_field'"); + + // The replay a boot makes of the same stored seed: unchanged writes, so + // the row is inserted again. The doors refuse such a seed at authoring. + const replayLogger = createLogger(); + const second = await new SeedLoaderService(engine, metadata, replayLogger).load({ seeds, config: CONFIG }); + expect(second.success).toBe(true); + expect(second.results[0]).toMatchObject({ inserted: 1, updated: 0 }); + expect(store.demo_other).toHaveLength(2); + expect(emptyKeyWarnings(replayLogger)).toHaveLength(1); + }); + + it('a composite key with a member no record carries warns the same way', async () => { + const { engine } = createFaithfulEngine(); + const logger = createLogger(); + await new SeedLoaderService(engine, createMetadata(), logger).load({ + seeds: [dataset(['code', 'no_such_field'], 'ignore')] as any[], config: CONFIG, + }); + const warned = emptyKeyWarnings(logger); + expect(warned).toHaveLength(1); + expect(warned[0]).toContain("externalId 'code+no_such_field'"); + }); + + it('control: a declared key converges on replay and warns nothing', async () => { + const { engine, store } = createFaithfulEngine(); + const metadata = createMetadata(); + const seeds = [dataset('name')] as any[]; + + const logger = createLogger(); + const first = await new SeedLoaderService(engine, metadata, logger).load({ seeds, config: CONFIG }); + expect(first.results[0]).toMatchObject({ inserted: 1 }); + const second = await new SeedLoaderService(engine, metadata, logger).load({ seeds, config: CONFIG }); + expect(second.results[0]).toMatchObject({ inserted: 0, updated: 0 }); + expect(store.demo_other).toHaveLength(1); + expect(emptyKeyWarnings(logger)).toEqual([]); + }); + + it('control: an `insert` dataset never matches, so it is not warned about', async () => { + const { engine } = createFaithfulEngine(); + const logger = createLogger(); + await new SeedLoaderService(engine, createMetadata(), logger).load({ + seeds: [dataset('no_such_field', 'insert')] as any[], config: CONFIG, + }); + expect(emptyKeyWarnings(logger)).toEqual([]); + }); + + it('control: a dataset where some record carries the key is not warned about', async () => { + const { engine } = createFaithfulEngine(); + const logger = createLogger(); + await new SeedLoaderService(engine, createMetadata(), logger).load({ + seeds: [dataset('code', 'upsert', [{ name: 'A', code: 'a' }, { name: 'B' }])] as any[], config: CONFIG, + }); + expect(emptyKeyWarnings(logger)).toEqual([]); + }); +}); diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index 6557a0c4b94..7a4b7288d98 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -1241,9 +1241,27 @@ export class SeedLoaderService implements ISeedLoaderService { // own copy (otherwise upsert would clobber other tenants' rows // that share the same natural key — e.g. `name: 'Acme Corp'`). let existingRecords: Map | undefined; - if ((mode === 'upsert' || mode === 'update' || mode === 'ignore') && !config.dryRun) { + const keyedMode = mode === 'upsert' || mode === 'update' || mode === 'ignore'; + if (keyedMode && !config.dryRun) { existingRecords = await this.loadExistingRecords(objectName, externalId, config.organizationId); } + // [#28952] A keyed dataset whose every record keys EMPTY — its + // `externalId` names a field no record carries, most often a field the + // object does not have — can never match a row: each record is written as + // new (`upsert`, `ignore`) or skipped (`update`) on every load. The save + // door and the publish promotion refuse such a seed; this load never + // refuses (a boot must not fail on a stored seed), so it says so once per + // dataset, at `warn`, and loads exactly as before. + if (keyedMode && dataset.records.length > 0 + && dataset.records.every((record) => this.externalIdKey(record as Record, externalId) === '')) { + this.logger.warn( + `[SeedLoader] Seed dataset for '${objectName}' keys every record on externalId ` + + `'${this.externalIdLabel(externalId)}', and no record carries a value for it, so this ${mode} load can ` + + `match no existing row: ${mode === 'update' ? 'every record is skipped' : 'every record is written as a new row, on every load'}. ` + + `Set externalId to a field the object declares and each record carries, or use mode 'insert'.`, + { object: objectName, externalId: this.externalIdLabel(externalId), mode, records: dataset.records.length }, + ); + } // [#21665] Per-organization row identity. On a per-organization replay, // decide which id each row authored with an `id` holds in THIS diff --git a/packages/qa/dogfood/test/seed-external-id-refusal.dogfood.test.ts b/packages/qa/dogfood/test/seed-external-id-refusal.dogfood.test.ts new file mode 100644 index 00000000000..4c2b26b8df6 --- /dev/null +++ b/packages/qa/dogfood/test/seed-external-id-refusal.dogfood.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#28952] A keyed seed whose `externalId` names no field of its object is +// refused at the metadata save door, over the real showcase composition and +// through the REST doors the card measured. +// +// ## What was broken +// +// `PUT /api/v1/meta/seed/:name?mode=draft&package=PKG` with `mode: 'upsert'` +// and `externalId: 'no_such_field'` answered 200, and every +// `POST /api/v1/packages/PKG/publish-drafts` of the unchanged seed answered +// `seedApplied {"success":true,"inserted":1,"updated":0}`: the loader keyed +// every existing row and every incoming record on a field none of them +// carries, so nothing matched and the table gained one identical row per +// publish, with no probe issue and no log line. +// +// ## What these cases pin +// +// The save door refuses it, `400 VALIDATION_ERROR` with the finding at +// `externalId` naming the field (`externalId.N` for a composite member), and +// nothing is stored, so `publish-drafts` has nothing of it to publish. +// Controls: the same seed keyed on `name` saves, publishes and converges on +// replay (`inserted: 1`, then `inserted: 0, updated: 0`, one row served); an +// `insert` seed is not judged. The publish door's refusal of a draft stored +// before this rule, and of a seed whose object is drafted in the same batch, +// is pinned in-process +// (`packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts`): +// no door can store the first any more. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +const PKG = 'app.seedkey28952'; + +const seed = (externalId: string | string[], mode = 'upsert') => ({ + object: 'seedkey_other', + mode, + externalId, + records: [{ name: 'G1', code: 'g' }], +}); + +describe('dogfood: a seed whose externalId names no field of its object is refused at save (#28952)', () => { + let stack: VerifyStack; + let token: string; + + const call = async (method: string, path: string, body?: unknown) => { + const res = await stack.apiAs(token, method, path, body); + const json: any = await res.json().catch(() => ({})); + return { status: res.status, json }; + }; + + /** Publish the package's drafts and answer the receipt. */ + const publish = async () => { + const res = await call('POST', `/packages/${PKG}/publish-drafts`, {}); + expect(res.status, JSON.stringify(res.json)).toBe(200); + return res.json?.data as { outcome?: string; publishedCount?: number; seedApplied?: { success: boolean; inserted: number; updated: number } }; + }; + + beforeAll(async () => { + stack = await bootShowcase(); + token = await stack.signIn(); + const created = await call('POST', '/packages', { + manifest: { id: PKG, name: 'Seed key probe', version: '0.1.0', type: 'app', namespace: 'seedkey' }, + }); + expect(created.status, JSON.stringify(created.json)).toBe(201); + const object = await call('PUT', `/meta/object/seedkey_other?mode=draft&package=${PKG}`, { + name: 'seedkey_other', + label: 'Seed key probe', + sharingModel: 'public_read_write', + fields: { name: { type: 'text', label: 'Name' }, code: { type: 'text', label: 'Code' } }, + }); + expect(object.status, JSON.stringify(object.json)).toBe(200); + expect(await publish()).toMatchObject({ outcome: 'published' }); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + /** The refusal's envelope, wherever the REST door nests it. */ + const expectRefused = (res: { status: number; json: any }, path: string, member: string) => { + expect(res.status, JSON.stringify(res.json)).toBe(400); + const wire = JSON.stringify(res.json); + expect(wire).toContain('VALIDATION_ERROR'); + expect(wire).toContain(`'${member}' is not a field of object 'seedkey_other'`); + expect(wire).toContain(`"path":"${path}"`); + }; + + it('the card: a draft save keyed on a field the object lacks is refused, naming it, and nothing is stored', async () => { + const saved = await call('PUT', `/meta/seed/r_ghost?mode=draft&package=${PKG}`, seed('no_such_field')); + expectRefused(saved, 'externalId', 'no_such_field'); + + const drafts = await call('GET', `/meta/_drafts?packageId=${PKG}`); + expect(drafts.status, JSON.stringify(drafts.json)).toBe(200); + expect(JSON.stringify(drafts.json)).not.toContain('r_ghost'); + }); + + it('a composite key with one member the object lacks is refused at that member', async () => { + const saved = await call('PUT', `/meta/seed/r_pair?mode=draft&package=${PKG}`, seed(['code', 'no_such_field'])); + expectRefused(saved, 'externalId.1', 'no_such_field'); + }); + + it('control: the same seed keyed on `name` saves, publishes and converges on replay', async () => { + expect((await call('PUT', `/meta/seed/r_good?mode=draft&package=${PKG}`, seed('name'))).status).toBe(200); + expect((await publish())?.seedApplied).toMatchObject({ success: true, inserted: 1, updated: 0 }); + + expect((await call('PUT', `/meta/seed/r_good?mode=draft&package=${PKG}`, seed('name'))).status).toBe(200); + expect((await publish())?.seedApplied).toMatchObject({ success: true, inserted: 0, updated: 0 }); + + const listed = await call('GET', '/data/seedkey_other'); + expect(listed.status, JSON.stringify(listed.json)).toBe(200); + expect(JSON.stringify(listed.json).match(/"G1"/g) ?? []).toHaveLength(1); + }); + + it('control: an `insert` seed never matches by its key, so it is not judged', async () => { + const saved = await call('PUT', `/meta/seed/r_insert?mode=draft&package=${PKG}`, seed('no_such_field', 'insert')); + expect(saved.status, JSON.stringify(saved.json)).toBe(200); + }); +}); From b8d8083b9d72bea601e7d3ccbe46cba4528ddaf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 05:36:31 +0000 Subject: [PATCH 2/5] test(metadata-protocol): the restore doors refuse a seed version keyed on a field its object lost Claude-Session: https://claude.ai/code/session_01FEkQ5pEjzdJdM36Fgh7YyY Co-Authored-By: Claude --- ....restore-doors-save-refusal-family.test.ts | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts b/packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts index aae885e0f18..dd858bf830b 100644 --- a/packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts +++ b/packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts @@ -8,7 +8,8 @@ * family of shapes after its schema gate: a hook with no `body`, a second * default record page of an object, an `external` object binding on a managed * datasource (both halves of the pair), an object `datasource` that names no - * datasource (#26724), a `runAs: 'system'` flow an anonymous + * datasource (#26724), a keyed seed whose `externalId` names no field of its + * object (#28952), a `runAs: 'system'` flow an anonymous * endpoint names, the shared runtime authoring rules, and the authoring gate a * domain plugin registers. Measured on `origin/main` `5801a4f2c` before this * file: each restore door wrote every one of those shapes back, so a version @@ -95,6 +96,8 @@ function makeStubEngine() { const registered = new Map>(); // [#26724] The drivers the engine has registered by name, with no datasource item. const drivers = new Set(); + /** The engine's registered objects, by name (empty unless a case stages one). */ + const objects = new Map(); let nextId = 0; let commit: unknown = null; @@ -188,7 +191,7 @@ function makeStubEngine() { listItems: (type: string) => Array.from(registered.get(type)?.values() ?? []), getItem: (type: string, name: string) => registered.get(type)?.get(name), getAllObjects: () => [], - getObject: () => undefined, + getObject: (name: string) => objects.get(name), getPackage: () => undefined, getArtifactItem: () => undefined, isPackageDisabled: () => false, @@ -200,6 +203,7 @@ function makeStubEngine() { historyRows, registered, drivers, + objects, serveCommit: (c: unknown) => { commit = c; }, }; } @@ -502,6 +506,31 @@ pinBothDoors({ }, }); +pinBothDoors({ + title: 'a seed restored keyed on a field its object no longer has (seedExternalIdSaveRefusal)', + type: 'seed', + name: 'wh_order_seed', + code: 'VALIDATION_ERROR', + status: 400, + async stage(h) { + const seedOf = (externalId: string, label?: string) => ({ + object: 'wh_order', mode: 'upsert', externalId, records: [{ name: 'O1', code: 'o1' }], + ...(label ? { label } : {}), + }); + h.objects.set('wh_order', { name: 'wh_order', fields: { name: { type: 'text' }, code: { type: 'text' } } }); + await h.protocol.saveMetaItem({ type: 'seed', name: 'wh_order_seed', item: seedOf('code'), mode: 'draft' }); + await h.protocol.saveMetaItem({ type: 'seed', name: 'wh_order_seed', item: seedOf('name'), mode: 'draft' }); + await h.protocol.publishMetaItem({ type: 'seed', name: 'wh_order_seed' }); + // The object lost the field v1 is keyed on. + h.objects.set('wh_order', { name: 'wh_order', fields: { name: { type: 'text' } } }); + return { refused: 1, accepted: 2 }; + }, + expectDetail: (error) => { + expect(error.issues).toEqual([expect.objectContaining({ path: 'externalId', code: 'custom' })]); + expect(error.issues[0].message).toBe("'code' is not a field of object 'wh_order'."); + }, +}); + pinBothDoors({ title: 'a body the runtime authoring rules refuse (assertRuntimeAuthoringRules)', type: 'object', From d9200288b9275176886bf8400c168ef43ec14145 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 06:26:16 +0000 Subject: [PATCH 3/5] test: pin the two new metadata-protocol engine doubles in the double-contract ledger Claude-Session: https://claude.ai/code/session_01FEkQ5pEjzdJdM36Fgh7YyY Co-Authored-By: Claude --- scripts/engine-double-contract.pinned.json | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 1e2dca872ff..28b59204f40 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1891,6 +1891,21 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.select-shorthand-refusal.test.ts", "verb": "delete", @@ -2171,6 +2186,21 @@ "verb": "update", "pinned": 2 }, + { + "file": "packages/metadata-protocol/src/seed-loader-empty-key-warn.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/seed-loader-empty-key-warn.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/seed-loader-empty-key-warn.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/seed-loader-engine-schema-fallback.test.ts", "verb": "delete", From 3d04cc7e44c0a2a3dcbc1447909fbe9e3845cca8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 06:46:30 +0000 Subject: [PATCH 4/5] fix(metadata-protocol): answer the seed externalId refusal with the save door's 422 INVALID_METADATA envelope One door, one shape: the refusal now carries the envelope the unknown datasource refusal uses (422 INVALID_METADATA, "TYPE/NAME failed spec validation", issues at externalId / externalId.N with the prose in each finding), at the save door, the publish promotion and the restore doors. Claude-Session: https://claude.ai/code/session_01FEkQ5pEjzdJdM36Fgh7YyY Co-Authored-By: Claude --- .changeset/28952-seed-external-id-refusal.md | 2 +- .../src/protocol-save-door-refusals.ts | 31 +++++++------ ....restore-doors-save-refusal-family.test.ts | 6 +-- .../protocol.seed-external-id-refusal.test.ts | 46 +++++++++++-------- packages/metadata-protocol/src/protocol.ts | 13 ++++-- .../seed-external-id-refusal.dogfood.test.ts | 11 +++-- 6 files changed, 60 insertions(+), 49 deletions(-) diff --git a/.changeset/28952-seed-external-id-refusal.md b/.changeset/28952-seed-external-id-refusal.md index 24b5aeae191..0c439aa8d58 100644 --- a/.changeset/28952-seed-external-id-refusal.md +++ b/.changeset/28952-seed-external-id-refusal.md @@ -12,7 +12,7 @@ Metadata-protocol: narrowed **BREAKING** accept-set narrowing at the metadata save and publish doors (`@objectstack/metadata-protocol`). - **Before.** A seed with `mode: 'upsert'` and `externalId: 'no_such_field'` was saved (`_diagnostics.valid: true`) and published. The loader keys every existing row and every incoming record on that field, which none of them carries, so every key was empty and nothing matched: each publish answered `seedApplied {"success":true,"inserted":1,"updated":0}` and inserted every record again, with no probe issue and no log line. -- **Now.** The save is refused, `400 VALIDATION_ERROR`, one finding per missing member at `externalId` (`externalId.N` for a composite member), naming the field and the object; nothing is stored. A seed draft stored before this change, or one whose object was still a draft at its save, is refused when it is published, before any seed row is written, and a package publish aborts on it. An object drafted in the same package batch is judged as it will be published. +- **Now.** The save is refused with the save door's envelope for a body it judges (the one an object naming an unknown datasource gets), `422 INVALID_METADATA`, one finding per missing member at `externalId` (`externalId.N` for a composite member), naming the field and the object; nothing is stored. A seed draft stored before this change, or one whose object was still a draft at its save, is refused when it is published, before any seed row is written, and a package publish aborts on it. An object drafted in the same package batch is judged as it will be published. - **What counts as a field.** The object's fields as the engine registers them (extension fields included) and the columns the registry injects (`id`, the audit and tenant columns). - **Not judged.** `insert` and `replace` seeds, which never match by key; a draft seed whose object is itself still a draft (judged at its publish); a seed whose object is neither registered nor drafted (the load reports it); a `migrate-stored` re-save. - **The boot loader never refuses.** For a keyed dataset whose every record has no value for the key, it now logs one `warn` naming the object and the key, and loads exactly as before: a replay of such a stored seed still inserts every record again (`upsert`, `ignore`) or skips it (`update`). diff --git a/packages/metadata-protocol/src/protocol-save-door-refusals.ts b/packages/metadata-protocol/src/protocol-save-door-refusals.ts index 8353d0538bd..9104f0f7818 100644 --- a/packages/metadata-protocol/src/protocol-save-door-refusals.ts +++ b/packages/metadata-protocol/src/protocol-save-door-refusals.ts @@ -694,7 +694,8 @@ export async function seedExternalIdSaveRefusal( item: unknown, name: string, pending?: 'drafts' | ReadonlyArray, -): Promise<(Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] }) | undefined> { + writeFace?: MetadataWriteFace, +): Promise<(Error & { code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[] }) | undefined> { if (singularType !== 'seed') return undefined; if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined; const seed = item as { object?: unknown; mode?: unknown; externalId?: unknown }; @@ -737,22 +738,22 @@ export async function seedExternalIdSaveRefusal( .map((member, index) => ({ member, index })) .filter(({ member }) => typeof member === 'string' && !fieldNames.has(member)); if (missing.length === 0) return undefined; - const spelled = missing.map(({ member }) => `'${String(member)}'`).join(', '); - const err = new Error( - `Correct \`externalId\` on seed '${name}' and send it again. Nothing was written. ` - + (declared - ? `${spelled} ${missing.length === 1 ? 'is not a field' : 'are not fields'} of object '${objectName}', ` - : `It is not declared, so it defaults to 'name', and object '${objectName}' has no field 'name', `) - + `so a load in \`${mode}\` mode could never match a row it wrote before: each publish and each replay would ` - + (mode === 'update' ? 'skip every record. ' : 'insert every record again. ') - + `Name ${composite ? 'only fields' : 'a field'} the object declares, or use \`mode: 'insert'\` for rows with no natural key.`, - ) as Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] }; - err.code = 'VALIDATION_ERROR'; - err.status = 400; - err.issues = missing.map(({ member, index }) => ({ + const consequence = `so a load in \`${mode}\` mode could never match a row it wrote before: each publish and each ` + + `replay would ${mode === 'update' ? 'skip every record' : 'insert every record again'}. Name a field the object ` + + "declares, or use `mode: 'insert'` for rows with no natural key."; + const issues: MetadataIssueEntry[] = missing.map(({ member, index }) => ({ path: composite ? `externalId.${index}` : 'externalId', - message: `'${String(member)}' is not a field of object '${objectName}'.`, + message: (declared + ? `Seed '${name}' keys on externalId member '${String(member)}', which is not a field of object '${objectName}', ` + : `Seed '${name}' declares no externalId, so it keys on the default 'name', which is not a field of object '${objectName}', `) + + consequence, code: 'custom', })); + const err = new Error( + `${singularType}/${name} failed spec validation: ${specValidationFindings(writeFace, issues)}`, + ) as Error & { code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[] }; + err.code = 'INVALID_METADATA'; + err.status = 422; + err.issues = issues; return err; } diff --git a/packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts b/packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts index dd858bf830b..ca05dca987e 100644 --- a/packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts +++ b/packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts @@ -510,8 +510,8 @@ pinBothDoors({ title: 'a seed restored keyed on a field its object no longer has (seedExternalIdSaveRefusal)', type: 'seed', name: 'wh_order_seed', - code: 'VALIDATION_ERROR', - status: 400, + code: 'INVALID_METADATA', + status: 422, async stage(h) { const seedOf = (externalId: string, label?: string) => ({ object: 'wh_order', mode: 'upsert', externalId, records: [{ name: 'O1', code: 'o1' }], @@ -527,7 +527,7 @@ pinBothDoors({ }, expectDetail: (error) => { expect(error.issues).toEqual([expect.objectContaining({ path: 'externalId', code: 'custom' })]); - expect(error.issues[0].message).toBe("'code' is not a field of object 'wh_order'."); + expect(error.issues[0].message.startsWith("Seed 'wh_order_seed' keys on externalId member 'code', which is not a field of object 'wh_order'")).toBe(true); }, }); diff --git a/packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts b/packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts index 32cc0b82cb7..ac3f3d074f5 100644 --- a/packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts +++ b/packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts @@ -13,8 +13,10 @@ * * Pinned here, through `saveMetaItem`, `publishMetaItem`, * `publishPackageDrafts` and the restore door: - * - the refusal: `400 VALIDATION_ERROR`, one finding per missing member at - * `externalId` (`externalId.N` for a composite member), nothing stored + * - the refusal: the save door's envelope for a body it judges (the one + * the unknown-datasource refusal answers), `422 INVALID_METADATA`, one + * finding per missing member at `externalId` (`externalId.N` for a + * composite member), naming the field and the object, nothing stored * (save), nothing promoted and no seed row written (publish of a draft * stored before the rule; the batch aborts); * - an undeclared `externalId` is `SeedSchema`'s default, `name`, and is @@ -27,7 +29,7 @@ * registered nor drafted, and a `migrate-stored` re-save. * * Each refusal is asserted by its code, its status, its path and its - * message's first sentence, spelled out here. + * finding's first clause, spelled out here. * * Harness: the stateful `sys_metadata` double of * `protocol.unknown-datasource-refusal.test.ts`, kept local, with a registry @@ -196,15 +198,21 @@ function plantSeedDraft(rows: Map, name: string, body: Record `Correct \`externalId\` on seed '${seedName}' and send it again. Nothing was written.`; - -const expectRefusal = (err: any, seedName: string, findings: Array<{ path: string; member: string; object: string }>) => { - expect({ code: err?.code, status: err?.status }, String(err?.message)).toEqual({ code: 'VALIDATION_ERROR', status: 400 }); - expect(String(err.message).startsWith(firstSentence(seedName)), String(err.message)).toBe(true); - expect(String(err.message).length).toBeLessThanOrEqual(500); - expect(err.issues).toEqual(findings.map((f) => ({ - path: f.path, code: 'custom', message: `'${f.member}' is not a field of object '${f.object}'.`, - }))); +/** A declared member's finding, up to its consequence. */ +const memberFinding = (seedName: string, member: string, objectName: string) => + `Seed '${seedName}' keys on externalId member '${member}', which is not a field of object '${objectName}', `; + +const expectRefusal = (err: any, seedName: string, findings: Array<{ path: string; member: string; object: string; implicit?: boolean }>) => { + expect({ code: err?.code, status: err?.status }, String(err?.message)).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(String(err.message).startsWith(`seed/${seedName} failed spec validation: `), String(err.message)).toBe(true); + expect(err.issues.map((i: any) => ({ path: i.path, code: i.code }))).toEqual(findings.map((f) => ({ path: f.path, code: 'custom' }))); + findings.forEach((f, n) => { + const first = f.implicit + ? `Seed '${seedName}' declares no externalId, so it keys on the default 'name', which is not a field of object '${f.object}', ` + : memberFinding(seedName, f.member, f.object); + expect(String(err.issues[n].message).startsWith(first), err.issues[n].message).toBe(true); + expect(String(err.issues[n].message).length).toBeLessThanOrEqual(500); + }); }; const OBJECTS = { seedkey_other: registered('seedkey_other') }; @@ -214,7 +222,7 @@ describe('[#28952] the seed save door — an `externalId` that names no field of ['a draft save into a package', { mode: 'draft', packageId: PKG }], ['a draft save naming no package', { mode: 'draft' }], ['a publish save', {}], - ])('%s is refused 400 at `externalId`, naming the field, and nothing is stored or loaded', async (_label, saveOpts) => { + ])('%s is refused 422 at `externalId`, naming the field, and nothing is stored or loaded', async (_label, saveOpts) => { const { protocol, rows, dataInserts } = makeSession({ objects: OBJECTS }); const err = await refusalOf(protocol.saveMetaItem({ @@ -222,7 +230,6 @@ describe('[#28952] the seed save door — an `externalId` that names no field of })); expectRefusal(err, 'r_ghost', [{ path: 'externalId', member: 'no_such_field', object: 'seedkey_other' }]); - expect(String(err.message)).toContain("'no_such_field' is not a field of object 'seedkey_other'"); expect(stored(rows, 'seed', 'r_ghost')).toEqual([]); expect(dataInserts).toEqual([]); }); @@ -253,8 +260,7 @@ describe('[#28952] the seed save door — an `externalId` that names no field of type: 'seed', name: 'r_default', item: { object: 'seedkey_coded', records: [{ code: 'g' }] }, mode: 'draft', })); - expectRefusal(err, 'r_default', [{ path: 'externalId', member: 'name', object: 'seedkey_coded' }]); - expect(String(err.message)).toContain("It is not declared, so it defaults to 'name'"); + expectRefusal(err, 'r_default', [{ path: 'externalId', member: 'name', object: 'seedkey_coded', implicit: true }]); }); }); @@ -278,8 +284,8 @@ describe('[#28952] the publish doors — a seed draft stored before the rule', ( expect(res).toMatchObject({ outcome: 'refused', success: false, publishedCount: 0, published: [] }); const causal = res.failed.find((f: any) => f.name === 'r_ghost'); - expect(causal, JSON.stringify(res.failed)).toMatchObject({ type: 'seed', code: 'VALIDATION_ERROR' }); - expect(JSON.stringify(causal)).toContain("'no_such_field' is not a field of object 'seedkey_other'"); + expect(causal, JSON.stringify(res.failed)).toMatchObject({ type: 'seed', code: 'INVALID_METADATA' }); + expect(JSON.stringify(causal)).toContain("externalId member 'no_such_field', which is not a field of object 'seedkey_other'"); expect(stored(rows, 'seed', 'r_ghost')).toEqual([`draft|${PKG}|"no_such_field"`]); expect(dataInserts).toEqual([]); }); @@ -298,8 +304,8 @@ describe('[#28952] the publish doors — a seed draft stored before the rule', ( expect(res).toMatchObject({ outcome: 'refused', success: false, publishedCount: 0 }); const causal = res.failed.find((f: any) => f.name === 'r_new'); - expect(causal, JSON.stringify(res.failed)).toMatchObject({ type: 'seed', code: 'VALIDATION_ERROR' }); - expect(JSON.stringify(causal)).toContain("'no_such_field' is not a field of object 'seedkey_new'"); + expect(causal, JSON.stringify(res.failed)).toMatchObject({ type: 'seed', code: 'INVALID_METADATA' }); + expect(JSON.stringify(causal)).toContain("externalId member 'no_such_field', which is not a field of object 'seedkey_new'"); expect(stored(rows, 'seed', 'r_new')).toEqual([`draft|${PKG}|"no_such_field"`]); expect(dataInserts).toEqual([]); }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 96642a86ae5..53a4b235fb2 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -20943,8 +20943,10 @@ export class ObjectStackProtocolImplementation implements * body, so a seed stored before this refusal or whose object lost the * field is refused when it is published and a batch publish aborts on * it, and by the restore doors ({@link refuseRestoredBodyTheSaveDoorRefuses}). - * Answered `400 VALIDATION_ERROR`, one finding per missing member at - * `externalId` (`externalId.N` for a composite member). The boot loader + * Answered as the schema gate answers, with the envelope of + * {@link unknownDatasourceSaveRefusal}: `422 INVALID_METADATA`, one finding + * per missing member at `externalId` (`externalId.N` for a composite + * member), naming the field and the object. The boot loader * never refuses; it warns once for a keyed dataset whose every record * keys empty ({@link SeedLoaderService}). */ @@ -20953,13 +20955,14 @@ export class ObjectStackProtocolImplementation implements item: unknown, name: string, pending?: 'drafts' | ReadonlyArray, - ): Promise<(Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] }) | undefined> { + writeFace?: MetadataWriteFace, + ): Promise<(Error & { code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[] }) | undefined> { const readEngine = () => this.engine; const host: ThisParameterType = { get engine() { return readEngine(); }, listDrafts: (...a) => this.listDrafts(...a), }; - return seedExternalIdSaveRefusal.call(host, singularType, item, name, pending); + return seedExternalIdSaveRefusal.call(host, singularType, item, name, pending, writeFace); } /** @@ -22641,7 +22644,7 @@ export class ObjectStackProtocolImplementation implements // existing row and is not judged. See {@link seedExternalIdSaveRefusal}. if (writeSource !== 'migrate-stored') { const seedKeyRefusal = await this.seedExternalIdSaveRefusal( - singularType, request.item, request.name, mode === 'draft' ? 'drafts' : undefined, + singularType, request.item, request.name, mode === 'draft' ? 'drafts' : undefined, request.writeFace, ); if (seedKeyRefusal) throw seedKeyRefusal; } diff --git a/packages/qa/dogfood/test/seed-external-id-refusal.dogfood.test.ts b/packages/qa/dogfood/test/seed-external-id-refusal.dogfood.test.ts index 4c2b26b8df6..405c17704ae 100644 --- a/packages/qa/dogfood/test/seed-external-id-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/seed-external-id-refusal.dogfood.test.ts @@ -16,8 +16,9 @@ // // ## What these cases pin // -// The save door refuses it, `400 VALIDATION_ERROR` with the finding at -// `externalId` naming the field (`externalId.N` for a composite member), and +// The save door refuses it with the envelope it gives every body it judges +// (the unknown-datasource refusal's), `422 INVALID_METADATA` with the finding +// at `externalId` naming the field (`externalId.N` for a composite member), and // nothing is stored, so `publish-drafts` has nothing of it to publish. // Controls: the same seed keyed on `name` saves, publishes and converges on // replay (`inserted: 1`, then `inserted: 0, updated: 0`, one row served); an @@ -80,10 +81,10 @@ describe('dogfood: a seed whose externalId names no field of its object is refus /** The refusal's envelope, wherever the REST door nests it. */ const expectRefused = (res: { status: number; json: any }, path: string, member: string) => { - expect(res.status, JSON.stringify(res.json)).toBe(400); + expect(res.status, JSON.stringify(res.json)).toBe(422); const wire = JSON.stringify(res.json); - expect(wire).toContain('VALIDATION_ERROR'); - expect(wire).toContain(`'${member}' is not a field of object 'seedkey_other'`); + expect(wire).toContain('INVALID_METADATA'); + expect(wire).toContain(`keys on externalId member '${member}', which is not a field of object 'seedkey_other'`); expect(wire).toContain(`"path":"${path}"`); }; From 9127f436332169631f997380f1a74079598e612f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:26:47 +0000 Subject: [PATCH 5/5] test(metadata-protocol): key the seed refusal fixture on an sku column The fixture's text column was named code, so a record literal read as a lowercase error code to the ADR-0112 casing gate. Renamed to sku; no behaviour change. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5pEjzdJdM36Fgh7YyY --- .../protocol.seed-external-id-refusal.test.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts b/packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts index ac3f3d074f5..88a8e94c8a8 100644 --- a/packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts +++ b/packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts @@ -164,8 +164,8 @@ const stored = (rows: Map, type: string, name: string) => .map((r) => `${r.state}|${r.package_id ?? '-'}|${JSON.stringify(JSON.parse(r.metadata).externalId ?? null)}`) .sort(); -/** A schema-valid object body with `name` and `code` text fields (or the given ones). */ -const object = (name: string, fields: string[] = ['name', 'code']) => ({ +/** A schema-valid object body with `name` and `sku` text fields (or the given ones). */ +const object = (name: string, fields: string[] = ['name', 'sku']) => ({ name, label: 'Seed Key', sharingModel: 'private', @@ -173,7 +173,7 @@ const object = (name: string, fields: string[] = ['name', 'code']) => ({ }); /** The registered (served) form: the authored fields plus the columns the registry injects. */ -const registered = (name: string, fields: string[] = ['name', 'code']) => ({ +const registered = (name: string, fields: string[] = ['name', 'sku']) => ({ ...object(name, fields), fields: { ...object(name, fields).fields, @@ -186,7 +186,7 @@ const seed = (target: string, externalId: string | string[] | undefined, mode = object: target, mode, ...(externalId !== undefined ? { externalId } : {}), - records: [{ name: 'G1', code: 'g', organization_id: 'org_seedkey' }], + records: [{ name: 'G1', sku: 'g', organization_id: 'org_seedkey' }], }); /** Plant a seed draft as a draft stored before the rule sits at rest. */ @@ -246,7 +246,7 @@ describe('[#28952] the seed save door — an `externalId` that names no field of const { protocol, rows } = makeSession({ objects: OBJECTS }); const err = await refusalOf(protocol.saveMetaItem({ - type: 'seed', name: 'r_pair', item: seed('seedkey_other', ['code', 'no_such_field']), mode: 'draft', + type: 'seed', name: 'r_pair', item: seed('seedkey_other', ['sku', 'no_such_field']), mode: 'draft', })); expectRefusal(err, 'r_pair', [{ path: 'externalId.1', member: 'no_such_field', object: 'seedkey_other' }]); @@ -254,13 +254,13 @@ describe('[#28952] the seed save door — an `externalId` that names no field of }); it("an undeclared `externalId` is the default 'name', refused on an object with no `name` field", async () => { - const { protocol } = makeSession({ objects: { seedkey_coded: registered('seedkey_coded', ['code']) } }); + const { protocol } = makeSession({ objects: { seedkey_sku: registered('seedkey_sku', ['sku']) } }); const err = await refusalOf(protocol.saveMetaItem({ - type: 'seed', name: 'r_default', item: { object: 'seedkey_coded', records: [{ code: 'g' }] }, mode: 'draft', + type: 'seed', name: 'r_default', item: { object: 'seedkey_sku', records: [{ sku: 'g' }] }, mode: 'draft', })); - expectRefusal(err, 'r_default', [{ path: 'externalId', member: 'name', object: 'seedkey_coded', implicit: true }]); + expectRefusal(err, 'r_default', [{ path: 'externalId', member: 'name', object: 'seedkey_sku', implicit: true }]); }); }); @@ -324,9 +324,9 @@ describe('[#28952] the restore door — a seed version whose `externalId` names describe('[#28952] controls — an `externalId` the object has is not refused', () => { it.each([ ["`externalId: 'name'`", 'name'], - ["`externalId: 'code'`", 'code'], + ["`externalId: 'sku'`", 'sku'], ['an injected column, `id`', 'id'], - ['a composite whose every member is a field', ['name', 'code']], + ['a composite whose every member is a field', ['name', 'sku']], ["undeclared (the default 'name')", undefined], ])('%s: saves as a draft, publishes, and saves live', async (_label, externalId) => { const { protocol, rows, dataInserts } = makeSession({ objects: OBJECTS }); @@ -349,12 +349,12 @@ describe('[#28952] controls — an `externalId` the object has is not refused', it('an object drafted in the same batch that declares the field publishes with the seed', async () => { const { protocol, rows } = makeSession(); await protocol.saveMetaItem({ type: 'object', name: 'seedkey_new', item: object('seedkey_new'), mode: 'draft', packageId: PKG }); - await protocol.saveMetaItem({ type: 'seed', name: 'r_new', item: seed('seedkey_new', 'code'), mode: 'draft', packageId: PKG }); + await protocol.saveMetaItem({ type: 'seed', name: 'r_new', item: seed('seedkey_new', 'sku'), mode: 'draft', packageId: PKG }); const res = await protocol.publishPackageDrafts({ packageId: PKG }); expect(res, JSON.stringify(res.failed)).toMatchObject({ outcome: 'published', publishedCount: 2 }); - expect(stored(rows, 'seed', 'r_new')).toEqual([`active|${PKG}|"code"`]); + expect(stored(rows, 'seed', 'r_new')).toEqual([`active|${PKG}|"sku"`]); }); it('an object neither registered nor drafted is not judged here (the load reports it)', async () => {