diff --git a/.changeset/26462-diff-missing-version-refused.md b/.changeset/26462-diff-missing-version-refused.md new file mode 100644 index 00000000000..930e1968005 --- /dev/null +++ b/.changeset/26462-diff-missing-version-refused.md @@ -0,0 +1,19 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +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" + +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. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing at `diffMetaItem` (`@objectstack/metadata-protocol`), served by `GET /api/v1/meta/:type/:name/diff`. + +- **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. +- **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. +- **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. +- **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. + +**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. diff --git a/packages/metadata-protocol/src/protocol.diff-canonical-type-and-history-outage.test.ts b/packages/metadata-protocol/src/protocol.diff-canonical-type-and-history-outage.test.ts index f59dd0674ba..c55288ea5d2 100644 --- a/packages/metadata-protocol/src/protocol.diff-canonical-type-and-history-outage.test.ts +++ b/packages/metadata-protocol/src/protocol.diff-canonical-type-and-history-outage.test.ts @@ -278,8 +278,12 @@ describe('#8868 — diffMetaItem folds its type at the request boundary', () => it('does NOT refuse a spelling that reaches for no declared type — the refusal stays narrow', async () => { // A plugin-registered runtime kind must never be refused by a static // rule. `fieldz` is not a plural of anything declared, so the boundary - // is silent and the request is served (with no rows, truthfully). - const { engine } = makeStubEngine(); + // is silent and the request is served. + // [#26462] Served from rows of its own: with none, the two named + // versions are a 404 `VERSION_NOT_FOUND` miss, which a weakened + // assertion here could not tell from a boundary refusal. + const { engine, tables } = makeStubEngine(); + seedTwoVersions(tables, PLUGIN_SHAPED_SPELLING, 'x'); const protocol = new ObjectStackProtocolImplementation(engine); const res: any = await protocol.diffMetaItem({ @@ -290,7 +294,7 @@ describe('#8868 — diffMetaItem folds its type at the request boundary', () => }); expect(res.type).toBe(PLUGIN_SHAPED_SPELLING); - expect(res.changed).toEqual([]); + expect(res).toMatchObject(REAL_DIFF_BODY); }); }); @@ -320,20 +324,28 @@ describe('#8833 — a history-store outage stops masquerading as an empty diff', // SELECTIVE rather than a blanket "any failure 503s" — which would // break first boot on a minimal deployment that never provisioned // `sys_metadata_history`. + // + // [#26462] What "benign" answers moved, not the discrimination: with no + // history rows, a NAMED version is a miss — 404 `VERSION_NOT_FOUND`, + // the rollback door's refusal, as for any item with no history — and + // the default range is the empty diff. Neither is the 503. const { engine } = makeStubEngine({ historyError: MISSING_TABLE_ERROR }); const protocol = new ObjectStackProtocolImplementation(engine); - const res: any = await protocol.diffMetaItem({ - type: PRESENT_TYPE, - name: 'grid', - fromVersion: 1, - toVersion: 2, - }); + const res: any = await protocol.diffMetaItem({ type: PRESENT_TYPE, name: 'grid' }); expect(res.added).toEqual([]); expect(res.removed).toEqual([]); expect(res.changed).toEqual([]); expect(res.type).toBe(PRESENT_TYPE); + + const err: any = await protocol + .diffMetaItem({ type: PRESENT_TYPE, name: 'grid', fromVersion: 1, toVersion: 2 }) + .then(() => null, (e: unknown) => e); + + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('VERSION_NOT_FOUND'); + expect(err.status).toBe(404); }); it('the 503 does not depend on the type — gated-shut types propagate identically', async () => { diff --git a/packages/metadata-protocol/src/protocol.diff-missing-version.test.ts b/packages/metadata-protocol/src/protocol.diff-missing-version.test.ts new file mode 100644 index 00000000000..1526d663e2e --- /dev/null +++ b/packages/metadata-protocol/src/protocol.diff-missing-version.test.ts @@ -0,0 +1,281 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #26462 — `diffMetaItem` answered a version the item has no history row at + * as an EMPTY side, with a 200: `?from=998&to=999` read as "no change", + * `?from=1&to=999` as "everything removed", and an item that never existed as + * "no change". ADR-0110 D3 rules that kind of answer out — a miss must never + * read as a fact about the document — and the rollback door on the same item + * already refuses the same number 404 `VERSION_NOT_FOUND`. + * + * The refusal is the rollback door's own ({@link versionNotFoundError}), so + * both doors name a missing version in one sentence; the parity arm below + * pins that by comparing the two answers, not by pinning the prose. + * + * What stays served, and is pinned here as the controls: + * - a named version that HAS a row, including a delete tombstone (a body-less + * row is a real version: the item did not exist there); + * - the default range (no `from` / `to`, #20397 / #20451), for any item. + */ +import { describe, expect, it } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, hashSpec, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './index.js'; +import { SysMetadataRepository } from './sys-metadata-repository.js'; + +const TYPE = 'dashboard'; +const NAME = 'qa_diff_probe'; + +/** + * Scalar equality only, and it REFUSES a combinator rather than guessing — + * `check:where-matcher` shape (b): treating `$or` as a column name silently + * excludes rows and greens the suite against a query nobody wrote. + */ +function matches(r: Record, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) { + throw new Error( + `stub engine: WHERE combinator '${k}' is not implemented by this double — ` + + 'it matches scalar equality only. Implement it here rather than letting it ' + + 'be read as a field name.', + ); + } + if (v === undefined) continue; + if (r[k] !== v) return false; + } + return true; +} + +function makeStubEngine() { + const tables: Record>> = { + sys_metadata: [], + sys_metadata_history: [], + }; + const engine: any = { + async find(table: string, o: { where: Record; limit?: number }) { + const rows = (tables[table] ?? []).filter((r) => matches(r, o.where)); + return typeof o?.limit === 'number' ? rows.slice(0, o.limit) : rows; + }, + async findOne(table: string, o: { where: Record }) { + assertEngineFindOnePredicate(table, o); + return (tables[table] ?? []).find((r) => matches(r, o.where)) ?? null; + }, + async insert() { return { id: 'stub' }; }, + async update(_t: string, data: Record, o: { where: Record }) { + assertEngineUpdateDispatch(data, o); + return { id: null }; + }, + async delete(_t: string, o?: Record) { + assertEngineDeleteDispatch(o); + return { deleted: 0 }; + }, + async transaction(cb: (ctx: any, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + async syncObjectSchema() { /* no DDL in this stub */ }, + registry: { + listItems: () => [], + isPackageDisabled: () => false, + getItem: () => undefined, + registerItem: () => {}, + registerObject: () => {}, + getPackage: () => undefined, + }, + }; + return { engine, tables }; +} + +/** + * The card's reproduction, steps 1–2: v1 and v2 of one item, differing only in + * `label`, and v2 live. `bodies` overrides the default pair; a `null` body is + * a delete tombstone. + */ +function seedHistory( + tables: Record>>, + name: string, + bodies: Array | null> = [ + { name, label: 'QA Diff', widgets: [] }, + { name, label: 'QA Diff v2', widgets: [] }, + ], +) { + const base = { organization_id: null, type: TYPE, name }; + bodies.forEach((body, i) => { + tables.sys_metadata_history!.push({ + ...base, + id: `h_${name}_${i + 1}`, + version: i + 1, + event_seq: i + 1, + operation_type: body === null ? 'delete' : i === 0 ? 'create' : 'update', + metadata: body === null ? null : JSON.stringify(body), + checksum: body === null ? null : hashSpec(body), + recorded_at: new Date(i + 1).toISOString(), + }); + }); + const live = bodies[bodies.length - 1]; + if (live) { + tables.sys_metadata!.push({ + ...base, + id: `m_${name}`, + state: 'active', + version: bodies.length, + metadata: JSON.stringify(live), + }); + } +} + +/** The refusal, or `null` when the call answered. */ +async function refusalOf(p: Promise): Promise { + return p.then(() => null, (e: unknown) => e); +} + +function expectVersionNotFound(err: any, version: number) { + // ADR-0112 envelope — code AND status; `.toThrow()` alone stays green on + // a bare `Error`. + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe('VERSION_NOT_FOUND'); + expect(err.status).toBe(404); + // Names the missing version, as the card's expected answer asks. + expect(err.message).toContain(`version ${version} `); +} + +describe('#26462 — an explicitly named version with no history row is refused 404 VERSION_NOT_FOUND', () => { + it('step 3, the control: two versions that exist answer their diff', async () => { + const { engine, tables } = makeStubEngine(); + seedHistory(tables, NAME); + const protocol = new ObjectStackProtocolImplementation(engine); + + const res: any = await protocol.diffMetaItem({ type: TYPE, name: NAME, fromVersion: 1, toVersion: 2 }); + + expect(res.fromVersion).toBe(1); + expect(res.toVersion).toBe(2); + expect(res.changed).toEqual([{ path: 'label', from: 'QA Diff', to: 'QA Diff v2' }]); + expect(res.added).toEqual([]); + expect(res.removed).toEqual([]); + }); + + it('step 4: both versions missing — refused, not "no change"', async () => { + const { engine, tables } = makeStubEngine(); + seedHistory(tables, NAME); + const protocol = new ObjectStackProtocolImplementation(engine); + + const err = await refusalOf(protocol.diffMetaItem({ type: TYPE, name: NAME, fromVersion: 998, toVersion: 999 })); + + expect(err).not.toBeNull(); + expect(err.code).toBe('VERSION_NOT_FOUND'); + expect(err.status).toBe(404); + }); + + it('step 5: the to side missing — refused, not "everything removed"', async () => { + const { engine, tables } = makeStubEngine(); + seedHistory(tables, NAME); + const protocol = new ObjectStackProtocolImplementation(engine); + + const err = await refusalOf(protocol.diffMetaItem({ type: TYPE, name: NAME, fromVersion: 1, toVersion: 999 })); + + expectVersionNotFound(err, 999); + }); + + it('the from side missing — refused, not "everything added"', async () => { + const { engine, tables } = makeStubEngine(); + seedHistory(tables, NAME); + const protocol = new ObjectStackProtocolImplementation(engine); + + const err = await refusalOf(protocol.diffMetaItem({ type: TYPE, name: NAME, fromVersion: 999, toVersion: 2 })); + + expectVersionNotFound(err, 999); + }); + + it('one side named and missing, the other defaulted — refused on either side', async () => { + const { engine, tables } = makeStubEngine(); + seedHistory(tables, NAME); + const protocol = new ObjectStackProtocolImplementation(engine); + + expectVersionNotFound(await refusalOf(protocol.diffMetaItem({ type: TYPE, name: NAME, toVersion: 999 })), 999); + expectVersionNotFound(await refusalOf(protocol.diffMetaItem({ type: TYPE, name: NAME, fromVersion: 999 })), 999); + }); + + it('step 6: an item that never existed — refused, not "no change"', async () => { + const { engine, tables } = makeStubEngine(); + seedHistory(tables, NAME); + const protocol = new ObjectStackProtocolImplementation(engine); + + const err = await refusalOf(protocol.diffMetaItem({ type: TYPE, name: 'qa_no_such_xyz', fromVersion: 1, toVersion: 2 })); + + expect(err).not.toBeNull(); + expect(err.code).toBe('VERSION_NOT_FOUND'); + expect(err.status).toBe(404); + expect(err.message).toContain(`${TYPE}/qa_no_such_xyz`); + }); + + it('a version number from ANOTHER item\'s history is this item\'s miss — refused, never that item\'s body', async () => { + // The card's open question. `other` has a v3; `NAME` stops at v2. The + // history read is keyed by (type, name), so `other`'s v3 is never a + // candidate: before the fix this answered `NAME`'s v1 against an + // EMPTY side (everything removed), not against `other`'s body. + const { engine, tables } = makeStubEngine(); + seedHistory(tables, NAME); + seedHistory(tables, 'other', [ + { name: 'other', label: 'O1', secret_marker: 'x' }, + { name: 'other', label: 'O2', secret_marker: 'x' }, + { name: 'other', label: 'O3', secret_marker: 'x' }, + ]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const err = await refusalOf(protocol.diffMetaItem({ type: TYPE, name: NAME, fromVersion: 1, toVersion: 3 })); + + expectVersionNotFound(err, 3); + expect(err.message).toContain(`${TYPE}/${NAME}`); + expect(err.message).not.toContain('other'); + }); + + it('the refusal is the rollback door\'s, sentence for sentence', async () => { + const { engine, tables } = makeStubEngine(); + seedHistory(tables, NAME); + const protocol = new ObjectStackProtocolImplementation(engine); + const repo = new SysMetadataRepository({ engine }); + + const diffErr = await refusalOf(protocol.diffMetaItem({ type: TYPE, name: NAME, fromVersion: 1, toVersion: 999 })); + const rollbackErr = await refusalOf(repo.restoreVersion({ org: 'system', type: TYPE, name: NAME }, 999, { actor: null })); + + expect(rollbackErr).not.toBeNull(); + expect(diffErr).not.toBeNull(); + expect({ code: diffErr.code, status: diffErr.status, message: diffErr.message }) + .toEqual({ code: rollbackErr.code, status: rollbackErr.status, message: rollbackErr.message }); + }); +}); + +describe('#26462 — what stays served', () => { + it('a delete tombstone is a real version: diffed as an empty side, not refused', async () => { + const { engine, tables } = makeStubEngine(); + seedHistory(tables, NAME, [ + { name: NAME, label: 'QA Diff', widgets: [] }, + null, + ]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const res: any = await protocol.diffMetaItem({ type: TYPE, name: NAME, fromVersion: 1, toVersion: 2 }); + + expect(res.toVersion).toBe(2); + expect(res.removed.map((e: any) => e.path).sort()).toEqual(['label', 'name', 'widgets']); + }); + + it('the default range of an item with history is unchanged', async () => { + const { engine, tables } = makeStubEngine(); + seedHistory(tables, NAME); + const protocol = new ObjectStackProtocolImplementation(engine); + + const res: any = await protocol.diffMetaItem({ type: TYPE, name: NAME }); + + expect(res.fromVersion).toBe(1); + expect(res.toVersion).toBe(2); + expect(res.changed).toEqual([{ path: 'label', from: 'QA Diff', to: 'QA Diff v2' }]); + }); + + it('the default range of an item with no history is unchanged: both sides absent, nothing refused', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + + const res: any = await protocol.diffMetaItem({ type: TYPE, name: 'qa_no_such_xyz' }); + + expect(res).toMatchObject({ fromVersion: null, toVersion: null, added: [], removed: [], changed: [] }); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index bee4f187c36..42c6ce883f1 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -57,6 +57,7 @@ import { packagelessAddressAmbiguityRefusal, resetEnvWritableMetadataTypes, unboundItemDraftAddress, + versionNotFoundError, type SysMetadataEngine, } from './sys-metadata-repository.js'; import { managedItemSealedSentence, packagedBaseRegimeRow, packagedBaseRegimeSentence } from './packaged-base-regime.js'; @@ -27162,7 +27163,8 @@ export class ObjectStackProtocolImplementation implements * earlier history row whose body differs from the to side's by this * diff's own equality, a body-less (delete) row comparing as `{}`, and * absent (`null`) when no earlier row differs. An explicit version on - * either side is used as named. Returns `{ added, removed, changed }` + * either side is used as named, and must have a history row of this + * item (a delete tombstone counts, compared as `{}`). Returns `{ added, removed, changed }` * keyed by JSON pointer-style paths for primitive leaves; nested * objects/arrays are reported as a single change record. * @@ -27179,13 +27181,22 @@ export class ObjectStackProtocolImplementation implements * application refusal in the author's own words, classified at the * producer and NOT the 503 below. See {@link * metadataReadFailureError}. + * @throws {@link versionNotFoundError} — 404 / `VERSION_NOT_FOUND` + * (#26462), when an explicit `fromVersion` or `toVersion` has no + * history row of this item, an item with no history at all + * included: the rollback door's refusal of the same number. A + * missing side compared as empty answered "no change" or + * "everything removed" (ADR-0110 D3). The default range is + * never refused. * @throws {@link metadataStoreUnavailableError} — 503 / * `SERVICE_UNAVAILABLE` (#8833), when the `sys_metadata_history` * read failed for any reason other than the table not being * provisioned yet, carrying the driver error on `cause`. The one * non-throwing failure is that unprovisioned table, where "no * history rows" is the truth rather than an unknown, so a minimal - * deployment still gets its benign empty diff. Without that + * deployment still gets its benign answer: the default range's + * empty diff, and for a named version the 404 miss above, as + * for any item with no history. Without that * asymmetry an outage would be indistinguishable from "these two * versions are identical" (ADR-0110 D3). */ @@ -27352,7 +27363,19 @@ export class ObjectStackProtocolImplementation implements if (request.toVersion !== undefined) { toVersion = request.toVersion; - toBody = byVersion.get(request.toVersion) ?? null; + // [#26462] ADR-0110 D3 — a named version with no row is a MISS, + // never an empty side: compared as `{}` it answered "no change" + // (both missing, or an item with no history at all) or + // "everything removed" with a 200. Refused as the rollback door + // refuses the same number. The read is keyed by (type, name), so + // another item's version is this item's miss too. A delete + // tombstone HAS a row (a `null` body, not `undefined`) and stays + // served. + const named = byVersion.get(request.toVersion); + if (named === undefined) { + throw versionNotFoundError({ type: singularType, name: request.name }, request.toVersion); + } + toBody = named; } else { // [#20397] The default `to` side is the CURRENT ACTIVE ROW, and ONE // read of that row supplies both of its facts: the body compared and @@ -27398,7 +27421,12 @@ export class ObjectStackProtocolImplementation implements } if (request.fromVersion !== undefined) { fromVersion = request.fromVersion; - fromBody = byVersion.get(request.fromVersion) ?? null; + // [#26462] The same miss on the from side — see the to side above. + const named = byVersion.get(request.fromVersion); + if (named === undefined) { + throw versionNotFoundError({ type: singularType, name: request.name }, request.fromVersion); + } + fromBody = named; } else if (toVersion !== null) { // [#20451] The default from side is the NEAREST EARLIER history row // whose body DIFFERS from the to side's, not the row immediately diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index bf25f4199d8..71e00f3212e 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -528,6 +528,30 @@ export function packageScopedRowWhere( return where; } +/** + * The refusal for a history version the addressed item has no row at: 404 + * `VERSION_NOT_FOUND`, naming the version and the item. One sentence for every + * door that takes a caller-named version — the rollback + * ({@link SysMetadataRepository.restoreVersion}) and the protocol's + * `diffMetaItem`, where a missing side used to be compared as an empty + * document and answered "no change" or "everything removed" (ADR-0110 D3: a + * miss never reads as a fact about the document). + * + * Deliberately NOT exported from the package entry, as + * {@link packagelessAddressAmbiguityRefusal} is not. + */ +export function versionNotFoundError( + ref: { type: string; name: string }, + version: number, +): Error & { code: 'VERSION_NOT_FOUND'; status: 404 } { + const err = new Error( + `No history row at version ${version} for ${ref.type}/${ref.name}.`, + ) as Error & { code: 'VERSION_NOT_FOUND'; status: 404 }; + err.code = 'VERSION_NOT_FOUND'; + err.status = 404; + return err; +} + /** * [#23165, ADR-0048] The refusal of a save, or a delete, that names no * package, of an item whose rows at the address are bound to two or more @@ -1634,14 +1658,7 @@ export class SysMetadataRepository implements MetadataRepository { }, context: { isSystem: true }, }); - if (!row) { - const err: any = new Error( - `No history row at version ${targetVersion} for ${ref.type}/${ref.name}.`, - ); - err.code = 'VERSION_NOT_FOUND'; - err.status = 404; - throw err; - } + if (!row) throw versionNotFoundError(ref, targetVersion); const raw = (row as any).metadata; if (raw === null || raw === undefined) { const err: any = new Error( diff --git a/packages/rest/src/meta-draft-read-author-exemption.test.ts b/packages/rest/src/meta-draft-read-author-exemption.test.ts index 81ab3667eed..4c96668dbd1 100644 --- a/packages/rest/src/meta-draft-read-author-exemption.test.ts +++ b/packages/rest/src/meta-draft-read-author-exemption.test.ts @@ -235,11 +235,14 @@ describe('[#20290] the draft read serves an app author the stored draft whole', const history = await (engine as any).find('sys_metadata_history', { where: { name: 'atlas' }, context: { isSystem: true } }); const draftVersion = Math.max(...history.map((h: any) => Number(h.version))); - const diff = await call('author', 'GET', '/diff', 'atlas', { from: '0', to: String(draftVersion) }); + // [#26462] From the published version, not `from=0`: a version with no + // history row is refused 404 `VERSION_NOT_FOUND`, so the draft's + // navigation is read as the `to` side of its change. + const diff = await call('author', 'GET', '/diff', 'atlas', { from: String(draftVersion - 1), to: String(draftVersion) }); const draft = await call('author', 'GET', '', 'atlas', { state: 'draft' }); expect(diff.statusCode).toBe(200); - const navigation = diff.body?.added?.find((e: any) => e.path === 'navigation')?.value; + const navigation = diff.body?.changed?.find((e: any) => e.path === 'navigation')?.to; expect(navIds({ navigation })).toEqual(navIds(draft.body?.item)); }, 60_000); diff --git a/packages/rest/src/meta-history-diff-authoring-door.test.ts b/packages/rest/src/meta-history-diff-authoring-door.test.ts index 08f0bdbc07a..a1e4a205c9b 100644 --- a/packages/rest/src/meta-history-diff-authoring-door.test.ts +++ b/packages/rest/src/meta-history-diff-authoring-door.test.ts @@ -331,11 +331,14 @@ describe('[#20378 · #20441] builders read /diff, /history and /audit as before const { door, draftVersion } = await boot(); const v = await draftVersion('app', 'atlas'); for (const who of BUILDERS) { - const res = await door(who, '/diff', 'app', 'atlas', { from: '0', to: String(v) }); + // [#26462] From the published version, not `from=0`: a version + // with no history row is refused 404 `VERSION_NOT_FOUND`, so the + // draft's navigation is read as the `to` side of its change. + const res = await door(who, '/diff', 'app', 'atlas', { from: String(v - 1), to: String(v) }); expect(res.statusCode, who).toBe(200); expect(res.body?.toVersion, who).toBe(v); expect(text(res), who).toContain('Atlas (draft)'); - const navigation = res.body?.added?.find((e: any) => e.path === 'navigation')?.value; + const navigation = res.body?.changed?.find((e: any) => e.path === 'navigation')?.to; expect(navIds({ navigation }), who).toContain('nav_atlas_launch_plan'); // Ruling 5856774816: whole for whoever may save the app, pruned // for any other caller — unchanged by this card. @@ -351,7 +354,9 @@ describe('[#20378 · #20441] builders read /diff, /history and /audit as before const res = await door(who, '/diff', 'view', 'opportunity.pipeline', { from: String(v - 1), to: String(v) }); expect(res.statusCode, who).toBe(200); expect(text(res), who).toContain('Pipeline (draft)'); - const draftOnly = await door(who, '/diff', 'view', 'opportunity.forecast', { from: '0', to: '1' }); + // [#26462] The default from side: a draft-only view has no + // earlier version, so it is absent and the whole body is added. + const draftOnly = await door(who, '/diff', 'view', 'opportunity.forecast', { to: '1' }); expect(draftOnly.statusCode, who).toBe(200); expect(text(draftOnly), who).toContain('Forecast'); } @@ -473,3 +478,26 @@ describe('[#23546] the trail admits a strict subset of the callers /meta/_drafts expect((await drafts('setupAdmin')).statusCode).toBe(200); }, 60_000); }); + +describe('[#26462] /diff refuses a named version with no history row, as /rollback does', () => { + it('steps 4–6: both versions missing, one missing, and an item that never existed answer 404 VERSION_NOT_FOUND; the step 3 control answers its change', async () => { + const { door, draftVersion } = await boot(); + const v = await draftVersion('view', 'opportunity.pipeline'); + const control = await door('studioBuilder', '/diff', 'view', 'opportunity.pipeline', { from: String(v - 1), to: String(v) }); + expect(control.statusCode).toBe(200); + expect(control.body?.changed?.map((e: any) => e.path)).toContain('label'); + + const cases: Array<[string, Record, number]> = [ + ['opportunity.pipeline', { from: '998', to: '999' }, 999], + ['opportunity.pipeline', { from: String(v - 1), to: '999' }, 999], + ['opportunity.pipeline', { from: '999', to: String(v) }, 999], + ['opportunity.no_such_view', { from: '1', to: '2' }, 2], + ]; + for (const [name, query, missing] of cases) { + const res = await door('studioBuilder', '/diff', 'view', name, query); + const label = `${name} ${JSON.stringify(query)}`; + expect(envelope(res), label).toEqual({ status: 404, code: 'VERSION_NOT_FOUND' }); + expect(text(res), label).toContain(`version ${missing} `); + } + }, 60_000); +}); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 5f74b4af5b8..ad7ac93d565 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -731,6 +731,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.diff-missing-version.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.diff-missing-version.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.diff-missing-version.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.driver-text-disclosure.test.ts", "verb": "delete",