Skip to content
Merged
19 changes: 19 additions & 0 deletions .changeset/26462-diff-missing-version-refused.md
Original file line number Diff line number Diff line change
@@ -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

<!-- 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. -->

**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.
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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);
});
});

Expand Down Expand Up @@ -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 () => {
Expand Down
281 changes: 281 additions & 0 deletions packages/metadata-protocol/src/protocol.diff-missing-version.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, where: Record<string, unknown>): 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<string, Array<Record<string, unknown>>> = {
sys_metadata: [],
sys_metadata_history: [],
};
const engine: any = {
async find(table: string, o: { where: Record<string, unknown>; 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<string, unknown> }) {
assertEngineFindOnePredicate(table, o);
return (tables[table] ?? []).find((r) => matches(r, o.where)) ?? null;
},
async insert() { return { id: 'stub' }; },
async update(_t: string, data: Record<string, unknown>, o: { where: Record<string, unknown> }) {
assertEngineUpdateDispatch(data, o);
return { id: null };
},
async delete(_t: string, o?: Record<string, unknown>) {
assertEngineDeleteDispatch(o);
return { deleted: 0 };
},
async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> {
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<string, Array<Record<string, unknown>>>,
name: string,
bodies: Array<Record<string, unknown> | 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<unknown>): Promise<any> {
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: [] });
});
});
Loading
Loading