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-zhuangos-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:

ReadResult
GET /meta/page (unscoped)2 rowscom.dogfood.alpha → "Home from ALPHA", com.dogfood.beta → "Home from BETA"
GET /meta/page?package=com.dogfood.alpha1 row — alpha only
GET /meta/page?package=com.dogfood.beta1 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

vercelBot commented Jul 18, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
specCanceledCanceledJul 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-zhuangforce-pushed the claude/adr-0048-metadata-dedupes-yje377 branch from a53890a to 3eea67eCompareJuly 18, 2026 15:51
@github-actionsgithub-actionsBot 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-zhuangforce-pushed the claude/adr-0048-metadata-dedupes-yje377 branch from 3eea67e to 4b99652CompareJuly 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 mainJul 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

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

@os-zhuang@claude