Skip to content

Commit 1507ba3

Browse files
os-zhuangclaude
andauthored
fix(objectql): MetadataFacade object writes now reach the map its reads use (#6725) (#7211)
`MetadataFacade.register('object', …)` wrote through `SchemaRegistry.registerItem`, into the generic `metadata` map. Every one of the facade's object reads resolves from `objectContributors`, which only `registerObject` populates: `getObject` goes straight there; `get('object', …)` and `exists` go via `registry.getItem`, which special-cases the object type back to `getObject`; `list`/`listNames` go via `registry.listItems`, which special-cases to `getAllObjects`. So an object written through the public facade was readable back through none of them — `register` resolved and every read answered `undefined`. `IMetadataService` declares `getObject(name)` ≡ `get('object', name)` and its own conformance test round-trips a `register('object', …)` through both members, so this was a shipped contract that could not work. Dormant in-tree only because nothing on `main` installs a `MetadataFacade` into the `metadata` slot. The write now performs both halves of the two-place object write the registry documents (`SchemaRegistry.unregisterObject`'s header; the in-tree precedent is `MetadataProtocol.applyObjectRegistryMutation`): `registerObject` for the contributor entry the reads resolve, plus the existing `registerItem` for the stored document. Both type spellings are covered, since both are special-cased on the read side. The contributor gets a COPY: `applyProtection` stamps in place and `applySystemFields` returns its input unchanged when there is nothing to inject, so a shared reference would have leaked a synthetic package id onto the stored document — what the "never invents a synthetic package id" pin forbids. That pin keeps its direct read of the generic map, because the stored document is what it was written to guard. A package-less object registers under the `'sys_metadata'` sentinel with `_provenance: 'org'`, so it cannot read as code-shipped. `unregister('object', …)` removes both halves too. Without that the fix would have re-opened #6808 from the other side: a removal that empties only the generic map leaves `getObject` — what the data plane dispatches on — serving a deleted object for the life of the process. Refs #6725, #6505, PR #6723, #6808, ADR-0010, ADR-0029. Claude-Session: https://claude.ai/code/session_0141cZum72My2vskaQSoQ1tZ Co-authored-by: Claude <noreply@anthropic.com>
1 parent cafec0a commit 1507ba3

4 files changed

Lines changed: 372 additions & 17 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
**`MetadataFacade.register('object', …)` now writes where its own object reads
6+
look — it was a silent no-op before (#6725).**
7+
8+
`MetadataFacade` is exported from `@objectstack/objectql`'s root and `core`
9+
entrypoints for hosts that want to occupy the kernel's `metadata` slot with a
10+
`SchemaRegistry`-backed service. Its object write went through
11+
`SchemaRegistry.registerItem`, which stores into the generic `metadata` map —
12+
and **every one of its object reads resolves from `objectContributors`**, which
13+
only `registerObject` populates:
14+
15+
- `getObject(name)``registry.getObject`;
16+
- `get('object', name)` and `exists('object', name)``registry.getItem`, which
17+
special-cases the object type straight back to `registry.getObject`;
18+
- `list('object')` and `listNames('object')``registry.listItems`, which
19+
special-cases to `registry.getAllObjects`.
20+
21+
So an object registered through the facade was readable back through **none** of
22+
them: `register` resolved successfully and every subsequent read answered
23+
`undefined` / `[]`. `IMetadataService` (`@objectstack/spec/contracts`) declares
24+
`getObject(name)``get('object', name)` and its own conformance test
25+
round-trips a `register('object', …)` through both members, so this was a
26+
shipped contract that could not work. Dormant in-tree only because nothing on
27+
`main` installs a `MetadataFacade` into the `metadata` slot — a downstream host
28+
that did (cloud, a third-party kernel) got the split, including the
29+
write-then-read in ObjectQL's own `bridgeObjectsToMetadataService`, whose
30+
"already registered?" probe would never answer and so re-registered the full
31+
object set on every boot.
32+
33+
**What changed.** The facade's object write now performs *both* halves of the
34+
two-place write the registry documents for a runtime-authored object
35+
(`SchemaRegistry.unregisterObject`'s header states the invariant; the in-tree
36+
precedent is `MetadataProtocol.applyObjectRegistryMutation`, which does exactly
37+
this): `registerObject` for the contributor entry every read resolves, plus the
38+
existing `registerItem` for the stored document. Both spellings of the type
39+
(`'object'` and `'objects'`) are covered, because both are special-cased on the
40+
read side.
41+
42+
Consequences a caller can observe:
43+
44+
- `getObject` / `get` / `exists` / `list` / `listNames` / `listObjects` now
45+
answer a facade-registered object. What they answer is the **runtime-effective**
46+
object the contract promises: system columns injected, primary title
47+
designated, `extend` contributions merged.
48+
- An object arriving with no `_packageId` is runtime-authored by definition, so
49+
it is registered under the platform's `'sys_metadata'` sentinel and stamped
50+
`_provenance: 'org'`. It therefore does **not** read as code-shipped —
51+
`getArtifactItem` / `isArtifactBacked` exclude both — and does not become
52+
un-editable. An object carrying a real `_packageId` is registered under it and
53+
keeps `_provenance: 'package'`. The stored document keeps its as-authored,
54+
unstamped shape; only the contributor copy carries registry coordinates.
55+
- `register('object', …)` can now **throw** where it previously succeeded and did
56+
nothing: claiming an object another package already owns is refused by
57+
ADR-0029. The contributor write runs first so a refusal writes nothing at all.
58+
- `unregister('object', name)` removes the object from both places. Without this
59+
the fix would have re-opened #6808 from the other side — a removal that empties
60+
only the generic map leaves `getObject`, which the data plane dispatches on,
61+
serving a deleted object for the life of the process. It refuses, per ADR-0029,
62+
an object still extended by another package.
63+
64+
No in-tree caller changes behaviour: `new MetadataFacade(...)` appears nowhere on
65+
`main` outside this package's own tests, and the `metadata` slot is filled by
66+
`MetadataManager` or `createMemoryMetadata`, both of which already round-tripped
67+
correctly.

packages/objectql/src/metadata-facade.test.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,181 @@ describe('MetadataFacade provenance passthrough', () => {
5050

5151
// getItem('object', …) routes to the merged-object path, so read the
5252
// generic collection directly to inspect what register() stored.
53+
//
54+
// [#6725] The direct read is STILL the right instrument here, and for
55+
// the same reason as before: this pin is about the STORED document, and
56+
// the two object reads answer the contributor copy — which now exists,
57+
// and which deliberately does carry the `'sys_metadata'` sentinel (see
58+
// the round-trip suite below). Reading through `get('object', …)` would
59+
// silently retarget this assertion at the other document and stop
60+
// guarding what it was written to guard.
5361
const stored = (registry as any).metadata.get('object')?.get('task');
5462
expect(stored).toBeDefined();
5563
expect(stored._packageId).toBeUndefined();
5664
expect(stored._provenance).toBeUndefined();
5765
});
66+
67+
it('keeps the stored document unstamped even when the contributor copy is stamped', async () => {
68+
// The hazard the copy in `registerObjectBothPlaces` exists for:
69+
// `applyProtection` stamps IN PLACE and `applySystemFields` returns its
70+
// input unchanged when there is nothing to inject (`systemFields: false`
71+
// takes that path), so a shared reference would leak the sentinel into
72+
// the entry the pin above guards.
73+
await facade.register('object', 'nothing_injected', {
74+
name: 'nothing_injected',
75+
label: 'No injection',
76+
fields: {},
77+
systemFields: false,
78+
});
79+
80+
const stored = (registry as any).metadata.get('object')?.get('nothing_injected');
81+
expect(stored._packageId).toBeUndefined();
82+
expect(stored._provenance).toBeUndefined();
83+
84+
// …while the contributor copy — a different document — is stamped.
85+
expect((registry.getObject('nothing_injected') as any)._packageId).toBe('sys_metadata');
86+
});
87+
});
88+
89+
/**
90+
* [#6725] The write/read pin.
91+
*
92+
* `MetadataFacade.register('object', …)` wrote through `registerItem` into the
93+
* generic `metadata` map, while every one of this class's object reads resolves
94+
* from `objectContributors` — so an object written through the public facade was
95+
* not readable back through the public facade. `IMetadataService`
96+
* (`@objectstack/spec/contracts`) declares `getObject(name)` ≡
97+
* `get('object', name)` and its own conformance test round-trips a
98+
* `register('object', …)` through both members; this file is the gate for the
99+
* facade's half of that.
100+
*
101+
* Refs #6725, #6505 / PR #6723, #6808, ADR-0010, ADR-0029.
102+
*/
103+
describe('MetadataFacade object write/read round-trip', () => {
104+
let registry: SchemaRegistry;
105+
let facade: MetadataFacade;
106+
107+
beforeEach(() => {
108+
registry = new SchemaRegistry({ multiTenant: false });
109+
facade = new MetadataFacade(registry);
110+
});
111+
112+
const taskDefinition = () => ({ name: 'task', label: 'Task', fields: {} });
113+
114+
it('reads a registered object back through BOTH getObject and get', async () => {
115+
await facade.register('object', 'task', taskDefinition());
116+
117+
const viaGetObject = await facade.getObject('task');
118+
const viaGet = await facade.get('object', 'task');
119+
120+
// Anti-vacuity: before the fix both members answered `undefined`, which
121+
// an identity assertion alone would have called agreement.
122+
expect(viaGetObject).toBeDefined();
123+
expect((viaGetObject as any).name).toBe('task');
124+
expect((viaGetObject as any).label).toBe('Task');
125+
expect(viaGetObject).toBe(viaGet);
126+
});
127+
128+
it('reads it back through the enumeration members too', async () => {
129+
await facade.register('object', 'task', taskDefinition());
130+
131+
expect(await facade.exists('object', 'task')).toBe(true);
132+
expect(await facade.listNames('object')).toEqual(['task']);
133+
expect(await facade.listObjects()).toHaveLength(1);
134+
const listed = await facade.list('object');
135+
expect(listed.map((o: any) => o.name)).toEqual(['task']);
136+
});
137+
138+
it('closes the same split for the plural `objects` spelling', async () => {
139+
// `registry.getItem` / `listItems` special-case both spellings to the
140+
// contributor path, so a write that handled only the singular left this
141+
// one broken in exactly the same way.
142+
await facade.register('objects', 'lead', { name: 'lead', label: 'Lead', fields: {} });
143+
144+
expect(await facade.getObject('lead')).toBeDefined();
145+
expect(await facade.get('objects', 'lead')).toBeDefined();
146+
expect(await facade.get('object', 'lead')).toBeDefined();
147+
});
148+
149+
it('serves the runtime-effective object, as the contract says it does', async () => {
150+
// #6505 / PR #6723: `getObject` answers the object as the engine runs
151+
// it, not the document its author wrote. The materialization seam is
152+
// `registerObject`'s, so it only runs now that the write reaches it.
153+
const multiTenantRegistry = new SchemaRegistry({ multiTenant: true });
154+
const multiTenantFacade = new MetadataFacade(multiTenantRegistry);
155+
156+
await multiTenantFacade.register('object', 'task', taskDefinition());
157+
158+
const effective = (await multiTenantFacade.getObject('task')) as any;
159+
expect(effective.fields.organization_id).toBeDefined();
160+
expect(effective.fields.created_at).toBeDefined();
161+
});
162+
163+
it('registers a package-less object under the sentinel, not as an artifact', async () => {
164+
await facade.register('object', 'task', taskDefinition());
165+
166+
const owner = registry.getObjectOwner('task');
167+
expect(owner?.packageId).toBe('sys_metadata');
168+
// ADR-0010: runtime-authored, so it must not read as code-shipped —
169+
// `getArtifactItem` is what write authorization consults.
170+
expect((registry.getObject('task') as any)._provenance).toBe('org');
171+
expect(registry.getArtifactItem('object', 'task')).toBeUndefined();
172+
});
173+
174+
it('registers a package-stamped object under its own package id', async () => {
175+
await facade.register('object', 'crm_account', {
176+
name: 'crm_account',
177+
label: 'Account',
178+
fields: {},
179+
_packageId: 'com.example.crm',
180+
});
181+
182+
expect(registry.getObjectOwner('crm_account')?.packageId).toBe('com.example.crm');
183+
const served = registry.getObject('crm_account') as any;
184+
expect(served._packageId).toBe('com.example.crm');
185+
expect(served._provenance).toBe('package');
186+
expect(registry.getArtifactItem('object', 'crm_account')).toBeDefined();
187+
});
188+
189+
it('re-registering the same object replaces it rather than accumulating owners', async () => {
190+
await facade.register('object', 'task', taskDefinition());
191+
await facade.register('object', 'task', { ...taskDefinition(), label: 'Task v2' });
192+
193+
expect(((await facade.getObject('task')) as any).label).toBe('Task v2');
194+
expect(registry.getObjectContributors('task')).toHaveLength(1);
195+
expect(await facade.listObjects()).toHaveLength(1);
196+
});
197+
198+
it('refuses to claim an object another package owns, and writes nothing', async () => {
199+
registry.registerObject({ name: 'task', label: 'Owned', fields: {} } as never, 'com.example.owner');
200+
201+
// ADR-0029 — one owner per object. The contributor write runs first
202+
// precisely so the refusal leaves the generic map untouched too.
203+
await expect(
204+
facade.register('object', 'task', { ...taskDefinition(), _packageId: 'com.example.other' }),
205+
).rejects.toThrow(/already owned by package "com.example.owner"/);
206+
207+
expect((registry as any).metadata.get('object')?.size ?? 0).toBe(0);
208+
expect(((await facade.getObject('task')) as any).label).toBe('Owned');
209+
});
210+
211+
it('unregisters an object out of BOTH places it was written into', async () => {
212+
await facade.register('object', 'task', taskDefinition());
213+
expect(await facade.getObject('task')).toBeDefined();
214+
215+
await facade.unregister('object', 'task');
216+
217+
// #6808: removing only the generic-map half left `getObject` — what the
218+
// data plane dispatches on — serving a deleted object for the life of
219+
// the process.
220+
expect(await facade.getObject('task')).toBeUndefined();
221+
expect(await facade.get('object', 'task')).toBeUndefined();
222+
expect(await facade.exists('object', 'task')).toBe(false);
223+
expect(await facade.listObjects()).toHaveLength(0);
224+
expect((registry as any).metadata.get('object')?.get('task')).toBeUndefined();
225+
});
226+
227+
it('unregistering an object nothing registered stays a no-op', async () => {
228+
await expect(facade.unregister('object', 'absent')).resolves.toBeUndefined();
229+
});
58230
});

packages/objectql/src/metadata-facade.ts

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,25 @@
22

33
import { SchemaRegistry } from './registry.js';
44

5+
/**
6+
* The two spellings of the object metadata type. `SchemaRegistry.getItem` /
7+
* `listItems` special-case BOTH to the contributor path, so a write that
8+
* handled only the singular left the plural with the same read/write split
9+
* (#6725).
10+
*/
11+
function isObjectType(type: string): boolean {
12+
return type === 'object' || type === 'objects';
13+
}
14+
15+
/**
16+
* The owning-package id used for an object registered through this facade with
17+
* no `_packageId` of its own. The platform's sentinel for "an overlay row bound
18+
* to no package" — `isArtifactBacked` (metadata-protocol) and
19+
* `SchemaRegistry.getArtifactItem` both exclude it explicitly, so it cannot
20+
* turn a runtime-authored object into an artifact-backed one.
21+
*/
22+
const RUNTIME_AUTHORED_PACKAGE_ID = 'sys_metadata';
23+
524
/**
625
* MetadataFacade
726
*
@@ -32,13 +51,90 @@ export class MetadataFacade {
3251
// definition and must not become artifact-backed (protocol.ts
3352
// isArtifactBacked gates write authorization on _packageId).
3453
const packageId = definition?._packageId;
35-
if (type === 'object') {
36-
this.registry.registerItem(type, definition, 'name' as any, packageId);
54+
if (isObjectType(type)) {
55+
this.registerObjectBothPlaces(type, definition, packageId);
3756
} else {
3857
this.registry.registerItem(type, definition, definition.id ? 'id' as any : 'name' as any, packageId);
3958
}
4059
}
4160

61+
/**
62+
* [#6725] An `object` lives in TWO places in a `SchemaRegistry`, and this
63+
* write has to reach both of them.
64+
*
65+
* `SchemaRegistry.unregisterObject`'s header states the invariant directly:
66+
* "a runtime-authored `object` is written into TWO places (`metadata['object']`
67+
* via `registerItem` and `objectContributors` via `registerObject`)". This
68+
* method used to perform only the first half, so a facade write landed
69+
* nowhere any facade read looks — `registerItem` stores into the generic
70+
* `metadata` map, while EVERY object read resolves from `objectContributors`:
71+
*
72+
* - `getObject` → `registry.getObject` → `objectContributors`
73+
* - `get('object', …)` / `exists` → `registry.getItem`, which special-cases
74+
* the object type straight back to `registry.getObject`
75+
* - `list('object')` / `listNames('object')` → `registry.listItems`, which
76+
* special-cases to `registry.getAllObjects`
77+
*
78+
* So `register('object', …)` was a silent no-op as far as this class's own
79+
* contract is concerned: `IMetadataService` (`@objectstack/spec/contracts`)
80+
* declares `getObject(name)` ≡ `get('object', name)` and its own conformance
81+
* test round-trips a `register('object', …)` through both members. Dormant
82+
* in-tree only because nothing on `main` installs a `MetadataFacade` into the
83+
* `metadata` slot — but the class is exported from this package's root and
84+
* `core` entrypoints, so a downstream host got the split.
85+
*
86+
* The shape mirrors the one in-tree precedent for the same write,
87+
* `MetadataProtocol.applyObjectRegistryMutation` (metadata-protocol), which
88+
* calls `registerItem` AND `registerObject` with `packageId || 'sys_metadata'`.
89+
* Neither half is redundant: the contributor entry is the runtime-effective
90+
* object every read resolves (post-materialization, extensions merged), the
91+
* generic-map entry is the stored document, and the contract's `getObject`
92+
* TSDoc (#6505) already tells consumers those are different things.
93+
*
94+
* ── The contributor copy is a COPY, deliberately ──
95+
*
96+
* `registerObject` runs `applyProtection`, which stamps `_packageId` /
97+
* `_provenance` **in place**, and `applySystemFields` returns its input
98+
* unchanged on the no-injection path (`sys_*`, `systemFields: false`, …).
99+
* Handing it the same reference `registerItem` stores would therefore write a
100+
* synthetic package id onto the generic-map entry — precisely what the
101+
* "never invents a synthetic package id for object registrations" pin in
102+
* `metadata-facade.test.ts` forbids, and what `isArtifactBacked` keys write
103+
* authorization off. The generic-map entry keeps its unstamped, as-authored
104+
* shape; only the contributor copy carries the registry's coordinates.
105+
*
106+
* ── Why `_provenance: 'org'` on the package-less copy ──
107+
*
108+
* `registerObject` demands a package id, and an item that arrived here with no
109+
* `_packageId` is runtime-authored by definition (see `register` above). The
110+
* `'sys_metadata'` sentinel is the id the platform already uses for exactly
111+
* that case. Stamping `'org'` alongside it is not belt-and-braces: without it
112+
* `applyProtection` would default the copy to `_provenance: 'package'` and
113+
* label a runtime-authored object a code artifact — the axis `isTenantAuthored`
114+
* (registry.ts) exists to keep straight, and the misclassification behind
115+
* cloud#970. An item that DID carry a real `_packageId` is registered under it
116+
* and keeps `'package'`, which is true of it.
117+
*
118+
* ── Ordering ──
119+
*
120+
* The contributor write goes first because it is the half that can refuse:
121+
* `registerObject` throws when another package already owns the name
122+
* (ADR-0029). Failing before `registerItem` runs means a refused registration
123+
* writes nothing at all, rather than re-opening the half-written split from
124+
* the other side.
125+
*/
126+
private registerObjectBothPlaces(type: string, definition: any, packageId: string | undefined): void {
127+
this.registry.registerObject(
128+
packageId
129+
? { ...definition }
130+
: { ...definition, _provenance: 'org' },
131+
// `||`, not `??`: an empty-string binding is "no package", the same
132+
// normalisation the protocol write path applies.
133+
packageId || RUNTIME_AUTHORED_PACKAGE_ID,
134+
);
135+
this.registry.registerItem(type, definition, 'name' as any, packageId);
136+
}
137+
42138
/**
43139
* Get a metadata item by type and name.
44140
*
@@ -70,8 +166,25 @@ export class MetadataFacade {
70166

71167
/**
72168
* Unregister a metadata item
169+
*
170+
* [#6725] An object leaves both places it was written into, for the same
171+
* reason {@link register} writes both: `unregisterItem` only empties the
172+
* generic `metadata` map, which no object read consults. Removing one half is
173+
* the exact shape of #6808 — the row was gone and `metadata['object']` was
174+
* empty while `getObject(name)` kept serving the deleted object for the life
175+
* of the process, and `getObject` is what the data plane dispatches on. Now
176+
* that the write reaches `objectContributors`, a removal that did not would
177+
* make every facade-registered object undeletable through this contract.
178+
*
179+
* `unregisterObject` is idempotent (`false` when nothing is registered under
180+
* the name) and refuses, by design, an object still extended by another
181+
* package — ADR-0029, the same judgement `unregisterObjectsByPackage`
182+
* encodes. It runs first so a refusal removes nothing at all.
73183
*/
74184
async unregister(type: string, name: string): Promise<void> {
185+
if (isObjectType(type)) {
186+
this.registry.unregisterObject(name);
187+
}
75188
this.registry.unregisterItem(type, name);
76189
}
77190

0 commit comments

Comments
 (0)