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
14 changes: 14 additions & 0 deletions .changeset/17319-action-bulk-dispatch-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
---

An action can now **declare which bulk dispatch contract its body is written for**, and a list view that wires it the other way is refused at authoring time instead of handing the body the opposite input in silence.

A list view has always been able to wire the same declared action two ways, and the two deliver opposite shapes to the same body: `bulkActions: ['<name>']` promotes the action to a def and dispatches it **once per selected row** (that row's `recordId`, no `_selectedIds`), while a `bulkActionDefs` entry with `execution: 'aggregate'` makes **one** dispatch for the whole selection (every id in `params._selectedIds`, no `recordId`). The action declared neither, so both mismatches failed quietly and in opposite directions — an aggregate body wired bare-string read `_selectedIds` as `undefined`, fell into its single-record branch and reported success for one row out of ten; a per-record body wired aggregate found no `recordId` and threw its own "nothing selected", which reads like a selection bug. Nothing caught either: `recordId` and `_selectedIds` are both built-in action params (ADR-0104), so the strict params gate admits either bag without a word, and the wiring lives on the view while the declaration would live on the action, so no single parse has both halves.

- **`ActionSchema` gains `execution`**, and it is `bulkActionDefs`' own vocabulary — the same key, the same two values (`'perRecord' | 'aggregate'`), the def's `BulkActionExecutionSchema` **imported rather than re-declared**, so there is no second spelling to drift. The near-miss keys (`dispatch`, `dispatchContract`, `bulkExecution`, `bulkDispatch`) rename onto it; ⛔ `mode` deliberately does **not**, because on an action `mode` is a declared key of its own.
- **`@objectstack/lint` gains `action-dispatch-contract-mismatch`** (severity `error`), a member of the reference-integrity suite, so it runs on `os validate`, `os lint` and `os compile` at once. It names the action, the view and **both** contracts — the declared one and the wired one — and offers both ends of the fix, because which end is wrong is the author's call. It judges every list tier: a view's `list`, each `listViews.<key>`, and an object's own `listViews`.
- **⛔ No silent default.** `execution` is optional and an action that omits it is *undeclared*, never defaulted to a contract — which is also the honest state of a body written to serve both (it reads `recordId` *and* `_selectedIds`), and why no third enum member was added. Existing sources are migrated by the new ADR-0087 semantic entry `action-bulk-dispatch-contract-undeclared`, which derives the declaration from the view wirings where they are unambiguous and hands back a structured TODO where one action is wired both ways.

Nothing about dispatch changes: this release adds a declaration and a build-time refusal measured against it. Existing apps are unaffected until they declare the key — the new rule has nothing to judge on an undeclared action, by construction.
1 change: 1 addition & 0 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ const result = ApiMethod.parse(data);
| **body** | `{ language: 'expression'; source: string } \| { language: 'js'; source: string; capabilities?: Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log'>[]; timeoutMs?: integer; … }` | optional | Action body — expression (L1) or sandboxed JS (L2). Only used when type is `script`. |
| **operation** | `Enum<'update'>` | optional | The declarative single-record field write, mirroring a list view's `bulkActionDefs`: `'update'` applies `patch` (merged under the collected `params`) to the current record on the data plane AS THE CALLER — never system-elevated — so the caller's permissions, the object's hooks and its validations fire as for a user edit. `type` stays at its default `'script'` (the platform action route the write is performed on); `target`/`body`/`method`/`bodyExtra` are refused beside it. `'delete'` and `'custom'` have no row-level form. |
| **patch** | `Record<string, any>` | optional | For `operation: 'update'` — static field values written to the current record, merged UNDER the user-supplied `params` so a fixed value can be declared without exposing it in the dialog. Written on the data plane as the caller: object permissions, hooks and validations fire as for a user edit. Refused on an action without `operation: 'update'` (it would be silently dropped). |
| **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | The bulk dispatch contract this action's BODY is written for, in `bulkActionDefs`' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's `recordId` (the view's `bulkActions: ['<name>']` bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in `params._selectedIds` (a `bulkActionDefs` entry with `execution: 'aggregate'`). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by `@objectstack/lint` (`action-dispatch-contract-mismatch`). |
| **execute** | `never` | optional | [REMOVED] `execute` was removed in @objectstack/spec 17 — use `target`. Rename the key; the value (a handler / flow / URL ref) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record<string, string>; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). |
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) |
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/kernel/metadata-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ const result = MetadataBulkResultSchema.parse(data);
| **body** | `{ language: 'expression'; source: string } \| { language: 'js'; source: string; capabilities?: Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log'>[]; timeoutMs?: integer; … }` | optional | Action body — expression (L1) or sandboxed JS (L2). Only used when type is `script`. |
| **operation** | `Enum<'update'>` | optional | The declarative single-record field write, mirroring a list view's `bulkActionDefs`: `'update'` applies `patch` (merged under the collected `params`) to the current record on the data plane AS THE CALLER — never system-elevated — so the caller's permissions, the object's hooks and its validations fire as for a user edit. `type` stays at its default `'script'` (the platform action route the write is performed on); `target`/`body`/`method`/`bodyExtra` are refused beside it. `'delete'` and `'custom'` have no row-level form. |
| **patch** | `Record<string, any>` | optional | For `operation: 'update'` — static field values written to the current record, merged UNDER the user-supplied `params` so a fixed value can be declared without exposing it in the dialog. Written on the data plane as the caller: object permissions, hooks and validations fire as for a user edit. Refused on an action without `operation: 'update'` (it would be silently dropped). |
| **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | The bulk dispatch contract this action's BODY is written for, in `bulkActionDefs`' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's `recordId` (the view's `bulkActions: ['<name>']` bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in `params._selectedIds` (a `bulkActionDefs` entry with `execution: 'aggregate'`). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by `@objectstack/lint` (`action-dispatch-contract-mismatch`). |
| **execute** | `never` | optional | [REMOVED] `execute` was removed in @objectstack/spec 17 — use `target`. Rename the key; the value (a handler / flow / URL ref) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record<string, string>; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). |
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) |
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/ui/action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const result = ActionSchema.parse(data);
| **body** | `{ language: 'expression'; source: string } \| { language: 'js'; source: string; capabilities?: Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log'>[]; timeoutMs?: integer; … }` | optional | Action body — expression (L1) or sandboxed JS (L2). Only used when type is `script`. |
| **operation** | `Enum<'update'>` | optional | The declarative single-record field write, mirroring a list view's `bulkActionDefs`: `'update'` applies `patch` (merged under the collected `params`) to the current record on the data plane AS THE CALLER — never system-elevated — so the caller's permissions, the object's hooks and its validations fire as for a user edit. `type` stays at its default `'script'` (the platform action route the write is performed on); `target`/`body`/`method`/`bodyExtra` are refused beside it. `'delete'` and `'custom'` have no row-level form. |
| **patch** | `Record<string, any>` | optional | For `operation: 'update'` — static field values written to the current record, merged UNDER the user-supplied `params` so a fixed value can be declared without exposing it in the dialog. Written on the data plane as the caller: object permissions, hooks and validations fire as for a user edit. Refused on an action without `operation: 'update'` (it would be silently dropped). |
| **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | The bulk dispatch contract this action's BODY is written for, in `bulkActionDefs`' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's `recordId` (the view's `bulkActions: ['<name>']` bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in `params._selectedIds` (a `bulkActionDefs` entry with `execution: 'aggregate'`). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by `@objectstack/lint` (`action-dispatch-contract-mismatch`). |
| **execute** | `never` | optional | [REMOVED] `execute` was removed in @objectstack/spec 17 — use `target`. Rename the key; the value (a handler / flow / URL ref) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record<string, string>; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). |
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) |
Expand Down
43 changes: 43 additions & 0 deletions content/docs/ui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,49 @@ action — one that acts on a whole selection and has no single-record home by
construction — exist at all.
</Callout>

## Declare the bulk dispatch contract

A list view can wire the same action two ways, and the two hand your body
**opposite** input:

| wiring | dispatches | the body receives |
| --- | --- | --- |
| `bulkActions: ['<name>']` | once **per selected row** | that row's `recordId`, no `_selectedIds` |
| `bulkActionDefs: [{ name: '<name>', operation: 'custom', execution: 'aggregate' }]` | **once** for the whole selection | `params._selectedIds: string[]`, no `recordId` |

Say which one you wrote the body for, with the same key and the same two values
the def uses:

```typescript
defineAction({
name: 'export_zip',
type: 'api',
target: '/api/v1/export/zip',
execution: 'aggregate', // this body reads params._selectedIds
});
```

`os validate` / `os lint` / `os build` then refuse a list view that wires it the
other way (`action-dispatch-contract-mismatch`), naming the action, the view and
both contracts. Without the declaration both mismatches fail **silently, in
opposite directions**: an aggregate body wired bare-string reads `_selectedIds`
as `undefined`, takes its single-record branch and reports success for one row
out of ten; a per-record body wired aggregate finds no `recordId` and throws its
own "nothing selected", which reads like a selection bug. Nothing else can catch
it — `recordId` and `_selectedIds` are both built-in action params, so the
strict params gate admits either bag without a word.

<Callout type="warn">
There is **no default**. An action that omits `execution` is checked against
neither wiring — which is also the honest declaration for a body written to
serve both contracts (it reads `recordId` *and* `_selectedIds`). There is no
third value for "both": one call and N calls have different side effects, so
if the two wirings want different behaviour they are two actions.
Upgrading an existing app? `os migrate meta` emits the
`action-bulk-dispatch-contract-undeclared` TODO, which derives the
declaration from the wirings you already have.
</Callout>

## Collect input and shape the UX

- **`params`** — prompt the user for input before execution. Prefer
Expand Down
22 changes: 22 additions & 0 deletions content/docs/ui/views.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,28 @@ Aggregate is the def form, which is where `execution` lives. A def that says
time: the renderer has no action attached to such a def, so it used to render a
button that reported success for every selected record and did nothing.

**The action gets a say, and the two ends are checked against each other.** An
action declares the contract its body was written for with the same key and the
same two values the def uses — `execution: 'perRecord' | 'aggregate'` (see
[Actions](/docs/ui/actions#declare-the-bulk-dispatch-contract)). Wiring a
declared action the other way is refused by `os validate` / `os lint` /
`os build` with `action-dispatch-contract-mismatch`, naming the action, the
view and both contracts:

```typescript
// actions: { name: 'export_zip', type: 'api', execution: 'aggregate', … }

bulkActions: ['export_zip'], // ⛔ refused — the bare string is the per-record
// contract; this body reads `_selectedIds`
bulkActionDefs: [
{ name: 'export_zip', operation: 'custom', execution: 'aggregate' }, // ✅
]
```

There is **no default**: an action that omits `execution` is checked against
neither wiring, which is also the right declaration for a body deliberately
written to serve both.

**Gating a def by capability.** An inline def takes
`requiredPermissions: string[]` with `action.requiredPermissions` semantics —
absent/empty always passes, several entries AND, unknown caller capabilities
Expand Down
25 changes: 22 additions & 3 deletions examples/app-showcase/src/ui/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ export const MarkDoneAction = defineAction({
"return { ok: true, id: id };",
capabilities: ['api.write'],
},
// #17319 — the dispatch contract this BODY is written for. It reads
// `ctx.recordId` and throws 'No record to mark done' without one, so an
// aggregate wiring would fail on every click with a message that reads like
// a selection bug. Declaring it makes that wiring a build-time refusal
// (`action-dispatch-contract-mismatch`) instead.
execution: 'perRecord',
successMessage: 'Task marked done.',
// Hide once the task is complete. Gate on `record.done` (the boolean this
// action sets) so the button vanishes after a successful click and stays
Expand Down Expand Up @@ -127,6 +133,11 @@ export const RecalcEstimateAction = defineAction({
objectName: task,
type: 'api',
target: '/api/v1/showcase/recalc',
// #17319 — one POST per record; the endpoint's per-record branch reads the
// single id. Its aggregate twin below is a SEPARATE action against the same
// endpoint, which is what the platform used to require: one body, one
// contract, and until this key no way to say which.
execution: 'perRecord',
successMessage: 'Estimate recalculated.',
locations: ['record_more', 'record_section'],
// The endpoint is record-scoped and rejects a body without an id. On a
Expand All @@ -139,9 +150,11 @@ export const RecalcEstimateAction = defineAction({

/**
* api, AGGREGATE-dispatched — the `execution: 'aggregate'` specimen
* (objectui#3139). The action itself is an ordinary api action; what makes it
* aggregate is the VIEW's `bulkActionDefs` entry naming it with
* `execution: 'aggregate'` (see `task.view.ts` → `bulk_actions`). The
* (objectui#3139). Since #17319 the ACTION declares the contract its body is
* written for (`execution: 'aggregate'`, below) and the VIEW performs the
* dispatch through a `bulkActionDefs` entry naming it with the same key and
* value (see `task.view.ts` → `bulk_actions`); a bare-string wiring of it is
* now refused at authoring time instead of quietly recalculating one row. The
* renderer then dispatches it ONCE for the whole selection, with every
* selected id in `params._selectedIds` — the recalc endpoint's batch branch
* recomputes all of them in that single call (the "one zip for N devices"
Expand Down Expand Up @@ -175,6 +188,12 @@ export const RecalcSelectionAction = defineAction({
objectName: task,
type: 'api',
target: '/api/v1/showcase/recalc',
// #17319 — ONE dispatch for the whole selection, every id in
// `params._selectedIds`. The view's `bulkActionDefs` entry still performs
// the dispatch; this declares what the body was written to receive, so a
// bare-string `bulkActions` wiring of it is refused rather than silently
// recalculating one row out of ten.
execution: 'aggregate',
successMessage: 'Estimates recalculated for the whole selection.',
locations: ['record_more'],
recordIdParam: 'recordId',
Expand Down
9 changes: 9 additions & 0 deletions examples/app-showcase/src/ui/views/field-zoo.view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,15 @@ export const FieldZooViews = defineView({
* specimens it acts on Full and reports Minimal as skipped, rather than
* quietly including it.
*
* #17319 — `showcase_zoo_visible_string` is wired BOTH ways in this file: as a
* bare string in the two `bulkActions` lists above (per-record) and here as an
* aggregate def. That is deliberate and it stays UNDECLARED: its body
* (`predicate-matrix.action.ts`) reads `ctx.recordId` AND `input._selectedIds`
* and copes with either, which is the one honest reason to omit `action.execution`.
* ⛔ Declaring either contract on it would make the OTHER wiring a lint error
* (`action-dispatch-contract-mismatch`) — there is no third enum member for
* "both", and no silent default for the omission.
*
* `execution: 'aggregate'` is not decoration — a `custom` def without it is
* a no-op the parser refuses outright ("the button runs, reports success
* for every selected record, and does nothing"). Aggregate means ONE
Expand Down
2 changes: 2 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,8 @@ export type {
} from './validate-object-field-refs.js';

export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js';
export { validateActionDispatchContract, ACTION_DISPATCH_CONTRACT_MISMATCH } from './validate-action-dispatch-contract.js';
export type { ActionDispatchContract, ActionDispatchContractFinding } from './validate-action-dispatch-contract.js';
export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-action-name-refs.js';

export { validateActionLocations, ACTION_NO_PLACEMENT } from './validate-action-locations.js';
Expand Down
Loading
Loading