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/flow-crud-bulk-intent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
"@objectstack/spec": minor
"@objectstack/service-automation": minor
---

feat(spec,automation): `update_record` / `delete_record` can declare bulk intent with `multi` (#5393)

A flow could not express "write every row this filter matches" — at all, from
any app. `UpdateRecordConfigSchema` / `DeleteRecordConfigSchema` are
`strictObject`s and neither declared any spelling of bulk intent (`multi`,
`bulk`, `all` and `options.multi` were each rejected as an unrecognized key),
and the CRUD executors never passed `options.multi` to the data engine. The
engine accepts a write only when `where.id` is a **scalar** or `options.multi`
is truthy, and throws otherwise — so a predicate `update_record` /
`delete_record` was unreachable, while the node descriptors advertised
`Delete Records` / "Delete records matching a filter." Declared ≠ enforced
(Prime Directive #10); the symptom was #5225's showcase sweep flow, which had
never deleted a record.

**New authorable key — `multi` (boolean, default `false`), on `update_record`
and `delete_record`.** One name for one concept (PD #12): `multi` is what the
data engine has always called it (`EngineUpdateOptions.multi` /
`EngineDeleteOptions.multi`), so the word is the same from node config to
driver call and greps end to end.

```ts
// before — refused by the engine at run time, with no authoring-time signal
{ type: 'delete_record', config: { objectName: 'lead', filter: { stage: 'stale' } } }

// after — the declaration makes the intent explicit and the write reachable
{ type: 'delete_record', config: { objectName: 'lead', filter: { stage: 'stale' }, multi: true } }
```

- **Absent or `false`** — unchanged behaviour. The executor forwards
`multi: false`, so the write must name one row by scalar `id`; anything else
(a predicate, or `id: { $in: [...] }`) is refused by the engine with
`Delete requires an ID or options.multi=true`. **That refusal is the
contract**, not a defect to route around: it is what keeps an undeclared
unbounded write from happening by accident.
- **`true`** — the executor forwards `options.multi: true`, the write lands on
`driver.updateMany` / `deleteMany`, and the step's `acted` metric reports the
affected row count.

Additive and backward compatible: no existing flow changes behaviour, and every
by-id write keeps working untouched.

Two guards are unchanged and worth stating explicitly. The #3810
erased-condition guard still refuses a node whose authored filter condition
interpolated to nothing, `multi` or not — bulk intent says "many rows are
fine", never "a condition may vanish". And `multi: true` with **no** `filter`
is the whole object, by declaration: write the constraint you mean.

Wrong spellings are answered by name rather than by edit distance (which
reaches `multi` from none of them): `bulk` / `all` / `multiple` get the
prescription, and `options: { multi: true }` is called out as the engine's
options bag written at the node's altitude.
2 changes: 2 additions & 0 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ const result = CreateRecordConfigSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **objectName** | `string` | ✅ | Object to delete from |
| **filter** | `Record<string, any>` | optional | Field/value pairs identifying the record(s) to delete |
| **multi** | `boolean` | optional | Declare bulk intent: delete every row the filter matches (default false — a predicate delete without it is refused by the engine) |


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


---
Expand Down
1 change: 1 addition & 0 deletions packages/services/service-automation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@objectstack/spec": "workspace:*"
},
"devDependencies": {
"@objectstack/objectql": "workspace:*",
"@types/node": "^26.1.2",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
Expand Down
Loading
Loading