Skip to content

Commit c6139f2

Browse files
fix(metadata-protocol): a view container's reset heals the expanded views its publish registered (#23196)
Fixes #23149 Clause-②: no ## What was wrong `DELETE /api/v1/meta/view/NAME` on a published overlay of a packaged view container answered `{ success: true, reset: true }`, but the delete path's registry heal (`restoreArtifactRegistryView`) healed the container's plain key only. The publish had also registered every view the container expands to (`hydrateExpandedViewItems`, one entry per `OBJECT.VIEWKEY` name), and those entries stayed in the process registry until a restart. `GET /meta/view` and `GET /meta/view?object=OBJECT` kept listing them, so the end-user list kept rendering the overlay's label and columns. ## Mechanism, measured (the dispatch's six premises) Measured on the real composition at `e540e7ffb` (before the change): the real `ObjectQL` engine and `SchemaRegistry`, the real `ObjectStackProtocolImplementation` on a host-config kernel (no environment), an in-memory driver, and a manifest shaped like `examples/app-showcase`'s `showcase_task` container (`list` with 7 columns, plus `listViews.in_progress`). The registry's `view` entries: | step | entries | |---|---| | boot | `com.example.showcase:showcase_task`, `com.example.showcase:showcase_task.default` (All Tasks, 7 columns), `com.example.showcase:showcase_task.in_progress` | | publish of the overlay (`All Tasks (X)`, `progress` dropped) | the three above, unchanged, plus bare `showcase_task`, `showcase_task.default` (All Tasks (X), 6 columns), `showcase_task.in_progress` | | `deleteMetaItem` answered `{success: true, reset: true}` | the three package entries, plus bare `showcase_task.default` (All Tasks (X), 6) and `showcase_task.in_progress`: only bare `showcase_task` was lifted | After that reset, `getMetaItems({ type: 'view' })` listed `showcase_task.default` twice (`All Tasks`/7 and `All Tasks (X)`/6), and `getMetaItem` of `showcase_task.default` served `All Tasks (X)`/6. 1. **The stale entries are the expansions `hydrateExpandedViewItems` registers, and the heal never addresses them.** Confirmed by the table. 2. **"The publish overwrote the packaged base under the same key, so retiring alone could leave no `showcase_task.default`."** Falsified. A package's expansions sit under the composite `PACKAGE:NAME` key and the publish writes the bare key, so the base is never overwritten. Lifting the bare entry (tier 1, `removeRuntimeShadow`) un-shadows it. Tiers 2 and 3 still run per name, for a name no package ships. 3. **Another package's name.** The hydration skips a name `anotherPackageShips`. The change records only the names it registered, so the heal never touches a skipped name. Pinned and ablated below. 4. **Scope gates.** The expansion heal runs after the `organizationId` gate and through the same walk, so it inherits both gates: an organization-scoped heal touches nothing (and the record is kept for a later environment-wide heal), and re-registration stays control-plane only. Pinned. 5. **Which source each door reads.** Confirmed, with one correction. Both list doors read `getMetaItems`, which starts from `registry.listItems` (bare and composite entries alike). With no stored row left, nothing is merged over that list, so it is the stale source. The by-name read asks the MetadataService (its step 2) before the registry (step 3). That is why the card saw a correct item read on the showcase: the metadata plugin answers it from the artifact. With no MetadataService, the by-name read reaches the registry and is stale too. The pins run without one, so they exercise the registry. 6. **The dashboard leg resets cleanly because a dashboard expands to nothing.** Confirmed on the same harness (`Ops (X)` published, then reset to `Ops`). It is the control. ## The change (`packages/metadata-protocol/src/protocol.ts` only) - `hydrateExpandedViewItems` records each name it registers, per registry and per container name (`recordViewExpansion`, a `private static` `WeakMap` keyed on the registry object, the way `meta-overlay-cache.ts` keys its bucket on the engine). - `restoreArtifactRegistryView`, after its `organizationId` gate, takes the container's recorded names (`takeViewExpansions`, views only) and heals each one through the same tier walk, as a `(type, name)` of its own. It does this before the container's own walk, which returns early from three of its tiers. **Why a record and not zone 3's re-derivation.** Running `expandRuntimeViewContainer` over the deleted body at reset time has three gaps, each measurable in the code: - three of the five heal callers have no body: the cluster peer's convergence (`applyRemoteMetadataMutation`), the no-row self-heal in `deleteMetaItem`, and any caller after the row is gone; - a view row bound to a package under a name another package ships (#22057) registers no container entry to read back; - the answer depends on registry state that can move between publish and reset. A package installed in between changes which names `anotherPackageShips` skips, and the re-derived set then misses a stale entry the publish really wrote. The record holds what was registered, so the two sides cannot drift. ## Pins (`protocol.view-container-reset-expansions.test.ts`, 6 cases) The registry double carries the real `SchemaRegistry`'s key shapes, `getItem` precedence, artifact lookup, listing and both heal verbs with their refusals. `@objectstack/objectql` cannot be imported from this package because it depends on it. The engine double is `protocol.served-content-hash.test.ts`'s. 1. The card's repro: after the reset, the list, the `?object=` list and the by-name read all serve the packaged `showcase_task.default` (`All Tasks`, 7) exactly once, and the bare names are empty. 2. A view the overlay added, which no package ships, is retired by the reset (tier 3). 3. Publish, reset, publish serves the second overlay on all three doors, and its reset heals again. 4. Control: a packaged dashboard resets as before. 5. An expansion name another package ships is never touched: a bare entry held under it survives the reset by identity, and the other package's artifact is unchanged. The name the container did register is healed. 6. An organization-scoped heal leaves every expansion and the container in place, and an environment-wide heal after it retires them. **The same six legs on the real composition** (a scratch file in `packages/objectql/src`, not committed), over the rebuilt `dist/`: 6/6 green, and 121/121 across it plus the 8 objectql suites driving this heal (`protocol-registry-shadow`, `environment-overlay-five-types`, `protocol-packaged-view-base`, `protocol-org-overlay-registry-gate`, `protocol-delete-object-registry-heal`, `protocol-object-overlay-layer`, `view-container-divergent-name-registrars`, `view-container-name-refusal`). ## Ablation (one-off, committed fix first; `scripts/ablation-replace.mjs` WRAP mode; this suite imports `./protocol.js`, so it resolves from `src/` and needs no build) - **Heal loop removed** (`await this.restoreArtifactRegistryView(type, expanded, organizationId);` replaced by a no-op; anchor count 1 to 0, blob `ff5f56386` to `7cd556184`): 5 red, 1 green (the dashboard control, as expected). Case 5 went red only at its "the registered name is healed" half. Restored: blob equals HEAD, `git diff HEAD` empty. - **The skipped name recorded too** (the record call moved ahead of the `anotherPackageShips` skip; anchor 1 to 0, blob to `af2c40b08`): only case 5 red, at `toBe(held)`. The heal lifted the entry it must leave. Restored the same way. A first attempt at this leg was a no-op: its replacement restated the anchor, and the tool refused it with "the anchor count moved 1 to 1", so nothing ran. ## Verification - `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. `tsc --listFiles` lists the new test once. - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2` on the merged tree `d03cbad29`: 233 files passed, 3 skipped; 28284 tests passed, 19 skipped. Also at `70a7be16a` before the merge: 232 passed, 28259 tests. - Gates: `dispatch-gates --repo objectstack-ai/objectstack --ran` at `fd1d615cb` reports 73 derived, 71 run with exit 0, 2 NOT MEASURED, 0 unrun. The new test's three pinned engine doubles were added to `scripts/engine-double-contract.pinned.json` (`--write`, +15 lines, this file's rows only). `check:engine-double-contract` is then green at 913 rows. - NOT MEASURED: `check-plugin-teardown-shape.mjs --self-test` exited 3 because this shallow clone cannot reach its pinned fixture commit `621a48760`. That run grades the script, not this diff. `check:dual-build-cjs-loads` exited 3 because 65 packages are unbuilt here. As a narrow stand-in, `require('packages/metadata-protocol/dist/index.cjs')` loads `ObjectStackProtocolImplementation`. - Built declarations: `dist/index.d.ts` gains three untyped `private static` member names on `ObjectStackProtocolImplementation` (`registeredViewExpansions`, `recordViewExpansion`, `takeViewExpansions`), beside the 279 private members already there. No public member, export, schema or accept-set change, so `Clause-②: no` stands. ## Acceptance notes - **Sibling defect, not addressed here.** A republish of the container that drops a member keeps serving that member's expanded view until restart. This was measured on the real composition: publish with an added `listViews.added_by_overlay`, then publish again without it. The list and the by-name read still serve `showcase_task.added_by_overlay`. The write-through registers the new expansion set and retires nothing it no longer contains. A reset still heals it, because the record is the union of every name this container registered. It is listed in the report for the seat to file. - **Residual, documented in the docblock.** An expansion whose walk stops on a degraded MetadataService read (tier 2, which a packaged name never reaches) stays registered until the container is published and reset again, or until a restart. The container's own name keeps its existing "a later delete heals it" behaviour. - **Two rows of one container name** (a package-less row and a package-bound one) record into one set. A reset of either heals the set, as the plain-key heal already lifts the name's single bare entry, and the remaining row is hydrated again on the next list read. - **Not done:** a live API run on `examples/app-showcase`. The full workspace build does not fit the foreground window here, so the real-composition harness stands in. A committed real-registry pin would live in `packages/objectql/src`, which is outside this claim's file surface. - `main` was merged at `881421194`, including #23167 on `protocol.ts`, with no conflict. --- _Generated by [Claude Code](https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 44e2d50 commit c6139f2

4 files changed

Lines changed: 576 additions & 0 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
Resetting a published overlay of a view container now stops serving the views it expanded to, without a restart
6+
7+
Clause-②: no
8+
9+
- A view container (a `defineView` document, such as a package's `showcase_task` views) expands to one view per entry, registered under `<object>.<viewKey>` (`showcase_task.default`, `showcase_task.in_progress`, …). A published overlay of the container registers its own expanded views under those names in the process registry.
10+
- `DELETE /api/v1/meta/view/NAME` on such an overlay answered `reset: true`, but its registry heal restored only the container's own name. `GET /api/v1/meta/view` and `GET /api/v1/meta/view?object=OBJECT` kept listing the overlay's expanded views next to the packaged ones until the process restarted, and the end-user list kept rendering the overlay's columns and label. The by-name read of an expanded view served the overlay's copy too wherever no MetadataService answered first.
11+
- The heal now also heals every view name the container registered when it was published, through the same steps as the container's own name. A packaged view of that name is served again, a view only the overlay added is removed, and a later publish of the container is served as before.
12+
- Unchanged: an organization-scoped delete still touches no registry entry, re-registration still happens on a control-plane kernel only, and a view name another package ships, which the publish never registered, is never touched by the reset. Dashboards and every other type reset as before.
13+
- No public export, signature, schema or accept-set change. The built entry declarations gain three `private static` member names on `ObjectStackProtocolImplementation`.

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19485,6 +19485,13 @@ export class ObjectStackProtocolImplementation implements
1948519485
* that no package ships, is registered as before. [#22057] The question is
1948619486
* {@link anotherPackageShips}, the one the container row's own
1948719487
* registration asks too.
19488+
*
19489+
* ## [#23149] Each registration is recorded for the delete's heal
19490+
*
19491+
* Every name registered here is recorded against the container's name
19492+
* ({@link recordViewExpansion}), and the delete's heal
19493+
* ({@link restoreArtifactRegistryView}) heals exactly those names. A name
19494+
* skipped above is not recorded, so the heal never touches it.
1948819495
*/
1948919496
private hydrateExpandedViewItems(
1949019497
type: string,
@@ -19497,9 +19504,63 @@ export class ObjectStackProtocolImplementation implements
1949719504
shipping ??= this.shippingPackagesOf(type);
1949819505
if (this.anotherPackageShips(type, String(item.name), item._packageId, shipping)) continue;
1949919506
registry.registerItem(type, item, 'name' as any);
19507+
ObjectStackProtocolImplementation.recordViewExpansion(
19508+
registry, (data as { name?: unknown }).name, item.name,
19509+
);
1950019510
}
1950119511
}
1950219512

19513+
/**
19514+
* [#23149] The names {@link hydrateExpandedViewItems} registered, per
19515+
* registry and then per container name: what a reset of that container
19516+
* has to heal.
19517+
*
19518+
* The registry holds no link from an expanded item back to its container,
19519+
* and the container row is gone by the time the heal runs. Expanding the
19520+
* body again at reset time would also answer from registry state that may
19521+
* have moved since the publish: a package installed in between changes
19522+
* which names {@link anotherPackageShips} skips. So the heal reads this
19523+
* record instead, and heals the names that were registered, no more.
19524+
*
19525+
* Keyed on the registry, not on the protocol instance, for the reason
19526+
* `meta-overlay-cache.ts` keys its bucket on the engine: two protocol
19527+
* instances over one registry share the entries they write. A `WeakMap`,
19528+
* so the record lives as long as the registry and no longer.
19529+
*
19530+
* Two rows of one name (a package-less row and a package-bound one) record
19531+
* into one set. A reset of either heals the set, as the plain-key heal
19532+
* already lifts the one bare entry the name has, and the remaining row is
19533+
* hydrated again on the next list read.
19534+
*/
19535+
private static readonly registeredViewExpansions = new WeakMap<object, Map<string, Set<string>>>();
19536+
19537+
/** [#23149] Records one expansion registration. See {@link registeredViewExpansions}. */
19538+
private static recordViewExpansion(registry: unknown, container: unknown, expanded: unknown): void {
19539+
if (!registry || typeof registry !== 'object') return;
19540+
if (typeof container !== 'string' || container === '') return;
19541+
if (typeof expanded !== 'string' || expanded === '') return;
19542+
let byContainer = this.registeredViewExpansions.get(registry);
19543+
if (byContainer === undefined) this.registeredViewExpansions.set(registry, (byContainer = new Map()));
19544+
let names = byContainer.get(container);
19545+
if (names === undefined) byContainer.set(container, (names = new Set()));
19546+
names.add(expanded);
19547+
}
19548+
19549+
/**
19550+
* [#23149] Removes and returns the expansions recorded for a view
19551+
* container, for the heal to heal once. Empty for every type but `view`,
19552+
* and for a name no container registered expansions under.
19553+
*/
19554+
private static takeViewExpansions(registry: unknown, type: string, container: string): string[] {
19555+
if (canonicalMetaType(type) !== 'view') return [];
19556+
if (!registry || typeof registry !== 'object') return [];
19557+
const byContainer = this.registeredViewExpansions.get(registry);
19558+
const names = byContainer?.get(container);
19559+
if (byContainer === undefined || names === undefined) return [];
19560+
byContainer.delete(container);
19561+
return [...names];
19562+
}
19563+
1950319564
/**
1950419565
* [#21980, #22057] True when a code package other than `own` ships an
1950519566
* artifact named `name`: some artifact in {@link shippedArtifactsOf}, the
@@ -19735,6 +19796,31 @@ export class ObjectStackProtocolImplementation implements
1973519796
* still guards re-registration only, because that is a fact about the
1973619797
* kernel this protocol instance serves, not about the row in hand.
1973719798
*
19799+
* ## [#23149] A view container's expansions are healed too
19800+
*
19801+
* A view container row registers more than its plain key: every view it
19802+
* expands to is registered under its own name `<object>.<viewKey>`
19803+
* ({@link hydrateExpandedViewItems}). The tiers above healed the container's
19804+
* plain key alone, so after a reset of a packaged view container that
19805+
* answered `reset: true`, the list doors kept serving the overlay's
19806+
* expanded views until a restart. Measured on a real `SchemaRegistry`: the
19807+
* packaged expansions sit under `<packageId>:<object>.<viewKey>`, untouched
19808+
* by the publish, and the overlay's under the bare names, where the reset
19809+
* left them.
19810+
*
19811+
* So each name the container registered ({@link takeViewExpansions}) is
19812+
* healed by this same walk, as a `(type, name)` of its own: a packaged view
19813+
* of that name is un-shadowed, a baseline is re-registered, and a name no
19814+
* layer serves is retired. Each name inherits every gate above: an
19815+
* organization-scoped heal touches none of them, and only a control-plane
19816+
* kernel re-registers. A name the hydration skipped because another
19817+
* package ships it was never recorded, so it is never touched.
19818+
*
19819+
* The names are taken before the walk, so each registration is healed
19820+
* once. A walk that stops on a degraded baseline read leaves that name
19821+
* registered until the container is published and reset again, or until a
19822+
* restart.
19823+
*
1973819824
* Best-effort: a failure must never block the delete that already
1973919825
* succeeded; the next full reload fixes the registry anyway.
1974019826
*/
@@ -19756,6 +19842,15 @@ export class ObjectStackProtocolImplementation implements
1975619842
// [#6780] ADR-0005 — the plain-key entry belongs to the env-wide row,
1975719843
// so only an env-wide removal may heal (or retire) it.
1975819844
if (organizationId !== null && organizationId !== undefined) return;
19845+
// [#23149] The view container's expansions, each by this same walk.
19846+
// See the header. Ahead of the container's own walk, which returns
19847+
// early from three of its tiers.
19848+
const expansions = ObjectStackProtocolImplementation.takeViewExpansions(
19849+
(this.engine as any)?.registry, type, name,
19850+
);
19851+
for (const expanded of expansions) {
19852+
await this.restoreArtifactRegistryView(type, expanded, organizationId);
19853+
}
1975919854
try {
1976019855
const registry: any = this.engine.registry;
1976119856
const singular = PLURAL_TO_SINGULAR[type] ?? type;

0 commit comments

Comments
 (0)