Skip to content

Commit 1d5d541

Browse files
fix(metadata-protocol): the default /diff from side is the nearest earlier version whose body differs (#20451) (#20518)
Fixes #20451 Clause-②: no ## What changes `diffMetaItem` (`packages/metadata-protocol/src/protocol.ts`), the default `from` side only. With no `fromVersion`, the from side is now the **nearest earlier history row whose body differs from the to side's**, by the diff's own equality: `diffShallow`'s three buckets not all empty, with an absent body compared as `{}` (the same `?? {}` the comparison below it uses). A body-less row (a delete's tombstone) therefore differs from any non-empty to side, and the walk stops on it (triage's answer A, 5875579209). With no earlier row that differs, the from side is absent: `fromVersion: null`, everything added. - The walk reads only the rows the function already has: the one `find` over `sys_metadata_history` (no limit), which the function sorts by `version` in memory. It adds no read and no cap. A unit pin asserts one history read on a walk path. - It reads no `operation_type` and adds no state column (ruling B on #20378, 5865708652). - An explicit `?from=` / `?to=` names exactly its versions. The to-side default (the active row's own version, PR #20443) is untouched. An explicit `?to=` with no `?from=` walks back from the named version's body. - The comparison runs on the stored bodies before redaction, as the diff itself does, so a credential-only change still stops the walk and its values are still not served (unit pin). The four statements of the default rule now say the new rule, each in its own words: the `diffMetaItem` docblock, `DiffMetaItemResponseSchema`'s JSDoc (`packages/spec`), the route's OpenAPI summary in `rest-server.ts`, and the SDK's `diffItem` docblock (`packages/client`). Comment and summary text only: no schema, route or signature change. ## Measured on the real REST stack The REST pins (`packages/rest/src/meta-diff-default-range-labels.test.ts`, real routes and real writes over better-sqlite3 `:memory:`) were committed first (`9b308b12b`) and run against the unchanged source with its closure built: **3 failed, 9 passed**. After the change: **12 passed**. | history (fixture-proved by reading `sys_metadata_history`) | before | after | |:--|:--|:--| | v1 `create` A (active), v2 `create` B (draft save), v3 `publish` B | `2 → 3`, empty | `1 → 3`, `label` and `columns` changed, equal to `?from=1&to=3` | | the same, then a v4 draft save | `2 → 3`, empty | `1 → 3` | | v1 `create` A, v2 `delete` (no body), v3 `create` A2 (draft), v4 `publish` A2 | `3 → 4`, empty | `2 → 4`, everything added, equal to `?from=2&to=4` | | v1 `create` A, v2 `delete` (no body), v3 `create` B (active) | `2 → 3`, everything added | the same bytes (green before and after) | | v1 `create` New (draft), v2 `publish` New | `1 → 2`, empty | `null → 2`, everything added | | v1 `create` (active) only | `null → 1`, everything added | the same bytes | | explicit `?from=2&to=3` over the first lineage | `2 → 3`, empty | the same bytes | The unit pins in `protocol.diff-dead-history-read.test.ts` repeat these lineages over seeded rows beside the file's read-counting double. **Ablation**, from the committed state: `node scripts/ablation-replace.mjs` replaced the walk's differ test (`if (d.added.length || d.removed.length || d.changed.length) {` → `if (true) {`, which is the old immediately-previous rule), anchor 1 → 0, blob `a2d2b7686f29` → `dd9cffcbd1f9`; the file ran **6 failed / 14 passed**, exactly the six walk-dependent pins; restored, blob == HEAD and `git diff HEAD` empty. No build is involved: the metadata-protocol suite imports `./index.js` from source. ## A pending release note corrected: needs confirmation (Check Changeset stays red) `.changeset/20397-diff-default-range-labels.md` (PR #20443, not yet released) said "The default `fromVersion` is still the history version immediately before that label." This PR makes that sentence false in the same release, so it now reads: "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)." One sentence, nothing else in that file. This is the DELIBERATE CORRECTION class `check-empty-changeset.mjs` names, so that gate exits 1 locally and **Check Changeset will stay red on purpose**. Please confirm the correction on this PR. It was outside the claim's file surface. `skip-changeset` is not applied and must not be. ## Changeset `.changeset/20451-diff-default-from-differs.md`: `@objectstack/metadata-protocol` `patch` and `@objectstack/rest` `patch`, `Clause-②: no`. The rest line is there because the route's OpenAPI summary is a runtime string served in the OpenAPI document. The `packages/spec` JSDoc and the `packages/client` docblock are comment-only, so they get no line, per the repo's rule that comments do not publish. All three packages are in one `fixed` group, so versions do not move differently either way. ## Verification (measured at `1f258bbd5`, after merging `origin/main` `9449512a3` with a true merge commit) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 88 commands, all run, each exit code written to a file before any pipe. **87 exit 0; 1 exit 1**: `node scripts/check-empty-changeset.mjs --base origin/main`, the release-note correction above. Also run, outside the derivation: the four roster gates whose roster sits under a changed path (`check-changeset-fixed`, `check:meta-url-spelling`, `check:spec-changes`, `check:error-code-casing`), all exit 0. - `dispatch-gates --ran`: "88 derived famil(ies) accounted for — 88 run, 0 NOT-MEASURED". - Tests, each through `os-verify-lock`: metadata-protocol 189 files passed, 3 skipped (2759 tests); rest `--project local` 219 files passed (4181 tests); client 50 files passed (641 tests); spec `--project local` 573 files passed (16801 tests). - Typecheck: metadata-protocol, rest (with `check:test-typecheck`), client (with `check:test-typecheck`) and spec all exit 0. Both edited test files are in their tsc programs (`--listFiles`: 1 hit each). - `pnpm --filter @objectstack/spec check:generated`: all 15 generated artifacts up to date, against a spec `dist` built from this tree. - Declared to CI, not run here: the five path-scheduled jobs (Test Core shards, Temporal Conformance, Dogfood Regression, Dogfood Verify CLI, Build Core) and the workspace type-check lanes, which `dispatch-gates` lists as CI's own shell. ## Statements the census named, measured - **Edited:** the four above, plus the header of `meta-diff-default-range-labels.test.ts` (this PR adds to that file), which said the from side is "the history row immediately preceding that label". - **Not false, not edited:** `docs/qa/platform-checklist/areas/studio-authoring.json` lines 346 and 402 ("omit the params for previous-vs-current"). On that probe's lifecycle (draft save, publish, second draft save, second publish) the default range now compares the second published revision with the first (`2 → 4`), which is the comparison those steps describe. Before this PR it compared the second publish with its own draft save and answered "no changes". - **Not false, not edited:** the test title in `rest-server-query-number-reads.test.ts:300` ("from/to still mean previous-vs-current (no version members)"). It asserts only that no version member reaches the verb, which still holds. This PR does not touch that file. ## Acceptance notes - The same checklist steps' explicit range `?from=1&to=2` compares the probe's first draft save (v1) with its own publish (v2). In the draft-then-publish lifecycle those two rows carry the same body, so that range answers "no changes", before and after this PR. This is a checklist wording issue, not a product defect. No card filed; carrier: none. - `DiffMetaItemResponseSchema.fromVersion`'s `.describe()` reads "`null` when that side is absent (e.g. the item had no earlier version)". It is still true, and now `null` also answers "no earlier version differs". It was left as is: a `.describe()` edit regenerates spec docs, and the claim limits `packages/spec` to comment text. - `.changeset/20139-rest-query-number-census.md` says "previous-vs-current on `/diff`" about absent parameters keeping their default. That is still true of the parameter handling. It is somebody else's pending note and is not touched. - Test-side deviation: `seedLineage` in `protocol.diff-dead-history-read.test.ts` moved from inside the #20397 `describe` to module scope, unchanged, so the #20451 block shares it rather than copying it. --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fb194c7 commit 1d5d541

8 files changed

Lines changed: 515 additions & 57 deletions

File tree

‎.changeset/20397-diff-default-range-labels.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@ fix(metadata-protocol): `diffMetaItem`'s default range labels its to side with t
66

77
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.
88

9-
- **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`.
9+
- **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`.
1010
- **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`).
1111
- Unchanged: the response shape, explicit `from` / `to` ranges, and the default range of an item with no draft pending.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/rest': patch
4+
---
5+
6+
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)
7+
8+
Clause-②: no — no key, export, route, parameter or response field moves; only which version the default `from` side names.
9+
10+
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=`.
11+
12+
- **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.
13+
- **Measured on the real REST stack**, before → after:
14+
15+
| history | default range before | default range now |
16+
|:--|:--|:--|
17+
| v1 active, v2 draft save, v3 publish | `2 → 3`, no changes | `1 → 3`, the change the publish carried |
18+
| the same with a v4 draft pending | `2 → 3`, no changes | `1 → 3` |
19+
| create, delete, draft save, publish | `3 → 4`, no changes | `2 → 4`, everything added |
20+
| create, delete, active recreate | `2 → 3`, everything added | unchanged |
21+
| a new item draft-saved, then published | `1 → 2`, no changes | `null → 2`, everything added |
22+
| a single version | `null → 1`, everything added | unchanged |
23+
24+
- **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.
25+
- `@objectstack/rest`: the route's OpenAPI summary states the new default.

‎packages/client/src/index.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2192,8 +2192,9 @@ export class ObjectStackClient {
21922192
},
21932193

21942194
/**
2195-
* Structural diff between two history versions (`from`/`to`); omit both
2196-
* for previous-vs-current.
2195+
* Structural diff between two history versions (`from`/`to`). Omit `to`
2196+
* for the active version; omit `from` for the nearest earlier version
2197+
* whose body differs from the `to` side's.
21972198
*/
21982199
diffItem: async (type: string, name: string, opts?: { from?: number; to?: number }): Promise<DiffMetaItemResponse> => {
21992200
const route = this.getRoute('metadata');

‎packages/metadata-protocol/src/protocol.diff-dead-history-read.test.ts‎

Lines changed: 248 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,46 @@ describe('#8798 — a history-table outage now answers the same way for every ty
283283
});
284284
});
285285

286+
type Seed = { version: number; op: string; body: Record<string, unknown> | null };
287+
/**
288+
* History rows in version order, plus the active and draft rows they left
289+
* behind. Shared by the #20397 and #20451 blocks below, both about the
290+
* DEFAULT range; the REST file names the real writes each lineage mirrors.
291+
*/
292+
function seedLineage(
293+
tables: Record<string, Array<Record<string, unknown>>>,
294+
type: string,
295+
name: string,
296+
history: Seed[],
297+
rows: { active?: Seed; draft?: Seed },
298+
) {
299+
const base = { organization_id: null, type, name };
300+
history.forEach((h, i) => {
301+
tables.sys_metadata_history!.push({
302+
...base,
303+
id: `h_${h.version}`,
304+
version: h.version,
305+
event_seq: i + 1,
306+
operation_type: h.op,
307+
metadata: h.body == null ? null : JSON.stringify(h.body),
308+
checksum: h.body == null ? null : hashSpec(h.body),
309+
recorded_at: new Date(i + 1).toISOString(),
310+
});
311+
});
312+
for (const state of ['active', 'draft'] as const) {
313+
const row = rows[state];
314+
if (!row) continue;
315+
tables.sys_metadata!.push({
316+
...base,
317+
id: `m_${state}`,
318+
state,
319+
version: row.version,
320+
metadata: JSON.stringify(row.body),
321+
checksum: hashSpec(row.body!),
322+
});
323+
}
324+
}
325+
286326
/**
287327
* [#20397] The DEFAULT range (no `toVersion`) labels its to side with the
288328
* version whose body it compares.
@@ -297,42 +337,6 @@ describe('#8798 — a history-table outage now answers the same way for every ty
297337
* real REST stack are `packages/rest/src/meta-diff-default-range-labels.test.ts`.
298338
*/
299339
describe('#20397 — the default range labels the to side with the active row\'s own version', () => {
300-
type Seed = { version: number; op: string; body: Record<string, unknown> | null };
301-
/** History rows in version order, plus the active and draft rows they left behind. */
302-
function seedLineage(
303-
tables: Record<string, Array<Record<string, unknown>>>,
304-
type: string,
305-
name: string,
306-
history: Seed[],
307-
rows: { active?: Seed; draft?: Seed },
308-
) {
309-
const base = { organization_id: null, type, name };
310-
history.forEach((h, i) => {
311-
tables.sys_metadata_history!.push({
312-
...base,
313-
id: `h_${h.version}`,
314-
version: h.version,
315-
event_seq: i + 1,
316-
operation_type: h.op,
317-
metadata: h.body == null ? null : JSON.stringify(h.body),
318-
checksum: h.body == null ? null : hashSpec(h.body),
319-
recorded_at: new Date(i + 1).toISOString(),
320-
});
321-
});
322-
for (const state of ['active', 'draft'] as const) {
323-
const row = rows[state];
324-
if (!row) continue;
325-
tables.sys_metadata!.push({
326-
...base,
327-
id: `m_${state}`,
328-
state,
329-
version: row.version,
330-
metadata: JSON.stringify(row.body),
331-
checksum: hashSpec(row.body!),
332-
});
333-
}
334-
}
335-
336340
for (const type of [ORDINARY_TYPE, 'app']) {
337341
it(`${type}: with a draft pending, toVersion is the active row's version and the answer is the explicit range's`, async () => {
338342
const { engine, tables } = makeStubEngine();
@@ -436,3 +440,211 @@ describe('#20397 — the default range labels the to side with the active row\'s
436440
});
437441
});
438442
});
443+
444+
/**
445+
* [#20451] With no `fromVersion`, the from side is the NEAREST EARLIER history
446+
* row whose body DIFFERS from the to side's, by the diff's own equality:
447+
* `diffShallow`'s three buckets all empty means equal, and a body-less row (a
448+
* delete's tombstone) compares as `{}` — so the walk stops on it.
449+
*
450+
* A draft save appends a history row and a publish appends the promoted body
451+
* again as the next one, so the row immediately before a published version
452+
* repeats its body. The old rule (the row immediately before) answered "no
453+
* changes" right after every publish. The lineages below are the ones the REST
454+
* pins in `packages/rest/src/meta-diff-default-range-labels.test.ts` write
455+
* through the real routes; here they are seeded, beside this file's
456+
* read-counting double.
457+
*/
458+
describe('#20451 — the default from side is the nearest earlier row whose body differs from the to side\'s', () => {
459+
const everythingAdded = (body: Record<string, unknown>) =>
460+
Object.entries(body).map(([path, value]) => ({ path, value }));
461+
462+
it('v1 active, a v2 draft save, a v3 publish: 1 → 3, the change the publish carried, in ONE history read', async () => {
463+
const { engine, tables, historyReads } = makeStubEngine();
464+
const a = { name: 'item', label: 'A' };
465+
const b = { name: 'item', label: 'B' };
466+
seedLineage(tables, ORDINARY_TYPE, 'item', [
467+
{ version: 1, op: 'create', body: a },
468+
{ version: 2, op: 'create', body: b },
469+
{ version: 3, op: 'publish', body: b },
470+
], { active: { version: 3, op: 'publish', body: b } });
471+
const protocol = new ObjectStackProtocolImplementation(engine);
472+
473+
const res: any = await protocol.diffMetaItem({ type: ORDINARY_TYPE, name: 'item' });
474+
475+
expect(historyReads()).toBe(1);
476+
expect(res).toEqual({
477+
type: ORDINARY_TYPE,
478+
name: 'item',
479+
fromVersion: 1,
480+
toVersion: 3,
481+
added: [],
482+
removed: [],
483+
changed: [{ path: 'label', from: 'A', to: 'B' }],
484+
});
485+
expect(res).toEqual(await protocol.diffMetaItem({ type: ORDINARY_TYPE, name: 'item', fromVersion: 1, toVersion: 3 }));
486+
});
487+
488+
it('the same lineage with a v4 draft pending still answers 1 → 3: the draft is neither side', async () => {
489+
const { engine, tables } = makeStubEngine();
490+
const a = { name: 'item', label: 'A' };
491+
const b = { name: 'item', label: 'B' };
492+
const c = { name: 'item', label: 'C pending' };
493+
seedLineage(tables, ORDINARY_TYPE, 'item', [
494+
{ version: 1, op: 'create', body: a },
495+
{ version: 2, op: 'create', body: b },
496+
{ version: 3, op: 'publish', body: b },
497+
{ version: 4, op: 'create', body: c },
498+
], { active: { version: 3, op: 'publish', body: b }, draft: { version: 4, op: 'create', body: c } });
499+
const protocol = new ObjectStackProtocolImplementation(engine);
500+
501+
const res: any = await protocol.diffMetaItem({ type: ORDINARY_TYPE, name: 'item' });
502+
503+
expect(res.fromVersion).toBe(1);
504+
expect(res.toVersion).toBe(3);
505+
expect(res.changed).toEqual([{ path: 'label', from: 'A', to: 'B' }]);
506+
expect(JSON.stringify(res)).not.toContain('C pending');
507+
});
508+
509+
it('an explicit range names exactly its versions: 2 → 3 over that lineage is still "no changes"', async () => {
510+
const { engine, tables } = makeStubEngine();
511+
const a = { name: 'item', label: 'A' };
512+
const b = { name: 'item', label: 'B' };
513+
seedLineage(tables, ORDINARY_TYPE, 'item', [
514+
{ version: 1, op: 'create', body: a },
515+
{ version: 2, op: 'create', body: b },
516+
{ version: 3, op: 'publish', body: b },
517+
], { active: { version: 3, op: 'publish', body: b } });
518+
const protocol = new ObjectStackProtocolImplementation(engine);
519+
520+
const res: any = await protocol.diffMetaItem({ type: ORDINARY_TYPE, name: 'item', fromVersion: 2, toVersion: 3 });
521+
522+
expect(res).toEqual({
523+
type: ORDINARY_TYPE, name: 'item', fromVersion: 2, toVersion: 3, added: [], removed: [], changed: [],
524+
});
525+
});
526+
527+
it('an explicit `toVersion` alone keeps its version, and the from side walks back from ITS body', async () => {
528+
const { engine, tables } = makeStubEngine();
529+
const a = { name: 'item', label: 'A' };
530+
const b = { name: 'item', label: 'B' };
531+
const c = { name: 'item', label: 'C' };
532+
seedLineage(tables, ORDINARY_TYPE, 'item', [
533+
{ version: 1, op: 'create', body: a },
534+
{ version: 2, op: 'create', body: b },
535+
{ version: 3, op: 'publish', body: b },
536+
{ version: 4, op: 'update', body: c },
537+
], { active: { version: 4, op: 'update', body: c } });
538+
const protocol = new ObjectStackProtocolImplementation(engine);
539+
540+
const res: any = await protocol.diffMetaItem({ type: ORDINARY_TYPE, name: 'item', toVersion: 3 });
541+
542+
expect(res.fromVersion).toBe(1);
543+
expect(res.toVersion).toBe(3);
544+
expect(res.changed).toEqual([{ path: 'label', from: 'A', to: 'B' }]);
545+
});
546+
547+
it('create, delete, draft save, publish: 2 → 4, everything added — the body-less delete row differs, so the walk stops on it', async () => {
548+
const { engine, tables } = makeStubEngine();
549+
const a = { name: 'item', label: 'A' };
550+
const a2 = { name: 'item', label: 'A2', columns: ['name'] };
551+
seedLineage(tables, ORDINARY_TYPE, 'item', [
552+
{ version: 1, op: 'create', body: a },
553+
{ version: 2, op: 'delete', body: null },
554+
{ version: 3, op: 'create', body: a2 },
555+
{ version: 4, op: 'publish', body: a2 },
556+
], { active: { version: 4, op: 'publish', body: a2 } });
557+
const protocol = new ObjectStackProtocolImplementation(engine);
558+
559+
const res: any = await protocol.diffMetaItem({ type: ORDINARY_TYPE, name: 'item' });
560+
561+
expect(res).toEqual({
562+
type: ORDINARY_TYPE,
563+
name: 'item',
564+
fromVersion: 2,
565+
toVersion: 4,
566+
added: everythingAdded(a2),
567+
removed: [],
568+
changed: [],
569+
});
570+
});
571+
572+
it('create, delete, active recreate: 2 → 3, everything added — the answer the immediately-previous rule gave', async () => {
573+
const { engine, tables } = makeStubEngine();
574+
const a = { name: 'item', label: 'A' };
575+
const b = { name: 'item', label: 'B' };
576+
seedLineage(tables, ORDINARY_TYPE, 'item', [
577+
{ version: 1, op: 'create', body: a },
578+
{ version: 2, op: 'delete', body: null },
579+
{ version: 3, op: 'create', body: b },
580+
], { active: { version: 3, op: 'create', body: b } });
581+
const protocol = new ObjectStackProtocolImplementation(engine);
582+
583+
const res: any = await protocol.diffMetaItem({ type: ORDINARY_TYPE, name: 'item' });
584+
// Naming the tombstone as the to side: an absent body is `{}` there
585+
// too, so the walk passes nothing and lands on v1, as it always did.
586+
const deletion: any = await protocol.diffMetaItem({ type: ORDINARY_TYPE, name: 'item', toVersion: 2 });
587+
588+
expect(res).toEqual({
589+
type: ORDINARY_TYPE, name: 'item', fromVersion: 2, toVersion: 3, added: everythingAdded(b), removed: [], changed: [],
590+
});
591+
expect(deletion).toEqual({
592+
type: ORDINARY_TYPE, name: 'item', fromVersion: 1, toVersion: 2, added: [], removed: everythingAdded(a), changed: [],
593+
});
594+
});
595+
596+
it('a brand-new item draft-saved then published: null → 2, everything added — no earlier row differs', async () => {
597+
const { engine, tables } = makeStubEngine();
598+
const n = { name: 'item', label: 'New' };
599+
seedLineage(tables, ORDINARY_TYPE, 'item', [
600+
{ version: 1, op: 'create', body: n },
601+
{ version: 2, op: 'publish', body: n },
602+
], { active: { version: 2, op: 'publish', body: n } });
603+
const protocol = new ObjectStackProtocolImplementation(engine);
604+
605+
const res: any = await protocol.diffMetaItem({ type: ORDINARY_TYPE, name: 'item' });
606+
607+
expect(res).toEqual({
608+
type: ORDINARY_TYPE, name: 'item', fromVersion: null, toVersion: 2, added: everythingAdded(n), removed: [], changed: [],
609+
});
610+
});
611+
612+
it('a single version: null → 1, everything added', async () => {
613+
const { engine, tables } = makeStubEngine();
614+
const only = { name: 'item', label: 'Only' };
615+
seedLineage(tables, ORDINARY_TYPE, 'item', [
616+
{ version: 1, op: 'create', body: only },
617+
], { active: { version: 1, op: 'create', body: only } });
618+
const protocol = new ObjectStackProtocolImplementation(engine);
619+
620+
const res: any = await protocol.diffMetaItem({ type: ORDINARY_TYPE, name: 'item' });
621+
622+
expect(res).toEqual({
623+
type: ORDINARY_TYPE, name: 'item', fromVersion: null, toVersion: 1, added: everythingAdded(only), removed: [], changed: [],
624+
});
625+
});
626+
627+
it('the walk compares RAW bodies: a credential-only rotation stops it, and the served values stay redacted', async () => {
628+
// The #8671 ruling (diff raw, redact what is emitted) applies to the
629+
// walk's comparison too. Compared redacted, the two bodies below are
630+
// equal and the walk would pass the rotation by.
631+
const { engine, tables } = makeStubEngine();
632+
const config = (password: string) => ({ host: 'db.internal', username: 'reporting', password });
633+
const old = { name: 'warehouse', label: 'Warehouse', driver: 'postgres', config: config('hunter2-old') };
634+
const rotated = { name: 'warehouse', label: 'Warehouse', driver: 'postgres', config: config('hunter3-new') };
635+
seedLineage(tables, 'datasource', 'warehouse', [
636+
{ version: 1, op: 'create', body: old },
637+
{ version: 2, op: 'create', body: rotated },
638+
{ version: 3, op: 'publish', body: rotated },
639+
], { active: { version: 3, op: 'publish', body: rotated } });
640+
const protocol = new ObjectStackProtocolImplementation(engine);
641+
642+
const res: any = await protocol.diffMetaItem({ type: 'datasource', name: 'warehouse' });
643+
644+
expect(res.fromVersion).toBe(1);
645+
expect(res.toVersion).toBe(3);
646+
expect(res.changed.map((e: { path: string }) => e.path)).toEqual(['config']);
647+
expect(JSON.stringify(res)).not.toContain('hunter2-old');
648+
expect(JSON.stringify(res)).not.toContain('hunter3-new');
649+
});
650+
});

0 commit comments

Comments
 (0)