diff --git a/.changeset/21412-metadata-protocol-save-door-container-name.md b/.changeset/21412-metadata-protocol-save-door-container-name.md new file mode 100644 index 00000000000..c271c39772d --- /dev/null +++ b/.changeset/21412-metadata-protocol-save-door-container-name.md @@ -0,0 +1,19 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +The runtime save door refuses a view container whose own `name` disagrees with the name it is saved under + +Clause-②: no (narrowing) + + + +**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. + +**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. + +**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. + +**The fix.** Drop the body's `name` (the door stamps the save name), or set it to the name the container is saved under. + +Not judged here: a standalone view record (`viewKind`) and every other metadata type. diff --git a/.changeset/21412-metadata-view-container-name-judge.md b/.changeset/21412-metadata-view-container-name-judge.md new file mode 100644 index 00000000000..c8df04b212b --- /dev/null +++ b/.changeset/21412-metadata-view-container-name-judge.md @@ -0,0 +1,10 @@ +--- +'@objectstack/metadata': minor +--- + +One judge for a view container's own `name` at every door that files a container: the new `@objectstack/metadata/view-container-name` entry + +Clause-②: yes + +- 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. +- 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. diff --git a/.changeset/21412-objectql-view-container-name-reexport.md b/.changeset/21412-objectql-view-container-name-reexport.md new file mode 100644 index 00000000000..c955c30b144 --- /dev/null +++ b/.changeset/21412-objectql-view-container-name-reexport.md @@ -0,0 +1,9 @@ +--- +'@objectstack/objectql': patch +--- + +`viewContainerNameRefusal` is now re-exported from `@objectstack/metadata/view-container-name` + +Clause-②: no + +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. diff --git a/.changeset/21412-spec-view-container-name-comment.md b/.changeset/21412-spec-view-container-name-comment.md new file mode 100644 index 00000000000..25442ac709c --- /dev/null +++ b/.changeset/21412-spec-view-container-name-comment.md @@ -0,0 +1,9 @@ +--- +'@objectstack/spec': patch +--- + +The comment above `ViewSchema`'s `guidance:` states who writes a view container's `name`, and the rule every door applies to it + +Clause-②: no + +`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. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index a4eefca502c..478daa416f9 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -94,6 +94,10 @@ import { // `sys-metadata-repository.ts` in this package and with `DatabaseLoader` in // `@objectstack/metadata` (#5108). See `rethrowUnlessMetadataStoreUnprovisioned`. import { isMissingTableError } from '@objectstack/metadata/errors'; +// [#21412] The divergent view-container `name` refusal — the one judge the +// boot loop, `os validate` and the artifact/HMR loader call too; this door +// passes the name it files the row under. See `saveMetaItem`. +import { savedViewContainerNameRefusal } from '@objectstack/metadata/view-container-name'; import type { BatchUpdateRequest, BatchUpdateResponse, @@ -1218,7 +1222,10 @@ export { stripReadDecorations }; * into a record risks producing an invalid record (e.g. a non-`.` * name). Structural validity is enforced separately by the view metadata schema * during the spec-validation step. No-op for non-view types and bodies that - * already carry a `name`. + * already carry a `name`. [#21412] A CONTAINER's authored `name` reaches this + * function only when it equals `saveName`: `saveMetaItem` refuses a + * disagreeing one first, through `savedViewContainerNameRefusal`, so keeping + * the authored `name` can no longer file a container under a second key. * * When `baseline` is provided (the registry entry this overlay will shadow), * missing identity fields — `viewKind`, `object`, `label` — are inherited onto @@ -17713,6 +17720,22 @@ export class ObjectStackProtocolImplementation implements // (#2555 — a console personalization PUT sends only the raw config). // See {@link normalizeViewMetadata}. { + // [#21412] FIRST, before the stamp below can keep an authored + // `name`: a view CONTAINER whose own `name` disagrees with the name + // this door files the row under is refused, `VALIDATION_ERROR` / + // 400, through the one judge the source registrars call. Accepted, + // it was stored under the row name and registered under the + // body's (`hydrateOverlayIntoRegistry` keys by `body.name`), so one + // document answered under two names. The key here is the save + // name, not the derived binding: this door keeps a container saved + // under a name other than its object (#13407, #21334), and the + // body it stamps for one must pass when sent back. A body with no + // `name` passes and is stamped below. Containers only — the + // every-type half is #21470. + if (singularType === 'view') { + const nameRefusal = savedViewContainerNameRefusal(request.item, request.name); + if (nameRefusal) throw nameRefusal; + } let baseline: unknown; if ((PLURAL_TO_SINGULAR[request.type] ?? request.type) === 'view' && typeof this.engine.registry?.getItem === 'function') { diff --git a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts index 11997b725cb..b3e3931aaaa 100644 --- a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts +++ b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts @@ -29,7 +29,9 @@ */ import { describe, expect, it } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; -import { expandViewContainer, ViewSchema } from '@objectstack/spec/ui'; +import { expandViewContainer, isAggregatedViewContainer, ViewSchema } from '@objectstack/spec/ui'; +import { MetadataPlugin } from '@objectstack/metadata'; +import { savedViewContainerNameRefusal } from '@objectstack/metadata/view-container-name'; import { ObjectStackProtocolImplementation } from './index.js'; interface Row { @@ -773,3 +775,122 @@ describe('#21334 a container on another package\'s object never takes that packa await expectEveryPackagedNameIntact(protocol); }); }); + +/** + * #21412 — the runtime save door refuses a view container whose own `name` + * disagrees with the name it is saved under, through the one judge the source + * registrars call (`@objectstack/metadata/view-container-name`). + * + * Before: the card's probe was ACCEPTED — stored as row `crm_lead` with body + * `name` `lead_views`, and registered as `lead_views` (the container, keyed by + * `body.name` in `hydrateOverlayIntoRegistry`) plus `crm_lead.default`: one + * document answering under a name its row does not have. The two source + * registrars refuse the same document. + * + * The key judged here is the SAVE name, not the binding: this door keeps a + * container saved under a name other than its object (the #13407 case above, + * #21334's arm), so the body it stamps for such a container must pass when it + * is sent back. Shapes as the card's measurement named them (row = save name): + * P1 row crm_lead / `name` lead_views; P2 row lead_views / `name` lead_views, + * bound to crm_lead; P2b P2 with no `name`; P3 row lead_views / `name` + * crm_lead; P4 row crm_lead / `name` lead_views, no other binding. + */ +describe('#21412 the save door refuses a container whose own name disagrees with the name it is saved under', () => { + const named = (name: string | undefined, body: Record) => + (name === undefined ? { ...body } : { name, ...body }); + /** Bound to crm_lead through its own `object`, no `data` on any arm. */ + const objectBound = { object: 'crm_lead', list: { label: 'All Leads', type: 'grid', columns: [{ field: 'name' }] } }; + /** No binding but whatever `name` it carries. */ + const unbound = { list: { label: 'All', type: 'grid', columns: [{ field: 'name' }] } }; + + async function save(name: string, item: unknown) { + const harness = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(harness.engine); + let error: any = null; + try { + await protocol.saveMetaItem({ type: 'view', name, item }); + } catch (e) { + error = e; + } + const viewRows = Array.from(harness.rows.values()).filter((r) => r.type === 'view'); + // The keys the registry holds a CONTAINER under — its expansions carry + // `viewKind` and are the container's derived items, not a second key + // for the document (seat answer Q4). + const containerKeys = Array.from(harness.registered.get('view')?.entries() ?? []) + .filter(([, v]) => isAggregatedViewContainer(v)) + .map(([k]) => k); + return { ...harness, protocol, error, viewRows, containerKeys }; + } + + function expectRefused(outcome: Awaited>) { + // The minimum a rejection pin asserts: the ADR-0112 envelope. + expect(outcome.error).toBeInstanceOf(Error); + expect(outcome.error.code).toBe('VALIDATION_ERROR'); + expect(outcome.error.status).toBe(400); + // ...and the refused document reached nothing. + expect(outcome.viewRows).toEqual([]); + expect(outcome.registered.get('view')?.size ?? 0).toBe(0); + } + + it('P1, the card\'s probe: refused VALIDATION_ERROR / 400, nothing stored, nothing registered', async () => { + expectRefused(await save('crm_lead', named('lead_views', leadContainer))); + }); + + it('P1 is refused THROUGH the judge: the door throws exactly what it returns for that document', async () => { + const body = named('lead_views', leadContainer); + const { error } = await save('crm_lead', body); + expect(error.message).toBe(savedViewContainerNameRefusal(body, 'crm_lead')!.message); + }); + + it('P1 answers the envelope a source registrar answers for the same document', async () => { + const body = named('lead_views', objectBound); + const { error: saveDoor } = await save('crm_lead', body); + const plugin = new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' } }) as any; + const ctx = { + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + registerService: () => {}, getService: () => undefined, trigger: async () => {}, + } as any; + const registrar = await plugin._parseAndRegisterArtifact(ctx, JSON.parse(JSON.stringify({ + manifest: { id: 'com.acme.crm', name: 'CRM', version: '1.0.0', type: 'app' }, + views: [body], + })), 'fixture-21412').then(() => null, (e: any) => e); + expect(registrar).toBeInstanceOf(Error); + expect([saveDoor.code, saveDoor.status]).toEqual([registrar.code, registrar.status]); + expect([saveDoor.code, saveDoor.status]).toEqual(['VALIDATION_ERROR', 400]); + }); + + it('P3: a `name` equal to the binding but not to the row is refused', async () => { + expectRefused(await save('lead_views', named('crm_lead', leadContainer))); + }); + + it('P4: a `name` that is the only binding, but not the row, is refused', async () => { + expectRefused(await save('crm_lead', named('lead_views', unbound))); + }); + + it('P2: a `name` equal to the row passes though the container binds elsewhere — one key, the row\'s', async () => { + const outcome = await save('lead_views', named('lead_views', objectBound)); + expect(outcome.error).toBeNull(); + expect(outcome.viewRows.map((r) => [r.name, JSON.parse(r.metadata).name])).toEqual([['lead_views', 'lead_views']]); + expect(outcome.containerKeys).toEqual(['lead_views']); + const list: any = await outcome.protocol.getMetaItems({ type: 'view' }); + expect(switcherMatches(list.items, 'crm_lead').map((v: any) => v.name)).toEqual(['crm_lead.default']); + }); + + it('P2b: an absent `name` passes and is stamped with the row name — and the stamped body passes when sent back', async () => { + const outcome = await save('lead_views', named(undefined, objectBound)); + expect(outcome.error).toBeNull(); + expect(outcome.viewRows.map((r) => JSON.parse(r.metadata).name)).toEqual(['lead_views']); + expect(outcome.containerKeys).toEqual(['lead_views']); + + const read: any = await outcome.protocol.getMetaItem({ type: 'view', name: 'lead_views' }); + expect(read.item.name).toBe('lead_views'); + const { _diagnostics: _drop, ...sentBack } = read.item; + await expect(outcome.protocol.saveMetaItem({ type: 'view', name: 'lead_views', item: sentBack })).resolves.toBeTruthy(); + }); + + it('CONTROL: a `name` equal to the row and the binding passes, under one key', async () => { + const outcome = await save('crm_lead', named('crm_lead', leadContainer)); + expect(outcome.error).toBeNull(); + expect(outcome.containerKeys).toEqual(['crm_lead']); + }); +}); diff --git a/packages/metadata/package.json b/packages/metadata/package.json index 443f80d43df..c49fae3a9db 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -56,6 +56,16 @@ "types": "./dist/view-container.d.cts", "default": "./dist/view-container.cjs" } + }, + "./view-container-name": { + "import": { + "types": "./dist/view-container-name.d.ts", + "default": "./dist/view-container-name.js" + }, + "require": { + "types": "./dist/view-container-name.d.cts", + "default": "./dist/view-container-name.cjs" + } } }, "files": [ diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index 09d622b5c29..6924156b5fd 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -229,6 +229,10 @@ import { isAggregatedViewContainer, expandViewContainer } from '@objectstack/spe // chain of their own; it carries the same order #13407 settled at the runtime // door (`expandRuntimeViewContainer` in `packages/metadata-protocol`). import { deriveViewContainerObject } from './view-container-expansion.js'; +// [#21412] The divergent container `name` refusal — the one judge the boot +// loop, `os validate` and the runtime save door call too. See the container +// branch of `_registerArtifactBodyCollections`. +import { viewContainerNameRefusal } from './view-container-name.js'; import type { IHttpServer } from '@objectstack/spec/contracts'; @@ -1177,6 +1181,16 @@ export class MetadataPlugin implements Plugin { // container, so the flattened copy is the duplicate, not a // second definition. if (slots.skip?.('view', viewObject)) continue; + // [#21412] A container whose own `name` disagrees with the + // key derived above is refused through the one judge every + // door that files a container calls, in its words — and + // BEFORE `memLoader.save`, so a refusal files nothing. + // `manager.register` below would refuse the same document + // too (#7378 row 1, `assertMetadataRegisterContract`), but + // in the generic register contract's words and only after + // the loader write; row 1 is unchanged for every type. + const nameRefusal = viewContainerNameRefusal(item, 'artifact', packageId); + if (nameRefusal) throw nameRefusal; applyProtection(item as any, { packageId: packageId, packageVersion: packageVersion, diff --git a/packages/metadata/src/serializers/typescript-serializer-annotation.test.ts b/packages/metadata/src/serializers/typescript-serializer-annotation.test.ts index 9982d657443..bdfa85a060c 100644 --- a/packages/metadata/src/serializers/typescript-serializer-annotation.test.ts +++ b/packages/metadata/src/serializers/typescript-serializer-annotation.test.ts @@ -168,7 +168,9 @@ describe('TypeScriptSerializer annotation, per metadata type', () => { it('no exports entry of the package re-exports the internal channel', async () => { // Control: the name is spelled right, so the absences below can fail. expect(Object.keys(await import('./typescript-serializer.js'))).toContain('serializeTypeScriptForMetadataType'); - expect(EXPORT_ENTRY_SOURCES.length).toBe(5); + // Six since `./view-container-name` (#21412): the count is the control + // that the loop below visits every entry, so a new entry moves it here. + expect(EXPORT_ENTRY_SOURCES.length).toBe(6); for (const source of EXPORT_ENTRY_SOURCES) { const entry = (await import(source)) as Record; expect(Object.keys(entry).length, source).toBeGreaterThan(0); diff --git a/packages/metadata/src/view-container-name.test.ts b/packages/metadata/src/view-container-name.test.ts new file mode 100644 index 00000000000..eb551a8a0ef --- /dev/null +++ b/packages/metadata/src/view-container-name.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #21412 — the divergent view-container `name` refusal, as ONE judge with two + * entries: the source registrars' (the key is DERIVED from the binding) and + * the runtime save door's (the key is the name the row is SAVED under). + * + * The boot registrar's and `os validate`'s use of the derived entry stays + * pinned where it always was (`packages/objectql`'s + * `view-container-name-refusal.test.ts`, `packages/cli`'s + * `validate-view-container-name.test.ts`), unedited: their words did not move. + * The save door's use is pinned at the door, in `packages/metadata-protocol`'s + * `view-container-runtime-expansion.test.ts`. This file pins the judge's two + * predicates against each other on the shapes that tell them apart, and the + * artifact/HMR door's container branch, which lives in this package. + * + * Shapes, named as the card's measurement named them (row = the save name): + * P1 row crm_lead, body `name` lead_views, bound to crm_lead — both refuse; + * P2 row lead_views, body `name` lead_views, bound to crm_lead — the derived + * entry refuses, the save door must NOT (#13407 keeps such a container, + * #21334 expands one under its own name); + * P3 row lead_views, body `name` crm_lead, bound to crm_lead — the derived + * entry passes, the save door must refuse; + * P4 row crm_lead, body `name` lead_views, no other binding — the derived + * entry passes (the binding falls back to `name`), the save door must + * refuse. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { savedViewContainerNameRefusal, viewContainerNameRefusal } from './view-container-name.js'; +import { MetadataPlugin } from './plugin.js'; + +const PKG = 'com.acme.crm'; + +const listArm = { label: 'All Leads', type: 'grid', columns: [{ field: 'name' }] }; +/** Bound to crm_lead through its own `object`, with no `data` on any arm. */ +const boundBody = (name?: string) => ({ ...(name === undefined ? {} : { name }), object: 'crm_lead', list: listArm }); +/** No binding but its own `name`. */ +const nameOnlyBody = (name: string) => ({ name, list: listArm }); + +/** The minimum a rejection pin asserts: the ADR-0112 envelope, not "it threw". */ +function expectEnvelope(refusal: unknown): void { + expect(refusal).toBeInstanceOf(Error); + expect((refusal as any).code).toBe('VALIDATION_ERROR'); + expect((refusal as any).status).toBe(400); + expect((refusal as any).httpStatus).toBe(400); +} + +describe('#21412 — the save door\'s entry judges the name the row is saved under', () => { + it('P1: a body `name` that is neither the row nor the binding is refused, naming both values', () => { + const refusal = savedViewContainerNameRefusal(boundBody('lead_views'), 'crm_lead'); + expectEnvelope(refusal); + expect(refusal!.message).toContain("'lead_views'"); + expect(refusal!.message).toContain("'crm_lead'"); + }); + + it('P3: a body `name` equal to the binding but not to the row is refused', () => { + expectEnvelope(savedViewContainerNameRefusal(boundBody('crm_lead'), 'lead_views')); + }); + + it('P4: a body `name` that is the only binding, but not the row, is refused', () => { + expectEnvelope(savedViewContainerNameRefusal(nameOnlyBody('lead_views'), 'crm_lead')); + }); + + it('P2: a body `name` equal to the row passes, though the container binds elsewhere', () => { + expect(savedViewContainerNameRefusal(boundBody('lead_views'), 'lead_views')).toBeUndefined(); + }); + + it('an absent, empty or non-string `name` refuses nothing — the door stamps or the schema judges it', () => { + expect(savedViewContainerNameRefusal(boundBody(), 'lead_views')).toBeUndefined(); + expect(savedViewContainerNameRefusal(boundBody(''), 'lead_views')).toBeUndefined(); + expect(savedViewContainerNameRefusal({ ...boundBody(), name: 7 }, 'lead_views')).toBeUndefined(); + }); + + it('SCOPE: a standalone ViewItem is not judged here — the every-type half is #21470', () => { + const record = { name: 'crm_lead.other', object: 'crm_lead', viewKind: 'list', list: listArm }; + expect(savedViewContainerNameRefusal(record, 'crm_lead.mine')).toBeUndefined(); + expect(savedViewContainerNameRefusal({ name: 'b', label: 'not a container' }, 'a')).toBeUndefined(); + }); +}); + +describe('#21412 — the two entries are one judgement keyed differently, not two rules', () => { + it('on P1 both entries refuse, in one envelope', () => { + const derived = viewContainerNameRefusal(boundBody('lead_views'), 'manifest', PKG); + const saved = savedViewContainerNameRefusal(boundBody('lead_views'), 'crm_lead'); + expectEnvelope(derived); + expectEnvelope(saved); + }); + + it('they part exactly where the derived key and the save name part (P2, P3, P4)', () => { + // P2: derived refuses (the name is not the binding); saved passes. + expectEnvelope(viewContainerNameRefusal(boundBody('lead_views'), 'manifest', PKG)); + expect(savedViewContainerNameRefusal(boundBody('lead_views'), 'lead_views')).toBeUndefined(); + // P3: derived passes (the name IS the binding); saved refuses. + expect(viewContainerNameRefusal(boundBody('crm_lead'), 'manifest', PKG)).toBeUndefined(); + expectEnvelope(savedViewContainerNameRefusal(boundBody('crm_lead'), 'lead_views')); + // P4: derived passes (the binding falls back to the name); saved refuses. + expect(viewContainerNameRefusal(nameOnlyBody('lead_views'), 'manifest', PKG)).toBeUndefined(); + expectEnvelope(savedViewContainerNameRefusal(nameOnlyBody('lead_views'), 'crm_lead')); + }); + + it('the derived entry keeps boot\'s precondition: a falsy derived key refuses nothing', () => { + // `deriveViewContainerObject`'s `??` chain keeps an empty string, and + // boot warns and skips such an entry instead of refusing it. + const emptyBinding = { name: 'x', list: { ...listArm, data: { provider: 'object', object: '' } } }; + expect(viewContainerNameRefusal(emptyBinding, 'manifest', PKG)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// The artifact/HMR door's container branch, driven as its own #13912 pin +// drives it (`plugin-artifact-view-container-object.test.ts`). +// --------------------------------------------------------------------------- + +function fakeCtx() { + return { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: vi.fn(() => undefined), + trigger: vi.fn(), + } as any; +} + +async function loadThroughArtifactDoor(container: unknown): Promise<{ plugin: any; error: any }> { + const plugin = new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' } }) as any; + const definition = JSON.parse(JSON.stringify({ + manifest: { id: PKG, name: 'CRM', version: '1.0.0', type: 'app' }, + views: [container], + })); + let error: any = null; + try { + await plugin._parseAndRegisterArtifact(fakeCtx(), definition, 'fixture-21412'); + } catch (e) { + error = e; + } + return { plugin, error }; +} + +describe('#21412 — the artifact/HMR door\'s container branch refuses through the judge', () => { + it('refuses the probe document with the judge\'s refusal, and files nothing', async () => { + const { plugin, error } = await loadThroughArtifactDoor(boundBody('lead_views')); + expectEnvelope(error); + // THROUGH the judge: the door throws what the derived entry returns + // for the same document, word for word — not the generic register + // contract's refusal (#7378 row 1), which this branch no longer reaches. + expect(error.message).toBe(viewContainerNameRefusal(boundBody('lead_views'), 'artifact', PKG)!.message); + expect(await plugin.manager.get('view', 'crm_lead')).toBeFalsy(); + expect(await plugin.manager.get('view', 'lead_views')).toBeFalsy(); + expect(await plugin.manager.get('view', 'crm_lead.default')).toBeFalsy(); + }); + + it('CONTROL: an agreeing or absent `name` still registers under the derived key', async () => { + for (const body of [boundBody('crm_lead'), boundBody()]) { + const { plugin, error } = await loadThroughArtifactDoor(body); + expect(error).toBeNull(); + expect(await plugin.manager.get('view', 'crm_lead')).toBeTruthy(); + expect(await plugin.manager.get('view', 'crm_lead.default')).toBeTruthy(); + } + }); +}); diff --git a/packages/metadata/src/view-container-name.ts b/packages/metadata/src/view-container-name.ts new file mode 100644 index 00000000000..1cad1d06976 --- /dev/null +++ b/packages/metadata/src/view-container-name.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `@objectstack/metadata/view-container-name` — the divergent view-container + * `name` refusal: ONE judge, called by every door that files a container. + * + * ## What it judges + * + * A container's own `name`, when set, must equal the key the door files the + * container under. A disagreement is refused: resolving it silently in either + * direction files the item under a key the author never wrote (#7378 row 1). + * The maintainer's ruling of 2026-09-03 (direction 2) made the source + * registrars refuse it; the runtime save door is the third door, ruled on + * #21412 (seat answer, Q1 A). + * + * The doors differ in WHERE their key comes from, and only there: + * + * - **The source registrars** — the ObjectQL boot loop, `os validate` / + * `os compile` (the same judge, through `@objectstack/objectql`) and the + * artifact/HMR loader (`plugin.ts`) — file a container under the object it + * binds to, so their key is DERIVED: {@link viewContainerNameRefusal}. + * - **The runtime save door** (`saveMetaItem`, which REST `PUT + * /meta/view/:name` and the dispatcher both call) files the row under the + * name it is SAVED under, and that name is not always the binding: the + * door keeps a container saved under a name other than its object (#13407) + * and expands one on another package's object under its own name (#21334). + * So its key is the save name: {@link savedViewContainerNameRefusal}. + * + * Judging the save door against the derived key instead was measured and + * refused (#21412 probes P2–P4): it refuses the body that same door stores + * for the #13407 shape when it is sent back, and it passes two bodies whose + * `name` disagrees with the row, which then register under the body's name. + * + * ## Why the derived entry derives the key itself + * + * For a container, the key the source registrars file under IS + * {@link deriveViewContainerObject} — the first branch of the boot loop's + * `resolveMetadataItemName` returns exactly that, gated on the same + * {@link isAggregatedViewContainer}, and the artifact door derives the same + * value before it registers. Taking the key as a parameter there would make + * every one of those callers re-derive it, a second spelling of "which + * derivation does a source registrar use" — so that entry keeps deriving. The + * save door's key is not a derivation at all; it is the request's own name, + * which is why that entry takes it. + * + * ## The gate, carried whole + * + * * {@link isAggregatedViewContainer} — the CONTAINER branch only. A + * standalone ViewItem's `name` is its identity, not a binding (the + * every-type half at the save door is #21470); + * * `name` a non-empty string AND different from the key. A container with + * no `name` is untouched (the save door stamps one); + * * a falsy key refuses nothing: the boot loop warns and skips an entry + * whose derived key is falsy, and `deriveViewContainerObject`'s `??` chain + * can keep `''` (`list: { data: { object: '' } }`), so a door that calls + * this alone cannot refuse what boot skips. + * + * ## The words + * + * One template; each door supplies only where its key came from + * ({@link KeyOrigin}). The source registrars' rendering is byte for byte the + * words the boot loop and `os validate` have always printed. The runtime + * string carries NO tracker id (`check:doc-authoring`). The envelope is + * ADR-0112's `VALIDATION_ERROR` / 400 at every door. + * + * ## Why a subpath of its own + * + * `@objectstack/metadata` is the one layer all three doors already depend on + * (`@objectstack/objectql` and `@objectstack/metadata-protocol` list it, and + * the artifact door lives in it); `@objectstack/core` cannot host it, because + * `@objectstack/metadata` lists core and the judge needs the derivation that + * lives here. It is NOT on the `./view-container` leaf: that entry imports + * nothing at all (its header measures why), and this one needs + * `isAggregatedViewContainer` from `@objectstack/spec`. Nor on the ROOT entry, + * which loads the manager and the filesystem machinery that objectql's + * ADR-0076 lean entry must not reach. + */ + +import { isAggregatedViewContainer } from '@objectstack/spec'; +import { deriveViewContainerObject } from './view-container.js'; + +/** The refusal, in the ADR-0112 envelope every door throws it in. */ +export interface ViewContainerNameRefusal extends Error { + code: 'VALIDATION_ERROR'; + status: 400; + httpStatus: 400; +} + +/** + * Where a door's key came from — the only words that differ between doors. + * `subject` names the container and its source, `key` says what the key is, + * `keyDetail` follows the key, and `alsoRefused` follows the shared reason. + */ +interface KeyOrigin { + subject: string; + key: string; + keyDetail: string; + alsoRefused: string; +} + +function refusal(name: string, key: string, origin: KeyOrigin): ViewContainerNameRefusal { + const err = new Error( + `Invalid ${origin.subject}: the container's own ` + + `\`name\` is '${name}', which disagrees with ${origin.key}, ` + + `'${key}'${origin.keyDetail}. A disagreement is almost always an authoring bug, and resolving ` + + 'it silently in either direction can file the item under a key the caller never wrote ' + + `(refuse loudly, locate the mismatch)${origin.alsoRefused}. Register under one name: drop \`name\`, or set it to ` + + `'${key}'.`, + ) as ViewContainerNameRefusal; + err.code = 'VALIDATION_ERROR'; + err.status = 400; + err.httpStatus = 400; + return err; +} + +/** The judgement every door shares: a set own `name` that is not the key. */ +function judge(container: unknown, key: string | undefined, origin: () => KeyOrigin): ViewContainerNameRefusal | undefined { + if (!isAggregatedViewContainer(container)) return undefined; + const name = (container as { name?: unknown }).name; + if (typeof name !== 'string' || !name) return undefined; + if (!key) return undefined; + if (name === key) return undefined; + return refusal(name, key, origin()); +} + +/** + * Judge one `views:` entry at a SOURCE registrar: the refusal when it is an + * aggregated container whose own `name` disagrees with the object key it binds + * to, else `undefined`. + * + * Pure: it throws nothing and registers nothing. The boot registrar and the + * artifact door throw what it returns; `os validate` reports it. + * + * @param container One entry of a `views:` collection. + * @param sourceLabel The words naming the source (`manifest`, `nested plugin`, + * `artifact`). + * @param ownerId The owning package id the registrar stamps. + */ +export function viewContainerNameRefusal( + container: unknown, + sourceLabel: string, + ownerId: string | undefined, +): ViewContainerNameRefusal | undefined { + return judge(container, deriveViewContainerObject(container), () => ({ + subject: `\`views:\` container from ${sourceLabel} '${ownerId}'`, + key: 'the object key it binds to', + keyDetail: ' (derived from its own `object`, else `list.data.object` / `form.data.object`)', + alsoRefused: ' — the artifact/HMR loader refuses this same document', + })); +} + +/** + * Judge one view body at the runtime SAVE door: the refusal when it is an + * aggregated container whose own `name` disagrees with the name it is saved + * under, else `undefined`. Call it before the door stamps a missing `name`. + * + * @param container The request's view body. + * @param saveName The name the door files the row under (`request.name`). + */ +export function savedViewContainerNameRefusal( + container: unknown, + saveName: string, +): ViewContainerNameRefusal | undefined { + return judge(container, saveName, () => ({ + subject: 'view container', + key: 'the name it is saved under', + keyDetail: '', + alsoRefused: '', + })); +} diff --git a/packages/metadata/tsup.config.ts b/packages/metadata/tsup.config.ts index 730430bbcc7..0aa0a0b6927 100644 --- a/packages/metadata/tsup.config.ts +++ b/packages/metadata/tsup.config.ts @@ -18,6 +18,11 @@ export default defineConfig({ // entry for the same reason `errors` has one: objectql's ADR-0076 lean // entry needs the pure function, not the manager, the loaders or their deps. 'src/view-container.ts', + // `@objectstack/metadata/view-container-name` — the divergent container + // `name` refusal every door that files a container calls (#21412). Its + // own entry, not the leaf above: it needs `@objectstack/spec`, which the + // leaf deliberately does not import. + 'src/view-container-name.ts', ], splitting: false, sourcemap: true, diff --git a/packages/objectql/src/view-container-divergent-name-registrars.test.ts b/packages/objectql/src/view-container-divergent-name-registrars.test.ts index 98931256bc7..b1c8036d3dd 100644 --- a/packages/objectql/src/view-container-divergent-name-registrars.test.ts +++ b/packages/objectql/src/view-container-divergent-name-registrars.test.ts @@ -257,17 +257,25 @@ describe('#14399 — the row\'s own `name` is the LAST term of the container der // observable on this shape: its refusal names the key it derived. // `toEqual(AGREED_KEYS)` above would also be satisfied by both sides // moving to `lead_views`, so the agreed VALUE is pinned at both. + // + // [#21412] Read from the judge's words now: the artifact door's + // container branch refuses through `viewContainerNameRefusal` before + // it files anything, so the generic register contract's refusal + // (#7378 row 1, `register('view', 'crm_lead'): data.name is …`) is no + // longer reached on this shape. Same derived key, same two values. const err = await loadThroughArtifactDoor(divergentContainer).catch((e) => e as any); expect(err).toBeInstanceOf(Error); - expect(err.message).toContain("register('view', 'crm_lead')"); - expect(err.message).toContain("data.name is 'lead_views'"); + expect(err.message).toContain("binds to, 'crm_lead'"); + expect(err.message).toContain("`name` is 'lead_views'"); }); it('MEASURED CORRECTION: the artifact door does not silently mis-key it — it refuses, enveloped (#7378 row 1)', async () => { // The card predicted a second SILENT key here. Measured: the door // derives `crm_lead`, then `assertMetadataRegisterContract` refuses the // whole artifact load because the document's own `data.name` still says - // `lead_views`. Asserting the ADR-0112 envelope, not merely "it threw": + // `lead_views`. [#21412] The refusal now comes one step earlier, from + // the same judge the boot loop throws (`viewContainerNameRefusal`), + // in the same envelope. Asserting the ADR-0112 envelope, not merely "it threw": // a bare `toThrow()` would stay green on any unrelated failure. const err = await loadThroughArtifactDoor(divergentContainer).catch((e) => e as any); expect(err.code).toBe('VALIDATION_ERROR'); diff --git a/packages/objectql/src/view-container-name-refusal.ts b/packages/objectql/src/view-container-name-refusal.ts index 21af64e519d..a030d170315 100644 --- a/packages/objectql/src/view-container-name-refusal.ts +++ b/packages/objectql/src/view-container-name-refusal.ts @@ -1,115 +1,61 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The divergent view-container `name` refusal — ONE judge, called by the boot - * registrar and by `os validate`. - * - * ## What it judges - * - * An aggregated `defineView` container is registered under the OBJECT it - * binds to, not under its own `name` (`resolveMetadataItemName` in - * `engine.ts`). A container whose own `name` is set and differs from that - * derived key is refused: resolving the disagreement silently in either - * direction files the item under a key the author never wrote (#7378 row 1). - * The boot loop adopted the refusal under the maintainer ruling of - * 2026-09-03 (direction 2), converging onto the artifact/HMR loader, which - * already refused the same document. - * - * ## Why this is a module and not a block inside `registerMetadataCollections` - * - * `os validate` is the author-time judge of what the runtime will accept. It - * used to pass this document (exit 0) while `os serve` refused it at boot - * (#20331). The fix is the SAME judgment at both doors, in the same words — - * not a second rule that agrees with the first only until one of them is - * edited. So the check moved here, and the boot loop throws what this - * returns; the CLI reports it. The message and the envelope moved byte for - * byte. The GATE moved whole, precondition included: the boot loop skips an - * entry whose derived key is falsy (it warns and registers nothing) BEFORE it - * reaches this check, and this function answers `undefined` for that entry - * too, so a door that calls it alone cannot refuse what boot skips. - * - * ## Why it derives the key itself instead of taking it - * - * For a container, the key boot registers under IS - * {@link deriveViewContainerObject} — the first branch of - * `resolveMetadataItemName` returns exactly that, gated on the same - * {@link isAggregatedViewContainer}. Taking the key as a parameter would make - * every other caller re-derive it, which is a second spelling of "which - * derivation does boot use for a container" — the drift this module exists to - * close. Deriving here keeps one answer for both doors. - * - * The gate is two of the three narrowings the ruling named as load-bearing; - * the third, `key === 'views'`, stays at the call site, because only the - * `views:` collection carries containers: - * * {@link isAggregatedViewContainer} — the CONTAINER branch only. A - * standalone ViewItem's `name` is its identity, not a binding; - * * `name` present AND different. A container with no `name` is untouched; - * so is one whose `name` already equals the derived key, and one that - * declares no binding anywhere else, because the derivation then falls - * back to that same `name` and cannot disagree with itself. - * Ahead of both sits the boot loop's own precondition: a derived key that is - * falsy means boot skips the entry, so there is nothing to refuse. The key - * CAN be `''` while `name` is set — `deriveViewContainerObject`'s `??` chain - * keeps an empty string, so `list: { data: { object: '' } }` with no - * top-level `object` derives `''` — and without this line a second door - * would refuse a container boot only warns about. - * - * The runtime string carries NO tracker id: it is read by authors and - * operators who cannot resolve one (`check:doc-authoring`). The envelope is - * the artifact door's — `VALIDATION_ERROR` / 400 — asserted equal to it in + * The divergent view-container `name` refusal, as `@objectstack/objectql` + * exports it — a re-export of the ONE judge, which lives in + * `@objectstack/metadata/view-container-name`. + * + * ## What it judges, and who calls it + * + * A container's own `name`, when set, must equal the key the door files the + * container under; a disagreement is refused under the maintainer's ruling of + * 2026-09-03 (direction 2), because resolving it silently in either direction + * files the item under a key the author never wrote (#7378 row 1). Four doors + * judge it, in one template: + * + * - the boot registrar (`registerMetadataCollections` in `engine.ts`), which + * throws what {@link viewContainerNameRefusal} returns; + * - `os validate` / `os compile` (`packages/cli`), which report it — the + * author-time judge of what `os serve` accepts (#20331); + * - the artifact/HMR loader (`@objectstack/metadata`'s `plugin.ts`), which + * throws it before it files anything; + * - the runtime save door (`saveMetaItem`, `@objectstack/metadata-protocol`), + * through the judge's save-door entry (#21412). + * + * ## Why the judge moved, and why this export stays + * + * The runtime save door is the third door and the one that forced the move: + * `@objectstack/metadata-protocol` cannot import `@objectstack/objectql` (the + * edge runs the other way), and `@objectstack/core` cannot host the judge + * because the derivation it needs lives in `@objectstack/metadata`, which lists + * core. `@objectstack/metadata` is the one layer every door already depends on, + * so the judge — its gate, its envelope and its words — lives there now, and + * this module keeps the published name and signature so `engine.ts` and the CLI + * doors import exactly what they imported before. The words the boot registrar + * and `os validate` print are byte for byte what this module used to build. + * + * ## Why the source registrars' entry derives the key, and the save door's does not + * + * For a container, the key the source registrars file under IS + * `deriveViewContainerObject` — the first branch of `resolveMetadataItemName` + * returns exactly that, gated on the same `isAggregatedViewContainer` — so the + * entry this module exports derives it itself rather than make every caller + * re-derive it (a second spelling of "which derivation does boot use for a + * container"). The precondition moved with it: a falsy derived key refuses + * nothing, because boot warns and skips that entry. + * + * The runtime save door's key is not a derivation: it files the row under the + * name it is SAVED under, and that name is not always the binding (it keeps a + * container saved under a name other than its object, #13407, and expands one + * on another package's object under its own name, #21334). So the save door's + * entry takes the key; judging it against the derived key was measured and + * refused (#21412). Both entries share one judgement and one template, and + * differ only in where the key came from. + * + * The envelope is the artifact door's and every door's — `VALIDATION_ERROR` / + * 400 — asserted equal across the source registrars in * `view-container-divergent-name-registrars.test.ts`. */ -import { isAggregatedViewContainer } from '@objectstack/spec'; -// The LEAF subpath, for the reason `engine.ts` states at its own import. -import { deriveViewContainerObject } from '@objectstack/metadata/view-container'; - -/** The refusal, in the ADR-0112 envelope the boot registrar throws it in. */ -export interface ViewContainerNameRefusal extends Error { - code: 'VALIDATION_ERROR'; - status: 400; - httpStatus: 400; -} - -/** - * Judge one `views:` entry: the refusal when it is an aggregated container - * whose own `name` disagrees with the object key it binds to, else - * `undefined`. - * - * Pure: it throws nothing and registers nothing. The boot registrar throws - * what it returns; `os validate` reports it. - * - * @param container One entry of a `views:` collection. - * @param sourceLabel The words naming the source, as the boot registrar - * names it (`manifest`, `nested plugin`). - * @param ownerId The owning package id the boot registrar stamps. - */ -export function viewContainerNameRefusal( - container: unknown, - sourceLabel: string, - ownerId: string | undefined, -): ViewContainerNameRefusal | undefined { - if (!isAggregatedViewContainer(container)) return undefined; - const name = (container as { name?: unknown }).name; - if (typeof name !== 'string' || !name) return undefined; - const itemName = deriveViewContainerObject(container); - // Boot's precondition, carried with the gate: `registerMetadataCollections` - // warns and skips an entry whose derived key is falsy before it reaches - // this check, so such an entry is never refused. - if (!itemName) return undefined; - if (name === itemName) return undefined; - const err = new Error( - `Invalid \`views:\` container from ${sourceLabel} '${ownerId}': the container's own ` - + `\`name\` is '${name}', which disagrees with the object key it binds to, ` - + `'${itemName}' (derived from its own \`object\`, else \`list.data.object\` / ` - + '`form.data.object`). A disagreement is almost always an authoring bug, and resolving ' - + 'it silently in either direction can file the item under a key the caller never wrote ' - + '(refuse loudly, locate the mismatch) — the artifact/HMR loader refuses ' - + 'this same document. Register under one name: drop `name`, or set it to ' - + `'${itemName}'.`, - ) as ViewContainerNameRefusal; - err.code = 'VALIDATION_ERROR'; - err.status = 400; - err.httpStatus = 400; - return err; -} +export { viewContainerNameRefusal } from '@objectstack/metadata/view-container-name'; +export type { ViewContainerNameRefusal } from '@objectstack/metadata/view-container-name'; diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index f988699edb4..ff5bf744a62 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -4720,9 +4720,16 @@ export const ViewSchema = lazySchema(() => strictObject({ // // `name`, `label` and `object` are NOT in this list, and the first draft had // all three — wrongly. A container carries its own identity and its object - // binding: `saveMetaItem` sends the name, artifact-shipped containers do - // (`service-ai/ai_traces`), the validation sweep injects it, and a - // stack-level `views: [...]` entry needs `object` to say which object it + // binding. Its `name` is written by the metadata door itself: `saveMetaItem` + // stamps the save name onto a body that has none (`normalizeViewMetadata`) + // and serves it back, so a read-then-write round trip sends it. That stamp is + // the only platform writer of the key — artifact-shipped containers carry + // none, and the validation sweep passes its name as the request name, not in + // the body. An authored `name` is held to one rule at every door that files a + // container: when set, it equals the key that door files it under (the object + // key the source registrars derive from the binding; the save name at + // `saveMetaItem`), or the door refuses it (`@objectstack/metadata/view-container-name`). + // And a stack-level `views: [...]` entry needs `object` to say which object it // belongs to (this file's own note on `ObjectListViewSchema` calls the // container "view definitions for a specific object", and `getViewsByObject()` // is what reads that binding). Tombstoning them rejected shapes the platform