Skip to content

Commit a91d12a

Browse files
fix(spec)!: a flattened view overlay is judged by the member its viewKind names (#20186) (#20245)
Fixes #20186 Clause-②: yes (narrowing) A flattened `view` overlay is now judged by the overlay member its `viewKind` names. A column-less list patch, which the console writes on every toolbar save, has its list keys judged, instead of being accepted by the form member with those keys stripped unread. This is route C-prime as ruled in seat answers `5855433719` and `5855548706` on #20186. ## What was wrong (measured) Both 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` answered `formOverlay` and the parse output was `type: 'simple'`, a form. I measured this through the real `saveMetaItem` on `origin/main` `4df101c3`, and again at the spec level on `ce70876e` after #20183 landed; the verdicts were byte-identical. Each of these bodies answered `success: true`, and the row held it exactly as sent: - `{ name, object, viewKind: 'list', sort: 'name desc' }`, a retired `sort` string; - the same body with `timeline: { …, metaFields: [...] }`; - the same body with `searchableFields: 'name'`. The mirror also held: the list member accepted `{ viewKind: 'form', columns: ['name'] }`. The column-less list body is not malformed; the console writes it. objectui's `buildPersistedViewBody` returns `{ ...patch, viewKind }` for an overlay, and `updateViewConfig` stamps `object`, `name` and `_isOverride`. That is the maintainer ruling on #7494 (comment `5261754173`): 「`persistViewPatch` 只存 patch,不存 merged base」. So refusing it (route A) was ruled out. ## What changed — `packages/spec/src/ui/view.zod.ts`, the flattened-overlay region only - **`flattenedViewOverlayFields(kind)`: one `viewKind` per member.** The list member admits `'list'` only and the form member `'form'` only. An absent `viewKind` keeps the #7741 binding prescription. - **The flattened LIST member judges a patch.** `columns` is optional on this member only; the authoring `ListViewSchema` keeps it required. `type` is declared here without its `.default('grid')`, so the member's checks can see whether the body named one. Three checks follow on the same object schema, in this order: 1. `checkListOverlayTypeNeedsColumns` refuses a column-less body that names a `type`, at `columns`. The issue is aborting, so the union envelope stays `invalid_union`. 2. The existing calendar check. 3. `applyListOverlayTypeDefault` (an `.overwrite()`) puts `type: 'grid'` back, in the key position the default took. `FormViewSchema` already uses the same `.overwrite()` shape for its `groups` → `sections` fold. **No pipe:** both overlay members are still `def.type === 'object'`, and a test pins it. - **The FORM member's `columns`** is a clone of `FormViewSchema`'s own count schema with an error map added. Its constraints are that schema's, and a field list gets a prescription. The member is built with `.safeExtend()`, because overriding a key on `FormViewSchema`, which carries refinements, is refused by `.extend()`. The two differ in that throw only. - `diagnoseViewMetadata` and `selectViewMetadataBranch` needed no change. Each body is now accepted by at most one overlay member, so the branch they name is the member that judged it. The conversions walk (`mapViewPayloads`) already picked an overlay's family from `viewKind`; the parse now agrees with it. ### Refusal and diagnosis texts (new) ```text LIST_OVERLAY_TYPE_NEEDS_COLUMNS (at `columns`, code custom) 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 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. FORM_OVERLAY_COLUMNS_IS_A_COUNT (at `columns`, code invalid_type) 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: [...] }]`. overlayViewKindArmMismatch(kind) (at `viewKind`; seen only on a direct member parse, the union mutes the unclaimed member) This is the flattened FORM overlay member, which judges `viewKind: "form"` only. A `viewKind: "list"` body is judged by the list overlay member (`VIEW_METADATA_MEMBERS.listOverlay`), and that member's issues are its diagnosis. ``` ### Served JSON Schema (`/api/v1/meta/types/view`) The schema is still an `anyOf` of four members in both directions. Members 0 and 1 are byte-identical. I diffed it against `ce70876e`, and it moves only by the contract: - member 2 (list overlay): `viewKind.enum` is `["list"]`, and `columns` is no longer in `required`. In the output direction `type` is no longer in `required`. The `type` default `grid` stays byte-identical in both directions, via `.optional().meta({ default })`. - member 3 (form overlay): `viewKind.enum` is `["form"]`. `columns` is unchanged. ## Pins: `ViewMetadataSchema` AND `saveMetaItem` - New: `packages/spec/src/ui/view-overlay-viewkind-arm.test.ts` (schema + `diagnoseViewMetadata`). - Added to `packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts` (its ledgered stub engine, the real `saveMetaItem`, and the stored row read back). Every refusal asserts `INVALID_METADATA`, `422`, nothing stored, and the issue's path, code and prescription. | body (flattened, `object` bound) | was | now | |:--|:--|:--| | `viewKind: 'list'`, `sort: [{ field, order }]` (the headline) | ACCEPT on formOverlay, `type: 'simple'` | ACCEPT on listOverlay, `type: 'grid'`, stored verbatim | | the objectui sort / hiddenFields / inlineEdit / columnState / rowHeight patches | ACCEPT on formOverlay | ACCEPT on listOverlay, stored verbatim | | `viewKind: 'list'`, `sort: 'name desc'` | ACCEPT, stored | REFUSE `invalid_type` at `sort` (the 17.5.0 prescription) | | `viewKind: 'list'`, `timeline.metaFields` | ACCEPT, stored | REFUSE `unrecognized_keys` at `timeline` | | `viewKind: 'list'`, `searchableFields: 'name'` | ACCEPT, stored | REFUSE `invalid_type` at `searchableFields` | | `viewKind: 'list'`, `sharing: { enabled: true }` (form block) | ACCEPT, stored | REFUSE `unrecognized_keys` at `sharing` | | `viewKind: 'form'`, `columns: ['name']` (mirror) | ACCEPT on listOverlay | REFUSE on formOverlay, `invalid_type` at `columns`, count prescription | | `viewKind: 'list'`, `type: 'kanban'`, no `columns` (`overlay.list.identity`) | REFUSE (`invalid_union`, bare `Invalid input` at `columns`) | REFUSE (`invalid_union`), `custom` at `columns` with the prescription | | control: real form overlay | ACCEPT | ACCEPT, parse output byte-identical | | control: list overlay with `columns` | ACCEPT | ACCEPT, parse output byte-identical | ### Pin sweep - **Flipped on purpose:** `view-union-diagnostics.test.ts` `put.isPinned`, `put.sortOrder` and `put.pinAndOrder`. The verdict is still ACCEPT, but the pinned parse output moves from `{ type: 'simple', … }` to `{ type: 'grid', … }`. The seat named these three as the intended change. - **Not flipped:** `overlay.list.identity`, which stays REFUSE and stays `invalid_union`. - **No pin anywhere asserted a W1 or W2 body refused.** The swept suites all ran green unchanged, apart from the three `put.*` pins above (numbers under Verification). - The #20183 pin in `view-overlay-options-bag.test.ts` (a column-less body with a BAD `options` bag stays refused) holds: the list member judges the bag. ### Readers keyed on the old form parse of these rows - **`graftNormalizedOperators`** (the `saveMetaItem` operator graft). On `805af4f2` the form member stripped a column-less list patch's `filter`, so an alias operator (`eq`) was stored as written. Now the list parse normalises it, and the graft writes the canonical operator into the row, as it already did for a list overlay with `columns`. Measured through the real save on this branch: `filter: [{ field: 'name', operator: 'eq', value: 'x' }]` is stored as `operator: 'equals'`. - **`graftFoldedFormSections`**. On `805af4f2` a list patch carrying the form key `groups` was parsed as a form (parse output `sections: [...]`), and the graft stored it as `sections`. Now the list member drops `groups` unread, and the row keeps `groups` as authored (measured through the real save). This is a W1-class body; see the Acceptance notes. - **objectui**, at the pin and at `main`, has no reader of `diagnoseViewMetadata` or of the parse output's `type` for these rows. Its only hits are docblocks naming `VIEW_METADATA_MEMBERS.formOverlay`, for real form overlays. ## Declared widening (W1, W2) — why `Clause-②: yes` Both classes are column-less, type-less `viewKind: 'list'` bodies that were refused and are now accepted. All readings below are on `805af4f2` and on this branch. - **W2**: a list-legal value under a key both members declare with different schemas. - Measured body: `{ name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', aria: { ariaLabel: 'Leads' } }`. It was REFUSED and is now ACCEPTED on listOverlay. - The class also covers an i18n `description`, the list `sharing` block and a valid legacy `options` bag. Each of those is pinned ACCEPT at both doors, and `aria` is pinned too. - The divergent keys, measured off the members: `type`, `columns`, `description`, `sharing`, `aria`, `options` (and `viewKind` itself). - This is the ruling working: a list body is judged by list rules. - **W1**: an invalid value under one of the 19 form-only keys. - Measured body: `{ name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', isPinned: true, layout: 'diagonal' }`. It was REFUSED, because the form member judged `layout`. It is now ACCEPTED, with `layout` dropped from the parse. - ⛔ It is not pinned as desired behaviour; see the Acceptance notes. - The 19 keys: `layout`, `title`, `defaultTab`, `tabPosition`, `allowSkip`, `showStepIndicator`, `splitDirection`, `splitSize`, `splitResizable`, `drawerSide`, `drawerWidth`, `modalSize`, `sections`, `groups`, `subforms`, `defaultSort`, `submitBehavior`, `buttons`, `defaults`. ## Census (literal and dynamic producers) - **objectstack** `4df101c3`: 236 literal `viewKind: 'list'` occurrences in 94 files under `packages/**`. Of those, 56 are column-less flattened: 41 tests, 4 changelog lines, and 11 comments or docstrings. Control: 105 flattened-with-columns and 70 `config` records were classified by the same scanner. - **`examples/**`**: 0 `viewKind` at all. Control: 15 files with `listViews` and 14 with `defineView`. - **objectui**: at the pin `f8a9d0fb`, 90 occurrences in 39 files; at `main` `25c7d584e`, 95 in 42. The column-less ones are tests plus 4 type declarations or docstrings. - **cloud** `main` `48d7066`: 60 occurrences in 13 files; all 14 column-less ones are tests. Its two producers emit `config` records. - **Literal source producers: 0 in every tree.** The one real producer is dynamic and was found by reading the code: objectui's `buildPersistedViewBody` plus `updateViewConfig`, called from the `sort`, `hiddenFields`, `inlineEdit` and `columnState` toolbar handlers on objectui `main`. It keeps saving. - No body that was accepted is refused for that producer, so **no ADR-0087 D2 conversion** is needed. - The semantic entry `view-overlay-judged-by-viewkind-arm` records the write-time refusals, and the registry is regenerated. - Stored rows in deployments: NOT MEASURED (no deployment store is reachable). ## Verification All heavy runs went through `os-verify-lock`. The lock is shared, so every timing is a shared-box reading. **Pre-merge, at `750d0c82`** (the branch merged with `805af4f2`), in one locked batch (`VERDICT command-exit 0`, held 984s): - Build of the `metadata-protocol`, `objectql`, `lint` and `rest` dependency closures (the `^...` filter): exit 0. - `spec check:generated`: exit 0, all 15 artefacts current, `api-surface/` included, so no generated artefact moves. - `spec` vitest `--project local`: 545 files passed, 16059 tests passed, 2 todo. - `metadata-protocol` vitest: 189 files passed, 3 skipped; 2715 tests passed, 19 skipped. `metadata-protocol` typecheck: exit 0. - Consumer suites, every test file in the package that names `viewKind`: `objectql` 24 files / 380 tests, `lint` 8 files / 349 tests, `rest` 11 files / 153 tests. All passed. - `spec` vitest `--project repo`: 32 files / 586 tests, measured at `87f9d156` in an earlier locked batch. After that, `packages/spec` changed only by a type annotation (`e272672a`). `spec typecheck` exited 0 on those bytes. **Post-merge, at `859730ef`** (merged with `c02fa127`): - The merge brought #20227 (view-item `owner`/`hidden` retirement, a disjoint region of `view.zod.ts`) and others. It merged clean, and the migration registry regenerates byte-identical. - The locked rebuild plus `check:generated`, `spec` local vitest and `metadata-protocol` vitest is queued. Until it runs it is NOT MEASURED here, and the dev report carries its reading. **Gates.** I re-derived the list on the actual paths (`dispatch-gates.mjs --commands`: 88 families) and ran every line, plus the 5 artefact-roster gates whose roster sits in a directory this diff touches. Exit codes went to disk before anything was read. - `--ran` reconciliation at `859730ef`: 88 derived, 81 run green, 7 NOT-MEASURED (exit 3, PREREQUISITE NOT MET), 0 unrun. - Five of the seven (`spec` `check:api-surface`, `check:browser-reachable-entries`, `check:dual-source-exports`, `check:entry-nameability`, `check:exported-any`) read the built `dist`. They were green at `750d0c82`, and the post-merge rebuild re-reads them. - The other two (`check:dual-build-cjs-loads`, `check:type-check-debt`) need the whole-workspace build and are declared to CI. - The 5 roster gates also ran green. - `check:engine-double-contract` and `check:objectql-double-limit` flagged the stub engine my first save-door test file declared. I moved those pins into the existing, ledgered double in `protocol.graft-folded-form-sections.test.ts`, and both gates are green: no ledger change. **Consumer notes.** - objectui's own suites run against its pinned spec version and were not run here. - `examples/**` carries no `viewKind` at all, so it has no objectui-facing overlay fixture to sweep. ## Acceptance notes - **W1, a same-family residual, not filed here.** A column-less list patch carrying an invalid form-only key is now accepted with the key dropped unread. - `reach:` measured at the public door, `saveMetaItem` (the `PUT /api/v1/meta/view` path), on this branch: `{ name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', isPinned: true, layout: 'diagonal' }` answers `success: true`, and the row holds `layout: 'diagonal'` as sent (a throwaway probe, never committed). The same save with `columns: ['name']` added answers identically. - Control on `origin/main` `805af4f2`: `{ name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', columns: ['name'], layout: 'diagonal' }` is ACCEPTED on listOverlay, with output `{"name":"crm_lead.all","type":"grid","columns":["name"],"object":"crm_lead","viewKind":"list"}` (`layout` dropped). So this change routes more bodies to the list member's existing handling of undeclared keys; it does not create that handling. - F2 (refusing the 19 form-only keys by name on the list member) was not taken on this card, per seat answer `5855548706`. The seat decides filing at ACCEPT. - Dedupe words: `list overlay form-only key stripped` · `flattened list overlay layout dropped unread` · `W1 form key on list patch`. - The `graftFoldedFormSections` behaviour change above (a list body's `groups` is no longer folded to `sections`) is the same residual seen from the save path. --- _Generated by [Claude Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0d3ec47 commit a91d12a

7 files changed

Lines changed: 718 additions & 20 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
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)
6+
7+
**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.
8+
9+
Clause-②: yes (narrowing)
10+
11+
## What was wrong
12+
13+
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`.
14+
15+
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」.
16+
17+
## What it does now
18+
19+
- **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.
20+
- **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'`.
21+
- **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.
22+
- **A field list under a form overlay's `columns` is refused** at `columns`: on a form view `columns` is the body-column count.
23+
- 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).
24+
25+
## FROM → TO
26+
27+
Each row is refused now and was accepted before, on a flattened overlay:
28+
29+
| you wrote | write instead |
30+
|:--|:--|
31+
| `viewKind: 'list'`, no `columns`, `sort: 'name desc'` | `sort: [{ field: 'name', order: 'desc' }]` — the bare string clause was retired in 17.5.0 |
32+
| `viewKind: 'list'`, no `columns`, `sort: [{ field, direction: 'desc' }]` | `sort: [{ field, order: 'desc' }]` |
33+
| `viewKind: 'list'`, no `columns`, `timeline: { …, metaFields: [...] }` | delete `metaFields`: the timeline block has no such key |
34+
| `viewKind: 'list'`, no `columns`, `searchableFields: 'name'` | `searchableFields: ['name']` |
35+
| `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` |
36+
| 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 |
37+
| `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`) |
38+
39+
**The one-line fix:** read the refusal. It is located at the key it refuses and says what that key takes.
40+
41+
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.
42+
43+
## Declared widening (why `Clause-②: yes`)
44+
45+
Two classes of column-less, type-less `viewKind: 'list'` bodies go from refused to accepted:
46+
47+
- **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.
48+
- **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.
49+
50+
## Census
51+
52+
- **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.
53+
- **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.
54+
- **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.
55+
56+
<!-- adr-0087: registered view-overlay-judged-by-viewkind-arm -->

‎packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,3 +410,100 @@ describe('[#20051] the save door judges a flat list overlay\'s `options` bag', (
410410
expect((ViewMetadataSchema as any).safeParse(body).success).toBe(true);
411411
});
412412
});
413+
414+
/**
415+
* #20186 — a flattened overlay is judged by the member its `viewKind` names,
416+
* pinned at the WRITE DOOR (the spec-side pins: `view-overlay-viewkind-arm.test.ts`).
417+
*
418+
* Before: a column-less `viewKind: 'list'` body was refused by the list member
419+
* (no `columns`) and ACCEPTED by the form member, which strips every list key —
420+
* so a retired `sort` string answered `success: true` and the row held it as
421+
* sent. Now the list member judges the column-less PATCH (the ruled storage
422+
* shape of every console toolbar save, maintainer ruling on #7494), and its
423+
* invalid list keys are refused, located, with nothing stored. Same double as
424+
* the blocks above, so the save runs the real repository path.
425+
*/
426+
describe('#20186 the save door judges a flattened overlay by the member its viewKind names', () => {
427+
const ID = { name: 'crm_lead.all', object: 'crm_lead' } as const;
428+
const LIST = { ...ID, viewKind: 'list' } as const;
429+
const FORM = { ...ID, viewKind: 'form' } as const;
430+
431+
/** The refusal, with its envelope asserted and NO row written. */
432+
async function refused(item: Record<string, unknown>): Promise<Array<{ path: string; code?: string; message: string }>> {
433+
const { protocol, rows } = makeProtocol();
434+
let err: any;
435+
try {
436+
await (protocol as any).saveMetaItem({ type: 'view', name: item.name as string, item });
437+
} catch (e) { err = e; }
438+
expect(err?.code).toBe('INVALID_METADATA');
439+
expect(err?.status).toBe(422);
440+
expect(Array.from(rows.values()).filter((r) => r.type === 'view')).toHaveLength(0);
441+
return err.issues;
442+
}
443+
const at = (issues: Array<{ path: string; code?: string; message: string }>, path: string) => {
444+
const hit = issues.find((i) => i.path === path);
445+
expect(hit, `no issue at \`${path}\` in ${JSON.stringify(issues)}`).toBeDefined();
446+
return hit!;
447+
};
448+
449+
it('the headline `{ name, object, viewKind: list, sort }` saves, and the row is the body as sent', async () => {
450+
const body = { ...LIST, sort: [{ field: 'name', order: 'asc' }] };
451+
expect(await storedViewBody(body.name, body)).toEqual(body);
452+
});
453+
454+
it('the objectui sort toggle (`{ ...patch, viewKind }` + object / name / _isOverride) saves verbatim', async () => {
455+
const body = { sort: [{ field: 'name', order: 'desc' }], viewKind: 'list', ...ID, _isOverride: true };
456+
expect(await storedViewBody(body.name, body)).toEqual(body);
457+
});
458+
459+
it('a retired bare-string `sort`: 422 at `sort`, with the 17.5.0 retirement prescription', async () => {
460+
const issue = at(await refused({ ...LIST, sort: 'name desc' }), 'sort');
461+
expect(issue.code).toBe('invalid_type');
462+
expect(issue.message).toContain('The bare string `sort` clause was removed from `view.sort` in @objectstack/spec 17.5.0');
463+
});
464+
465+
it('the `timeline.metaFields` twin: 422 at `timeline`, naming the key', async () => {
466+
const issue = at(await refused({ ...LIST, timeline: { startDateField: 'created', titleField: 'name', metaFields: ['region'] } }), 'timeline');
467+
expect(issue.code).toBe('unrecognized_keys');
468+
expect(issue.message).toContain('Unrecognized key(s) on this timeline configuration: `metaFields`.');
469+
});
470+
471+
it('a non-array `searchableFields`: 422 at `searchableFields`', async () => {
472+
expect(at(await refused({ ...LIST, searchableFields: 'name' }), 'searchableFields').code).toBe('invalid_type');
473+
});
474+
475+
it('a form-style `sharing` on a list body: 422 at `sharing`', async () => {
476+
expect(at(await refused({ ...LIST, sharing: { enabled: true } }), 'sharing').code).toBe('unrecognized_keys');
477+
});
478+
479+
it('a column-less list overlay that names a `type`: 422 at `columns`, with the prescription', async () => {
480+
const issue = at(await refused({ ...LIST, type: 'kanban', groupByField: 'stage' }), 'columns');
481+
expect(issue.code).toBe('custom');
482+
expect(issue.message).toMatch(/^This list view overlay sets `type` but lists no `columns`\./);
483+
});
484+
485+
it('the mirror — list `columns` on a `viewKind: form` body: 422 at `columns`, with the count prescription', async () => {
486+
const issue = at(await refused({ ...FORM, columns: ['name'] }), 'columns');
487+
expect(issue.code).toBe('invalid_type');
488+
expect(issue.message).toMatch(/^On a form view `columns` is the NUMBER of body columns/);
489+
});
490+
491+
// W2 — declared widening (`Clause-②: yes (narrowing)`): a list-legal value
492+
// under a key both members declare with different schemas, refused before
493+
// because the FORM member judged it.
494+
it.each([
495+
['aria', { aria: { ariaLabel: 'Leads' } }],
496+
['an i18n description', { description: { en: 'All leads' } }],
497+
['list-style sharing', { sharing: { type: 'personal' } }],
498+
])('W2: %s now saves verbatim', async (_label, extra) => {
499+
const body = { ...LIST, ...extra };
500+
expect(await storedViewBody(body.name, body)).toEqual(body);
501+
});
502+
503+
it('controls: a real form overlay and a list overlay with `columns` save verbatim', async () => {
504+
const form = { ...FORM, type: 'simple', sections: [{ label: 'Main', fields: ['name'] }] };
505+
expect(await storedViewBody(form.name, form)).toEqual(form);
506+
const list = { ...LIST, columns: ['name'], sort: [{ field: 'name', order: 'asc' }] };
507+
expect(await storedViewBody(list.name, list)).toEqual(list);
508+
});
509+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// #20186, route C-prime (seat answers 5855433719 / 5855548706). A write-time
6+
// re-routing of the flattened view overlays: each member judges only the
7+
// `viewKind` it names. The stored rows it newly refuses are read and served
8+
// exactly as before and fail only on their next save, which is why this is a
9+
// semantic entry and not a conversion — which value a refused key meant is a
10+
// fact only its author holds.
11+
export const entry: SemanticMigration = {
12+
id: 'view-overlay-judged-by-viewkind-arm',
13+
surface:
14+
'A flattened `view` overlay saved through the metadata write door (`PUT /api/v1/meta/view/:name`, the '
15+
+ 'Studio / MCP save) whose `viewKind` names one family while the body was judged by the other: a '
16+
+ 'column-less `viewKind: "list"` body, which only the form overlay member used to accept (its list keys '
17+
+ '`sort`, `searchableFields`, `timeline`, `sharing` and the rest stripped unread), and a `viewKind: "form"` '
18+
+ 'body carrying list `columns`, which only the list overlay member used to accept.',
19+
replacement:
20+
'Each overlay is judged by the member its `viewKind` names. A column-less list overlay is a patch on the '
21+
+ 'view it shadows and carries list keys the list view schema accepts: a `sort` array of `{ field, order }` '
22+
+ '(the bare string clause was retired in 17.5.0), no `timeline.metaFields` (the timeline block has no such '
23+
+ 'key), an array `searchableFields`, and the list `sharing` block (`{ type, lockedBy }`), not the form '
24+
+ 'public-link block. A column-less list overlay names no `type`; one that does is a full inline config and '
25+
+ 'lists its `columns`. A form overlay\'s `columns` is its body-column count (an integer); a field list '
26+
+ 'means the body is a list view (`viewKind: "list"`) or belongs in `sections: [{ fields }]`.',
27+
reason:
28+
'Both overlay members shared one `viewKind: list | form` enum. The list member required `columns`, so it '
29+
+ 'refused the column-less list patch the console writes on every toolbar save (the ruled patch-only '
30+
+ 'storage shape, maintainer ruling: 「`persistViewPatch` 只存 patch,不存 merged base」); the '
31+
+ 'union then tried the form member, which requires no list key and strips every one, and accepted it — '
32+
+ 'so a retired `sort` string or a `timeline.metaFields` the list schema refuses by name was saved with '
33+
+ '`success: true` and stored as sent. Measured on `origin/main` @ `4df101c3` and again at `ce70876e` '
34+
+ '(after the `options`-bag door landed) through the real save. Ruled route C-prime: each member admits one `viewKind`, the list '
35+
+ 'member judges a column-less patch (`columns` optional there only; the authoring list view keeps it '
36+
+ 'required), and a column-less body that names a `type` stays refused at `columns`. Not convertible: '
37+
+ 'whether a refused value was a typo or a stale capability is the author\'s call.',
38+
acceptanceCriteria:
39+
'Every stored flattened `view` overlay saves again unchanged. A row that does not is refused '
40+
+ '`422 INVALID_METADATA` on its next save, with the issue located at the refused key (`sort`, `timeline`, '
41+
+ '`searchableFields`, `sharing`, `columns`) and carrying that key\'s own message — for a column-less list '
42+
+ 'overlay naming a `type`, the prescription at `columns`; for a field list on a form overlay, the '
43+
+ 'body-column-count prescription at `columns`. Nothing is rewritten on read and nothing is refused on '
44+
+ 'read: a row that fails is served exactly as stored until it is saved. Verify by re-saving each stored '
45+
+ 'flattened overlay (a GET then a PUT of the same body) and reading a `200`.',
46+
};

0 commit comments

Comments
 (0)