Skip to content

Commit d7f56bf

Browse files
committed
test: pin the view-container name judge at the save door and the artifact door
Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 390f699 commit d7f56bf

3 files changed

Lines changed: 293 additions & 4 deletions

File tree

‎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+
});
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #21412 — the divergent view-container `name` refusal, as ONE judge with two
5+
* entries: the source registrars' (the key is DERIVED from the binding) and
6+
* the runtime save door's (the key is the name the row is SAVED under).
7+
*
8+
* The boot registrar's and `os validate`'s use of the derived entry stays
9+
* pinned where it always was (`packages/objectql`'s
10+
* `view-container-name-refusal.test.ts`, `packages/cli`'s
11+
* `validate-view-container-name.test.ts`), unedited: their words did not move.
12+
* The save door's use is pinned at the door, in `packages/metadata-protocol`'s
13+
* `view-container-runtime-expansion.test.ts`. This file pins the judge's two
14+
* predicates against each other on the shapes that tell them apart, and the
15+
* artifact/HMR door's container branch, which lives in this package.
16+
*
17+
* Shapes, named as the card's measurement named them (row = the save name):
18+
* P1 row crm_lead, body `name` lead_views, bound to crm_lead — both refuse;
19+
* P2 row lead_views, body `name` lead_views, bound to crm_lead — the derived
20+
* entry refuses, the save door must NOT (#13407 keeps such a container,
21+
* #21334 expands one under its own name);
22+
* P3 row lead_views, body `name` crm_lead, bound to crm_lead — the derived
23+
* entry passes, the save door must refuse;
24+
* P4 row crm_lead, body `name` lead_views, no other binding — the derived
25+
* entry passes (the binding falls back to `name`), the save door must
26+
* refuse.
27+
*/
28+
29+
import { describe, it, expect, vi } from 'vitest';
30+
import { savedViewContainerNameRefusal, viewContainerNameRefusal } from './view-container-name.js';
31+
import { MetadataPlugin } from './plugin.js';
32+
33+
const PKG = 'com.acme.crm';
34+
35+
const listArm = { label: 'All Leads', type: 'grid', columns: [{ field: 'name' }] };
36+
/** Bound to crm_lead through its own `object`, with no `data` on any arm. */
37+
const boundBody = (name?: string) => ({ ...(name === undefined ? {} : { name }), object: 'crm_lead', list: listArm });
38+
/** No binding but its own `name`. */
39+
const nameOnlyBody = (name: string) => ({ name, list: listArm });
40+
41+
/** The minimum a rejection pin asserts: the ADR-0112 envelope, not "it threw". */
42+
function expectEnvelope(refusal: unknown): void {
43+
expect(refusal).toBeInstanceOf(Error);
44+
expect((refusal as any).code).toBe('VALIDATION_ERROR');
45+
expect((refusal as any).status).toBe(400);
46+
expect((refusal as any).httpStatus).toBe(400);
47+
}
48+
49+
describe('#21412 — the save door\'s entry judges the name the row is saved under', () => {
50+
it('P1: a body `name` that is neither the row nor the binding is refused, naming both values', () => {
51+
const refusal = savedViewContainerNameRefusal(boundBody('lead_views'), 'crm_lead');
52+
expectEnvelope(refusal);
53+
expect(refusal!.message).toContain("'lead_views'");
54+
expect(refusal!.message).toContain("'crm_lead'");
55+
});
56+
57+
it('P3: a body `name` equal to the binding but not to the row is refused', () => {
58+
expectEnvelope(savedViewContainerNameRefusal(boundBody('crm_lead'), 'lead_views'));
59+
});
60+
61+
it('P4: a body `name` that is the only binding, but not the row, is refused', () => {
62+
expectEnvelope(savedViewContainerNameRefusal(nameOnlyBody('lead_views'), 'crm_lead'));
63+
});
64+
65+
it('P2: a body `name` equal to the row passes, though the container binds elsewhere', () => {
66+
expect(savedViewContainerNameRefusal(boundBody('lead_views'), 'lead_views')).toBeUndefined();
67+
});
68+
69+
it('an absent, empty or non-string `name` refuses nothing — the door stamps or the schema judges it', () => {
70+
expect(savedViewContainerNameRefusal(boundBody(), 'lead_views')).toBeUndefined();
71+
expect(savedViewContainerNameRefusal(boundBody(''), 'lead_views')).toBeUndefined();
72+
expect(savedViewContainerNameRefusal({ ...boundBody(), name: 7 }, 'lead_views')).toBeUndefined();
73+
});
74+
75+
it('SCOPE: a standalone ViewItem is not judged here — the every-type half is #21470', () => {
76+
const record = { name: 'crm_lead.other', object: 'crm_lead', viewKind: 'list', list: listArm };
77+
expect(savedViewContainerNameRefusal(record, 'crm_lead.mine')).toBeUndefined();
78+
expect(savedViewContainerNameRefusal({ name: 'b', label: 'not a container' }, 'a')).toBeUndefined();
79+
});
80+
});
81+
82+
describe('#21412 — the two entries are one judgement keyed differently, not two rules', () => {
83+
it('on P1 both entries refuse, in one envelope', () => {
84+
const derived = viewContainerNameRefusal(boundBody('lead_views'), 'manifest', PKG);
85+
const saved = savedViewContainerNameRefusal(boundBody('lead_views'), 'crm_lead');
86+
expectEnvelope(derived);
87+
expectEnvelope(saved);
88+
});
89+
90+
it('they part exactly where the derived key and the save name part (P2, P3, P4)', () => {
91+
// P2: derived refuses (the name is not the binding); saved passes.
92+
expectEnvelope(viewContainerNameRefusal(boundBody('lead_views'), 'manifest', PKG));
93+
expect(savedViewContainerNameRefusal(boundBody('lead_views'), 'lead_views')).toBeUndefined();
94+
// P3: derived passes (the name IS the binding); saved refuses.
95+
expect(viewContainerNameRefusal(boundBody('crm_lead'), 'manifest', PKG)).toBeUndefined();
96+
expectEnvelope(savedViewContainerNameRefusal(boundBody('crm_lead'), 'lead_views'));
97+
// P4: derived passes (the binding falls back to the name); saved refuses.
98+
expect(viewContainerNameRefusal(nameOnlyBody('lead_views'), 'manifest', PKG)).toBeUndefined();
99+
expectEnvelope(savedViewContainerNameRefusal(nameOnlyBody('lead_views'), 'crm_lead'));
100+
});
101+
102+
it('the derived entry keeps boot\'s precondition: a falsy derived key refuses nothing', () => {
103+
// `deriveViewContainerObject`'s `??` chain keeps an empty string, and
104+
// boot warns and skips such an entry instead of refusing it.
105+
const emptyBinding = { name: 'x', list: { ...listArm, data: { provider: 'object', object: '' } } };
106+
expect(viewContainerNameRefusal(emptyBinding, 'manifest', PKG)).toBeUndefined();
107+
});
108+
});
109+
110+
// ---------------------------------------------------------------------------
111+
// The artifact/HMR door's container branch, driven as its own #13912 pin
112+
// drives it (`plugin-artifact-view-container-object.test.ts`).
113+
// ---------------------------------------------------------------------------
114+
115+
function fakeCtx() {
116+
return {
117+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
118+
registerService: vi.fn(),
119+
getService: vi.fn(() => undefined),
120+
trigger: vi.fn(),
121+
} as any;
122+
}
123+
124+
async function loadThroughArtifactDoor(container: unknown): Promise<{ plugin: any; error: any }> {
125+
const plugin = new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' } }) as any;
126+
const definition = JSON.parse(JSON.stringify({
127+
manifest: { id: PKG, name: 'CRM', version: '1.0.0', type: 'app' },
128+
views: [container],
129+
}));
130+
let error: any = null;
131+
try {
132+
await plugin._parseAndRegisterArtifact(fakeCtx(), definition, 'fixture-21412');
133+
} catch (e) {
134+
error = e;
135+
}
136+
return { plugin, error };
137+
}
138+
139+
describe('#21412 — the artifact/HMR door\'s container branch refuses through the judge', () => {
140+
it('refuses the probe document with the judge\'s refusal, and files nothing', async () => {
141+
const { plugin, error } = await loadThroughArtifactDoor(boundBody('lead_views'));
142+
expectEnvelope(error);
143+
// THROUGH the judge: the door throws what the derived entry returns
144+
// for the same document, word for word — not the generic register
145+
// contract's refusal (#7378 row 1), which this branch no longer reaches.
146+
expect(error.message).toBe(viewContainerNameRefusal(boundBody('lead_views'), 'artifact', PKG)!.message);
147+
expect(await plugin.manager.get('view', 'crm_lead')).toBeFalsy();
148+
expect(await plugin.manager.get('view', 'lead_views')).toBeFalsy();
149+
expect(await plugin.manager.get('view', 'crm_lead.default')).toBeFalsy();
150+
});
151+
152+
it('CONTROL: an agreeing or absent `name` still registers under the derived key', async () => {
153+
for (const body of [boundBody('crm_lead'), boundBody()]) {
154+
const { plugin, error } = await loadThroughArtifactDoor(body);
155+
expect(error).toBeNull();
156+
expect(await plugin.manager.get('view', 'crm_lead')).toBeTruthy();
157+
expect(await plugin.manager.get('view', 'crm_lead.default')).toBeTruthy();
158+
}
159+
});
160+
});

‎packages/objectql/src/view-container-divergent-name-registrars.test.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -257,17 +257,25 @@ describe('#14399 — the row\'s own `name` is the LAST term of the container der
257257
// observable on this shape: its refusal names the key it derived.
258258
// `toEqual(AGREED_KEYS)` above would also be satisfied by both sides
259259
// moving to `lead_views`, so the agreed VALUE is pinned at both.
260+
//
261+
// [#21412] Read from the judge's words now: the artifact door's
262+
// container branch refuses through `viewContainerNameRefusal` before
263+
// it files anything, so the generic register contract's refusal
264+
// (#7378 row 1, `register('view', 'crm_lead'): data.name is …`) is no
265+
// longer reached on this shape. Same derived key, same two values.
260266
const err = await loadThroughArtifactDoor(divergentContainer).catch((e) => e as any);
261267
expect(err).toBeInstanceOf(Error);
262-
expect(err.message).toContain("register('view', 'crm_lead')");
263-
expect(err.message).toContain("data.name is 'lead_views'");
268+
expect(err.message).toContain("binds to, 'crm_lead'");
269+
expect(err.message).toContain("`name` is 'lead_views'");
264270
});
265271

266272
it('MEASURED CORRECTION: the artifact door does not silently mis-key it — it refuses, enveloped (#7378 row 1)', async () => {
267273
// The card predicted a second SILENT key here. Measured: the door
268274
// derives `crm_lead`, then `assertMetadataRegisterContract` refuses the
269275
// whole artifact load because the document's own `data.name` still says
270-
// `lead_views`. Asserting the ADR-0112 envelope, not merely "it threw":
276+
// `lead_views`. [#21412] The refusal now comes one step earlier, from
277+
// the same judge the boot loop throws (`viewContainerNameRefusal`),
278+
// in the same envelope. Asserting the ADR-0112 envelope, not merely "it threw":
271279
// a bare `toThrow()` would stay green on any unrelated failure.
272280
const err = await loadThroughArtifactDoor(divergentContainer).catch((e) => e as any);
273281
expect(err.code).toBe('VALIDATION_ERROR');

0 commit comments

Comments
 (0)