Skip to content

Commit fef38ec

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(metadata): package-scoped customization overlays (ADR-0048 #1824) (#1827)
Key sys_metadata overlays by (type, name, organization_id, package_id) so two installed packages shipping the same name each carry their own overlay, instead of one physically shadowing both. - Index idx_sys_metadata_overlay_active/_draft include package_id; runtime migration uses COALESCE(package_id,'') so package-less globals stay unique. - SysMetadataRepository.whereFor/put/get scope the upsert per package (a save for B no longer overwrites A's same-name overlay; package-less → global row). - getMetaItem/getMetaItemLayered overlay fallback resolves only the global (package_id IS NULL) row on a scoped miss, never another package's row. Verified live (two packages each shipping page/showcase_task_workbench): two overlay rows coexist; ?package= single reads + ?layers= editor view each return their own overlay; index migrated in place. objectql 606 green. Follow-up: unscoped list still dedupes by bare name (collapses overlay collisions); per-package single-item + editor paths unaffected. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 22b7c26 commit fef38ec

5 files changed

Lines changed: 138 additions & 25 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/metadata-core": minor
4+
---
5+
6+
feat(metadata): package-scoped customization overlays (ADR-0048 #1824)
7+
8+
A `sys_metadata` customization overlay is now keyed by `(type, name,
9+
organization_id, package_id)`, so two installed packages shipping an item of the
10+
same `type`/`name` can each carry their **own** overlay. Previously the overlay
11+
uniqueness key was `(type, name, organization_id)` — physically one row per
12+
name — so customizing one package's item shadowed both, and a package-scoped
13+
read fell back to whichever row existed.
14+
15+
- **Index**: `idx_sys_metadata_overlay_active` / `…_draft` now include
16+
`package_id`. The runtime migration (`ensureOverlayIndex`) uses
17+
`COALESCE(package_id, '')` so package-less (global) overlays stay unique among
18+
themselves (a plain unique index treats NULLs as distinct). DROP-then-CREATE,
19+
idempotent; existing rows migrate safely (the old key already guaranteed one
20+
row per `(type, name, org)`).
21+
- **Write**: `SysMetadataRepository.whereFor`/`put`/`get` scope the upsert to the
22+
requested package, so a save bound to package B no longer finds and overwrites
23+
package A's same-name overlay. A package-less save (`packageId` null) targets
24+
the global row.
25+
- **Read**: `getMetaItem` / `getMetaItemLayered` overlay lookups already prefer
26+
the package-scoped row; the fallback now resolves only the **global**
27+
(`package_id IS NULL`) overlay, never a *different* package's row. Package-less
28+
readers are unchanged (match-any, back-compat).
29+
30+
Verified live against a real collision (two packages each shipping
31+
`page/showcase_task_workbench`): two overlay rows coexist, and `?package=` single
32+
reads + the `?layers=true` Studio editor view each return that package's own
33+
overlay; the unique index migrated in place.
34+
35+
Known follow-up: the *unscoped list* (`GET /meta/:type` with no `?package=`)
36+
still dedupes by bare name, so when two packages both carry an overlay on the
37+
same name the list collapses them — the per-package single-item and editor paths
38+
are unaffected. Tracked for the list-dedup-by-name work.

packages/metadata-core/src/objects/sys-metadata.object.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -199,15 +199,20 @@ export const SysMetadataObject = ObjectSchema.create({
199199
},
200200

201201
indexes: [
202-
// ADR-0005 (revised 2026-05): overlay uniqueness is scoped by
203-
// (type, name, organization_id), restricted to active rows so resets
204-
// / archived versions don't collide. environment_id is deprecated and
205-
// not part of the discriminator. The runtime layer (protocol.ts
206-
// ensureOverlayIndex) issues a DROP-then-CREATE migration to
207-
// replace any pre-existing legacy composite index in-place.
202+
// ADR-0005 (revised 2026-05) + ADR-0048: overlay uniqueness is scoped by
203+
// (type, name, organization_id, package_id), restricted to active rows so
204+
// resets / archived versions don't collide. `package_id` is part of the
205+
// discriminator so two installed packages shipping the same `type`/`name`
206+
// each get their OWN customization row (a package-less / global overlay
207+
// uses NULL). environment_id is deprecated and not part of the
208+
// discriminator. The runtime layer (protocol.ts ensureOverlayIndex) issues
209+
// a DROP-then-CREATE migration that uses `COALESCE(package_id,'')` so the
210+
// package-less rows stay unique among themselves (SQLite treats NULLs as
211+
// distinct in a plain unique index); this declaration is the fallback shape
212+
// for drivers without the runtime migration.
208213
{
209214
name: 'idx_sys_metadata_overlay_active',
210-
fields: ['type', 'name', 'organization_id'],
215+
fields: ['type', 'name', 'organization_id', 'package_id'],
211216
unique: true,
212217
partial: "state = 'active'",
213218
},

packages/objectql/src/protocol-meta.test.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
6060
organizationId: 'org_alpha',
6161
});
6262
expect(mockEngine.findOne).toHaveBeenCalledWith('sys_metadata', {
63-
where: { type: 'app', name: 'test_app', organization_id: 'org_alpha', state: 'active' },
63+
// ADR-0048 — a package-less save scopes the upsert lookup to the
64+
// GLOBAL row (package_id IS NULL), not any package's row.
65+
where: { type: 'app', name: 'test_app', organization_id: 'org_alpha', state: 'active', package_id: null },
6466
});
6567
expect(mockEngine.insert).toHaveBeenCalledWith('sys_metadata', expect.objectContaining({
6668
organization_id: 'org_alpha',
@@ -241,7 +243,8 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
241243
await protocol.saveMetaItem({ type: 'app', name: 'test_app', item: sampleApp });
242244

243245
expect(mockEngine.findOne).toHaveBeenCalledWith('sys_metadata', {
244-
where: { type: 'app', name: 'test_app', organization_id: null, state: 'active' }
246+
// ADR-0048 — package-less save scopes the lookup to the global row.
247+
where: { type: 'app', name: 'test_app', organization_id: null, state: 'active', package_id: null }
245248
});
246249
expect(mockEngine.insert).toHaveBeenCalledWith('sys_metadata', expect.objectContaining({
247250
name: 'test_app',
@@ -252,6 +255,25 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
252255
}), expect.anything());
253256
});
254257

258+
it('scopes the upsert lookup to the requested package (ADR-0048 #1824)', async () => {
259+
// A save bound to package B must look up B's own row, not match (and
260+
// overwrite) package A's same-name overlay — that is what lets two
261+
// installed packages keep independent customizations.
262+
mockEngine.findOne.mockResolvedValue(null);
263+
264+
await protocol.saveMetaItem({
265+
type: 'app', name: 'test_app', item: sampleApp,
266+
organizationId: 'org_alpha', packageId: 'com.acme.beta',
267+
});
268+
269+
expect(mockEngine.findOne).toHaveBeenCalledWith('sys_metadata', {
270+
where: { type: 'app', name: 'test_app', organization_id: 'org_alpha', state: 'active', package_id: 'com.acme.beta' },
271+
});
272+
expect(mockEngine.insert).toHaveBeenCalledWith('sys_metadata', expect.objectContaining({
273+
package_id: 'com.acme.beta',
274+
}), expect.anything());
275+
});
276+
255277
it('should update an existing record in the database and increment version', async () => {
256278
const existingRecord = { id: 'existing-uuid', version: 2 };
257279
mockEngine.findOne.mockResolvedValue(existingRecord);

packages/objectql/src/protocol.ts

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -750,20 +750,25 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
750750
throw new Error('driver has neither raw nor execute');
751751
}
752752
};
753-
// ADR-0005 (revised 2026-05): per-env DBs replace the old
753+
// ADR-0005 (revised 2026-05) + ADR-0048: per-env DBs replace the old
754754
// "per-project" isolation, so `environment_id` is no longer a
755755
// discriminator. Overlay uniqueness is `(type, name,
756-
// organization_id)` filtered to active rows. Drop the legacy
757-
// composite index first so the new partial UNIQUE can claim
758-
// the same name — DROP INDEX IF EXISTS is idempotent.
756+
// organization_id, COALESCE(package_id,''))` filtered to active
757+
// rows — `package_id` is in the key so two installed packages
758+
// shipping the same name each get their own overlay, while
759+
// `COALESCE(...,'')` keeps the package-less (global) rows unique
760+
// among themselves (a plain unique index would treat NULLs as
761+
// distinct and allow duplicate globals). Drop the legacy composite
762+
// index first so the new partial UNIQUE can claim the same name —
763+
// DROP INDEX IF EXISTS is idempotent.
759764
try { await exec("DROP INDEX IF EXISTS idx_sys_metadata_overlay_active"); } catch { /* best-effort */ }
760765
const partialSql =
761766
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sys_metadata_overlay_active " +
762-
"ON sys_metadata (type, name, organization_id) " +
767+
"ON sys_metadata (type, name, organization_id, COALESCE(package_id, '')) " +
763768
"WHERE state = 'active'";
764769
const fallbackSql =
765770
"CREATE INDEX IF NOT EXISTS idx_sys_metadata_overlay_active " +
766-
"ON sys_metadata (type, name, organization_id)";
771+
"ON sys_metadata (type, name, organization_id, package_id)";
767772
try {
768773
await exec(partialSql);
769774
} catch (err: any) {
@@ -779,12 +784,14 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
779784
}
780785
// Mirror the same partial-UNIQUE for draft rows so a second
781786
// simultaneous draft cannot be inserted for the same
782-
// (type,name,org). The unique-active index above already
787+
// (type,name,org,package). The unique-active index above already
783788
// guards published rows; the two never collide because the
784-
// `state` predicate disambiguates them.
789+
// `state` predicate disambiguates them. DROP first so an existing
790+
// legacy 3-column draft index is replaced in-place (ADR-0048).
791+
try { await exec("DROP INDEX IF EXISTS idx_sys_metadata_overlay_draft"); } catch { /* best-effort */ }
785792
const draftPartialSql =
786793
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sys_metadata_overlay_draft " +
787-
"ON sys_metadata (type, name, organization_id) " +
794+
"ON sys_metadata (type, name, organization_id, COALESCE(package_id, '')) " +
788795
"WHERE state = 'draft'";
789796
try {
790797
await exec(draftPartialSql);
@@ -794,7 +801,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
794801
try {
795802
await exec(
796803
"CREATE INDEX IF NOT EXISTS idx_sys_metadata_overlay_draft " +
797-
"ON sys_metadata (type, name, organization_id)",
804+
"ON sys_metadata (type, name, organization_id, package_id)",
798805
);
799806
} catch {
800807
// ignore — best effort
@@ -1428,6 +1435,11 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
14281435
where: { ...base, package_id: request.packageId },
14291436
});
14301437
if (scoped) return scoped;
1438+
// ADR-0048 — global (package-less) draft only, never
1439+
// another package's draft.
1440+
return await this.engine.findOne('sys_metadata', {
1441+
where: { ...base, package_id: null },
1442+
});
14311443
}
14321444
return await this.engine.findOne('sys_metadata', { where: base });
14331445
};
@@ -1476,7 +1488,15 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
14761488
where: { ...base, package_id: request.packageId },
14771489
});
14781490
if (scoped) return scoped;
1491+
// ADR-0048 — no package-owned overlay; fall back to the
1492+
// GLOBAL (package-less) overlay only. Must NOT match a
1493+
// different package's row, or a collision would serve
1494+
// package B's customization for a package A read.
1495+
return await this.engine.findOne('sys_metadata', {
1496+
where: { ...base, package_id: null },
1497+
});
14791498
}
1499+
// No package context (legacy/runtime reader) — match any.
14801500
return await this.engine.findOne('sys_metadata', { where: base });
14811501
};
14821502
const rec = await lookup(request.type);
@@ -1748,6 +1768,11 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
17481768
where: { ...base, package_id: request.packageId },
17491769
});
17501770
if (scoped) return scoped;
1771+
// ADR-0048 — fall back to the GLOBAL (package-less)
1772+
// overlay only, never another package's row.
1773+
return await this.engine.findOne('sys_metadata', {
1774+
where: { ...base, package_id: null },
1775+
});
17511776
}
17521777
return await this.engine.findOne('sys_metadata', { where: base });
17531778
};
@@ -3722,8 +3747,13 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
37223747
// Parent is scoped to the lifecycle we're about to write:
37233748
// a draft's parent is the current draft hash (or null
37243749
// for the first draft); a publish's parent is the
3725-
// current published hash.
3726-
const current = await repo.get(ref, { state: mode === 'draft' ? 'draft' : 'active' });
3750+
// current published hash. ADR-0048 — scope to the same
3751+
// package the upsert targets so a collision's other-package
3752+
// row is never read as this item's parent.
3753+
const current = await repo.get(ref, {
3754+
state: mode === 'draft' ? 'draft' : 'active',
3755+
packageId: request.packageId ?? null,
3756+
});
37273757
parentVersion = current?.hash ?? null;
37283758
}
37293759
try {

packages/objectql/src/sys-metadata-repository.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -250,11 +250,17 @@ export class SysMetadataRepository implements MetadataRepository {
250250
* live published row (`'active'`). Pass `'draft'` to read the pending
251251
* unpublished revision (if any).
252252
*/
253-
async get(ref: MetaRef, opts?: { state?: OverlayState }): Promise<MetadataItem | null> {
253+
async get(
254+
ref: MetaRef,
255+
opts?: { state?: OverlayState; packageId?: string | null },
256+
): Promise<MetadataItem | null> {
254257
this.assertOpen();
255258
const state = opts?.state ?? 'active';
259+
// ADR-0048 — when a package scope is supplied, resolve the row owned by
260+
// that package (used by saveMetaItem to read the correct parent-version
261+
// lineage before an upsert). Omitted → legacy "any package" match.
256262
const row = await this.engine.findOne('sys_metadata', {
257-
where: this.whereFor(ref, state),
263+
where: this.whereFor(ref, state, opts && 'packageId' in opts ? (opts.packageId ?? null) : undefined),
258264
});
259265
if (!row) return null;
260266
return this.rowToItem(ref, row);
@@ -314,8 +320,11 @@ export class SysMetadataRepository implements MetadataRepository {
314320
// Run all reads + writes inside one transaction so the optimistic
315321
// lock, the parent-row mutation, and the history append are atomic.
316322
const result = await this.withTxn(async (ctx) => {
323+
// ADR-0048 — scope the existing-row lookup to the requested package so a
324+
// save for package B does not find (and overwrite) package A's same-name
325+
// overlay. A package-less save (packageId null) targets the global row.
317326
const existing = await this.engine.findOne('sys_metadata', {
318-
where: this.whereFor(ref, state),
327+
where: this.whereFor(ref, state, opts.packageId ?? null),
319328
context: ctx,
320329
});
321330
const existingHash: string | null = existing?.checksum ?? null;
@@ -903,13 +912,22 @@ export class SysMetadataRepository implements MetadataRepository {
903912
private whereFor(
904913
ref: Pick<MetaRef, 'type' | 'name'>,
905914
state: OverlayState = 'active',
915+
packageId?: string | null,
906916
): Record<string, unknown> {
907-
return {
917+
const where: Record<string, unknown> = {
908918
type: ref.type,
909919
name: ref.name,
910920
organization_id: this.organizationId,
911921
state,
912922
};
923+
// ADR-0048 — when the caller scopes by package, the overlay row is keyed by
924+
// `(org, type, name, package_id)` so two installed packages shipping the
925+
// same name each get their OWN customization row (a package-less / global
926+
// overlay uses `package_id IS NULL`). When `packageId` is omitted (legacy
927+
// callers — delete/promote/restore), the package dimension is left out so
928+
// the query keeps its historical "match any package" behaviour.
929+
if (packageId !== undefined) where.package_id = packageId; // string → eq; null → IS NULL
930+
return where;
913931
}
914932

915933
private fullRef(ref: Pick<MetaRef, 'type' | 'name'>): MetaRef {

0 commit comments

Comments
 (0)