Repository navigation
Commit 78c13bd
fix(metadata-protocol): a republish of a view container retires the views it dropped, through one reconcile (#23234)
Part of #23198
Clause-②: no
## What was wrong
A view container (a `defineView` document such as `showcase_task`)
registers its own name plus one view per entry, under `OBJECT.VIEWKEY`,
in the process registry. A republish of the container registered the
views it expanded to and retired none it no longer expanded to. After a
publish that dropped `listViews.added_by_overlay`, `GET
/api/v1/meta/view`, the `?object=` list and the by-name read kept
serving `showcase_task.added_by_overlay` until a restart. Measured
before the change, on this PR's pins (red at `0219bb6a0`) and over the
real `SchemaRegistry` (see Verification).
## The change (`packages/metadata-protocol/src/protocol.ts`)
One private reconcile, `reconcileViewExpansions(type, write,
organizationId)`. After any write that changes a container's stored row,
it leaves the registry holding the views that row expands to now:
1. It registers each expanded view, except a name another package ships
([#21980], [#22057]). That name is never recorded.
2. It replaces the record of the rows the write addresses with the names
it just registered. The record is PR #23196's, now kept per stored row.
3. It heals each name the rows registered before and do not register
now, through PR #23196's per-name walk (`restoreArtifactRegistryView`).
A packaged view of that name is un-shadowed, a baseline is re-registered
on a control-plane kernel, and a name no layer serves is retired.
Both sides call it, and nothing else registers an expansion or touches
the record:
- **The registration.** `hydrateExpandedViewItems` passes the row's
binding and its expansion. `hydrateOverlayIntoRegistry` reaches it on
both of its registering paths. That covers every hydration: the
write-through (save-publish, publish, rollback, revert, a replica's
convergence), the list read and the boot.
- **The heal.** `restoreArtifactRegistryView` passes `removed:
'every-row'` (an empty set) ahead of its tier walk. A reset, a delete, a
revert's removal and a replica's removal reach it there.
The walk reads the MetadataService, so the reconcile is async.
`hydrateOverlayIntoRegistry` and `applyRegistryWriteThrough` became
`async`, and all eight call sites await them. A publish therefore
answers after the heal. The `#9111` type assert still runs before the
first `await`, and its refusal reaches the same `catch` as before.
`protocol.hydrate-overlay-canonical-type.test.ts` awaits the hydrator
now. Its four affected cases assert what they asserted before: code,
status, message and nothing minted.
**Why the record is per row (zone 2, hypothesis 2, measured).** One
container name can have a package-less row and a package-bound row
(ADR-0048), and the list read hydrates both on every call. If the record
is keyed on the name alone, each row reconciles against the other row's
set. The first list read then retired the package-less row's own member
(ablation B below). Keyed per row, a republish of one row heals only
what that row dropped. A view both rows expand to, which one row drops,
leaves the registry with that row. Both doors answer it from the other
row's stored row, and the next list read registers it again (pinned). A
reset still heals every row of the name, because the heal is handed no
binding (#23196's behaviour, unchanged).
## Zone 2 hypotheses
1. **The record is a union. Confirmed.** Pin: a reset after a republish
leaves alone a name the container dropped earlier. Before the change,
that reset healed a bare entry held by another source.
2. **Two rows of one name. Confirmed for a name-only key.** Answered
with per-row keys (above).
3. **Callers, enumerated from the tree.** `hydrateOverlayIntoRegistry`
has three callers: the list read's hydration in `getMetaItems` (not the
cluster peer), `applyRegistryWriteThrough`, and `loadMetaFromDb`. The
peer reaches the registry through `applyRegistryWriteThrough` or
`restoreArtifactRegistryView`. The completeness pin reads that
population from the source.
4. **The gates.** The organization gate is in the reconcile itself and
in both callers. Re-registration stays control-plane only, inside the
walk. The another-package skip is inside the reconcile's registration
loop. #23196's pins for the organization gate and the another-package
skip pass unchanged.
5. **A degraded tier-2 read.** Inherited from the code, not pinned by a
run. The walk stops, so that name stays registered (and is no longer
recorded) until a restart. No retry was added.
## Pins
`protocol.view-container-reset-expansions.test.ts`, a new `[#23198]`
block of 5 cases on #23196's doubles (so no new engine-double rows).
Each case asserts the list read, the `?object=` list and the by-name
read together:
- a republish adds a member;
- the card's repro: a republish drops two members, so the one no package
ships is served by no door, and the packaged `showcase_task.in_progress`
is served again, once;
- a reset after a republish;
- a delete of a container no package ships, after a republish that
dropped a member;
- two rows of one container name.
`protocol.view-expansion-reconcile-call-sites.test.ts`, the family's
closing check (13 cases, source-reading, no CI gate). It covers three
populations:
- **Stored-row writes.** Every repository write verb and raw
`sys_metadata` write in `protocol.ts` (9 sites) carries a recorded route
to the write-through or the heal. A same-member route must sit after its
write and before the member's next write. A write with no route turns it
red, naming the write.
- **The chain.** The write-through reaches the hydrator. Every
registering path of the hydrator reaches the expansion hydration. The
heal reconciles ahead of its walk, past its organization gate. Every
call into the chain is awaited.
- **The record and the verbs.** Only the reconcile reads or writes the
record, and only the walk calls the heal verbs.
The data door cannot write `sys_metadata`: its `apiMethods` are `get`
and `list`.
`protocol.register-item-call-sites.test.ts` (#22057): the expansion's
`registerItem(` row moves to `reconcileViewExpansions`.
## Ablation (fix committed first; `scripts/ablation-replace.mjs` WRAP
mode; these suites import `./protocol.js`, so they resolve from `src/`
and need no build)
Every leg was restored with blob equal to HEAD and an empty `git diff
HEAD`.
- **A. The heal of a dropped name made a no-op** (anchor 1 to 0, blob
`a9b1a41c7` to `5faee5a1f`). 9 red, 2 green: the dashboard control and
"a republish adds a member".
- **B. The record keyed on the container name alone** (blob to
`70a74e603`). Only the two-row case is red, at `read 1: expected
undefined to be 'A only'`.
- **C. The bound-row path of the hydrator registers without
reconciling.** This leg took three attempts:
1. Replacing the call and its return with a commented `return true;`
went red, but only at the closing check's path count. So the count now
reads a trailing comment too (`675c31319`).
2. A bare `return true;` was refused by the tool: its replacement
restated part of the anchor, and the replace count stayed 5 to 5.
Nothing ran in that attempt.
3. Deleting the `await this.hydrateExpandedViewItems` line (blob to
`c85a10a1c`) turned only the closing check red, in 2 cases. All 11
behaviour pins stayed green, so this bypass is visible to the closing
check alone.
- **D. The delete's heal after `repo.delete` removed** (blob to
`e1658dc6f`). The census is red: `deleteMetaItem: repo.delete( →
restoreArtifactRegistryView`.
- **E. The rollback's write-through not awaited** (at `99dd8fda3`, blob
`e5645d2f9` to `a6c6cec44`). The await case is red.
Legs A to D ran at `a4b879a33` and `675c31319`, on the `protocol.ts`
blob `a9b1a41c7` that the merge left unchanged. After them,
`protocol.ts` changed only in comments.
## Verification (HEAD `99dd8fda35`)
- Full `@objectstack/metadata-protocol` suite on the merged tree
`4035e9baee` (after `main` at `75cada0ea`), run in two halves: 116 files
passed and 3 skipped (25931 tests), then 118 files passed (2370 tests).
VERDICT command-exit 0 for both. Before the merge, at `a4b879a33`: 234
files passed, 3 skipped, 28301 tests. `99dd8fda35` adds comment lines
and one closing-check case, which ran green with typecheck.
- `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. `tsc
--listFiles` lists all four changed or new test files.
- **Real composition** (scratch file in `packages/objectql/src`, not
committed, over the rebuilt `dist/`, with the real `SchemaRegistry`).
The three legs are the card's repro then a reset, the delete, and the
two rows. They are 3/3 green. Against the base `protocol.ts` (rebuilt,
then restored to HEAD and rebuilt) they are 3/3 red, at `Added` and
`Closed` still served and `a_only` still registered. 12 files and 178
tests are green across them plus the 11 `objectql` suites that drive
this heal and hydration.
- **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `99dd8fda35` derives 65 commands. `--ran`
with exit codes reports 64 run with exit 0 and 1 NOT MEASURED.
`check:lean-entry-closure` first exited 3 because `objectql/dist` was
absent; after the `objectql` build it exited 0. **NOT MEASURED:
`check:dual-build-cjs-loads`**, reason: exit 3, 60+ packages unbuilt in
this container. Narrow stand-in: `require` of
`packages/metadata-protocol/dist/index.cjs` loads
`ObjectStackProtocolImplementation` (a function).
- **Lint, a declared narrowing.** All 5 changed `.ts` files are in
`eslint.config.mjs`'s population (`--print-config` resolves each).
`eslint --no-inline-config --format json` reports 5 files, 0 errors and
0 warnings. Type-aware linting is not enabled: the resolved config has
no `parserOptions.project` or `projectService`. So this diff cannot move
the verdict of an untouched file. The full `pnpm lint` is CI's.
- **Built declarations.** `ObjectStackProtocolImplementation` gains the
untyped private name `reconcileViewExpansions` and loses
`recordViewExpansion` and `takeViewExpansions`. `ViewContainerWrite` is
module-private and absent from `dist/index.d.ts`. No public member,
export, schema or accept-set changes, so `Clause-②: no` stands.
- Changed lines: +635 / -102 in 6 files against merge base `75cada0ea`.
## Acceptance notes
- **Orphan adoption bypasses the reconcile, and the closing check names
it** (recorded as a shrink-only `bypass` row). `POST
/api/v1/packages/ID/adopt-orphans` (`reassignOrphanedMetadata`) rebinds
a package-less view container row to another package with a raw
`sys_metadata` update and touches no registry entry. This was measured
over the real `SchemaRegistry` with this fix built. After adopting an
overlay of `showcase_task` into another package, the row expands to
`showcase_task.showcase_task*` for that package. Meanwhile the
package-less registration's bare `showcase_task`,
`showcase_task.default` (`All Tasks (X)`, 6) and
`showcase_task.in_progress` stay registered, and all three doors keep
serving `All Tasks (X)` for `showcase_task.default` until a restart. It
is the same family, but a fifth write kind outside this claim's region.
It is reported for the seat to route; see the report on #23198.
- **Residual, documented in the reconcile's docblock.** A dropped name
whose walk stops on a degraded MetadataService read stays registered, no
longer recorded, until a restart. Before this change the same residual
was reachable from a reset only.
- **Two rows of one name.** A reset or delete of either row heals both
rows' views. The remaining row is hydrated again on the next list read.
This is #23196's behaviour, unchanged.
- **Test hygiene, not addressed.**
`protocol.code-defined-datasource-door.test.ts` calls the now-async
hydrator without `await` in its `hydrate` helper. It stays correct
because a `datasource` row registers before the first `await`.
- Not done: a live API run on `examples/app-showcase`. The real-registry
scratch stands in.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 197fbe2 commit 78c13bd
6 files changed
Lines changed: 635 additions & 102 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
231 | 231 | | |
232 | 232 | | |
233 | 233 | | |
234 | | - | |
| 234 | + | |
235 | 235 | | |
236 | 236 | | |
237 | 237 | | |
238 | | - | |
| 238 | + | |
239 | 239 | | |
240 | 240 | | |
241 | 241 | | |
| |||
248 | 248 | | |
249 | 249 | | |
250 | 250 | | |
251 | | - | |
| 251 | + | |
252 | 252 | | |
253 | 253 | | |
254 | 254 | | |
255 | 255 | | |
256 | 256 | | |
257 | | - | |
| 257 | + | |
258 | 258 | | |
259 | 259 | | |
260 | | - | |
| 260 | + | |
261 | 261 | | |
262 | 262 | | |
263 | 263 | | |
264 | | - | |
| 264 | + | |
265 | 265 | | |
266 | 266 | | |
267 | 267 | | |
268 | 268 | | |
269 | | - | |
| 269 | + | |
270 | 270 | | |
271 | 271 | | |
272 | | - | |
| 272 | + | |
273 | 273 | | |
274 | | - | |
| 274 | + | |
275 | 275 | | |
276 | 276 | | |
277 | | - | |
| 277 | + | |
278 | 278 | | |
279 | 279 | | |
280 | | - | |
| 280 | + | |
281 | 281 | | |
282 | 282 | | |
283 | | - | |
| 283 | + | |
284 | 284 | | |
285 | 285 | | |
286 | 286 | | |
287 | 287 | | |
288 | 288 | | |
289 | | - | |
| 289 | + | |
290 | 290 | | |
291 | 291 | | |
292 | 292 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
103 | 103 | | |
104 | 104 | | |
105 | 105 | | |
106 | | - | |
| 106 | + | |
| 107 | + | |
107 | 108 | | |
108 | | - | |
| 109 | + | |
109 | 110 | | |
110 | 111 | | |
111 | | - | |
| 112 | + | |
112 | 113 | | |
113 | 114 | | |
114 | 115 | | |
| |||
0 commit comments