Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/metadata-service-contract-ruled-table.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
---
"@objectstack/spec": minor
---

`IMetadataService` now states the #7378 three-cell register ruling (maintainer, 2026-08-12), superseding the 2026-08-11 option-(a) text the contract carried — including a "(c) PARKED — do not implement" paragraph pointed at behaviour that is now ruled and shipped.

- `register`/`get` TSDoc states the ruled contract: a `data.name` disagreeing with the `name` argument is refused loudly (`VALIDATION_ERROR`, status 400, locating message naming both spellings, nothing stored); a non-object (or array) `data` is refused, never accepted-and-dropped or coerced into storability; type stores key on the canonical singular type (plural spellings fold through `PLURAL_TO_SINGULAR` before any store decision).
- `MetadataRoundTripExpectation` gains a `refused` kind, and the five ruled `METADATA_ROUNDTRIP_CASES` rows now carry the ruled answers. Four rows were renamed to state the ruled propositions: `key-is-the-name-argument-object`/`-nonobject` → `data-name-mismatch-refused-object`/`-nonobject`, `primitive-data-roundtrips` → `primitive-data-refused`, `array-data-roundtrips` → `array-data-refused`, and `plural-objects-type-is-its-own-store` → `plural-type-folds-to-canonical-store` (now `readable` through the canonical fold).
- The contract's reference double refuses rows 1/3 and folds row 2, restating the semantics of `assertMetadataRegisterContract` / `canonicalMetadataServiceType` (`@objectstack/core`) locally, since spec is the dependency root and cannot import core.

The shipped implementations already behave this way (their half landed separately); this release makes the contract's declared text and executable table agree with them.
115 changes: 21 additions & 94 deletions packages/objectql/src/metadata-service-roundtrip-conformance.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,35 +25,28 @@
* subject is the one that would notice a `register` that threw, silently
* skipped, or mutated the document on the way to `loader.save`.
*
* ## All three rows are now RULED (#7378) — and the table lags the ruling
* ## All three cells are RULED (#7378, 2026-08-12) — and the table carries them
*
* This file's `// DIVERGENCE` era is over. The maintainer's three-cell ruling
* of 2026-08-12 (#7378, 裁定人:维护者 huangyiirene), quoted verbatim and
* untranslated:
*
* > 1. **Row 1(key 归属)= (c) 响亮拒绝。** `register(type, name, data)` 中
* > `name` 参数与 `data.name` 不一致时,所有实现统一拒绝并报错定位。
* > 2. **Row 2(objects/object 别名)= 所有实现一个答案,与
* > `check:meta-type-normalized` 收敛。**
* > 3. **Row 3(非对象 data 静默丢弃)= 响亮拒绝(throw)。**
* >
* > 三格的 `// DIVERGENCE` pin 在裁定 PR 内同步更新(该测试文件设计意图即如此)。
*
* Every shipped implementation now enforces it through ONE shared guard —
* of 2026-08-12 (#7378, 裁定人:维护者 huangyiirene): mismatch between the
* `name` argument and `data.name` refused loudly (row 1); one answer for the
* objects/object spellings, converged with `check:meta-type-normalized` (row
* 2); non-object `data` refused, never accepted-and-dropped (row 3). Every
* shipped implementation enforces it through ONE shared guard —
* `assertMetadataRegisterContract` / `canonicalMetadataServiceType`
* (`@objectstack/core/metadata-service-contract`), whose header carries the
* full ruling text and the row-2 convergence rationale (the direction is
* `check:meta-type-normalized`'s: normalize once at the entry, decide on the
* normalized value — the gate's header carries #3984/#5881/#6241).
* full verbatim ruling text and the row-2 convergence rationale (the
* direction is `check:meta-type-normalized`'s: normalize once at the entry,
* decide on the normalized value — the gate's header carries
* #3984/#5881/#6241).
*
* **{@link RULED_CONTRACT_ANSWERS} below overrides the table's `expected` for
* the five ruled case rows.** The table's own reference answers still describe
* the PRE-ruling reference semantics, because the table — and the contract's
* reference double beside it — live under `packages/spec/src/**`, whose half
* of this ruling is the `domain:spec` seat's, tracked on #7378. When that half
* lands (table rows re-ruled, reference double refusing/folding), the
* `table lags the ruling` wiring test below goes red on purpose: delete the
* override for each row it names and hold every subject to the table again.
* The shared table states the same ruling as `expected` answers — `refused`
* rows carry the ADR-0112 envelope contract, the plural row is `readable`
* through the canonical fold — so this driver holds every subject to the
* table directly. (Between the ruling's implementation half and its spec-side
* half, a `RULED_CONTRACT_ANSWERS` override map here carried the ruled
* answers over a still-pre-ruling table; the handoff wiring test went red
* when the table landed, and both were deleted, as designed.)
*
* ## Two assertion strengths, declared per subject
*
Expand DownExpand Up@@ -172,37 +165,6 @@ class WritableFixtureLoader implements MetadataLoader {
}
}

/**
* The RULED contract answer for a case, where the 2026-08-12 ruling and the
* table's (spec-side, still pre-ruling) `expected` disagree — see the header
* for why the two can disagree at all and for when each entry here dies.
*
* - `refused` — `register` must reject the case's write with the ADR-0112
* envelope (`code` AND `status`), a locating message, and NOTHING stored.
* - `readable` — the case's final write is readable back, even though the
* table still says `absent`.
*/
type RuledAnswer =
| { readonly kind: 'refused'; readonly note: string }
| { readonly kind: 'readable'; readonly note: string };

const RULED_1 =
'Row 1 (#7378, 2026-08-12): a data.name disagreeing with the name argument is REFUSED loudly by every implementation — silent resolution in either direction can misplace the item. Replaces the option-(a) argument-wins ruling of 2026-08-11 that the table still describes.';

const RULED_2 =
"Row 2 (#7378, 2026-08-12): all implementations give ONE answer, converged with check:meta-type-normalized's enforced direction — plural folds to singular before any decision, so 'objects' and 'object' address one store. The table's `absent` still describes the pre-ruling reference semantics (raw-string type keys).";

const RULED_3 =
'Row 3 (#7378, 2026-08-12): a non-object data is REFUSED (throw) by every implementation — accept-then-drop was indefensible, and coercing into storability (the interim { name, content } box) is equally forbidden. The table still expects the value readable back.';

const RULED_CONTRACT_ANSWERS: Readonly<Record<string, RuledAnswer>> = {
'key-is-the-name-argument-object': { kind: 'refused', note: RULED_1 },
'key-is-the-name-argument-nonobject': { kind: 'refused', note: RULED_1 },
'primitive-data-roundtrips': { kind: 'refused', note: RULED_3 },
'array-data-roundtrips': { kind: 'refused', note: RULED_3 },
'plural-objects-type-is-its-own-store': { kind: 'readable', note: RULED_2 },
};

const IMPLEMENTATIONS: readonly ShippedImplementation[] = [
{
label: 'MetadataManager (registry only)',
Expand DownExpand Up@@ -303,9 +265,8 @@ describe.each(IMPLEMENTATIONS)(
'%s',
async (_id, testCase) => {
const service = implementation.create();
const ruled = RULED_CONTRACT_ANSWERS[testCase.id];

if (ruled?.kind === 'refused') {
if (testCase.expected.kind === 'refused') {
await assertRefused(service, testCase);
return;
}
Expand All@@ -320,10 +281,7 @@ describe.each(IMPLEMENTATIONS)(
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 =
ruled?.kind === 'readable'
? { kind: 'readable' as const, document: lastWrittenDocument(testCase) }
: testCase.expected;
const expected = testCase.expected;

if (expected.kind === 'readable') {
// Anti-vacuity: `toMatchObject` against an absent document
Expand DownExpand Up@@ -359,9 +317,8 @@ describe.each(IMPLEMENTATIONS)(
);

/**
* [#7378 rows 1/2] Driver-local pins the table does not carry (the table is the
* spec seat's half — see the header). These keep the ruled behaviour from
* passing for a wrong, narrower reason.
* [#7378 rows 1/2] Driver-local pins the table deliberately does not carry.
* These keep the ruled behaviour from passing for a wrong, narrower reason.
*/
describe.each(IMPLEMENTATIONS)('#7378 ruled behaviour, beyond the table [$label]', (implementation) => {
it('row 1 is a MISMATCH rule: a document with NO name of its own registers under the argument', async () => {
Expand DownExpand Up@@ -423,33 +380,3 @@ describe.each(IMPLEMENTATIONS)('#7378 ruled behaviour, beyond the table [$label]
expect(await service.listNames('objects')).toContain('pin_both_ways');
});
});

describe('round-trip conformance table wiring', () => {
const ids = new Set(METADATA_ROUNDTRIP_CASES.map((testCase) => testCase.id));

it('declares no ruled override for a case id the table does not contain', () => {
// A renamed case would otherwise turn its override into a dead entry,
// and every subject would quietly be held to the pre-ruling reference
// answer it is known to fail.
for (const id of Object.keys(RULED_CONTRACT_ANSWERS)) {
expect(ids, id).toContain(id);
}
});

it('the table still lags the 2026-08-12 ruling — this red is the handoff signal', () => {
// The overrides above exist ONLY because the table and the reference
// double live under `packages/spec/src/**`, the `domain:spec` seat's
// half of #7378. When that half lands, each assertion here goes red:
// delete the corresponding RULED_CONTRACT_ANSWERS entry and let every
// subject be held to the table's (then-ruled) answer directly.
for (const [id, ruled] of Object.entries(RULED_CONTRACT_ANSWERS)) {
const testCase = METADATA_ROUNDTRIP_CASES.find((candidate) => candidate.id === id);
if (!testCase) continue; // the wiring test above owns this failure
if (ruled.kind === 'refused') {
expect(testCase.expected.kind, `${id}: table updated? delete its override`).toBe('readable');
} else {
expect(testCase.expected.kind, `${id}: table updated? delete its override`).toBe('absent');
}
}
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,8 @@
* `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
* against a `Map`-of-`Map`s keyed by the CANONICAL type × the `name` ARGUMENT,
* refusing what it cannot key (#7378, maintainer ruling 2026-08-12) — 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
Expand DownExpand Up@@ -36,26 +37,71 @@ import {
METADATA_ROUNDTRIP_CASES,
type MetadataRoundTripCase,
} from './metadata-service-roundtrip-conformance';
import { StandardErrorCode } from '../api/errors.zod';
import { pluralToSingular } from '../shared/metadata-collection.zod';

/**
* 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`.
* The #7378 three-cell register contract (maintainer ruling 2026-08-12),
* restated locally. The shipped implementations share ONE guard —
* `assertMetadataRegisterContract` / `canonicalMetadataServiceType`
* (`@objectstack/core`, `packages/core/src/metadata-service-contract.ts`,
* whose header carries the verbatim ruling) — which this file deliberately
* does NOT import: `packages/spec` is the dependency root and core depends on
* spec, not the other way round. The semantics are restated against the same
* `PLURAL_TO_SINGULAR` map (which spec itself owns, so the fold has one
* source), keeping the reference double executable from the root; keep the
* two in step through the shared conformance table, which both drivers replay.
*/
function registerRefusal(message: string): Error & { code: string; status: number } {
const error = new Error(message) as Error & { code: string; status: number };
error.code = StandardErrorCode.enum.VALIDATION_ERROR;
error.status = 400;
return error;
}

/** Rows 1 and 3: refuse what the store cannot key, before any write. */
function assertRegisterContract(type: string, name: string, data: unknown): void {
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
const shape = data === null ? 'null' : Array.isArray(data) ? 'an array' : `a ${typeof data}`;
throw registerRefusal(
`IMetadataService.register('${type}', '${name}'): data is ${shape}, not a metadata document — ` +
`refused, never accepted-and-dropped or coerced into storability (#7378 row 3).`,
);
}
const documentName = (data as { name?: unknown }).name;
if (documentName !== undefined && documentName !== name) {
throw registerRefusal(
`IMetadataService.register('${type}', '${name}'): data.name is '${String(documentName)}', which disagrees ` +
`with the name argument '${name}' — refused, since silent resolution in either direction can misplace the item (#7378 row 1).`,
);
}
}

/**
* The reference store: CANONICAL `type` → `name` → `data`, guarded by the
* refusals above, and nothing else. Written out here rather than imported so
* that the reference semantics live in the file that asserts them — typed
* against the contract so a signature change reaches this file via `tsc`.
* Every member folds its `type` through `pluralToSingular` (#7378 row 2), so
* the two spellings of a type address one store in both directions.
*/
function createReferenceService(): IMetadataService {
const store = new Map<string, Map<string, unknown>>();
const typeStore = (type: string): Map<string, unknown> => {
let map = store.get(type);
const canonical = pluralToSingular(type);
let map = store.get(canonical);
if (!map) {
map = new Map();
store.set(type, map);
store.set(canonical, map);
}
return map;
};

return {
register: async (type, name, data) => { typeStore(type).set(name, data); },
register: async (type, name, data) => {
assertRegisterContract(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); },
Expand All@@ -66,6 +112,62 @@ function createReferenceService(): IMetadataService {
};
}

/**
* The `name` a case's written document carries when that is NOT the key the
* case reads — the spelling a misplacing implementation would file the item
* under, which the refusal's message must NAME and its absence probes cover.
*/
function disagreeingDocumentName(testCase: MetadataRoundTripCase): string | undefined {
const written = testCase.writes[testCase.writes.length - 1]?.data;
const documentName = (written as { name?: unknown } | undefined)?.name;
return typeof documentName === 'string' && documentName !== testCase.read.name
? documentName
: undefined;
}

/**
* A `refused` row (#7378 rows 1/3): the single write rejects with the
* ADR-0112 envelope (`code` AND `status` — a rejection test that checks only
* "it threw" is not one), locates the problem in its message, and stores
* NOTHING — neither under the argument key nor under the document's own name.
*/
async function assertRefused(service: IMetadataService, testCase: MetadataRoundTripCase): Promise<void> {
expect(testCase.writes).toHaveLength(1);
const write = testCase.writes[0];

const error = await service.register(write.type, write.name, write.data).then(
() => undefined,
(thrown: unknown) => thrown as Error & { code?: string; status?: number },
);
expect(error, `register must REFUSE this write (#7378): ${testCase.id}`).toBeDefined();
expect(error).toMatchObject({
code: StandardErrorCode.enum.VALIDATION_ERROR,
status: 400,
});

// 报错定位 — the message names the write's coordinates…
const message = String(error?.message ?? '');
expect(message).toContain(`'${write.type}'`);
expect(message).toContain(`'${write.name}'`);
// …and, on the mismatch rows, BOTH disagreeing spellings.
const disagreeing = disagreeingDocumentName(testCase);
if (disagreeing !== undefined) {
expect(message).toContain(`'${disagreeing}'`);
}

// The refusal wrote nothing: absent under the argument key…
expect(await service.get(testCase.read.type, testCase.read.name)).toBeUndefined();
expect(await service.exists(testCase.read.type, testCase.read.name)).toBe(false);
const names = await service.listNames(testCase.read.type);
expect(names).not.toContain(testCase.read.name);
// …and never under the document's own name either — the misplacement the
// ruling exists to make impossible.
if (disagreeing !== undefined) {
expect(await service.get(testCase.read.type, disagreeing)).toBeUndefined();
expect(names).not.toContain(disagreeing);
}
}

/** 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) {
Expand All@@ -85,6 +187,11 @@ describe('IMetadataService round-trip conformance — contract reference double'
it.each(METADATA_ROUNDTRIP_CASES.map((testCase) => [testCase.id, testCase] as const))(
'%s',
async (_id, testCase) => {
if (testCase.expected.kind === 'refused') {
await assertRefused(createReferenceService(), testCase);
return;
}

const { got, exists, names } = await replay(createReferenceService(), testCase);

if (testCase.expected.kind === 'readable') {
Expand Down
Loading
Loading