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/21558-container-own-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 its own expansion produces

Clause-②: yes (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 such a container was meant as the object's container 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 such row and no writer that produces the shape by default: no seeded `sys_metadata` view rows in the example apps, no packaged container with a top-level `name` among the twelve `defineView` sites in `examples/`, and no Studio or in-repo AI writer that saves a container under an expanded name unless its author types that name into the container (Studio's generic metadata editor saves a body under its own `name`); hosted tenants 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 `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 one of the names its own expansion produces: for example `{ name: 'crm_lead.default', object: 'crm_lead', list: { … } }` saved as `crm_lead.default`, the name its bare `list` expands to. That row is the name's own stored row, and an expansion fills only names that have no row of their own (the object door adopts that rule in this same release), so the container's expansion never filled it. 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 container. No door answered a view item for the name, and 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. Whether a name is one the container's own expansion produces is decided by the same expansion the read doors run, so every member kind (a bare or named `list`, `listViews`, `form`, `formViews`) and the expander's de-duplicated names (`…_2`) are judged where the readers place them. A container with no `name` is judged under the save name the door stamps on it. A container on another package's object expands under its own name, which is never the name it is saved under, so it is not refused.

**What still saves.** A container under its object's name, which expands as before. 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, now report such a row as failed with this refusal instead of re-saving it.

**The fix.** Save the container under its object's name (`crm_lead`), or save a view item (`name`, `object`, `viewKind`, `config`) under the expanded name (`crm_lead.default`).
81 changes: 81 additions & 0 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17683,6 +17683,77 @@ export class ObjectStackProtocolImplementation implements
}
}

/**
* [#21558] The save door's refusal of a view container saved under a name
* its OWN expansion produces — `{ name: 'crm_lead.default', object:
* 'crm_lead', list }` saved as `crm_lead.default`, whose bare `list`
* expands to exactly that name.
*
* Both read doors give a name with a stored row of its own that row, and
* let an expansion fill only a name with no row (#21510's one predicate,
* `namesWithOwnStoredRow`). Such a container IS the row of that name, so
* its own expansion never fills it: the object door, which never
* enumerates a container, lists nothing under the name, and the by-name
* read answers the raw container. No door answers a view item for it, and
* nothing told the author why. Triage's ruling refuses the shape here, at
* authoring (Prime Directive 12), and keeps the readers' one predicate
* whole: ⛔ no second own-row test in the readers.
*
* "A name its own expansion produces" is answered by the readers' own
* expansion, {@link expandRuntimeViewContainer}, never by a copy of its
* naming, so the save door and the read doors cannot disagree about it:
* every member kind and the expander's de-duplication are covered as the
* readers place them. A container on another package's object expands
* under its own name (#21334), as `<object>.<container name>…`, which is
* never the container name itself, so that arm is never refused. The
* package binding is the request's, as the registry write-through
* registers the expansion.
*
* The body judged is the one the author sent, with the door's own `name`
* stamp ({@link normalizeViewMetadata}: a missing or falsy `name` becomes
* the save name) applied first, since the expansion of an unnamed
* container is placed by that name. It is asked BEFORE that function's
* identity patch: a container whose only member is `form` is not one of
* the shapes the patch leaves alone, so under the name of a registered
* view item it would take that item's `viewKind`, stop being a container,
* and reach the schema as a malformed view item instead of this refusal.
*
* A view item (`viewKind` set) is not a container, so a view item saved
* under an expanded name is untouched: it is the sanctioned override for
* that name. Rows already stored in this shape are untouched too: the
* read doors serve them as before, and only a new save is refused.
*
* `VALIDATION_ERROR` / 400, the envelope of the name check it sits beside
* (`savedItemNameRefusal`): an authoring refusal of the request's own
* name, decided from the body. The prescription is the ruling's: save the
* container under its object's name, or save a view item under the
* expanded name. Runtime words carry no tracker number.
*/
private containerOwnExpansionNameRefusal(
type: string,
item: unknown,
saveName: string,
packageId: string | null | undefined,
): (Error & { code: 'VALIDATION_ERROR'; status: 400 }) | 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 };
const own = this.expandRuntimeViewContainer(type, stamped, { packageId })
.find((expanded) => expanded.name === saveName);
if (!own) return undefined;
const object = String(own.object);
const err = new Error(
`Invalid view container: it is saved under '${saveName}', which is a name its own expansion `
+ `produces (its ${String(own.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 no read would answer `
+ `a view under '${saveName}'. Save the container under its object's name, '${object}', 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 @@ -18164,6 +18235,16 @@ export class ObjectStackProtocolImplementation implements
const nameRefusal = savedItemNameRefusal(singularType, request.item, request.name, 'save');
if (nameRefusal) throw nameRefusal;
}
// [#21558] …and a view container saved under a name its OWN
// expansion produces, with the same envelope. Asked of the body as
// authored, before the stamp below can take a registry entry's
// `viewKind` onto it. See {@link containerOwnExpansionNameRefusal}.
{
const ownExpansionRefusal = this.containerOwnExpansionNameRefusal(
singularType, request.item, request.name, request.packageId,
);
if (ownExpansionRefusal) throw ownExpansionRefusal;
}
let baseline: unknown;
if ((PLURAL_TO_SINGULAR[request.type] ?? request.type) === 'view'
&& typeof this.engine.registry?.getItem === 'function') {
Expand Down
Loading
Loading