From 2f7ccdc30752721dd2994c2245c6a2a0094bdd8b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 13:14:42 +0000 Subject: [PATCH 1/4] test(metadata-protocol): run SysMetadataRepository through the shared repository contract suite (#10420) --- packages/metadata-core/src/contract-suite.ts | 53 +++- .../sys-metadata-repository.contract.test.ts | 243 ++++++++++++++++++ 2 files changed, 284 insertions(+), 12 deletions(-) create mode 100644 packages/metadata-protocol/src/sys-metadata-repository.contract.test.ts diff --git a/packages/metadata-core/src/contract-suite.ts b/packages/metadata-core/src/contract-suite.ts index cb83eb3e69..35524bf774 100644 --- a/packages/metadata-core/src/contract-suite.ts +++ b/packages/metadata-core/src/contract-suite.ts @@ -20,22 +20,36 @@ import { describe, it, expect } from 'vitest'; import type { MetadataRepository } from './repository.js'; -import type { MetaRef, MetadataEvent } from './types.js'; +import type { MetaRef, MetadataEvent, MetadataType } from './types.js'; import { hashSpec } from './canonicalize.js'; import { ConflictError } from './errors.js'; export interface ContractSuiteOptions { /** If the implementation supports `version`-pinned reads, set true. */ supportsVersionedReads?: boolean; + /** + * The metadata type nearly every clause writes under. Defaults to `'view'`. + * + * A FIXTURE knob, deliberately not an invariant knob: no clause below is + * added, removed or weakened by moving it, because none of the seven + * invariants is a statement about a particular type. It exists because an + * implementation may sit behind a **write-authorization door** keyed on the + * type — `SysMetadataRepository.assertAllowed()` refuses any type whose + * registry entry lacks `allowOrgOverride` — so a hard-coded fixture type + * decides which implementations can be held to the table at all. Naming the + * two types here is what keeps that ONE table, instead of carving a second + * one for the engine-backed implementation to be measured against. + */ + primaryType?: MetadataType; + /** + * A second, DISTINCT type, used only where a clause must prove a type filter + * discriminates (`list`'s `type` filter, `watch`'s). Defaults to `'object'`. + * Same fixture-knob argument as {@link primaryType}; it must differ from it + * or those two clauses assert nothing. + */ + secondaryType?: MetadataType; } -const refOf = (overrides: Partial = {}): MetaRef => ({ - org: 'system', - type: 'view', - name: 'sample_view', - ...overrides, -}); - const spec = (label: string) => ({ label, columns: ['a', 'b'] }); /** A value whose `toJSON` collapses it to something other than its own keys. */ @@ -123,6 +137,21 @@ export function runRepositoryContractTests( factory: () => MetadataRepository | Promise, opts: ContractSuiteOptions = {}, ): void { + const primaryType: MetadataType = opts.primaryType ?? 'view'; + const secondaryType: MetadataType = opts.secondaryType ?? 'object'; + if (primaryType === secondaryType) { + throw new Error( + `runRepositoryContractTests(${label}): primaryType and secondaryType must differ — ` + + `both are '${primaryType}', which makes the list/watch type-filter clauses vacuous.`, + ); + } + const refOf = (overrides: Partial = {}): MetaRef => ({ + org: 'system', + type: primaryType, + name: 'sample_view', + ...overrides, + }); + describe(`MetadataRepository contract — ${label}`, () => { // ── 1. Atomic put + canonical hash ────────────────────────────── describe('put / get', () => { @@ -402,7 +431,7 @@ export function runRepositoryContractTests( await repo.put(refOf({ name: 'a' }), spec('a'), { parentVersion: null, actor: 't' }); await repo.put(refOf({ name: 'b' }), spec('b'), { parentVersion: null, actor: 't' }); const events = await take( - repo.watch({ org: 'system', type: 'view', name: 'a' }), + repo.watch({ org: 'system', type: primaryType, name: 'a' }), 5, 200, ); @@ -417,12 +446,12 @@ export function runRepositoryContractTests( const repo = await factory(); await repo.put(refOf({ name: 'alpha' }), spec('a'), { parentVersion: null, actor: 't' }); await repo.put(refOf({ name: 'beta' }), spec('b'), { parentVersion: null, actor: 't' }); - await repo.put(refOf({ type: 'object', name: 'thing' }), spec('o'), { + await repo.put(refOf({ type: secondaryType, name: 'thing' }), spec('o'), { parentVersion: null, actor: 't', }); const headers: unknown[] = []; - for await (const h of repo.list({ type: 'view' })) headers.push(h); + for await (const h of repo.list({ type: primaryType })) headers.push(h); expect(headers.length).toBe(2); for (const h of headers) { expect((h as { body?: unknown }).body).toBeUndefined(); @@ -435,7 +464,7 @@ export function runRepositoryContractTests( await repo.put(refOf({ name: `v_${i}` }), spec(`v${i}`), { parentVersion: null, actor: 't' }); } const headers: unknown[] = []; - for await (const h of repo.list({ type: 'view', limit: 3 })) headers.push(h); + for await (const h of repo.list({ type: primaryType, limit: 3 })) headers.push(h); expect(headers.length).toBe(3); }); }); diff --git a/packages/metadata-protocol/src/sys-metadata-repository.contract.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.contract.test.ts new file mode 100644 index 0000000000..3974cd9133 --- /dev/null +++ b/packages/metadata-protocol/src/sys-metadata-repository.contract.test.ts @@ -0,0 +1,243 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #10420 — `SysMetadataRepository` under the SHARED repository contract suite. + * + * ## Why this file exists + * + * `runRepositoryContractTests` exists so that ONE table of invariants holds for + * EVERY `MetadataRepository` implementation — its own header says so, and the + * #7856 / #7992 serialized-form pins were deliberately put there rather than + * beside either bug for that reason. Until this file, the suite had exactly two + * call sites: `InMemoryRepository` and `FileSystemRepository` — the two + * implementations that carry the TEST traffic. `SysMetadataRepository`, which + * carries the PRODUCTION traffic (561 of 1,729 `put()` invocations in the #8006 + * census, including all four production call sites), was never handed to it. + * + * ⚠️ Green here is the EXPECTED outcome and is worth nothing on its own — the + * card that ordered this file said so explicitly. What makes the coverage real + * is that every clause is REACHED: a factory that quietly fails to wire up, or + * an adaptation that skips the clauses an engine-backed repository cannot run, + * produces exactly the same green and reads as coverage. So: + * + * - the adaptation below adds NO wrapper between the suite and the + * repository — `factory()` returns the real `SysMetadataRepository`, and + * every `put`/`get`/`delete`/`list`/`history`/`watch` the suite issues goes + * straight into it, through its own authorization door; + * - the three dimensions the suite's `factory()` shape does not model — + * `state: 'draft' | 'active'`, `packageId`, org scoping — are pinned as + * facts at the bottom of this file rather than described in prose, so a + * later change that moves the suite off the paths it exercises today + * fails here instead of going quiet. + * + * ## The one thing that had to move in the shared suite + * + * Nothing about the invariants. The suite hard-coded its two FIXTURE metadata + * types (`'view'` for almost everything, `'object'` for the one clause that + * must prove a type filter discriminates), and `SysMetadataRepository` — unlike + * the other two implementations — has a write-authorization door keyed on the + * type: `assertAllowed()` refuses `'object'` under the default + * `override-artifact` intent because its registry entry is + * `allowOrgOverride: false`. So `ContractSuiteOptions` grew `primaryType` / + * `secondaryType`, defaulting to today's values, and this call site names two + * types the door admits. That keeps ONE invariant table; the alternative — + * wrapping the repository in an adapter that injects `intent: 'runtime-only'` — + * would have put a test-only shim between the suite and the subject, which is + * precisely the "reads as coverage" failure above. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +// [#5619] The producer's OWN write-verb dispatch decisions (#4550 delete / +// #5480 update), so the fake engine below cannot accept a call ObjectQL +// refuses. Imported from `@objectstack/metadata-core` rather than +// `@objectstack/objectql`: objectql DEPENDS ON this package, so that import +// would close a dependency cycle turbo rejects outright. +import { + assertEngineDeleteDispatch, + assertEngineUpdateDispatch, + hashSpec, +} from '@objectstack/metadata-core'; +import { runRepositoryContractTests } from '@objectstack/metadata-core/testing'; +import { SysMetadataRepository } from './sys-metadata-repository.js'; + +interface Row { + [k: string]: unknown; +} + +/** + * In-memory engine double honouring just enough of `SysMetadataEngine` to run + * the shared contract suite: two tables (`sys_metadata` + `sys_metadata_history`) + * and a `transaction()` with REAL rollback, so contract invariant 1 ("atomic + * put — no half-states") is an observation here rather than an assumption. + * + * Its write verbs open with the producer's own dispatch predicates, so it + * cannot accept a `delete`/`update` shape the real engine throws on — the + * `check:engine-double-contract` rule, and the reason a green suite over a + * loose double is not a suite at all. + */ +function makeFakeEngine() { + let rows: Row[] = []; + let historyRows: Row[] = []; + let nextRowId = 1; + + /** + * Predicate matching, deliberately EXACT-equality only. Anything the double + * cannot answer faithfully throws instead of silently matching nothing — + * a `$or` quietly treated as "no rows" is how a double stops standing in for + * the engine while staying green. + */ + const matches = (row: Row, where: Record): boolean => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`); + if (v === undefined) return true; + return row[k] === v; + }); + + const tableOf = (table: string): Row[] => (table === 'sys_metadata_history' ? historyRows : rows); + + return { + /** Inspection seams for the implementation-specific pins below. */ + rows: () => rows.map((r) => ({ ...r })), + historyRows: () => historyRows.map((r) => ({ ...r })), + + async find( + table: string, + opts: { where: Record; limit?: number }, + ): Promise { + const hits = tableOf(table).filter((r) => matches(r, opts.where)); + return typeof opts.limit === 'number' ? hits.slice(0, opts.limit) : hits; + }, + async findOne(table: string, opts: { where: Record }): Promise { + return tableOf(table).find((r) => matches(r, opts.where)) ?? null; + }, + async insert(table: string, data: Record): Promise<{ id: string }> { + const id = (data.id as string | undefined) ?? `r_${nextRowId++}`; + tableOf(table).push({ ...data, id }); + return { id }; + }, + async update( + table: string, + data: Record, + opts: { where: Record }, + ): Promise<{ id: string }> { + assertEngineUpdateDispatch(data, opts); + const row = tableOf(table).find((r) => matches(r, opts.where)); + if (!row) throw new Error('fake engine: update matched no row'); + Object.assign(row, data); + return { id: row.id as string }; + }, + async delete(table: string, opts: { where: Record }): Promise<{ deleted: number }> { + assertEngineDeleteDispatch(opts); + const target = tableOf(table); + const idx = target.findIndex((r) => matches(r, opts.where)); + if (idx < 0) return { deleted: 0 }; + target.splice(idx, 1); + return { deleted: 1 }; + }, + async transaction(cb: (ctx: unknown, info: { owned: boolean }) => Promise): Promise { + const rowsSnapshot = rows.map((r) => ({ ...r })); + const historySnapshot = historyRows.map((r) => ({ ...r })); + try { + return await cb({ txn: true }, { owned: true }); + } catch (err) { + rows = rowsSnapshot; + historyRows = historySnapshot; + throw err; + } + }, + }; +} + +/** Repos handed to the suite, so each case's instance is closed after it. */ +const created: SysMetadataRepository[] = []; + +afterEach(() => { + while (created.length) { + try { + created.pop()!.close(); + } catch { + /* ignore */ + } + } +}); + +function makeRepo(): SysMetadataRepository { + const repo = new SysMetadataRepository({ + engine: makeFakeEngine(), + // The env-wide overlay scope. `orgLabel: 'system'` is what makes + // `fullRef().org` agree with the suite's `refOf()` — the repository ignores + // `ref.org` and stamps its own, which is exactly the single-org shape the + // "different orgs have independent sequences" clause is written to skip. + organizationId: null, + orgLabel: 'system', + }); + created.push(repo); + return repo; +} + +runRepositoryContractTests('SysMetadataRepository', makeRepo, { + // Both types must clear `assertAllowed()` under the suite's default + // `override-artifact` intent, i.e. both are `allowOrgOverride: true` in + // `DEFAULT_METADATA_TYPE_REGISTRY`. `'object'` — the suite's default second + // type — is not, on purpose (packaged objects are locked); `'dashboard'` is. + primaryType: 'view', + secondaryType: 'dashboard', +}); + +/** + * The three dimensions `runRepositoryContractTests`' `factory()` shape does not + * model. The suite drives each of them at its DEFAULT, and that is a fact about + * which paths the coverage above actually reaches — so it is asserted here + * rather than asserted in a comment. + */ +describe('SysMetadataRepository — what the contract suite does and does not reach', () => { + it('every suite write lands on the ACTIVE row; no draft row is ever created', async () => { + const engine = makeFakeEngine(); + const repo = new SysMetadataRepository({ engine, organizationId: null, orgLabel: 'system' }); + created.push(repo); + const ref = { org: 'system', type: 'view' as const, name: 'sample_view' }; + + const a = await repo.put(ref, { label: 'x' }, { parentVersion: null, actor: 't' }); + await repo.put(ref, { label: 'y' }, { parentVersion: a.version, actor: 't' }); + + const states = engine.rows().map((r) => r.state); + expect(states).toEqual(['active']); + expect(await repo.listDrafts()).toEqual([]); + }); + + it('every suite write lands on the UNBOUND row — `package_id` is null throughout', async () => { + const engine = makeFakeEngine(); + const repo = new SysMetadataRepository({ engine, organizationId: null, orgLabel: 'system' }); + created.push(repo); + const ref = { org: 'system', type: 'view' as const, name: 'sample_view' }; + + await repo.put(ref, { label: 'x' }, { parentVersion: null, actor: 't' }); + + expect(engine.rows().map((r) => r.package_id)).toEqual([null]); + }); + + it('is single-org: `ref.org` is ignored, which is WHY the multi-org clause self-skips', async () => { + const engine = makeFakeEngine(); + const repo = new SysMetadataRepository({ engine, organizationId: null, orgLabel: 'system' }); + created.push(repo); + const orgA = { org: 'org_a', type: 'view' as const, name: 'sample_view' }; + const orgB = { org: 'org_b', type: 'view' as const, name: 'sample_view' }; + + const a = await repo.put(orgA, { label: 'a1' }, { parentVersion: null, actor: 't' }); + // The row `orgA` created is the row `orgB` collides with: one scope, one + // row, so the second create is a ConflictError. The shared suite's + // "different orgs have independent sequences" clause catches exactly this + // and returns — it is SKIPPED for this implementation, not passed. + await expect( + repo.put(orgB, { label: 'b1' }, { parentVersion: null, actor: 't' }), + ).rejects.toThrow(); + expect(engine.rows()).toHaveLength(1); + expect(engine.rows()[0]!.organization_id).toBeNull(); + // …and the ref it stamps back is this repository's own label, not the + // caller's. + const got = await repo.get(orgA); + expect(got!.ref.org).toBe('system'); + expect(got!.hash).toBe(a.version); + expect(got!.hash).toBe(hashSpec(got!.body)); + }); +}); From dfb210273a3feea67955ec89bef5c14b0fe54d0c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 13:21:12 +0000 Subject: [PATCH 2/4] test(metadata-core): declared-divergence mechanism for the repository contract suite (#10420) --- packages/metadata-core/src/contract-suite.ts | 183 ++++++++++++++---- .../sys-metadata-repository.contract.test.ts | 11 ++ 2 files changed, 156 insertions(+), 38 deletions(-) diff --git a/packages/metadata-core/src/contract-suite.ts b/packages/metadata-core/src/contract-suite.ts index 35524bf774..a5f87fd2ec 100644 --- a/packages/metadata-core/src/contract-suite.ts +++ b/packages/metadata-core/src/contract-suite.ts @@ -16,6 +16,13 @@ * 5. Event ordering (monotonic seq, no gaps) * 6. Resumability (watch with `since` replays) * 7. Tombstones (delete event emitted, get returns null) + * + * Two knobs, both narrow on purpose. `primaryType` / `secondaryType` move the + * FIXTURE metadata types (an implementation may sit behind a write door keyed + * on the type — see the option's own notes); `declaredDivergences` records an + * issue-tracked exception to the table WITHOUT skipping the clause it names. + * Neither adds, removes or weakens an invariant, which is the property that + * keeps this one table rather than one table per implementation. */ import { describe, it, expect } from 'vitest'; @@ -48,6 +55,35 @@ export interface ContractSuiteOptions { * or those two clauses assert nothing. */ secondaryType?: MetadataType; + /** + * Issue-tracked exceptions to the invariant table above. + * + * ⚠️ Read the shape before reaching for it. A declaration does NOT skip the + * clause it names — a skipped clause is indistinguishable from coverage in a + * green run, which is the one failure a shared contract suite must not have. + * It swaps the clause for one that **pins the divergent behaviour**, so the + * suite reds the day the implementation starts conforming and whoever fixes + * it is told, by name, to delete the declaration in the same PR. Same + * shrink-only, audited-in-both-directions shape as the repo's other ledgers. + * + * There is deliberately no free-form escape here: every member is one named + * invariant, and its value is the issue that will retire it. + */ + declaredDivergences?: DeclaredDivergences; +} + +/** @see ContractSuiteOptions.declaredDivergences */ +export interface DeclaredDivergences { + /** + * **Invariant 6 (resumability).** The implementation's `watch()` delivers + * live events only; it never replays from its durable log, so neither + * `watch(filter, since)` nor a `watch(filter)` opened after a write can + * surface an event that already committed. + * + * Value is the tracking issue, e.g. `'#10842'` — `SysMetadataRepository`, + * the only declaration today. + */ + resumableWatch?: string; } const spec = (label: string) => ({ label, columns: ['a', 'b'] }); @@ -145,6 +181,13 @@ export function runRepositoryContractTests( `both are '${primaryType}', which makes the list/watch type-filter clauses vacuous.`, ); } + const resumableWatchDivergence = opts.declaredDivergences?.resumableWatch; + if (resumableWatchDivergence !== undefined && resumableWatchDivergence.trim() === '') { + throw new Error( + `runRepositoryContractTests(${label}): declaredDivergences.resumableWatch must name the ` + + `tracking issue — an anonymous exception is the skip this mechanism exists to refuse.`, + ); + } const refOf = (overrides: Partial = {}): MetaRef => ({ org: 'system', type: primaryType, @@ -398,46 +441,110 @@ export function runRepositoryContractTests( expect(evts.every((e, i) => i === 0 || e.seq > evts[i - 1]!.seq)).toBe(true); }); - it('watch(sinceSeq) replays subsequent events then goes live', async () => { - const repo = await factory(); - const ref = refOf(); - const a = await repo.put(ref, spec('1'), { parentVersion: null, actor: 't' }); - const b = await repo.put(ref, spec('2'), { parentVersion: a.version, actor: 't' }); - - // Start watching with `since = a.seq` — must replay b, then deliver a live event. - const iter = repo.watch({ org: ref.org }, a.seq); - const collected: MetadataEvent[] = []; - const it = iter[Symbol.asyncIterator](); - - // First yield should be the replay of `b`. - const first = await it.next(); - expect(first.done).toBe(false); - collected.push(first.value as MetadataEvent); - expect(collected[0]!.seq).toBe(b.seq); - - // Now trigger a live event and collect it. - const livePromise = it.next(); - const c = await repo.put(ref, spec('3'), { parentVersion: b.version, actor: 't' }); - const live = await livePromise; - expect(live.done).toBe(false); - collected.push(live.value as MetadataEvent); - expect(collected[1]!.seq).toBe(c.seq); + if (resumableWatchDivergence === undefined) { + it('watch(sinceSeq) replays subsequent events then goes live', async () => { + const repo = await factory(); + const ref = refOf(); + const a = await repo.put(ref, spec('1'), { parentVersion: null, actor: 't' }); + const b = await repo.put(ref, spec('2'), { parentVersion: a.version, actor: 't' }); + + // Start watching with `since = a.seq` — must replay b, then deliver a live event. + const iter = repo.watch({ org: ref.org }, a.seq); + const collected: MetadataEvent[] = []; + const it = iter[Symbol.asyncIterator](); + + // First yield should be the replay of `b`. + const first = await it.next(); + expect(first.done).toBe(false); + collected.push(first.value as MetadataEvent); + expect(collected[0]!.seq).toBe(b.seq); + + // Now trigger a live event and collect it. + const livePromise = it.next(); + const c = await repo.put(ref, spec('3'), { parentVersion: b.version, actor: 't' }); + const live = await livePromise; + expect(live.done).toBe(false); + collected.push(live.value as MetadataEvent); + expect(collected[1]!.seq).toBe(c.seq); + + await it.return?.(undefined); + }); - await it.return?.(undefined); - }); + it('watch filters by type and name', async () => { + const repo = await factory(); + await repo.put(refOf({ name: 'a' }), spec('a'), { parentVersion: null, actor: 't' }); + await repo.put(refOf({ name: 'b' }), spec('b'), { parentVersion: null, actor: 't' }); + const events = await take( + repo.watch({ org: 'system', type: primaryType, name: 'a' }), + 5, + 200, + ); + expect(events.length).toBe(1); + expect(events[0]!.ref.name).toBe('a'); + }); + } else { + // ── DECLARED DIVERGENCE — invariant 6 is not satisfied here ────── + // + // Both clauses above lean on the implementation replaying from its + // durable log. This implementation does not, and the two replacements + // below are NOT relaxations of the pair: the first PINS the absence of + // replay (so it reds the day replay lands and this whole branch has to + // go), and the second re-asks the filter question the second clause is + // named for, sourced from the live stream instead of the replay buffer, + // so filter coverage is not silently traded away for the exception. + + it(`watch(sinceSeq) does NOT replay, then goes live — DECLARED DIVERGENCE ${resumableWatchDivergence}`, async () => { + const repo = await factory(); + const ref = refOf(); + const a = await repo.put(ref, spec('1'), { parentVersion: null, actor: 't' }); + const b = await repo.put(ref, spec('2'), { parentVersion: a.version, actor: 't' }); + + const it = repo.watch({ org: ref.org }, a.seq)[Symbol.asyncIterator](); + + // ONE pending `next()`, deliberately. Invariant 6 says `b` (seq > + // a.seq, already committed) must satisfy it. Here nothing does, and + // the SAME promise is later settled by a live event — which is what + // separates "does not replay" from "the stream is dead". + const pending = it.next(); + let settled = false; + const mark = () => { + settled = true; + }; + pending.then(mark, mark); + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(settled).toBe(false); + + const c = await repo.put(ref, spec('3'), { parentVersion: b.version, actor: 't' }); + const live = await pending; + expect(live.done).toBe(false); + expect((live.value as MetadataEvent).seq).toBe(c.seq); + + await it.return?.(undefined); + }); - it('watch filters by type and name', async () => { - const repo = await factory(); - await repo.put(refOf({ name: 'a' }), spec('a'), { parentVersion: null, actor: 't' }); - await repo.put(refOf({ name: 'b' }), spec('b'), { parentVersion: null, actor: 't' }); - const events = await take( - repo.watch({ org: 'system', type: primaryType, name: 'a' }), - 5, - 200, - ); - expect(events.length).toBe(1); - expect(events[0]!.ref.name).toBe('a'); - }); + it(`watch filters by type and name — over the live stream — DECLARED DIVERGENCE ${resumableWatchDivergence}`, async () => { + const repo = await factory(); + const it = repo + .watch({ org: 'system', type: primaryType, name: 'a' }) + [Symbol.asyncIterator](); + const collected: MetadataEvent[] = []; + const pump = (async () => { + for (;;) { + const r = await it.next(); + if (r.done) return; + collected.push(r.value as MetadataEvent); + } + })(); + + await repo.put(refOf({ name: 'a' }), spec('a'), { parentVersion: null, actor: 't' }); + await repo.put(refOf({ name: 'b' }), spec('b'), { parentVersion: null, actor: 't' }); + await new Promise((resolve) => setTimeout(resolve, 100)); + await it.return?.(undefined); + await pump; + + expect(collected.map((e) => e.ref.name)).toEqual(['a']); + }); + } }); // ── list ──────────────────────────────────────────────────────── diff --git a/packages/metadata-protocol/src/sys-metadata-repository.contract.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.contract.test.ts index 3974cd9133..d77bde3842 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.contract.test.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.contract.test.ts @@ -182,6 +182,17 @@ runRepositoryContractTests('SysMetadataRepository', makeRepo, { // type — is not, on purpose (packaged objects are locked); `'dashboard'` is. primaryType: 'view', secondaryType: 'dashboard', + // #10842 — the one invariant this implementation does NOT satisfy, found by + // this very file: `watch()` registers an in-memory listener and reads `since` + // only as a drop-filter on live events, so it never replays from + // `sys_metadata_history`. Declaring it does not skip the clauses: the suite + // swaps in two that pin the divergence and re-ask the filter question over + // the live stream, so this line has to be deleted the day replay lands. + // Not fixed here — both production `watch()` consumers subscribe with no + // `since`, so a full-log replay would flood HMR and cache invalidation at + // every `setRepository()`, and what `watch()` with no `since` owes is not + // written in `repository.ts` at all. #10842 carries the fork. + declaredDivergences: { resumableWatch: '#10842' }, }); /** From d26dea46b048a67c76e0c46db5130d6ab94b1e01 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 13:25:11 +0000 Subject: [PATCH 3/4] chore(changeset): metadata-core contract-suite options for #10420 --- .../sysmetadata-repository-contract-suite.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .changeset/sysmetadata-repository-contract-suite.md diff --git a/.changeset/sysmetadata-repository-contract-suite.md b/.changeset/sysmetadata-repository-contract-suite.md new file mode 100644 index 0000000000..8cf1fa1231 --- /dev/null +++ b/.changeset/sysmetadata-repository-contract-suite.md @@ -0,0 +1,31 @@ +--- +"@objectstack/metadata-core": minor +--- + +`runRepositoryContractTests` gains two narrow options so the shared invariant +table can be applied to `SysMetadataRepository` — the implementation that backs +every production metadata write, and the one that had never been handed to the +suite (#10420). Both are additive and optional; every existing call site is +unchanged. + +- **`primaryType` / `secondaryType`** move the suite's two *fixture* metadata + types (previously hard-coded `'view'` and `'object'`), defaulting to exactly + those. This is a fixture knob, not an invariant knob: no clause is added, + removed or weakened by moving it. It exists because an implementation may sit + behind a write-authorization door keyed on the type — + `SysMetadataRepository.assertAllowed()` refuses any type whose registry entry + lacks `allowOrgOverride`, `'object'` included — so a hard-coded fixture type + silently decided which implementations could be held to the table at all. +- **`declaredDivergences`** records an issue-tracked exception to the table. + It does **not** skip the clause it names — a skipped clause is + indistinguishable from coverage in a green run, which is the one failure a + shared contract suite must not have. It swaps in a clause that *pins the + divergent behaviour*, so the suite reds the day the implementation starts + conforming and whoever fixes it is told to delete the declaration in the same + PR. Shrink-only, audited in the fixing direction, like the repo's other + ledgers. The only member today is `resumableWatch` (contract invariant 6), and + the only declaration is `SysMetadataRepository` — see #10842. + +Publishable behaviour is otherwise untouched: `packages/metadata-protocol` gains +a test file only, and 32 of the suite's 34 clauses were already satisfied by +`SysMetadataRepository` on the first run. From 5eaaf14fdd4f07d4035da3ae6089813f8c591853 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 13:30:08 +0000 Subject: [PATCH 4/4] chore(gates): record the new SysMetadataRepository contract-suite engine double in the pinned ledger (#10420) --- scripts/engine-double-contract.pinned.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index c79605ac78..39f749ccc4 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -841,6 +841,16 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/sys-metadata-repository.contract.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/sys-metadata-repository.contract.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/sys-metadata-repository.draft-drain.test.ts", "verb": "delete",