Skip to content

Commit 168f60f

Browse files
os-zhuangclaude
andauthored
feat(spec,automation): declare bulk intent on update_record / delete_record (#5393) (#5485)
`UpdateRecordConfigSchema` / `DeleteRecordConfigSchema` are strictObjects and neither declared any spelling of bulk intent, while the CRUD executors never passed `options.multi`. The data engine accepts a write only when `where.id` is a scalar or `options.multi` is truthy and throws otherwise, so a predicate update/delete was unreachable from any flow in any app — while the node descriptors advertised "Delete records matching a filter." Declared != enforced (PD #10); #5225's showcase sweep flow was the site it surfaced at. Adds `multi` (boolean, default false) to both config contracts and wires the executors to forward it as `options.multi`. One name for one concept (PD #12): `multi` is the engine's own word for it, so the concept is greppable from node config to driver call. The engine's rejection path is untouched — refusing an undeclared predicate write IS the contract. - spec: `multi` on both schemas, with `bulk`/`all`/`multiple` prescriptions and a wrong-layer answer for `options: { multi: true }` (edit distance reaches `multi` from none of them); the same curation is copied to the registration door in service-automation's engine, per #4001's finding that detection generalizes for free while prose does not. - descriptors: `multi` declared on both designer forms, so the form<->Zod ledger reconciles and the key is authorable in Studio, not only by hand. - tests: `crud-bulk-intent.test.ts`, whose delete double opens with `assertEngineDeleteDispatch` from @objectstack/objectql — the producer's own predicate — so it cannot accept a call the real engine refuses. That is the #5197 blind spot closed for this file: `run-summary.test.ts`'s zero-parameter `async delete() { return false }` accepts predicate deletes AND is invisible to check-engine-double-contract's arity test. Needed a devDependency on @objectstack/objectql (acyclic — objectql's closure does not contain service-automation); the eight service-automation baseline entries citing that missing dep as their blocker are updated to say so. Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D Co-authored-by: Claude <noreply@anthropic.com>
1 parent 96d3d4d commit 168f60f

11 files changed

Lines changed: 609 additions & 17 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+
"@objectstack/service-automation": minor
4+
---
5+
6+
feat(spec,automation): `update_record` / `delete_record` can declare bulk intent with `multi` (#5393)
7+
8+
A flow could not express "write every row this filter matches" — at all, from
9+
any app. `UpdateRecordConfigSchema` / `DeleteRecordConfigSchema` are
10+
`strictObject`s and neither declared any spelling of bulk intent (`multi`,
11+
`bulk`, `all` and `options.multi` were each rejected as an unrecognized key),
12+
and the CRUD executors never passed `options.multi` to the data engine. The
13+
engine accepts a write only when `where.id` is a **scalar** or `options.multi`
14+
is truthy, and throws otherwise — so a predicate `update_record` /
15+
`delete_record` was unreachable, while the node descriptors advertised
16+
`Delete Records` / "Delete records matching a filter." Declared ≠ enforced
17+
(Prime Directive #10); the symptom was #5225's showcase sweep flow, which had
18+
never deleted a record.
19+
20+
**New authorable key — `multi` (boolean, default `false`), on `update_record`
21+
and `delete_record`.** One name for one concept (PD #12): `multi` is what the
22+
data engine has always called it (`EngineUpdateOptions.multi` /
23+
`EngineDeleteOptions.multi`), so the word is the same from node config to
24+
driver call and greps end to end.
25+
26+
```ts
27+
// before — refused by the engine at run time, with no authoring-time signal
28+
{ type: 'delete_record', config: { objectName: 'lead', filter: { stage: 'stale' } } }
29+
30+
// after — the declaration makes the intent explicit and the write reachable
31+
{ type: 'delete_record', config: { objectName: 'lead', filter: { stage: 'stale' }, multi: true } }
32+
```
33+
34+
- **Absent or `false`** — unchanged behaviour. The executor forwards
35+
`multi: false`, so the write must name one row by scalar `id`; anything else
36+
(a predicate, or `id: { $in: [...] }`) is refused by the engine with
37+
`Delete requires an ID or options.multi=true`. **That refusal is the
38+
contract**, not a defect to route around: it is what keeps an undeclared
39+
unbounded write from happening by accident.
40+
- **`true`** — the executor forwards `options.multi: true`, the write lands on
41+
`driver.updateMany` / `deleteMany`, and the step's `acted` metric reports the
42+
affected row count.
43+
44+
Additive and backward compatible: no existing flow changes behaviour, and every
45+
by-id write keeps working untouched.
46+
47+
Two guards are unchanged and worth stating explicitly. The #3810
48+
erased-condition guard still refuses a node whose authored filter condition
49+
interpolated to nothing, `multi` or not — bulk intent says "many rows are
50+
fine", never "a condition may vanish". And `multi: true` with **no** `filter`
51+
is the whole object, by declaration: write the constraint you mean.
52+
53+
Wrong spellings are answered by name rather than by edit distance (which
54+
reaches `multi` from none of them): `bulk` / `all` / `multiple` get the
55+
prescription, and `options: { multi: true }` is called out as the engine's
56+
options bag written at the node's altitude.

‎content/docs/references/automation/builtin-node-config.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ const result = CreateRecordConfigSchema.parse(data);
158158
| :--- | :--- | :--- | :--- |
159159
| **objectName** | `string` | ✅ | Object to delete from |
160160
| **filter** | `Record<string, any>` | optional | Field/value pairs identifying the record(s) to delete |
161+
| **multi** | `boolean` | optional | Declare bulk intent: delete every row the filter matches (default false — a predicate delete without it is refused by the engine) |
161162

162163

163164
---
@@ -240,6 +241,7 @@ const result = CreateRecordConfigSchema.parse(data);
240241
| **objectName** | `string` | ✅ | Object to update |
241242
| **filter** | `Record<string, any>` | optional | Field/value pairs identifying the record(s) to update |
242243
| **fields** | `Record<string, any>` | optional | Field values to write |
244+
| **multi** | `boolean` | optional | Declare bulk intent: update every row the filter matches (default false — a predicate update without it is refused by the engine) |
243245

244246

245247
---

‎packages/services/service-automation/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@objectstack/spec": "workspace:*"
2424
},
2525
"devDependencies": {
26+
"@objectstack/objectql": "workspace:*",
2627
"@types/node": "^26.1.2",
2728
"typescript": "^6.0.3",
2829
"vitest": "^4.1.10"

0 commit comments

Comments
 (0)