Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/20186-view-overlay-judged-by-viewkind-arm.md
Original file line number Diff line number Diff line change
@@ -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), 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

- **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.

<!-- adr-0087: registered view-overlay-judged-by-viewkind-arm -->
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): Promise<Array<{ path: string; code?: string; message: string }>> {
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);
});
});
Original file line number Diff line number Diff line change
@@ -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`.',
};
Loading
Loading