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/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 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..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 @@ -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); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 365f0a2c7d4..f1d0f7e0c06 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -8006,7 +8006,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`).