Skip to content

Commit 1c63231

Browse files
fix(metadata-protocol)!: /diff refuses a version with no history row 404 VERSION_NOT_FOUND, as rollback does (#26540)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XDtV8BPxJvKToJNtpCCYF3x)_ Fixes #26462 Clause-②: no (narrowing) — measured on this diff: `diffMetaItem` and the REST `/diff` door that forwards to it refuse a named `from` / `to` version with no history row of the item (404 `VERSION_NOT_FOUND`), which answered 200 before. The default range and every version that has a row answer as before. No error code is added (`VERSION_NOT_FOUND` is already in `ERROR_CODE_LEDGER`), no export is added to a package entry, and nothing under `packages/spec` changes. Metadata-protocol: none Skills-docs: none — no `skills/**` file or docs page teaches the `/diff` answer for a version that has no row; the client SDK page shows a range of versions that exist. ## 维护者速读 - **改了什么**:元数据版本对比接口 `GET /api/v1/meta/:type/:name/diff` 遇到不存在的 `from` / `to` 版本号时,改为返回 404 `VERSION_NOT_FOUND`(与回滚接口同一句话),不再返回 200 的“没有变化”或“全部被删除”。 - **为什么改**:输错或已清理的版本号会让作者和审核人误以为两版相同或条目已删除;回滚接口对同一个号码早已拒绝,ADR-0110 D3 也不允许把“查不到”说成事实。 - **风险与回滚**:只影响显式指定了不存在版本号的调用;不带 `from` / `to` 的默认对比、存在的版本(包括删除墓碑)都不变。回滚即 revert 本 PR。 - **先看**:`packages/metadata-protocol/src/protocol.ts`(`diffMetaItem` 两处取版本)、`packages/metadata-protocol/src/sys-metadata-repository.ts`(共用的 `versionNotFoundError`)、`packages/rest/src/meta-history-diff-authoring-door.test.ts`(线上 404 与原来用 `from=0` 的对照测试)。 - **两分钟验证**:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.diff-missing-version.test.ts`,预期 11 个用例全绿;在 main 上同一文件 7 个转红。 - **需要你做的**:无,席位落地。 ## What changes - `diffMetaItem` (`packages/metadata-protocol/src/protocol.ts`) no longer reads a named version with `byVersion.get(v) ?? null`. A `from` or `to` with no history row of the item throws the rollback door's refusal: 404 `VERSION_NOT_FOUND`, `No history row at version N for TYPE/NAME.` The edit stays in place inside the method; no code moved. - The refusal is not minted twice. `SysMetadataRepository.restoreVersion` built it inline; it is now `versionNotFoundError(ref, version)` in `sys-metadata-repository.ts`, which `restoreVersion` and `diffMetaItem` both throw. It is not exported from the package entry, like `packagelessAddressAmbiguityRefusal` beside it. This file is outside the claim's declared file surface; it is the shared constructor the order asks for ("reuse the rollback door's refusal … do not mint a second one"). - A delete tombstone is a row: the check is on `undefined` from the map, not on a `null` body, so a tombstone is still diffed as an empty side. - The REST route is unchanged. Its `catch` already hands the throw to `handleRouteError`, which answers the same envelope the rollback door answers. - The method's docblock gains the `@throws` line and the unprovisioned-table sentence says what "benign" answers now. **The open question on the card: does a version from ANOTHER item's history leak through?** No. Measured on `main` with the stub engine: item `qa_diff_probe` has v1–v2, item `other` has v1–v3, and `diffMetaItem({ name: 'qa_diff_probe', fromVersion: 1, toVersion: 3 })` answered `{"fromVersion":1,"toVersion":3,"added":[],"removed":[{"path":"name",…},{"path":"label","value":"QA Diff"},{"path":"widgets","value":[]}],"changed":[]}`. The history read is keyed by `(type, name)`, so `other`'s v3 was never a candidate; it was the same miss read as "everything removed". It is now the same 404, pinned in `protocol.diff-missing-version.test.ts`. ## Tests - New `packages/metadata-protocol/src/protocol.diff-missing-version.test.ts` (11 cases): the step 3 control, steps 4, 5 and 6, a missing `from`, one side named and missing with the other defaulted, the other-item version, and a parity arm comparing `{ code, status, message }` with `SysMetadataRepository.restoreVersion`'s refusal for the same item and version. Controls: a delete tombstone version still answers, and the default range answers unchanged for an item with history and for one with none. - New REST case in `packages/rest/src/meta-history-diff-authoring-door.test.ts`, on the real protocol behind `RestServer`: both missing, `to` missing, `from` missing and an item that never existed each answer `{ status: 404, code: 'VERSION_NOT_FOUND' }` naming the version; the step 3 control answers 200 with its `label` change. - Pins whose asserted value moved, each with its subject kept: - `protocol.diff-canonical-type-and-history-outage.test.ts`, "a genuinely-absent table still answers benignly": the #8833 subject is that an unprovisioned table is not the 503. It now asserts both benign answers: the default range's empty diff, and the 404 miss for named versions (no rows exist). - Same file, "does NOT refuse a spelling that reaches for no declared type": the subject is that the type boundary does not refuse `fieldz`. It now seeds two versions of `fieldz/x` and asserts the real diff, because with no rows the named versions are a 404 that a weaker assertion could not tell from a boundary refusal. - `meta-history-diff-authoring-door.test.ts` and `meta-draft-read-author-exemption.test.ts`: three builder controls read the draft by diffing `from=0`, a version that has no row. They now diff from the published version (`v - 1`) and read the draft's navigation as the `to` side of its change, or use the default from side for the draft-only view. What each pins (who reads the draft whole, who reads it pruned) is unchanged. ## Acceptance notes - `from=0` was a test idiom only. No production caller sends it: `ui/` has no caller of the metadata client's `diff`, and the client SDK's `diffItem` forwards what it is given. The changeset's remedy line names it. - The step 6 item was a `dashboard` on the card. In the REST case I used a `view`, the type the existing suite boots; the dashboard path reaches the same protocol line. ## Verification Base `45de8c6cb`. Final head `db2eb1e5`: `f9ed7619` plus the changeset's `Clause-②` line moved to `no (narrowing)`, on which the four changeset gates (`check-changeset-no-major`, `check-adr-0087-registration`, `check-empty-changeset` with `--base origin/main`, and `check:changeset-gate-self-tests`) re-ran with exit 0. Every other reading below was taken on `f9ed7619`. `origin/main` has moved 27 commits since the base; none touches a file this diff touches, and `git merge-tree --write-tree HEAD origin/main` is clean, so the branch was not merged. CI validates the merge ref. <details><summary>Red first on base, green on the fix</summary> - Protocol, `protocol.diff-missing-version.test.ts` on the base source (the fix not yet committed): `Tests 7 failed | 4 passed (11)`. The 7 are steps 4, 5, 6, the missing `from`, the one-side-defaulted case, the other-item version and the rollback parity arm, each `expected null` (the call answered 200). The 4 controls passed. On the fix: `Tests 11 passed (11)`. - The other-item probe on base answered `removed: [name, label, widgets]` of `qa_diff_probe` v1, not `other`'s body. - REST, `meta-history-diff-authoring-door.test.ts -t 26462`. The suite resolves `@objectstack/metadata-protocol` through `dist/`, so the base leg wrote the base blobs of `protocol.ts` and `sys-metadata-repository.ts` (marker count on disk 0), rebuilt the package (dist marker files 0), and ran: `AssertionError: opportunity.pipeline from 998 to 999: expected status 200, code undefined to deeply equal status 404`, `Tests 1 failed | 19 skipped`. Restored with `git checkout HEAD --`; `git diff HEAD` empty and the blob hash equals HEAD's (`42c6ce88`). Rebuilt (dist marker files 1): `Tests 1 passed`. </details> <details><summary>Package suites</summary> - `pnpm --filter @objectstack/metadata-protocol test`: before the two pin updates `Test Files 1 failed | 268 passed | 3 skipped (272)`, `Tests 1 failed | 21575 passed | 19 skipped`; the one red was the plugin-shaped spelling pin (updated, see Tests). The two diff suites after the update: `Tests 20 passed (20)`. `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. - `pnpm --filter @objectstack/rest test`: `Test Files 2 failed | 322 passed (324)`, `Tests 3 failed | 6480 passed | 326 skipped` before the `from=0` controls were updated; the 3 were those controls (`expected 404 to be 200`). After: both files `Tests 26 passed (26)` (with the new REST case). - `packages/objectql` `protocol-publish-rollback.test.ts`: `Tests 12 passed (12)`. `packages/runtime` `meta-app-package-id-key.test.ts`: `Tests 12 passed (12)`. - `eslint --no-inline-config packages/metadata-protocol packages/rest`: exit 0. </details> <details><summary>Full dogfood (the narrowing arm)</summary> `pnpm --filter @objectstack/dogfood test` under the lock after building its closure, on `75b1f560`: `Test Files 300 passed | 3 skipped (303)`, `Tests 2532 passed | 18 skipped (2550)`, `VERDICT command-exit 0`. The final head differs from `75b1f560` only in the new test file's `find` double and `scripts/engine-double-contract.pinned.json`, neither of which dogfood reads. </details> <details><summary>Derived gates: 69 derived, 68 run with exit 0, 1 NOT MEASURED</summary> `node scripts/pm/dispatch-gates.mjs --ran` on the final diff: `69 derived famil(ies) accounted for — 68 run, 1 NOT-MEASURED`. The first pass found two real reds in the new test's stub engine, both fixed and re-run on the final head: `check:engine-double-contract` (the ledger had to learn the new pinned doubles, `--write`) and `check:objectql-double-limit` (the `find` double now applies the caller's `limit`). `check:dts-closure`, `check:dual-build-cjs-loads` and `check:type-check-debt` first read a tree a concurrent closure build was rewriting or could not take the lock behind it; each was re-run on the final head and exited 0. NOT MEASURED: `check-plugin-teardown-shape.mjs --self-test`, exit 3, its fixture is pinned to a commit this shallow clone cannot reach; CI runs it. ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 3 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:bash32-floor :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:cli-command-ids :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:durability-log-level :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:entry-guard :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:filter-alias-parity :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:parse-guard :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:pnpm-filter-targets :: exit 0 pnpm check:pnpm-flag-spellings :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:scripts-symbol-anchors :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5pKvmsCDBZABPxvMnu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7dd5a2f commit 1c63231

8 files changed

Lines changed: 429 additions & 26 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol)!: `GET /api/v1/meta/:type/:name/diff` refuses a `from` or `to` version that has no history row with 404 `VERSION_NOT_FOUND`, as the rollback door already does, instead of answering 200 "no change" or "everything removed"
6+
7+
Clause-②: no (narrowing) — `diffMetaItem` (and the REST `/diff` door that forwards to it) refuses a named version with no history row of the item, which it answered 200 before. The default range and every version that has a row answer exactly as before.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) A read door's answer to a version that does not exist: no key of any metadata schema is added, removed, renamed or re-shaped, no export or type changes, and the refusal is the existing VERSION_NOT_FOUND code the rollback door answers. There is nothing for `objectstack migrate meta` to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing at `diffMetaItem` (`@objectstack/metadata-protocol`), served by `GET /api/v1/meta/:type/:name/diff`.
13+
14+
- **Before.** A `from` or `to` version the item has no history row at was compared as an empty document. `?from=998&to=999` answered 200 with nothing added, removed or changed, which reads as "these versions are identical". `?from=1&to=999` answered 200 with every top-level key of version 1 under `removed`, which reads as "the item was deleted". An item that never existed answered 200 "no change". The rollback door refused the same numbers with 404.
15+
- **Now.** A named `from` or `to` with no history row of the item is refused 404 `VERSION_NOT_FOUND`, with the rollback door's own sentence: `No history row at version N for TYPE/NAME.` An item with no history at all is refused the same way. A version number that exists only in another item's history is this item's miss, refused the same way; it was never read from the other item.
16+
- **Unchanged.** The default range (no `from` / `to`) answers as before, for every item, including one with no history. A version that has a row answers as before, a delete tombstone included (compared as an empty side). A history read failure is still the 503 `SERVICE_UNAVAILABLE`, and a deployment that never provisioned the history table still gets the empty default diff.
17+
- **Who sees it.** A caller who names a version that does not exist, for example a mistyped or pruned number, or `from=0` used to mean "before the first version". It now gets 404 instead of a 200 that described a change that did not happen.
18+
19+
**The remedy.** Name versions from `GET /api/v1/meta/:type/:name/history`. To compare a version against nothing, omit `from` on an item's first version: the default from side is absent when no earlier version differs.

‎packages/metadata-protocol/src/protocol.diff-canonical-type-and-history-outage.test.ts‎

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -278,8 +278,12 @@ describe('#8868 — diffMetaItem folds its type at the request boundary', () =>
278278
it('does NOT refuse a spelling that reaches for no declared type — the refusal stays narrow', async () => {
279279
// A plugin-registered runtime kind must never be refused by a static
280280
// rule. `fieldz` is not a plural of anything declared, so the boundary
281-
// is silent and the request is served (with no rows, truthfully).
282-
const { engine } = makeStubEngine();
281+
// is silent and the request is served.
282+
// [#26462] Served from rows of its own: with none, the two named
283+
// versions are a 404 `VERSION_NOT_FOUND` miss, which a weakened
284+
// assertion here could not tell from a boundary refusal.
285+
const { engine, tables } = makeStubEngine();
286+
seedTwoVersions(tables, PLUGIN_SHAPED_SPELLING, 'x');
283287
const protocol = new ObjectStackProtocolImplementation(engine);
284288

285289
const res: any = await protocol.diffMetaItem({
@@ -290,7 +294,7 @@ describe('#8868 — diffMetaItem folds its type at the request boundary', () =>
290294
});
291295

292296
expect(res.type).toBe(PLUGIN_SHAPED_SPELLING);
293-
expect(res.changed).toEqual([]);
297+
expect(res).toMatchObject(REAL_DIFF_BODY);
294298
});
295299
});
296300

@@ -320,20 +324,28 @@ describe('#8833 — a history-store outage stops masquerading as an empty diff',
320324
// SELECTIVE rather than a blanket "any failure 503s" — which would
321325
// break first boot on a minimal deployment that never provisioned
322326
// `sys_metadata_history`.
327+
//
328+
// [#26462] What "benign" answers moved, not the discrimination: with no
329+
// history rows, a NAMED version is a miss — 404 `VERSION_NOT_FOUND`,
330+
// the rollback door's refusal, as for any item with no history — and
331+
// the default range is the empty diff. Neither is the 503.
323332
const { engine } = makeStubEngine({ historyError: MISSING_TABLE_ERROR });
324333
const protocol = new ObjectStackProtocolImplementation(engine);
325334

326-
const res: any = await protocol.diffMetaItem({
327-
type: PRESENT_TYPE,
328-
name: 'grid',
329-
fromVersion: 1,
330-
toVersion: 2,
331-
});
335+
const res: any = await protocol.diffMetaItem({ type: PRESENT_TYPE, name: 'grid' });
332336

333337
expect(res.added).toEqual([]);
334338
expect(res.removed).toEqual([]);
335339
expect(res.changed).toEqual([]);
336340
expect(res.type).toBe(PRESENT_TYPE);
341+
342+
const err: any = await protocol
343+
.diffMetaItem({ type: PRESENT_TYPE, name: 'grid', fromVersion: 1, toVersion: 2 })
344+
.then(() => null, (e: unknown) => e);
345+
346+
expect(err).toBeInstanceOf(Error);
347+
expect(err.code).toBe('VERSION_NOT_FOUND');
348+
expect(err.status).toBe(404);
337349
});
338350

339351
it('the 503 does not depend on the type — gated-shut types propagate identically', async () => {
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #26462 — `diffMetaItem` answered a version the item has no history row at
5+
* as an EMPTY side, with a 200: `?from=998&to=999` read as "no change",
6+
* `?from=1&to=999` as "everything removed", and an item that never existed as
7+
* "no change". ADR-0110 D3 rules that kind of answer out — a miss must never
8+
* read as a fact about the document — and the rollback door on the same item
9+
* already refuses the same number 404 `VERSION_NOT_FOUND`.
10+
*
11+
* The refusal is the rollback door's own ({@link versionNotFoundError}), so
12+
* both doors name a missing version in one sentence; the parity arm below
13+
* pins that by comparing the two answers, not by pinning the prose.
14+
*
15+
* What stays served, and is pinned here as the controls:
16+
* - a named version that HAS a row, including a delete tombstone (a body-less
17+
* row is a real version: the item did not exist there);
18+
* - the default range (no `from` / `to`, #20397 / #20451), for any item.
19+
*/
20+
import { describe, expect, it } from 'vitest';
21+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, hashSpec, assertEngineFindOnePredicate } from '@objectstack/metadata-core';
22+
import { ObjectStackProtocolImplementation } from './index.js';
23+
import { SysMetadataRepository } from './sys-metadata-repository.js';
24+
25+
const TYPE = 'dashboard';
26+
const NAME = 'qa_diff_probe';
27+
28+
/**
29+
* Scalar equality only, and it REFUSES a combinator rather than guessing —
30+
* `check:where-matcher` shape (b): treating `$or` as a column name silently
31+
* excludes rows and greens the suite against a query nobody wrote.
32+
*/
33+
function matches(r: Record<string, unknown>, where: Record<string, unknown>): boolean {
34+
for (const [k, v] of Object.entries(where)) {
35+
if (k.startsWith('$')) {
36+
throw new Error(
37+
`stub engine: WHERE combinator '${k}' is not implemented by this double — `
38+
+ 'it matches scalar equality only. Implement it here rather than letting it '
39+
+ 'be read as a field name.',
40+
);
41+
}
42+
if (v === undefined) continue;
43+
if (r[k] !== v) return false;
44+
}
45+
return true;
46+
}
47+
48+
function makeStubEngine() {
49+
const tables: Record<string, Array<Record<string, unknown>>> = {
50+
sys_metadata: [],
51+
sys_metadata_history: [],
52+
};
53+
const engine: any = {
54+
async find(table: string, o: { where: Record<string, unknown>; limit?: number }) {
55+
const rows = (tables[table] ?? []).filter((r) => matches(r, o.where));
56+
return typeof o?.limit === 'number' ? rows.slice(0, o.limit) : rows;
57+
},
58+
async findOne(table: string, o: { where: Record<string, unknown> }) {
59+
assertEngineFindOnePredicate(table, o);
60+
return (tables[table] ?? []).find((r) => matches(r, o.where)) ?? null;
61+
},
62+
async insert() { return { id: 'stub' }; },
63+
async update(_t: string, data: Record<string, unknown>, o: { where: Record<string, unknown> }) {
64+
assertEngineUpdateDispatch(data, o);
65+
return { id: null };
66+
},
67+
async delete(_t: string, o?: Record<string, unknown>) {
68+
assertEngineDeleteDispatch(o);
69+
return { deleted: 0 };
70+
},
71+
async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> {
72+
return cb(undefined, { owned: true });
73+
},
74+
async syncObjectSchema() { /* no DDL in this stub */ },
75+
registry: {
76+
listItems: () => [],
77+
isPackageDisabled: () => false,
78+
getItem: () => undefined,
79+
registerItem: () => {},
80+
registerObject: () => {},
81+
getPackage: () => undefined,
82+
},
83+
};
84+
return { engine, tables };
85+
}
86+
87+
/**
88+
* The card's reproduction, steps 1–2: v1 and v2 of one item, differing only in
89+
* `label`, and v2 live. `bodies` overrides the default pair; a `null` body is
90+
* a delete tombstone.
91+
*/
92+
function seedHistory(
93+
tables: Record<string, Array<Record<string, unknown>>>,
94+
name: string,
95+
bodies: Array<Record<string, unknown> | null> = [
96+
{ name, label: 'QA Diff', widgets: [] },
97+
{ name, label: 'QA Diff v2', widgets: [] },
98+
],
99+
) {
100+
const base = { organization_id: null, type: TYPE, name };
101+
bodies.forEach((body, i) => {
102+
tables.sys_metadata_history!.push({
103+
...base,
104+
id: `h_${name}_${i + 1}`,
105+
version: i + 1,
106+
event_seq: i + 1,
107+
operation_type: body === null ? 'delete' : i === 0 ? 'create' : 'update',
108+
metadata: body === null ? null : JSON.stringify(body),
109+
checksum: body === null ? null : hashSpec(body),
110+
recorded_at: new Date(i + 1).toISOString(),
111+
});
112+
});
113+
const live = bodies[bodies.length - 1];
114+
if (live) {
115+
tables.sys_metadata!.push({
116+
...base,
117+
id: `m_${name}`,
118+
state: 'active',
119+
version: bodies.length,
120+
metadata: JSON.stringify(live),
121+
});
122+
}
123+
}
124+
125+
/** The refusal, or `null` when the call answered. */
126+
async function refusalOf(p: Promise<unknown>): Promise<any> {
127+
return p.then(() => null, (e: unknown) => e);
128+
}
129+
130+
function expectVersionNotFound(err: any, version: number) {
131+
// ADR-0112 envelope — code AND status; `.toThrow()` alone stays green on
132+
// a bare `Error`.
133+
expect(err).toBeInstanceOf(Error);
134+
expect(err.code).toBe('VERSION_NOT_FOUND');
135+
expect(err.status).toBe(404);
136+
// Names the missing version, as the card's expected answer asks.
137+
expect(err.message).toContain(`version ${version} `);
138+
}
139+
140+
describe('#26462 — an explicitly named version with no history row is refused 404 VERSION_NOT_FOUND', () => {
141+
it('step 3, the control: two versions that exist answer their diff', async () => {
142+
const { engine, tables } = makeStubEngine();
143+
seedHistory(tables, NAME);
144+
const protocol = new ObjectStackProtocolImplementation(engine);
145+
146+
const res: any = await protocol.diffMetaItem({ type: TYPE, name: NAME, fromVersion: 1, toVersion: 2 });
147+
148+
expect(res.fromVersion).toBe(1);
149+
expect(res.toVersion).toBe(2);
150+
expect(res.changed).toEqual([{ path: 'label', from: 'QA Diff', to: 'QA Diff v2' }]);
151+
expect(res.added).toEqual([]);
152+
expect(res.removed).toEqual([]);
153+
});
154+
155+
it('step 4: both versions missing — refused, not "no change"', async () => {
156+
const { engine, tables } = makeStubEngine();
157+
seedHistory(tables, NAME);
158+
const protocol = new ObjectStackProtocolImplementation(engine);
159+
160+
const err = await refusalOf(protocol.diffMetaItem({ type: TYPE, name: NAME, fromVersion: 998, toVersion: 999 }));
161+
162+
expect(err).not.toBeNull();
163+
expect(err.code).toBe('VERSION_NOT_FOUND');
164+
expect(err.status).toBe(404);
165+
});
166+
167+
it('step 5: the to side missing — refused, not "everything removed"', async () => {
168+
const { engine, tables } = makeStubEngine();
169+
seedHistory(tables, NAME);
170+
const protocol = new ObjectStackProtocolImplementation(engine);
171+
172+
const err = await refusalOf(protocol.diffMetaItem({ type: TYPE, name: NAME, fromVersion: 1, toVersion: 999 }));
173+
174+
expectVersionNotFound(err, 999);
175+
});
176+
177+
it('the from side missing — refused, not "everything added"', async () => {
178+
const { engine, tables } = makeStubEngine();
179+
seedHistory(tables, NAME);
180+
const protocol = new ObjectStackProtocolImplementation(engine);
181+
182+
const err = await refusalOf(protocol.diffMetaItem({ type: TYPE, name: NAME, fromVersion: 999, toVersion: 2 }));
183+
184+
expectVersionNotFound(err, 999);
185+
});
186+
187+
it('one side named and missing, the other defaulted — refused on either side', async () => {
188+
const { engine, tables } = makeStubEngine();
189+
seedHistory(tables, NAME);
190+
const protocol = new ObjectStackProtocolImplementation(engine);
191+
192+
expectVersionNotFound(await refusalOf(protocol.diffMetaItem({ type: TYPE, name: NAME, toVersion: 999 })), 999);
193+
expectVersionNotFound(await refusalOf(protocol.diffMetaItem({ type: TYPE, name: NAME, fromVersion: 999 })), 999);
194+
});
195+
196+
it('step 6: an item that never existed — refused, not "no change"', async () => {
197+
const { engine, tables } = makeStubEngine();
198+
seedHistory(tables, NAME);
199+
const protocol = new ObjectStackProtocolImplementation(engine);
200+
201+
const err = await refusalOf(protocol.diffMetaItem({ type: TYPE, name: 'qa_no_such_xyz', fromVersion: 1, toVersion: 2 }));
202+
203+
expect(err).not.toBeNull();
204+
expect(err.code).toBe('VERSION_NOT_FOUND');
205+
expect(err.status).toBe(404);
206+
expect(err.message).toContain(`${TYPE}/qa_no_such_xyz`);
207+
});
208+
209+
it('a version number from ANOTHER item\'s history is this item\'s miss — refused, never that item\'s body', async () => {
210+
// The card's open question. `other` has a v3; `NAME` stops at v2. The
211+
// history read is keyed by (type, name), so `other`'s v3 is never a
212+
// candidate: before the fix this answered `NAME`'s v1 against an
213+
// EMPTY side (everything removed), not against `other`'s body.
214+
const { engine, tables } = makeStubEngine();
215+
seedHistory(tables, NAME);
216+
seedHistory(tables, 'other', [
217+
{ name: 'other', label: 'O1', secret_marker: 'x' },
218+
{ name: 'other', label: 'O2', secret_marker: 'x' },
219+
{ name: 'other', label: 'O3', secret_marker: 'x' },
220+
]);
221+
const protocol = new ObjectStackProtocolImplementation(engine);
222+
223+
const err = await refusalOf(protocol.diffMetaItem({ type: TYPE, name: NAME, fromVersion: 1, toVersion: 3 }));
224+
225+
expectVersionNotFound(err, 3);
226+
expect(err.message).toContain(`${TYPE}/${NAME}`);
227+
expect(err.message).not.toContain('other');
228+
});
229+
230+
it('the refusal is the rollback door\'s, sentence for sentence', async () => {
231+
const { engine, tables } = makeStubEngine();
232+
seedHistory(tables, NAME);
233+
const protocol = new ObjectStackProtocolImplementation(engine);
234+
const repo = new SysMetadataRepository({ engine });
235+
236+
const diffErr = await refusalOf(protocol.diffMetaItem({ type: TYPE, name: NAME, fromVersion: 1, toVersion: 999 }));
237+
const rollbackErr = await refusalOf(repo.restoreVersion({ org: 'system', type: TYPE, name: NAME }, 999, { actor: null }));
238+
239+
expect(rollbackErr).not.toBeNull();
240+
expect(diffErr).not.toBeNull();
241+
expect({ code: diffErr.code, status: diffErr.status, message: diffErr.message })
242+
.toEqual({ code: rollbackErr.code, status: rollbackErr.status, message: rollbackErr.message });
243+
});
244+
});
245+
246+
describe('#26462 — what stays served', () => {
247+
it('a delete tombstone is a real version: diffed as an empty side, not refused', async () => {
248+
const { engine, tables } = makeStubEngine();
249+
seedHistory(tables, NAME, [
250+
{ name: NAME, label: 'QA Diff', widgets: [] },
251+
null,
252+
]);
253+
const protocol = new ObjectStackProtocolImplementation(engine);
254+
255+
const res: any = await protocol.diffMetaItem({ type: TYPE, name: NAME, fromVersion: 1, toVersion: 2 });
256+
257+
expect(res.toVersion).toBe(2);
258+
expect(res.removed.map((e: any) => e.path).sort()).toEqual(['label', 'name', 'widgets']);
259+
});
260+
261+
it('the default range of an item with history is unchanged', async () => {
262+
const { engine, tables } = makeStubEngine();
263+
seedHistory(tables, NAME);
264+
const protocol = new ObjectStackProtocolImplementation(engine);
265+
266+
const res: any = await protocol.diffMetaItem({ type: TYPE, name: NAME });
267+
268+
expect(res.fromVersion).toBe(1);
269+
expect(res.toVersion).toBe(2);
270+
expect(res.changed).toEqual([{ path: 'label', from: 'QA Diff', to: 'QA Diff v2' }]);
271+
});
272+
273+
it('the default range of an item with no history is unchanged: both sides absent, nothing refused', async () => {
274+
const { engine } = makeStubEngine();
275+
const protocol = new ObjectStackProtocolImplementation(engine);
276+
277+
const res: any = await protocol.diffMetaItem({ type: TYPE, name: 'qa_no_such_xyz' });
278+
279+
expect(res).toMatchObject({ fromVersion: null, toVersion: null, added: [], removed: [], changed: [] });
280+
});
281+
});

0 commit comments

Comments
 (0)