fix(metadata-protocol): the object door never lets a container's expansion displace a stored row of the same name (#21510) - #21557
Conversation
…tored row of the same name The object door (getMetaItems, readFlattenedMetaItems) upserted every name a stored view container expands over the merged items, a stored row of exactly that name included, while the by-name read answered the row. Both doors now ask one predicate, namesWithOwnStoredRow, over the rows they select for the caller: an expansion fills only a name with no stored row of its own. The by-name read's predicate is factored out unchanged; the list read now asks it. Pins: the dev's setup (a stored overlay of showcase_task's container plus a stored row named showcase_task.default) on both kernels, both scopes and both write orders, with the row-less expanded name as the control, and a row stored for one organization answering for that organization only. Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
…and diff beside an expansion of that name The by-name family (history, diff) asks the same own-row predicate the list read now asks, so a reversal of that predicate is observable on both doors. Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
…ded names A view item saved under an expanded name is what the object door now lists; a container stored under such a name is its own row too, so the object door (which never lists a container) lists nothing there. Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin df433a2a7a4787dbedb5dce78cfb67a0a6ce0ba4 && git checkout df433a2a7a4787dbedb5dce78cfb67a0a6ce0ba4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6cf1154a65ffcae3fdfe607a5d221572157ee353 d12a8f62563dba5805ee8f95fca5dae6f25fd287 && git checkout -B drift-repro 6cf1154a65ffcae3fdfe607a5d221572157ee353 && git merge --no-ff d12a8f62563dba5805ee8f95fca5dae6f25fd287
node scripts/docs-audit/affected-docs.mjs --json 6cf1154a65ffcae3fdfe607a5d221572157ee353
|
Fixes #21510
Clause-②: no
What this changes
Triage's ruling on the card (comment 5964342087): the stored row wins on both doors. ADR-0005 keys an overlay by its own name, so a row stored under exactly a name is the sanctioned override for it. An expansion is derived from its container, so it fills only names that have no row of their own. The by-name read has applied that rule since PR #21508. This PR makes the object door's list read apply it too, through the same predicate.
readFlattenedMetaItems(the list read behindGET /api/v1/meta/view?object=OBJECT) upserted every name a stored view container expands into its answer by bare name (byName.set(vi.name, vi)). That ran after the package-aware merge had seated the stored rows, so it replaced a stored row of the same name. The by-name read (getMetaItem) answered that row. The two doors disagreed, against metadata: a view container with a bare list on another package's object silently replaces that object's packaged default view on GET /meta/view?object= — while the by-name read still serves the original #21334's ruling ("the object door and the by-name read answer the same row").resolveRowlessExpandedView: arecords.some(...)that compared each row'snamewithrequest.name, over the rowsreadActiveOverlayRowsselected for the caller. It is factored out unchanged asnamesWithOwnStoredRow(records), which returns the names that have a stored row of their own in that selection.resolveRowlessExpandedViewnow asks it in place of the inline test, with the same answer for every input: a non-string row name matched no request name before and is left out of the set now. The list read asks it over its ownrecords, the same selection, and skips an expansion whose name is in the set. There is no second test.resolveRowlessExpandedView(plus its docblock).hydrateExpandedViewItems, the save door and the data door's existence gate are not touched.Measured, in-process at the protocol (the #21334 showcase harness)
The dev's setup, as the card describes it: a stored overlay of the showcase's own
showcase_taskcontainer, with alist(labelFromContainer) and alistViews.in_progressmember (labelFromContainer In Progress), plus a stored ViewItem row namedshowcase_task.default(labelByNameRow), written through the save door.showcase_task.defaultshowcase_task.in_progress, before and afterenv_localFromContainer/ByNameRowByNameRowenv_localFromContainer/ByNameRowByNameRowenv_localFromContainer/ByNameRowByNameRowFromContainer/ByNameRowByNameRowFromContainer/ByNameRowByNameRow"Before" is
origin/mainat24dc7c1134, read with a throwaway test that was deleted afterwards. "After" is this branch.The public input (PM mechanism assumption 4). In every cell above, the save door accepts
saveMetaItemwith typeview, the nameshowcase_task.defaultand a body whosenameisshowcase_task.default, and it stores the row under that name. Since #21470 the save door judges the bodynameagainst the save name, and these are equal. The pins assert that the row was stored.Tests
In
packages/metadata-protocol/src/view-container-runtime-expansion.test.ts, nested in the #21334 block to reuse its faithful-registry harness, there are 10 new cases (the file goes from 119 to 129):showcase_task.defaulton both doors, and the by-name item equals the listed one (_diagnosticsexcluded). The row's name keeps its own history (every event'sref.nameis the row's) and its own diff (nameis the row's), never the container's. The control,showcase_task.in_progress, answers the expansion on both doors. Every name the object door lists answers the same item by name.org_acme.org_acmegets the row on both doors. A caller with no organization andorg_globexget the container's expansion on both doors.Results:
6a41000f1e, the full package suite (vitest run) gives 206 files passed / 3 skipped, 3173 tests passed / 19 skipped.pnpm --filter @objectstack/metadata-protocol typecheckis clean, andtsc --listFilesincludes the edited test file.dist/(it carriesnamesWithOwnStoredRow): the test files that read the view object door through the real protocol.@objectstack/objectqlgives 5 files / 71 tests and@objectstack/restgives 1 file / 24 tests, all green. The rest of those packages, and the dogfood suites, are CI's.Reverse verification
Each mutation was made from committed state (
6a41000f1e) throughscripts/ablation-replace.mjs, inside a script with an EXIT/INT/TERM restore trap. After each leg, the restore was proven: blobf1622d5bdde2equals HEAD, andgit diff HEADis empty. The subject resolves through relative source imports (./index.js), so nodist/leg applies. The predicted direction was red, and every leg went red.… && false) continue;): 10 failed / 119 passed. All 10 new cases fail withexpected 'FromContainer' to be 'ByNameRow', the card's defect.resolveRowlessExpandedView): 8 failed / 121 passed. Both doors still answer the row, but history now delegates to the container:every event names the row: expected false to be true. So the by-name family reads the same predicate and is pinned by it.Gates
dispatch-gates --commands --repo objectstack-ai/objectstackat the final headd12a8f6256derived 64 families, the same set as at6a41000f1e. The PM's lead had 56; the changeset adds 8:check-adr-0087-registration×2,check-empty-changeset×2,release-rehearsal-clone --self-test,release-pending-publish --self-test,check:objectui-changesetandcheck:pm-changeset-deadline-census.pnpm check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET, 44 package entry points with nodist/) in the first run at6a41000f1e. By the run atd12a8f6256, thosedist/directories were present in this worktree (created at 06:27Z, while the first run'scheck:type-check-debtre-measure was running), and it measured 106 require entry points across 66 packages, which load.--ranreconciliation atd12a8f6256: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN.d12a8f6256changes only the changeset, so the test, typecheck and ablation readings above (taken at6a41000f1e) read the sameprotocol.tsand test file bytes.eslint --no-inline-config --format jsonover the 2 changed TypeScript files gives 2 files linted, 0 errors and 0 warnings. The config does no type-aware linting (noparserOptions.project; see the note ateslint.config.mjsline 328), so this diff cannot move a verdict on an untouched file. A repo-widepnpm lintis CI's.Acceptance notes
{ name: 'showcase_task.default', object: 'showcase_task', list: {...} }saved undershowcase_task.default, and its barelistexpands to that same name.origin/mainlist): the object door listed the self-expansion, and the by-name read answered the raw container. The doors disagreed.showcase_task.default, while the by-name read still answers the raw container. That is how every container's own name already behaves. The doors still disagree, now in a different way.dist/index.d.tsgains one private member line (private namesWithOwnStoredRow;). No public member or exported type changes.Setof row names per call, over rows it already holds. There is no extra read.Generated by Claude Code