Skip to content

Commit 9f2bd76

Browse files
committed
fix(spec): migration entry, changeset and the os validate door pins for the row operation
Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude <noreply@anthropic.com>
1 parent 87e50a1 commit 9f2bd76

3 files changed

Lines changed: 90 additions & 0 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: a page action row declares `ActionSchema`'s own `operation` enum and refuses `onSuccess` beside `operation: 'update'`, with the action's text
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: registered ui-page-action-row-operation-declared -->
10+
11+
**BREAKING**: an accept-set narrowing on two keys of four published page component rows, shipped as `minor` under the launch-window convention for accept-set narrowings.
12+
13+
The page action rows are `action:button` and `action:icon` (`properties`), and each member of an `action:group` or `action:menu` (`properties.actions[]`). Each forwards `operation` to the console's action runner, which reads it before any executor: `operation: 'update'` performs the declarative write through the platform action route and runs no executor of its own. The rows typed `operation` as an open value and never carried `ActionSchema`'s refusal of `onSuccess` beside `operation: 'update'`, so a row with an `api` or `script` executor, the write and a hop parsed while the same action was refused. Now:
14+
15+
- a row's `operation` is `ActionSchema`'s own enum (one member, `'update'`); `'delete'` and `'custom'` get the action's "bulk-def operation with no row-level form" refusal, and any other value is refused at `operation`;
16+
- a row's `onSuccess` beside `operation: 'update'` is refused whatever executor the row names (or none), with ONE text shared with `ActionSchema`: the hop would follow the write, whose success carries no result `${result.*}` could read.
17+
18+
`ActionSchema`'s reach is unchanged. Its message for the pairing used to open "`onSuccess` is not defined for an `operation: 'update'` action yet"; it now states what the runner does (reads `operation` before any executor, writes, then hops) and keeps the prescription "Drop `onSuccess`".
19+
20+
### FROM → TO
21+
22+
| before | what to write instead |
23+
| --- | --- |
24+
| `{ actionType: 'api', operation: 'update', patch: { status: 'done' }, onSuccess: { navigate: '/done' } }` (or `type` on a member, or no executor) | the same row without `onSuccess`: the runner performs the write before any executor, so the hop followed a write whose success declares no result |
25+
| `{ operation: 'delete', … }` / `{ operation: 'custom', … }` on a row | no `operation`: a row delete is the object's own delete affordance, and a row is already the action a bulk def's `'custom'` would name |
26+
| any other `operation` value | `operation: 'update'` with a `patch`, or no `operation` (the runner ran nothing for it) |
27+
28+
**The one-line fix: on a page action row, write `operation` only as `'update'`, and never beside `onSuccess`.** `os migrate meta --from 17` lists the D3 entry `ui-page-action-row-operation-declared`; the rewrite is by hand.
29+
30+
**What an author now sees.** `os validate`'s props gate reports the refusal at the row's `operation` or `onSuccess` (`properties.…`, or `properties.actions.N.…` for a member). For example, `{ actionType: 'api', operation: 'update', patch: { status: 'done' }, onSuccess: { navigate: '/done' } }` on an `action:button` gets: ``An `action:button`'s `onSuccess` (`operation: 'update'`) would follow a result the contract does not declare: the console's action runner reads `operation` before any executor, …``. The same row without `onSuccess` parses unchanged.
31+
32+
**Measured producers.** None in this repository. In objectui (at its pin `1fb1ced36243` and its main `b9814b3`), the action renderers' own tests carry `operation: 'update'` rows without `onSuccess`, which stay accepted; no row carries another `operation` value or the pairing. objectui's metadata-admin form already clears `onSuccess` when an author switches an action into an update.

‎packages/lint/src/validate-component-props-row-on-success.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,29 @@ describe("the props gate refuses a page action row's `onSuccess` beside an execu
108108
expect(validateComponentProps(stackWith(node({ [key]: 'form', target: 'task_form' })))).toEqual([]);
109109
});
110110
});
111+
112+
// `operation` on a page action row: `ActionSchema`'s own enum, and its refusal
113+
// of `onSuccess` beside `operation: 'update'` (the runner performs the write
114+
// before any executor), whatever executor the row names.
115+
116+
describe("the props gate judges a page action row's `operation` as `ActionSchema` does", () => {
117+
const UPDATE = { operation: 'update', patch: { status: 'done' } };
118+
119+
it.each(ROWS)("REFUSAL: %s with `api`, `operation: 'update'` and `onSuccess` is one finding at `onSuccess`", (_row, node, at, key, subject) => {
120+
const findings = validateComponentProps(stackWith(node({ [key]: 'api', ...UPDATE, onSuccess: { navigate: '/tasks' } })));
121+
expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_PROPS_INVALID, `${at}.onSuccess`]]);
122+
expect(findings[0].message).toContain(
123+
`${ownerOf(subject)} \`onSuccess\` (\`operation: 'update'\`) would follow a result the contract does not declare:`,
124+
);
125+
});
126+
127+
it.each(ROWS)("ACCEPTED CONTROL: %s with `api` and `operation: 'update'` and no `onSuccess` reports nothing", (_row, node, _at, key) => {
128+
expect(validateComponentProps(stackWith(node({ [key]: 'api', ...UPDATE })))).toEqual([]);
129+
});
130+
131+
it.each(ROWS)("REFUSAL: %s with `operation: 'delete'` is one finding at `operation`", (_row, node, at) => {
132+
const findings = validateComponentProps(stackWith(node({ operation: 'delete', patch: { status: 'done' } })));
133+
expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_PROPS_INVALID, `${at}.operation`]]);
134+
expect(findings[0].message).toContain("`operation: 'delete'` is a bulk-def operation with no row-level form");
135+
});
136+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'ui-page-action-row-operation-declared',
7+
surface: 'the `operation` of a page action row (`properties.operation` on an `action:button` or `action:icon`, '
8+
+ 'and `operation` on each `action:group` / `action:menu` member of `properties.actions`): any value but '
9+
+ '`\'update\'`. Also the `onSuccess` of a page action row that declares `operation: \'update\'`, whatever '
10+
+ 'executor the row names (`actionType` on a node, `type` on a member, or none)',
11+
replacement: '`operation: \'update\'` with a `patch` for the declarative single-record write, or no `operation`. '
12+
+ 'A row delete is the object\'s own delete affordance; a bulk def\'s `\'custom\'` has no row form, because the '
13+
+ 'row is already the action. Beside `operation: \'update\'`, no `onSuccess`. ⛔ NOT mechanically convertible: '
14+
+ 'whether a row meant the write or the hop is the author\'s call.',
15+
reason:
16+
'Every page action row forwards `operation` to the console\'s action runner beside its executor, and the runner '
17+
+ 'reads it before any executor: `operation === \'update\'` performs the declarative write through the platform '
18+
+ 'action route, and any other value falls through to the executor and does nothing. The rows typed the key as '
19+
+ 'an open value, so such a value parsed clean, and they never carried `ActionSchema`\'s refusal of `onSuccess` '
20+
+ 'beside `operation: \'update\'`: a row with an `api` or `script` executor, the write and a hop parsed, while '
21+
+ 'the same action was refused. The rows now declare `ActionSchema`\'s own `operation` enum and refuse the '
22+
+ 'pairing with the action\'s own text: the hop would follow the write, which runs no handler and no executor, '
23+
+ 'so its success carries no result `${result.*}` could read. A page\'s `properties` is not parsed on the '
24+
+ 'metadata save or load path; `os validate`\'s props gate parses it against these same rows and reports the '
25+
+ 'refusal at the row\'s `operation` or `onSuccess`. Measured producers: none in this repository; none in '
26+
+ 'objectui\'s action rows at its pin or main.',
27+
acceptanceCriteria:
28+
'Every page action row\'s `operation` is absent or `\'update\'`, and no row with `operation: \'update\'` carries '
29+
+ '`onSuccess`; `os validate` reports no `component-props-invalid` finding at `properties.operation`, '
30+
+ '`properties.actions.N.operation`, or at the `onSuccess` of an `operation: \'update\'` row, and every such row '
31+
+ 'parses as before.',
32+
};

0 commit comments

Comments
 (0)