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
2128import { describe , it , expect } from 'vitest' ;
2229import type { MetadataRepository } from './repository.js' ;
23- import type { MetaRef , MetadataEvent } from './types.js' ;
30+ import type { MetaRef , MetadataEvent , MetadataType } from './types.js' ;
2431import { hashSpec } from './canonicalize.js' ;
2532import { ConflictError } from './errors.js' ;
2633
2734export 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
3989const 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