Skip to content
14 changes: 14 additions & 0 deletions .changeset/23198-container-expansion-reconcile.md
Original file line number Diff line number Diff line change
@@ -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 `<object>.<viewKey>`. 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.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -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']);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,13 @@ const SITES: ReadonlyArray<Site & {
viewOnlyBy: 'const bound = canonicalType === \'view\' ? boundPackageOf(options.packageId) : undefined;',
},
{
member: 'hydrateExpandedViewItems', args: 'type, item, \'name\' as any',
// [#23198] Moved from hydrateExpandedViewItems into the one reconcile, which that hydration calls.
member: 'reconcileViewExpansions', args: 'type, item, \'name\' as any',
disposition: 'yes, view only: skips a name another package ships',
path: 'hydrateOverlayIntoRegistry, for each expansion of a stored view container',
path: 'hydrateExpandedViewItems (from hydrateOverlayIntoRegistry), for each expansion of a stored view container',
why: 'an expansion: not registered where another package ships its name (#21980), and the by-name read answers it '
+ 'from its stored container row',
viewOnlyBy: 'if ((PLURAL_TO_SINGULAR[type] ?? type) !== \'view\') return [];',
viewOnlyBy: 'if (canonicalMetaType(type) !== \'view\') return;',
},
{
member: 'restoreArtifactRegistryView', args: 'type, baseline.data, \'name\'',
Expand Down
Loading
Loading