From 633d445586f121a26fd8adcab0d98af2d95883ce Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 14 Jun 2026 02:30:15 +0500 Subject: [PATCH] fix(metadata): keep each colliding item's own _packageId provenance (ADR-0048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When two packages ship a same-name item, getMetaItem/getMetaItems grafted the artifact protection envelope from a first-match lookup, so the second package's item inherited the FIRST package's _packageId. The frontend prefer-local (dashboard/report/page) filters the unscoped list by _packageId, so this mislabel resolved collisions to the wrong package. Scope the artifact lookup to the requested package (getMetaItem) and to each item's own _packageId (getMetaItems list decorate). getItem ordering unchanged — a bare-key overlay still takes ADR-0005 precedence. Verified live (page collision showcase vs studio): single ?package=, the unscoped list (both items, correct _packageId each), and the frontend page route (/apps//page/showcase_task_workbench renders each package's own page). 603 objectql tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...adr-0048-collision-packageid-provenance.md | 24 +++++++++++++++++++ packages/objectql/src/protocol-meta.test.ts | 19 +++++++++++++++ packages/objectql/src/protocol.ts | 11 ++++++++- packages/objectql/src/registry.ts | 7 ++++++ 4 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 .changeset/adr-0048-collision-packageid-provenance.md diff --git a/.changeset/adr-0048-collision-packageid-provenance.md b/.changeset/adr-0048-collision-packageid-provenance.md new file mode 100644 index 0000000000..cc8763786f --- /dev/null +++ b/.changeset/adr-0048-collision-packageid-provenance.md @@ -0,0 +1,24 @@ +--- +"@objectstack/objectql": patch +--- + +fix(metadata): keep each colliding item's own `_packageId` provenance (ADR-0048) + +When two installed packages ship an item of the same `type`/`name`, the +single-item and list reads grafted the artifact protection envelope from a +**first-match** artifact lookup (`lookupArtifactItem(type, name)`), so the +second package's item inherited the FIRST package's `_packageId`. The frontend +prefer-local resolution (dashboard/report/page) filters the unscoped list by +`_packageId`, so this mislabel made it resolve a collision to the wrong package +(or fail to find the local item entirely). + +- `getMetaItem` now scopes the artifact lookup to `request.packageId`. +- `getMetaItems` scopes the per-item decorate to the requested package (when the + whole list is package-scoped) else to each item's own `_packageId`. + +`getItem` ordering is unchanged — a bare-key runtime/DB overlay still takes +ADR-0005 precedence over the packaged item (clarifying comment added). An +env-wide (package-less) overlay of a name that collides across packages remains +inherently ambiguous by schema (`sys_metadata` is unique on `type+name+org`, not +package); pure-artifact collisions (the marketplace default) now resolve and +list correctly per package. diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index 49224cc3fa..ff3c0e72c8 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -815,6 +815,25 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { expect(userPage._provenance).toBeUndefined(); }); + it('keeps each colliding item\'s own _packageId on the list (ADR-0048)', async () => { + // Two installed packages ship `page/home`. The list decorate step + // grafts artifact protection per item; that lookup must be scoped to + // EACH item's owning package, or both rows inherit the first-match + // package's `_packageId` and the frontend prefer-local (which filters + // by `_packageId`) can no longer tell them apart. + registry.registerItem('page', { name: 'home', label: 'Acme Home' }, 'name', 'com.acme.crm'); + registry.registerItem('page', { name: 'home', label: 'Globex Home' }, 'name', 'com.globex.crm'); + mockEngine.find.mockResolvedValue([]); + + const result = await protocol.getMetaItems({ type: 'page' }); + const homes = result.items.filter((i: any) => i.name === 'home'); + + expect(homes).toHaveLength(2); + const byPkg = Object.fromEntries(homes.map((h: any) => [h._packageId, h.label])); + expect(byPkg['com.acme.crm']).toBe('Acme Home'); + expect(byPkg['com.globex.crm']).toBe('Globex Home'); + }); + it('should fall back to DB when registry is empty for type', async () => { mockEngine.find.mockResolvedValue([ { diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index 65ba2f5d60..98a32926e0 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -1386,9 +1386,15 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { items: decorateMetadataItems( request.type, (items as any[]).map((it) => { + // ADR-0048 — scope the artifact lookup to THIS item's owning + // package so a same-name collision grafts each item's own + // protection envelope, not the first-registered package's. + // (`requested` packageId, when the whole list is scoped, + // takes priority; else the item's own `_packageId`.) const a = this.lookupArtifactItem( request.type, (it as any)?.name, + packageId ?? ((it as any)?._packageId as string | undefined), ); return mergeArtifactProtection(it, a) as any; }), @@ -1583,7 +1589,10 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { // persisted overlay copy that pre-dates the artifact's `_lock` // declaration; we must consult the in-memory artifact registry // directly and let its protection envelope override. - const artifactItem = this.lookupArtifactItem(request.type, request.name); + // ADR-0048 — scope the artifact lookup to the requested package so a + // same-name collision grafts the OWNING package's protection envelope + // (`_packageId`/`_lock`), not whichever package registered first. + const artifactItem = this.lookupArtifactItem(request.type, request.name, request.packageId); let decorated = decorateMetadataItem( request.type, mergeArtifactProtection(item, artifactItem), diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 6201392a4e..41832dc380 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -933,6 +933,13 @@ export class SchemaRegistry { const collection = this.metadata.get(type); if (!collection) return undefined; + // A bare-key entry (a runtime/DB overlay rehydrated by restoreMetadataFromDb) + // intentionally shadows the packaged composite item — ADR-0005 overlay + // precedence (a customization wins over its package default). This is + // checked before prefer-local so that precedence holds; note an env-wide + // (package-less) overlay of a name that collides across packages is + // inherently ambiguous by schema (sys_metadata is unique on type+name+org, + // not package) and resolves to the single overlay row. const direct = collection.get(name); if (direct) return direct as T;