Skip to content

Commit fe3a1cd

Browse files
os-zhuangclaude
andcommitted
feat(objectql): ADR-0048 Phase 1+2 — namespace install gate + prefer-local resolution
Phase 1 — install-time namespace gate. `SchemaRegistry.installPackage` refuses a package whose `manifest.namespace` is already owned by a DIFFERENT installed package (new `NamespaceConflictError`), making explicit and early the constraint the object/table layer already enforces implicitly (a duplicate `CREATE TABLE <ns>_<obj>` fails at the DB). Same-package reinstall and shareable platform namespaces (base/system/sys) are exempt; OS_METADATA_COLLISION=warn downgrades to a warning. Phase 2 — prefer-local (container-scoped) resolution. `getItem(type, name, ns?)` resolves a bare name to the item owned by `ns`'s package before any cross-package fallback, preserving ADR-0005 overlay precedence and remaining backward compatible (param optional). `getApp` resolves prefer-local against its own name (app.name ≡ namespace in v1). The per-item collision guard now narrows to the cases prefer-local CANNOT disambiguate (shared/missing namespace), so two DIFFERENT-namespace packages legitimately coexist on the same bare name — the marketplace coexistence the revised ADR-0048 targets. Every existing collision test still passes (namespace-less fixtures remain a hard error). Tests: registry-namespace-install-gate.test.ts (7), registry-prefer-local- resolution.test.ts (7). Full objectql suite green (600). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 61bdba1 commit fe3a1cd

3 files changed

Lines changed: 305 additions & 6 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0048 Phase 1 — install-time namespace gate.
5+
*
6+
* A package's `manifest.namespace` is the mandatory object-name prefix and the
7+
* container that scopes its UI metadata, so it must be unique per installation.
8+
* `installPackage` refuses a package whose namespace is already owned by a
9+
* *different* installed package. Same-package reinstall and shareable platform
10+
* namespaces (`base`/`system`/`sys`) pass through; `OS_METADATA_COLLISION=warn`
11+
* downgrades the refusal to a warning.
12+
*/
13+
14+
import { describe, it, expect, beforeEach } from 'vitest';
15+
import { SchemaRegistry, NamespaceConflictError } from './registry';
16+
17+
const manifest = (id: string, namespace: string) => ({
18+
id,
19+
name: id,
20+
namespace,
21+
version: '1.0.0',
22+
});
23+
24+
describe('SchemaRegistry — namespace install gate (ADR-0048 Phase 1)', () => {
25+
let registry: SchemaRegistry;
26+
27+
beforeEach(() => {
28+
registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' });
29+
registry.logLevel = 'silent';
30+
});
31+
32+
it('refuses a package whose namespace is already owned by a different package', () => {
33+
registry.installPackage(manifest('com.acme.crm', 'crm') as any);
34+
expect(() =>
35+
registry.installPackage(manifest('com.beta.crm', 'crm') as any),
36+
).toThrowError(NamespaceConflictError);
37+
});
38+
39+
it('error names both packages and the namespace', () => {
40+
registry.installPackage(manifest('com.acme.crm', 'crm') as any);
41+
try {
42+
registry.installPackage(manifest('com.beta.crm', 'crm') as any);
43+
throw new Error('expected a namespace conflict error');
44+
} catch (e) {
45+
expect(e).toBeInstanceOf(NamespaceConflictError);
46+
const err = e as NamespaceConflictError;
47+
expect(err.namespace).toBe('crm');
48+
expect(err.existingPackageId).toBe('com.acme.crm');
49+
expect(err.incomingPackageId).toBe('com.beta.crm');
50+
expect(err.message).toContain('com.acme.crm');
51+
expect(err.message).toContain('com.beta.crm');
52+
expect(err.message).toContain('crm');
53+
}
54+
// The conflicting package must NOT have been recorded.
55+
expect(registry.getPackage('com.beta.crm')).toBeUndefined();
56+
expect(registry.getNamespaceOwners('crm')).toEqual(['com.acme.crm']);
57+
});
58+
59+
it('allows the same package to reinstall/reload its own namespace', () => {
60+
registry.installPackage(manifest('com.acme.crm', 'crm') as any);
61+
expect(() =>
62+
registry.installPackage(manifest('com.acme.crm', 'crm') as any),
63+
).not.toThrow();
64+
});
65+
66+
it('allows two packages with distinct namespaces', () => {
67+
registry.installPackage(manifest('com.acme.crm', 'crm') as any);
68+
expect(() =>
69+
registry.installPackage(manifest('com.acme.hr', 'hr') as any),
70+
).not.toThrow();
71+
});
72+
73+
it('exempts shareable platform namespaces (base/system/sys)', () => {
74+
for (const ns of ['base', 'system', 'sys']) {
75+
registry.installPackage(manifest(`com.a.${ns}`, ns) as any);
76+
expect(() =>
77+
registry.installPackage(manifest(`com.b.${ns}`, ns) as any),
78+
).not.toThrow();
79+
}
80+
});
81+
82+
it('downgrades to a warning under collisionPolicy "warn"', () => {
83+
const warnReg = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'warn' });
84+
warnReg.logLevel = 'silent';
85+
warnReg.installPackage(manifest('com.acme.crm', 'crm') as any);
86+
expect(() =>
87+
warnReg.installPackage(manifest('com.beta.crm', 'crm') as any),
88+
).not.toThrow();
89+
// Both packages are recorded; the namespace now has two owners.
90+
expect(warnReg.getNamespaceOwners('crm').sort()).toEqual(['com.acme.crm', 'com.beta.crm']);
91+
});
92+
93+
it('releases the namespace on uninstall, allowing a different package to claim it', () => {
94+
registry.installPackage(manifest('com.acme.crm', 'crm') as any);
95+
registry.uninstallPackage('com.acme.crm');
96+
expect(() =>
97+
registry.installPackage(manifest('com.beta.crm', 'crm') as any),
98+
).not.toThrow();
99+
expect(registry.getNamespaceOwners('crm')).toEqual(['com.beta.crm']);
100+
});
101+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0048 Phase 2 — prefer-local (container-scoped) resolution.
5+
*
6+
* With the install-time namespace gate (Phase 1) keeping namespaces distinct,
7+
* two packages may legitimately ship the same bare name (e.g. `page/home`).
8+
* They no longer collide at registration; instead `getItem(type, name, ns)`
9+
* routes each caller to the item owned by its own namespace's package. The
10+
* per-item collision guard now fires only where prefer-local CANNOT
11+
* disambiguate (shared / missing namespace).
12+
*/
13+
14+
import { describe, it, expect, beforeEach } from 'vitest';
15+
import { SchemaRegistry } from './registry';
16+
17+
const install = (reg: SchemaRegistry, id: string, namespace: string) =>
18+
reg.installPackage({ id, name: id, namespace, version: '1.0.0' } as any);
19+
20+
describe('SchemaRegistry — prefer-local resolution (ADR-0048 Phase 2)', () => {
21+
let registry: SchemaRegistry;
22+
23+
beforeEach(() => {
24+
registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' });
25+
registry.logLevel = 'silent';
26+
install(registry, 'com.acme.crm', 'crm');
27+
install(registry, 'com.acme.hr', 'hr');
28+
});
29+
30+
it('lets two distinct-namespace packages coexist on the same bare name', () => {
31+
registry.registerItem('page', { name: 'home', title: 'CRM Home' }, 'name', 'com.acme.crm');
32+
expect(() =>
33+
registry.registerItem('page', { name: 'home', title: 'HR Home' }, 'name', 'com.acme.hr'),
34+
).not.toThrow();
35+
});
36+
37+
it('resolves prefer-local to the namespace owner', () => {
38+
registry.registerItem('page', { name: 'home', title: 'CRM Home' }, 'name', 'com.acme.crm');
39+
registry.registerItem('page', { name: 'home', title: 'HR Home' }, 'name', 'com.acme.hr');
40+
41+
expect(registry.getItem<any>('page', 'home', 'crm')?.title).toBe('CRM Home');
42+
expect(registry.getItem<any>('page', 'home', 'hr')?.title).toBe('HR Home');
43+
});
44+
45+
it('context-free getItem still returns one of the entries (legacy first-match fallback)', () => {
46+
registry.registerItem('page', { name: 'home', title: 'CRM Home' }, 'name', 'com.acme.crm');
47+
registry.registerItem('page', { name: 'home', title: 'HR Home' }, 'name', 'com.acme.hr');
48+
49+
const got = registry.getItem<any>('page', 'home');
50+
expect(['CRM Home', 'HR Home']).toContain(got?.title);
51+
});
52+
53+
it('keeps runtime/DB overlay (bare key) precedence over prefer-local (ADR-0005)', () => {
54+
registry.registerItem('page', { name: 'home', title: 'CRM Home' }, 'name', 'com.acme.crm');
55+
// Runtime-authored overlay under the bare key (no package provenance).
56+
registry.registerItem('page', { name: 'home', title: 'overlay' }, 'name');
57+
58+
expect(registry.getItem<any>('page', 'home', 'crm')?.title).toBe('overlay');
59+
});
60+
61+
it('falls back to first-match when the namespace owns no such item', () => {
62+
registry.registerItem('page', { name: 'home', title: 'CRM Home' }, 'name', 'com.acme.crm');
63+
// hr has no `home`; asking within hr's container falls back to the only entry.
64+
expect(registry.getItem<any>('page', 'home', 'hr')?.title).toBe('CRM Home');
65+
});
66+
67+
it('still fails loudly when two packages SHARE a namespace (unresolvable)', () => {
68+
const reg = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' });
69+
reg.logLevel = 'silent';
70+
// `sys` is a shareable platform namespace, exempt from the install gate, so
71+
// two packages CAN both own it — but then a same-named item is ambiguous.
72+
install(reg, 'com.a.sys', 'sys');
73+
install(reg, 'com.b.sys', 'sys');
74+
reg.registerItem('flow', { name: 'cleanup' }, 'name', 'com.a.sys');
75+
expect(() =>
76+
reg.registerItem('flow', { name: 'cleanup' }, 'name', 'com.b.sys'),
77+
).toThrow();
78+
});
79+
});

packages/objectql/src/registry.ts

Lines changed: 125 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,17 @@ function isRealPackage(pkg: unknown): pkg is string {
317317
return typeof pkg === 'string' && pkg.length > 0 && pkg !== SYS_METADATA_OWNER;
318318
}
319319

320+
/**
321+
* Platform namespaces that multiple packages may legitimately share, so the
322+
* install-time namespace-uniqueness gate (ADR-0048 Phase 1) must never fire on
323+
* them: the FQN-exempt reserved namespaces (`base`, `system`) plus `sys`
324+
* (system objects such as `sys_metadata` are contributed by many packages — see
325+
* {@link SchemaRegistry.registerNamespace}, which is intentionally many-to-one).
326+
*/
327+
function isShareableNamespace(ns: string): boolean {
328+
return RESERVED_NAMESPACES.has(ns) || ns === 'sys';
329+
}
330+
320331
/**
321332
* Raised when two **different** code packages register a generic (non-object)
322333
* metadata item under the same `(type, name)` in the code-defined base layer
@@ -357,6 +368,43 @@ export class MetadataCollisionError extends Error {
357368
}
358369
}
359370

371+
/**
372+
* Raised when a package is installed whose `manifest.namespace` is already owned
373+
* by a **different** installed package in this installation (ADR-0048 Phase 1).
374+
*
375+
* The namespace is the mandatory object-name prefix (`${namespace}_${shortName}`)
376+
* and — once installed — the container that scopes a package's UI/automation
377+
* metadata. Two packages sharing a namespace would collide at the object/table
378+
* layer (a duplicate `CREATE TABLE crm_account` already fails loudly at the DB)
379+
* and would make container-scoped resolution ambiguous. This gate refuses the
380+
* install up front with an actionable error, instead of letting a half-applied
381+
* install blow up later at table creation. Shareable platform namespaces
382+
* (`base`/`system`/`sys`) are exempt.
383+
*/
384+
export class NamespaceConflictError extends Error {
385+
readonly namespace: string;
386+
readonly existingPackageId: string;
387+
readonly incomingPackageId: string;
388+
389+
constructor(namespace: string, existingPackageId: string, incomingPackageId: string) {
390+
super(
391+
`Namespace conflict: namespace "${namespace}" is already owned by ` +
392+
`package "${existingPackageId}", so package "${incomingPackageId}" ` +
393+
`cannot be installed alongside it. A namespace is the mandatory prefix ` +
394+
`of every object name (e.g. "${namespace}_account") and the container ` +
395+
`that scopes a package's UI metadata, so it must be unique per ` +
396+
`installation. Choose a different namespace for "${incomingPackageId}", ` +
397+
`or uninstall "${existingPackageId}" first. If this is a deliberate ` +
398+
`migration, set OS_METADATA_COLLISION=warn to downgrade to a warning. ` +
399+
`See ADR-0048.`,
400+
);
401+
this.name = 'NamespaceConflictError';
402+
this.namespace = namespace;
403+
this.existingPackageId = existingPackageId;
404+
this.incomingPackageId = incomingPackageId;
405+
}
406+
}
407+
360408
export class SchemaRegistry {
361409
// ==========================================
362410
// Logging control
@@ -837,7 +885,15 @@ export class SchemaRegistry {
837885
// artifact-vs-DB warning below.
838886
if (isRealPackage(packageId)) {
839887
const conflictOwner = this.findOtherPackageOwner(collection, baseName, packageId);
840-
if (conflictOwner) {
888+
if (conflictOwner && !this.isPreferLocalDisambiguable(packageId, conflictOwner)) {
889+
// ADR-0048 Phase 2 — the guard now only fires when prefer-local
890+
// resolution (see getItem) CANNOT disambiguate the two owners: they
891+
// share a namespace (possible only for shareable platform namespaces
892+
// like `sys`, which the install gate exempts) or one lacks a namespace
893+
// (legacy package with no container to scope by). Two packages in
894+
// DIFFERENT namespaces legitimately coexist on the same bare name —
895+
// the install-time namespace gate keeps their namespaces distinct and
896+
// prefer-local routes each caller to its own container.
841897
const err = new MetadataCollisionError(type, baseName, conflictOwner, packageId);
842898
if (this.collisionPolicy === 'warn') {
843899
console.warn(`[Registry] ${err.message}`);
@@ -894,6 +950,21 @@ export class SchemaRegistry {
894950
return undefined;
895951
}
896952

953+
/**
954+
* True when prefer-local resolution ({@link getItem}) can route callers in
955+
* each owner's container to the right item — i.e. the two packages declare
956+
* **distinct, non-empty namespaces** (ADR-0048 Phase 2). When it returns
957+
* false (shared namespace, or a missing namespace on either side) a same
958+
* `(type, name)` clash is genuinely unresolvable and the collision guard
959+
* fires. Namespaces are read from the installed-package records, which the
960+
* install path registers before a package's metadata is loaded.
961+
*/
962+
private isPreferLocalDisambiguable(incoming: string, owner: string): boolean {
963+
const incomingNs = this.getPackage(incoming)?.manifest?.namespace;
964+
const ownerNs = this.getPackage(owner)?.manifest?.namespace;
965+
return !!incomingNs && !!ownerNs && incomingNs !== ownerNs;
966+
}
967+
897968
/**
898969
* Validate Metadata against Spec Zod Schemas
899970
*/
@@ -939,19 +1010,40 @@ export class SchemaRegistry {
9391010
}
9401011

9411012
/**
942-
* Universal Get Method
1013+
* Universal Get Method.
1014+
*
1015+
* ADR-0048 Phase 2 — *prefer-local* resolution. When `currentNamespace` is
1016+
* given (the container the caller is resolving within), a bare name resolves
1017+
* to the item owned by *that namespace's* package before any cross-package
1018+
* fallback, so two packages shipping e.g. `page/home` no longer resolve by
1019+
* registration order (first-match-wins). Omitting `currentNamespace`
1020+
* preserves the legacy resolution exactly, so this is backward compatible.
1021+
*
1022+
* Precedence (highest first):
1023+
* 1. bare-key runtime/DB overlay (ADR-0005 sanctioned override) — unchanged
1024+
* 2. the `currentNamespace` owner's composite entry (prefer-local)
1025+
* 3. first composite match (legacy first-registered-wins fallback)
9431026
*/
944-
getItem<T>(type: string, name: string): T | undefined {
1027+
getItem<T>(type: string, name: string, currentNamespace?: string): T | undefined {
9451028
// Special handling for 'object' and 'objects' types - use objectContributors
9461029
if (type === 'object' || type === 'objects') {
9471030
return this.getObject(name) as unknown as T | undefined;
9481031
}
949-
1032+
9501033
const collection = this.metadata.get(type);
9511034
if (!collection) return undefined;
9521035
const direct = collection.get(name);
9531036
if (direct) return direct as T;
954-
// Scan for composite keys
1037+
1038+
// Prefer-local: resolve within the caller's container (namespace) first.
1039+
if (currentNamespace) {
1040+
for (const owner of this.getNamespaceOwners(currentNamespace)) {
1041+
const local = collection.get(`${owner}:${name}`);
1042+
if (local) return local as T;
1043+
}
1044+
}
1045+
1046+
// Fallback: first composite key matching the bare name (legacy behaviour).
9551047
for (const [key, item] of collection) {
9561048
if (key.endsWith(`:${name}`)) {
9571049
return item as T;
@@ -1080,6 +1172,30 @@ export class SchemaRegistry {
10801172
// ==========================================
10811173

10821174
installPackage(manifest: ObjectStackManifest, settings?: Record<string, any>): InstalledPackage {
1175+
// ADR-0048 Phase 1 — install-time namespace gate. Refuse a package whose
1176+
// namespace is already owned by a *different* installed package; this is
1177+
// the constraint the object/table layer enforces implicitly (a duplicate
1178+
// `CREATE TABLE <ns>_<obj>` fails at the DB), made explicit and early.
1179+
// Same-package reinstall/reload is excluded (owner === manifest.id), and
1180+
// shareable platform namespaces (base/system/sys) are exempt.
1181+
if (manifest.namespace && !isShareableNamespace(manifest.namespace)) {
1182+
const conflictOwner = this.getNamespaceOwners(manifest.namespace).find(
1183+
(owner) => owner !== manifest.id,
1184+
);
1185+
if (conflictOwner) {
1186+
if (this.collisionPolicy === 'warn') {
1187+
console.warn(
1188+
`[Registry] Namespace conflict (downgraded to warning via ` +
1189+
`OS_METADATA_COLLISION=warn): namespace "${manifest.namespace}" is ` +
1190+
`already owned by "${conflictOwner}"; installing "${manifest.id}" ` +
1191+
`anyway. See ADR-0048.`,
1192+
);
1193+
} else {
1194+
throw new NamespaceConflictError(manifest.namespace, conflictOwner, manifest.id);
1195+
}
1196+
}
1197+
}
1198+
10831199
const now = new Date().toISOString();
10841200
const disabled = this.initialDisabledPackageIds.has(manifest.id);
10851201
const pkg: InstalledPackage = {
@@ -1175,7 +1291,10 @@ export class SchemaRegistry {
11751291
}
11761292

11771293
getApp(name: string): any {
1178-
const app = this.getItem('app', name);
1294+
// ADR-0048 (v1) — one app per package, with `app.name ≡ manifest.namespace`,
1295+
// so the app name *is* its own container: resolve prefer-local against it so
1296+
// two packages' apps never resolve by registration order.
1297+
const app = this.getItem('app', name, name);
11791298
if (!app) return app;
11801299
return this.applyNavContributions(app);
11811300
}

0 commit comments

Comments
 (0)