Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/21620-container-sibling-expansion-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/metadata-protocol': minor
---

The runtime save door refuses a view container saved under a name another stored container of the same object expands to

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing at one runtime write door over existing keys: no key of `ViewSchema` or of any other metadata schema is removed, renamed or re-shaped, so there is no tombstone and nothing mechanical for `objectstack migrate meta` to rewrite. Whether the refused container was meant as a member of the container that already expands that name, or as a view item of that name, is authoring intent no conversion entry can decide. New saves are refused with the remedy; a row stored before this change keeps its bytes and is served as before, and no stored row is re-saved. The census found no packaged container that can reach this door in this shape (the source registrars file a container under its object and refuse a `name` that disagrees with it; the thirteen `defineView` sites in this repository carry no top-level `name`), no seeded `sys_metadata` view rows in the example apps, and no Studio or in-repo AI writer that saves a container under a name another container expands unless its author types that name (Studio's generic metadata editor saves a body under the name it carries); hosted tenants and the cloud AI author were not measured. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this rule and this diff adds none (not registered / already-registered); and the change narrows what a runtime write door accepts, not a runtime interface or a type surface alone (not runtime-interface-only / type-surface-only). -->

**BREAKING** accept-set narrowing at the runtime save door, shipped as `minor` under the repo's launch-window convention for breaking changes, the grade the same door's earlier name refusals shipped with.

**What was accepted before.** `saveMetaItem`, which `PUT /api/v1/meta/view/:name` and the dispatcher's metadata save both call, accepted an aggregated view container (`list` / `form` / `listViews` / `formViews`) saved under a name that another stored container of the same object expands to. For example, with `{ name: 'crm_lead', object: 'crm_lead', list: { … }, listViews: { pipeline: { … } } }` stored, a second container `{ object: 'crm_lead', list: { … } }` saved as `crm_lead.pipeline`. The second container became that name's own stored row, and an expansion fills only a name with no row of its own, so the first container's `crm_lead.pipeline` view was no longer served: the object door (`GET /api/v1/meta/view?object=…`), which never lists a container, listed nothing under the name, and the by-name read answered the raw second container. Nothing said why.

**What is refused now.** That save, with `VALIDATION_ERROR` / 400, before anything is stored or registered, in draft and in publish mode. The other containers are the stored rows the read doors select for the same caller (environment-wide rows plus the caller's organization's), each expanded exactly as the read doors expand it, so every member kind (a bare or named `list`, `listViews`, `form`, `formViews`), the expander's de-duplicated names, and the names a container on another package's object expands under its own name are all judged where the readers place them. A container with no `name` is judged under the save name the door stamps on it.

**What still saves.** A container under its object's name, which expands as before, and its own re-save. A container under any other name of its own that no other stored container of its object expands to: this door keeps a container saved under a name other than its object, and this change leaves that alone. A view item (a body carrying `viewKind`) under an expanded name, the sanctioned override for that name. The read doors are unchanged. A row stored in this shape before this change keeps its bytes and is served as before; `migrate meta --stored` and package duplication, which re-save stored rows through this door, report such a row as failed with this refusal instead of re-saving it.

**The fix.** Add the view as a member of the stored container that already expands the name (in the example, the container `crm_lead`, whose `listViews.pipeline` is that view), or save a view item (`name`, `object`, `viewKind`, `config`) under the expanded name (`crm_lead.pipeline`).
121 changes: 121 additions & 0 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ import { isMissingTableError } from '@objectstack/metadata/errors';
// door (`saveMetaItem`), the restore doors (`rollbackMetaItem`, `revertCommit`)
// and the draft promotion (`promoteDraftForPublish`).
import { savedItemNameRefusal } from '@objectstack/metadata/view-container-name';
// [#21620] The one spelling of "which object a view container binds to" — the
// derivation the source registrars file a container under — so the save door's
// sibling-expansion refusal judges "the same object" as every other door does.
import { deriveViewContainerObject } from '@objectstack/metadata/view-container';
import type {
BatchUpdateRequest,
BatchUpdateResponse,
Expand Down Expand Up @@ -17955,6 +17959,113 @@ export class ObjectStackProtocolImplementation implements
return err;
}

/**
* [#21620] The save door's refusal of a view container saved under a name
* that ANOTHER stored container of the same object expands to — with
* `{ name: 'crm_lead', object: 'crm_lead', listViews: { pipeline } }`
* stored, a second container `{ object: 'crm_lead', list }` saved as
* `crm_lead.pipeline`.
*
* The harm is #21558's, reached through a sibling: the second container
* becomes the stored row of `crm_lead.pipeline`, and both read doors give
* a name with a row of its own that row (#21510's one predicate,
* {@link namesWithOwnStoredRow}). So the first container's expansion no
* longer fills the name, the object door — which never enumerates a
* container — lists nothing under it, and the by-name read answers the raw
* second container: the sibling's view is gone from both doors and no door
* answers a view item for the name. #21558's check cannot see this: the
* second container's OWN expansion is `crm_lead.default`, never its save
* name.
*
* Triage's ruling on the card named a broader check — a container's name
* must be its object's name — and made it conditional on a census, with
* THIS narrower check as the fallback. The census hit: this door keeps a
* container saved under a name other than its object (#13407's live
* authoring path, which the platform checklist's live view-authoring item
* drives; #21412's P2 and P2b, ruled; #21334's arm expands one under its
* own name, ruled), and Studio's metadata editor re-saves such a container
* under its stored name. So the name is judged only against what the
* other stored containers of the same object expand to.
*
* The judgment is the readers' own, never a copy of it:
* - the rows are the ones {@link readActiveOverlayRows} selects for this
* caller, through the read gate the readers apply
* ({@link organizationIdForMetaRead}) and with no package filter, so
* every reader whose selection holds this container and a sibling is
* covered for this caller's scope;
* - each row is parsed by {@link storedOverlayEntries} and expanded by
* {@link expandStoredViewContainers}, with the row's own package
* binding, so every member kind, the expander's de-duplication and
* #21334's arm are judged where the readers place them;
* - the row stored under the save name itself is left out: it is the row
* this save replaces, not a sibling;
* - "the same object" is the expanded view's `object` against
* {@link deriveViewContainerObject} of the body, the one derivation
* every door files a container under.
*
* The body judged is the one the author sent, with the door's own `name`
* stamp applied first (a body with no `name` is judged under the save
* name), BEFORE {@link normalizeViewMetadata}'s identity patch — on an
* unscoped kernel the sibling's expansion is registered under the name,
* and a `form`-only container would take its `viewKind` there and reach
* the schema as a malformed view item instead of this refusal.
*
* A view item (`viewKind` set) is not a container and is untouched: under
* an expanded name it is that name's sanctioned override. Rows already
* stored in this shape keep their bytes and are served as before; only a
* new save of one is refused, and the re-savers that write through this
* door (`migrateStoredMetadata`, `duplicatePackage`) record that refusal
* as the row's failure instead of re-saving it.
*
* `VALIDATION_ERROR` / 400, the envelope of the two name checks it sits
* beside. The prescription names the stored container that expands the
* name, and gives two arms: add the view as a member of THAT container, or
* save a view item under the expanded name. ⛔ It never prescribes a save
* under a name another stored container holds — not even the object's own
* name, which in the card's pair IS the sibling: an author (or an AI)
* following such an arm literally would replace the sibling's row and drop
* the very view this refusal keeps serving. Runtime words carry no tracker
* number.
*/
private async containerSiblingExpansionNameRefusal(
type: string,
item: unknown,
saveName: string,
organizationId: string | undefined,
): Promise<(Error & { code: 'VALIDATION_ERROR'; status: 400 }) | undefined> {
if ((PLURAL_TO_SINGULAR[type] ?? type) !== 'view') return undefined;
if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined;
const body = item as Record<string, unknown>;
const stamped = body.name ? body : { ...body, name: saveName };
if (!isAggregatedViewContainer(stamped)) return undefined;
const object = deriveViewContainerObject(stamped);
if (!object) return undefined;
let records: any[] = [];
try {
records = await this.readActiveOverlayRows({ type }, organizationIdForMetaRead(type, organizationId));
} catch (error) {
// [#5532] The readers' rule: only an unprovisioned store means "no
// rows". Any other failure is not answered as "no sibling".
this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata');
}
const siblings = this.storedOverlayEntries({ type }, records)
.filter((entry) => entry.name !== saveName);
const hit = this.expandStoredViewContainers(type, siblings)
.find(({ item: expanded }) => expanded.name === saveName && expanded.object === object);
if (!hit) return undefined;
const err = new Error(
`Invalid view container: it is saved under '${saveName}', which is a name the stored container `
+ `'${hit.container.name}' expands (its ${String(hit.item.viewKind)} view on '${object}'). An expanded `
+ `view fills only a name that has no stored row of its own, and this container would be that row, so `
+ `that view would no longer be served and no read would answer a view under '${saveName}'. Add the `
+ `view as a member of the container '${hit.container.name}' (its list, listViews, form or formViews), `
+ `or save a view item (name, object, viewKind and config) under '${saveName}'.`,
) as Error & { code: 'VALIDATION_ERROR'; status: 400 };
err.code = 'VALIDATION_ERROR';
err.status = 400;
return err;
}

// [#21207] `parentVersion` is a CALLER's version token — the keyed form a
// receipt served — and is compared in that form (`storedParentForToken`).
// `storedParentVersion` is the in-process twin for a caller that read the
Expand Down Expand Up @@ -18446,6 +18557,16 @@ export class ObjectStackProtocolImplementation implements
);
if (ownExpansionRefusal) throw ownExpansionRefusal;
}
// [#21620] …and a view container saved under a name ANOTHER stored
// container of the same object expands to, with the same envelope,
// judged by the readers' own row selection and expansion. Also
// before the stamp. See {@link containerSiblingExpansionNameRefusal}.
{
const siblingExpansionRefusal = await this.containerSiblingExpansionNameRefusal(
singularType, request.item, request.name, request.organizationId,
);
if (siblingExpansionRefusal) throw siblingExpansionRefusal;
}
let baseline: unknown;
if ((PLURAL_TO_SINGULAR[request.type] ?? request.type) === 'view'
&& typeof this.engine.registry?.getItem === 'function') {
Expand Down
Loading
Loading