Skip to content

Commit 3cf7f61

Browse files
fix(metadata-protocol)!: refuse a keyed seed whose externalId names no field of its object (#29066)
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X7V7Gq8G5nk7gPLT3UNF6Sk)_ Fixes #28952 Clause-②: no (narrowing) Metadata-protocol: narrowed ## 维护者速读 - **改了什么**:种子(seed)在 `upsert` / `update` / `ignore` 模式下,如果 `externalId`(组合键的任一成员;没写时默认是 `name`)不是目标对象的字段,保存、发布和恢复版本时都会被拒绝(`422 INVALID_METADATA`,和对象数据源写错时同一种拒绝格式,指出是哪个字段、哪个对象),什么都不写入。启动时加载种子从不拒绝,只在某个数据集的每条记录都取不到键值时打一行 warn。 - **为什么改**:原来这种种子保存时显示有效,每次发布都回报「成功、新增 1 条」,表里却每发布一次就多一份重复数据,没有任何提示,只有最终用户会发现。 - **风险与回滚**:低到中。这是对保存/发布入口的收紧:以前能存进去的这类种子现在会被拒。实测本仓库 4 个示例、平台包和 hotcrm 里没有一个种子命中。`insert` / `replace` 模式不受影响。回滚即撤销本 PR。 - **先看**:`packages/metadata-protocol/src/protocol-save-door-refusals.ts`(新增的 `seedExternalIdSaveRefusal`,判断对象字段的逻辑在这里);`packages/metadata-protocol/src/protocol.ts`(保存入口和发布晋升里的两处调用);`packages/metadata-protocol/src/seed-loader.ts`(启动加载的那行 warn)。 - **两分钟验证**:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.seed-external-id-refusal.test.ts`,预期 22/22 通过(main 上其中 12 条拒绝用例是红的,对照组是绿的)。 - **需要你做的**:无,席位落地。 ## What changes **The refusal.** New `seedExternalIdSaveRefusal` in `packages/metadata-protocol/src/protocol-save-door-refusals.ts`, beside the other save-door refusals, with its class method in `protocol.ts`. - **Judged.** A `seed` in a keyed mode (`upsert`, `update`, `ignore`). Each member of `externalId` is checked; an undeclared `externalId` is `SeedSchema`'s default, `name`, the same default the loader applies (`dataset.externalId || 'name'`). `insert` and `replace` never match by key and are not judged. - **Against what.** The object as the publish will have it: a draft of it in the same package batch (`request.pending.objects`, the batch closure `publishPackageDrafts` already collects), else the engine's registered definition (`engine.registry.getObject`), whose fields include extension fields. The columns the registry injects count, through the spec's `resolveInjectedSystemColumns` (so `id` and the audit and tenant columns are fields). - **Not refused when the object cannot be resolved.** On a draft save, a seed whose object is itself still a draft is left for its publish, where the batch's object draft is judged (the order's "object resolved only at publish" case). A seed whose object is neither registered nor drafted is not judged at all: the load already reports an object it cannot find. - **Envelope.** The save door's envelope for a body it judges, the one `unknownDatasourceSaveRefusal` (#26724) answers: `422 INVALID_METADATA`, message `seed/NAME failed spec validation: ` plus `specValidationFindings` (the REST faces carry the headline and the prose lives in `issues[]`), and `issues[]` with one finding per missing member at `externalId` (`externalId.N` for a composite member), `code: 'custom'`. Each finding opens "Seed 'NAME' keys on externalId member 'FIELD', which is not a field of object 'OBJECT'" (or, undeclared, "declares no externalId, so it keys on the default 'name'"), then the consequence and the remedy, under the 500-character REST cap. No new error code. **Where it is asked.** The same three places `unknownDatasourceSaveRefusal` is: - `saveMetaItem`, draft and publish mode, after the schema and before the authoring gate and every write. A `migrate-stored` re-save (the platform healing its own storage) is not judged. - `promoteDraftForPublish`, on the draft body, before `repo.promoteDraft` writes and before any seed row is loaded. It covers a seed draft stored before this change, one whose object was a draft at its save, and one whose object has since lost the field. `publishPackageDrafts` aborts the batch on it (`outcome: 'refused'`), and `publishMetaItem` refuses it. - The restore doors, through `refuseRestoredBodyTheSaveDoorRefuses` (`rollbackMetaItem`, `revertCommit`). **The boot loader warns, never refuses.** In `SeedLoaderService.loadDataset` (`seed-loader.ts`), a keyed dataset whose every record has no value for its key logs one `warn` per dataset, naming the object, the key and the mode. Its writes are unchanged. A replay of such a stored seed on this head still inserts every record again (`upsert`, `ignore`) or skips it (`update`); the loader test pins that, so the behaviour the doors now stop is stated rather than implied. **Changeset** `.changeset/28952-seed-external-id-refusal.md`: `minor` on `@objectstack/metadata-protocol`, both declaration lines, the BREAKING banner and the ADR-0087 `not-required (no-migration-prescription)` marker. ## Tests - **Step 1, the measurement (before any refusal).** Every seed module in `examples/app-showcase`, `app-crm`, `app-todo`, `app-multi-package`, the platform packages and hotcrm (`d4ffe99`) was imported and each keyed seed's `externalId` checked against its object's fields plus the injected columns. 47 keyed seeds (showcase 19, crm 5, todo 1, hotcrm 22; the platform packages and app-multi-package ship none), all with a declared `externalId`: **0 dangling**, 0 relying on an implicit `name` the object lacks. Two targeted platform objects the scan could not load were read by hand: `sys_business_unit` keyed on `id` (the primary key) and hotcrm's `sys_activity` keyed on `['source_object', 'source_id']` (both declared in `plugin-audit`). - **New `packages/metadata-protocol/src/protocol.seed-external-id-refusal.test.ts`** (22 tests): the refusal at a draft save into a package, a draft save with no package and a publish save; each keyed mode; a composite with one bad member (`externalId.1`); an undeclared key on an object with no `name`; `publishMetaItem` and `publishPackageDrafts` on a seed draft stored before the rule (draft stays a draft, the batch promotes nothing, no seed row written); an object drafted in the same batch that lacks the field; the restore door. Controls: `name`, `code`, `id`, a composite of declared fields and an undeclared key on an object with `name` each save, publish (and load: the double records the seed row) and save live; `insert` and `replace`; an object drafted in the same batch that declares the field; an object neither registered nor drafted; a `migrate-stored` re-save. - **New `packages/metadata-protocol/src/seed-loader-empty-key-warn.test.ts`** (5 tests): one `warn` per load naming object and key, `success: true`, and a replay inserting the row again (2 rows); a composite member no record carries; controls: a declared key converging (`inserted: 1`, then `inserted: 0, updated: 0`) with no warn, an `insert` dataset and a dataset where some record carries the key. - **`protocol.restore-doors-save-refusal-family.test.ts`**: one more `pinBothDoors` case, a seed version keyed on a field its object has since lost, refused at `rollbackMetaItem` and `revertCommit` with nothing stored; its controls restore the accepted version. The harness's registry gained an `objects` map (empty for every other case). - **New dogfood case `packages/qa/dogfood/test/seed-external-id-refusal.dogfood.test.ts`** (4 tests, the card's repro over the real showcase and REST): the draft `PUT /api/v1/meta/seed/r_ghost` refused 422 `INVALID_METADATA` at `externalId` with nothing in `_drafts`; the composite refused at `externalId.1`; control: `externalId: 'name'` publishes `inserted: 1`, republishes `inserted: 0, updated: 0`, and `GET /api/v1/data/seedkey_other` serves one `G1`; control: an `insert` seed saves. - **Red on base `62b3ec36`.** The three in-process files with the four source files restored to base: 16 failed, 47 passed, every red a refusal or warn pin and every control green. The dogfood case on a base build of `@objectstack/metadata-protocol`: 3 failed, 1 passed. Its control failed too, `inserted: 2` instead of `1`, because the ghost seed the base had accepted was published in the same batch: the card's duplication, measured. <details><summary>Ablation (one-off, from committed HEAD)</summary> The save door's check in `saveMetaItem` was disabled with `node scripts/ablation-replace.mjs` (`if (seedKeyRefusal) throw seedKeyRefusal;` → `if (seedKeyRefusal) console.debug('ABLATION_28952_SAVE_DOOR');`): anchor 1 → 0, blob `96642a86ae58` → `18dc7463b075`. `@objectstack/metadata-protocol` was rebuilt and `scripts/ablation-dist-preflight.mjs` read the marker in 2 built files. Readings: the dogfood case 3 failed, 1 passed (the card and composite refusals, and the control, whose publish now carried the ghost seed and was refused by the publish promotion); `protocol.seed-external-id-refusal.test.ts` 8 failed, 14 passed (the save-door cases; the publish-door cases stay green, as they should). Restored: blob equals HEAD `96642a86ae58`, `git diff HEAD` empty; rebuilt, the preflight `--absent` read the marker in 0 of 24 built files with the tree clean; the dogfood case passed 4/4 again. A first attempt used `void 'ABLATION_28952_SAVE_DOOR'` as the marker. The bundler drops that expression, so the preflight read it as absent from dist even though the dogfood case went red; it was redone with the `console.debug` marker above, and only the second run's readings are quoted. </details> ## Acceptance notes - **Envelope.** The order first named `400 VALIDATION_ERROR`; the seat then ruled one door, one shape, so the refusal answers with the unknown-datasource refusal's `422 INVALID_METADATA` envelope at all three sites (commit `3d04cc7e`). - **"The publish probe".** The L3 probe pass (`runBuildProbes`, `probes.checked.seeds`) runs after the publish has written and by contract never fails it, so it cannot refuse "before any write". The publish-side refusal therefore sits in `promoteDraftForPublish`, where every publish path promotes a draft and the sibling refusals already sit. `build-probes.ts` is unchanged. - **Implicit `name`.** An undeclared `externalId` is judged as `name`, because the loader keys on `name` and the duplication is the same. The measurement found no shipped seed relying on it. - **Extension fields in the same batch.** An object's registered definition includes its extension fields. An `object_extension` drafted in the same package batch is not in the batch closure this reads, so a seed keyed on a field only that pending extension adds would be refused at the batch publish. No shipped seed does this; noted, not filed. - **Loader warn level.** The order fixed `warn`. Under AGENTS.md's degradation rule the duplicate rows are a data-consistency outcome, but the doors now refuse such a seed at authoring and the boot must not fail, so the remaining case is a stored seed from before this change. ## Verification Head `d9200288` (base `62b3ec36`). `origin/main` moved 9 commits since the base, none touching `packages/metadata-protocol`, `packages/qa/dogfood` or the double ledger; the branch was not merged with it, and the merge queue tests the merged tree. <details><summary>Package tests, typecheck, lint</summary> - `pnpm --filter @objectstack/metadata-protocol test`: 299 files passed (4 skipped), 22137 tests passed (26 skipped). - `pnpm --filter @objectstack/metadata-protocol --filter @objectstack/dogfood typecheck`: exit 0 (the metadata-protocol `tsconfig` includes `src/**/*`, test files too). - `pnpm exec eslint --no-inline-config packages/metadata-protocol/src packages/qa/dogfood/test/seed-external-id-refusal.dogfood.test.ts`: exit 0. </details> <details><summary>Dogfood battery (a narrowing)</summary> `pnpm --filter @objectstack/dogfood test` under the verify lock, after the `@objectstack/dogfood^...` closure build: VERDICT command-exit 0, Test Files 384 passed (3 skipped), Tests 3167 passed (28 skipped), at `b8d8083b`. The one later commit, `d9200288`, changes only `scripts/engine-double-contract.pinned.json`, which no dogfood case reads. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands` on this diff named 73 families (65 before the ledger commit, 8 more after it). All 73 exit 0, and `--ran` answers: 73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN. - First pass, three non-zero exits, each fixed and re-run: `check:engine-double-contract` exit 1 (the two new test doubles were not pinned; `--write`, committed in `d9200288`, then exit 0); `check-plugin-teardown-shape --self-test` exit 3 (shallow clone; the fixture commit fetched, then exit 0); `check:dual-build-cjs-loads` exit 3 (prerequisite: not every package had `dist/`; after a full `turbo run build`, 118/118 tasks, exit 0: 115 require entry points across 70 packages load). - The 8 families the ledger commit added (`check:agent-test-spelling`, `check:bash32-floor`, `check:cli-command-ids`, `check:entry-guard`, `check:parse-guard`, `check:pnpm-filter-targets`, `check:pnpm-flag-spellings`, `check:scripts-symbol-anchors`): exit 0 at `d9200288`. - The other 62 ran at `b8d8083b`. CI runs the full farm on this head. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5pEjzdJdM36Fgh7YyY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 692e0dc commit 3cf7f61

10 files changed

Lines changed: 941 additions & 5 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
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)
6+
7+
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.
8+
Metadata-protocol: narrowed
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: the save and publish doors now judge an existing key, `externalId`, against the seed's object. A seed that names a field its object has saves as before; the fix for a refused one is to name a field the object declares, which no migration can choose for the author. -->
11+
12+
**BREAKING** accept-set narrowing at the metadata save and publish doors (`@objectstack/metadata-protocol`).
13+
14+
- **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.
15+
- **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.
16+
- **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).
17+
- **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.
18+
- **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`).
19+
- **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.
20+
21+
**The remedy.** Set `externalId` to a field the object declares, or use `mode: 'insert'` for rows with no natural key.

‎packages/metadata-protocol/src/protocol-authoring-gate.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export interface AuthoringGateHost {
9090
runAuthoringGate: ObjectStackProtocolImplementation['runAuthoringGate'];
9191
tenancyPostureInForce: ObjectStackProtocolImplementation['tenancyPostureInForce'];
9292
unknownDatasourceSaveRefusal: ObjectStackProtocolImplementation['unknownDatasourceSaveRefusal'];
93+
seedExternalIdSaveRefusal: ObjectStackProtocolImplementation['seedExternalIdSaveRefusal'];
9394
}
9495

9596
/** The body of `ObjectStackProtocolImplementation#restoredBodyDerivation`; the method's docblock there is its contract. */
@@ -123,7 +124,7 @@ export function restoredBodyWriter(
123124

124125
/** The body of `ObjectStackProtocolImplementation#refuseRestoredBodyTheSaveDoorRefuses`; the method's docblock there is its contract. */
125126
export async function refuseRestoredBodyTheSaveDoorRefuses(
126-
this: Pick<AuthoringGateHost, 'anonymousElevatedFlowRefusal' | 'assertRuntimeAuthoringRules' | 'authoringChannel' | 'credentialChannelFor' | 'defaultRecordPageSaveRefusal' | 'externalBindingSaveRefusal' | 'getOverlayRepo' | 'permissionSetNameCollisionRefusal' | 'runAuthoringGate' | 'unknownDatasourceSaveRefusal'>,
127+
this: Pick<AuthoringGateHost, 'anonymousElevatedFlowRefusal' | 'assertRuntimeAuthoringRules' | 'authoringChannel' | 'credentialChannelFor' | 'defaultRecordPageSaveRefusal' | 'externalBindingSaveRefusal' | 'getOverlayRepo' | 'permissionSetNameCollisionRefusal' | 'runAuthoringGate' | 'seedExternalIdSaveRefusal' | 'unknownDatasourceSaveRefusal'>,
127128
type: string,
128129
name: string,
129130
body: unknown,
@@ -140,6 +141,8 @@ export async function refuseRestoredBodyTheSaveDoorRefuses(
140141
if (bindingRefusal) throw bindingRefusal;
141142
const datasourceRefusal = await this.unknownDatasourceSaveRefusal(type, body, name);
142143
if (datasourceRefusal) throw datasourceRefusal;
144+
const seedKeyRefusal = await this.seedExternalIdSaveRefusal(type, body, name);
145+
if (seedKeyRefusal) throw seedKeyRefusal;
143146
const repo = this.getOverlayRepo();
144147
const credentialsRefRefusal = await datasourceCredentialsRefSaveRefusal({
145148
headAt: (...a) => repo.headAt(...a), type, name, item: body,

‎packages/metadata-protocol/src/protocol-save-door-refusals.ts‎

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ import {
6262
import { resolveItemLock, withItemLockFamily } from './item-lock.js';
6363
import type { ItemAddress } from './item-lock.js';
6464
import { itemDiscriminator } from '@objectstack/metadata-core';
65-
import { externalBindingDatasourceRefusal } from '@objectstack/spec/data';
65+
import { externalBindingDatasourceRefusal, resolveInjectedSystemColumns } from '@objectstack/spec/data';
6666
import { METADATA_ITEM_NAME_PATTERN } from '@objectstack/spec/shared';
6767
import { defaultRecordPageObject } from '@objectstack/spec/ui';
6868
import { MetadataProtectionFields, validateObjectNamespacePrefix } from '@objectstack/spec/kernel';
@@ -677,3 +677,83 @@ export function objectNamespacePrefixViolation(
677677
if (typeof packageId !== 'string' || packageId === '') return null;
678678
return validateObjectNamespacePrefix(name, this.engine?.registry?.getPackage?.(packageId)?.manifest?.namespace);
679679
}
680+
681+
/** The seed modes whose load matches incoming records to existing rows by the dataset's `externalId`. */
682+
const KEYED_SEED_MODES: ReadonlySet<string> = new Set(['upsert', 'update', 'ignore']);
683+
684+
/**
685+
* The body of `ObjectStackProtocolImplementation#seedExternalIdSaveRefusal`;
686+
* the method's docblock there is its contract. Written here, beside the other
687+
* refusals the save door and the publish promotion ask in turn, rather than
688+
* moved: its reads are the engine's registered object and, on a draft save,
689+
* the object drafts.
690+
*/
691+
export async function seedExternalIdSaveRefusal(
692+
this: Pick<SaveDoorRefusalHost, 'engine' | 'listDrafts'>,
693+
singularType: string,
694+
item: unknown,
695+
name: string,
696+
pending?: 'drafts' | ReadonlyArray<unknown>,
697+
writeFace?: MetadataWriteFace,
698+
): Promise<(Error & { code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[] }) | undefined> {
699+
if (singularType !== 'seed') return undefined;
700+
if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined;
701+
const seed = item as { object?: unknown; mode?: unknown; externalId?: unknown };
702+
if (typeof seed.object !== 'string' || seed.object === '') return undefined;
703+
const objectName = seed.object;
704+
// `SeedSchema`'s defaults, as the loader applies them (`mode: 'upsert'`,
705+
// `externalId: 'name'`): a body read before the parse filled them keys the
706+
// same way a parsed one does.
707+
const mode = typeof seed.mode === 'string' ? seed.mode : 'upsert';
708+
if (!KEYED_SEED_MODES.has(mode)) return undefined;
709+
const declared = seed.externalId !== undefined;
710+
const externalId = declared ? seed.externalId : 'name';
711+
const composite = Array.isArray(externalId);
712+
const members = (composite ? externalId : [externalId]) as unknown[];
713+
// The object as the publish will have it: a draft of it in the same
714+
// package batch, else the engine's registered definition (its fields as
715+
// the runtime serves them, extension fields and injected columns
716+
// included). On a draft save, an object that is itself still a draft may
717+
// gain the field before it is published, so the seed is judged at its
718+
// publish instead. An object resolved by neither is not judged here:
719+
// the load reports an object it cannot find.
720+
const pendingObject = Array.isArray(pending)
721+
? pending.find((o) => (o as { name?: unknown } | null)?.name === objectName) as { fields?: unknown } | undefined
722+
: undefined;
723+
if (!pendingObject && pending === 'drafts'
724+
&& (await this.listDrafts({ type: 'object' })).drafts.some((d) => d.name === objectName)) {
725+
return undefined;
726+
}
727+
const registered = (this.engine?.registry as { getObject?(name: string): unknown } | undefined)
728+
?.getObject?.(objectName) as { fields?: unknown } | undefined;
729+
if (!pendingObject && !registered) return undefined;
730+
const fieldNames = new Set<string>(resolveInjectedSystemColumns(pendingObject ?? registered).names);
731+
for (const def of [registered, pendingObject]) {
732+
const fields = def?.fields;
733+
if (fields && typeof fields === 'object' && !Array.isArray(fields)) {
734+
for (const key of Object.keys(fields)) fieldNames.add(key);
735+
}
736+
}
737+
const missing = members
738+
.map((member, index) => ({ member, index }))
739+
.filter(({ member }) => typeof member === 'string' && !fieldNames.has(member));
740+
if (missing.length === 0) return undefined;
741+
const consequence = `so a load in \`${mode}\` mode could never match a row it wrote before: each publish and each `
742+
+ `replay would ${mode === 'update' ? 'skip every record' : 'insert every record again'}. Name a field the object `
743+
+ "declares, or use `mode: 'insert'` for rows with no natural key.";
744+
const issues: MetadataIssueEntry[] = missing.map(({ member, index }) => ({
745+
path: composite ? `externalId.${index}` : 'externalId',
746+
message: (declared
747+
? `Seed '${name}' keys on externalId member '${String(member)}', which is not a field of object '${objectName}', `
748+
: `Seed '${name}' declares no externalId, so it keys on the default 'name', which is not a field of object '${objectName}', `)
749+
+ consequence,
750+
code: 'custom',
751+
}));
752+
const err = new Error(
753+
`${singularType}/${name} failed spec validation: ${specValidationFindings(writeFace, issues)}`,
754+
) as Error & { code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[] };
755+
err.code = 'INVALID_METADATA';
756+
err.status = 422;
757+
err.issues = issues;
758+
return err;
759+
}

‎packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
* family of shapes after its schema gate: a hook with no `body`, a second
99
* default record page of an object, an `external` object binding on a managed
1010
* datasource (both halves of the pair), an object `datasource` that names no
11-
* datasource (#26724), a `runAs: 'system'` flow an anonymous
11+
* datasource (#26724), a keyed seed whose `externalId` names no field of its
12+
* object (#28952), a `runAs: 'system'` flow an anonymous
1213
* endpoint names, the shared runtime authoring rules, and the authoring gate a
1314
* domain plugin registers. Measured on `origin/main` `5801a4f2c` before this
1415
* file: each restore door wrote every one of those shapes back, so a version
@@ -95,6 +96,8 @@ function makeStubEngine() {
9596
const registered = new Map<string, Map<string, unknown>>();
9697
// [#26724] The drivers the engine has registered by name, with no datasource item.
9798
const drivers = new Set<string>();
99+
/** The engine's registered objects, by name (empty unless a case stages one). */
100+
const objects = new Map<string, unknown>();
98101
let nextId = 0;
99102
let commit: unknown = null;
100103

@@ -188,7 +191,7 @@ function makeStubEngine() {
188191
listItems: (type: string) => Array.from(registered.get(type)?.values() ?? []),
189192
getItem: (type: string, name: string) => registered.get(type)?.get(name),
190193
getAllObjects: () => [],
191-
getObject: () => undefined,
194+
getObject: (name: string) => objects.get(name),
192195
getPackage: () => undefined,
193196
getArtifactItem: () => undefined,
194197
isPackageDisabled: () => false,
@@ -200,6 +203,7 @@ function makeStubEngine() {
200203
historyRows,
201204
registered,
202205
drivers,
206+
objects,
203207
serveCommit: (c: unknown) => { commit = c; },
204208
};
205209
}
@@ -502,6 +506,31 @@ pinBothDoors({
502506
},
503507
});
504508

509+
pinBothDoors({
510+
title: 'a seed restored keyed on a field its object no longer has (seedExternalIdSaveRefusal)',
511+
type: 'seed',
512+
name: 'wh_order_seed',
513+
code: 'INVALID_METADATA',
514+
status: 422,
515+
async stage(h) {
516+
const seedOf = (externalId: string, label?: string) => ({
517+
object: 'wh_order', mode: 'upsert', externalId, records: [{ name: 'O1', code: 'o1' }],
518+
...(label ? { label } : {}),
519+
});
520+
h.objects.set('wh_order', { name: 'wh_order', fields: { name: { type: 'text' }, code: { type: 'text' } } });
521+
await h.protocol.saveMetaItem({ type: 'seed', name: 'wh_order_seed', item: seedOf('code'), mode: 'draft' });
522+
await h.protocol.saveMetaItem({ type: 'seed', name: 'wh_order_seed', item: seedOf('name'), mode: 'draft' });
523+
await h.protocol.publishMetaItem({ type: 'seed', name: 'wh_order_seed' });
524+
// The object lost the field v1 is keyed on.
525+
h.objects.set('wh_order', { name: 'wh_order', fields: { name: { type: 'text' } } });
526+
return { refused: 1, accepted: 2 };
527+
},
528+
expectDetail: (error) => {
529+
expect(error.issues).toEqual([expect.objectContaining({ path: 'externalId', code: 'custom' })]);
530+
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);
531+
},
532+
});
533+
505534
pinBothDoors({
506535
title: 'a body the runtime authoring rules refuse (assertRuntimeAuthoringRules)',
507536
type: 'object',

0 commit comments

Comments
 (0)