From 9b308b12b807c64355cd28a2e774de3decd1a776 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 20:13:57 +0000 Subject: [PATCH 1/4] test(rest): pin the default /diff from side over publish and delete lineages Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude --- .../meta-diff-default-range-labels.test.ts | 196 +++++++++++++++++- 1 file changed, 191 insertions(+), 5 deletions(-) diff --git a/packages/rest/src/meta-diff-default-range-labels.test.ts b/packages/rest/src/meta-diff-default-range-labels.test.ts index 98163d034d2..7c1fe392388 100644 --- a/packages/rest/src/meta-diff-default-range-labels.test.ts +++ b/packages/rest/src/meta-diff-default-range-labels.test.ts @@ -19,9 +19,10 @@ * * The to side is the active row, labelled with that row's own `version` (the * column `SysMetadataRepository.put` stamps with the version of the history row - * it appends), and the from side is the history row immediately preceding that - * label. With no active row the to side is absent and so is its label: `null` - * on both sides, as `DiffMetaItemResponseSchema` declares. + * it appends). With no active row the to side is absent and so is its label: + * `null` on both sides, as `DiffMetaItemResponseSchema` declares. The from side + * is the nearest earlier history row whose body differs from the to side's + * (#20451, the second `describe` below). * * ## Why this file boots the real stack * @@ -127,16 +128,40 @@ async function boot() { }); if (res.statusCode !== 200) throw new Error(`seeding ${type}/${body.name} failed: ${JSON.stringify(res.body)}`); }; + const publish = async (type: string, name: string) => { + const res = await as('system', 'POST', `${META}/:type/:name/publish`, { + path: `${META}/${type}/${name}/publish`, + params: { type, name }, + }); + if (res.statusCode !== 200) throw new Error(`publishing ${type}/${name} failed: ${JSON.stringify(res.body)}`); + }; + const remove = async (type: string, name: string) => { + const res = await as('system', 'DELETE', `${META}/:type/:name`, { + path: `${META}/${type}/${name}`, + params: { type, name }, + }); + if (res.statusCode !== 200) throw new Error(`deleting ${type}/${name} failed: ${JSON.stringify(res.body)}`); + }; const diff = (type: string, name: string, query: Record = {}) => as('author', 'GET', `${META}/:type/:name/diff`, { path: `${META}/${type}/${name}/diff`, params: { type, name }, query }); /** The stored rows, read past every door: the fixture proof each assertion leans on. */ const storedRow = async (type: string, name: string, state: 'active' | 'draft') => - (await engine.find('sys_metadata', { where: { type, name, state } }))[0] as { version?: unknown } | undefined; + (await engine.find('sys_metadata', { where: { type, name, state } }))[0] as { version?: unknown; metadata?: unknown } | undefined; const historyVersions = async (type: string, name: string) => ((await engine.find('sys_metadata_history', { where: { type, name } })) as Array<{ version: number }>) .map((r) => r.version) .sort((a, b) => a - b); - return { save, diff, storedRow, historyVersions }; + /** Each history row's version, operation and label: which rows repeat a body, and which carry none. */ + const historyRows = async (type: string, name: string) => + ((await engine.find('sys_metadata_history', { where: { type, name } })) as Array<{ + version: number; operation_type: string; metadata: unknown; + }>) + .map((r) => { + const body = r.metadata == null ? null : (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata); + return { version: r.version, op: r.operation_type, label: body == null ? null : body.label }; + }) + .sort((a, b) => a.version - b.version); + return { save, publish, remove, diff, storedRow, historyVersions, historyRows }; } const view = (label: string, columns: string[] = ['name']) => @@ -246,3 +271,164 @@ describe('[#20397] GET /meta/:type/:name/diff — the default range labels the t expect(res.body?.changed).toEqual([{ path: 'label', from: 'A', to: 'B' }]); }, 60_000); }); + +/** + * [#20451] With no `from`, the from side is the nearest earlier history row + * whose body DIFFERS from the to side's, by the diff's own equality: all three + * buckets empty means equal, and a body-less row (a delete's) compares as `{}`. + * + * Every draft save appends a history row, and a publish appends the promoted + * body again as the next one, so the row immediately before a published + * version is usually the draft save it came from, carrying the same body. The + * default range used to take that row and answered "no changes" right after + * every publish. Measured on this stack before the change: v1 active, a v2 draft + * save, a v3 publish answered `2 → 3` with empty buckets, and create, delete, + * draft save, publish answered `3 → 4` with empty buckets. + * + * An explicit `?from=` / `?to=` still names exactly its versions. + */ +describe('[#20451] GET /meta/:type/:name/diff — the default from side is the nearest earlier version whose body differs', () => { + /** The stored active body, parsed: the "everything added" arm's expected entries, in the diff's key order. */ + const allAdded = (row: { metadata?: unknown } | undefined) => { + const body = typeof row?.metadata === 'string' ? JSON.parse(row.metadata) : row?.metadata; + return Object.entries(body as Record).map(([path, value]) => ({ path, value })); + }; + + it('view: v1 active, a v2 draft save and a v3 publish answer 1 → 3, the change the publish carried, and still do with a v4 draft pending', async () => { + const b = await boot(); + await b.save('view', view('A'), 'active'); + await b.save('view', view('B', ['name', 'owner']), 'draft'); + await b.publish('view', 'lead_all'); + + // Fixture proof: the publish repeated the draft save's body as the next row. + expect(await b.historyRows('view', 'lead_all')).toEqual([ + { version: 1, op: 'create', label: 'A' }, + { version: 2, op: 'create', label: 'B' }, + { version: 3, op: 'publish', label: 'B' }, + ]); + expect((await b.storedRow('view', 'lead_all', 'active'))?.version).toBe(3); + + const res = await b.diff('view', 'lead_all'); + const explicit = await b.diff('view', 'lead_all', { from: '1', to: '3' }); + + expect(res.statusCode, text(res)).toBe(200); + expect(res.body?.fromVersion).toBe(1); + expect(res.body?.toVersion).toBe(3); + expect(res.body?.changed).toEqual([ + { path: 'label', from: 'A', to: 'B' }, + { path: 'columns', from: ['name'], to: ['name', 'owner'] }, + ]); + expect(res.body).toEqual(explicit.body); + + await b.save('view', view('C pending', ['name', 'owner', 'stage']), 'draft'); + expect(await b.historyVersions('view', 'lead_all')).toEqual([1, 2, 3, 4]); + + const pending = await b.diff('view', 'lead_all'); + + expect(pending.statusCode, text(pending)).toBe(200); + expect(pending.body).toEqual(explicit.body); + expect(text(pending)).not.toContain('C pending'); + }, 60_000); + + it('view: an explicit range still names exactly its versions — ?from=2&to=3 answers the draft save against its publish, "no changes"', async () => { + const b = await boot(); + await b.save('view', view('A'), 'active'); + await b.save('view', view('B', ['name', 'owner']), 'draft'); + await b.publish('view', 'lead_all'); + + const res = await b.diff('view', 'lead_all', { from: '2', to: '3' }); + + expect(res.statusCode, text(res)).toBe(200); + expect(res.body).toEqual({ + type: 'view', name: 'lead_all', fromVersion: 2, toVersion: 3, added: [], removed: [], changed: [], + }); + }, 60_000); + + it('view: create, delete, draft save and publish answer 2 → 4, everything added — the walk stops on the body-less delete row', async () => { + const b = await boot(); + await b.save('view', view('A'), 'active'); + await b.remove('view', 'lead_all'); + await b.save('view', view('A2', ['name', 'owner']), 'draft'); + await b.publish('view', 'lead_all'); + + expect(await b.historyRows('view', 'lead_all')).toEqual([ + { version: 1, op: 'create', label: 'A' }, + { version: 2, op: 'delete', label: null }, + { version: 3, op: 'create', label: 'A2' }, + { version: 4, op: 'publish', label: 'A2' }, + ]); + const active = await b.storedRow('view', 'lead_all', 'active'); + expect(active?.version).toBe(4); + + const res = await b.diff('view', 'lead_all'); + const explicit = await b.diff('view', 'lead_all', { from: '2', to: '4' }); + + expect(res.statusCode, text(res)).toBe(200); + expect(res.body?.fromVersion).toBe(2); + expect(res.body?.toVersion).toBe(4); + expect(res.body?.removed).toEqual([]); + expect(res.body?.changed).toEqual([]); + expect(res.body?.added).toEqual(allAdded(active)); + expect(res.body?.added).toContainEqual({ path: 'label', value: 'A2' }); + expect(res.body).toEqual(explicit.body); + }, 60_000); + + it('view: create, delete and an active recreate answer 2 → 3, everything added — the answer the rule before #20451 gave', async () => { + const b = await boot(); + await b.save('view', view('A'), 'active'); + await b.remove('view', 'lead_all'); + await b.save('view', view('B', ['name', 'owner']), 'active'); + + expect(await b.historyRows('view', 'lead_all')).toEqual([ + { version: 1, op: 'create', label: 'A' }, + { version: 2, op: 'delete', label: null }, + { version: 3, op: 'create', label: 'B' }, + ]); + const active = await b.storedRow('view', 'lead_all', 'active'); + + const res = await b.diff('view', 'lead_all'); + + expect(res.statusCode, text(res)).toBe(200); + expect(res.body).toEqual({ + type: 'view', + name: 'lead_all', + fromVersion: 2, + toVersion: 3, + added: allAdded(active), + removed: [], + changed: [], + }); + }, 60_000); + + it('view: a brand-new item draft-saved and then published answers null → 2, everything added — no earlier row differs', async () => { + const b = await boot(); + await b.save('view', view('New'), 'draft'); + await b.publish('view', 'lead_all'); + + expect(await b.historyRows('view', 'lead_all')).toEqual([ + { version: 1, op: 'create', label: 'New' }, + { version: 2, op: 'publish', label: 'New' }, + ]); + const active = await b.storedRow('view', 'lead_all', 'active'); + + const res = await b.diff('view', 'lead_all'); + + expect(res.statusCode, text(res)).toBe(200); + expect(res.body).toEqual({ + type: 'view', name: 'lead_all', fromVersion: null, toVersion: 2, added: allAdded(active), removed: [], changed: [], + }); + }, 60_000); + + it('view: a single version answers null → 1, everything added', async () => { + const b = await boot(); + await b.save('view', view('Only'), 'active'); + const active = await b.storedRow('view', 'lead_all', 'active'); + + const res = await b.diff('view', 'lead_all'); + + expect(res.statusCode, text(res)).toBe(200); + expect(res.body).toEqual({ + type: 'view', name: 'lead_all', fromVersion: null, toVersion: 1, added: allAdded(active), removed: [], changed: [], + }); + }, 60_000); +}); From b3c6efac1c0e14a7da46e8759fc6926245107fc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 20:21:21 +0000 Subject: [PATCH 2/4] fix(metadata-protocol): the default /diff from side is the nearest earlier row whose body differs With no fromVersion, diffMetaItem walks back from the to side over the history rows it already read and stops at the first row whose body differs by diffShallow's own equality, a body-less (delete) row comparing as an empty object. An explicit version on either side is used as named. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude --- .../protocol.diff-dead-history-read.test.ts | 284 +++++++++++++++--- packages/metadata-protocol/src/protocol.ts | 50 ++- 2 files changed, 289 insertions(+), 45 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.diff-dead-history-read.test.ts b/packages/metadata-protocol/src/protocol.diff-dead-history-read.test.ts index 6e284609cbb..44c70ca3b56 100644 --- a/packages/metadata-protocol/src/protocol.diff-dead-history-read.test.ts +++ b/packages/metadata-protocol/src/protocol.diff-dead-history-read.test.ts @@ -283,6 +283,46 @@ describe('#8798 — a history-table outage now answers the same way for every ty }); }); +type Seed = { version: number; op: string; body: Record | null }; +/** + * History rows in version order, plus the active and draft rows they left + * behind. Shared by the #20397 and #20451 blocks below, both about the + * DEFAULT range; the REST file names the real writes each lineage mirrors. + */ +function seedLineage( + tables: Record>>, + type: string, + name: string, + history: Seed[], + rows: { active?: Seed; draft?: Seed }, +) { + const base = { organization_id: null, type, name }; + history.forEach((h, i) => { + tables.sys_metadata_history!.push({ + ...base, + id: `h_${h.version}`, + version: h.version, + event_seq: i + 1, + operation_type: h.op, + metadata: h.body == null ? null : JSON.stringify(h.body), + checksum: h.body == null ? null : hashSpec(h.body), + recorded_at: new Date(i + 1).toISOString(), + }); + }); + for (const state of ['active', 'draft'] as const) { + const row = rows[state]; + if (!row) continue; + tables.sys_metadata!.push({ + ...base, + id: `m_${state}`, + state, + version: row.version, + metadata: JSON.stringify(row.body), + checksum: hashSpec(row.body!), + }); + } +} + /** * [#20397] The DEFAULT range (no `toVersion`) labels its to side with the * version whose body it compares. @@ -297,42 +337,6 @@ describe('#8798 — a history-table outage now answers the same way for every ty * real REST stack are `packages/rest/src/meta-diff-default-range-labels.test.ts`. */ describe('#20397 — the default range labels the to side with the active row\'s own version', () => { - type Seed = { version: number; op: string; body: Record | null }; - /** History rows in version order, plus the active and draft rows they left behind. */ - function seedLineage( - tables: Record>>, - type: string, - name: string, - history: Seed[], - rows: { active?: Seed; draft?: Seed }, - ) { - const base = { organization_id: null, type, name }; - history.forEach((h, i) => { - tables.sys_metadata_history!.push({ - ...base, - id: `h_${h.version}`, - version: h.version, - event_seq: i + 1, - operation_type: h.op, - metadata: h.body == null ? null : JSON.stringify(h.body), - checksum: h.body == null ? null : hashSpec(h.body), - recorded_at: new Date(i + 1).toISOString(), - }); - }); - for (const state of ['active', 'draft'] as const) { - const row = rows[state]; - if (!row) continue; - tables.sys_metadata!.push({ - ...base, - id: `m_${state}`, - state, - version: row.version, - metadata: JSON.stringify(row.body), - checksum: hashSpec(row.body!), - }); - } - } - for (const type of [ORDINARY_TYPE, 'app']) { it(`${type}: with a draft pending, toVersion is the active row's version and the answer is the explicit range's`, async () => { const { engine, tables } = makeStubEngine(); @@ -436,3 +440,211 @@ describe('#20397 — the default range labels the to side with the active row\'s }); }); }); + +/** + * [#20451] With no `fromVersion`, the from side is the NEAREST EARLIER history + * row whose body DIFFERS from the to side's, by the diff's own equality: + * `diffShallow`'s three buckets all empty means equal, and a body-less row (a + * delete's tombstone) compares as `{}` — so the walk stops on it. + * + * A draft save appends a history row and a publish appends the promoted body + * again as the next one, so the row immediately before a published version + * repeats its body. The old rule (the row immediately before) answered "no + * changes" right after every publish. The lineages below are the ones the REST + * pins in `packages/rest/src/meta-diff-default-range-labels.test.ts` write + * through the real routes; here they are seeded, beside this file's + * read-counting double. + */ +describe('#20451 — the default from side is the nearest earlier row whose body differs from the to side\'s', () => { + const everythingAdded = (body: Record) => + Object.entries(body).map(([path, value]) => ({ path, value })); + + it('v1 active, a v2 draft save, a v3 publish: 1 → 3, the change the publish carried, in ONE history read', async () => { + const { engine, tables, historyReads } = makeStubEngine(); + const a = { name: 'item', label: 'A' }; + const b = { name: 'item', label: 'B' }; + seedLineage(tables, ORDINARY_TYPE, 'item', [ + { version: 1, op: 'create', body: a }, + { version: 2, op: 'create', body: b }, + { version: 3, op: 'publish', body: b }, + ], { active: { version: 3, op: 'publish', body: b } }); + const protocol = new ObjectStackProtocolImplementation(engine); + + const res: any = await protocol.diffMetaItem({ type: ORDINARY_TYPE, name: 'item' }); + + expect(historyReads()).toBe(1); + expect(res).toEqual({ + type: ORDINARY_TYPE, + name: 'item', + fromVersion: 1, + toVersion: 3, + added: [], + removed: [], + changed: [{ path: 'label', from: 'A', to: 'B' }], + }); + expect(res).toEqual(await protocol.diffMetaItem({ type: ORDINARY_TYPE, name: 'item', fromVersion: 1, toVersion: 3 })); + }); + + it('the same lineage with a v4 draft pending still answers 1 → 3: the draft is neither side', async () => { + const { engine, tables } = makeStubEngine(); + const a = { name: 'item', label: 'A' }; + const b = { name: 'item', label: 'B' }; + const c = { name: 'item', label: 'C pending' }; + seedLineage(tables, ORDINARY_TYPE, 'item', [ + { version: 1, op: 'create', body: a }, + { version: 2, op: 'create', body: b }, + { version: 3, op: 'publish', body: b }, + { version: 4, op: 'create', body: c }, + ], { active: { version: 3, op: 'publish', body: b }, draft: { version: 4, op: 'create', body: c } }); + const protocol = new ObjectStackProtocolImplementation(engine); + + const res: any = await protocol.diffMetaItem({ type: ORDINARY_TYPE, name: 'item' }); + + expect(res.fromVersion).toBe(1); + expect(res.toVersion).toBe(3); + expect(res.changed).toEqual([{ path: 'label', from: 'A', to: 'B' }]); + expect(JSON.stringify(res)).not.toContain('C pending'); + }); + + it('an explicit range names exactly its versions: 2 → 3 over that lineage is still "no changes"', async () => { + const { engine, tables } = makeStubEngine(); + const a = { name: 'item', label: 'A' }; + const b = { name: 'item', label: 'B' }; + seedLineage(tables, ORDINARY_TYPE, 'item', [ + { version: 1, op: 'create', body: a }, + { version: 2, op: 'create', body: b }, + { version: 3, op: 'publish', body: b }, + ], { active: { version: 3, op: 'publish', body: b } }); + const protocol = new ObjectStackProtocolImplementation(engine); + + const res: any = await protocol.diffMetaItem({ type: ORDINARY_TYPE, name: 'item', fromVersion: 2, toVersion: 3 }); + + expect(res).toEqual({ + type: ORDINARY_TYPE, name: 'item', fromVersion: 2, toVersion: 3, added: [], removed: [], changed: [], + }); + }); + + it('an explicit `toVersion` alone keeps its version, and the from side walks back from ITS body', async () => { + const { engine, tables } = makeStubEngine(); + const a = { name: 'item', label: 'A' }; + const b = { name: 'item', label: 'B' }; + const c = { name: 'item', label: 'C' }; + seedLineage(tables, ORDINARY_TYPE, 'item', [ + { version: 1, op: 'create', body: a }, + { version: 2, op: 'create', body: b }, + { version: 3, op: 'publish', body: b }, + { version: 4, op: 'update', body: c }, + ], { active: { version: 4, op: 'update', body: c } }); + const protocol = new ObjectStackProtocolImplementation(engine); + + const res: any = await protocol.diffMetaItem({ type: ORDINARY_TYPE, name: 'item', toVersion: 3 }); + + expect(res.fromVersion).toBe(1); + expect(res.toVersion).toBe(3); + expect(res.changed).toEqual([{ path: 'label', from: 'A', to: 'B' }]); + }); + + it('create, delete, draft save, publish: 2 → 4, everything added — the body-less delete row differs, so the walk stops on it', async () => { + const { engine, tables } = makeStubEngine(); + const a = { name: 'item', label: 'A' }; + const a2 = { name: 'item', label: 'A2', columns: ['name'] }; + seedLineage(tables, ORDINARY_TYPE, 'item', [ + { version: 1, op: 'create', body: a }, + { version: 2, op: 'delete', body: null }, + { version: 3, op: 'create', body: a2 }, + { version: 4, op: 'publish', body: a2 }, + ], { active: { version: 4, op: 'publish', body: a2 } }); + const protocol = new ObjectStackProtocolImplementation(engine); + + const res: any = await protocol.diffMetaItem({ type: ORDINARY_TYPE, name: 'item' }); + + expect(res).toEqual({ + type: ORDINARY_TYPE, + name: 'item', + fromVersion: 2, + toVersion: 4, + added: everythingAdded(a2), + removed: [], + changed: [], + }); + }); + + it('create, delete, active recreate: 2 → 3, everything added — the answer the immediately-previous rule gave', async () => { + const { engine, tables } = makeStubEngine(); + const a = { name: 'item', label: 'A' }; + const b = { name: 'item', label: 'B' }; + seedLineage(tables, ORDINARY_TYPE, 'item', [ + { version: 1, op: 'create', body: a }, + { version: 2, op: 'delete', body: null }, + { version: 3, op: 'create', body: b }, + ], { active: { version: 3, op: 'create', body: b } }); + const protocol = new ObjectStackProtocolImplementation(engine); + + const res: any = await protocol.diffMetaItem({ type: ORDINARY_TYPE, name: 'item' }); + // Naming the tombstone as the to side: an absent body is `{}` there + // too, so the walk passes nothing and lands on v1, as it always did. + const deletion: any = await protocol.diffMetaItem({ type: ORDINARY_TYPE, name: 'item', toVersion: 2 }); + + expect(res).toEqual({ + type: ORDINARY_TYPE, name: 'item', fromVersion: 2, toVersion: 3, added: everythingAdded(b), removed: [], changed: [], + }); + expect(deletion).toEqual({ + type: ORDINARY_TYPE, name: 'item', fromVersion: 1, toVersion: 2, added: [], removed: everythingAdded(a), changed: [], + }); + }); + + it('a brand-new item draft-saved then published: null → 2, everything added — no earlier row differs', async () => { + const { engine, tables } = makeStubEngine(); + const n = { name: 'item', label: 'New' }; + seedLineage(tables, ORDINARY_TYPE, 'item', [ + { version: 1, op: 'create', body: n }, + { version: 2, op: 'publish', body: n }, + ], { active: { version: 2, op: 'publish', body: n } }); + const protocol = new ObjectStackProtocolImplementation(engine); + + const res: any = await protocol.diffMetaItem({ type: ORDINARY_TYPE, name: 'item' }); + + expect(res).toEqual({ + type: ORDINARY_TYPE, name: 'item', fromVersion: null, toVersion: 2, added: everythingAdded(n), removed: [], changed: [], + }); + }); + + it('a single version: null → 1, everything added', async () => { + const { engine, tables } = makeStubEngine(); + const only = { name: 'item', label: 'Only' }; + seedLineage(tables, ORDINARY_TYPE, 'item', [ + { version: 1, op: 'create', body: only }, + ], { active: { version: 1, op: 'create', body: only } }); + const protocol = new ObjectStackProtocolImplementation(engine); + + const res: any = await protocol.diffMetaItem({ type: ORDINARY_TYPE, name: 'item' }); + + expect(res).toEqual({ + type: ORDINARY_TYPE, name: 'item', fromVersion: null, toVersion: 1, added: everythingAdded(only), removed: [], changed: [], + }); + }); + + it('the walk compares RAW bodies: a credential-only rotation stops it, and the served values stay redacted', async () => { + // The #8671 ruling (diff raw, redact what is emitted) applies to the + // walk's comparison too. Compared redacted, the two bodies below are + // equal and the walk would pass the rotation by. + const { engine, tables } = makeStubEngine(); + const config = (password: string) => ({ host: 'db.internal', username: 'reporting', password }); + const old = { name: 'warehouse', label: 'Warehouse', driver: 'postgres', config: config('hunter2-old') }; + const rotated = { name: 'warehouse', label: 'Warehouse', driver: 'postgres', config: config('hunter3-new') }; + seedLineage(tables, 'datasource', 'warehouse', [ + { version: 1, op: 'create', body: old }, + { version: 2, op: 'create', body: rotated }, + { version: 3, op: 'publish', body: rotated }, + ], { active: { version: 3, op: 'publish', body: rotated } }); + const protocol = new ObjectStackProtocolImplementation(engine); + + const res: any = await protocol.diffMetaItem({ type: 'datasource', name: 'warehouse' }); + + expect(res.fromVersion).toBe(1); + expect(res.toVersion).toBe(3); + expect(res.changed.map((e: { path: string }) => e.path)).toEqual(['config']); + expect(JSON.stringify(res)).not.toContain('hunter2-old'); + expect(JSON.stringify(res)).not.toContain('hunter3-new'); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 508f88c8c8e..a2d2b7686f2 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -21298,10 +21298,13 @@ export class ObjectStackProtocolImplementation implements * versions of a metadata item. Either side may be omitted: when * `toVersion` is undefined the current active body is used, labelled * with that active row's own `version` (`null` when there is no active - * row); when `fromVersion` is undefined the immediately previous history - * row is used. Returns `{ added, removed, changed }` keyed by JSON - * pointer-style paths for primitive leaves; nested objects/arrays - * are reported as a single change record. + * row); when `fromVersion` is undefined the from side is the nearest + * 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 }` + * keyed by JSON pointer-style paths for primitive leaves; nested + * objects/arrays are reported as a single change record. * * The `type` is folded to its canonical spelling at the boundary * ({@link canonicalizeMetaRequestType}), so the echoed `type` reports the @@ -21511,11 +21514,40 @@ export class ObjectStackProtocolImplementation implements fromVersion = request.fromVersion; fromBody = byVersion.get(request.fromVersion) ?? null; } else if (toVersion !== null) { - // Use the version immediately preceding `toVersion` - const sorted = histRows.map((r) => r.version).filter((v) => v < toVersion!); - if (sorted.length) { - fromVersion = sorted[sorted.length - 1]!; - fromBody = byVersion.get(fromVersion) ?? null; + // [#20451] The default from side is the NEAREST EARLIER history row + // whose body DIFFERS from the to side's, not the row immediately + // before it. Every draft save appends a history row and a publish + // appends the promoted body again as the next one, so the row + // immediately before a published version is usually the draft save + // it came from, with the same body: the default answered "no + // changes" right after every publish, and the change the publish + // carried was one explicit range away. + // + // "Differs" is this diff's OWN equality — `diffShallow`'s three + // buckets not all empty, with an absent body compared as `{}` + // exactly as the comparison below compares it — never a second + // notion of which rows count. So a body-less row (a delete's + // tombstone) differs from any non-empty to side and the walk stops + // on it, naming the deletion: the item did not exist there. + // ⛔ Do not branch on `operation_type` or add a lifecycle column to + // tell drafts from publishes: the #20378 ruling (comment + // 5865708652) declined the state column, and the retriage answer + // on #20451 (comment 5875579209) is this equality over every row. + // + // The walk reads only `histRows`, already the item's whole history + // from the one `find` above, sorted by version; it adds no read and + // no cap. No earlier row differs ⇒ the from side is absent (`null`, + // everything added), the answer for an item with no earlier version. + const earlier = histRows.map((r) => r.version).filter((v) => v < toVersion!); + const rawTarget = toBody ?? {}; + for (let i = earlier.length - 1; i >= 0; i--) { + const candidate = byVersion.get(earlier[i]!) ?? null; + const d = diffShallow(candidate ?? {}, rawTarget); + if (d.added.length || d.removed.length || d.changed.length) { + fromVersion = earlier[i]!; + fromBody = candidate; + break; + } } } // [#8671] Diff RAW, then redact the EMITTED values — maintainer ruling From 21778aeb93a2101696782ac38905594fbca7e978 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 20:27:25 +0000 Subject: [PATCH 3/4] docs(spec,rest,client): state the default /diff from side where the rule is documented; add the changeset The response schema's JSDoc, the route's OpenAPI summary and the SDK's diffItem docblock now say what an omitted from and to mean. The pending sibling changeset's sentence about the from default is corrected for the same release. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude --- .changeset/20397-diff-default-range-labels.md | 2 +- .changeset/20451-diff-default-from-differs.md | 25 +++++++++++++++++++ packages/client/src/index.ts | 5 ++-- packages/rest/src/rest-server.ts | 2 +- packages/spec/src/api/protocol.zod.ts | 4 ++- 5 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 .changeset/20451-diff-default-from-differs.md diff --git a/.changeset/20397-diff-default-range-labels.md b/.changeset/20397-diff-default-range-labels.md index 5d1b4aba118..663c490e8bb 100644 --- a/.changeset/20397-diff-default-range-labels.md +++ b/.changeset/20397-diff-default-range-labels.md @@ -6,6 +6,6 @@ fix(metadata-protocol): `diffMetaItem`'s default range labels its to side with t With no `toVersion`, the to side is the current active `sys_metadata` row. Its body was compared, but `toVersion` came from the newest `sys_metadata_history` row, which is a draft save whenever a draft is pending: every draft save appends a history row. The labels and the bodies then named different rows. Measured on the real REST stack, an app with one active save and two draft saves answered `fromVersion 2 → toVersion 3` over its version-1 body, and a view with one active save and one draft save answered "no changes" labelled `1 → 2` while version 2 differs. -- **Now:** `toVersion` is the active row's own `version`, read in the same read as its body. The default `fromVersion` is still the history version immediately before that label. An item whose active row is version 2 with a draft pending answers `1 → 2`, the same answer as `?from=1&to=2`. +- **Now:** `toVersion` is the active row's own `version`, read in the same read as its body. The default `fromVersion` rule is not changed by this entry (#20451, in the same release, then moves it to the nearest earlier version whose body differs from the to side's). An item whose active row is version 2 with a draft pending answers `1 → 2`, the same answer as `?from=1&to=2`. - **No active row** (a draft-only item, or a deleted one): the to side is absent, and both labels are `null` with empty buckets, as the response schema declares for an absent side. Before, a draft-only item was labelled with its newest draft save, and its from side could be an earlier draft save's body. A deleted item was labelled `N-1 → N` up to its tombstone. That deletion is still read by naming its versions (`?from=N-1&to=N`). - Unchanged: the response shape, explicit `from` / `to` ranges, and the default range of an item with no draft pending. diff --git a/.changeset/20451-diff-default-from-differs.md b/.changeset/20451-diff-default-from-differs.md new file mode 100644 index 00000000000..a09da07e44d --- /dev/null +++ b/.changeset/20451-diff-default-from-differs.md @@ -0,0 +1,25 @@ +--- +'@objectstack/metadata-protocol': patch +'@objectstack/rest': patch +--- + +fix(metadata-protocol): `GET /meta/:type/:name/diff` with no `from` compares against the nearest earlier version whose body differs, so the default diff right after a publish shows what the publish changed (#20451) + +Clause-②: no — no key, export, route, parameter or response field moves; only which version the default `from` side names. + +Every draft save appends a `sys_metadata_history` row, and publishing the draft appends the same body again as the next row. The default `from` side was the history row immediately before the `to` side, so right after a publish it was the draft save the publish came from, and the default diff answered "no changes". The change the publish carried was reachable only by naming `?from=`. + +- **Now:** with no `from`, `diffMetaItem` walks back from the `to` side over the history rows it already reads and takes the nearest earlier row whose body differs, by the diff's own equality (all three buckets empty means equal). A body-less row, a delete's, compares as an empty body, so the walk stops on it and the answer names the deletion. With no earlier row that differs, the `from` side is absent: `fromVersion: null`, everything added. +- **Measured on the real REST stack**, before → after: + +| history | default range before | default range now | +|:--|:--|:--| +| v1 active, v2 draft save, v3 publish | `2 → 3`, no changes | `1 → 3`, the change the publish carried | +| the same with a v4 draft pending | `2 → 3`, no changes | `1 → 3` | +| create, delete, draft save, publish | `3 → 4`, no changes | `2 → 4`, everything added | +| create, delete, active recreate | `2 → 3`, everything added | unchanged | +| a new item draft-saved, then published | `1 → 2`, no changes | `null → 2`, everything added | +| a single version | `null → 1`, everything added | unchanged | + +- **Unchanged:** an explicit `?from=` / `?to=` names exactly its versions (`?from=2&to=3` over the first row still answers "no changes"); the default `to` side is the active version; the response shape; the one history read, with no cap. The walk compares the stored bodies before redaction, as the diff itself does, so a credential-only change still stops it and its values are still not served. +- `@objectstack/rest`: the route's OpenAPI summary states the new default. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 85056f06383..d8c27571bae 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -2192,8 +2192,9 @@ export class ObjectStackClient { }, /** - * Structural diff between two history versions (`from`/`to`); omit both - * for previous-vs-current. + * Structural diff between two history versions (`from`/`to`). Omit `to` + * for the active version; omit `from` for the nearest earlier version + * whose body differs from the `to` side's. */ diffItem: async (type: string, name: string, opts?: { from?: number; to?: number }): Promise => { const route = this.getRoute('metadata'); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 01de5bf7829..cec64f20263 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -8031,7 +8031,7 @@ export class RestServer { } }, metadata: { - summary: 'Diff two metadata versions (from/to query params; omit for previous-vs-current)', + summary: 'Diff two metadata versions (from/to query params; to defaults to the active version, from to the nearest earlier version whose body differs)', tags: ['metadata'], }, }); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 9ac510ab36b..85f68fa41d5 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -1779,7 +1779,9 @@ export const RollbackMetaItemResponseSchema = lazySchema(() => z.object({ /** * `GET /meta/:type/:name/diff` — structural diff between two history - * versions (`from`/`to`; omit both for previous-vs-current). + * versions (`from`/`to`). An omitted `to` is the active version; an omitted + * `from` is the nearest earlier version whose body differs from the `to` + * side's (a deletion counts as an empty body). * * Transcribed from `diffMetaItem`'s declared return * (`@objectstack/metadata-protocol` `protocol.ts`). From 183466a48f52ff6537c5cb7bc938a8e6bd8ef9fe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 21:02:38 +0000 Subject: [PATCH 4/4] test(rest): the default-range file header no longer says the previous version Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude --- packages/rest/src/meta-diff-default-range-labels.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rest/src/meta-diff-default-range-labels.test.ts b/packages/rest/src/meta-diff-default-range-labels.test.ts index 7c1fe392388..3dc3a41c020 100644 --- a/packages/rest/src/meta-diff-default-range-labels.test.ts +++ b/packages/rest/src/meta-diff-default-range-labels.test.ts @@ -1,8 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#20397] `GET /meta/:type/:name/diff` with no `from` / `to` compares the - * previous version with the CURRENT one, and labels each side with the version + * [#20397] `GET /meta/:type/:name/diff` with no `from` / `to` compares an + * earlier version with the CURRENT one, and labels each side with the version * whose body it is. * * ## The defect