diff --git a/.changeset/23149-view-container-reset-heals-expansions.md b/.changeset/23149-view-container-reset-heals-expansions.md new file mode 100644 index 00000000000..94609c3c34d --- /dev/null +++ b/.changeset/23149-view-container-reset-heals-expansions.md @@ -0,0 +1,13 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +Resetting a published overlay of a view container now stops serving the views it expanded to, 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 under `.` (`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. +- `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. +- 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. +- 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. +- No public export, signature, schema or accept-set change. The built entry declarations gain three `private static` member names on `ObjectStackProtocolImplementation`. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index e5f31f6faed..fa71ce5ada9 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -19485,6 +19485,13 @@ export class ObjectStackProtocolImplementation implements * that no package ships, is registered as before. [#22057] The question is * {@link anotherPackageShips}, the one the container row's own * registration asks too. + * + * ## [#23149] Each registration is recorded for the delete's heal + * + * 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. */ private hydrateExpandedViewItems( type: string, @@ -19497,9 +19504,63 @@ export class ObjectStackProtocolImplementation implements 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, + ); } } + /** + * [#23149] The names {@link hydrateExpandedViewItems} registered, per + * registry and then per container name: what a reset of that container + * has to heal. + * + * 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 + * body again at reset time would also answer from registry state that may + * have moved since the publish: a package installed in between changes + * which names {@link anotherPackageShips} skips. So the heal reads this + * record instead, and heals the names that were registered, no more. + * + * Keyed on the registry, not on the protocol instance, for the reason + * `meta-overlay-cache.ts` keys its bucket on the engine: two protocol + * 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. + */ + 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]; + } + /** * [#21980, #22057] True when a code package other than `own` ships an * artifact named `name`: some artifact in {@link shippedArtifactsOf}, the @@ -19735,6 +19796,31 @@ export class ObjectStackProtocolImplementation implements * still guards re-registration only, because that is a fact about the * kernel this protocol instance serves, not about the row in hand. * + * ## [#23149] A view container's expansions are healed too + * + * A view container row registers more than its plain key: every view it + * expands to is registered under its own name `.` + * ({@link hydrateExpandedViewItems}). The tiers above healed the container's + * plain key alone, so after a reset of a packaged view container that + * answered `reset: true`, the list doors kept serving the overlay's + * expanded views until a restart. Measured on a real `SchemaRegistry`: the + * packaged expansions sit under `:.`, untouched + * 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. + * + * 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. + * * Best-effort: a failure must never block the delete that already * succeeded; the next full reload fixes the registry anyway. */ @@ -19756,6 +19842,15 @@ 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); + } try { const registry: any = this.engine.registry; const singular = PLURAL_TO_SINGULAR[type] ?? type; 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 new file mode 100644 index 00000000000..18947bd6a4a --- /dev/null +++ b/packages/metadata-protocol/src/protocol.view-container-reset-expansions.test.ts @@ -0,0 +1,453 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23149] Resetting a published overlay of a packaged view CONTAINER heals the + * expanded views its publish registered, not only the container's plain key. + * + * ## What was measured + * + * On `examples/app-showcase` an admin overlaid the packaged `showcase_task` + * container (`list.label` → `All Tasks (X)`, the `progress` column dropped), + * published it, and reset it: `DELETE /meta/view/showcase_task` answered + * `{ success: true, reset: true }`, while `GET /meta/view` and + * `GET /meta/view?object=showcase_task` kept serving `showcase_task.default` as + * the overlay's six-column `All Tasks (X)` until a restart. + * + * Reproduced one layer down, over the real `ObjectQL` engine and its + * `SchemaRegistry` before this change: the boot registers the packaged + * container's expansions under `:.`; the publish + * registers the overlay's expansions under the bare `.` names + * ({@link ObjectStackProtocolImplementation} `hydrateExpandedViewItems`) and + * leaves the packaged entries untouched; the reset's heal lifted the bare + * `showcase_task` entry and nothing else, so the list read listed the packaged + * `showcase_task.default` AND the overlay's stale one, and the by-name read + * (with no MetadataService answering first) served the stale one. The heal now + * walks every name the container registered through the same tiers it walks + * for the container's own name. + * + * ## The harness + * + * `@objectstack/objectql` cannot be imported here: it depends on this package. + * So the registry is a double carrying the real `SchemaRegistry`'s key shapes + * (`:` for a package's entry, the bare name for a hydrated + * row), its `getItem` precedence, its artifact lookup, its listing, and its two + * heal verbs (`removeRuntimeShadow`, `removeOverlayEntry`) with their refusals. + * The engine is the in-memory `sys_metadata` double of + * `protocol.served-content-hash.test.ts`. No MetadataService is registered, so + * the by-name read answers from the registry, the source that was stale. + * + * ## Pins + * + * - after the reset, the list read, the object-filtered list and the by-name + * read all serve the packaged expansion, once, and the bare names are empty; + * - a view the overlay added, which no package ships, is retired; + * - publish → reset → publish serves the second overlay, and its reset heals; + * - control: a packaged dashboard (no expansion) resets as before; + * - 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. + */ +import { describe, expect, it } from 'vitest'; +import { + assertEngineDeleteDispatch, + assertEngineFindOnePredicate, + assertEngineUpdateDispatch, + isTenantAuthored, +} from '@objectstack/metadata-core'; +import { expandViewContainer } from '@objectstack/spec/ui'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const PKG = 'com.example.showcase'; +const OTHER_PKG = 'com.example.other'; +const OBJ = 'showcase_task'; +const DEFAULT_VIEW = `${OBJ}.default`; +const IN_PROGRESS_VIEW = `${OBJ}.in_progress`; +const SHIPPED_COLUMNS = ['title', 'project', 'assignee', 'status', 'priority', 'due_date', 'progress']; + +const data = { provider: 'object' as const, object: OBJ }; + +/** The `defineView` container the package ships for its object. */ +const shippedContainer = () => ({ + list: { label: 'All Tasks', type: 'grid', data, columns: SHIPPED_COLUMNS.map((field) => ({ field })) }, + listViews: { + in_progress: { label: 'In Progress', type: 'grid', data, columns: [{ field: 'title' }, { field: 'status' }] }, + }, +}); + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; + checksum?: string; + [k: string]: unknown; +} + +function keyOf(w: Record) { + return `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; +} + +function matchesWhere(r: Record, where: Record = {}): boolean { + for (const [k, v] of Object.entries(where)) { + if (k === '$or') { + if (!(v as Array>).some((c) => matchesWhere(r, c))) return false; + continue; + } + // Any other combinator is REFUSED, never read as a field name. + if (k.startsWith('$')) throw new Error(`stub engine: combinator '${k}' is not implemented`); + if (v === undefined) continue; + if (r[k] !== v) return false; + } + return true; +} + +/** + * The real `SchemaRegistry`'s generic-metadata half, for the verbs the + * protocol's save, read and heal paths call on a view or a dashboard. + */ +function makeRegistry() { + const byType = new Map>(); + const collection = (type: string) => { + let c = byType.get(type); + if (!c) byType.set(type, (c = new Map())); + return c; + }; + const isArtifact = (it: any) => !!it && !!it._packageId && it._packageId !== 'sys_metadata' && !isTenantAuthored(it); + return { + registerItem(type: string, item: any, keyField = 'name', packageId?: string) { + // `applyProtection` stamps a package's entry; a bare registration is left as handed in. + if (packageId) { + if (item._packageId === undefined) item._packageId = packageId; + if (item._provenance === undefined) item._provenance = 'package'; + } + const name = String(item[keyField]); + collection(type).set(packageId ? `${packageId}:${name}` : name, item); + }, + /** Bare slot first, then prefer-local, then the first composite. */ + getItem(type: string, name: string, packageId?: string) { + const c = byType.get(type); + if (!c) return undefined; + const direct = c.get(name); + if (direct) return direct; + if (packageId) { + const local = c.get(`${packageId}:${name}`); + if (local) return local; + } + for (const [key, item] of c) if (key.endsWith(`:${name}`)) return item; + return undefined; + }, + getArtifactItem(type: string, name: string, packageId?: string) { + const c = byType.get(type); + if (!c) return undefined; + if (packageId) { + const local = c.get(`${packageId}:${name}`); + if (isArtifact(local)) return local; + } + for (const [key, item] of c) if (key !== name && key.endsWith(`:${name}`) && isArtifact(item)) return item; + const direct = c.get(name); + if (isArtifact(direct) && (!packageId || direct._packageId === packageId)) return direct; + return undefined; + }, + listItems(type: string, packageId?: string) { + const all = [...(byType.get(type)?.values() ?? [])]; + return packageId ? all.filter((it) => it._packageId === packageId) : all; + }, + /** Drops the bare entry only while a package's entry of the name remains. */ + removeRuntimeShadow(type: string, name: string) { + const c = byType.get(type); + if (!c || !c.has(name)) return false; + for (const [key, item] of c) { + if (key !== name && key.endsWith(`:${name}`) && item?._packageId && item._packageId !== 'sys_metadata') { + c.delete(name); + return true; + } + } + return false; + }, + /** Drops the bare entry unless it is itself a package's artifact. */ + removeOverlayEntry(type: string, name: string) { + const c = byType.get(type); + if (!c || !c.has(name)) return false; + if (isArtifact(c.get(name))) return false; + c.delete(name); + return true; + }, + getPackagedObjectOwner(name: string) { + return name === OBJ ? { packageId: PKG, ownership: 'own' } : undefined; + }, + isPackageDisabled: () => false, + getPackage: () => undefined, + registerObject: () => {}, + /** Test reach: the bare slot alone, as no registry verb answers it. */ + bare(type: string, name: string) { + return byType.get(type)?.get(name); + }, + }; +} + +function makeEngine() { + const rows = new Map(); + const historyRows: Array> = []; + const auditRows: Array> = []; + let nextId = 0; + const findRow = (w: Record): { key: string; row: Row } | null => { + if (w.id !== undefined) { + for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r }; + return null; + } + if (w.package_id !== undefined) { + const k = keyOf(w); + const r = rows.get(k); + return r ? { key: k, row: r } : null; + } + for (const [k, r] of rows) if (matchesWhere(r, w)) return { key: k, row: r }; + return null; + }; + const registry = makeRegistry(); + const engine: any = { + async findOne(table: string, o: { where: Record }) { + assertEngineFindOnePredicate(table, o); + if (table === 'sys_metadata_history') return historyRows.find((h) => matchesWhere(h, o.where)) ?? null; + return findRow(o.where)?.row ?? null; + }, + async find(table: string, o: { where?: Record; limit?: number } = {}) { + const matched = table === 'sys_metadata_audit' + ? auditRows.filter((a) => matchesWhere(a, o.where)) + : table === 'sys_metadata_history' + ? historyRows.filter((h) => matchesWhere(h, o.where)) + : Array.from(rows.values()).filter((r) => matchesWhere(r, o.where)); + // The caller's bound, applied after the filter, by presence. + return typeof o?.limit === 'number' ? matched.slice(0, o.limit) : matched; + }, + async insert(table: string, row: Record) { + nextId += 1; + if (table === 'sys_metadata_audit') { + auditRows.push({ id: `a_${nextId}`, ...row }); + return { id: `a_${nextId}` }; + } + if (table === 'sys_metadata_history') { + historyRows.push({ id: `h_${nextId}`, ...row }); + return { id: `h_${nextId}` }; + } + const stored = { id: `r_${nextId}`, ...row } as Row; + rows.set(keyOf(row), stored); + return { id: stored.id }; + }, + async update(_t: string, patch: Record, o: { where: Record }) { + assertEngineUpdateDispatch(patch, o); + const found = findRow(o.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...patch } as Row; + rows.delete(found.key); + rows.set(keyOf(merged), merged); + return { id: found.row.id }; + }, + async delete(_t: string, o: { where: Record }) { + assertEngineDeleteDispatch(o); + const found = findRow(o.where); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + async transaction(cb: (ctx: any, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + async syncObjectSchema() {}, + registry, + }; + return { engine, registry, rows }; +} + +/** + * A control-plane kernel (no environment), registered the way the boot + * registers the package: the container under the object's name and each view it + * expands to under its own, both in the package's slot. + */ +function boot(opts: { otherPackageShipsInProgress?: boolean } = {}) { + const { engine, registry, rows } = makeEngine(); + const stamp = (item: T, pkg = PKG) => + ({ ...item, _packageId: pkg, _packageVersion: '1.0.0', _provenance: 'package' }); + registry.registerItem('view', stamp({ name: OBJ, ...shippedContainer() }), 'name', PKG); + for (const vi of expandViewContainer(OBJ, shippedContainer())) { + registry.registerItem('view', stamp(vi as object), 'name', PKG); + } + if (opts.otherPackageShipsInProgress) { + const other = { ...shippedContainer().listViews.in_progress, label: 'Other In Progress' }; + const [inProgress] = expandViewContainer(OBJ, { listViews: { in_progress: other } }); + registry.registerItem('view', stamp(inProgress as object, OTHER_PKG), 'name', OTHER_PKG); + } + registry.registerItem('dashboard', stamp({ name: 'ops_dash', label: 'Ops', widgets: [] }), 'name', PKG); + const protocol = new ObjectStackProtocolImplementation(engine); + return { protocol, registry, rows }; +} + +const stripInternal = (body: Record) => + Object.fromEntries(Object.entries(body).filter(([k]) => !k.startsWith('_'))); + +/** Studio's designer loop on the container: read, edit, save a draft, publish. */ +async function publishContainerOverlay( + protocol: ObjectStackProtocolImplementation, + edit: (container: Record) => void, +) { + const read: any = await protocol.getMetaItem({ type: 'view', name: OBJ }); + const container = stripInternal(read.item) as Record; + edit(container); + const saved: any = await protocol.saveMetaItem({ type: 'view', name: OBJ, item: container, mode: 'draft' }); + expect(saved).toMatchObject({ success: true }); + const published: any = await protocol.publishMetaItem({ type: 'view', name: OBJ }); + expect(published).toMatchObject({ success: true }); +} + +const xEdit = (label: string, dropProgress: boolean) => (container: Record) => { + container.list = { + ...container.list, + label, + columns: (container.list.columns as Array<{ field: string }>).filter((c) => !dropProgress || c.field !== 'progress'), + }; +}; + +const labelOf = (view: any) => view?.config?.label ?? view?.label; +const columnCountOf = (view: any) => (view?.config?.columns ?? view?.columns)?.length; + +async function listItems(protocol: ObjectStackProtocolImplementation, type = 'view'): Promise { + const res: any = await protocol.getMetaItems({ type }); + return Array.isArray(res) ? res : res.items; +} + +/** The views every read door serves under `name`: the list, the `?object=` list, the by-name read. */ +async function doors(protocol: ObjectStackProtocolImplementation, name: string) { + 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 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)]), + objectList: byObject.filter((v: any) => v?.name === name).map((v) => [labelOf(v), columnCountOf(v)]), + item: one?.item === undefined ? undefined : [labelOf(one.item), columnCountOf(one.item)], + }; +} + +const PACKAGED_DEFAULT = ['All Tasks', 7]; + +describe('[#23149] a reset of a packaged view container heals the expansions its publish registered', () => { + it("the card's repro: after the reset every read door serves the packaged expansion, once", async () => { + const { protocol, registry } = boot(); + await publishContainerOverlay(protocol, xEdit('All Tasks (X)', true)); + // The overlay is what the doors served before the reset. + expect(await doors(protocol, DEFAULT_VIEW)).toEqual({ + list: [['All Tasks (X)', 6]], + objectList: [['All Tasks (X)', 6]], + item: ['All Tasks (X)', 6], + }); + + const reset: any = await protocol.deleteMetaItem({ type: 'view', name: OBJ }); + expect(reset).toMatchObject({ success: true, reset: true }); + + expect(await doors(protocol, DEFAULT_VIEW)).toEqual({ + list: [PACKAGED_DEFAULT], + objectList: [PACKAGED_DEFAULT], + item: PACKAGED_DEFAULT, + }); + // The bare names the publish registered are empty again, the member + // the overlay did not edit included; the packaged entries stay. + expect(registry.bare('view', DEFAULT_VIEW)).toBeUndefined(); + expect(registry.bare('view', IN_PROGRESS_VIEW)).toBeUndefined(); + expect(registry.bare('view', OBJ)).toBeUndefined(); + expect(labelOf(registry.getItem('view', DEFAULT_VIEW))).toBe('All Tasks'); + }); + + it('a view the overlay added, which no package ships, is retired by the reset', async () => { + const { protocol, registry } = boot(); + const added = `${OBJ}.added_by_overlay`; + await publishContainerOverlay(protocol, (container) => { + container.listViews = { + ...container.listViews, + added_by_overlay: { label: 'Added', type: 'grid', data, columns: [{ field: 'title' }] }, + }; + }); + expect((await doors(protocol, added)).list).toEqual([['Added', 1]]); + + await protocol.deleteMetaItem({ type: 'view', name: OBJ }); + + expect(await doors(protocol, added)).toEqual({ list: [], objectList: [], item: undefined }); + expect(registry.bare('view', added)).toBeUndefined(); + }); + + it('publish → reset → publish serves the second overlay, and its reset heals again', async () => { + const { protocol } = boot(); + await publishContainerOverlay(protocol, xEdit('First overlay', true)); + await protocol.deleteMetaItem({ type: 'view', name: OBJ }); + + await publishContainerOverlay(protocol, xEdit('Second overlay', false)); + expect(await doors(protocol, DEFAULT_VIEW)).toEqual({ + list: [['Second overlay', 7]], + objectList: [['Second overlay', 7]], + item: ['Second overlay', 7], + }); + + await protocol.deleteMetaItem({ type: 'view', name: OBJ }); + expect(await doors(protocol, DEFAULT_VIEW)).toEqual({ + list: [PACKAGED_DEFAULT], + objectList: [PACKAGED_DEFAULT], + item: PACKAGED_DEFAULT, + }); + }); + + it('control: a packaged dashboard, which expands to nothing, resets as before', async () => { + const { protocol } = boot(); + await protocol.saveMetaItem({ + type: 'dashboard', name: 'ops_dash', item: { name: 'ops_dash', label: 'Ops (X)', widgets: [] }, mode: 'draft', + }); + await protocol.publishMetaItem({ type: 'dashboard', name: 'ops_dash' }); + const before = (await listItems(protocol, 'dashboard')).filter((d: any) => d?.name === 'ops_dash'); + expect(before.map((d: any) => d.label)).toEqual(['Ops (X)']); + + const reset: any = await protocol.deleteMetaItem({ type: 'dashboard', name: 'ops_dash' }); + expect(reset).toMatchObject({ success: true, reset: true }); + + const after = (await listItems(protocol, 'dashboard')).filter((d: any) => d?.name === 'ops_dash'); + expect(after.map((d: any) => d.label)).toEqual(['Ops']); + const one: any = await protocol.getMetaItem({ type: 'dashboard', name: 'ops_dash' }); + expect(one.item.label).toBe('Ops'); + }); + + it('an expansion name another package ships is never touched by the heal', async () => { + const { protocol, registry } = boot({ otherPackageShipsInProgress: true }); + // Whatever holds that bare name is not this container's: here, a + // registration from another source, which the heal must leave. + const held = { name: IN_PROGRESS_VIEW, object: OBJ, viewKind: 'list', label: 'Held elsewhere', _provenance: 'org' }; + registry.registerItem('view', held, 'name'); + + await publishContainerOverlay(protocol, xEdit('All Tasks (X)', true)); + // The hydration skipped the name (#21980): the bare slot is unchanged. + expect(registry.bare('view', IN_PROGRESS_VIEW)).toBe(held); + expect(labelOf(registry.bare('view', DEFAULT_VIEW))).toBe('All Tasks (X)'); + + await protocol.deleteMetaItem({ type: 'view', name: OBJ }); + + expect(registry.bare('view', IN_PROGRESS_VIEW)).toBe(held); + expect(labelOf(registry.getArtifactItem('view', IN_PROGRESS_VIEW, OTHER_PKG))).toBe('Other In Progress'); + // The name the container did register is healed. + expect(registry.bare('view', DEFAULT_VIEW)).toBeUndefined(); + }); + + it('an organization-scoped heal touches none of the expansions; an environment-wide one after it heals them', async () => { + const { protocol, registry } = boot(); + await publishContainerOverlay(protocol, xEdit('All Tasks (X)', true)); + const heal = (organizationId: string | null) => + (protocol as any).restoreArtifactRegistryView('view', OBJ, organizationId) as Promise; + + await heal('org_a'); + expect(labelOf(registry.bare('view', DEFAULT_VIEW))).toBe('All Tasks (X)'); + expect(registry.bare('view', IN_PROGRESS_VIEW)).toBeDefined(); + expect(registry.bare('view', OBJ)).toBeDefined(); + + await heal(null); + expect(registry.bare('view', DEFAULT_VIEW)).toBeUndefined(); + expect(registry.bare('view', IN_PROGRESS_VIEW)).toBeUndefined(); + expect(registry.bare('view', OBJ)).toBeUndefined(); + }); +}); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 1bdcb02f2f0..5e68155de99 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1591,6 +1591,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.view-container-reset-expansions.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.view-container-reset-expansions.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.view-container-reset-expansions.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.write-response-internal-fields.tripwire.test.ts", "verb": "delete",