Skip to content

Commit c1dfe34

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(metadata): keep each colliding item's own _packageId provenance (ADR-0048) (#1822)
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/<pkg>/page/showcase_task_workbench renders each package's own page). 603 objectql tests green. 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 f62b4de commit c1dfe34

4 files changed

Lines changed: 60 additions & 1 deletion

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(metadata): keep each colliding item's own `_packageId` provenance (ADR-0048)
6+
7+
When two installed packages ship an item of the same `type`/`name`, the
8+
single-item and list reads grafted the artifact protection envelope from a
9+
**first-match** artifact lookup (`lookupArtifactItem(type, name)`), so the
10+
second package's item inherited the FIRST package's `_packageId`. The frontend
11+
prefer-local resolution (dashboard/report/page) filters the unscoped list by
12+
`_packageId`, so this mislabel made it resolve a collision to the wrong package
13+
(or fail to find the local item entirely).
14+
15+
- `getMetaItem` now scopes the artifact lookup to `request.packageId`.
16+
- `getMetaItems` scopes the per-item decorate to the requested package (when the
17+
whole list is package-scoped) else to each item's own `_packageId`.
18+
19+
`getItem` ordering is unchanged — a bare-key runtime/DB overlay still takes
20+
ADR-0005 precedence over the packaged item (clarifying comment added). An
21+
env-wide (package-less) overlay of a name that collides across packages remains
22+
inherently ambiguous by schema (`sys_metadata` is unique on `type+name+org`, not
23+
package); pure-artifact collisions (the marketplace default) now resolve and
24+
list correctly per package.

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,6 +815,25 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
815815
expect(userPage._provenance).toBeUndefined();
816816
});
817817

818+
it('keeps each colliding item\'s own _packageId on the list (ADR-0048)', async () => {
819+
// Two installed packages ship `page/home`. The list decorate step
820+
// grafts artifact protection per item; that lookup must be scoped to
821+
// EACH item's owning package, or both rows inherit the first-match
822+
// package's `_packageId` and the frontend prefer-local (which filters
823+
// by `_packageId`) can no longer tell them apart.
824+
registry.registerItem('page', { name: 'home', label: 'Acme Home' }, 'name', 'com.acme.crm');
825+
registry.registerItem('page', { name: 'home', label: 'Globex Home' }, 'name', 'com.globex.crm');
826+
mockEngine.find.mockResolvedValue([]);
827+
828+
const result = await protocol.getMetaItems({ type: 'page' });
829+
const homes = result.items.filter((i: any) => i.name === 'home');
830+
831+
expect(homes).toHaveLength(2);
832+
const byPkg = Object.fromEntries(homes.map((h: any) => [h._packageId, h.label]));
833+
expect(byPkg['com.acme.crm']).toBe('Acme Home');
834+
expect(byPkg['com.globex.crm']).toBe('Globex Home');
835+
});
836+
818837
it('should fall back to DB when registry is empty for type', async () => {
819838
mockEngine.find.mockResolvedValue([
820839
{

packages/objectql/src/protocol.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1386,9 +1386,15 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
13861386
items: decorateMetadataItems(
13871387
request.type,
13881388
(items as any[]).map((it) => {
1389+
// ADR-0048 — scope the artifact lookup to THIS item's owning
1390+
// package so a same-name collision grafts each item's own
1391+
// protection envelope, not the first-registered package's.
1392+
// (`requested` packageId, when the whole list is scoped,
1393+
// takes priority; else the item's own `_packageId`.)
13891394
const a = this.lookupArtifactItem(
13901395
request.type,
13911396
(it as any)?.name,
1397+
packageId ?? ((it as any)?._packageId as string | undefined),
13921398
);
13931399
return mergeArtifactProtection(it, a) as any;
13941400
}),
@@ -1583,7 +1589,10 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
15831589
// persisted overlay copy that pre-dates the artifact's `_lock`
15841590
// declaration; we must consult the in-memory artifact registry
15851591
// directly and let its protection envelope override.
1586-
const artifactItem = this.lookupArtifactItem(request.type, request.name);
1592+
// ADR-0048 — scope the artifact lookup to the requested package so a
1593+
// same-name collision grafts the OWNING package's protection envelope
1594+
// (`_packageId`/`_lock`), not whichever package registered first.
1595+
const artifactItem = this.lookupArtifactItem(request.type, request.name, request.packageId);
15871596
let decorated = decorateMetadataItem(
15881597
request.type,
15891598
mergeArtifactProtection(item, artifactItem),

packages/objectql/src/registry.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,6 +933,13 @@ export class SchemaRegistry {
933933

934934
const collection = this.metadata.get(type);
935935
if (!collection) return undefined;
936+
// A bare-key entry (a runtime/DB overlay rehydrated by restoreMetadataFromDb)
937+
// intentionally shadows the packaged composite item — ADR-0005 overlay
938+
// precedence (a customization wins over its package default). This is
939+
// checked before prefer-local so that precedence holds; note an env-wide
940+
// (package-less) overlay of a name that collides across packages is
941+
// inherently ambiguous by schema (sys_metadata is unique on type+name+org,
942+
// not package) and resolves to the single overlay row.
936943
const direct = collection.get(name);
937944
if (direct) return direct as T;
938945

0 commit comments

Comments
 (0)