Skip to content

Commit e9cb15d

Browse files
committed
fix(metadata-protocol): boot hydration uses the shared package-scoped overlay registration (#4624)
loadMetaFromDb's non-object branch kept a THIRD inline copy of the overlay->SchemaRegistry rule with an UNSCOPED artifact lookup — the exact pre-#1828 shape: a name-colliding overlay grafted the first-registered package's _lock/_packageId/_provenance onto another package's row at boot (ADR-0048 gap). The branch now delegates to the ONE shared hydrateOverlayIntoRegistry (#4521), passing the row's own package_id, so the ADR-0048 package-scoped lookup applies at boot exactly as it does on the read-side hydration and the write-through. No other boot behaviour changes: artifacts-not-yet-loaded boot orders register the row unchanged (scoped and unscoped both find nothing), package-less rows keep the legacy best-effort graft, and the helper carries no environment gate so row selection is untouched. Pin test: two packages shipping the same-named artifact — boot hydration must graft each row's envelope from ITS OWN package (fails pre-fix with com.acme.a grafted onto com.acme.b's row). Fixes #4624 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5
1 parent 73d701f commit e9cb15d

3 files changed

Lines changed: 222 additions & 10 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): boot hydration grafts each overlay row's protection envelope from ITS OWN package (#4624)
6+
7+
`loadMetaFromDb` (boot hydration) kept a **third** inline copy of the
8+
overlay→SchemaRegistry registration rule, and its artifact lookup was
9+
**unscoped** — the exact pre-#1828 shape ADR-0048 removed from `getMetaItems`:
10+
with two installed packages shipping the same `type`/`name`, a name-colliding
11+
overlay row grafted the **first-registered** package's
12+
`_lock`/`_lockReason`/`_packageId`/`_provenance` onto another package's row at
13+
every kernel boot. A row customized under package B could come up wearing
14+
package A's identity and lock.
15+
16+
The non-object branch now delegates to the ONE shared
17+
`hydrateOverlayIntoRegistry` (introduced by #4521 for the read-side hydration
18+
and the write-through), passing the row's own `package_id` — one rule, one
19+
implementation, and the ADR-0048 package-scoped lookup applies at boot exactly
20+
as it does on read and write.
21+
22+
No other boot behaviour changes:
23+
24+
- **Boot order** — when packaged artifacts have not loaded yet at hydration
25+
time, the scoped lookup finds nothing, exactly like the unscoped one did,
26+
and the row registers unchanged.
27+
- **Package-less (global) rows**`package_id IS NULL` keeps the legacy
28+
best-effort first-match graft, identical to the read-side hydration.
29+
- **Row selection** — the helper carries no environment gate; which rows
30+
`loadMetaFromDb` loads is decided by its query, unchanged here.

packages/metadata-protocol/src/protocol.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8960,17 +8960,23 @@ export class ObjectStackProtocolImplementation implements
89608960
record.packageId || 'sys_metadata',
89618961
);
89628962
} else {
8963-
// Same envelope graft as the getMetaItems hydration:
8964-
// the plain-key entry shadows any packaged artifact,
8965-
// so carry the artifact's `_lock`/`_packageId`/
8966-
// `_provenance` along (ADR-0010 §3.3). When artifacts
8967-
// load after this hydration the merge finds nothing
8968-
// and the row registers unchanged — same as before.
8969-
const artifact = this.lookupArtifactItem(normalizedType, (data as any)?.name);
8970-
this.engine.registry.registerItem(
8963+
// Same rule as the getMetaItems read-side hydration and
8964+
// the #4521 write-through — the ONE shared
8965+
// {@link hydrateOverlayIntoRegistry}: graft the
8966+
// artifact's protection envelope (ADR-0010 §3.3) with
8967+
// the artifact lookup scoped to the row's OWN package
8968+
// (ADR-0048 / #1828 / #4624). Pre-fix this branch kept
8969+
// a third inline copy that looked the artifact up
8970+
// UNSCOPED, so a name-colliding overlay grafted the
8971+
// first-registered package's `_lock`/`_packageId`/
8972+
// `_provenance` onto another package's row at boot.
8973+
// When artifacts load after this hydration the merge
8974+
// finds nothing and the row registers unchanged — same
8975+
// as before, scoped or not.
8976+
this.hydrateOverlayIntoRegistry(
89718977
normalizedType,
8972-
mergeArtifactProtection(data, artifact) as any,
8973-
'name' as any,
8978+
data,
8979+
(record as { package_id?: string | null }).package_id ?? undefined,
89748980
);
89758981
}
89768982
loaded++;
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #4624 — boot hydration (`loadMetaFromDb`) grafts each overlay row's
5+
* protection envelope from ITS OWN package (ADR-0048 / #1828).
6+
*
7+
* Pre-fix, the non-object branch of `loadMetaFromDb` kept a third inline
8+
* copy of the overlay→SchemaRegistry rule and looked the artifact up
9+
* UNSCOPED (`lookupArtifactItem(type, name)` without the row's
10+
* `package_id`) — the exact pre-#1828 shape: with two installed packages
11+
* shipping the same `type`/`name`, a name-colliding overlay row grafted
12+
* the FIRST-registered package's `_lock`/`_packageId`/`_provenance` onto
13+
* another package's row at boot (composite-scan first-match by Map
14+
* iteration order).
15+
*
16+
* Post-fix the branch delegates to the ONE shared
17+
* `hydrateOverlayIntoRegistry` (#4521), so the ADR-0048 package-scoped
18+
* lookup applies at boot exactly as it does on the read-side hydration
19+
* and the write-through.
20+
*/
21+
22+
import { describe, it, expect } from 'vitest';
23+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
24+
import { SchemaRegistry } from './registry.js';
25+
26+
const PKG_A = 'com.acme.a';
27+
const PKG_B = 'com.acme.b';
28+
29+
function artifactPage(pkg: string, label: string) {
30+
return {
31+
name: 'home',
32+
label,
33+
_packageId: pkg,
34+
_packageVersion: '1.0.0',
35+
_provenance: 'package',
36+
_lock: 'full',
37+
_lockReason: `Locked by ${pkg}`,
38+
};
39+
}
40+
41+
interface Row {
42+
id: string;
43+
type: string;
44+
name: string;
45+
organization_id: string | null;
46+
package_id: string | null;
47+
state: string;
48+
metadata: string;
49+
}
50+
51+
function makeEngine(registry: SchemaRegistry, rows: Row[]) {
52+
const matches = (r: Row, where: Record<string, unknown>): boolean => {
53+
for (const [k, v] of Object.entries(where)) {
54+
if (v === undefined) continue;
55+
if ((r as any)[k] !== v) return false;
56+
}
57+
return true;
58+
};
59+
const engine: any = {
60+
registry,
61+
async find(_t: string, opts: { where: Record<string, unknown> }) {
62+
return rows.filter((r) => matches(r, opts.where));
63+
},
64+
async findOne(_t: string, opts: { where: Record<string, unknown> }) {
65+
return rows.find((r) => matches(r, opts.where)) ?? null;
66+
},
67+
async insert() { return { id: 'x' }; },
68+
async update() { return { id: 'x' }; },
69+
async delete() { return { deleted: 0 }; },
70+
};
71+
return engine;
72+
}
73+
74+
function overlayRow(partial: Partial<Row> & { name: string; metadata: unknown }): Row {
75+
return {
76+
id: `r_${partial.name}_${partial.package_id ?? 'global'}`,
77+
type: 'page',
78+
organization_id: null,
79+
package_id: null,
80+
state: 'active',
81+
...partial,
82+
metadata: typeof partial.metadata === 'string'
83+
? partial.metadata
84+
: JSON.stringify(partial.metadata),
85+
} as Row;
86+
}
87+
88+
describe('loadMetaFromDb — ADR-0048 package-scoped protection graft at boot (#4624)', () => {
89+
it('grafts the envelope from the row\'s OWN package, not the first-registered one', async () => {
90+
const registry = new SchemaRegistry({ multiTenant: false });
91+
registry.logLevel = 'silent';
92+
// Package A registers FIRST — pre-fix, the unscoped composite scan
93+
// returned A for every same-named row, whatever package owned it.
94+
registry.registerItem('page', artifactPage(PKG_A, 'A Home'), 'name', PKG_A);
95+
registry.registerItem('page', artifactPage(PKG_B, 'B Home'), 'name', PKG_B);
96+
97+
const rows = [
98+
overlayRow({
99+
name: 'home',
100+
package_id: PKG_B,
101+
metadata: { name: 'home', label: 'B Home (customized)' },
102+
}),
103+
];
104+
const engine = makeEngine(registry, rows);
105+
const protocol = new ObjectStackProtocolImplementation(engine);
106+
107+
const res = await protocol.loadMetaFromDb();
108+
expect(res.loaded).toBe(1);
109+
expect(res.errors).toBe(0);
110+
111+
// The hydrated plain-key entry carries package B's envelope —
112+
// pre-fix it carried PKG_A's (`_packageId: 'com.acme.a'`,
113+
// `_lockReason: 'Locked by com.acme.a'`).
114+
const direct: any = registry.getItem('page', 'home');
115+
expect(direct.label).toBe('B Home (customized)'); // overlay content wins
116+
expect(direct._packageId).toBe(PKG_B);
117+
expect(direct._lock).toBe('full');
118+
expect(direct._lockReason).toBe(`Locked by ${PKG_B}`);
119+
expect(direct._provenance).toBe('package');
120+
});
121+
122+
it('registers the row unchanged when artifacts have not loaded yet (boot-order no-op)', async () => {
123+
// Empty registry at hydration time — the scoped lookup finds
124+
// nothing, exactly like the unscoped one did, and the row
125+
// registers without a grafted envelope. Artifact-after-hydration
126+
// boot orders are unaffected by the scoping.
127+
const registry = new SchemaRegistry({ multiTenant: false });
128+
registry.logLevel = 'silent';
129+
const rows = [
130+
overlayRow({
131+
name: 'home',
132+
package_id: PKG_B,
133+
metadata: { name: 'home', label: 'B Home (customized)' },
134+
}),
135+
];
136+
const engine = makeEngine(registry, rows);
137+
const protocol = new ObjectStackProtocolImplementation(engine);
138+
139+
const res = await protocol.loadMetaFromDb();
140+
expect(res.loaded).toBe(1);
141+
142+
const direct: any = registry.getItem('page', 'home');
143+
expect(direct.label).toBe('B Home (customized)');
144+
expect(direct._lock).toBeUndefined();
145+
expect(direct._packageId).toBeUndefined();
146+
expect(direct._provenance).toBeUndefined();
147+
});
148+
149+
it('keeps the legacy best-effort graft for package-less (global) rows', async () => {
150+
// A row with no package binding keeps the pre-existing unscoped
151+
// first-match semantics — identical to the read-side hydration.
152+
const registry = new SchemaRegistry({ multiTenant: false });
153+
registry.logLevel = 'silent';
154+
registry.registerItem('page', artifactPage(PKG_A, 'A Home'), 'name', PKG_A);
155+
registry.registerItem('page', artifactPage(PKG_B, 'B Home'), 'name', PKG_B);
156+
157+
const rows = [
158+
overlayRow({
159+
name: 'home',
160+
package_id: null,
161+
metadata: { name: 'home', label: 'Global overlay' },
162+
}),
163+
];
164+
const engine = makeEngine(registry, rows);
165+
const protocol = new ObjectStackProtocolImplementation(engine);
166+
167+
await protocol.loadMetaFromDb();
168+
169+
const direct: any = registry.getItem('page', 'home');
170+
expect(direct.label).toBe('Global overlay');
171+
// Best-effort first-match: SOME package's envelope is grafted
172+
// (legacy behaviour, unchanged by #4624 — do not over-pin which).
173+
expect([PKG_A, PKG_B]).toContain(direct._packageId);
174+
expect(direct._lock).toBe('full');
175+
});
176+
});

0 commit comments

Comments
 (0)