From b9ca766a2a27333c1f5628ea0ea40a428be444cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:40:01 +0000 Subject: [PATCH 1/7] wip(spec): flattened overlay members judge only their own viewKind (C-prime premise probe) The form overlay member admits viewKind form only; the flattened LIST overlay member makes columns optional, refuses a column-less body that names a type (located at columns), and re-applies the grid default with an overwrite check after the refusal reads the input side. Both members stay plain object schemas (no pipe). Work in progress: tests, changeset, ADR-0087 entry and generated artefacts are not in this commit. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- packages/spec/src/ui/view.zod.ts | 170 +++++++++++++++++++++++++++++-- 1 file changed, 163 insertions(+), 7 deletions(-) diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 17af6c62fc0..8fe42566ff0 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -5137,6 +5137,36 @@ const INLINE_VIEW_KIND_REQUIRED = + '(`{ name, object, viewKind, config: { … } }`), or make it a container. ' + VIEW_WRAP_REMEDY; +/** The other flattened overlay family — the `viewKind` the given arm does NOT judge. */ +function otherViewKind(kind: 'list' | 'form'): 'list' | 'form' { + return kind === 'list' ? 'form' : 'list'; +} + +/** + * [#20186] The refusal one flattened overlay member gives a body that names the + * OTHER family's `viewKind`. + * + * Each member judges exactly one `viewKind`, so a body is judged by the arm its + * `viewKind` names and by no other. Before this, both members shared one + * `list | form` enum, and a `viewKind: 'list'` body the list member refused + * (it carried no `columns`) was ACCEPTED by the form member, which `.strip()`s + * every list key unread: its `sort`, `searchableFields` and `timeline` were + * never judged, and the write door stored them as sent. + * + * Seen only by a consumer that parses one member directly + * (`VIEW_METADATA_MEMBERS.formOverlay.safeParse(…)`): through the union, the + * member the body does not claim is muted in favour of the one it does + * ({@link focusClaimedBranch}), so the author reads the claimed arm's own + * diagnosis. So the text says where the diagnosis is, not how to "fix" a + * `viewKind` that is correct. + */ +function overlayViewKindArmMismatch(kind: 'list' | 'form'): string { + const other = otherViewKind(kind); + return `This is the flattened ${kind.toUpperCase()} overlay member, which judges \`viewKind: "${kind}"\` only. ` + + `A \`viewKind: "${other}"\` body is judged by the ${other} overlay member ` + + `(\`VIEW_METADATA_MEMBERS.${other}Overlay\`), and that member's issues are its diagnosis.`; +} + /** * Identity + structural-guard fields layered onto the two "flattened runtime * overlay" members. The `config`/`list`/`form`/`listViews`/`formViews` guards @@ -5149,7 +5179,7 @@ const INLINE_VIEW_KIND_REQUIRED = * the identity fields stay optional: they are display/round-trip state, not * what any read path filters on. */ -function flattenedViewOverlayFields() { +function flattenedViewOverlayFields(kind: 'list' | 'form') { return { // No grammar, deliberately: the write path stamps this name rather than an // author writing it, so a flat overlay name is legal here while the SAME @@ -5164,8 +5194,11 @@ function flattenedViewOverlayFields() { + 'row can never be served. Inherited from the shadowed entry on personalization PUTs.', ), viewKind: z - .enum(ViewKindSchema.options, { - error: (issue) => (issue.input === undefined ? INLINE_VIEW_KIND_REQUIRED : undefined), + .enum([kind], { + error: (issue) => { + if (issue.input === undefined) return INLINE_VIEW_KIND_REQUIRED; + return issue.input === otherViewKind(kind) ? overlayViewKindArmMismatch(kind) : undefined; + }, }) .describe( 'View family — REQUIRED on an inline view config: half of the `object` + `viewKind` ' @@ -5528,6 +5561,121 @@ const FORM_OVERLAY_OPTIONS_REFUSED = 'A form view carries no `options` bag: `options.kanban`, `options.timeline` and the other per-kind blocks ' + 'belong to a list view. Remove `options`, or save this body as a list view (`viewKind: "list"` with its `columns`).'; +/** [#20186] The list type a flattened list overlay that names none parses to. */ +const LIST_OVERLAY_DEFAULT_TYPE = 'grid'; + +/** + * [#20186] The refusal a column-less flattened list overlay gets when it names + * a `type`. + */ +const LIST_OVERLAY_TYPE_NEEDS_COLUMNS = + 'This list view overlay sets `type` but lists no `columns`. A body that sets `type` is a full inline list ' + + 'config, and a full config lists its columns: add `columns: ["field_a", "field_b"]`. A personalization ' + + 'patch (`sort`, `hiddenFields`, `columnState`, `inlineEdit`, …) sets no `type` and needs no `columns`: ' + + 'remove `type` to save this body as a patch on the view it shadows.'; + +/** + * [#20186] The refusal the flattened FORM overlay gives a field LIST under + * `columns`. + */ +const FORM_OVERLAY_COLUMNS_IS_A_COUNT = + 'On a form view `columns` is the NUMBER of body columns (an integer, 1 or more), not a list of fields, ' + + 'and this body says `viewKind: "form"`. A list of fields is a list view\'s `columns`: if this is a ' + + 'list view, set `viewKind: "list"`; if it is a form, list its fields in `sections: [{ fields: [...] }]`.'; + +/** + * [#20186] The two keys the flattened LIST overlay member declares differently + * from the authoring {@link ListViewSchema}: `columns` optional, and `type` + * without its default. + * + * `columns` — a flattened list overlay is either a full inline config or a + * PATCH on the view it shadows. The patch is the ruled storage shape of every + * console toolbar save: objectui's `persistViewPatch` stores the patch and + * nothing else (maintainer ruling on #7494, comment `5261754173`: + * 「`persistViewPatch` 只存 patch,不存 merged base」), so a `sort` click + * arrives as `{ sort, viewKind: 'list', object, name }` with no `columns`. + * Requiring `columns` here refused that write on this member, and the body was + * then ACCEPTED by the form member with every list key stripped unjudged. The + * authoring {@link ListViewSchema} keeps `columns` required: an authored list + * view is a full config, never a patch. + * + * `type` — declared WITHOUT the authoring shape's `.default('grid')`, so the + * member's checks see whether the body NAMED a type. That is the line between + * the two readings: a patch names no type, a full config does, and a full + * config without `columns` is refused ({@link checkListOverlayTypeNeedsColumns}). + * The default is re-applied after the checks ({@link applyListOverlayTypeDefault}), + * so the parse output still carries a list type. Both are CHECKS on the same + * object schema, not a pipe: a pipe would serve its input side to + * `/api/v1/meta/types/view`, which is `{}` for a transform — the degradation + * {@link assertViewIdentity} records for the union's own door. + */ +function listOverlayPatchFields() { + const shape = (ListViewShapeSchema as unknown as { shape: Record }).shape; + const type = shape.type as unknown as z.ZodDefault; + return { + columns: shape.columns!.optional(), + // `.meta({ default })` keeps the served JSON Schema's `default: 'grid'` + // byte-identical: the default is real, applied by the overwrite below. + type: type.unwrap().optional().meta({ default: LIST_OVERLAY_DEFAULT_TYPE }), + }; +} + +/** + * [#20186] A column-less flattened list overlay that NAMES a `type` is a full + * inline config missing its columns — refused at `columns`. Reads the input + * side: `type` carries no default on this member (see + * {@link listOverlayPatchFields}), so `undefined` here means the body named none. + */ +function checkListOverlayTypeNeedsColumns( + view: { type?: unknown; columns?: unknown }, + ctx: z.RefinementCtx, +): void { + if (view.type !== undefined && view.columns === undefined) { + ctx.addIssue({ code: 'custom', path: ['columns'], message: LIST_OVERLAY_TYPE_NEEDS_COLUMNS }); + } +} + +/** + * [#20186] Re-apply the list `type` default the member declares without, in + * the key position the default would have taken, so the parse output of a + * patch is a list (`type: 'grid'`) and every body that names its type parses + * byte-identically to before. + */ +function applyListOverlayTypeDefault(view: T): T { + if (view.type !== undefined) return view; + const shape = (ListViewShapeSchema as unknown as { shape: Record }).shape; + const out: Record = {}; + let placed = false; + for (const key of Object.keys(shape)) { + if (key === 'type') { + out.type = LIST_OVERLAY_DEFAULT_TYPE; + placed = true; + } else if (key in view) { + out[key] = (view as Record)[key]; + } + } + for (const key of Object.keys(view)) if (!(key in out)) out[key] = (view as Record)[key]; + if (!placed) out.type = LIST_OVERLAY_DEFAULT_TYPE; + return out as T; +} + +/** + * [#20186] `columns` on the flattened FORM overlay: the form's own count, + * refused with {@link FORM_OVERLAY_COLUMNS_IS_A_COUNT} when a field list + * arrives there. A clone of {@link FormViewSchema}'s own `columns` schema with + * an error map added, so its constraints are that schema's and cannot drift. + */ +function formOverlayColumnsField(): z.ZodOptional { + const optional = (FormViewSchema as unknown as { shape: { columns: z.ZodOptional } }).shape.columns; + const count = optional.unwrap(); + const withPrescription = count.clone({ + ...count._zod.def, + error: (issue) => (Array.isArray(issue.input) ? FORM_OVERLAY_COLUMNS_IS_A_COUNT : undefined), + }); + const description = optional.description; + return description ? withPrescription.optional().describe(description) : withPrescription.optional(); +} + /** * [#6391] Member 3 of {@link ViewMetadataSchema} — a flattened runtime LIST * overlay: an inline `ListView` config at the top level plus the optional @@ -5561,10 +5709,13 @@ const ListViewOverlayWireSchema = lazySchema(() => // `viewDoorsCarryingObjectLevelChecks` in `view.test.ts` fails if any of the // three attachment points is dropped. ListViewShapeSchema.extend({ - ...flattenedViewOverlayFields(), + ...flattenedViewOverlayFields('list'), options: ListViewOverlayOptionsSchema.optional(), + ...listOverlayPatchFields(), }).strip() - .superRefine(checkListViewCalendarVisualization), + .superRefine(checkListOverlayTypeNeedsColumns) + .superRefine(checkListViewCalendarVisualization) + .overwrite(applyListOverlayTypeDefault), ); /** @@ -5578,9 +5729,14 @@ const ListViewOverlayWireSchema = lazySchema(() => * {@link FORM_OVERLAY_OPTIONS_REFUSED} for the fall-through it closes. */ const FormViewOverlayWireSchema = lazySchema(() => - FormViewSchema.extend({ - ...flattenedViewOverlayFields(), + // `.safeExtend()`: this member now re-declares a key the form shape carries + // (`columns`, see {@link formOverlayColumnsField}), and {@link FormViewSchema} + // carries refinements, which `.extend()` refuses to combine with an overwrite. + // The two differ in that throw only — the refinements ride along either way. + FormViewSchema.safeExtend({ + ...flattenedViewOverlayFields('form'), options: z.undefined({ error: () => FORM_OVERLAY_OPTIONS_REFUSED }).optional(), + columns: formOverlayColumnsField(), }).strip(), ); From 41d255826157c15f158d17dc241ba17441eafc66 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:41:01 +0000 Subject: [PATCH 2/7] wip(spec): the type-bearing column-less list overlay refusal aborts, so the union envelope keeps invalid_union Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- packages/spec/src/ui/view.zod.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 8fe42566ff0..3f0bf677ed4 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -5631,7 +5631,13 @@ function checkListOverlayTypeNeedsColumns( ctx: z.RefinementCtx, ): void { if (view.type !== undefined && view.columns === undefined) { - ctx.addIssue({ code: 'custom', path: ['columns'], message: LIST_OVERLAY_TYPE_NEEDS_COLUMNS }); + // `continue: false` — an ABORTING issue, like the field-level refusal + // (`columns` required) it replaces on this body. A non-aborting one would + // leave this member the union's only non-aborted branch, and zod then + // returns that branch's issues UNWRAPPED (`handleUnionResults`): the + // envelope's top-level code would move from `invalid_union` to `custom` + // on a body whose verdict did not move. + ctx.addIssue({ code: 'custom', path: ['columns'], message: LIST_OVERLAY_TYPE_NEEDS_COLUMNS, continue: false }); } } From 78d37a22b8a7715c638463e24d631ace98e4720f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:54:00 +0000 Subject: [PATCH 3/7] fix(spec)!: a flattened view overlay is judged by the member its viewKind names; pins, ADR-0087 entry, changeset Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- ...186-view-overlay-judged-by-viewkind-arm.md | 56 ++++ ...protocol.view-overlay-viewkind-arm.test.ts | 215 ++++++++++++++ .../18.view-overlay-judged-by-viewkind-arm.ts | 46 +++ packages/spec/src/migrations/registry.ts | 42 +++ .../src/ui/view-overlay-viewkind-arm.test.ts | 263 ++++++++++++++++++ .../src/ui/view-union-diagnostics.test.ts | 10 +- packages/spec/src/ui/view.zod.ts | 42 ++- 7 files changed, 662 insertions(+), 12 deletions(-) create mode 100644 .changeset/20186-view-overlay-judged-by-viewkind-arm.md create mode 100644 packages/metadata-protocol/src/protocol.view-overlay-viewkind-arm.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.view-overlay-judged-by-viewkind-arm.ts create mode 100644 packages/spec/src/ui/view-overlay-viewkind-arm.test.ts diff --git a/.changeset/20186-view-overlay-judged-by-viewkind-arm.md b/.changeset/20186-view-overlay-judged-by-viewkind-arm.md new file mode 100644 index 00000000000..d42b1168c30 --- /dev/null +++ b/.changeset/20186-view-overlay-judged-by-viewkind-arm.md @@ -0,0 +1,56 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec)!: a flattened `view` overlay is judged by the member its `viewKind` names, so a column-less list patch has its list keys judged instead of stripped by the form member (#20186) + +**BREAKING** accept-set change on the `view` write door (`PUT /api/v1/meta/view/:name`, the Studio and MCP save), and on every door that parses `ViewMetadataSchema` (the assembled-manifest `viewItems:` union included). It narrows, and it widens two small classes, both declared below. It ships as `minor` under the repo's launch-window convention for breaking changes. + +Clause-②: yes (narrowing) + +## What was wrong + +The two flattened overlay members of `ViewMetadataSchema` shared one `viewKind: 'list' | 'form'` enum. The list member required `columns`, so it refused a column-less `viewKind: 'list'` body. The union then tried the form member, which requires no list key and `.strip()`s every one, and ACCEPTED the body. `diagnoseViewMetadata` named `formOverlay`, the parse output was `type: 'simple'` (a form), and the body's `sort`, `searchableFields` and `timeline` were never judged. Measured through the real `saveMetaItem` on `origin/main` @ `4df101c3`, and again at `ce70876e`: a retired bare-string `sort`, a `timeline.metaFields` and a non-array `searchableFields` each answered `success: true`, and the row held them as sent. The mirror held too: the list member accepted a `viewKind: 'form'` body carrying list `columns`. + +That column-less list body is not a malformed one. It is what the console stores on every toolbar save (sort, hidden fields, inline edit, column widths) for a code-defined list view: objectui's `persistViewPatch` stores the patch and nothing else, per the maintainer ruling 「`persistViewPatch` 只存 patch,不存 merged base」. + +## What it does now + +- **One `viewKind` per member.** The list overlay member admits `viewKind: 'list'` only; the form overlay member admits `viewKind: 'form'` only. A flattened body is judged by the member its `viewKind` names, and `diagnoseViewMetadata` names that member. +- **The list member judges a column-less patch.** `columns` is optional on the flattened list overlay member only. The authoring `ListViewSchema` keeps it required: an authored list view is a full config, never a patch. The console's patch-only writes keep saving, stored verbatim, and a lean list patch now parses to a list (`type: 'grid'`), not to `type: 'simple'`. +- **A column-less body that names a `type` is still refused**, now located at `columns`: a body that sets `type` is a full inline config, and a full config lists its columns. The refusal names both ways out. +- **A field list under a form overlay's `columns` is refused** at `columns`: on a form view `columns` is the body-column count. +- The served JSON Schema (`/api/v1/meta/types/view`) is still an `anyOf` of four members. The only movements: each overlay member's `viewKind` enum names one value, the list overlay member no longer lists `columns` as required, and in the output direction it no longer lists `type` as required (the `grid` default is still declared and still applied). + +## FROM → TO + +Each row is refused now and was accepted before, on a flattened overlay: + +| you wrote | write instead | +|:--|:--| +| `viewKind: 'list'`, no `columns`, `sort: 'name desc'` | `sort: [{ field: 'name', order: 'desc' }]` — the bare string clause was retired in 17.5.0 | +| `viewKind: 'list'`, no `columns`, `sort: [{ field, direction: 'desc' }]` | `sort: [{ field, order: 'desc' }]` | +| `viewKind: 'list'`, no `columns`, `timeline: { …, metaFields: [...] }` | delete `metaFields`: the timeline block has no such key | +| `viewKind: 'list'`, no `columns`, `searchableFields: 'name'` | `searchableFields: ['name']` | +| `viewKind: 'list'`, no `columns`, `sharing: { enabled: true, publicLink, … }` (the form public-link block) | the list `sharing` block, `sharing: { type: 'personal' \| 'collaborative', lockedBy? }`, or delete `sharing` | +| any other list key the list view schema refuses, on a column-less list overlay | the value the list view schema accepts — the refusal names the key | +| `viewKind: 'form'` with `columns: ['name', …]` | a field list means a list view: `viewKind: 'list'`; for a form, `sections: [{ fields: ['name', …] }]` and `columns` as a count (`columns: 2`) | + +**The one-line fix:** read the refusal. It is located at the key it refuses and says what that key takes. + +A column-less list overlay that names a `type` (`{ viewKind: 'list', type: 'kanban', … }` without `columns`) was refused before and is refused now; only its location moved, to `columns`. Add `columns`, or drop `type` to save the body as a patch. + +## Declared widening (why `Clause-②: yes`) + +Two classes of column-less, type-less `viewKind: 'list'` bodies go from refused to accepted: + +- **W2** — a list-legal value under a key both members declare with different schemas: `aria` (the form member carries a retirement tombstone there), an i18n `description` (the form member takes a plain string only), and the list `sharing` block (the form member's is the public-link block). Measured: `{ name, object, viewKind: 'list', aria: { ariaLabel: 'Leads' } }` was refused, and is accepted. This is the change working: a list body is judged by list rules. +- **W1** — an invalid value under one of the 19 form-only keys (`layout`, `sections`, `title`, …). Measured: `{ name, object, viewKind: 'list', isPinned: true, layout: 'diagonal' }` was refused (the form member judged `layout`), and is accepted with `layout` dropped unread. That is the list member's existing handling of a key it does not declare: a list overlay WITH `columns` and `layout: 'diagonal'` was already accepted the same way. It is a named residual, not a contract. + +## Census + +- **objectstack** @ `4df101c3` (examples, packages): no source writes a flattened `viewKind: 'list'` body without `columns` as a literal; every literal hit is a test fixture, a changelog line or a comment. `examples/**` authors views as containers (15 files with `listViews`) and carries no `viewKind` at all. +- **objectui**, at the `.objectui-sha` pin `f8a9d0fb0` and at `main` `25c7d584e`, and **cloud** `main` `48d7066`: no source literal either. The one real producer is dynamic: objectui's `buildPersistedViewBody` returns `{ ...patch, viewKind }` for an overlay and `updateViewConfig` stamps `object`, `name` and the overlay marker. Those bodies keep saving, now judged by the list member. +- **Production `sys_metadata` rows: NOT MEASURED.** No deployment's store is reachable from the repository. A stored row that fails keeps being read and served exactly as stored. It is refused only on its next save, and the refusal names the key. + + diff --git a/packages/metadata-protocol/src/protocol.view-overlay-viewkind-arm.test.ts b/packages/metadata-protocol/src/protocol.view-overlay-viewkind-arm.test.ts new file mode 100644 index 00000000000..6c34b8ebdce --- /dev/null +++ b/packages/metadata-protocol/src/protocol.view-overlay-viewkind-arm.test.ts @@ -0,0 +1,215 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #20186 — the flattened `view` overlay verdicts, pinned at the WRITE DOOR. + * + * `ViewMetadataSchema` now judges a flattened body by the arm its `viewKind` + * names (the spec-side pins live in `view-overlay-viewkind-arm.test.ts`). The + * spec pins alone would stay green if `saveMetaItem` stopped consulting that + * schema, and the defect was measured HERE: before the change, a column-less + * `viewKind: 'list'` body carrying a retired `sort` string answered + * `success: true` and the row held the string as sent. So this file drives the + * REAL `saveMetaItem` against a stub engine and reads the persisted + * `sys_metadata` row, both for what is now refused (nothing stored, a located + * `422 INVALID_METADATA`) and for what must keep saving (the console's + * patch-only toolbar writes, ruled on #7494 — stored verbatim). + */ +import { describe, expect, it } from 'vitest'; +import { + assertEngineDeleteDispatch, + assertEngineUpdateDispatch, assertEngineFindOnePredicate, +} from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + state: string; + metadata: string; +} + +const keyOf = (w: Record) => + `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}`; + +/** + * The engine surface the repository write path touches — the stub shape + * `protocol.graft-folded-form-sections.test.ts` uses, keyed BY TABLE so a + * journal row can never be read back as a `sys_metadata` row. + */ +function makeProtocol() { + const tables = new Map>(); + const tableOf = (table: string): Map => { + const existing = tables.get(table); + if (existing) return existing; + const created = new Map(); + tables.set(table, created); + return created; + }; + const rows = tableOf('sys_metadata'); + let nextId = 0; + const findRow = (table: string, w: Record): { key: string; row: Row } | null => { + if (w.id !== undefined) { + for (const [k, r] of tableOf(table)) if (r.id === w.id) return { key: k, row: r }; + return null; + } + for (const [k, r] of tableOf(table)) { + if (w.type !== undefined && r.type !== w.type) continue; + if (w.name !== undefined && r.name !== w.name) continue; + if (w.organization_id !== undefined && r.organization_id !== w.organization_id) continue; + if (w.state !== undefined && r.state !== w.state) continue; + return { key: k, row: r }; + } + return null; + }; + const engine: any = { + async findOne(table: string, opts: { where: Record }) { + assertEngineFindOnePredicate(table, opts); + return findRow(table, opts.where)?.row ?? null; + }, + async find(table: string, opts: { where: Record }) { + return Array.from(tableOf(table).values()).filter((r) => { + if (opts.where.type && r.type !== opts.where.type) return false; + if (opts.where.organization_id !== undefined + && r.organization_id !== opts.where.organization_id) return false; + if (opts.where.state && r.state !== opts.where.state) return false; + return true; + }); + }, + async insert(table: string, data: Record) { + nextId += 1; + const row = { id: `r_${nextId}`, ...(data as any) } as Row; + tableOf(table).set(keyOf(data), row); + return { id: row.id }; + }, + async update(table: string, data: Record, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + const found = findRow(table, opts.where); + if (!found) return { id: null }; + tableOf(table).set(found.key, { ...found.row, ...(data as any) }); + return { id: found.row.id }; + }, + async delete(table: string, opts: { where: Record }) { + assertEngineDeleteDispatch(opts); + const found = findRow(table, opts.where); + if (!found) return { deleted: 0 }; + tableOf(table).delete(found.key); + return { deleted: 1 }; + }, + registry: { registerItem: () => {}, registerObject: () => {} }, + }; + return { protocol: new ObjectStackProtocolImplementation(engine, () => new Map()), rows }; +} + +const ID = { name: 'crm_lead.all', object: 'crm_lead' } as const; +const LIST = { ...ID, viewKind: 'list' } as const; +const FORM = { ...ID, viewKind: 'form' } as const; + +/** Save through the real write door; return the body the stored ROW holds. */ +async function stored(item: Record): Promise { + const { protocol, rows } = makeProtocol(); + const result: any = await (protocol as any).saveMetaItem({ type: 'view', name: item.name as string, item }); + expect(result.success, JSON.stringify(result)).toBe(true); + const row = Array.from(rows.values()).find((r) => r.type === 'view'); + expect(row, 'the save persisted no view row').toBeDefined(); + return JSON.parse(row!.metadata); +} + +/** Save through the real write door; return the refusal, and prove nothing was stored. */ +async function refused(item: Record): Promise<{ code: string; status: number; issues: Array<{ path: string; code?: string; message: string }> }> { + const { protocol, rows } = makeProtocol(); + let thrown: any; + try { + await (protocol as any).saveMetaItem({ type: 'view', name: item.name as string, item }); + } catch (e) { + thrown = e; + } + expect(thrown, 'the save must be refused').toBeDefined(); + expect(thrown.code).toBe('INVALID_METADATA'); + expect(thrown.status).toBe(422); + expect(rows.size, 'a refused save stores nothing').toBe(0); + return thrown; +} + +/** The envelope entry located at `path`. */ +function at(err: { issues: Array<{ path: string; code?: string; message: string }> }, path: string) { + const issue = err.issues.find((i) => i.path === path); + expect(issue, `no issue at \`${path}\` in ${JSON.stringify(err.issues)}`).toBeDefined(); + return issue!; +} + +describe('#20186 the write door stores the console patch-only list overlay verbatim', () => { + it('the headline `{ name, object, viewKind: list, sort }` saves, and the row is the body as sent', async () => { + const body = { ...LIST, sort: [{ field: 'name', order: 'asc' }] }; + expect(await stored(body)).toEqual(body); + }); + + it('the objectui sort toggle (`{ ...patch, viewKind }` + object/name/_isOverride) saves verbatim', async () => { + const body = { sort: [{ field: 'name', order: 'desc' }], viewKind: 'list', ...ID, _isOverride: true }; + expect(await stored(body)).toEqual(body); + }); +}); + +describe('#20186 …and REFUSES the list keys it used to store unjudged', () => { + it('a retired bare-string `sort`: 422 at `sort`, with the 17.5.0 retirement prescription', async () => { + const err = await refused({ ...LIST, sort: 'name desc' }); + const issue = at(err, 'sort'); + expect(issue.code).toBe('invalid_type'); + expect(issue.message).toContain('The bare string `sort` clause was removed from `view.sort` in @objectstack/spec 17.5.0'); + }); + + it('the `timeline.metaFields` twin: 422 at `timeline`, naming the key', async () => { + const err = await refused({ ...LIST, timeline: { startDateField: 'created', titleField: 'name', metaFields: ['region'] } }); + const issue = at(err, 'timeline'); + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.message).toContain('Unrecognized key(s) on this timeline configuration: `metaFields`.'); + }); + + it('a non-array `searchableFields`: 422 at `searchableFields`', async () => { + const err = await refused({ ...LIST, searchableFields: 'name' }); + expect(at(err, 'searchableFields').code).toBe('invalid_type'); + }); + + it('a form-style `sharing` on a list body: 422 at `sharing`', async () => { + const err = await refused({ ...LIST, sharing: { enabled: true } }); + expect(at(err, 'sharing').code).toBe('unrecognized_keys'); + }); + + it('a column-less list overlay that names a `type`: 422 at `columns`, with the prescription', async () => { + const err = await refused({ ...LIST, type: 'kanban', groupByField: 'stage' }); + const issue = at(err, 'columns'); + expect(issue.code).toBe('custom'); + expect(issue.message).toMatch(/^This list view overlay sets `type` but lists no `columns`\./); + }); + + it('the mirror — list `columns` on a `viewKind: form` body: 422 at `columns`, with the count prescription', async () => { + const err = await refused({ ...FORM, columns: ['name'] }); + const issue = at(err, 'columns'); + expect(issue.code).toBe('invalid_type'); + expect(issue.message).toMatch(/^On a form view `columns` is the NUMBER of body columns/); + }); +}); + +describe('#20186 W2 — a list-legal value under a key both arms declare differently now saves', () => { + it.each([ + ['aria', { aria: { ariaLabel: 'Leads' } }], + ['an i18n description', { description: { en: 'All leads' } }], + ['list-style sharing', { sharing: { type: 'personal' } }], + ])('%s', async (_label, extra) => { + const body = { ...LIST, ...extra }; + expect(await stored(body)).toEqual(body); + }); +}); + +describe('#20186 controls', () => { + it('a real form overlay saves verbatim', async () => { + const body = { ...FORM, type: 'simple', sections: [{ label: 'Main', fields: ['name'] }] }; + expect(await stored(body)).toEqual(body); + }); + + it('a list overlay with `columns` saves verbatim', async () => { + const body = { ...LIST, columns: ['name'], sort: [{ field: 'name', order: 'asc' }] }; + expect(await stored(body)).toEqual(body); + }); +}); diff --git a/packages/spec/src/migrations/entries/semantic/18.view-overlay-judged-by-viewkind-arm.ts b/packages/spec/src/migrations/entries/semantic/18.view-overlay-judged-by-viewkind-arm.ts new file mode 100644 index 00000000000..df9f89a0c0a --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.view-overlay-judged-by-viewkind-arm.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #20186, route C-prime (seat answers 5855433719 / 5855548706). A write-time +// re-routing of the flattened view overlays: each member judges only the +// `viewKind` it names. The stored rows it newly refuses are read and served +// exactly as before and fail only on their next save, which is why this is a +// semantic entry and not a conversion — which value a refused key meant is a +// fact only its author holds. +export const entry: SemanticMigration = { + id: 'view-overlay-judged-by-viewkind-arm', + surface: + 'A flattened `view` overlay saved through the metadata write door (`PUT /api/v1/meta/view/:name`, the ' + + 'Studio / MCP save) whose `viewKind` names one family while the body was judged by the other: a ' + + 'column-less `viewKind: "list"` body, which only the form overlay member used to accept (its list keys ' + + '`sort`, `searchableFields`, `timeline`, `sharing` and the rest stripped unread), and a `viewKind: "form"` ' + + 'body carrying list `columns`, which only the list overlay member used to accept.', + replacement: + 'Each overlay is judged by the member its `viewKind` names. A column-less list overlay is a patch on the ' + + 'view it shadows and carries list keys the list view schema accepts: a `sort` array of `{ field, order }` ' + + '(the bare string clause was retired in 17.5.0), no `timeline.metaFields` (the timeline block has no such ' + + 'key), an array `searchableFields`, and the list `sharing` block (`{ type, lockedBy }`), not the form ' + + 'public-link block. A column-less list overlay names no `type`; one that does is a full inline config and ' + + 'lists its `columns`. A form overlay\'s `columns` is its body-column count (an integer); a field list ' + + 'means the body is a list view (`viewKind: "list"`) or belongs in `sections: [{ fields }]`.', + reason: + 'Both overlay members shared one `viewKind: list | form` enum. The list member required `columns`, so it ' + + 'refused the column-less list patch the console writes on every toolbar save (the ruled patch-only ' + + 'storage shape, maintainer ruling: 「`persistViewPatch` 只存 patch,不存 merged base」); the ' + + 'union then tried the form member, which requires no list key and strips every one, and accepted it — ' + + 'so a retired `sort` string or a `timeline.metaFields` the list schema refuses by name was saved with ' + + '`success: true` and stored as sent. Measured on `origin/main` @ `4df101c3` and again at `ce70876e` ' + + '(after the `options`-bag door landed) through the real save. Ruled route C-prime: each member admits one `viewKind`, the list ' + + 'member judges a column-less patch (`columns` optional there only; the authoring list view keeps it ' + + 'required), and a column-less body that names a `type` stays refused at `columns`. Not convertible: ' + + 'whether a refused value was a typo or a stale capability is the author\'s call.', + acceptanceCriteria: + 'Every stored flattened `view` overlay saves again unchanged. A row that does not is refused ' + + '`422 INVALID_METADATA` on its next save, with the issue located at the refused key (`sort`, `timeline`, ' + + '`searchableFields`, `sharing`, `columns`) and carrying that key\'s own message — for a column-less list ' + + 'overlay naming a `type`, the prescription at `columns`; for a field list on a form overlay, the ' + + 'body-column-count prescription at `columns`. Nothing is rewritten on read and nothing is refused on ' + + 'read: a row that fails is served exactly as stored until it is saved. Verify by re-saving each stored ' + + 'flattened overlay (a GET then a PUT of the same body) and reading a `200`.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index ebc1154cf32..701b78c3974 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -14613,6 +14613,48 @@ const step18: MigrationStep = { + 'equals, and operator: "in" with value: ["won"] — select the same rows, so the result ' + 'set cannot tell you which the metadata meant, and only the author knows.', }, + // #20186, route C-prime (seat answers 5855433719 / 5855548706). A write-time + // re-routing of the flattened view overlays: each member judges only the + // `viewKind` it names. The stored rows it newly refuses are read and served + // exactly as before and fail only on their next save, which is why this is a + // semantic entry and not a conversion — which value a refused key meant is a + // fact only its author holds. + { + id: 'view-overlay-judged-by-viewkind-arm', + surface: + 'A flattened `view` overlay saved through the metadata write door (`PUT /api/v1/meta/view/:name`, the ' + + 'Studio / MCP save) whose `viewKind` names one family while the body was judged by the other: a ' + + 'column-less `viewKind: "list"` body, which only the form overlay member used to accept (its list keys ' + + '`sort`, `searchableFields`, `timeline`, `sharing` and the rest stripped unread), and a `viewKind: "form"` ' + + 'body carrying list `columns`, which only the list overlay member used to accept.', + replacement: + 'Each overlay is judged by the member its `viewKind` names. A column-less list overlay is a patch on the ' + + 'view it shadows and carries list keys the list view schema accepts: a `sort` array of `{ field, order }` ' + + '(the bare string clause was retired in 17.5.0), no `timeline.metaFields` (the timeline block has no such ' + + 'key), an array `searchableFields`, and the list `sharing` block (`{ type, lockedBy }`), not the form ' + + 'public-link block. A column-less list overlay names no `type`; one that does is a full inline config and ' + + 'lists its `columns`. A form overlay\'s `columns` is its body-column count (an integer); a field list ' + + 'means the body is a list view (`viewKind: "list"`) or belongs in `sections: [{ fields }]`.', + reason: + 'Both overlay members shared one `viewKind: list | form` enum. The list member required `columns`, so it ' + + 'refused the column-less list patch the console writes on every toolbar save (the ruled patch-only ' + + 'storage shape, maintainer ruling: 「`persistViewPatch` 只存 patch,不存 merged base」); the ' + + 'union then tried the form member, which requires no list key and strips every one, and accepted it — ' + + 'so a retired `sort` string or a `timeline.metaFields` the list schema refuses by name was saved with ' + + '`success: true` and stored as sent. Measured on `origin/main` @ `4df101c3` and again at `ce70876e` ' + + '(after the `options`-bag door landed) through the real save. Ruled route C-prime: each member admits one `viewKind`, the list ' + + 'member judges a column-less patch (`columns` optional there only; the authoring list view keeps it ' + + 'required), and a column-less body that names a `type` stays refused at `columns`. Not convertible: ' + + 'whether a refused value was a typo or a stale capability is the author\'s call.', + acceptanceCriteria: + 'Every stored flattened `view` overlay saves again unchanged. A row that does not is refused ' + + '`422 INVALID_METADATA` on its next save, with the issue located at the refused key (`sort`, `timeline`, ' + + '`searchableFields`, `sharing`, `columns`) and carrying that key\'s own message — for a column-less list ' + + 'overlay naming a `type`, the prescription at `columns`; for a field list on a form overlay, the ' + + 'body-column-count prescription at `columns`. Nothing is rewritten on read and nothing is refused on ' + + 'read: a row that fails is served exactly as stored until it is saved. Verify by re-saving each stored ' + + 'flattened overlay (a GET then a PUT of the same body) and reading a `200`.', + }, // The door half of ruling A on objectui#10380 (#20051). A write-time narrowing // of the flattened view overlays: the stored rows it newly refuses are read and // served exactly as before and fail only on their next save, which is why this diff --git a/packages/spec/src/ui/view-overlay-viewkind-arm.test.ts b/packages/spec/src/ui/view-overlay-viewkind-arm.test.ts new file mode 100644 index 00000000000..be387a0a699 --- /dev/null +++ b/packages/spec/src/ui/view-overlay-viewkind-arm.test.ts @@ -0,0 +1,263 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #20186 — a flattened `view` overlay is judged by the arm its `viewKind` + * names, and by no other. + * + * ## The defect + * + * The two flattened overlay members of {@link ViewMetadataSchema} shared one + * `viewKind: 'list' | 'form'` enum. The list member required `columns`, so it + * refused a column-less `viewKind: 'list'` body; the union then tried the form + * member, which requires no list key and `.strip()`s every one, and ACCEPTED + * it. `diagnoseViewMetadata` named `formOverlay`, the parse output was + * `type: 'simple'` (a form), and the body's `sort`, `searchableFields` and + * `timeline` were never judged — the write door stored a retired `sort` string + * and a `timeline.metaFields` the list schema refuses by name. The mirror held + * too: the list member accepted a `viewKind: 'form'` body carrying list + * `columns`. + * + * ## The ruled route (C-prime, seat answers 5855433719 / 5855548706 on #20186) + * + * - The form member admits `viewKind: 'form'` only; the list member `'list'` + * only. + * - The flattened LIST member judges a column-less PATCH — the ruled storage + * shape of every console toolbar save (maintainer ruling on #7494, + * 「`persistViewPatch` 只存 patch,不存 merged base」): `columns` is optional + * there, and only there. The authoring `ListViewSchema` keeps it required. + * - A column-less list overlay that NAMES a `type` is a full inline config + * missing its columns, and stays refused — now at `columns`, with a + * prescription. + * + * ## What is declared, not discovered + * + * Two classes of column-less, type-less `viewKind: 'list'` bodies go from + * refused to accepted (`Clause-②: yes (narrowing)`): + * + * - **W2** — a list-legal value under a key both arms declare with different + * schemas (`aria`, an i18n `description`, list-style `sharing`). The form arm + * used to judge those keys by FORM rules; the list arm now judges them by the + * list rules they belong to. Pinned ACCEPT below: it is the ruling working. + * - **W1** — an invalid value under a form-only key (`layout: 'diagonal'`), + * which the list member drops unread, exactly as it already drops it on a + * list overlay WITH `columns`. ⛔ Deliberately NOT pinned here as desired + * behaviour: it is a named same-family residual (the PR's acceptance notes), + * not a contract. + */ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { + VIEW_METADATA_MEMBERS, + ViewMetadataSchema, + diagnoseViewMetadata, +} from './view.zod'; + +const ID = { name: 'crm_lead.all', object: 'crm_lead' } as const; +const LIST = { ...ID, viewKind: 'list' } as const; +const FORM = { ...ID, viewKind: 'form' } as const; + +/** The refused body's union envelope AND its branch diagnosis. */ +function refusal(body: unknown) { + const parsed = ViewMetadataSchema.safeParse(body); + expect(parsed.success, 'the union must refuse this body').toBe(false); + const diagnosis = diagnoseViewMetadata(body); + expect(diagnosis.success).toBe(false); + if (parsed.success || diagnosis.success) throw new Error('unreachable'); + return { + topCodes: [...new Set(parsed.error.issues.map((i) => i.code))], + branch: diagnosis.branch, + issues: diagnosis.issues, + }; +} + +/** The accepted body's parse output AND the branch that accepted it. */ +function acceptance(body: unknown) { + const parsed = ViewMetadataSchema.safeParse(body); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + const diagnosis = diagnoseViewMetadata(body); + expect(diagnosis.success).toBe(true); + return { data: parsed.data as Record, branch: diagnosis.branch }; +} + +describe('#20186 a column-less list PATCH is judged by the list member', () => { + it('the headline `{ name, object, viewKind: list, sort }` is ACCEPTED on listOverlay and parses to a LIST', () => { + const { data, branch } = acceptance({ ...LIST, sort: [{ field: 'name', order: 'asc' }] }); + expect(branch).toBe('listOverlay'); + // Not `type: 'simple'` — the form member's default, which is what a lean + // list body parsed to while the form member was the one accepting it. + expect(data.type).toBe('grid'); + expect(data.sort).toEqual([{ field: 'name', order: 'asc' }]); + }); + + // The objectui producer: `buildPersistedViewBody` stores `{ ...patch, + // viewKind }`, and `updateViewConfig` stamps `object`, `name` and the overlay + // marker. One body per toolbar path the console persists. + it.each([ + ['sort', { sort: [{ field: 'name', order: 'desc' }] }], + ['hiddenFields', { hiddenFields: ['email'] }], + ['inlineEdit', { inlineEdit: true }], + ['columnState', { columnState: { widths: { name: 120 } } }], + ['rowHeight', { rowHeight: 'compact' }], + ])('the console %s toggle (patch only, ruled on #7494) is ACCEPTED on listOverlay', (_label, patch) => { + const { branch, data } = acceptance({ ...patch, viewKind: 'list', ...ID, _isOverride: true }); + expect(branch).toBe('listOverlay'); + expect(data.type).toBe('grid'); + }); +}); + +describe('#20186 …and its list keys are JUDGED, not stripped', () => { + it('a retired bare-string `sort` is REFUSED at `sort`, with the 17.5.0 retirement prescription', () => { + const r = refusal({ ...LIST, sort: 'name desc' }); + expect(r.topCodes).toEqual(['invalid_union']); + expect(r.branch).toBe('listOverlay'); + expect(r.issues).toHaveLength(1); + expect(r.issues[0]).toMatchObject({ code: 'invalid_type', path: ['sort'] }); + expect(r.issues[0]!.message).toContain('The bare string `sort` clause was removed from `view.sort` in @objectstack/spec 17.5.0'); + }); + + it('the `timeline.metaFields` twin is REFUSED at `timeline`, naming the key', () => { + const r = refusal({ ...LIST, timeline: { startDateField: 'created', titleField: 'name', metaFields: ['region'] } }); + expect(r.topCodes).toEqual(['invalid_union']); + expect(r.branch).toBe('listOverlay'); + expect(r.issues).toHaveLength(1); + expect(r.issues[0]).toMatchObject({ code: 'unrecognized_keys', path: ['timeline'], keys: ['metaFields'] }); + expect(r.issues[0]!.message).toContain('Unrecognized key(s) on this timeline configuration: `metaFields`.'); + }); + + it('a non-array `searchableFields` is REFUSED at `searchableFields`', () => { + const r = refusal({ ...LIST, searchableFields: 'name' }); + expect(r.topCodes).toEqual(['invalid_union']); + expect(r.branch).toBe('listOverlay'); + expect(r.issues).toHaveLength(1); + expect(r.issues[0]).toMatchObject({ code: 'invalid_type', path: ['searchableFields'], expected: 'array' }); + }); + + it('a form-style `sharing` on a list body is REFUSED at `sharing` — list sharing is judged by list rules now', () => { + const r = refusal({ ...LIST, sharing: { enabled: true } }); + expect(r.topCodes).toEqual(['invalid_union']); + expect(r.branch).toBe('listOverlay'); + expect(r.issues[0]).toMatchObject({ code: 'unrecognized_keys', path: ['sharing'] }); + }); +}); + +describe('#20186 a column-less list overlay that NAMES a `type` stays refused — at `columns`', () => { + // `overlay.list.identity` in `view-union-diagnostics.test.ts`: refused before, + // refused now, and still wrapped as `invalid_union` (the refusal is an + // ABORTING issue, so the list member is not the union's lone survivor). + it.each([ + ['a kanban config with no columns', { name: 'x', object: 'crm_lead', viewKind: 'list', type: 'kanban', groupByField: 'stage' }], + ['a grid config with no columns', { ...LIST, type: 'grid', sort: [{ field: 'name', order: 'asc' }] }], + ])('%s', (_label, body) => { + const r = refusal(body); + expect(r.topCodes).toEqual(['invalid_union']); + expect(r.branch).toBe('listOverlay'); + expect(r.issues).toHaveLength(1); + expect(r.issues[0]).toMatchObject({ code: 'custom', path: ['columns'] }); + expect(r.issues[0]!.message).toMatch(/^This list view overlay sets `type` but lists no `columns`\./); + // Pasteable: it names both ways out. + expect(r.issues[0]!.message).toContain('add `columns: ["field_a", "field_b"]`'); + expect(r.issues[0]!.message).toContain('remove `type` to save this body as a patch'); + }); +}); + +describe('#20186 the mirror: a `viewKind: form` body is judged by the form member', () => { + it('list `columns` on a form overlay is REFUSED under formOverlay, at `columns`, with the count prescription', () => { + const r = refusal({ ...FORM, columns: ['name'] }); + expect(r.topCodes).toEqual(['invalid_union']); + expect(r.branch).toBe('formOverlay'); + expect(r.issues).toHaveLength(1); + expect(r.issues[0]).toMatchObject({ code: 'invalid_type', path: ['columns'] }); + expect(r.issues[0]!.message).toMatch(/^On a form view `columns` is the NUMBER of body columns/); + expect(r.issues[0]!.message).toContain('set `viewKind: "list"`'); + }); + + it('…while a form body-column COUNT is still accepted (the form schema\'s own constraints, unchanged)', () => { + expect(acceptance({ ...FORM, columns: 2 }).branch).toBe('formOverlay'); + const r = refusal({ ...FORM, columns: 0 }); + expect(r.issues[0]).toMatchObject({ code: 'too_small', path: ['columns'] }); + }); +}); + +describe('#20186 W2 — a list-legal value under a key both arms declare differently is ACCEPTED', () => { + // Refused before: the form member judged these keys by FORM rules (a retired + // `aria` tombstone, a string-only `description`, a public-link `sharing`). + // Declared as a widening: `Clause-②: yes (narrowing)`. + it.each([ + ['`aria` (the list view ARIA block; the form arm carries a retirement tombstone)', { aria: { ariaLabel: 'Leads' } }], + ['an i18n `description` (the form arm takes a plain string only)', { description: { en: 'All leads' } }], + ['list-style `sharing` (the form arm\'s is the public-link block)', { sharing: { type: 'personal' } }], + ])('%s', (_label, extra) => { + expect(acceptance({ ...LIST, ...extra }).branch).toBe('listOverlay'); + }); +}); + +describe('#20186 controls — byte-identical parse output (measured on origin/main ce70876e)', () => { + it('a real form overlay', () => { + const { data, branch } = acceptance({ + name: 'acct.f', object: 'account', viewKind: 'form', type: 'simple', sections: [{ label: 'Main', fields: ['name'] }], + }); + expect(branch).toBe('formOverlay'); + expect(JSON.stringify(data)).toBe( + '{"type":"simple","sections":[{"label":"Main","collapsible":false,"collapsed":false,"columns":1,"fields":["name"]}],' + + '"name":"acct.f","object":"account","viewKind":"form"}', + ); + }); + + it('a list overlay WITH `columns` (and no `type` — the default still lands in its own key position)', () => { + const { data, branch } = acceptance({ + name: 'acct.c', object: 'account', viewKind: 'list', columns: ['name'], sort: [{ field: 'name', order: 'asc' }], + }); + expect(branch).toBe('listOverlay'); + expect(JSON.stringify(data)).toBe( + '{"name":"acct.c","type":"grid","columns":["name"],"sort":[{"field":"name","order":"asc"}],"object":"account","viewKind":"list"}', + ); + }); +}); + +describe('#20186 each member judges ONE viewKind — read off the members themselves', () => { + it('a direct parse of the form member names where a list body is judged', () => { + const r = VIEW_METADATA_MEMBERS.formOverlay.safeParse({ ...LIST, sort: [{ field: 'name', order: 'asc' }] }); + expect(r.success).toBe(false); + const issue = r.error!.issues.find((i) => i.path[0] === 'viewKind'); + expect(issue?.message).toMatch(/^This is the flattened FORM overlay member, which judges `viewKind: "form"` only\./); + expect(issue?.message).toContain('`VIEW_METADATA_MEMBERS.listOverlay`'); + }); + + it('…and the list member refuses a form body the same way', () => { + const r = VIEW_METADATA_MEMBERS.listOverlay.safeParse({ ...FORM, columns: ['name'] }); + expect(r.success).toBe(false); + const issue = r.error!.issues.find((i) => i.path[0] === 'viewKind'); + expect(issue?.message).toMatch(/^This is the flattened LIST overlay member, which judges `viewKind: "list"` only\./); + }); + + it('an ABSENT viewKind keeps the binding prescription (#7741), on both members', () => { + for (const member of [VIEW_METADATA_MEMBERS.listOverlay, VIEW_METADATA_MEMBERS.formOverlay]) { + const r = member.safeParse({ object: 'crm_lead', hidden: true }); + const issue = r.error!.issues.find((i) => i.path[0] === 'viewKind'); + expect(issue?.message).toContain('This inline view config names no `viewKind`'); + } + }); +}); + +describe('#20186 the premise: no pipe, and the served JSON Schema moves only by the contract', () => { + it('both overlay members are still plain object schemas', () => { + // A pipe serves its INPUT side to `/api/v1/meta/types/view`, and for a + // transform that is `{}` (the `assertViewIdentity` docblock). The input-side + // refusal and the default are both CHECKS on the object instead. + expect((VIEW_METADATA_MEMBERS.listOverlay as unknown as { _zod: { def: { type: string } } })._zod.def.type).toBe('object'); + expect((VIEW_METADATA_MEMBERS.formOverlay as unknown as { _zod: { def: { type: string } } })._zod.def.type).toBe('object'); + }); + + it.each(['input', 'output'] as const)('the %s JSON Schema: four members, one viewKind per overlay arm, list `type` default kept', (io) => { + const json = z.toJSONSchema(ViewMetadataSchema, { unrepresentable: 'any', io }) as { + anyOf: Array<{ properties: Record; required?: string[] }>; + }; + expect(json.anyOf).toHaveLength(4); + const [, , list, form] = json.anyOf; + expect(list!.properties.viewKind!.enum).toEqual(['list']); + expect(form!.properties.viewKind!.enum).toEqual(['form']); + expect(list!.properties.type!.default).toBe('grid'); + expect(list!.required).not.toContain('columns'); + expect(list!.required).toEqual(expect.arrayContaining(['object', 'viewKind'])); + }); +}); diff --git a/packages/spec/src/ui/view-union-diagnostics.test.ts b/packages/spec/src/ui/view-union-diagnostics.test.ts index 7f6c06cb27d..2bc7d3fe8a3 100644 --- a/packages/spec/src/ui/view-union-diagnostics.test.ts +++ b/packages/spec/src/ui/view-union-diagnostics.test.ts @@ -293,10 +293,14 @@ describe('[#7025] the acceptance face of ViewMetadataSchema — as re-ruled by # { type: 'simple', ...BOUND_FORM }], ['overlay.consoleFilterId', { filter: [{ id: 'row-1', field: 'name', operator: '=', value: 'x' }], ...BOUND_FORM }, { type: 'simple', ...BOUND_FORM }], - ['put.isPinned', { isPinned: true, ...BOUND_LIST }, { type: 'simple', ...BOUND_LIST }], - ['put.sortOrder', { sortOrder: 3, ...BOUND_LIST }, { type: 'simple', ...BOUND_LIST }], + // [#20186] A lean `viewKind: 'list'` PUT is judged by the LIST member now, + // so it parses to a list (`type: 'grid'`). It used to parse to + // `type: 'simple'` — a FORM — because the form member was the one accepting + // it, with every list key stripped unread. + ['put.isPinned', { isPinned: true, ...BOUND_LIST }, { type: 'grid', ...BOUND_LIST }], + ['put.sortOrder', { sortOrder: 3, ...BOUND_LIST }, { type: 'grid', ...BOUND_LIST }], ['put.hidden', { hidden: true, ...BOUND_FORM }, { type: 'simple', hidden: true, ...BOUND_FORM }], - ['put.pinAndOrder', { isPinned: true, sortOrder: 3, ...BOUND_LIST }, { type: 'simple', ...BOUND_LIST }], + ['put.pinAndOrder', { isPinned: true, sortOrder: 3, ...BOUND_LIST }, { type: 'grid', ...BOUND_LIST }], ]; const REFUSED: Array<[string, unknown, string[]]> = [ diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 3f0bf677ed4..ca05721578b 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -5178,6 +5178,16 @@ function overlayViewKindArmMismatch(kind: 'list' | 'form'): string { * refusal constants above for the measured reason and the ruling. The rest of * the identity fields stay optional: they are display/round-trip state, not * what any read path filters on. + * + * [#20186] `viewKind` is PER ARM: the list member admits `'list'` only and the + * form member `'form'` only, so a flattened body is judged by the arm its + * `viewKind` names and by no other. The two arms used to share one + * `list | form` enum, and the form member — which requires no list key and + * `.strip()`s every one — then accepted any `viewKind: 'list'` body the list + * member refused, storing its `sort` / `searchableFields` / `timeline` + * unjudged; the list member likewise accepted a `viewKind: 'form'` body that + * carried list `columns`. The conversions walk (`mapViewPayloads`) already + * picked an overlay's family from `viewKind`; now the parse does too. */ function flattenedViewOverlayFields(kind: 'list' | 'form') { return { @@ -5704,6 +5714,14 @@ function formOverlayColumnsField(): z.ZodOptional { * [#20051] …and `options` is DECLARED here, judged by * {@link ListViewOverlayOptionsSchema}: `.strip()` re-opens the top level for * round-trip keys, and a bag the renderer reads is not one of those. + * + * [#20186] This member judges `viewKind: 'list'` only (the form sibling + * `'form'` only), and it judges a column-less PATCH as well as a full inline + * config — see {@link listOverlayPatchFields}. A column-less body that names a + * `type` is a full config missing its columns and is refused at `columns` + * ({@link checkListOverlayTypeNeedsColumns}). The three attached checks run in + * order: that refusal reads the input side, the calendar check is unchanged, + * and {@link applyListOverlayTypeDefault} restores the `grid` default last. */ const ListViewOverlayWireSchema = lazySchema(() => // [#13216] Built from {@link ListViewShapeSchema}, not {@link ListViewSchema}: @@ -5727,12 +5745,16 @@ const ListViewOverlayWireSchema = lazySchema(() => /** * [#6391] Member 4 of {@link ViewMetadataSchema} — a flattened runtime FORM * overlay, published as `VIEW_METADATA_MEMBERS.formOverlay`. Same construction - * and the same `.strip()` rationale as {@link ListViewOverlayWireSchema}; the - * list member is tried first, and a flattened form (no required `columns`, - * disjoint `type` enum) then matches here. + * and the same `.strip()` rationale as {@link ListViewOverlayWireSchema}. * * [#20051] `options` is pinned ABSENT here — see * {@link FORM_OVERLAY_OPTIONS_REFUSED} for the fall-through it closes. + * + * [#20186] It judges `viewKind: 'form'` only. Before, it was the union's + * fall-through for every flattened body the list member refused — a + * column-less `viewKind: 'list'` patch among them — and it accepted those with + * their list keys stripped unread. Its `columns` (the form's body-column + * COUNT) now answers a field list with {@link FORM_OVERLAY_COLUMNS_IS_A_COUNT}. */ const FormViewOverlayWireSchema = lazySchema(() => // `.safeExtend()`: this member now re-declares a key the form shape carries @@ -5779,7 +5801,8 @@ export const VIEW_METADATA_MEMBERS = { viewItem: ViewItemWireSchema, // 2. Non-empty defineView container. container: ViewContainerWireSchema, - // 3/4. Flattened runtime overlay — inline ListView / FormView config + identity. + // 3/4. Flattened runtime overlay — inline ListView / FormView config + identity, + // each judging only the `viewKind` it names (#20186). listOverlay: ListViewOverlayWireSchema, formOverlay: FormViewOverlayWireSchema, } as const satisfies Record; @@ -5816,11 +5839,12 @@ function viewMetadataVocabulary(): Set { /** * [#6391] The `type` values that tell the two flattened overlay branches apart. * - * `ListViewSchema.type` and `FormViewSchema.type` are disjoint enums — the - * property the union's own comment already relies on ("a flattened form (no - * required `columns`, disjoint `type` enum) then matches the form member"). Read - * off the schemas rather than re-listed, so a new view type cannot make the - * dispatch disagree with the members. + * `ListViewSchema.type` and `FormViewSchema.type` are disjoint enums, so a + * body that names no `viewKind` (which both overlay members refuse) can still + * be told apart for diagnosis by its `type`; a body that names one is settled + * by it first, exactly as the members judge it (#20186). Read off the schemas + * rather than re-listed, so a new view type cannot make the dispatch disagree + * with the members. */ function overlayTypeValues(schema: z.ZodTypeAny): ReadonlySet { const shape = (schema as unknown as { _zod?: { def?: { shape?: Record } } }) From 98c96b4d38ff423684fe5d8858a1ac567ee6863a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:56:00 +0000 Subject: [PATCH 4/7] test(spec): W2 includes a valid legacy options bag on a column-less list patch Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- .changeset/20186-view-overlay-judged-by-viewkind-arm.md | 2 +- packages/spec/src/ui/view-overlay-viewkind-arm.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.changeset/20186-view-overlay-judged-by-viewkind-arm.md b/.changeset/20186-view-overlay-judged-by-viewkind-arm.md index d42b1168c30..30d1af93937 100644 --- a/.changeset/20186-view-overlay-judged-by-viewkind-arm.md +++ b/.changeset/20186-view-overlay-judged-by-viewkind-arm.md @@ -44,7 +44,7 @@ A column-less list overlay that names a `type` (`{ viewKind: 'list', type: 'kanb Two classes of column-less, type-less `viewKind: 'list'` bodies go from refused to accepted: -- **W2** — a list-legal value under a key both members declare with different schemas: `aria` (the form member carries a retirement tombstone there), an i18n `description` (the form member takes a plain string only), and the list `sharing` block (the form member's is the public-link block). Measured: `{ name, object, viewKind: 'list', aria: { ariaLabel: 'Leads' } }` was refused, and is accepted. This is the change working: a list body is judged by list rules. +- **W2** — a list-legal value under a key both members declare with different schemas: `aria` (the form member carries a retirement tombstone there), an i18n `description` (the form member takes a plain string only), the list `sharing` block (the form member's is the public-link block), and a valid legacy `options` bag (the form member pins `options` absent). Measured: `{ name, object, viewKind: 'list', aria: { ariaLabel: 'Leads' } }` was refused, and is accepted. This is the change working: a list body is judged by list rules. - **W1** — an invalid value under one of the 19 form-only keys (`layout`, `sections`, `title`, …). Measured: `{ name, object, viewKind: 'list', isPinned: true, layout: 'diagonal' }` was refused (the form member judged `layout`), and is accepted with `layout` dropped unread. That is the list member's existing handling of a key it does not declare: a list overlay WITH `columns` and `layout: 'diagonal'` was already accepted the same way. It is a named residual, not a contract. ## Census diff --git a/packages/spec/src/ui/view-overlay-viewkind-arm.test.ts b/packages/spec/src/ui/view-overlay-viewkind-arm.test.ts index be387a0a699..94f178c9eac 100644 --- a/packages/spec/src/ui/view-overlay-viewkind-arm.test.ts +++ b/packages/spec/src/ui/view-overlay-viewkind-arm.test.ts @@ -35,7 +35,8 @@ * refused to accepted (`Clause-②: yes (narrowing)`): * * - **W2** — a list-legal value under a key both arms declare with different - * schemas (`aria`, an i18n `description`, list-style `sharing`). The form arm + * schemas (`aria`, an i18n `description`, list-style `sharing`, a valid + * legacy `options` bag). The form arm * used to judge those keys by FORM rules; the list arm now judges them by the * list rules they belong to. Pinned ACCEPT below: it is the ruling working. * - **W1** — an invalid value under a form-only key (`layout: 'diagonal'`), @@ -186,6 +187,7 @@ describe('#20186 W2 — a list-legal value under a key both arms declare differe ['`aria` (the list view ARIA block; the form arm carries a retirement tombstone)', { aria: { ariaLabel: 'Leads' } }], ['an i18n `description` (the form arm takes a plain string only)', { description: { en: 'All leads' } }], ['list-style `sharing` (the form arm\'s is the public-link block)', { sharing: { type: 'personal' } }], + ['a valid legacy `options` bag (the form arm pins `options` absent)', { options: { map: { locationField: 'address' } } }], ])('%s', (_label, extra) => { expect(acceptance({ ...LIST, ...extra }).branch).toBe('listOverlay'); }); From 87f9d156e42518c9e2b4fcb161e25708c0652b49 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:05:39 +0000 Subject: [PATCH 5/7] fix(spec): the type-needs-columns prescription calls a toolbar write a patch on the view, not a personalization Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- packages/spec/src/ui/view.zod.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index ca05721578b..f995072bc5f 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -5580,8 +5580,8 @@ const LIST_OVERLAY_DEFAULT_TYPE = 'grid'; */ const LIST_OVERLAY_TYPE_NEEDS_COLUMNS = 'This list view overlay sets `type` but lists no `columns`. A body that sets `type` is a full inline list ' - + 'config, and a full config lists its columns: add `columns: ["field_a", "field_b"]`. A personalization ' - + 'patch (`sort`, `hiddenFields`, `columnState`, `inlineEdit`, …) sets no `type` and needs no `columns`: ' + + 'config, and a full config lists its columns: add `columns: ["field_a", "field_b"]`. A patch on the view ' + + 'it shadows (`sort`, `hiddenFields`, `columnState`, `inlineEdit`, …) sets no `type` and needs no `columns`: ' + 'remove `type` to save this body as a patch on the view it shadows.'; /** From e272672a70701e0baa0a06ae9f0fbd3f40f26112 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:35:16 +0000 Subject: [PATCH 6/7] fix(spec): type the form overlay columns error map's issue parameter Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- packages/spec/src/ui/view.zod.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index f995072bc5f..65b6f0b47dd 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -5686,7 +5686,9 @@ function formOverlayColumnsField(): z.ZodOptional { const count = optional.unwrap(); const withPrescription = count.clone({ ...count._zod.def, - error: (issue) => (Array.isArray(issue.input) ? FORM_OVERLAY_COLUMNS_IS_A_COUNT : undefined), + // The def's issue type is `never` for a number's own map; the runtime hands + // it the raw issue, whose `input` is what this reads. + error: (issue: { input?: unknown }) => (Array.isArray(issue.input) ? FORM_OVERLAY_COLUMNS_IS_A_COUNT : undefined), }); const description = optional.description; return description ? withPrescription.optional().describe(description) : withPrescription.optional(); From 750d0c8216325eb02fc1495bee0e0ba0c04933f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:23:52 +0000 Subject: [PATCH 7/7] test(metadata-protocol): pin the viewKind-arm verdicts at the save door on the existing ledgered double Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- ...rotocol.graft-folded-form-sections.test.ts | 97 ++++++++ ...protocol.view-overlay-viewkind-arm.test.ts | 215 ------------------ 2 files changed, 97 insertions(+), 215 deletions(-) delete mode 100644 packages/metadata-protocol/src/protocol.view-overlay-viewkind-arm.test.ts diff --git a/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts b/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts index 2d5f98d95b5..24764d95dd4 100644 --- a/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts +++ b/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts @@ -410,3 +410,100 @@ describe('[#20051] the save door judges a flat list overlay\'s `options` bag', ( expect((ViewMetadataSchema as any).safeParse(body).success).toBe(true); }); }); + +/** + * #20186 — a flattened overlay is judged by the member its `viewKind` names, + * pinned at the WRITE DOOR (the spec-side pins: `view-overlay-viewkind-arm.test.ts`). + * + * Before: a column-less `viewKind: 'list'` body was refused by the list member + * (no `columns`) and ACCEPTED by the form member, which strips every list key — + * so a retired `sort` string answered `success: true` and the row held it as + * sent. Now the list member judges the column-less PATCH (the ruled storage + * shape of every console toolbar save, maintainer ruling on #7494), and its + * invalid list keys are refused, located, with nothing stored. Same double as + * the blocks above, so the save runs the real repository path. + */ +describe('#20186 the save door judges a flattened overlay by the member its viewKind names', () => { + const ID = { name: 'crm_lead.all', object: 'crm_lead' } as const; + const LIST = { ...ID, viewKind: 'list' } as const; + const FORM = { ...ID, viewKind: 'form' } as const; + + /** The refusal, with its envelope asserted and NO row written. */ + async function refused(item: Record): Promise> { + const { protocol, rows } = makeProtocol(); + let err: any; + try { + await (protocol as any).saveMetaItem({ type: 'view', name: item.name as string, item }); + } catch (e) { err = e; } + expect(err?.code).toBe('INVALID_METADATA'); + expect(err?.status).toBe(422); + expect(Array.from(rows.values()).filter((r) => r.type === 'view')).toHaveLength(0); + return err.issues; + } + const at = (issues: Array<{ path: string; code?: string; message: string }>, path: string) => { + const hit = issues.find((i) => i.path === path); + expect(hit, `no issue at \`${path}\` in ${JSON.stringify(issues)}`).toBeDefined(); + return hit!; + }; + + it('the headline `{ name, object, viewKind: list, sort }` saves, and the row is the body as sent', async () => { + const body = { ...LIST, sort: [{ field: 'name', order: 'asc' }] }; + expect(await storedViewBody(body.name, body)).toEqual(body); + }); + + it('the objectui sort toggle (`{ ...patch, viewKind }` + object / name / _isOverride) saves verbatim', async () => { + const body = { sort: [{ field: 'name', order: 'desc' }], viewKind: 'list', ...ID, _isOverride: true }; + expect(await storedViewBody(body.name, body)).toEqual(body); + }); + + it('a retired bare-string `sort`: 422 at `sort`, with the 17.5.0 retirement prescription', async () => { + const issue = at(await refused({ ...LIST, sort: 'name desc' }), 'sort'); + expect(issue.code).toBe('invalid_type'); + expect(issue.message).toContain('The bare string `sort` clause was removed from `view.sort` in @objectstack/spec 17.5.0'); + }); + + it('the `timeline.metaFields` twin: 422 at `timeline`, naming the key', async () => { + const issue = at(await refused({ ...LIST, timeline: { startDateField: 'created', titleField: 'name', metaFields: ['region'] } }), 'timeline'); + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.message).toContain('Unrecognized key(s) on this timeline configuration: `metaFields`.'); + }); + + it('a non-array `searchableFields`: 422 at `searchableFields`', async () => { + expect(at(await refused({ ...LIST, searchableFields: 'name' }), 'searchableFields').code).toBe('invalid_type'); + }); + + it('a form-style `sharing` on a list body: 422 at `sharing`', async () => { + expect(at(await refused({ ...LIST, sharing: { enabled: true } }), 'sharing').code).toBe('unrecognized_keys'); + }); + + it('a column-less list overlay that names a `type`: 422 at `columns`, with the prescription', async () => { + const issue = at(await refused({ ...LIST, type: 'kanban', groupByField: 'stage' }), 'columns'); + expect(issue.code).toBe('custom'); + expect(issue.message).toMatch(/^This list view overlay sets `type` but lists no `columns`\./); + }); + + it('the mirror — list `columns` on a `viewKind: form` body: 422 at `columns`, with the count prescription', async () => { + const issue = at(await refused({ ...FORM, columns: ['name'] }), 'columns'); + expect(issue.code).toBe('invalid_type'); + expect(issue.message).toMatch(/^On a form view `columns` is the NUMBER of body columns/); + }); + + // W2 — declared widening (`Clause-②: yes (narrowing)`): a list-legal value + // under a key both members declare with different schemas, refused before + // because the FORM member judged it. + it.each([ + ['aria', { aria: { ariaLabel: 'Leads' } }], + ['an i18n description', { description: { en: 'All leads' } }], + ['list-style sharing', { sharing: { type: 'personal' } }], + ])('W2: %s now saves verbatim', async (_label, extra) => { + const body = { ...LIST, ...extra }; + expect(await storedViewBody(body.name, body)).toEqual(body); + }); + + it('controls: a real form overlay and a list overlay with `columns` save verbatim', async () => { + const form = { ...FORM, type: 'simple', sections: [{ label: 'Main', fields: ['name'] }] }; + expect(await storedViewBody(form.name, form)).toEqual(form); + const list = { ...LIST, columns: ['name'], sort: [{ field: 'name', order: 'asc' }] }; + expect(await storedViewBody(list.name, list)).toEqual(list); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.view-overlay-viewkind-arm.test.ts b/packages/metadata-protocol/src/protocol.view-overlay-viewkind-arm.test.ts deleted file mode 100644 index 6c34b8ebdce..00000000000 --- a/packages/metadata-protocol/src/protocol.view-overlay-viewkind-arm.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * #20186 — the flattened `view` overlay verdicts, pinned at the WRITE DOOR. - * - * `ViewMetadataSchema` now judges a flattened body by the arm its `viewKind` - * names (the spec-side pins live in `view-overlay-viewkind-arm.test.ts`). The - * spec pins alone would stay green if `saveMetaItem` stopped consulting that - * schema, and the defect was measured HERE: before the change, a column-less - * `viewKind: 'list'` body carrying a retired `sort` string answered - * `success: true` and the row held the string as sent. So this file drives the - * REAL `saveMetaItem` against a stub engine and reads the persisted - * `sys_metadata` row, both for what is now refused (nothing stored, a located - * `422 INVALID_METADATA`) and for what must keep saving (the console's - * patch-only toolbar writes, ruled on #7494 — stored verbatim). - */ -import { describe, expect, it } from 'vitest'; -import { - assertEngineDeleteDispatch, - assertEngineUpdateDispatch, assertEngineFindOnePredicate, -} from '@objectstack/metadata-core'; -import { ObjectStackProtocolImplementation } from './protocol.js'; - -interface Row { - id: string; - type: string; - name: string; - organization_id: string | null; - state: string; - metadata: string; -} - -const keyOf = (w: Record) => - `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}`; - -/** - * The engine surface the repository write path touches — the stub shape - * `protocol.graft-folded-form-sections.test.ts` uses, keyed BY TABLE so a - * journal row can never be read back as a `sys_metadata` row. - */ -function makeProtocol() { - const tables = new Map>(); - const tableOf = (table: string): Map => { - const existing = tables.get(table); - if (existing) return existing; - const created = new Map(); - tables.set(table, created); - return created; - }; - const rows = tableOf('sys_metadata'); - let nextId = 0; - const findRow = (table: string, w: Record): { key: string; row: Row } | null => { - if (w.id !== undefined) { - for (const [k, r] of tableOf(table)) if (r.id === w.id) return { key: k, row: r }; - return null; - } - for (const [k, r] of tableOf(table)) { - if (w.type !== undefined && r.type !== w.type) continue; - if (w.name !== undefined && r.name !== w.name) continue; - if (w.organization_id !== undefined && r.organization_id !== w.organization_id) continue; - if (w.state !== undefined && r.state !== w.state) continue; - return { key: k, row: r }; - } - return null; - }; - const engine: any = { - async findOne(table: string, opts: { where: Record }) { - assertEngineFindOnePredicate(table, opts); - return findRow(table, opts.where)?.row ?? null; - }, - async find(table: string, opts: { where: Record }) { - return Array.from(tableOf(table).values()).filter((r) => { - if (opts.where.type && r.type !== opts.where.type) return false; - if (opts.where.organization_id !== undefined - && r.organization_id !== opts.where.organization_id) return false; - if (opts.where.state && r.state !== opts.where.state) return false; - return true; - }); - }, - async insert(table: string, data: Record) { - nextId += 1; - const row = { id: `r_${nextId}`, ...(data as any) } as Row; - tableOf(table).set(keyOf(data), row); - return { id: row.id }; - }, - async update(table: string, data: Record, opts: { where: Record }) { - assertEngineUpdateDispatch(data, opts); - const found = findRow(table, opts.where); - if (!found) return { id: null }; - tableOf(table).set(found.key, { ...found.row, ...(data as any) }); - return { id: found.row.id }; - }, - async delete(table: string, opts: { where: Record }) { - assertEngineDeleteDispatch(opts); - const found = findRow(table, opts.where); - if (!found) return { deleted: 0 }; - tableOf(table).delete(found.key); - return { deleted: 1 }; - }, - registry: { registerItem: () => {}, registerObject: () => {} }, - }; - return { protocol: new ObjectStackProtocolImplementation(engine, () => new Map()), rows }; -} - -const ID = { name: 'crm_lead.all', object: 'crm_lead' } as const; -const LIST = { ...ID, viewKind: 'list' } as const; -const FORM = { ...ID, viewKind: 'form' } as const; - -/** Save through the real write door; return the body the stored ROW holds. */ -async function stored(item: Record): Promise { - const { protocol, rows } = makeProtocol(); - const result: any = await (protocol as any).saveMetaItem({ type: 'view', name: item.name as string, item }); - expect(result.success, JSON.stringify(result)).toBe(true); - const row = Array.from(rows.values()).find((r) => r.type === 'view'); - expect(row, 'the save persisted no view row').toBeDefined(); - return JSON.parse(row!.metadata); -} - -/** Save through the real write door; return the refusal, and prove nothing was stored. */ -async function refused(item: Record): Promise<{ code: string; status: number; issues: Array<{ path: string; code?: string; message: string }> }> { - const { protocol, rows } = makeProtocol(); - let thrown: any; - try { - await (protocol as any).saveMetaItem({ type: 'view', name: item.name as string, item }); - } catch (e) { - thrown = e; - } - expect(thrown, 'the save must be refused').toBeDefined(); - expect(thrown.code).toBe('INVALID_METADATA'); - expect(thrown.status).toBe(422); - expect(rows.size, 'a refused save stores nothing').toBe(0); - return thrown; -} - -/** The envelope entry located at `path`. */ -function at(err: { issues: Array<{ path: string; code?: string; message: string }> }, path: string) { - const issue = err.issues.find((i) => i.path === path); - expect(issue, `no issue at \`${path}\` in ${JSON.stringify(err.issues)}`).toBeDefined(); - return issue!; -} - -describe('#20186 the write door stores the console patch-only list overlay verbatim', () => { - it('the headline `{ name, object, viewKind: list, sort }` saves, and the row is the body as sent', async () => { - const body = { ...LIST, sort: [{ field: 'name', order: 'asc' }] }; - expect(await stored(body)).toEqual(body); - }); - - it('the objectui sort toggle (`{ ...patch, viewKind }` + object/name/_isOverride) saves verbatim', async () => { - const body = { sort: [{ field: 'name', order: 'desc' }], viewKind: 'list', ...ID, _isOverride: true }; - expect(await stored(body)).toEqual(body); - }); -}); - -describe('#20186 …and REFUSES the list keys it used to store unjudged', () => { - it('a retired bare-string `sort`: 422 at `sort`, with the 17.5.0 retirement prescription', async () => { - const err = await refused({ ...LIST, sort: 'name desc' }); - const issue = at(err, 'sort'); - expect(issue.code).toBe('invalid_type'); - expect(issue.message).toContain('The bare string `sort` clause was removed from `view.sort` in @objectstack/spec 17.5.0'); - }); - - it('the `timeline.metaFields` twin: 422 at `timeline`, naming the key', async () => { - const err = await refused({ ...LIST, timeline: { startDateField: 'created', titleField: 'name', metaFields: ['region'] } }); - const issue = at(err, 'timeline'); - expect(issue.code).toBe('unrecognized_keys'); - expect(issue.message).toContain('Unrecognized key(s) on this timeline configuration: `metaFields`.'); - }); - - it('a non-array `searchableFields`: 422 at `searchableFields`', async () => { - const err = await refused({ ...LIST, searchableFields: 'name' }); - expect(at(err, 'searchableFields').code).toBe('invalid_type'); - }); - - it('a form-style `sharing` on a list body: 422 at `sharing`', async () => { - const err = await refused({ ...LIST, sharing: { enabled: true } }); - expect(at(err, 'sharing').code).toBe('unrecognized_keys'); - }); - - it('a column-less list overlay that names a `type`: 422 at `columns`, with the prescription', async () => { - const err = await refused({ ...LIST, type: 'kanban', groupByField: 'stage' }); - const issue = at(err, 'columns'); - expect(issue.code).toBe('custom'); - expect(issue.message).toMatch(/^This list view overlay sets `type` but lists no `columns`\./); - }); - - it('the mirror — list `columns` on a `viewKind: form` body: 422 at `columns`, with the count prescription', async () => { - const err = await refused({ ...FORM, columns: ['name'] }); - const issue = at(err, 'columns'); - expect(issue.code).toBe('invalid_type'); - expect(issue.message).toMatch(/^On a form view `columns` is the NUMBER of body columns/); - }); -}); - -describe('#20186 W2 — a list-legal value under a key both arms declare differently now saves', () => { - it.each([ - ['aria', { aria: { ariaLabel: 'Leads' } }], - ['an i18n description', { description: { en: 'All leads' } }], - ['list-style sharing', { sharing: { type: 'personal' } }], - ])('%s', async (_label, extra) => { - const body = { ...LIST, ...extra }; - expect(await stored(body)).toEqual(body); - }); -}); - -describe('#20186 controls', () => { - it('a real form overlay saves verbatim', async () => { - const body = { ...FORM, type: 'simple', sections: [{ label: 'Main', fields: ['name'] }] }; - expect(await stored(body)).toEqual(body); - }); - - it('a list overlay with `columns` saves verbatim', async () => { - const body = { ...LIST, columns: ['name'], sort: [{ field: 'name', order: 'asc' }] }; - expect(await stored(body)).toEqual(body); - }); -});