Skip to content

Commit 7a5ef00

Browse files
test(objectql): pin getObject(n) = get('object', n) across all three IMetadataService implementations (#6839)
PR #6723 documented on `IMetadataService.getObject` that the pair resolves through one lookup in every shipped implementation, leaving the statement declared-but-ungated. This adds the conformance table that gates it. Four subjects over three implementations: MetadataManager under BOTH of its resolution paths (in-memory registry hit and loader fallback), createMemoryMetadata, and MetadataFacade. Three cases each: a present object answered identically (reference identity, which is what every implementation's mechanism actually delivers), `undefined` from both members on a miss, and a name-discrimination case that a member ignoring its argument cannot survive. The facade is seeded through `SchemaRegistry.registerObject`, never through `facade.register('object', ...)`: the latter writes where neither of the facade's object reads look (#6725), so seeding that way would compare undefined to undefined and call it equivalence. The `toBeDefined()` in the present-object case is what keeps that vacuous version from passing. Measured before pinning — the equivalence holds today on all four subjects, on both the hit and miss paths, at reference identity. Verified non-vacuous by ablation: breaking each implementation's getObject in turn reddens exactly its own subject's present + discrimination cases and leaves the miss case green. Refs #6505, PR #6723, #6724, #6725. Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6595262 commit 7a5ef00

1 file changed

Lines changed: 227 additions & 0 deletions

File tree

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Conformance pin — `getObject(name)` ≡ `get('object', name)` (#6745).
5+
*
6+
* `IMetadataService.getObject` (`packages/spec/src/contracts/metadata-service.ts`)
7+
* DECLARES that the pair resolves through one lookup in every implementation this
8+
* repo ships, and that both members hand back the identical object. PR #6723 (for
9+
* #6505) wrote that down after measuring it with a throwaway probe, which left the
10+
* statement declared-but-ungated: nothing failed if a later edit made the pair
11+
* diverge, and the contract TSDoc would then simply be lying. This file is the gate.
12+
*
13+
* `packages/objectql` is the only package that can see all three implementations —
14+
* it depends on both `@objectstack/metadata` (MetadataManager) and
15+
* `@objectstack/core` (createMemoryMetadata), and owns MetadataFacade itself.
16+
* `packages/spec` cannot host it: the contract has no runtime.
17+
*
18+
* Two things about the shape here are load-bearing, and both are the difference
19+
* between this pin and a green-but-empty one:
20+
*
21+
* 1. **The facade is seeded through `registry.registerObject`, never through
22+
* `facade.register('object', …)`.** Those are not interchangeable: the facade
23+
* writes objects through `SchemaRegistry.registerItem`, which stores into the
24+
* generic `metadata` map, while BOTH of its object reads resolve from
25+
* `objectContributors` (`registry.getItem` special-cases the `object` type
26+
* straight back to `registry.getObject`). An object written the first way is
27+
* readable back through neither member — measured, both `undefined` — so a pin
28+
* that seeded that way would compare `undefined` to `undefined` and call it
29+
* equivalence. That write/read split is a separate, already-filed finding
30+
* (#6725); this file deliberately does not assert on it, and the
31+
* `expect(...).toBeDefined()` in the present-object case is what stops the
32+
* vacuous version from ever passing here again.
33+
*
34+
* 2. **MetadataManager appears twice, under both of its resolution paths.** Its
35+
* `get` answers from the in-memory registry when it can and falls back to the
36+
* loaders otherwise; `getObject` delegates to `get`, so a future edit that
37+
* re-implemented `getObject` against the registry alone would still agree on
38+
* every registry-seeded case and diverge only on a loader-backed one. Seeding
39+
* one subject through each path is what makes that break visible.
40+
*
41+
* Reference identity (`toBe`), not deep equality, is the assertion because
42+
* identity is what each implementation's mechanism actually delivers today —
43+
* measured on all four subjects, on both call orders — and it is what the contract
44+
* claims. `MetadataManager.getObject` literally returns `this.get('object', name)`;
45+
* `createMemoryMetadata` reads one `Map` from both members; the facade's two paths
46+
* converge on `SchemaRegistry.getObject`, whose merge result is memoized in
47+
* `mergedObjectCache`, so both members hand back the same instance.
48+
*
49+
* Refs #6745, #6505, PR #6723, #6725.
50+
*/
51+
52+
import { describe, it, expect } from 'vitest';
53+
import type { IMetadataService } from '@objectstack/spec/contracts';
54+
import { SchemaRegistry } from './registry';
55+
import { MetadataFacade } from './metadata-facade';
56+
import { MetadataManager, type MetadataLoader } from '@objectstack/metadata';
57+
import { createMemoryMetadata } from '@objectstack/core';
58+
59+
/**
60+
* The two contract members under test, and nothing else. Typing the subjects
61+
* against `IMetadataService` rather than against the concrete classes is
62+
* deliberate: it is the contract's declaration this file is pinning, so a
63+
* signature change on either member should reach this file through `tsc`.
64+
*/
65+
type ObjectResolvingService = Pick<IMetadataService, 'get' | 'getObject'>;
66+
67+
interface ObjectFixture {
68+
readonly name: string;
69+
readonly definition: Record<string, unknown>;
70+
}
71+
72+
const objectFixture = (name: string): ObjectFixture => ({
73+
name,
74+
definition: {
75+
name,
76+
label: name.replace(/_/g, ' '),
77+
fields: { title: { type: 'text', label: 'Title' } },
78+
},
79+
});
80+
81+
/**
82+
* A shipped implementation plus the seeding channel ITS OWN object reads observe.
83+
* The channel is part of the subject, not an incidental detail — see note 1 in
84+
* the file header.
85+
*/
86+
interface PinnedImplementation {
87+
readonly label: string;
88+
create(objects: readonly ObjectFixture[]): Promise<ObjectResolvingService>;
89+
}
90+
91+
/** Minimal read-only loader, so MetadataManager's loader-fallback path is real. */
92+
class FixtureLoader implements MetadataLoader {
93+
readonly contract: MetadataLoader['contract'] = {
94+
name: 'getobject-equivalence-fixture',
95+
protocol: 'memory:',
96+
capabilities: { read: true, write: false, watch: false, list: true },
97+
};
98+
99+
private readonly storage = new Map<string, unknown>();
100+
101+
constructor(objects: readonly ObjectFixture[]) {
102+
for (const object of objects) {
103+
this.storage.set(object.name, object.definition);
104+
}
105+
}
106+
107+
async load(type: string, name: string) {
108+
const data = type === 'object' ? this.storage.get(name) : undefined;
109+
return data
110+
? { data, source: this.contract.name, format: 'json' as const, loadTime: 0 }
111+
: { data: null };
112+
}
113+
114+
async loadMany<T = unknown>(type: string): Promise<T[]> {
115+
return (type === 'object' ? Array.from(this.storage.values()) : []) as T[];
116+
}
117+
118+
async exists(type: string, name: string): Promise<boolean> {
119+
return type === 'object' && this.storage.has(name);
120+
}
121+
122+
async stat() {
123+
return null;
124+
}
125+
126+
async list(type: string): Promise<string[]> {
127+
return type === 'object' ? Array.from(this.storage.keys()) : [];
128+
}
129+
}
130+
131+
const IMPLEMENTATIONS: readonly PinnedImplementation[] = [
132+
{
133+
// `get` answers from the in-memory registry on this one.
134+
label: 'MetadataManager (registry hit)',
135+
async create(objects) {
136+
const manager = new MetadataManager({ formats: ['json'], loaders: [] });
137+
for (const object of objects) {
138+
await manager.register('object', object.name, object.definition);
139+
}
140+
return manager;
141+
},
142+
},
143+
{
144+
// Nothing is registered, so `get` can only answer through the loaders —
145+
// the second of MetadataManager's two resolution paths.
146+
label: 'MetadataManager (loader fallback)',
147+
async create(objects) {
148+
return new MetadataManager({ formats: ['json'], loaders: [new FixtureLoader(objects)] });
149+
},
150+
},
151+
{
152+
label: 'createMemoryMetadata',
153+
async create(objects) {
154+
const memory = createMemoryMetadata();
155+
for (const object of objects) {
156+
await memory.register('object', object.name, object.definition);
157+
}
158+
return memory;
159+
},
160+
},
161+
{
162+
label: 'MetadataFacade',
163+
async create(objects) {
164+
const registry = new SchemaRegistry({ multiTenant: false });
165+
for (const object of objects) {
166+
// NOT `facade.register('object', …)` — that writes where neither of
167+
// the facade's object reads look (#6725), which would make the
168+
// present-object case below compare undefined to undefined.
169+
registry.registerObject(object.definition as never, 'com.example.pin');
170+
}
171+
return new MetadataFacade(registry);
172+
},
173+
},
174+
];
175+
176+
describe.each(IMPLEMENTATIONS)(
177+
'IMetadataService conformance — getObject(n) ≡ get(\'object\', n) [$label]',
178+
({ create }) => {
179+
it('answers a present object identically through both members', async () => {
180+
const alpha = objectFixture('pin_alpha');
181+
const service = await create([alpha]);
182+
183+
const viaGetObject = await service.getObject(alpha.name);
184+
const viaGet = await service.get('object', alpha.name);
185+
186+
// Anti-vacuity: without this, a subject that resolved NOTHING would
187+
// satisfy the equivalence below by answering undefined twice.
188+
expect(viaGetObject).toBeDefined();
189+
expect((viaGetObject as { name?: string }).name).toBe(alpha.name);
190+
191+
expect(viaGetObject).toBe(viaGet);
192+
});
193+
194+
it('answers undefined through both members for an object nothing registered', async () => {
195+
const service = await create([objectFixture('pin_alpha')]);
196+
197+
const viaGetObject = await service.getObject('pin_absent');
198+
const viaGet = await service.get('object', 'pin_absent');
199+
200+
expect(viaGetObject).toBeUndefined();
201+
expect(viaGet).toBeUndefined();
202+
});
203+
204+
it('keeps the pair name-discriminating when several objects are registered', async () => {
205+
const alpha = objectFixture('pin_alpha');
206+
const beta = objectFixture('pin_beta');
207+
const service = await create([alpha, beta]);
208+
209+
const alphaViaGetObject = await service.getObject(alpha.name);
210+
const alphaViaGet = await service.get('object', alpha.name);
211+
const betaViaGetObject = await service.getObject(beta.name);
212+
const betaViaGet = await service.get('object', beta.name);
213+
214+
expect(alphaViaGetObject).toBeDefined();
215+
expect(betaViaGetObject).toBeDefined();
216+
217+
expect(alphaViaGetObject).toBe(alphaViaGet);
218+
expect(betaViaGetObject).toBe(betaViaGet);
219+
220+
// A member that ignored `name` and returned the first object would
221+
// agree with itself on every case above; it cannot survive this one.
222+
expect(alphaViaGetObject).not.toBe(betaViaGetObject);
223+
expect((alphaViaGetObject as { name?: string }).name).toBe(alpha.name);
224+
expect((betaViaGetObject as { name?: string }).name).toBe(beta.name);
225+
});
226+
},
227+
);

0 commit comments

Comments
 (0)