diff --git a/.changeset/23198-container-expansion-reconcile.md b/.changeset/23198-container-expansion-reconcile.md new file mode 100644 index 00000000000..3264ef49019 --- /dev/null +++ b/.changeset/23198-container-expansion-reconcile.md @@ -0,0 +1,14 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +Republishing a view container without one of its views now stops serving that view, without a restart + +Clause-②: no + +- A view container (a `defineView` document, such as a package's `showcase_task` views) expands to one view per entry, registered in the process registry under `.`. A publish of the container registered the views it expanded to and removed none it no longer expanded to. So after `POST /api/v1/meta/view/NAME/publish` of a container whose `listViews` lost an entry, `GET /api/v1/meta/view`, `GET /api/v1/meta/view?object=OBJECT` and the by-name read of that view kept serving it until the process restarted. +- Every write that changes a view container's stored row now leaves the registry holding the views that row expands to: a publish, a save that publishes, a rollback, a revert, a replica's convergence, a reset and a delete. So does the list read's and the boot's hydration of a stored row. The write answers after this, so a read that follows it serves the published set. +- A view the container no longer expands to is healed the way a reset heals it: where a package ships a view of that name, the package's view is served again, and a name nothing else serves is removed. As for a reset, a name whose MetadataService read fails stays registered until a restart. +- When one container name has two stored rows (one bound to no package, one bound to a package), each row is compared with what that row registered before. A republish of one row removes only the views that row dropped, and the list read leaves both rows' views registered however often it runs. A view both rows expand to, which one row drops, is served from the other row's stored row and registered again by the next list read. A reset or delete of the name heals every row's views, as before. +- Unchanged: an organization-scoped write touches no registry entry, a baseline is re-registered on a control-plane kernel only, and a view name another package ships is never registered or healed for the container. Every other type registers and resets as before. +- No public export, signature, schema or accept-set change. In the built entry declarations, `ObjectStackProtocolImplementation` gains one untyped `private` member name and loses two `private static` ones. diff --git a/packages/metadata-protocol/src/protocol.hydrate-overlay-canonical-type.test.ts b/packages/metadata-protocol/src/protocol.hydrate-overlay-canonical-type.test.ts index ef17866398c..faf362007f2 100644 --- a/packages/metadata-protocol/src/protocol.hydrate-overlay-canonical-type.test.ts +++ b/packages/metadata-protocol/src/protocol.hydrate-overlay-canonical-type.test.ts @@ -231,11 +231,11 @@ describe('[#9111] the manifest map is tolerant AND incomplete where the URL map // ═══════════════════════════════════════════════════════════════════════════ describe('[#9111] `hydrateOverlayIntoRegistry` asserts its type is canonical', () => { - it('refuses a manifest-BLIND plural with code + status, and mints nothing', () => { + it('refuses a manifest-BLIND plural with code + status, and mints nothing', async () => { const { protocol, registeredItems } = makeUnscopedProtocol([]); let caught: any; try { - protocol.hydrateOverlayIntoRegistry('fields', { name: 'showcase_task.title' }, { + await protocol.hydrateOverlayIntoRegistry('fields', { name: 'showcase_task.title' }, { packageId: null, organizationId: null, }); @@ -248,45 +248,45 @@ describe('[#9111] `hydrateOverlayIntoRegistry` asserts its type is canonical', ( expect(registeredItems).toHaveLength(0); }); - it('refuses a manifest-PRESENT plural too — the #8862 residue this card was filed on', () => { + it('refuses a manifest-PRESENT plural too — the #8862 residue this card was filed on', async () => { // `'objects'` folds in both maps, so no producer delivers it today. // The assert is the COMPLETE contract rather than the narrow // at-rest predicate, so a future unfolded caller is refused whichever // plural it holds. Ablation 3 is what keeps this distinct from §3. const { protocol, registeredItems } = makeUnscopedProtocol([]); - expect(() => protocol.hydrateOverlayIntoRegistry('objects', { name: 'ticket' }, { + await expect(protocol.hydrateOverlayIntoRegistry('objects', { name: 'ticket' }, { packageId: null, organizationId: null, - })).toThrowError(/Refusing to register a SchemaRegistry overlay entry/); + })).rejects.toThrowError(/Refusing to register a SchemaRegistry overlay entry/); expect(registeredItems).toHaveLength(0); }); - it('is judged on the SPELLING, ahead of every no-op return', () => { + it('is judged on the SPELLING, ahead of every no-op return', async () => { // An org-scoped row returns `false` without registering. The assert // still fires: a caller that stopped folding must not be able to hide // behind a body this call happened not to register anyway. const { protocol } = makeUnscopedProtocol([]); - expect(() => protocol.hydrateOverlayIntoRegistry('fields', { name: 'x' }, { + await expect(protocol.hydrateOverlayIntoRegistry('fields', { name: 'x' }, { packageId: null, organizationId: 'org_alpha', - })).toThrowError(/Refusing to register a SchemaRegistry overlay entry/); + })).rejects.toThrowError(/Refusing to register a SchemaRegistry overlay entry/); // …and a body with no `name` at all. - expect(() => protocol.hydrateOverlayIntoRegistry('fields', { label: 'no name' }, { + await expect(protocol.hydrateOverlayIntoRegistry('fields', { label: 'no name' }, { packageId: null, organizationId: null, - })).toThrowError(/Refusing to register a SchemaRegistry overlay entry/); + })).rejects.toThrowError(/Refusing to register a SchemaRegistry overlay entry/); }); - it('cannot refuse a canonical type or a plugin-registered kind', () => { + it('cannot refuse a canonical type or a plugin-registered kind', async () => { const { protocol, registeredItems } = makeUnscopedProtocol([]); // Canonical — registers, as before. - expect(protocol.hydrateOverlayIntoRegistry('view', { name: 'grid' }, { + expect(await protocol.hydrateOverlayIntoRegistry('view', { name: 'grid' }, { packageId: null, organizationId: null, })).toBe(true); // A kind the platform has never heard of: `canonicalMetaType` is the // identity for anything the static map does not carry, so this gate is // positive-control-by-construction and can never fire on a plugin kind. - expect(protocol.hydrateOverlayIntoRegistry('acme_widget', { name: 'w' }, { + expect(await protocol.hydrateOverlayIntoRegistry('acme_widget', { name: 'w' }, { packageId: null, organizationId: null, })).toBe(true); expect(registeredItems.map((r) => r.type)).toEqual(['view', 'acme_widget']); diff --git a/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts b/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts index dadbb7dbd89..ba41458369e 100644 --- a/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts +++ b/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts @@ -103,12 +103,13 @@ const SITES: ReadonlyArray; }; +/** + * [#23198] What a write did to a view container's stored rows, as + * `ObjectStackProtocolImplementation.reconcileViewExpansions` takes it. + * + * - `binding`: one row was hydrated (a publish, a republish, a rollback, a + * revert, a replica's convergence, the boot, the list read). `binding` is + * the row's package binding, `undefined` for the package-less row, and + * `expansion` is everything that row's body expands to now. + * - `removed: 'every-row'`: a row of the name was removed (a reset, a delete, + * a revert's removal, a replica's removal). The heal is handed no binding, + * so it addresses every row of the name, and the set they imply is empty. + */ +type ViewContainerWrite = + | { + readonly container: string; + readonly binding: string | undefined; + readonly expansion: ReadonlyArray>; + } + | { readonly container: string; readonly removed: 'every-row' }; + /** * ADR-0010 §3.3 — Overlay the artifact's metadata-protection envelope * onto a returned item so artifact-level lock/packageId/provenance @@ -7502,7 +7522,7 @@ export class ObjectStackProtocolImplementation implements // way the recovery callers derive it (#4867 / #4636) — the wire // carries no binding, and must not. const packageId = await this.resolveOverlayPackageBinding(type, evt.name, orgId, evt.member); - this.applyRegistryWriteThrough({ + await this.applyRegistryWriteThrough({ type, name: evt.name, item: current.body, @@ -9690,7 +9710,7 @@ export class ObjectStackProtocolImplementation implements if (this.environmentId === undefined) { for (const { name, data, packageId: recPkg, organizationId: recOrg } of overlays) { if (request.type === 'object' && this.isUnservedSealedOverlay(request.type, name)) continue; - this.hydrateOverlayIntoRegistry(request.type, data, { + await this.hydrateOverlayIntoRegistry(request.type, data, { packageId: recPkg, organizationId: recOrg, }); @@ -18883,10 +18903,17 @@ export class ObjectStackProtocolImplementation implements * stays with the callers: that is a fact about the kernel this protocol * instance serves, not about the row in hand. * - * Returns whether the row is live in this registry's view: registered, or - * answered from its row as the section below states. Org-scoped rows, + * Resolves to whether the row is live in this registry's view: registered, + * or answered from its row as the section below states. Org-scoped rows, * bodies without a `name`, and registry doubles without `registerItem` - * are no-ops and return `false`. The boot's page report reads it. + * are no-ops and resolve to `false`. The boot's page report reads it. + * + * [#23198] Asynchronous because a view container's expansion ends in + * {@link reconcileViewExpansions}, which heals each name the row no longer + * expands to through the delete's walk, and that walk reads the + * MetadataService. Everything up to that point still runs before the + * first `await`, the type assert below included, and every caller awaits + * it, so a refusal reaches the same `catch` it reached before. * * ## [#22057] A VIEW row bound to one package, never under a name another ships * @@ -19001,11 +19028,11 @@ export class ObjectStackProtocolImplementation implements * anything the static map does not carry, so a runtime kind the platform * has never heard of can never trip this gate. */ - private hydrateOverlayIntoRegistry( + private async hydrateOverlayIntoRegistry( type: string, data: unknown, options: { packageId?: string | null; organizationId: string | null }, - ): boolean { + ): Promise { // [#9111] See the header: the mint door asserts, the caller folds. // Placed FIRST, ahead of every no-op return below, so the contract is // judged on the spelling itself rather than on whether this particular @@ -19037,7 +19064,7 @@ export class ObjectStackProtocolImplementation implements // judged by their own names. ⛔ View only. const bound = canonicalType === 'view' ? boundPackageOf(options.packageId) : undefined; if (bound !== undefined && this.anotherPackageShips(type, String((data as any).name), bound)) { - this.hydrateExpandedViewItems(type, data, options, registry); + await this.hydrateExpandedViewItems(type, data, options); return true; } // [#23074] The artifact of the row's OWN bundle member: an overlay of @@ -19071,7 +19098,7 @@ export class ObjectStackProtocolImplementation implements // tenant row it is, it is reported as one and never armed. const envelope = canonicalType === 'flow' ? undefined : artifact; registry.registerItem(type, mergeArtifactProtection(type, stateTenantAuthorship(data), envelope), 'name' as any); - this.hydrateExpandedViewItems(type, data, options, registry); + await this.hydrateExpandedViewItems(type, data, options); return true; } @@ -19486,34 +19513,116 @@ export class ObjectStackProtocolImplementation implements * {@link anotherPackageShips}, the one the container row's own * registration asks too. * - * ## [#23149] Each registration is recorded for the delete's heal + * ## [#23149, #23198] The registration is the reconcile's * - * Every name registered here is recorded against the container's name - * ({@link recordViewExpansion}), and the delete's heal - * ({@link restoreArtifactRegistryView}) heals exactly those names. A name - * skipped above is not recorded, so the heal never touches it. + * This expands the row and hands the result to + * {@link reconcileViewExpansions}, which registers it (skipping a name + * another package ships, as above), records it against the row, and heals + * every name the row registered before and does not expand to now. A + * republish that dropped a member used to register the new set and retire + * nothing, so the dropped member was served until a restart. */ - private hydrateExpandedViewItems( + private async hydrateExpandedViewItems( type: string, data: unknown, options: { packageId?: string | null; organizationId: string | null }, - registry: any, - ): void { + ): Promise { + await this.reconcileViewExpansions(type, { + container: String((data as { name?: unknown }).name), + binding: boundPackageOf(options.packageId), + expansion: this.expandRuntimeViewContainer(type, data, { ...options, tenantAuthored: true }), + }, options.organizationId); + } + + /** + * [#23198] THE reconcile of a view container's expansions: after any write + * that changes a container's stored row (a publish, a republish, a reset, a + * delete), the registry holds exactly the expansion set the stored row now + * implies. Both sides call it: the registration + * ({@link hydrateExpandedViewItems}, for every hydration of a row) and the + * delete's heal ({@link restoreArtifactRegistryView}, whose set is + * empty). No other code registers an expansion, reads the record, or + * writes it (`protocol.view-expansion-reconcile-call-sites.test.ts`). + * + * In order: + * + * 1. Register each expanded view, unless another package ships its name + * ([#21980, #22057], {@link anotherPackageShips}). Such a name is never + * recorded, so no heal ever touches it. + * 2. Replace the record of the rows the write addresses with the names + * registered in step 1. The old names come out of the record before any + * heal runs, so a write that lands meanwhile reconciles against the new + * set. + * 3. Heal each old name the new set lacks through the delete's per-name + * walk ({@link restoreArtifactRegistryView}): a packaged view of that + * name is un-shadowed, a MetadataService baseline is re-registered, and + * a name no layer serves is retired. + * + * Every gate the walk carries applies here too. An organization-scoped + * write registers nothing and heals nothing (#6602 / #6780: the bare + * entries belong to the environment-wide row). Only a control-plane kernel + * re-registers a baseline. A walk that stops on a degraded baseline read + * retires nothing, and that name stays registered, unrecorded, until a + * restart: there is no retry ([#23149]'s residual, now reached from a + * republish as well as a reset). + * + * Views only, judged on the canonical type: no other type expands, and a + * row of another type stored under a container's name must not heal the + * container's expansions. + */ + private async reconcileViewExpansions( + type: string, + write: ViewContainerWrite, + /** The write's own scope: `null` for an env-wide row. [#6780] */ + organizationId: string | null, + ): Promise { + if (canonicalMetaType(type) !== 'view') return; + if (organizationId !== null && organizationId !== undefined) return; + const registry: any = (this.engine as any)?.registry; + if (!registry || typeof registry !== 'object') return; + + // 1. The set the stored row implies now. A non-empty expansion comes + // only from the hydrator, past its `registerItem` capability probe. + const registered = new Set(); let shipping: ShippingPackages | undefined; - for (const item of this.expandRuntimeViewContainer(type, data, { ...options, tenantAuthored: true })) { + for (const item of 'expansion' in write ? write.expansion : []) { shipping ??= this.shippingPackagesOf(type); if (this.anotherPackageShips(type, String(item.name), item._packageId, shipping)) continue; registry.registerItem(type, item, 'name' as any); - ObjectStackProtocolImplementation.recordViewExpansion( - registry, (data as { name?: unknown }).name, item.name, - ); + registered.add(String(item.name)); + } + + // 2. The record: the addressed rows' old names out, the new set in. + const record = ObjectStackProtocolImplementation.registeredViewExpansions; + let containers = record.get(registry); + if (containers === undefined) record.set(registry, (containers = new Map())); + const rows = containers.get(write.container) ?? new Map>(); + const previous = new Set(); + if ('removed' in write) { + for (const names of rows.values()) for (const name of names) previous.add(name); + rows.clear(); + } else { + const row = write.binding ?? ''; + for (const name of rows.get(row) ?? []) previous.add(name); + if (registered.size > 0) rows.set(row, registered); + else rows.delete(row); + } + if (rows.size > 0) containers.set(write.container, rows); + else containers.delete(write.container); + + // 3. Heal what the rows registered before and do not now. + for (const name of previous) { + if (registered.has(name)) continue; + await this.restoreArtifactRegistryView(type, name, organizationId); } } /** - * [#23149] The names {@link hydrateExpandedViewItems} registered, per - * registry and then per container name: what a reset of that container - * has to heal. + * [#23149, #23198] The names {@link reconcileViewExpansions} registered, + * per registry, then per container name, then per stored row of that name + * (its package binding, `''` for the package-less row). It is what a later + * write of that row reconciles against, and what a reset heals. Only the + * reconcile reads or writes it. * * The registry holds no link from an expanded item back to its container, * and the container row is gone by the time the heal runs. Expanding the @@ -19527,39 +19636,19 @@ export class ObjectStackProtocolImplementation implements * instances over one registry share the entries they write. A `WeakMap`, * so the record lives as long as the registry and no longer. * - * Two rows of one 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 one bare entry the name has, and the remaining row is - * hydrated again on the next list read. + * Per row, because one container name can have two stored rows (a + * package-less row and a package-bound one, ADR-0048), and the list read + * hydrates both on every call. Keyed on the name alone, each row's + * hydration reconciled against the other row's set, so a list read + * retired the members one row has and the other lacks. 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. A reset addresses every row of the name, because + * its heal is handed no binding, and the row that remains is hydrated + * again on the next list read. */ - private static readonly registeredViewExpansions = new WeakMap>>(); - - /** [#23149] Records one expansion registration. See {@link registeredViewExpansions}. */ - private static recordViewExpansion(registry: unknown, container: unknown, expanded: unknown): void { - if (!registry || typeof registry !== 'object') return; - if (typeof container !== 'string' || container === '') return; - if (typeof expanded !== 'string' || expanded === '') return; - let byContainer = this.registeredViewExpansions.get(registry); - if (byContainer === undefined) this.registeredViewExpansions.set(registry, (byContainer = new Map())); - let names = byContainer.get(container); - if (names === undefined) byContainer.set(container, (names = new Set())); - names.add(expanded); - } - - /** - * [#23149] Removes and returns the expansions recorded for a view - * container, for the heal to heal once. Empty for every type but `view`, - * and for a name no container registered expansions under. - */ - private static takeViewExpansions(registry: unknown, type: string, container: string): string[] { - if (canonicalMetaType(type) !== 'view') return []; - if (!registry || typeof registry !== 'object') return []; - const byContainer = this.registeredViewExpansions.get(registry); - const names = byContainer?.get(container); - if (byContainer === undefined || names === undefined) return []; - byContainer.delete(container); - return [...names]; - } + private static readonly registeredViewExpansions = + new WeakMap>>>(); /** * [#21980, #22057] True when a code package other than `own` ships an @@ -19624,15 +19713,19 @@ export class ObjectStackProtocolImplementation implements * for all three hydration paths. Callers pass the SAME `orgId` they wrote * the row with, so the registry's view cannot disagree with the row's * scope. + * + * [#23198] Every caller awaits it: a republish of a view container heals + * the members it dropped ({@link reconcileViewExpansions}) before the + * write answers, so the reads that follow it serve the published set. */ - private applyRegistryWriteThrough(request: { + private async applyRegistryWriteThrough(request: { type: string; name: string; item?: any; packageId?: string | null; /** The row's org scope — `null` for an env-wide row. [#6602] */ organizationId: string | null; - }): void { + }): Promise { // [#8862] The `|| request.type === 'objects'` limb is GONE — this // branch admitted the plural that {@link applyObjectRegistryMutation} // then consumed. All four call sites of this method fold at their @@ -19676,7 +19769,7 @@ export class ObjectStackProtocolImplementation implements } if (this.environmentId !== undefined) return; try { - this.hydrateOverlayIntoRegistry(request.type, request.item, { + await this.hydrateOverlayIntoRegistry(request.type, request.item, { packageId: request.packageId ?? undefined, organizationId: request.organizationId, }); @@ -19808,18 +19901,19 @@ export class ObjectStackProtocolImplementation implements * by the publish, and the overlay's under the bare names, where the reset * left them. * - * So each name the container registered ({@link takeViewExpansions}) is - * healed by this same walk, as a `(type, name)` of its own: a packaged view - * of that name is un-shadowed, a baseline is re-registered, and a name no - * layer serves is retired. Each name inherits every gate above: an - * organization-scoped heal touches none of them, and only a control-plane - * kernel re-registers. A name the hydration skipped because another - * package ships it was never recorded, so it is never touched. + * So each name the container's rows registered is healed by this same + * walk, as a `(type, name)` of its own: a packaged view of that name is + * un-shadowed, a baseline is re-registered, and a name no layer serves is + * retired. Each name inherits every gate above: an organization-scoped + * heal touches none of them, and only a control-plane kernel + * re-registers. A name the hydration skipped because another package + * ships it was never recorded, so it is never touched. * - * The names are taken before the walk, so each registration is healed - * once. A walk that stops on a degraded baseline read leaves that name - * registered until the container is published and reset again, or until a - * restart. + * [#23198] The heal does this through {@link reconcileViewExpansions}, + * the one function a republish reconciles through too, with the empty set + * a removed row implies. The names leave the record before the walk, so + * each registration is healed once. A walk that stops on a degraded + * baseline read leaves that name registered until a restart. * * Best-effort: a failure must never block the delete that already * succeeded; the next full reload fixes the registry anyway. @@ -19842,15 +19936,11 @@ export class ObjectStackProtocolImplementation implements // [#6780] ADR-0005 — the plain-key entry belongs to the env-wide row, // so only an env-wide removal may heal (or retire) it. if (organizationId !== null && organizationId !== undefined) return; - // [#23149] The view container's expansions, each by this same walk. - // See the header. Ahead of the container's own walk, which returns - // early from three of its tiers. - const expansions = ObjectStackProtocolImplementation.takeViewExpansions( - (this.engine as any)?.registry, type, name, - ); - for (const expanded of expansions) { - await this.restoreArtifactRegistryView(type, expanded, organizationId); - } + // [#23149, #23198] The view container's expansions, through the one + // reconcile, with the empty set a removed row implies; it heals each + // by this same walk. See the header. Ahead of the container's own + // walk, which returns early from three of its tiers. + await this.reconcileViewExpansions(type, { container: name, removed: 'every-row' }, organizationId); try { const registry: any = this.engine.registry; const singular = PLURAL_TO_SINGULAR[type] ?? type; @@ -21804,7 +21894,7 @@ export class ObjectStackProtocolImplementation implements // so an item that is already listable must be dispatchable // in the same breath. See {@link applyRegistryWriteThrough}. if (mode === 'publish') { - this.applyRegistryWriteThrough({ + await this.applyRegistryWriteThrough({ type: singularTypeForRepo, name: request.name, item: request.item, @@ -23202,7 +23292,7 @@ export class ObjectStackProtocolImplementation implements // saveMetaItem's post-persistence registry update path — #4521 makes // that path cover every overlay type, so promoting a drafted action // makes it dispatchable at once instead of at the next listing). - this.applyRegistryWriteThrough({ + await this.applyRegistryWriteThrough({ type: args.singularType, name: args.name, item: args.body, @@ -26519,7 +26609,7 @@ export class ObjectStackProtocolImplementation implements // the repo-facing reads above keep `it.type`, which is the // spelling the row is stored with. Two different keys, on // purpose. - this.applyRegistryWriteThrough({ + await this.applyRegistryWriteThrough({ type: PLURAL_TO_SINGULAR[it.type] ?? it.type, name: it.name, item: restored.item.body, @@ -26869,7 +26959,7 @@ export class ObjectStackProtocolImplementation implements // `already owned by package "app."` into the best-effort // `console.warn` — a rollback that reported success while the // registry kept serving the body it was supposed to revert. - this.applyRegistryWriteThrough({ + await this.applyRegistryWriteThrough({ type: singularType, name: request.name, item: result.item.body, @@ -28071,7 +28161,7 @@ export class ObjectStackProtocolImplementation implements // env-wide rows") stops depending on a query filter // staying correct, because the hydrator refuses an // org-scoped row whatever selected it. - const hydrated = this.hydrateOverlayIntoRegistry( + const hydrated = await this.hydrateOverlayIntoRegistry( normalizedType, data, { diff --git a/packages/metadata-protocol/src/protocol.view-container-reset-expansions.test.ts b/packages/metadata-protocol/src/protocol.view-container-reset-expansions.test.ts index 18947bd6a4a..e1004709aaa 100644 --- a/packages/metadata-protocol/src/protocol.view-container-reset-expansions.test.ts +++ b/packages/metadata-protocol/src/protocol.view-container-reset-expansions.test.ts @@ -46,6 +46,17 @@ * - an expansion name another package ships is never touched by the heal; * - an organization-scoped heal touches none of the container's expansions, * and an environment-wide heal after it still heals them. + * + * ## [#23198] One reconcile, after every write + * + * A republish that dropped a member registered the new expansion set and + * retired nothing, so the dropped member was served until a restart. Now every + * write that changes a container's stored row (a publish, a republish, a reset, + * a delete) ends in one reconcile, which leaves the registry holding the set the + * stored row now implies. The second block pins it per write, each on the list, + * the `?object=` list and the by-name read, plus the two rows of one container + * name. `protocol.view-expansion-reconcile-call-sites.test.ts` pins that no + * write reaches the registry around the reconcile. */ import { describe, expect, it } from 'vitest'; import { @@ -318,10 +329,10 @@ async function listItems(protocol: ObjectStackProtocolImplementation, type = 'vi } /** The views every read door serves under `name`: the list, the `?object=` list, the by-name read. */ -async function doors(protocol: ObjectStackProtocolImplementation, name: string) { +async function doors(protocol: ObjectStackProtocolImplementation, name: string, object = OBJ) { const all = await listItems(protocol); // The object-bound predicate the `?object=` list and the switcher filter on. - const byObject = all.filter((v: any) => v && v.viewKind && v.object === OBJ); + const byObject = all.filter((v: any) => v && v.viewKind && v.object === object); const one: any = await protocol.getMetaItem({ type: 'view', name }).catch(() => undefined); return { list: all.filter((v: any) => v?.name === name).map((v) => [labelOf(v), columnCountOf(v)]), @@ -451,3 +462,165 @@ describe('[#23149] a reset of a packaged view container heals the expansions its expect(registry.bare('view', OBJ)).toBeUndefined(); }); }); + +const ADDED_VIEW = `${OBJ}.added_by_overlay`; +const addedMember = () => ({ label: 'Added', type: 'grid', data, columns: [{ field: 'title' }] }); +const NOT_SERVED = { list: [], objectList: [], item: undefined }; +/** One view, served alike by the list, the `?object=` list and the by-name read. */ +const servedOnEveryDoor = (label: string, columns: number) => + ({ list: [[label, columns]], objectList: [[label, columns]], item: [label, columns] }); + +/** A container no package ships, on an object no package owns: its delete retires, never un-shadows. */ +const RT_OBJ = 'rt_ticket'; +const rtData = { provider: 'object' as const, object: RT_OBJ }; +const rtContainer = (listViews: Record) => ({ + name: RT_OBJ, + object: RT_OBJ, + list: { label: 'All Tickets', type: 'grid', data: rtData, columns: [{ field: 'title' }, { field: 'status' }] }, + listViews, +}); +const rtMember = (label: string) => ({ label, type: 'grid', data: rtData, columns: [{ field: 'title' }] }); + +describe('[#23198] after every write the registry holds the expansion set the stored container row implies', () => { + it('a republish that adds a member serves it on every door', async () => { + const { protocol } = boot(); + await publishContainerOverlay(protocol, xEdit('All Tasks (X)', true)); + expect(await doors(protocol, ADDED_VIEW)).toEqual(NOT_SERVED); + + await publishContainerOverlay(protocol, (container) => { + container.listViews = { ...container.listViews, added_by_overlay: addedMember() }; + }); + + expect(await doors(protocol, ADDED_VIEW)).toEqual(servedOnEveryDoor('Added', 1)); + expect(await doors(protocol, DEFAULT_VIEW)).toEqual(servedOnEveryDoor('All Tasks (X)', 6)); + }); + + it("the card's repro: a republish that drops members stops serving them, and a packaged view of a dropped name is served again", async () => { + const { protocol, registry } = boot(); + await publishContainerOverlay(protocol, (container) => { + container.listViews = { + in_progress: { ...container.listViews.in_progress, label: 'In Progress (X)' }, + added_by_overlay: addedMember(), + }; + }); + expect(await doors(protocol, ADDED_VIEW)).toEqual(servedOnEveryDoor('Added', 1)); + expect(await doors(protocol, IN_PROGRESS_VIEW)).toEqual(servedOnEveryDoor('In Progress (X)', 2)); + + // The republish keeps the list and drops both members. + await publishContainerOverlay(protocol, (container) => { + delete container.listViews; + }); + + // The member no package ships is served by no door; the packaged + // member's name serves the package's view, once. + expect(await doors(protocol, ADDED_VIEW)).toEqual(NOT_SERVED); + expect(await doors(protocol, IN_PROGRESS_VIEW)).toEqual(servedOnEveryDoor('In Progress', 2)); + expect(registry.bare('view', ADDED_VIEW)).toBeUndefined(); + expect(registry.bare('view', IN_PROGRESS_VIEW)).toBeUndefined(); + expect(labelOf(registry.getArtifactItem('view', IN_PROGRESS_VIEW, PKG))).toBe('In Progress'); + // The member the republish kept is still the overlay's own registration. + expect(registry.bare('view', DEFAULT_VIEW)).toBeDefined(); + expect(await doors(protocol, DEFAULT_VIEW)).toEqual(servedOnEveryDoor(...(PACKAGED_DEFAULT as [string, number]))); + }); + + it('a reset after a republish heals the set the container registers now, and no name it dropped before', async () => { + const { protocol, registry } = boot(); + await publishContainerOverlay(protocol, (container) => { + container.listViews = { ...container.listViews, added_by_overlay: addedMember() }; + }); + await publishContainerOverlay(protocol, (container) => { + delete container.listViews.added_by_overlay; + xEdit('All Tasks (X)', true)(container); + }); + // The dropped name is held by another source now. The container no + // longer registers it, so its reset must leave it. + const held = { name: ADDED_VIEW, object: OBJ, viewKind: 'list', label: 'Held elsewhere', _provenance: 'org' }; + registry.registerItem('view', held, 'name'); + + const reset: any = await protocol.deleteMetaItem({ type: 'view', name: OBJ }); + expect(reset).toMatchObject({ success: true, reset: true }); + + expect(registry.bare('view', ADDED_VIEW)).toBe(held); + expect(await doors(protocol, DEFAULT_VIEW)).toEqual(servedOnEveryDoor(...(PACKAGED_DEFAULT as [string, number]))); + expect(await doors(protocol, IN_PROGRESS_VIEW)).toEqual(servedOnEveryDoor('In Progress', 2)); + expect(registry.bare('view', DEFAULT_VIEW)).toBeUndefined(); + expect(registry.bare('view', IN_PROGRESS_VIEW)).toBeUndefined(); + }); + + it('a delete of a container no package ships retires the set its last publish registered', async () => { + const { protocol, registry } = boot(); + const OPEN = `${RT_OBJ}.open`; + const CLOSED = `${RT_OBJ}.closed`; + const RT_DEFAULT = `${RT_OBJ}.default`; + expect(await protocol.saveMetaItem({ + type: 'view', name: RT_OBJ, item: rtContainer({ open: rtMember('Open'), closed: rtMember('Closed') }), + })).toMatchObject({ success: true }); + expect(await doors(protocol, CLOSED, RT_OBJ)).toEqual(servedOnEveryDoor('Closed', 1)); + + // The republish drops `closed`: retired on every door, the rest kept. + expect(await protocol.saveMetaItem({ + type: 'view', name: RT_OBJ, item: rtContainer({ open: rtMember('Open') }), + })).toMatchObject({ success: true }); + expect(await doors(protocol, CLOSED, RT_OBJ)).toEqual(NOT_SERVED); + expect(registry.bare('view', CLOSED)).toBeUndefined(); + expect(await doors(protocol, OPEN, RT_OBJ)).toEqual(servedOnEveryDoor('Open', 1)); + + const deleted: any = await protocol.deleteMetaItem({ type: 'view', name: RT_OBJ }); + expect(deleted).toMatchObject({ success: true }); + + for (const name of [RT_DEFAULT, OPEN, CLOSED, RT_OBJ]) { + expect(await doors(protocol, name, RT_OBJ), name).toEqual(NOT_SERVED); + expect(registry.bare('view', name), name).toBeUndefined(); + } + }); + + it('two rows of one container name: each row reconciles against its own registrations', async () => { + const { protocol, registry, rows } = boot(); + const A_ONLY = `${OBJ}.a_only`; + const B_ONLY = `${OBJ}.b_only`; + const SHARED = `${OBJ}.shared`; + const member = (label: string) => ({ label, type: 'grid', data, columns: [{ field: 'title' }] }); + const { list } = shippedContainer(); + // A package-less overlay row and a row bound to the package, of one name + // (ADR-0048). Each has a member of its own, and both have `shared`. + const seed = (packageId: string | null, item: Record) => { + const row: Row = { + id: `seed_${packageId ?? 'none'}`, type: 'view', name: OBJ, organization_id: null, + package_id: packageId, state: 'active', metadata: JSON.stringify(item), + }; + rows.set(keyOf(row), row); + return row; + }; + const unbound = seed(null, { name: OBJ, list, listViews: { a_only: member('A only'), shared: member('A shared') } }); + seed(PKG, { name: OBJ, list, listViews: { b_only: member('B only'), shared: member('B shared') } }); + + // Each list read hydrates both rows. Neither row's names are healed by + // the other's hydration, however often the list is read. + for (let read = 1; read <= 2; read++) { + await listItems(protocol); + expect(labelOf(registry.bare('view', A_ONLY)), `read ${read}`).toBe('A only'); + expect(labelOf(registry.bare('view', B_ONLY)), `read ${read}`).toBe('B only'); + expect(registry.bare('view', SHARED), `read ${read}`).toBeDefined(); + } + + // The package-less row is republished without its members: the write + // the door makes after persisting it. + const republished = { name: OBJ, list }; + unbound.metadata = JSON.stringify(republished); + await (protocol as any).applyRegistryWriteThrough({ + type: 'view', name: OBJ, item: republished, packageId: null, organizationId: null, + }); + + // Its own member is retired, and the member both rows had leaves the + // registry with it. The other row's own member is untouched. + expect(registry.bare('view', A_ONLY)).toBeUndefined(); + expect(registry.bare('view', SHARED)).toBeUndefined(); + expect(labelOf(registry.bare('view', B_ONLY))).toBe('B only'); + // The first read after it: every door serves the other row's `shared` + // from its stored row, and the read registers it again. + expect(await doors(protocol, SHARED)).toEqual(servedOnEveryDoor('B shared', 1)); + expect(labelOf(registry.bare('view', SHARED))).toBe('B shared'); + expect(await doors(protocol, A_ONLY)).toEqual(NOT_SERVED); + expect(await doors(protocol, B_ONLY)).toEqual(servedOnEveryDoor('B only', 1)); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts b/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts new file mode 100644 index 00000000000..ee260c5be06 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.view-expansion-reconcile-call-sites.test.ts @@ -0,0 +1,255 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23198] The registry-heal family's closing check: every write that changes a + * stored row in `protocol.ts` reaches the registry through the write-through or + * the delete's heal, both of which end in the one reconcile of a view + * container's expansions (`reconcileViewExpansions`), and nothing else + * registers an expansion or touches the record that reconcile keeps. + * + * The family is a registry entry that a stored row put there and that outlives + * the change to that row (a reset that healed the container's key alone, a + * republish that retired nothing). A per-path fix closes only the path someone + * noticed, so this reads the whole population from the source: + * + * 1. THE STORED-ROW WRITES: every call of a repository write verb (`put`, + * `delete`, `promoteDraft`, `restoreVersion`) and every raw engine write to + * `sys_metadata`. Each is keyed by its member and its verb, and records the + * route by which the registry follows it. A write with no recorded route + * turns this red and names itself. The data door cannot write + * `sys_metadata` (its `apiMethods` are `get` and `list`), so these are all + * the writes this process makes to it. + * 2. THE CHAIN: the write-through reaches the hydrator, every path of the + * hydrator that registers the row reaches the expansion hydration, that + * reaches the reconcile, and the heal runs the reconcile ahead of its walk. + * Every call into the chain is awaited, so a write answers after it. + * 3. THE RECORD AND THE VERBS: only the reconcile reads or writes the record; + * the heal verbs are called by the heal walk alone. Every `registerItem(` + * call is pinned with its member in `protocol.register-item-call-sites.test.ts`, + * where the expansion's registration sits in the reconcile. + * + * One write is recorded as NOT reaching the registry: the orphan adoption's + * rebinding (`reassignOrphanedMetadata`, `POST /packages/:id/adopt-orphans`). + * It changes a row's package binding, which changes what a view container + * expands to, and the registry keeps the old registrations until a restart. + * It is reported for its own card. The row is shrink-only: when that write is + * routed, this turns red until the row moves to a route. + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const SOURCE = readFileSync(fileURLToPath(new URL('./protocol.ts', import.meta.url)), 'utf8'); +const LINES = SOURCE.split('\n'); + +/** A class member's header in `protocol.ts`: four spaces, optional modifiers, its name, its parameter list. */ +const MEMBER_HEADER = /^ {4}(?:(?:private|public|protected|static|async|override|readonly)\s+)*([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(/; +/** A line of a comment: a block comment's body or opener, or a line comment. */ +const COMMENT_LINE = /^\s*(?:\*|\/\*|\/\/)/; + +/** Every member header, in source order, with the offset of its line. */ +const MEMBERS: ReadonlyArray<{ name: string; offset: number }> = (() => { + const out: Array<{ name: string; offset: number }> = []; + let offset = 0; + for (const text of LINES) { + const header = MEMBER_HEADER.exec(text); + if (header) out.push({ name: header[1], offset }); + offset += text.length + 1; + } + return out; +})(); + +const lineOf = (offset: number) => SOURCE.slice(0, offset).split('\n').length - 1; +const isCode = (offset: number) => !COMMENT_LINE.test(LINES[lineOf(offset)]); + +/** The member a source offset sits in, and that member's extent. */ +function memberAt(offset: number): { name: string; start: number; end: number } { + let at = -1; + for (let i = 0; i < MEMBERS.length && MEMBERS[i].offset <= offset; i++) at = i; + if (at === -1) return { name: '(no member)', start: 0, end: 0 }; + return { name: MEMBERS[at].name, start: MEMBERS[at].offset, end: MEMBERS[at + 1]?.offset ?? SOURCE.length }; +} + +/** Every match of `pattern` on a code line, with its member. */ +function codeMatches(pattern: RegExp): Array<{ offset: number; text: string; member: string }> { + const out: Array<{ offset: number; text: string; member: string }> = []; + for (const m of SOURCE.matchAll(new RegExp(pattern.source, 'g'))) { + if (!isCode(m.index!)) continue; + out.push({ offset: m.index!, text: m[0].replace(/\s+/g, ' '), member: memberAt(m.index!).name }); + } + return out; +} + +/** The code lines of one member, by name: it must be declared exactly once. */ +function bodyOf(name: string): string { + const found = MEMBERS.filter((m) => m.name === name); + expect(found.map((m) => m.name), `the member ${name}`).toEqual([name]); + const { start, end } = memberAt(found[0].offset); + return SOURCE.slice(start, end).split('\n').filter((l) => !COMMENT_LINE.test(l)).join('\n'); +} + +// ── 1. The stored-row writes ───────────────────────────────────────────── + +/** A repository write verb on the one spelling a repository is bound under, or a raw `sys_metadata` write. */ +const STORED_ROW_WRITE = /\brepo\.(?:put|delete|promoteDraft|restoreVersion)\(|this\.engine\.(?:insert|update|delete)\(\s*'sys_metadata'/; + +type Route = + /** The write-through or the heal is called after the write, in the same member, before its next write. */ + | { readonly sameMember: 'applyRegistryWriteThrough' | 'restoreArtifactRegistryView' } + /** The member hands the row to its callers, and each of them calls `then`, which calls the write-through. */ + | { readonly handedTo: 'callers'; readonly then: 'runPublishSideEffects' } + /** An organization-scoped row, which never enters the registry (#6602): nothing to reconcile. */ + | { readonly organizationScoped: true } + /** ⛔ Not reconciled. Shrink-only: a finding, reported for its own card. */ + | { readonly bypass: string }; + +const WRITES: ReadonlyArray<{ readonly member: string; readonly verb: string; readonly route: Route }> = [ + { member: 'saveMetaItem', verb: 'repo.put(', route: { sameMember: 'applyRegistryWriteThrough' } }, + { member: 'promoteDraftForPublish', verb: 'repo.promoteDraft(', route: { handedTo: 'callers', then: 'runPublishSideEffects' } }, + { member: 'removeLegacyOrganizationRowOnUninstall', verb: 'repo.delete(', route: { organizationScoped: true } }, + { member: 'revertCommit', verb: 'repo.delete(', route: { sameMember: 'restoreArtifactRegistryView' } }, + { member: 'revertCommit', verb: 'repo.restoreVersion(', route: { sameMember: 'applyRegistryWriteThrough' } }, + { member: 'rollbackMetaItem', verb: 'repo.restoreVersion(', route: { sameMember: 'applyRegistryWriteThrough' } }, + { member: 'deleteMetaItem', verb: 'repo.delete(', route: { sameMember: 'restoreArtifactRegistryView' } }, + { member: 'deleteMetaItem', verb: 'this.engine.delete(\'sys_metadata\'', route: { sameMember: 'restoreArtifactRegistryView' } }, + { + member: 'reassignOrphanedMetadata', verb: 'this.engine.update(\'sys_metadata\'', + route: { bypass: 'rebinds a row to a package; the registry keeps the old binding\'s registrations until a restart' }, + }, +]; + +/** The verb as `WRITES` spells it: no whitespace after its open paren. */ +const verbOf = (text: string) => text.replace(/\(\s*/, '('); + +describe('[#23198] every stored-row write in protocol.ts reaches the reconcile', () => { + const writes = codeMatches(STORED_ROW_WRITE).map((w) => ({ ...w, verb: verbOf(w.text) })); + + it('the writes in the source are exactly the recorded ones', () => { + const key = (w: { member: string; verb: string }) => `${w.member}: ${w.verb}`; + expect( + writes.map(key).filter((k) => !WRITES.map(key).includes(k)), + 'a stored-row write with no recorded route to the registry: route it through applyRegistryWriteThrough ' + + '(a live row) or restoreArtifactRegistryView (a removed row), and record it in WRITES', + ).toEqual([]); + expect(writes.map(key).sort(), 'each recorded write once').toEqual(WRITES.map(key).sort()); + }); + + it('a same-member route is called after its write and before the member\'s next write', () => { + for (const row of WRITES) { + if (!('sameMember' in row.route)) continue; + const write = writes.find((w) => w.member === row.member && w.verb === row.verb)!; + const { end } = memberAt(write.offset); + const nextWrite = writes.find((w) => w.member === row.member && w.offset > write.offset)?.offset ?? end; + const routes = codeMatches(new RegExp(`this\\.${row.route.sameMember}\\(`)) + .filter(({ offset }) => offset > write.offset && offset < nextWrite); + expect(routes.length, `${row.member}: ${row.verb} → ${row.route.sameMember}`).toBeGreaterThan(0); + } + }); + + it('a write handed to its callers reaches the write-through in each of them', () => { + for (const row of WRITES) { + if (!('handedTo' in row.route)) continue; + const callers = codeMatches(new RegExp(`this\\.${row.member}\\(`)); + expect(callers.length, `${row.member}: its callers`).toBeGreaterThan(0); + for (const call of callers) { + const { end } = memberAt(call.offset); + const then = codeMatches(new RegExp(`this\\.${row.route.then}\\(`)) + .filter(({ offset }) => offset > call.offset && offset < end); + expect(then.length, `${call.member}: ${row.member} → ${row.route.then}`).toBeGreaterThan(0); + } + expect(bodyOf(row.route.then)).toContain('this.applyRegistryWriteThrough('); + } + }); + + it('an organization-scoped write reaches only a legacy organization\'s repository', () => { + for (const row of WRITES.filter((r) => 'organizationScoped' in r.route)) { + expect(bodyOf(row.member)).toContain('const repo = this.legacyOrganizationRepo('); + } + }); + + it('a recorded bypass still reaches nothing: route it, then move its row', () => { + for (const row of WRITES.filter((r) => 'bypass' in r.route)) { + const body = bodyOf(row.member); + expect(body).not.toContain('this.applyRegistryWriteThrough('); + expect(body).not.toContain('this.restoreArtifactRegistryView('); + expect(body).not.toContain('this.hydrateOverlayIntoRegistry('); + } + }); + + it('a repository is bound under the one spelling the census reads', () => { + // A repository bound under any other name would write past the census. + const ACQUIRE = /this\.(?:getOverlayRepo\(\)|legacyOrganizationRepo\(|repositoryFor\()|new SysMetadataRepository\(/; + const OWN = ['getOverlayRepo', 'legacyOrganizationRepo', 'repositoryFor']; + const stray = codeMatches(ACQUIRE).filter(({ offset, member }) => { + if (OWN.includes(member)) return false; + const line = LINES[lineOf(offset)].trim(); + return !/^const repo = this\.(?:getOverlayRepo\(\)|legacyOrganizationRepo\([^)]*\));$/.test(line) + && !/this\.getOverlayRepo\(\)\.listDrafts\(/.test(line); + }); + expect(stray.map(({ member, offset }) => `${member}: ${LINES[lineOf(offset)].trim()}`)).toEqual([]); + expect(codeMatches(/new SysMetadataRepository\(/).map((m) => m.member)).toEqual(['repositoryFor']); + }); +}); + +// ── 2. The chain ───────────────────────────────────────────────────────── + +describe('[#23198] the write-through and the heal both end in the one reconcile', () => { + it('the write-through hydrates every type but `object` through the hydrator', () => { + expect(bodyOf('applyRegistryWriteThrough')).toContain('await this.hydrateOverlayIntoRegistry(request.type, request.item, {'); + }); + + it('every path of the hydrator that registers the row hydrates its expansions first', () => { + const body = bodyOf('hydrateOverlayIntoRegistry'); + const lines = body.split('\n').filter((l) => !COMMENT_LINE.test(l) && l.trim() !== ''); + const registers = lines.map((l, i) => ({ l, i })).filter(({ l }) => l.trim().startsWith('return true;')); + expect(registers.length, 'the registering paths').toBe(2); + for (const { i } of registers) { + expect(lines[i - 1].trim()).toBe('await this.hydrateExpandedViewItems(type, data, options);'); + } + }); + + it('the reconcile has exactly two callers: the registration and the heal', () => { + expect(codeMatches(/this\.reconcileViewExpansions\(/).map((m) => m.member).sort()) + .toEqual(['hydrateExpandedViewItems', 'restoreArtifactRegistryView']); + expect(codeMatches(/this\.hydrateExpandedViewItems\(/).map((m) => m.member)) + .toEqual(['hydrateOverlayIntoRegistry', 'hydrateOverlayIntoRegistry']); + }); + + it('every call into the chain is awaited, so a write answers after its reconcile', () => { + const CHAIN = /this\.(?:applyRegistryWriteThrough|hydrateOverlayIntoRegistry|hydrateExpandedViewItems|reconcileViewExpansions|restoreArtifactRegistryView)\(/; + const calls = codeMatches(CHAIN); + expect(calls.length).toBeGreaterThan(0); + expect( + calls.filter(({ offset }) => !/\bawait\s+$/.test(SOURCE.slice(offset - 12, offset))) + .map(({ member, text }) => `${member}: ${text}`), + ).toEqual([]); + }); + + it('the heal reconciles to the empty set ahead of its tier walk, past the organization gate', () => { + const body = bodyOf('restoreArtifactRegistryView'); + const gate = body.indexOf('if (organizationId !== null && organizationId !== undefined) return;'); + const reconcile = body.indexOf("await this.reconcileViewExpansions(type, { container: name, removed: 'every-row' }, organizationId);"); + const walk = body.indexOf('registry.removeRuntimeShadow('); + expect(gate).toBeGreaterThan(-1); + expect(reconcile).toBeGreaterThan(gate); + expect(walk).toBeGreaterThan(reconcile); + }); +}); + +// ── 3. The record and the verbs ───────────────────────────────────────── + +describe('[#23198] nothing reaches the record or the heal verbs around the reconcile', () => { + it('only the reconcile reads or writes the record', () => { + const DECLARATION = ' private static readonly registeredViewExpansions ='; + const uses = codeMatches(/\bregisteredViewExpansions\b/) + .filter(({ offset }) => !LINES[lineOf(offset)].startsWith(DECLARATION)); + expect(uses.map((u) => u.member)).toEqual(['reconcileViewExpansions']); + expect(SOURCE.split(DECLARATION).length - 1, 'one declaration').toBe(1); + }); + + it('the heal verbs are called by the heal walk alone', () => { + const verbs = codeMatches(/\.(?:removeRuntimeShadow|removeOverlayEntry)\(/); + expect(verbs.length).toBeGreaterThan(0); + expect([...new Set(verbs.map((v) => v.member))]).toEqual(['restoreArtifactRegistryView']); + }); +});