Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/adr-0048-collision-packageid-provenance.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions packages/objectql/src/protocol-meta.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
{
Expand Down
11 changes: 10 additions & 1 deletion packages/objectql/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}),
Expand Down Expand Up @@ -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),
Expand Down
7 changes: 7 additions & 0 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down