Skip to content

Commit 05bc692

Browse files
Elon Muskclaude
andauthored
test(metadata-protocol,metadata-core): run SysMetadataRepository through the shared repository contract suite (#10851)
* test(metadata-protocol): run SysMetadataRepository through the shared repository contract suite (#10420) * test(metadata-core): declared-divergence mechanism for the repository contract suite (#10420) * chore(changeset): metadata-core contract-suite options for #10420 * chore(gates): record the new SysMetadataRepository contract-suite engine double in the pinned ledger (#10420) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dbb4803 commit 05bc692

4 files changed

Lines changed: 479 additions & 48 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/metadata-core": minor
3+
---
4+
5+
`runRepositoryContractTests` gains two narrow options so the shared invariant
6+
table can be applied to `SysMetadataRepository` — the implementation that backs
7+
every production metadata write, and the one that had never been handed to the
8+
suite (#10420). Both are additive and optional; every existing call site is
9+
unchanged.
10+
11+
- **`primaryType` / `secondaryType`** move the suite's two *fixture* metadata
12+
types (previously hard-coded `'view'` and `'object'`), defaulting to exactly
13+
those. This is a fixture knob, not an invariant knob: no clause is added,
14+
removed or weakened by moving it. It exists because an implementation may sit
15+
behind a write-authorization door keyed on the type —
16+
`SysMetadataRepository.assertAllowed()` refuses any type whose registry entry
17+
lacks `allowOrgOverride`, `'object'` included — so a hard-coded fixture type
18+
silently decided which implementations could be held to the table at all.
19+
- **`declaredDivergences`** records an issue-tracked exception to the table.
20+
It does **not** skip the clause it names — a skipped clause is
21+
indistinguishable from coverage in a green run, which is the one failure a
22+
shared contract suite must not have. It swaps in a clause that *pins the
23+
divergent behaviour*, so the suite reds the day the implementation starts
24+
conforming and whoever fixes it is told to delete the declaration in the same
25+
PR. Shrink-only, audited in the fixing direction, like the repo's other
26+
ledgers. The only member today is `resumableWatch` (contract invariant 6), and
27+
the only declaration is `SysMetadataRepository` — see #10842.
28+
29+
Publishable behaviour is otherwise untouched: `packages/metadata-protocol` gains
30+
a test file only, and 32 of the suite's 34 clauses were already satisfied by
31+
`SysMetadataRepository` on the first run.

packages/metadata-core/src/contract-suite.ts

Lines changed: 184 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,75 @@
1616
* 5. Event ordering (monotonic seq, no gaps)
1717
* 6. Resumability (watch with `since` replays)
1818
* 7. Tombstones (delete event emitted, get returns null)
19+
*
20+
* Two knobs, both narrow on purpose. `primaryType` / `secondaryType` move the
21+
* FIXTURE metadata types (an implementation may sit behind a write door keyed
22+
* on the type — see the option's own notes); `declaredDivergences` records an
23+
* issue-tracked exception to the table WITHOUT skipping the clause it names.
24+
* Neither adds, removes or weakens an invariant, which is the property that
25+
* keeps this one table rather than one table per implementation.
1926
*/
2027

2128
import { describe, it, expect } from 'vitest';
2229
import type { MetadataRepository } from './repository.js';
23-
import type { MetaRef, MetadataEvent } from './types.js';
30+
import type { MetaRef, MetadataEvent, MetadataType } from './types.js';
2431
import { hashSpec } from './canonicalize.js';
2532
import { ConflictError } from './errors.js';
2633

2734
export interface ContractSuiteOptions {
2835
/** If the implementation supports `version`-pinned reads, set true. */
2936
supportsVersionedReads?: boolean;
37+
/**
38+
* The metadata type nearly every clause writes under. Defaults to `'view'`.
39+
*
40+
* A FIXTURE knob, deliberately not an invariant knob: no clause below is
41+
* added, removed or weakened by moving it, because none of the seven
42+
* invariants is a statement about a particular type. It exists because an
43+
* implementation may sit behind a **write-authorization door** keyed on the
44+
* type — `SysMetadataRepository.assertAllowed()` refuses any type whose
45+
* registry entry lacks `allowOrgOverride` — so a hard-coded fixture type
46+
* decides which implementations can be held to the table at all. Naming the
47+
* two types here is what keeps that ONE table, instead of carving a second
48+
* one for the engine-backed implementation to be measured against.
49+
*/
50+
primaryType?: MetadataType;
51+
/**
52+
* A second, DISTINCT type, used only where a clause must prove a type filter
53+
* discriminates (`list`'s `type` filter, `watch`'s). Defaults to `'object'`.
54+
* Same fixture-knob argument as {@link primaryType}; it must differ from it
55+
* or those two clauses assert nothing.
56+
*/
57+
secondaryType?: MetadataType;
58+
/**
59+
* Issue-tracked exceptions to the invariant table above.
60+
*
61+
* ⚠️ Read the shape before reaching for it. A declaration does NOT skip the
62+
* clause it names — a skipped clause is indistinguishable from coverage in a
63+
* green run, which is the one failure a shared contract suite must not have.
64+
* It swaps the clause for one that **pins the divergent behaviour**, so the
65+
* suite reds the day the implementation starts conforming and whoever fixes
66+
* it is told, by name, to delete the declaration in the same PR. Same
67+
* shrink-only, audited-in-both-directions shape as the repo's other ledgers.
68+
*
69+
* There is deliberately no free-form escape here: every member is one named
70+
* invariant, and its value is the issue that will retire it.
71+
*/
72+
declaredDivergences?: DeclaredDivergences;
3073
}
3174

32-
const refOf = (overrides: Partial<MetaRef> = {}): MetaRef => ({
33-
org: 'system',
34-
type: 'view',
35-
name: 'sample_view',
36-
...overrides,
37-
});
75+
/** @see ContractSuiteOptions.declaredDivergences */
76+
export interface DeclaredDivergences {
77+
/**
78+
* **Invariant 6 (resumability).** The implementation's `watch()` delivers
79+
* live events only; it never replays from its durable log, so neither
80+
* `watch(filter, since)` nor a `watch(filter)` opened after a write can
81+
* surface an event that already committed.
82+
*
83+
* Value is the tracking issue, e.g. `'#10842'` — `SysMetadataRepository`,
84+
* the only declaration today.
85+
*/
86+
resumableWatch?: string;
87+
}
3888

3989
const spec = (label: string) => ({ label, columns: ['a', 'b'] });
4090

@@ -123,6 +173,28 @@ export function runRepositoryContractTests(
123173
factory: () => MetadataRepository | Promise<MetadataRepository>,
124174
opts: ContractSuiteOptions = {},
125175
): void {
176+
const primaryType: MetadataType = opts.primaryType ?? 'view';
177+
const secondaryType: MetadataType = opts.secondaryType ?? 'object';
178+
if (primaryType === secondaryType) {
179+
throw new Error(
180+
`runRepositoryContractTests(${label}): primaryType and secondaryType must differ — ` +
181+
`both are '${primaryType}', which makes the list/watch type-filter clauses vacuous.`,
182+
);
183+
}
184+
const resumableWatchDivergence = opts.declaredDivergences?.resumableWatch;
185+
if (resumableWatchDivergence !== undefined && resumableWatchDivergence.trim() === '') {
186+
throw new Error(
187+
`runRepositoryContractTests(${label}): declaredDivergences.resumableWatch must name the ` +
188+
`tracking issue — an anonymous exception is the skip this mechanism exists to refuse.`,
189+
);
190+
}
191+
const refOf = (overrides: Partial<MetaRef> = {}): MetaRef => ({
192+
org: 'system',
193+
type: primaryType,
194+
name: 'sample_view',
195+
...overrides,
196+
});
197+
126198
describe(`MetadataRepository contract — ${label}`, () => {
127199
// ── 1. Atomic put + canonical hash ──────────────────────────────
128200
describe('put / get', () => {
@@ -369,46 +441,110 @@ export function runRepositoryContractTests(
369441
expect(evts.every((e, i) => i === 0 || e.seq > evts[i - 1]!.seq)).toBe(true);
370442
});
371443

372-
it('watch(sinceSeq) replays subsequent events then goes live', async () => {
373-
const repo = await factory();
374-
const ref = refOf();
375-
const a = await repo.put(ref, spec('1'), { parentVersion: null, actor: 't' });
376-
const b = await repo.put(ref, spec('2'), { parentVersion: a.version, actor: 't' });
377-
378-
// Start watching with `since = a.seq` — must replay b, then deliver a live event.
379-
const iter = repo.watch({ org: ref.org }, a.seq);
380-
const collected: MetadataEvent[] = [];
381-
const it = iter[Symbol.asyncIterator]();
382-
383-
// First yield should be the replay of `b`.
384-
const first = await it.next();
385-
expect(first.done).toBe(false);
386-
collected.push(first.value as MetadataEvent);
387-
expect(collected[0]!.seq).toBe(b.seq);
388-
389-
// Now trigger a live event and collect it.
390-
const livePromise = it.next();
391-
const c = await repo.put(ref, spec('3'), { parentVersion: b.version, actor: 't' });
392-
const live = await livePromise;
393-
expect(live.done).toBe(false);
394-
collected.push(live.value as MetadataEvent);
395-
expect(collected[1]!.seq).toBe(c.seq);
444+
if (resumableWatchDivergence === undefined) {
445+
it('watch(sinceSeq) replays subsequent events then goes live', async () => {
446+
const repo = await factory();
447+
const ref = refOf();
448+
const a = await repo.put(ref, spec('1'), { parentVersion: null, actor: 't' });
449+
const b = await repo.put(ref, spec('2'), { parentVersion: a.version, actor: 't' });
450+
451+
// Start watching with `since = a.seq` — must replay b, then deliver a live event.
452+
const iter = repo.watch({ org: ref.org }, a.seq);
453+
const collected: MetadataEvent[] = [];
454+
const it = iter[Symbol.asyncIterator]();
455+
456+
// First yield should be the replay of `b`.
457+
const first = await it.next();
458+
expect(first.done).toBe(false);
459+
collected.push(first.value as MetadataEvent);
460+
expect(collected[0]!.seq).toBe(b.seq);
461+
462+
// Now trigger a live event and collect it.
463+
const livePromise = it.next();
464+
const c = await repo.put(ref, spec('3'), { parentVersion: b.version, actor: 't' });
465+
const live = await livePromise;
466+
expect(live.done).toBe(false);
467+
collected.push(live.value as MetadataEvent);
468+
expect(collected[1]!.seq).toBe(c.seq);
469+
470+
await it.return?.(undefined);
471+
});
396472

397-
await it.return?.(undefined);
398-
});
473+
it('watch filters by type and name', async () => {
474+
const repo = await factory();
475+
await repo.put(refOf({ name: 'a' }), spec('a'), { parentVersion: null, actor: 't' });
476+
await repo.put(refOf({ name: 'b' }), spec('b'), { parentVersion: null, actor: 't' });
477+
const events = await take(
478+
repo.watch({ org: 'system', type: primaryType, name: 'a' }),
479+
5,
480+
200,
481+
);
482+
expect(events.length).toBe(1);
483+
expect(events[0]!.ref.name).toBe('a');
484+
});
485+
} else {
486+
// ── DECLARED DIVERGENCE — invariant 6 is not satisfied here ──────
487+
//
488+
// Both clauses above lean on the implementation replaying from its
489+
// durable log. This implementation does not, and the two replacements
490+
// below are NOT relaxations of the pair: the first PINS the absence of
491+
// replay (so it reds the day replay lands and this whole branch has to
492+
// go), and the second re-asks the filter question the second clause is
493+
// named for, sourced from the live stream instead of the replay buffer,
494+
// so filter coverage is not silently traded away for the exception.
495+
496+
it(`watch(sinceSeq) does NOT replay, then goes live — DECLARED DIVERGENCE ${resumableWatchDivergence}`, async () => {
497+
const repo = await factory();
498+
const ref = refOf();
499+
const a = await repo.put(ref, spec('1'), { parentVersion: null, actor: 't' });
500+
const b = await repo.put(ref, spec('2'), { parentVersion: a.version, actor: 't' });
501+
502+
const it = repo.watch({ org: ref.org }, a.seq)[Symbol.asyncIterator]();
503+
504+
// ONE pending `next()`, deliberately. Invariant 6 says `b` (seq >
505+
// a.seq, already committed) must satisfy it. Here nothing does, and
506+
// the SAME promise is later settled by a live event — which is what
507+
// separates "does not replay" from "the stream is dead".
508+
const pending = it.next();
509+
let settled = false;
510+
const mark = () => {
511+
settled = true;
512+
};
513+
pending.then(mark, mark);
514+
await new Promise((resolve) => setTimeout(resolve, 200));
515+
expect(settled).toBe(false);
516+
517+
const c = await repo.put(ref, spec('3'), { parentVersion: b.version, actor: 't' });
518+
const live = await pending;
519+
expect(live.done).toBe(false);
520+
expect((live.value as MetadataEvent).seq).toBe(c.seq);
521+
522+
await it.return?.(undefined);
523+
});
399524

400-
it('watch filters by type and name', async () => {
401-
const repo = await factory();
402-
await repo.put(refOf({ name: 'a' }), spec('a'), { parentVersion: null, actor: 't' });
403-
await repo.put(refOf({ name: 'b' }), spec('b'), { parentVersion: null, actor: 't' });
404-
const events = await take(
405-
repo.watch({ org: 'system', type: 'view', name: 'a' }),
406-
5,
407-
200,
408-
);
409-
expect(events.length).toBe(1);
410-
expect(events[0]!.ref.name).toBe('a');
411-
});
525+
it(`watch filters by type and name — over the live stream — DECLARED DIVERGENCE ${resumableWatchDivergence}`, async () => {
526+
const repo = await factory();
527+
const it = repo
528+
.watch({ org: 'system', type: primaryType, name: 'a' })
529+
[Symbol.asyncIterator]();
530+
const collected: MetadataEvent[] = [];
531+
const pump = (async () => {
532+
for (;;) {
533+
const r = await it.next();
534+
if (r.done) return;
535+
collected.push(r.value as MetadataEvent);
536+
}
537+
})();
538+
539+
await repo.put(refOf({ name: 'a' }), spec('a'), { parentVersion: null, actor: 't' });
540+
await repo.put(refOf({ name: 'b' }), spec('b'), { parentVersion: null, actor: 't' });
541+
await new Promise((resolve) => setTimeout(resolve, 100));
542+
await it.return?.(undefined);
543+
await pump;
544+
545+
expect(collected.map((e) => e.ref.name)).toEqual(['a']);
546+
});
547+
}
412548
});
413549

414550
// ── list ────────────────────────────────────────────────────────
@@ -417,12 +553,12 @@ export function runRepositoryContractTests(
417553
const repo = await factory();
418554
await repo.put(refOf({ name: 'alpha' }), spec('a'), { parentVersion: null, actor: 't' });
419555
await repo.put(refOf({ name: 'beta' }), spec('b'), { parentVersion: null, actor: 't' });
420-
await repo.put(refOf({ type: 'object', name: 'thing' }), spec('o'), {
556+
await repo.put(refOf({ type: secondaryType, name: 'thing' }), spec('o'), {
421557
parentVersion: null,
422558
actor: 't',
423559
});
424560
const headers: unknown[] = [];
425-
for await (const h of repo.list({ type: 'view' })) headers.push(h);
561+
for await (const h of repo.list({ type: primaryType })) headers.push(h);
426562
expect(headers.length).toBe(2);
427563
for (const h of headers) {
428564
expect((h as { body?: unknown }).body).toBeUndefined();
@@ -435,7 +571,7 @@ export function runRepositoryContractTests(
435571
await repo.put(refOf({ name: `v_${i}` }), spec(`v${i}`), { parentVersion: null, actor: 't' });
436572
}
437573
const headers: unknown[] = [];
438-
for await (const h of repo.list({ type: 'view', limit: 3 })) headers.push(h);
574+
for await (const h of repo.list({ type: primaryType, limit: 3 })) headers.push(h);
439575
expect(headers.length).toBe(3);
440576
});
441577
});

0 commit comments

Comments
 (0)