Skip to content

Commit 5555047

Browse files
fix(metadata-protocol,metadata): the runtime save door refuses a view container whose name disagrees with its save name, through the one judge every door calls (#21412) (#21483)
Fixes #21412 Clause-②: yes (narrowing) The runtime save door (`saveMetaItem`, which REST `PUT /api/v1/meta/view/:name` and the dispatcher's metadata save both call) now refuses an aggregated view container whose body `name` disagrees with the name it is saved under. It answers `VALIDATION_ERROR` / 400 before anything is stored or registered, and it refuses through the same judge the source registrars call. Before this change, the card's probe (row `crm_lead`, body `name` `lead_views`) was accepted, stored under `crm_lead`, and registered under `lead_views` plus `crm_lead.default`, so one document answered under two names. This round follows the seat's answer on the card (comment 5961930912) to the dev's `needs_decision` report (5961864645): Q1 A, Q2 A, Q3 A and Q4 A. ## What changed - **One judge, in `@objectstack/metadata`.** New subpath `@objectstack/metadata/view-container-name` (`packages/metadata/src/view-container-name.ts`). The judgement: a container's own `name`, when set, equals the key the door files the container under. It has two entries, which share one gate, one envelope and one message template: - `viewContainerNameRefusal(container, sourceLabel, ownerId)` is the source registrars' entry. Its key is DERIVED from the binding (`deriveViewContainerObject`). It is the function that used to live in `packages/objectql`, moved, and its words are byte for byte the same (proof below). - `savedViewContainerNameRefusal(container, saveName)` is the save door's entry. Its key is the save name. - **Why the save door's key is the save name, not the binding.** The door keeps a container saved under a name other than its object (the #13407 pin) and expands one on another package's object under its own name (#21334's ruled arm). The dev's probes measured that the derived key would refuse the body the door itself stores for that shape when it is sent back, and would pass two bodies whose `name` disagrees with the row (P3, P4). - **Why `@objectstack/metadata`.** `@objectstack/core` cannot host the judge: the judge needs `deriveViewContainerObject`, which lives in `@objectstack/metadata`, and `@objectstack/metadata` lists core. `@objectstack/metadata` is the one layer all three doors already depend on. The judge is a subpath of its own, not the `./view-container` leaf, because that leaf imports nothing and the judge needs `isAggregatedViewContainer` from `@objectstack/spec`. It is not on the root entry either, because the root loads the manager and the filesystem machinery that objectql's ADR-0076 lean entry must not reach. `check:lean-entry-closure` holds: `@objectstack/objectql/core` is 15 packages, the admitted set held exactly. - **The save door** (`packages/metadata-protocol/src/protocol.ts`, `saveMetaItem`) calls `savedViewContainerNameRefusal(request.item, request.name)` for `view` first, before `normalizeViewMetadata` can keep an authored `name`. Containers only. `normalizeViewMetadata`'s docblock says so. - **The artifact/HMR door's container branch** (`packages/metadata/src/plugin.ts`) calls `viewContainerNameRefusal(item, 'artifact', packageId)` after it derives the key and before `memLoader.save` / `manager.register`. The probe document is now refused through the judge, in the judge's words. Row 1 (`assertMetadataRegisterContract`) is unchanged for every type; on this shape it is simply no longer reached. - **`@objectstack/objectql`** keeps `viewContainerNameRefusal` and the `ViewContainerNameRefusal` type as a re-export (`src/view-container-name-refusal.ts`). Its module header is rewritten: the judge's home, why it moved, why the source registrars' entry derives the key and the save door's takes it. `engine.ts` and `packages/cli` are untouched. - **Rider 5954896314** (comment only): the comment above `ViewSchema`'s `guidance:` in `packages/spec/src/ui/view.zod.ts` no longer says `saveMetaItem` sends the name, artifact-shipped containers do, and the sweep injects it. It says the door's own stamp (`normalizeViewMetadata`) is the only platform writer of the key and states the one rule, worded to Q1 A. No schema change. ## The message: one template, two renderings The per-door words are one value, the door's key origin. It fills four slots: the subject, the key clause, the text after the key, and the cross-reference after the shared reason. Everything else is shared. - The source registrars' rendering is byte-identical to the old objectql function. This was proven by a temporary test that compared old and new outputs over 10 fixtures, 3 of them refusals: message, `code`, `status` and `httpStatus` were all equal, `Tests 1 passed`. The test was deleted afterwards. - The save door's rendering differs in three places, and each has a reason: - Its subject is `view container` and not `` `views:` container from SOURCE 'OWNER' ``: the save door has no `views:` collection and no owning manifest. - Its key clause is `the name it is saved under`. - It drops the cross-reference `the artifact/HMR loader refuses this same document`, which would be false at this door for P3 and P4: the artifact loader accepts a body whose `name` equals its binding. - The ruling said the renderings differ "only in the clause that names where the key came from". Read literally, that is one slot. All four slots carry that one fact, so this is reported as a measured reading, not chosen silently. ## Pins (triage pins, restated to Q1 A) - `packages/metadata-protocol/src/view-container-runtime-expansion.test.ts`, the filing's own stub engine: - P1, the card's probe, is refused `VALIDATION_ERROR` / 400. No row is stored and nothing is registered. - P1's message is exactly `savedViewContainerNameRefusal`'s, so the refusal goes through the judge. - P1's `code` / `status` equal the artifact/HMR registrar's for the same document. - P3 and P4 are refused, with nothing stored or registered. - P2 (name equal to the row, bound elsewhere) passes. The container is registered under exactly one key, its row key (Q4 A), and the object door serves `crm_lead.default`. - P2b (no name) passes, is stamped with the row name, and the stamped body passes when it is read and sent back. - A control with name, row and binding all equal passes, under one key. - `packages/metadata/src/view-container-name.test.ts` covers: - each entry on P1 to P4; - the shapes where the two entries part; - the boot precondition: a falsy derived key refuses nothing; - the scope: a standalone ViewItem and a non-container are not judged (#21470 remains open for the every-type half); - the artifact door's container branch: it throws exactly the derived entry's refusal and files nothing, and as a control, an agreeing or absent `name` still registers. - The boot loop's and `os validate`'s refusal words are unchanged, and their pins are unedited: `packages/objectql/src/view-container-name-refusal.test.ts` (green) and `packages/cli/test/validate-view-container-name.test.ts` (CI; see Tests). - Edited because the ruling moves them: in `packages/objectql/src/view-container-divergent-name-registrars.test.ts`, the artifact door's two message assertions now read the judge's words (`binds to, 'crm_lead'`, `` `name` is 'lead_views' ``) instead of row 1's. The envelope-equality pin is unchanged. ## Reverse verification (both from committed HEAD, through `scripts/ablation-replace.mjs`) - **Save door.** The call `if (nameRefusal) throw nameRefusal;` in `protocol.ts` was disabled (anchor 1 to 0, blob `478daa416f90` to `6f9ae10ffb7b`), and `view-container-runtime-expansion.test.ts` went `5 failed | 65 passed (70)`: P1 three times, P3 and P4. P2, P2b and the control stayed green. The tool then restored the file to HEAD (`blob == HEAD (478daa4)`, `git diff HEAD` empty). Both test and subject resolve the protocol from `src`, so no build was involved. - **Artifact door.** The same call in `plugin.ts` was disabled (blob `6924156b5fda` to `cef5ec76af37`), and `view-container-name.test.ts` went `1 failed | 10 passed (11)`. The refusal came from row 1 instead (no `httpStatus`), so the assertion failed. The file was restored to HEAD. The test imports `./plugin.js` from `src`. ## Tests Code at `a70d0d61a4` is identical to `7d4ee0ac46` outside `.changeset/`. All runs went through `scripts/pm/os-verify-lock.sh`, and each gave `VERDICT command-exit 0`: - `@objectstack/metadata`: `pnpm test` gave `Test Files 57 passed (57)`, `Tests 847 passed (847)`, and `pnpm typecheck` exited 0. Its tsconfig includes `src/**/*`, so the tests are type-checked. - `@objectstack/metadata-protocol`: `pnpm test` gave `Test Files 205 passed | 3 skipped (208)`, `Tests 3092 passed | 19 skipped (3111)`, and `pnpm typecheck` exited 0 (tests included). - `@objectstack/objectql`: the `local` project gave `Test Files 366 passed (366)`, `Tests 7383 passed (7383)`, `test:repo` gave `1 passed`, and `typecheck` exited 0. That covers `tsc`, the scripts project, and `check:test-typecheck` OK, held in the debt ledger. - Build: `turbo run build --filter=@objectstack/objectql^... --filter=@objectstack/objectql` gave `14 successful`. The new subpath loads under both conditions (`require` and `import` each return both entries, `VALIDATION_ERROR` 400), and `dist/view-container-name.d.ts` / `.d.cts` are emitted. - **Not run here: `packages/cli`**, the `os validate` pins. `@objectstack/objectql`'s export keeps its name, signature and bytes of output, and the cli imports it unchanged. Building the cli closure is 60 tasks, 11 of them cached. CI runs them. The byte-identity proof above is the local evidence. - One count control moved: `packages/metadata/src/serializers/typescript-serializer-annotation.test.ts` pins how many `exports` entries it visits (5 to 6), so the new entry is checked too. ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` was derived at `a70d0d61a4` and gave 99 commands. All 99 were run at `a70d0d61a4`: - 98 exited 0. - 1 is **NOT MEASURED**: `pnpm check:dual-build-cjs-loads` exited 3 with `PREREQUISITE NOT MET`, because it needs every package's `dist/` (the direct load check above stands in, but is not the gate). - Of note: `check-adr-0087-registration --base origin/main`: `1 declared-breaking changeset(s), each carrying an ADR-0087 disposition`. `check-changeset-no-major`: no major; the level axis needs a PR payload. `check:lean-entry-closure`: admitted set held. `check:published-files`, `check:dts-closure`, `check:issue-citations` (22 resolve), `check:doc-authoring`, `check:spec-docblock-symbol-anchors`, `check:nul-bytes`, `check:test-source-alias`, `check:cross-package-test-inputs`: all green. Lint, a proven narrowing at `a70d0d61a4`: `eslint --no-inline-config --format json` over the 10 touched `.ts` files reports 10 files, 0 errors, 0 warnings, and none ignored. Every one is in eslint's population (`--print-config` resolves each). `eslint.config.mjs` never enables type-aware linting (its own note near line 327: no `parserOptions.project`, no typed rules), so this diff cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's. ## Changesets - `@objectstack/metadata` `minor`: the new subpath, and the artifact door's refusal speaking through the judge. - `@objectstack/metadata-protocol` `minor` with a **BREAKING** banner: an accept-set narrowing at the save door, graded like the boot loop's refusal of the same divergence. Its ADR-0087 disposition is `not-required (no-migration-prescription)`. - `@objectstack/objectql` `patch`: the re-export. - `@objectstack/spec` `patch`: comment only. `src/**/*.zod.ts` ships as source, and the comment ships in the `ui` JavaScript output (measured: the new sentence is in 20 `dist` files; the old one is in none). **The `Clause-②` line above is the claim's, copied as dispatched.** By `scripts/pm/clause2-line.mjs`'s own definitions, this diff both widens a public surface (the new `@objectstack/metadata` subpath, with two functions and a type) and narrows an accept set (the save door). That reads as `yes (narrowing)`. This is raised to the seat in the dev report; the line here is not changed by the dev. ## Acceptance notes - `carrier: #21470` (#21470 remains open). Container bodies stored before this change that already carry a divergent `name` keep registering under that `name` at boot (`loadMetaFromDb`, then `hydrateOverlayIntoRegistry`). `revertCommit` and `rollbackMetaItem` re-persist stored versions through `repo.restoreVersion` without passing the save seam. The count of such rows is not measured. - The every-type half (a record view or any other type saved with a body `name` that differs from the row) is #21470's, and is not judged here. --- _Generated by [Claude Code](https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ab52182 commit 5555047

15 files changed

Lines changed: 631 additions & 118 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
The runtime save door refuses a view container whose own `name` disagrees with the name it is saved under
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing at one door over an existing key: `ViewSchema.name` is not removed, renamed or re-shaped, so there is no tombstone and nothing mechanical for `objectstack migrate meta` to rewrite. Which of the two names a divergent container meant (the body's, or the one it was saved under) is authoring intent no conversion entry can decide. New saves are refused with the remedy; a row stored before this change keeps its bytes. 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). -->
10+
11+
**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 ObjectQL boot loop's refusal of the same divergence shipped with.
12+
13+
**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`) whose body carried a `name` different from the name it was saved under. It stored the row under the save name and registered the container under the body's `name`, so one document answered under two names. The source registrars (the ObjectQL boot loop and the artifact/HMR loader) and `os validate` already refused a container whose `name` disagrees with the key they file it under.
14+
15+
**What is refused now.** That body, with `VALIDATION_ERROR` / 400, before anything is stored or registered, through the same judge the source registrars call (`@objectstack/metadata/view-container-name`). The key judged here is the save name: a container saved under a name other than the object it binds to still saves, and so does the body the door stores for it when it is read and sent back.
16+
17+
**The fix.** Drop the body's `name` (the door stamps the save name), or set it to the name the container is saved under.
18+
19+
Not judged here: a standalone view record (`viewKind`) and every other metadata type.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/metadata': minor
3+
---
4+
5+
One judge for a view container's own `name` at every door that files a container: the new `@objectstack/metadata/view-container-name` entry
6+
7+
Clause-②: yes
8+
9+
- New subpath `@objectstack/metadata/view-container-name`. It exports `viewContainerNameRefusal(container, sourceLabel, ownerId)`, the source registrars' entry, whose key is the object the container binds to (its own `object`, else `list.data.object` / `form.data.object`). It also exports `savedViewContainerNameRefusal(container, saveName)`, the runtime save door's entry, whose key is the name the row is saved under, and the `ViewContainerNameRefusal` type. Both return a `VALIDATION_ERROR` / 400 refusal for an aggregated view container whose own `name` is set and differs from that key, and `undefined` otherwise. A container with no `name`, and a standalone view record (`viewKind`), are not judged.
10+
- The artifact/HMR loader's container branch now refuses such a container through the judge, before it files anything. What it refuses and the envelope are unchanged (`VALIDATION_ERROR` / 400). The message is now the judge's, the words the ObjectQL boot loop and `os validate` print, where it was the generic `IMetadataService.register` contract's.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
`viewContainerNameRefusal` is now re-exported from `@objectstack/metadata/view-container-name`
6+
7+
Clause-②: no
8+
9+
The divergent view-container `name` judge moved to `@objectstack/metadata`, the one layer the boot loop, the artifact/HMR loader and the runtime save door all depend on, so all three call one judge. `@objectstack/objectql` keeps the `viewContainerNameRefusal` export, its signature and the `ViewContainerNameRefusal` type. The boot loop's refusal and the words it and `os validate` print are unchanged, byte for byte.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
The comment above `ViewSchema`'s `guidance:` states who writes a view container's `name`, and the rule every door applies to it
6+
7+
Clause-②: no
8+
9+
`src/ui/view.zod.ts` ships as source, and the comment also ships in the `ui` JavaScript output. It used to say that `saveMetaItem` sends a container's `name`, that artifact-shipped containers do, and that the validation sweep injects it. It now says the metadata door's own stamp (`normalizeViewMetadata`) is the only platform writer of the key. Artifact-shipped containers carry none, and the sweep passes its name as the request name. It also states the rule: when an authored `name` is set, it must equal the key the door files the container under, or the door refuses it. ⛔ No schema, parse, export or accept-set change.

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ import {
9494
// `sys-metadata-repository.ts` in this package and with `DatabaseLoader` in
9595
// `@objectstack/metadata` (#5108). See `rethrowUnlessMetadataStoreUnprovisioned`.
9696
import { isMissingTableError } from '@objectstack/metadata/errors';
97+
// [#21412] The divergent view-container `name` refusal — the one judge the
98+
// boot loop, `os validate` and the artifact/HMR loader call too; this door
99+
// passes the name it files the row under. See `saveMetaItem`.
100+
import { savedViewContainerNameRefusal } from '@objectstack/metadata/view-container-name';
97101
import type {
98102
BatchUpdateRequest,
99103
BatchUpdateResponse,
@@ -1218,7 +1222,10 @@ export { stripReadDecorations };
12181222
* into a record risks producing an invalid record (e.g. a non-`<object>.<key>`
12191223
* name). Structural validity is enforced separately by the view metadata schema
12201224
* during the spec-validation step. No-op for non-view types and bodies that
1221-
* already carry a `name`.
1225+
* already carry a `name`. [#21412] A CONTAINER's authored `name` reaches this
1226+
* function only when it equals `saveName`: `saveMetaItem` refuses a
1227+
* disagreeing one first, through `savedViewContainerNameRefusal`, so keeping
1228+
* the authored `name` can no longer file a container under a second key.
12221229
*
12231230
* When `baseline` is provided (the registry entry this overlay will shadow),
12241231
* missing identity fields — `viewKind`, `object`, `label` — are inherited onto
@@ -17713,6 +17720,22 @@ export class ObjectStackProtocolImplementation implements
1771317720
// (#2555 — a console personalization PUT sends only the raw config).
1771417721
// See {@link normalizeViewMetadata}.
1771517722
{
17723+
// [#21412] FIRST, before the stamp below can keep an authored
17724+
// `name`: a view CONTAINER whose own `name` disagrees with the name
17725+
// this door files the row under is refused, `VALIDATION_ERROR` /
17726+
// 400, through the one judge the source registrars call. Accepted,
17727+
// it was stored under the row name and registered under the
17728+
// body's (`hydrateOverlayIntoRegistry` keys by `body.name`), so one
17729+
// document answered under two names. The key here is the save
17730+
// name, not the derived binding: this door keeps a container saved
17731+
// under a name other than its object (#13407, #21334), and the
17732+
// body it stamps for one must pass when sent back. A body with no
17733+
// `name` passes and is stamped below. Containers only — the
17734+
// every-type half is #21470.
17735+
if (singularType === 'view') {
17736+
const nameRefusal = savedViewContainerNameRefusal(request.item, request.name);
17737+
if (nameRefusal) throw nameRefusal;
17738+
}
1771617739
let baseline: unknown;
1771717740
if ((PLURAL_TO_SINGULAR[request.type] ?? request.type) === 'view'
1771817741
&& typeof this.engine.registry?.getItem === 'function') {

‎packages/metadata-protocol/src/view-container-runtime-expansion.test.ts‎

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929
*/
3030
import { describe, expect, it } from 'vitest';
3131
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core';
32-
import { expandViewContainer, ViewSchema } from '@objectstack/spec/ui';
32+
import { expandViewContainer, isAggregatedViewContainer, ViewSchema } from '@objectstack/spec/ui';
33+
import { MetadataPlugin } from '@objectstack/metadata';
34+
import { savedViewContainerNameRefusal } from '@objectstack/metadata/view-container-name';
3335
import { ObjectStackProtocolImplementation } from './index.js';
3436

3537
interface Row {
@@ -773,3 +775,122 @@ describe('#21334 a container on another package\'s object never takes that packa
773775
await expectEveryPackagedNameIntact(protocol);
774776
});
775777
});
778+
779+
/**
780+
* #21412 — the runtime save door refuses a view container whose own `name`
781+
* disagrees with the name it is saved under, through the one judge the source
782+
* registrars call (`@objectstack/metadata/view-container-name`).
783+
*
784+
* Before: the card's probe was ACCEPTED — stored as row `crm_lead` with body
785+
* `name` `lead_views`, and registered as `lead_views` (the container, keyed by
786+
* `body.name` in `hydrateOverlayIntoRegistry`) plus `crm_lead.default`: one
787+
* document answering under a name its row does not have. The two source
788+
* registrars refuse the same document.
789+
*
790+
* The key judged here is the SAVE name, not the binding: this door keeps a
791+
* container saved under a name other than its object (the #13407 case above,
792+
* #21334's arm), so the body it stamps for such a container must pass when it
793+
* is sent back. Shapes as the card's measurement named them (row = save name):
794+
* P1 row crm_lead / `name` lead_views; P2 row lead_views / `name` lead_views,
795+
* bound to crm_lead; P2b P2 with no `name`; P3 row lead_views / `name`
796+
* crm_lead; P4 row crm_lead / `name` lead_views, no other binding.
797+
*/
798+
describe('#21412 the save door refuses a container whose own name disagrees with the name it is saved under', () => {
799+
const named = (name: string | undefined, body: Record<string, unknown>) =>
800+
(name === undefined ? { ...body } : { name, ...body });
801+
/** Bound to crm_lead through its own `object`, no `data` on any arm. */
802+
const objectBound = { object: 'crm_lead', list: { label: 'All Leads', type: 'grid', columns: [{ field: 'name' }] } };
803+
/** No binding but whatever `name` it carries. */
804+
const unbound = { list: { label: 'All', type: 'grid', columns: [{ field: 'name' }] } };
805+
806+
async function save(name: string, item: unknown) {
807+
const harness = makeStubEngine();
808+
const protocol = new ObjectStackProtocolImplementation(harness.engine);
809+
let error: any = null;
810+
try {
811+
await protocol.saveMetaItem({ type: 'view', name, item });
812+
} catch (e) {
813+
error = e;
814+
}
815+
const viewRows = Array.from(harness.rows.values()).filter((r) => r.type === 'view');
816+
// The keys the registry holds a CONTAINER under — its expansions carry
817+
// `viewKind` and are the container's derived items, not a second key
818+
// for the document (seat answer Q4).
819+
const containerKeys = Array.from(harness.registered.get('view')?.entries() ?? [])
820+
.filter(([, v]) => isAggregatedViewContainer(v))
821+
.map(([k]) => k);
822+
return { ...harness, protocol, error, viewRows, containerKeys };
823+
}
824+
825+
function expectRefused(outcome: Awaited<ReturnType<typeof save>>) {
826+
// The minimum a rejection pin asserts: the ADR-0112 envelope.
827+
expect(outcome.error).toBeInstanceOf(Error);
828+
expect(outcome.error.code).toBe('VALIDATION_ERROR');
829+
expect(outcome.error.status).toBe(400);
830+
// ...and the refused document reached nothing.
831+
expect(outcome.viewRows).toEqual([]);
832+
expect(outcome.registered.get('view')?.size ?? 0).toBe(0);
833+
}
834+
835+
it('P1, the card\'s probe: refused VALIDATION_ERROR / 400, nothing stored, nothing registered', async () => {
836+
expectRefused(await save('crm_lead', named('lead_views', leadContainer)));
837+
});
838+
839+
it('P1 is refused THROUGH the judge: the door throws exactly what it returns for that document', async () => {
840+
const body = named('lead_views', leadContainer);
841+
const { error } = await save('crm_lead', body);
842+
expect(error.message).toBe(savedViewContainerNameRefusal(body, 'crm_lead')!.message);
843+
});
844+
845+
it('P1 answers the envelope a source registrar answers for the same document', async () => {
846+
const body = named('lead_views', objectBound);
847+
const { error: saveDoor } = await save('crm_lead', body);
848+
const plugin = new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' } }) as any;
849+
const ctx = {
850+
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
851+
registerService: () => {}, getService: () => undefined, trigger: async () => {},
852+
} as any;
853+
const registrar = await plugin._parseAndRegisterArtifact(ctx, JSON.parse(JSON.stringify({
854+
manifest: { id: 'com.acme.crm', name: 'CRM', version: '1.0.0', type: 'app' },
855+
views: [body],
856+
})), 'fixture-21412').then(() => null, (e: any) => e);
857+
expect(registrar).toBeInstanceOf(Error);
858+
expect([saveDoor.code, saveDoor.status]).toEqual([registrar.code, registrar.status]);
859+
expect([saveDoor.code, saveDoor.status]).toEqual(['VALIDATION_ERROR', 400]);
860+
});
861+
862+
it('P3: a `name` equal to the binding but not to the row is refused', async () => {
863+
expectRefused(await save('lead_views', named('crm_lead', leadContainer)));
864+
});
865+
866+
it('P4: a `name` that is the only binding, but not the row, is refused', async () => {
867+
expectRefused(await save('crm_lead', named('lead_views', unbound)));
868+
});
869+
870+
it('P2: a `name` equal to the row passes though the container binds elsewhere — one key, the row\'s', async () => {
871+
const outcome = await save('lead_views', named('lead_views', objectBound));
872+
expect(outcome.error).toBeNull();
873+
expect(outcome.viewRows.map((r) => [r.name, JSON.parse(r.metadata).name])).toEqual([['lead_views', 'lead_views']]);
874+
expect(outcome.containerKeys).toEqual(['lead_views']);
875+
const list: any = await outcome.protocol.getMetaItems({ type: 'view' });
876+
expect(switcherMatches(list.items, 'crm_lead').map((v: any) => v.name)).toEqual(['crm_lead.default']);
877+
});
878+
879+
it('P2b: an absent `name` passes and is stamped with the row name — and the stamped body passes when sent back', async () => {
880+
const outcome = await save('lead_views', named(undefined, objectBound));
881+
expect(outcome.error).toBeNull();
882+
expect(outcome.viewRows.map((r) => JSON.parse(r.metadata).name)).toEqual(['lead_views']);
883+
expect(outcome.containerKeys).toEqual(['lead_views']);
884+
885+
const read: any = await outcome.protocol.getMetaItem({ type: 'view', name: 'lead_views' });
886+
expect(read.item.name).toBe('lead_views');
887+
const { _diagnostics: _drop, ...sentBack } = read.item;
888+
await expect(outcome.protocol.saveMetaItem({ type: 'view', name: 'lead_views', item: sentBack })).resolves.toBeTruthy();
889+
});
890+
891+
it('CONTROL: a `name` equal to the row and the binding passes, under one key', async () => {
892+
const outcome = await save('crm_lead', named('crm_lead', leadContainer));
893+
expect(outcome.error).toBeNull();
894+
expect(outcome.containerKeys).toEqual(['crm_lead']);
895+
});
896+
});

‎packages/metadata/package.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@
5656
"types": "./dist/view-container.d.cts",
5757
"default": "./dist/view-container.cjs"
5858
}
59+
},
60+
"./view-container-name": {
61+
"import": {
62+
"types": "./dist/view-container-name.d.ts",
63+
"default": "./dist/view-container-name.js"
64+
},
65+
"require": {
66+
"types": "./dist/view-container-name.d.cts",
67+
"default": "./dist/view-container-name.cjs"
68+
}
5969
}
6070
},
6171
"files": [

‎packages/metadata/src/plugin.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,10 @@ import { isAggregatedViewContainer, expandViewContainer } from '@objectstack/spe
229229
// chain of their own; it carries the same order #13407 settled at the runtime
230230
// door (`expandRuntimeViewContainer` in `packages/metadata-protocol`).
231231
import { deriveViewContainerObject } from './view-container-expansion.js';
232+
// [#21412] The divergent container `name` refusal — the one judge the boot
233+
// loop, `os validate` and the runtime save door call too. See the container
234+
// branch of `_registerArtifactBodyCollections`.
235+
import { viewContainerNameRefusal } from './view-container-name.js';
232236
import type { IHttpServer } from '@objectstack/spec/contracts';
233237

234238

@@ -1177,6 +1181,16 @@ export class MetadataPlugin implements Plugin {
11771181
// container, so the flattened copy is the duplicate, not a
11781182
// second definition.
11791183
if (slots.skip?.('view', viewObject)) continue;
1184+
// [#21412] A container whose own `name` disagrees with the
1185+
// key derived above is refused through the one judge every
1186+
// door that files a container calls, in its words — and
1187+
// BEFORE `memLoader.save`, so a refusal files nothing.
1188+
// `manager.register` below would refuse the same document
1189+
// too (#7378 row 1, `assertMetadataRegisterContract`), but
1190+
// in the generic register contract's words and only after
1191+
// the loader write; row 1 is unchanged for every type.
1192+
const nameRefusal = viewContainerNameRefusal(item, 'artifact', packageId);
1193+
if (nameRefusal) throw nameRefusal;
11801194
applyProtection(item as any, {
11811195
packageId: packageId,
11821196
packageVersion: packageVersion,

‎packages/metadata/src/serializers/typescript-serializer-annotation.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,9 @@ describe('TypeScriptSerializer annotation, per metadata type', () => {
168168
it('no exports entry of the package re-exports the internal channel', async () => {
169169
// Control: the name is spelled right, so the absences below can fail.
170170
expect(Object.keys(await import('./typescript-serializer.js'))).toContain('serializeTypeScriptForMetadataType');
171-
expect(EXPORT_ENTRY_SOURCES.length).toBe(5);
171+
// Six since `./view-container-name` (#21412): the count is the control
172+
// that the loop below visits every entry, so a new entry moves it here.
173+
expect(EXPORT_ENTRY_SOURCES.length).toBe(6);
172174
for (const source of EXPORT_ENTRY_SOURCES) {
173175
const entry = (await import(source)) as Record<string, unknown>;
174176
expect(Object.keys(entry).length, source).toBeGreaterThan(0);

0 commit comments

Comments
 (0)