From c90d7ba1155d6ac77ed2d28fdd2fb66fce4a17c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 08:04:53 +0000 Subject: [PATCH] =?UTF-8?q?test(spec,objectql):=20pin=20the=20IMetadataSer?= =?UTF-8?q?vice=20register=20=E2=86=92=20get=20round-trip=20across=20every?= =?UTF-8?q?=20shipped=20implementation=20(#7223)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `register(type, name, data)` and `get(type, name)` are the contract's first two CRUD members, and the round-trip between them was exercised in exactly one place — `packages/spec/src/contracts/metadata-service.test.ts`, against a hand-rolled `Map`-of-`Map`s double written inside the test itself. No shipped implementation was held to it, which is the hole #6725 fell through: a shipped, exported `IMetadataService` could not perform its own most basic round-trip while the full objectql suite and all 64 `lint.yml` gates stayed green. Adds `METADATA_ROUNDTRIP_CASES` (`@objectstack/spec/contracts`) — 15 cases, one table, a thin driver per implementation, the shape `data/filter-logic-conformance.ts` already uses for filter backends — plus the two drivers that run it: - the contract's own reference double, in `packages/spec` (the dependency root, which can see no implementation); - every implementation this repo ships, in `packages/objectql` (the only package that can see all three at once): `MetadataManager` with and without a writable loader, `createMemoryMetadata`, `MetadataFacade`. The pre-existing Map double in `metadata-service.test.ts` is untouched — it pins the contract's type surface and its own inline round-trip, independent of any implementation. No shipped behaviour changes. Three cases get different answers from `MetadataFacade` than from the other implementations and the reference double; each is pinned as measured under a `// DIVERGENCE` marker and filed as its own card rather than reconciled here. Verified the suite is not vacuous by re-introducing the #6725 split locally (dropping the contributor write from `MetadataFacade.registerObjectBothPlaces`): four rows go red, including the plain object round-trip. Refs #7223, #6725, PR #7211, #6745. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0193R6tMZqgrdFrCSnaogFc4 --- .../metadata-service-roundtrip-conformance.md | 33 ++ ...data-service-roundtrip-conformance.test.ts | 341 ++++++++++++++++++ packages/spec/api-surface/contracts.json | 5 + packages/spec/export-origins/contracts.json | 5 + packages/spec/src/contracts/index.ts | 4 + ...data-service-roundtrip-conformance.test.ts | 118 ++++++ .../metadata-service-roundtrip-conformance.ts | 283 +++++++++++++++ 7 files changed, 789 insertions(+) create mode 100644 .changeset/metadata-service-roundtrip-conformance.md create mode 100644 packages/objectql/src/metadata-service-roundtrip-conformance.test.ts create mode 100644 packages/spec/src/contracts/metadata-service-roundtrip-conformance.test.ts create mode 100644 packages/spec/src/contracts/metadata-service-roundtrip-conformance.ts diff --git a/.changeset/metadata-service-roundtrip-conformance.md b/.changeset/metadata-service-roundtrip-conformance.md new file mode 100644 index 0000000000..01f68da332 --- /dev/null +++ b/.changeset/metadata-service-roundtrip-conformance.md @@ -0,0 +1,33 @@ +--- +"@objectstack/spec": patch +--- + +test(spec,objectql): pin the `IMetadataService` `register` → `get` round-trip across every shipped implementation (#7223) + +`register(type, name, data)` and `get(type, name)` are the contract's first two +CRUD members, and until now the round-trip between them was exercised in exactly +ONE place — `contracts/metadata-service.test.ts`, against a `Map`-of-`Map`s +double written inside the test itself. No **shipped** implementation was held to +it. That is the hole #6725 fell through: `MetadataFacade.register('object', …)` +wrote into a map none of its own reads consulted, every read answered +`undefined`, and the full `packages/objectql` suite plus all 64 `lint.yml` gates +stayed green while a shipped, exported implementation of the platform's central +metadata contract could not perform its own most basic round-trip. + +**`METADATA_ROUNDTRIP_CASES`** (`@objectstack/spec/contracts`) is the shared +table that closes it — 15 cases covering the plain round-trip on an object-typed +and a non-object-typed write, the miss shape, re-registration, type scoping in +both directions, name case sensitivity, and the `data`-keying and primitive-value +edges. Same shape as `FILTER_LOGIC_CASES`: one table, a thin driver per +implementation. Third-party authors implementing the contract can run it without +depending on ObjectQL. + +Two drivers ship with it: the contract's own reference double (in `spec`, which +has no runtime and can see no implementation), and every implementation this repo +ships — `MetadataManager` with and without a writable loader, +`createMemoryMetadata`, and `MetadataFacade` — driven from `packages/objectql`, +the only package that can see all three at once. + +No shipped behaviour changes. Where implementations answer a case differently +today, each answer is pinned as measured under a `// DIVERGENCE` marker rather +than reconciled — see the notes in the objectql driver and the card they link. diff --git a/packages/objectql/src/metadata-service-roundtrip-conformance.test.ts b/packages/objectql/src/metadata-service-roundtrip-conformance.test.ts new file mode 100644 index 0000000000..8a1bd2e234 --- /dev/null +++ b/packages/objectql/src/metadata-service-roundtrip-conformance.test.ts @@ -0,0 +1,341 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `METADATA_ROUNDTRIP_CASES` driver #2 — every `IMetadataService` this repo + * SHIPS (#7223). + * + * The table lives in `@objectstack/spec` + * (`contracts/metadata-service-roundtrip-conformance.ts`) together with the + * reference answers and the argument for why it exists; this file is the thin + * driver that replays it against real implementations. Read the table's header + * first — in particular what `expected` does and does not claim. + * + * `packages/objectql` hosts it because it is the only package that can see all + * three implementations at once: it depends on `@objectstack/metadata` + * (`MetadataManager`) and `@objectstack/core` (`createMemoryMetadata`) and owns + * `MetadataFacade`. That is the same argument + * `metadata-service-getobject-equivalence.test.ts` (#6745) already makes for + * living here, and it is why `packages/spec` cannot host this half. + * + * ## The four subjects, and why four for three implementations + * + * `MetadataManager` appears twice. Its `register` writes the in-memory registry + * AND persists to every `datasource:` loader that declares write capability, so + * a subject with no loaders never executes the second half. The writable-loader + * subject is the one that would notice a `register` that threw, silently + * skipped, or mutated the document on the way to `loader.save`. + * + * ## Two assertion strengths, declared per subject + * + * `documentFidelity` says whether a subject hands back the document it was + * given. `MetadataManager` and `createMemoryMetadata` store and return the very + * reference (`verbatim`), so they are held to exact equality. `MetadataFacade` + * resolves objects through `SchemaRegistry`, which answers the RUNTIME-EFFECTIVE + * object — system fields (`organization_id`, `created_at`, …) injected, + * extensions merged — and copies non-object documents while filling in `name`. + * `toEqual(input)` is therefore the wrong assertion for it, exactly as #7223 + * predicted; it is held to a recursive-subset match plus every key/visibility + * assertion the others get. The weaker match is scoped to the ONE subject that + * needs it rather than applied to the whole table. + * + * ## Divergences are PINNED, not resolved + * + * Three cases get different answers from `MetadataFacade` than from the other + * implementations and the contract's reference double. Each is recorded below + * as a `// DIVERGENCE` entry stating the measured behaviour — this file asserts + * what each implementation does TODAY and changes no shipped behaviour. Which + * answer is correct is a separate ruling, filed as its own card (see the + * per-divergence notes). If you are here because one of these tests failed + * after a behaviour change: that is the pin working. Update it in the PR that + * makes the ruling, not silently. + * + * Refs #7223, #6725, PR #7211, #6745. + */ + +import { describe, it, expect } from 'vitest'; +import { + METADATA_ROUNDTRIP_CASES, + type MetadataRoundTripCase, + type IMetadataService, +} from '@objectstack/spec/contracts'; +import { SchemaRegistry } from './registry'; +import { MetadataFacade } from './metadata-facade'; +import { MetadataManager, type MetadataLoader } from '@objectstack/metadata'; +import { createMemoryMetadata } from '@objectstack/core'; + +/** + * The members the table exercises, and nothing else. Typed against the contract + * rather than the concrete classes on purpose: a signature change on any of the + * four should reach this file through `tsc`. + */ +type RoundTrippingService = Pick; + +/** + * How a subject answers a `readable` row. + * + * - `verbatim` — `get` returns the document `register` was handed. Asserted + * with exact equality. + * - `runtime-effective` — `get` returns a derived document that CONTAINS the + * authored one. Asserted as a recursive subset. + */ +type DocumentFidelity = 'verbatim' | 'runtime-effective'; + +/** + * A per-subject answer that differs from the table's reference answer. + * `readable-as-last-write` means "the document the case's final write carried". + */ +type DivergentAnswer = + | { readonly kind: 'absent'; readonly note: string } + | { readonly kind: 'readable-as-last-write'; readonly note: string }; + +interface PinnedImplementation { + readonly label: string; + readonly documentFidelity: DocumentFidelity; + /** Keyed by {@link MetadataRoundTripCase.id}. Every key is checked to exist. */ + readonly divergences?: Readonly>; + create(): RoundTrippingService; +} + +/** + * A minimal writable `datasource:` loader, so `MetadataManager.register`'s + * persistence half actually runs. `save` AND `delete` are both required by + * `assertWritableLoaderContract` — the sole gate into `MetadataManager`'s + * loader map. + */ +class WritableFixtureLoader implements MetadataLoader { + readonly contract: MetadataLoader['contract'] = { + name: 'roundtrip-conformance-writable', + protocol: 'datasource:', + capabilities: { read: true, write: true, watch: false, list: true }, + }; + + private readonly storage = new Map(); + + /** + * NUL as the type/name separator, written as an escape rather than as a + * literal byte -- a literal one makes git treat this file as binary and + * trips `check:nul-bytes`. Neither a metadata type nor a name can contain + * one, so no two distinct pairs can collide on a single key the way they + * could with a `:` or `/` separator. + */ + private key(type: string, name: string): string { + return `${type}\u0000${name}`; + } + + async save(type: string, name: string, data: unknown): Promise { + this.storage.set(this.key(type, name), data); + } + + async delete(type: string, name: string): Promise { + this.storage.delete(this.key(type, name)); + } + + async load(type: string, name: string) { + const data = this.storage.get(this.key(type, name)); + return data === undefined + ? { data: null } + : { data, source: this.contract.name, format: 'json' as const, loadTime: 0 }; + } + + async loadMany(type: string): Promise { + return this.entriesOfType(type).map(([, value]) => value) as T[]; + } + + async exists(type: string, name: string): Promise { + return this.storage.has(this.key(type, name)); + } + + async stat() { + return null; + } + + async list(type: string): Promise { + return this.entriesOfType(type).map(([key]) => key.slice(type.length + 1)); + } + + private entriesOfType(type: string): Array<[string, unknown]> { + return Array.from(this.storage.entries()).filter(([key]) => key.startsWith(`${type}\u0000`)); + } +} + +/** + * ── DIVERGENCE 1 — the effective key is `data.name`, not the `name` argument ── + * + * Cases `key-is-the-name-argument-object` / `-nonobject`. + * + * `MetadataFacade.register` opens with + * `{ ...data, name: data.name ?? name }` and then hands the DOCUMENT to + * `SchemaRegistry.registerObject` / `registerItem`, which key on the document's + * own `name`. The `name` argument is therefore only a fallback for a document + * that carries none: when the two disagree, the item lands under `data.name` + * and `get(type, )` answers `undefined`, `exists` + * answers `false`, and `listNames` reports the other spelling. Measured on both + * an object-typed and a view-typed write. + * + * `MetadataManager` and `createMemoryMetadata` both key on the argument, as does + * the contract's reference double. The contract TSDoc names the parameter on + * both members (`@param name - Item name/identifier (snake_case)`) and says + * nothing about `data.name`, so nothing in-tree currently RULES which is right — + * which is why this is pinned as measured and filed, not fixed here. + */ +const DIVERGENCE_1 = 'MetadataFacade keys on `data.name` when it disagrees with the `name` argument; the other implementations key on the argument. Pinned as measured (#7223).'; + +/** + * ── DIVERGENCE 2 — the plural `objects` type is aliased to `object` ── + * + * Case `plural-objects-type-is-its-own-store`. + * + * `MetadataFacade`'s `isObjectType` treats `'object'` and `'objects'` as the + * same type on the WRITE side (deliberately, per its header: #6725 left the + * plural with the same read/write split the singular had). The consequence this + * case measures is on the READ side: a `register('objects', n, …)` is visible + * through `get('object', n)`, `exists('object', n)` and `listNames('object')`. + * + * `MetadataManager` and `createMemoryMetadata` key their type stores on the + * string they are handed, so the two spellings are two stores and the item is + * invisible under the singular. + */ +const DIVERGENCE_2 = 'MetadataFacade aliases the plural `objects` type to `object`; the other implementations keep one store per type string. Pinned as measured (#7223).'; + +/** + * ── DIVERGENCE 3 — a non-object `data` value is dropped ── + * + * Case `primitive-data-roundtrips`. + * + * The contract declares `data: unknown`. `MetadataFacade.register` passes a + * non-object value through unchanged (its `{ ...data }` branch is guarded on + * `typeof data === 'object' && data !== null`) and then registers it under the + * document's own `name` — which a string does not have. The write is ACCEPTED + * (no throw), the registry logs `Registered setting: undefined`, and the value + * is readable back through nothing: `get` answers `undefined`, `exists` answers + * `false`, `listNames` is empty. Silent loss, the same family of failure as + * #6725 — which is the reason this row is in the table at all. + * + * `MetadataManager` and `createMemoryMetadata` store the value against the key + * and hand it straight back. + */ +const DIVERGENCE_3 = 'MetadataFacade silently drops a non-object `data` value — accepted by `register`, readable back through no member. The other implementations round-trip it. Pinned as measured (#7223).'; + +const IMPLEMENTATIONS: readonly PinnedImplementation[] = [ + { + label: 'MetadataManager (registry only)', + documentFidelity: 'verbatim', + create: () => new MetadataManager({ formats: ['json'], loaders: [] }), + }, + { + // The half a loader-less manager never executes: `register` persists to + // every writable `datasource:` loader before it announces. + label: 'MetadataManager (writable datasource loader)', + documentFidelity: 'verbatim', + create: () => new MetadataManager({ formats: ['json'], loaders: [new WritableFixtureLoader()] }), + }, + { + label: 'createMemoryMetadata', + documentFidelity: 'verbatim', + create: () => createMemoryMetadata(), + }, + { + label: 'MetadataFacade', + documentFidelity: 'runtime-effective', + divergences: { + 'key-is-the-name-argument-object': { kind: 'absent', note: DIVERGENCE_1 }, + 'key-is-the-name-argument-nonobject': { kind: 'absent', note: DIVERGENCE_1 }, + 'plural-objects-type-is-its-own-store': { kind: 'readable-as-last-write', note: DIVERGENCE_2 }, + 'primitive-data-roundtrips': { kind: 'absent', note: DIVERGENCE_3 }, + }, + create: () => new MetadataFacade(new SchemaRegistry({ multiTenant: false })), + }, +]; + +/** The document a case's final write carried for the key being read. */ +function lastWrittenDocument(testCase: MetadataRoundTripCase): unknown { + return testCase.writes[testCase.writes.length - 1]?.data; +} + +/** + * The answer this subject is held to for this case: the table's reference + * answer, unless the subject declares a divergence for it. + */ +function expectationFor( + implementation: PinnedImplementation, + testCase: MetadataRoundTripCase, +): { kind: 'readable'; document: unknown } | { kind: 'absent' } { + const divergence = implementation.divergences?.[testCase.id]; + if (!divergence) return testCase.expected; + return divergence.kind === 'absent' + ? { kind: 'absent' } + : { kind: 'readable', document: lastWrittenDocument(testCase) }; +} + +describe.each(IMPLEMENTATIONS)( + 'IMetadataService round-trip conformance [$label]', + (implementation) => { + it.each(METADATA_ROUNDTRIP_CASES.map((testCase) => [testCase.id, testCase] as const))( + '%s', + async (_id, testCase) => { + const service = implementation.create(); + + for (const write of testCase.writes) { + await service.register(write.type, write.name, write.data); + } + for (const removal of testCase.removes ?? []) { + await service.unregister(removal.type, removal.name); + } + + const got = await service.get(testCase.read.type, testCase.read.name); + const exists = await service.exists(testCase.read.type, testCase.read.name); + const names = await service.listNames(testCase.read.type); + const expected = expectationFor(implementation, testCase); + + if (expected.kind === 'readable') { + // Anti-vacuity: `toMatchObject` against an absent document + // would fail on its own, but stating this first makes a + // regression read as "nothing came back" rather than as a + // shape mismatch buried in a diff. + expect(got).toBeDefined(); + + if (implementation.documentFidelity === 'verbatim' || typeof expected.document !== 'object' || expected.document === null) { + expect(got).toEqual(expected.document); + } else { + // The runtime-effective document CONTAINS the authored one. + expect(got).toMatchObject(expected.document as Record); + } + + expect(exists).toBe(true); + // Exactly once: an implementation that appended instead of + // overwriting would satisfy every assertion above on the + // re-register rows and fail only this one. + expect(names.filter((name) => name === testCase.read.name)).toHaveLength(1); + } else { + expect(got).toBeUndefined(); + expect(exists).toBe(false); + expect(names).not.toContain(testCase.read.name); + } + }, + ); + }, +); + +describe('round-trip conformance table wiring', () => { + it('declares no divergence for a case id the table does not contain', () => { + // A renamed case would otherwise turn its divergence override into a + // dead entry, and the subject would quietly be held to the reference + // answer it is known to fail. + const ids = new Set(METADATA_ROUNDTRIP_CASES.map((testCase) => testCase.id)); + for (const implementation of IMPLEMENTATIONS) { + for (const id of Object.keys(implementation.divergences ?? {})) { + expect(ids, `${implementation.label} → ${id}`).toContain(id); + } + } + }); + + it('holds at least one implementation to every case', () => { + // Guards the opposite failure from the one above: a case that every + // subject declared a divergence for would be pinned by nobody against + // the reference answer. + for (const testCase of METADATA_ROUNDTRIP_CASES) { + const conforming = IMPLEMENTATIONS.filter((i) => !i.divergences?.[testCase.id]); + expect(conforming.length, testCase.id).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index 3f7c4a8ee4..91c2bb4533 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -179,11 +179,16 @@ "LockAcquireOptions (interface)", "LockHandle (interface)", "Logger (interface)", + "METADATA_ROUNDTRIP_CASES (const)", "MarkReadResult (interface)", "MessageObservability (interface)", "MetadataExportOptions (interface)", "MetadataImportOptions (interface)", "MetadataImportResult (interface)", + "MetadataRoundTripCase (interface)", + "MetadataRoundTripExpectation (type)", + "MetadataRoundTripRemoval (interface)", + "MetadataRoundTripWrite (interface)", "MetadataTypeInfo (interface)", "MetadataWatchCallback (type)", "MetadataWatchHandle (interface)", diff --git a/packages/spec/export-origins/contracts.json b/packages/spec/export-origins/contracts.json index 8dc03fc9cf..7d66cef74b 100644 --- a/packages/spec/export-origins/contracts.json +++ b/packages/spec/export-origins/contracts.json @@ -179,11 +179,16 @@ "LockAcquireOptions": "src/contracts/cluster-service.ts#LockAcquireOptions (interface)", "LockHandle": "src/contracts/cluster-service.ts#LockHandle (interface)", "Logger": "src/contracts/logger.ts#Logger (interface)", + "METADATA_ROUNDTRIP_CASES": "src/contracts/metadata-service-roundtrip-conformance.ts#METADATA_ROUNDTRIP_CASES (const)", "MarkReadResult": "src/contracts/notification-service.ts#MarkReadResult (interface)", "MessageObservability": "src/contracts/ai-service.ts#MessageObservability (interface)", "MetadataExportOptions": "src/contracts/metadata-service.ts#MetadataExportOptions (interface)", "MetadataImportOptions": "src/contracts/metadata-service.ts#MetadataImportOptions (interface)", "MetadataImportResult": "src/contracts/metadata-service.ts#MetadataImportResult (interface)", + "MetadataRoundTripCase": "src/contracts/metadata-service-roundtrip-conformance.ts#MetadataRoundTripCase (interface)", + "MetadataRoundTripExpectation": "src/contracts/metadata-service-roundtrip-conformance.ts#MetadataRoundTripExpectation (type)", + "MetadataRoundTripRemoval": "src/contracts/metadata-service-roundtrip-conformance.ts#MetadataRoundTripRemoval (interface)", + "MetadataRoundTripWrite": "src/contracts/metadata-service-roundtrip-conformance.ts#MetadataRoundTripWrite (interface)", "MetadataTypeInfo": "src/contracts/metadata-service.ts#MetadataTypeInfo (interface)", "MetadataWatchCallback": "src/contracts/metadata-service.ts#MetadataWatchCallback (type)", "MetadataWatchHandle": "src/contracts/metadata-service.ts#MetadataWatchHandle (interface)", diff --git a/packages/spec/src/contracts/index.ts b/packages/spec/src/contracts/index.ts index ef2b30afef..cfcde455e8 100644 --- a/packages/spec/src/contracts/index.ts +++ b/packages/spec/src/contracts/index.ts @@ -25,6 +25,10 @@ export * from './queue-service.js'; export * from './notification-service.js'; export * from './storage-service.js'; export * from './metadata-service.js'; +// The write→read round-trip cases every occupant of the `metadata` slot is +// checked against (#7223) — one table, a thin driver per implementation, the +// shape `data/filter-logic-conformance.ts` already uses for filter backends. +export * from './metadata-service-roundtrip-conformance.js'; export * from './auth-service.js'; export * from './automation-service.js'; export * from './analytics-service.js'; diff --git a/packages/spec/src/contracts/metadata-service-roundtrip-conformance.test.ts b/packages/spec/src/contracts/metadata-service-roundtrip-conformance.test.ts new file mode 100644 index 0000000000..e38396627d --- /dev/null +++ b/packages/spec/src/contracts/metadata-service-roundtrip-conformance.test.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `METADATA_ROUNDTRIP_CASES` driver #1 — the contract's own reference double. + * + * This file runs the shared table (`metadata-service-roundtrip-conformance.ts`) + * against a `Map`-of-`Map`s keyed by `type` × the `name` ARGUMENT, which is + * what the table's `expected` column means. Its subject is deliberately NOT a + * shipped implementation: `packages/spec` is the dependency root and the + * contract has no runtime, so nothing here can import one. The shipped + * implementations are driven by + * `packages/objectql/src/metadata-service-roundtrip-conformance.test.ts`. + * + * So what does this file buy, if it "asserts that a `Map` behaves like a + * `Map`"? Two things the objectql driver cannot: + * + * 1. **It keeps the reference semantics honest.** The objectql driver states + * per-implementation answers as deltas against this table; a table whose own + * reference answers were never executed would let a typo in `expected` + * silently redefine what "conforming" means for every subject at once. + * 2. **It keeps the table executable from the dependency root.** A third-party + * author implementing this contract can run the same cases without + * depending on `@objectstack/metadata`, `@objectstack/core` or ObjectQL. + * + * The pre-existing double in `metadata-service.test.ts` is untouched and stays + * where it is: it pins the contract's TYPE surface (a minimal implementation + * compiles, optional members are optional) and its own inline round-trip. This + * file pins the table. Neither subsumes the other. + * + * Refs #7223, #6725. + */ + +import { describe, it, expect } from 'vitest'; +import type { IMetadataService } from './metadata-service'; +import { + METADATA_ROUNDTRIP_CASES, + type MetadataRoundTripCase, +} from './metadata-service-roundtrip-conformance'; + +/** + * The reference store: `type` → `name` → `data`, and nothing else. Written out + * here rather than imported so that the reference semantics live in the file + * that asserts them — the same store `metadata-service.test.ts` builds inline, + * typed against the contract so a signature change reaches this file via `tsc`. + */ +function createReferenceService(): IMetadataService { + const store = new Map>(); + const typeStore = (type: string): Map => { + let map = store.get(type); + if (!map) { + map = new Map(); + store.set(type, map); + } + return map; + }; + + return { + register: async (type, name, data) => { typeStore(type).set(name, data); }, + get: async (type, name) => typeStore(type).get(name), + list: async (type) => Array.from(typeStore(type).values()), + unregister: async (type, name) => { typeStore(type).delete(name); }, + exists: async (type, name) => typeStore(type).has(name), + listNames: async (type) => Array.from(typeStore(type).keys()), + getObject: async (name) => typeStore('object').get(name), + listObjects: async () => Array.from(typeStore('object').values()), + }; +} + +/** Replay a case's setup, then answer its single read through all four members. */ +async function replay(service: IMetadataService, testCase: MetadataRoundTripCase) { + for (const write of testCase.writes) { + await service.register(write.type, write.name, write.data); + } + for (const removal of testCase.removes ?? []) { + await service.unregister(removal.type, removal.name); + } + return { + got: await service.get(testCase.read.type, testCase.read.name), + exists: await service.exists(testCase.read.type, testCase.read.name), + names: await service.listNames(testCase.read.type), + }; +} + +describe('IMetadataService round-trip conformance — contract reference double', () => { + it.each(METADATA_ROUNDTRIP_CASES.map((testCase) => [testCase.id, testCase] as const))( + '%s', + async (_id, testCase) => { + const { got, exists, names } = await replay(createReferenceService(), testCase); + + if (testCase.expected.kind === 'readable') { + expect(got).toEqual(testCase.expected.document); + expect(exists).toBe(true); + // Exactly once: a store that appended instead of overwriting + // would satisfy every assertion above on the re-register rows + // and fail only this one. + expect(names.filter((name) => name === testCase.read.name)).toHaveLength(1); + } else { + expect(got).toBeUndefined(); + expect(exists).toBe(false); + expect(names).not.toContain(testCase.read.name); + } + }, + ); + + it('states a case id at most once, so a driver override cannot silently target two rows', () => { + const ids = METADATA_ROUNDTRIP_CASES.map((testCase) => testCase.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('covers both an object-typed and a non-object-typed round-trip', () => { + // The asymmetry that produced #6725: `object` reads are special-cased in + // SchemaRegistry and the generic types are not. A table that lost one of + // the two sides would still look full. + const readable = METADATA_ROUNDTRIP_CASES.filter((c) => c.expected.kind === 'readable'); + expect(readable.some((c) => c.read.type === 'object')).toBe(true); + expect(readable.some((c) => c.read.type !== 'object')).toBe(true); + }); +}); diff --git a/packages/spec/src/contracts/metadata-service-roundtrip-conformance.ts b/packages/spec/src/contracts/metadata-service-roundtrip-conformance.ts new file mode 100644 index 0000000000..692a6a9f36 --- /dev/null +++ b/packages/spec/src/contracts/metadata-service-roundtrip-conformance.ts @@ -0,0 +1,283 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Canonical conformance cases for the `IMetadataService` **write→read + * round-trip** — `register(type, name, data)` followed by `get(type, name)`, + * and the three members that must agree with `get` about the same key + * (`exists`, `listNames`, `list`). + * + * ## Why this exists (#7223) + * + * `register` and `get` are this contract's first two CRUD members, and until + * this table the round-trip between them was exercised in exactly ONE place — + * `metadata-service.test.ts`, against a `Map`-of-`Map`s double written inside + * the test itself. That file is the right thing for `packages/spec` to host + * (the contract has no runtime, and spec is the dependency root), but it means + * the round-trip was pinned against **no shipped implementation at all**: the + * suite asserted that a `Map` behaves like a `Map`. + * + * That is not a hypothetical hole. It is the exact hole #6725 fell through: + * `MetadataFacade.register('object', …)` wrote into a map none of its own reads + * consulted, every read answered `undefined`, and the full `packages/objectql` + * suite (167 files, 2917 tests) plus all 64 `lint.yml` gates stayed green while + * a shipped, exported implementation of the platform's central metadata + * contract could not perform its own most basic round-trip. + * + * ## How to use it + * + * Same shape as {@link ../data/filter-logic-conformance | FILTER_LOGIC_CASES}: + * ONE table here, a thin driver per implementation. Each case is a short + * sequence of writes (and optional removals) followed by exactly ONE read; the + * driver replays it against its subject and asserts {@link MetadataRoundTripCase.expected}. + * + * Two drivers run this table today: + * + * | Driver | Subject | + * |---|---| + * | `metadata-service-roundtrip-conformance.test.ts` (this directory) | the contract's own `Map`-of-`Map`s reference double | + * | `packages/objectql/src/metadata-service-roundtrip-conformance.test.ts` | `MetadataManager`, `MetadataManager` + writable loader, `createMemoryMetadata`, `MetadataFacade` | + * + * `packages/objectql` hosts the shipped-implementation driver because it is the + * only package that can see all three at once — it depends on + * `@objectstack/metadata` and `@objectstack/core` and owns `MetadataFacade` — + * the same argument `metadata-service-getobject-equivalence.test.ts` (#6745) + * already makes for living there. + * + * ## What `expected` means, precisely + * + * **The reference semantics: a store keyed by `type` × the `name` ARGUMENT.** + * That is what the contract's own double implements, and what its parameter + * names say (`@param name - Item name/identifier (snake_case)` on both + * members). It is deliberately NOT a ruling that every shipped implementation + * currently satisfies it — three cases below are answered differently by + * `MetadataFacade` today, and those answers are pinned as measured, with a + * `// DIVERGENCE` marker, in the objectql driver. Pinning ≠ blessing: read the + * divergence notes there and the card they link before treating either answer + * as the intended one. + * + * ## Deliberate scope + * + * The write→read round-trip only. `unregister` appears in exactly one case, as + * the anti-vacuity control for the `absent` expectation — proof that the + * `absent` assertions can flip — not as coverage of the removal contract. + * Overlays, `query`, bulk writes, watch/subscribe and the loader fallback are + * all out; the loader fallback is `metadata-service-getobject-equivalence.test.ts`'s + * subject and is reached without a `register` at all. + * + * A case belongs here only if it is a question every occupant of the `metadata` + * service slot has to answer. Where the answers currently differ, the case + * still belongs — that divergence is the finding. + * + * Refs #7223, #6725, PR #7211, #6745, #6505 / PR #6723. + */ + +/** One `register(type, name, data)` call in a case's setup. */ +export interface MetadataRoundTripWrite { + readonly type: string; + readonly name: string; + readonly data: unknown; +} + +/** One `unregister(type, name)` call, applied after every write in the case. */ +export interface MetadataRoundTripRemoval { + readonly type: string; + readonly name: string; +} + +/** + * What the single read of a case is expected to find. + * + * `readable` carries the document the write put there, so a driver never has to + * re-derive "which write won" by searching the setup. Drivers assert it as a + * recursive SUBSET of what `get` returns, because an implementation may answer + * the runtime-effective document rather than the stored one — `MetadataFacade` + * resolves objects through `SchemaRegistry`, which injects system fields + * (`organization_id`, `created_at`, …) that the author never wrote. A driver + * whose subject is known to answer verbatim SHOULD additionally assert exact + * equality; the objectql driver does this via `documentFidelity`. + */ +export type MetadataRoundTripExpectation = + | { readonly kind: 'readable'; readonly document: unknown } + | { readonly kind: 'absent' }; + +export interface MetadataRoundTripCase { + /** Stable id — what a divergence override in a driver keys off. */ + readonly id: string; + /** Sentence stating the proposition, used as the test title. */ + readonly title: string; + /** Applied in order, through `register(type, name, data)`. */ + readonly writes: readonly MetadataRoundTripWrite[]; + /** Applied after every write, through `unregister(type, name)`. */ + readonly removes?: readonly MetadataRoundTripRemoval[]; + /** The ONE read the case makes, through `get` / `exists` / `listNames`. */ + readonly read: { readonly type: string; readonly name: string }; + readonly expected: MetadataRoundTripExpectation; + /** Why the case is in the table — what breaks if it is dropped. */ + readonly why: string; +} + +const objectDocument = (name: string, label = name) => ({ + name, + label, + fields: { title: { type: 'text', label: 'Title' } }, +}); + +const viewDocument = (name: string, label = `${name} view`) => ({ + name, + label, + type: 'grid', +}); + +/** The `object` document every plain-round-trip case writes. */ +const PIN_ACCOUNT = objectDocument('pin_account'); +/** The non-`object` document, so the type special-casing is exercised on both sides. */ +const PIN_GRID = viewDocument('pin_grid'); + +const PIN_DUP_FIRST = objectDocument('pin_dup', 'First label'); +const PIN_DUP_SECOND = objectDocument('pin_dup', 'Second label'); +const PIN_DUP_VIEW_FIRST = viewDocument('pin_dup_view', 'First label'); +const PIN_DUP_VIEW_SECOND = viewDocument('pin_dup_view', 'Second label'); + +const PIN_SCOPED_VIEW = viewDocument('pin_scoped'); +const PIN_SCOPED_OBJECT = objectDocument('pin_scoped_obj'); + +/** Deliberately not snake_case — the case-sensitivity rows need a cased name. */ +const PIN_CASED = objectDocument('Pin_Cased'); + +/** `data.name` disagrees with the `name` argument — see the two cases using it. */ +const PIN_KEYED_OBJECT = objectDocument('pin_data_name'); +const PIN_KEYED_VIEW = viewDocument('pin_data_name_view'); + +const PIN_PLURAL = objectDocument('pin_plural'); + +const PIN_REMOVED = objectDocument('pin_removed'); + +export const METADATA_ROUNDTRIP_CASES: readonly MetadataRoundTripCase[] = [ + { + id: 'object-roundtrip', + title: "register('object', n, d) is readable back through get('object', n)", + writes: [{ type: 'object', name: 'pin_account', data: PIN_ACCOUNT }], + read: { type: 'object', name: 'pin_account' }, + expected: { kind: 'readable', document: PIN_ACCOUNT }, + why: 'The contract\'s first two CRUD members, on the type whose reads are special-cased. This is the row #6725 would have failed.', + }, + { + id: 'nonobject-roundtrip', + title: "register('view', n, d) is readable back through get('view', n)", + writes: [{ type: 'view', name: 'pin_grid', data: PIN_GRID }], + read: { type: 'view', name: 'pin_grid' }, + expected: { kind: 'readable', document: PIN_GRID }, + why: 'The generic type store — NOT special-cased on the read side of SchemaRegistry, which is the asymmetry that produced #6725. An implementation can pass one of these two rows and fail the other.', + }, + { + id: 'get-before-register-object', + title: "get('object', n) is absent before anything registers n", + writes: [], + read: { type: 'object', name: 'pin_never_written' }, + expected: { kind: 'absent' }, + why: 'The miss shape. Without it, an implementation that answers a truthy default for every key would satisfy every `readable` row above.', + }, + { + id: 'get-before-register-nonobject', + title: "get('view', n) is absent before anything registers n", + writes: [], + read: { type: 'view', name: 'pin_never_written' }, + expected: { kind: 'absent' }, + why: 'The miss shape on the generic store, for the same reason the round-trip is pinned on both.', + }, + { + id: 're-register-object', + title: "re-registering the same object type+name resolves to the LAST write", + writes: [ + { type: 'object', name: 'pin_dup', data: PIN_DUP_FIRST }, + { type: 'object', name: 'pin_dup', data: PIN_DUP_SECOND }, + ], + read: { type: 'object', name: 'pin_dup' }, + expected: { kind: 'readable', document: PIN_DUP_SECOND }, + why: 'Overwrite-vs-reject is unstated in the contract TSDoc. Every shipped implementation currently OVERWRITES and keeps one entry; this pins that, so a future implementation that rejects or duplicates has to say so rather than drift.', + }, + { + id: 're-register-nonobject', + title: 're-registering the same view type+name resolves to the LAST write', + writes: [ + { type: 'view', name: 'pin_dup_view', data: PIN_DUP_VIEW_FIRST }, + { type: 'view', name: 'pin_dup_view', data: PIN_DUP_VIEW_SECOND }, + ], + read: { type: 'view', name: 'pin_dup_view' }, + expected: { kind: 'readable', document: PIN_DUP_VIEW_SECOND }, + why: 'The object write path reaches two stores and the generic one reaches a single map, so overwrite semantics are worth asserting on both.', + }, + { + id: 'type-scopes-view-away-from-object', + title: "get('object', n) does not see a register('view', n, …)", + writes: [{ type: 'view', name: 'pin_scoped', data: PIN_SCOPED_VIEW }], + read: { type: 'object', name: 'pin_scoped' }, + expected: { kind: 'absent' }, + why: 'Type scoping, in the direction that matters most: a view leaking into the object store would be dispatched on by the data plane.', + }, + { + id: 'type-scopes-object-away-from-view', + title: "get('view', n) does not see a register('object', n, …)", + writes: [{ type: 'object', name: 'pin_scoped_obj', data: PIN_SCOPED_OBJECT }], + read: { type: 'view', name: 'pin_scoped_obj' }, + expected: { kind: 'absent' }, + why: 'The other direction, which a single shared name-keyed map would fail while passing the one above.', + }, + { + id: 'name-is-case-sensitive-exact-hit', + title: 'a cased name is readable back under its exact spelling', + writes: [{ type: 'object', name: 'Pin_Cased', data: PIN_CASED }], + read: { type: 'object', name: 'Pin_Cased' }, + expected: { kind: 'readable', document: PIN_CASED }, + why: 'The control for the row below: without it, an implementation that dropped cased names entirely would pass the miss assertion for the wrong reason.', + }, + { + id: 'name-is-case-sensitive-lower-miss', + title: 'a cased name is NOT readable back under a lowercased spelling', + writes: [{ type: 'object', name: 'Pin_Cased', data: PIN_CASED }], + read: { type: 'object', name: 'pin_cased' }, + expected: { kind: 'absent' }, + why: 'No shipped implementation normalizes name case today. Pinning that keeps a future one from folding case silently — which would make two authored items collide into one.', + }, + { + id: 'key-is-the-name-argument-object', + title: "get('object', n) finds a write made under n even when data.name differs", + writes: [{ type: 'object', name: 'pin_key', data: PIN_KEYED_OBJECT }], + read: { type: 'object', name: 'pin_key' }, + expected: { kind: 'readable', document: PIN_KEYED_OBJECT }, + why: 'Whether `name` or `data.name` is the key is the whole round-trip. The contract names the parameter on both members and says nothing about `data.name`, so the reference answer is the argument. **Shipped implementations disagree here** — see the DIVERGENCE notes in the objectql driver.', + }, + { + id: 'key-is-the-name-argument-nonobject', + title: "get('view', n) finds a write made under n even when data.name differs", + writes: [{ type: 'view', name: 'pin_key_view', data: PIN_KEYED_VIEW }], + read: { type: 'view', name: 'pin_key_view' }, + expected: { kind: 'readable', document: PIN_KEYED_VIEW }, + why: 'The same question on the generic store, so a divergence cannot be mistaken for object-specific special-casing.', + }, + { + id: 'plural-objects-type-is-its-own-store', + title: "get('object', n) does not see a register('objects', n, …)", + writes: [{ type: 'objects', name: 'pin_plural', data: PIN_PLURAL }], + read: { type: 'object', name: 'pin_plural' }, + expected: { kind: 'absent' }, + why: 'The two spellings of the object type. The reference store keys on the string it is given; some implementations alias the plural to the singular. **Shipped implementations disagree here** — see the DIVERGENCE notes in the objectql driver.', + }, + { + id: 'primitive-data-roundtrips', + title: 'a non-object `data` value is readable back unchanged', + writes: [{ type: 'setting', name: 'pin_flag', data: 'enabled' }], + read: { type: 'setting', name: 'pin_flag' }, + expected: { kind: 'readable', document: 'enabled' }, + why: '`data` is declared `unknown`, not `object`. An implementation that derives its key from `data.name` has nothing to derive it from here. **Shipped implementations disagree here** — see the DIVERGENCE notes in the objectql driver.', + }, + { + id: 'absent-after-unregister', + title: 'a registered item is absent again after unregister', + writes: [{ type: 'object', name: 'pin_removed', data: PIN_REMOVED }], + removes: [{ type: 'object', name: 'pin_removed' }], + read: { type: 'object', name: 'pin_removed' }, + expected: { kind: 'absent' }, + why: 'Anti-vacuity control for every `absent` row: it is the one whose subject definitely existed a moment earlier, so a driver that silently failed to write anything cannot pass it alongside the readable rows.', + }, +];