Skip to content

fix(metadata-protocol): unscoped metadata list dedupes package-aware, not by bare name (ADR-0048 #1828)#3231

Merged
os-zhuang merged 1 commit into
mainfrom
claude/adr-0048-metadata-dedupes-yje377
Jul 18, 2026
Merged

fix(metadata-protocol): unscoped metadata list dedupes package-aware, not by bare name (ADR-0048 #1828)#3231
os-zhuang merged 1 commit into
mainfrom
claude/adr-0048-metadata-dedupes-yje377

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Closes #1828.

Problem

The unscoped getMetaItems (packages/metadata-protocol/src/protocol.ts) merges registry items, sys_metadata overlay rows, draft-preview rows, and MetadataService items into Maps keyed by bare name (four sites). When two installed packages ship the same type/name (e.g. page/home) and either has a sys_metadata overlay, the DB-overlay merge collapses them to one row (last-write-wins), so an unscoped GET /meta/:type returns only one. The frontend prefer-local resolution (preferLocal(list, name, app._packageId) in objectui app-shell) reads that unscoped list, so a collapsed list makes it pick the surviving row regardless of package.

The single-item (?package=) and Studio/layered paths were already fixed by ADR-0048's earlier issues (#1810/#1816/#1819/#1822/#1827); the unscoped list was the remaining gap.

Fix

Key the merge by (package, name) via a shared mergePackageAwareOverlay helper whose per-package resolution mirrors getMetaItem(name, packageId=P) — a package-scoped row wins for its own package, else the package-less ("global", package_id IS NULL) row is the fallback — so the list and single-item paths agree. Applied to all four dedup sites: env/org mergedMap, DB-overlay active merge, draft-preview merge, MetadataService merge.

Two provenance fixes so a collision no longer mislabels _packageId:

  • scope the registry-hydration artifact graft to each row's own package_id;
  • SchemaRegistry.getArtifactItem(type, name, packageId) no longer returns a bare-key overlay owned by a different package. The bare slot holds a single row (last hydrate wins); once the list stopped collapsing, the list-decorate step (lookupArtifactItem(type, name, <row's own package>)) would otherwise graft that one package's id onto every same-name row. This second bug was invisible to unit tests and only surfaced by dogfooding a real collision against the running server (see below).

Behaviour

  • Collision: two packages' same-name rows stay distinct, each with its own correct _packageId.
  • Single package, unchanged: a package-less (env-wide) overlay still wins over the single artifact it customizes (ADR-0005 precedence preserved).
  • Legacy attribution mismatch: a package-less active row + its package_id-bearing draft still collapse to one slot (draft wins), matching the single-item path.

Acceptance (from #1828)

  • Unscoped GET /meta/page with two packages each shipping the same page returns both rows, each with its own correct _packageId.
  • Existing single-package list behaviour and ADR-0005 overlay precedence unchanged.
  • Verified end-to-end on the running server with a real two-package collision (see below). The frontend preferLocal (pre-existing objectui code) filters this now-correct list by _packageId; the backend contract it depends on is proven. The objectui console UI itself was not driven — the console is a vendored bundle (.objectui-sha) not built in this framework worktree.

Live verification (dogfood)

Booted the showcase example app (objectstack dev --ui --seed-admin, SQLite, isolated port) and created a real collision — two packages each with a package-scoped page/dogfood_collision overlay:

Read Result
GET /meta/page (unscoped) 2 rowscom.dogfood.alpha → "Home from ALPHA", com.dogfood.beta → "Home from BETA"
GET /meta/page?package=com.dogfood.alpha 1 row — alpha only
GET /meta/page?package=com.dogfood.beta 1 row — beta only

Both rows survive with the correct _packageId; scoped reads isolate. Pre-fix, the unscoped byName dedup collapsed these to a single row. (This live run is what exposed and drove the getArtifactItem provenance fix above.)

Tests

New unit tests:

  • protocol-meta.test.ts — two-package same-name overlays both survive with correct _packageId; single-package env-wide overlay still overlays (one row); MetadataService same-name collision keeps both.
  • registry-prefer-local-resolution.test.tsgetArtifactItem rejects a foreign-package bare entry.

Verified locally: @objectstack/objectql suite (71 files, 956 tests) green — including view-identity-overlay, registry-shadow, layered-overlay, and cross-package-collision regressions; @objectstack/runtime (556) and @objectstack/rest green; changed files typecheck clean; check:nul-bytes + eslint --no-inline-config pass.

Related: #1822 (single-item/list provenance), #1827 / #1824 (package-scoped overlays).

🤖 Generated with Claude Code

https://claude.ai/code/session_01DKFjwN2EVXPB2CJwNzPs8c

@vercel

vercel Bot commented Jul 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Canceled Canceled Jul 18, 2026 4:26pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/objectql.

13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol, @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)
  • content/docs/releases/v9.mdx (via @objectstack/objectql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang force-pushed the claude/adr-0048-metadata-dedupes-yje377 branch from a53890a to 3eea67e Compare July 18, 2026 15:51
@github-actions github-actions Bot added documentation Improvements or additions to documentation tooling labels Jul 18, 2026
… not by bare name (ADR-0048 #1828)

`getMetaItems` merged registry items, `sys_metadata` overlay rows,
draft-preview rows, and MetadataService items into `Map`s keyed by bare
`name`. When two installed packages ship the same `type/name` (e.g.
`page/home`) and either has an overlay, the DB-overlay merge collapsed
them to one row (last-write-wins), so an unscoped `GET /meta/:type`
returned only one — and the frontend prefer-local resolution, which reads
the unscoped list, could no longer tell the two packages' rows apart.

Key the three merge sites (plus the env/org `mergedMap`) by
`(package, name)` via a shared `mergePackageAwareOverlay` helper whose
per-package resolution mirrors `getMetaItem(name, packageId=P)`
(scoped row, else package-less/global fallback), so:

- two packages' same-name rows stay distinct, each with its own `_packageId`;
- a package-less (env-wide) overlay still wins over the single artifact it
  customizes — ADR-0005 precedence and single-package behaviour unchanged;
- a legacy row whose active/draft layers disagree on package attribution
  still collapses (draft wins), matching the single-item path.

Two provenance fixes so a collision no longer mislabels `_packageId`:
- scope the registry-hydration artifact graft to the row's own `package_id`;
- `SchemaRegistry.getArtifactItem(type, name, packageId)` no longer returns a
  bare-key overlay owned by a DIFFERENT package. The bare slot holds one row
  (last hydrate wins); once the list stopped collapsing, the list-decorate
  step (`lookupArtifactItem(type, name, <row's own package>)`) would otherwise
  graft that one package's id onto every same-name row. Found by dogfooding a
  real two-package collision against the running server.

Tests: two-package overlay collision keeps both rows with correct
`_packageId`; single-package env-wide overlay unchanged; MetadataService
collision keeps both; `getArtifactItem` rejects a foreign-package bare entry.
Verified end-to-end on a booted showcase app (unscoped `/meta/page` returns
both rows, each correct `_packageId`; scoped reads isolate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DKFjwN2EVXPB2CJwNzPs8c
@os-zhuang
os-zhuang force-pushed the claude/adr-0048-metadata-dedupes-yje377 branch from 3eea67e to 4b99652 Compare July 18, 2026 16:16
@os-zhuang
os-zhuang marked this pull request as ready for review July 18, 2026 16:19
@os-zhuang
os-zhuang merged commit 0e41302 into main Jul 18, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/adr-0048-metadata-dedupes-yje377 branch July 18, 2026 16:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-0048 follow-up: unscoped metadata list dedupes by bare name (collapses same-name collisions across packages)

2 participants