@lotics/cli 0.276.0 → 0.278.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -1
- package/dist/src/cli.js +1823 -580
- package/docs/app_bindings.md +181 -0
- package/docs/data_model.md +174 -8
- package/docs/filters.md +114 -0
- package/docs/workflows.md +336 -0
- package/package.json +1 -1
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# Workflows — the steps a workflow runs
|
|
2
|
+
|
|
3
|
+
A workflow is written as a strict subset of JavaScript. The source is never run as JavaScript: a
|
|
4
|
+
save parses it into steps, type-checks it against what its trigger supplies, and stores the steps.
|
|
5
|
+
Only the forms below are accepted; anything else is refused at save with the line, the column and
|
|
6
|
+
what to write instead. A save that succeeds may still return `warnings` — advisory hints such as a
|
|
7
|
+
loop that may never end. Read them.
|
|
8
|
+
|
|
9
|
+
The same grammar serves an automation, a table's lifecycle workflow and an app's workflow. Only an
|
|
10
|
+
automation's source opens with a trigger declaration; a lifecycle workflow takes its trigger from its
|
|
11
|
+
table and event, an app workflow from the app that calls it.
|
|
12
|
+
|
|
13
|
+
## Triggers
|
|
14
|
+
|
|
15
|
+
An automation answers an event outside the records: a schedule, a webhook, an inbound email. A
|
|
16
|
+
reaction to a record being created, updated or deleted is a table lifecycle workflow (see
|
|
17
|
+
**Table lifecycle workflows**).
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
on({ type: "recurring_schedule", cron_expression: "0 9 * * MON" });
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Every source below is typed, so a misspelled member is refused at save rather than read as nothing.
|
|
24
|
+
All three carry `trigger.trigger_id`.
|
|
25
|
+
|
|
26
|
+
| trigger | required config | what the body reads |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `recurring_schedule` | `cron_expression` | `runtime.timezone`; the current time is `now()` |
|
|
29
|
+
| `receive_webhook` | `secret`? | `trigger.method`, `trigger.headers[...]`, `trigger.query[...]`, `trigger.body` (any JSON, or raw text when the request is not JSON), `trigger.received_at` |
|
|
30
|
+
| `receive_gmail_email` / `receive_outlook_email` | `connected_account_id`, `filter` | `trigger.email_id`, `trigger.from`, `trigger.to`, `trigger.cc`, `trigger.bcc`, `trigger.subject`, `trigger.date`, `trigger.body` (plain text), `trigger.reply_to`, `trigger.in_reply_to`, `trigger.attachments` (file refs — assign them straight to a files field). One type serves both providers, so `labels`, `thread_id`, `importance` and `conversation_id` are not readable. |
|
|
31
|
+
|
|
32
|
+
A whole automation:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
on({ type: "recurring_schedule", cron_expression: "0 9 * * MON" });
|
|
36
|
+
|
|
37
|
+
const open = await query_records({
|
|
38
|
+
table_id: "tbl_tickets",
|
|
39
|
+
filters: { node_type: "condition", field_key: "fld_status", operator: "has_any_of", value: ["opt_open"] },
|
|
40
|
+
});
|
|
41
|
+
await send_email({
|
|
42
|
+
to: "team@example.com",
|
|
43
|
+
subject: "Weekly digest",
|
|
44
|
+
body: `${size(open.records)} tickets open as of ${formatDate(now(), "YYYY-MM-DD")}.`,
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Steps
|
|
49
|
+
|
|
50
|
+
| step | syntax |
|
|
51
|
+
|---|---|
|
|
52
|
+
| tool call, kept | `const <id> = await <tool>({ ...inputs });` — also `let x = await …` and `x = await …` |
|
|
53
|
+
| tool call | `await <tool>({ ...inputs });` |
|
|
54
|
+
| agent | `const <id> = await agent({ instructions, input, tools, model, output });` — see **An agent step** |
|
|
55
|
+
| app agent | `const <id> = await app_agent({ alias: "<agent alias>", input: { ... } });` — runs one of the app's declared agents, in an app workflow only, and resolves to its declared outputs or its final text. `wait: false` only starts the run and resolves to `{ run_id }`; the run's failure is then its own, not the workflow's. Refused in a run an agent started. |
|
|
56
|
+
| bind | `const <id> = <expression>;` — evaluated once; later steps read `<id>`. Bind any expression used twice. |
|
|
57
|
+
| if / else | `if (<expr>) { ... } else { ... }` — `else` optional, `else if` chains allowed |
|
|
58
|
+
| switch | `switch (<expr>) { case "X": { ... } default: { ... } }` — string cases only, no fallthrough |
|
|
59
|
+
| for | `for (const <name> of <expr>) { ... }` |
|
|
60
|
+
| wait | `await wait({ duration_in_minutes: 5 });` |
|
|
61
|
+
| wait for an event | `await wait_for_event({ event_type: "webhook", event_ref: <expr>, timeout_in_minutes: 60 });` |
|
|
62
|
+
| wait for an approval | `await wait_for_approval({ approvers: <expr>, prompt: <expr> });` — see **Waiting for an approval** |
|
|
63
|
+
| return | `return({ status: "success" \| "error", message: <expr>, field_errors: { ... }? });` |
|
|
64
|
+
| validate | `validate({ checks: [{ fail_when: <expr>, field_key: "...", message: <expr> }, ...] });` |
|
|
65
|
+
|
|
66
|
+
`return` is a call, not a JavaScript `return` statement. `validate` refuses the write when a check's
|
|
67
|
+
`fail_when` is truthy; the check's `field_key` names the control its message lands on — a field of the
|
|
68
|
+
trigger table in a lifecycle workflow; in an app workflow a declared input, or the `fld_` key of a
|
|
69
|
+
field on a table the body names; in an automation it is not checked.
|
|
70
|
+
|
|
71
|
+
The name in `const x = await tool({...})` is the step's id; later expressions read its output as
|
|
72
|
+
`x.records`, `x.id` and so on. A `// id: my_step` comment directly above a statement sets an explicit
|
|
73
|
+
id; any other `//` comment there becomes the step's description.
|
|
74
|
+
|
|
75
|
+
**`await` inside a statement** runs the call as its own step first, then reads its output where it is
|
|
76
|
+
written: `size((await query_records({...})).records)`, an `if` test, a `return` value, a `for-of`
|
|
77
|
+
iterable, a tool input. `const x = c ? await t({...}) : null;` is stored as the `if` it means. An
|
|
78
|
+
`await` that would run on some paths only — inside `&&`, `||`, `??`, `?.`, a nested ternary, a loop
|
|
79
|
+
test, a `validate` check — is refused; write the `if`.
|
|
80
|
+
|
|
81
|
+
**Names are block-scoped, as in JavaScript** — `const`, `let`, loop items and `catch (e)`: sibling
|
|
82
|
+
blocks may reuse a name and an inner block may shadow an outer one. A local may take a helper's name
|
|
83
|
+
(`const size = 3;`), but calling `size(...)` while it is in scope is refused. Tool names, the reserved
|
|
84
|
+
roots, `linked` and `_s` followed by digits cannot be bound.
|
|
85
|
+
|
|
86
|
+
### Waiting for an approval
|
|
87
|
+
|
|
88
|
+
`wait_for_approval` is the wait worth binding — `wait` and `wait_for_event` carry nothing to read.
|
|
89
|
+
Bind it to branch on the decision:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
const approval = await wait_for_approval({
|
|
93
|
+
approvers: record["fld_approvers"],
|
|
94
|
+
prompt: `Approve the quote for ${record["fld_name"]}?`,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (approval.status == "approved") {
|
|
98
|
+
// the approved path
|
|
99
|
+
} else {
|
|
100
|
+
// the rejected or timed-out path; approval.decision_comment says why
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
It resolves to `{ status: "approved" | "rejected" | "timed_out", decided_by: MemberId | null,
|
|
105
|
+
decided_at: ISO string, decision_comment: string | null }`.
|
|
106
|
+
|
|
107
|
+
`approvers` takes a bare member id (`"mbr_…"`), a bare group id (`"grp_…"`) or a full principal
|
|
108
|
+
(`{ type: "member_group", id: "grp_x" }`), alone or in a list — so a member field's value passes as it
|
|
109
|
+
is.
|
|
110
|
+
|
|
111
|
+
### An agent step
|
|
112
|
+
|
|
113
|
+
`const x = await agent({ instructions, input, tools, model, output });` runs a model with tools as one
|
|
114
|
+
step — summarize a record, classify, draft text.
|
|
115
|
+
|
|
116
|
+
- `instructions` — a fixed string.
|
|
117
|
+
- `input` — an object literal; its fields are expressions, evaluated and handed to the model (`{}` for
|
|
118
|
+
none). It is the ONLY workflow data the agent sees: read `record`, prior steps, `runtime` or the loop
|
|
119
|
+
item here, never inside `instructions` or `tools`, which are fixed literals and read no binding.
|
|
120
|
+
- `tools` — the names of the tools it may call.
|
|
121
|
+
- `model` — optional; omit it to follow the platform's default model.
|
|
122
|
+
- `output` — `{ mode: "text" }` resolves to a string; `{ mode: "object", schema }` to a typed object.
|
|
123
|
+
|
|
124
|
+
Only `x` comes back: in text mode `x` is the string itself, in object mode `x.field` reads a field the
|
|
125
|
+
`schema` declares. The agent's own tool calls are not readable. An agent step cannot give a
|
|
126
|
+
synchronous verdict, so a `before_*` lifecycle workflow refuses it.
|
|
127
|
+
|
|
128
|
+
## Keys, not names
|
|
129
|
+
|
|
130
|
+
1. **Fields and options are named by key.** A field reads as `record["fld_status"]`, an option as
|
|
131
|
+
`"opt_done"`, a linked field as `linked(record["fld_customer"])[0]["fld_name"]`. `get_table` lists
|
|
132
|
+
each field's `key` and each option's `key`; a display name is refused at save with the key to use.
|
|
133
|
+
2. **`==` against a multi-select means "includes".** `record["fld_tags"] == "opt_urgent"` on a
|
|
134
|
+
multi-select is stored as `includes(record["fld_tags"], "opt_urgent")`; either spelling works.
|
|
135
|
+
3. **Text renders labels.** Inside a template or a text tool input, a select, member or link value
|
|
136
|
+
renders its display text: `` `${record["fld_status"]}` `` writes "Done", not `opt_done`.
|
|
137
|
+
|
|
138
|
+
## What an expression reads
|
|
139
|
+
|
|
140
|
+
- `record` — the trigger record (on an update, the record as it now stands).
|
|
141
|
+
- `prev_record` — the record before the change, same shape (updates and deletes only).
|
|
142
|
+
- `changes` — the fields an update changed, a PARTIAL map: `changes["fld_x"]?.next_value` and
|
|
143
|
+
`?.prev_value`, the `?.` required (updates only).
|
|
144
|
+
- `trigger` — what a non-record trigger carries (see **Triggers**). `trigger.data`,
|
|
145
|
+
`trigger.prev_data` and `trigger.changes` are the same three roots spelled long; a saved body reads
|
|
146
|
+
back in the short form.
|
|
147
|
+
- a loop's item name, and `index`, the iteration's 0-based index.
|
|
148
|
+
- `<step id>` — an earlier step's output (`dup.records`).
|
|
149
|
+
- `runtime.timezone`, `runtime.workflow_id`, `runtime.execution_id`, `runtime.workspace_id`,
|
|
150
|
+
`runtime.organization_id`, `runtime.change_origin`, `runtime.triggered_by_member_id`. The current
|
|
151
|
+
time is `now()`.
|
|
152
|
+
|
|
153
|
+
## Paths
|
|
154
|
+
|
|
155
|
+
- `record.fld_x` and `record["fld_x"]` read a field; `[n]` reads a list's n-th item.
|
|
156
|
+
- A path may start at an awaited call: `(await get_record({...})).data["fld_x"]`. It may not start at a
|
|
157
|
+
helper's result — `first(found.records).id` is refused; bind `first(found.records)`, then read it.
|
|
158
|
+
- A record reads in its declared shape wherever it comes from — the trigger, a tool result, a `let`, a
|
|
159
|
+
callback parameter: a single select or member as its key or null, a link as its ids. (The record
|
|
160
|
+
tools called outside a workflow return the stored arrays instead.)
|
|
161
|
+
- `linked(record["fld_link"])[n]["fld_x"]` fetches the n-th linked row and reads a field of it; bare
|
|
162
|
+
`linked(record["fld_link"])` is every linked row, one fetch per row. Its argument is a path ending on
|
|
163
|
+
a link field and carries the guard: `linked(record?.["fld_link"])[0]`.
|
|
164
|
+
- `changes["fld_link"]?.next_value` is the same list of ids; `linked()` over a change is refused —
|
|
165
|
+
read `linked(record["fld_link"])` or `linked(prev_record["fld_link"])`.
|
|
166
|
+
|
|
167
|
+
## Operators and statements
|
|
168
|
+
|
|
169
|
+
Operators in JavaScript precedence: `? :`, `||`, `??`, `&&`, `== !=`, `< <= > >=`, `+ -`, `* /`, prefix
|
|
170
|
+
`! -`.
|
|
171
|
+
|
|
172
|
+
- `??` is `coalesce(left, right)`; `?.` reads through a null (`a?.b`, `a?.[0]`, and `s?.trim()` for the
|
|
173
|
+
method names listed under **Helpers**). `x?.()` is refused: helpers and tools are not values.
|
|
174
|
+
- Templates use backticks and `${...}`.
|
|
175
|
+
- Destructuring — `const { fld_status, fld_name } = record;` binds each name. Renames
|
|
176
|
+
(`{ fld_status: s }`), quoted keys (`{ "fld_ref": ref }`), array patterns with holes
|
|
177
|
+
(`const [first, , third] = xs;`) and defaults (`{ fld_note = "" }`, which apply on null too) work on
|
|
178
|
+
`const`, `let` and `for (const { id } of rows)`. A tool result destructures directly:
|
|
179
|
+
`const { records } = await query_records({...});`. `const a = 1, b = 2;` declares both. Nested
|
|
180
|
+
patterns and rest are refused.
|
|
181
|
+
- Spread — `[...a, b]` and `{ ...a, b: 1 }` (later keys win). Refused inside a tool input: bind the
|
|
182
|
+
merged value first and pass the binding.
|
|
183
|
+
- Shorthand — `{ table_id }` is `{ table_id: table_id }`.
|
|
184
|
+
- `xs.push(a, b);` appends, also on a key (`o.items.push(v)`); `o.a.b = v;` sets a nested key;
|
|
185
|
+
`x ??= v`, `x ||= v` and `x &&= v` assign. A `const` may be pushed to or have a key set, as in
|
|
186
|
+
JavaScript; a loop item is read-only.
|
|
187
|
+
- `undefined` is the same value as `null`. `=== undefined` and `!== undefined` are refused, since they
|
|
188
|
+
cannot tell a missing key from null: test `isNull(x)`, or `includes(keys(o), "k")` for whether a key
|
|
189
|
+
was sent.
|
|
190
|
+
- A filter node may leave out `node_type` when its keys say which it is: `field_key` / `operator` /
|
|
191
|
+
`value` a condition, `logic` / `children` a group, `path` / `condition` a traversal.
|
|
192
|
+
- `function name(p = <default>) { return <expr>; }` — top level only, expanded at every call (a call
|
|
193
|
+
may come before it). Every parameter needs a default, which gives it its type; the body is one
|
|
194
|
+
`return <expr>;` reading only its parameters, helpers and the reserved roots — never the caller's
|
|
195
|
+
names, nor `index`. No recursion; a function never called is refused. A body read back shows the
|
|
196
|
+
expression at each call, not the `function`.
|
|
197
|
+
|
|
198
|
+
**try / catch.** `try { ... } catch (e) { ... }` catches a tool error or an expression error inside the
|
|
199
|
+
body; `e` is `{ message, type, step_id?, detail? }`. A failed `validate` and a `return` are not
|
|
200
|
+
errors — they end the workflow — and a step that runs after a wait inside the `try` is outside it.
|
|
201
|
+
`finally` is refused: put always-run steps after the `try`.
|
|
202
|
+
|
|
203
|
+
**Loops.** `for-of`, `while (cond) { ... }`, `do { ... } while (cond);` and
|
|
204
|
+
`for (let i = 0; i < n; i++) { ... }`, each capped at 10,000 iterations. `break;` and `continue;` act
|
|
205
|
+
on the innermost loop; labels and `for-in` are refused.
|
|
206
|
+
|
|
207
|
+
**`let`.** `let x = <expr>;` declares a block-scoped variable; the initializer is required
|
|
208
|
+
(`let x = null;`). Reassign with `=`, `+=`, `-=`, `*=`, `/=`, `%=`, `??=`, `++` and `--`. Re-declaring in
|
|
209
|
+
the same block is refused; shadowing in a nested block is allowed. A `let` keeps its value across a
|
|
210
|
+
`wait`, `wait_for_event` or `wait_for_approval`.
|
|
211
|
+
|
|
212
|
+
**Refused:** regex, `new`, `typeof`, `instanceof`, `in`, `delete`, `void`, rest elements, computed keys,
|
|
213
|
+
classes, `throw`, `import`, `export`, and a function as a value (`const f = (x) => ...`).
|
|
214
|
+
|
|
215
|
+
## Helpers
|
|
216
|
+
|
|
217
|
+
Called as `size(arr)`. The method form works only where the name is also a JavaScript method —
|
|
218
|
+
`x.trim()`, `arr.includes(v)`, `arr.at(-1)`, `arr.map(fn)`, `s.split(",")`; `arr.size()` is refused.
|
|
219
|
+
|
|
220
|
+
- **Null and type**: `isNull`, `isNotNull`, `isEmpty`, `isString`, `isNumber`, `isBoolean`, `isArray`,
|
|
221
|
+
`isObject`, `coalesce`, `get`, `toNumber`, `toString`, `typeOf`, `parseJson`, `toJson`
|
|
222
|
+
- **Lists**: `size`, `first`, `requireFirst` (the first item, refusing an empty list — after a
|
|
223
|
+
`validate` on the size it saves an `if`), `last`, `nth`, `at` (`at(arr, -1)` counts from the end),
|
|
224
|
+
`includes`, `filter`, `find`, `some`, `every`, `pluck`, `sortBy`, `groupBy`, `countBy`, `unique`,
|
|
225
|
+
`uniqueBy`, `compact` (drops falsy items), `flatten`, `reverse`, `slice`, `concat`, `difference`,
|
|
226
|
+
`differenceBy`, `intersection`, `intersectionBy`, `list`, `reduce(arr, (acc, x) => ..., initial)`,
|
|
227
|
+
`range(end)` / `range(start, end)`
|
|
228
|
+
- **Math**: `sum`, `sumBy`, `mean`, `meanBy`, `minBy`, `maxBy`, `round`, `ceil`, `floor`, `min`, `max`,
|
|
229
|
+
`abs`, `mod`, `pow`, `sqrt`, `clamp`, `percentage`
|
|
230
|
+
- **Text**: `upper`, `lower`, `capitalize`, `trim`, `contains`, `startsWith`, `endsWith`, `replace`,
|
|
231
|
+
`replaceAll`, `substring`, `length`, `split`, `join`, `padStart(str, length, char)`,
|
|
232
|
+
`padEnd(str, length, char)`, `formatNumber(x, decimals)` (ungrouped, as `x.toFixed`),
|
|
233
|
+
`formatDecimal(x, decimals, locale)` (grouped: `formatDecimal(151000, 0, "vi-VN")` is `151.000`),
|
|
234
|
+
`numberToWords` (an integer in Vietnamese words)
|
|
235
|
+
- **Objects**: `keys`, `values`, `entries`, `nonNullKeys`, `pick`, `omit`, `merge`
|
|
236
|
+
- **Dates**, in the workspace's timezone: `now`, `formatDate`, `parseDate`, `addDays`, `subDays`,
|
|
237
|
+
`addHours`, `subHours`, `addMinutes`, `subMinutes`, `startOfDay`, `endOfDay`,
|
|
238
|
+
`differenceInCalendarDays`, `differenceInHours`, `differenceInMinutes`, `isBefore`, `isAfter`,
|
|
239
|
+
`isSameDay`, `isToday`, `isWithinRange`
|
|
240
|
+
- **Other**: `formatCurrency(amount, locale, currency)`, `randomNumber(len)`,
|
|
241
|
+
`randomAlphaNumeric(len)`, `sample(items)` (a random item)
|
|
242
|
+
|
|
243
|
+
A date helper given a null or empty date returns null, so guard its result before comparing or
|
|
244
|
+
writing it: `differenceInCalendarDays(a, b) ?? 0`.
|
|
245
|
+
|
|
246
|
+
`filter`, `find`, `some`, `every`, `sortBy`, `pluck`, `sumBy`, `meanBy`, `minBy`, `maxBy`, `groupBy`,
|
|
247
|
+
`countBy`, `uniqueBy`, `differenceBy` and `intersectionBy` take a path string
|
|
248
|
+
(`filter(record.items, "Status", "open")`) or a callback
|
|
249
|
+
(`filter(record.items, x => x.Status == "open" && x.Amount > 100)`); `reduce` takes a callback and an
|
|
250
|
+
initial value. A callback gets `(item, idx)` (`reduce`: `(acc, item, idx)`) and reads `record`,
|
|
251
|
+
`runtime`, the names in scope, loop items and an enclosing callback's parameters. Its body is one
|
|
252
|
+
expression — `(x) => <expr>`, `(x) => { return <expr>; }` or `function (x) { return <expr>; }`;
|
|
253
|
+
several statements, `async` and named function expressions are refused.
|
|
254
|
+
|
|
255
|
+
## Writing a field: null and undefined
|
|
256
|
+
|
|
257
|
+
In `update_records`' `set` and `create_records`' `records`:
|
|
258
|
+
|
|
259
|
+
- `null` clears the field.
|
|
260
|
+
- `undefined`, or leaving the key out, leaves the field as it is.
|
|
261
|
+
|
|
262
|
+
So passing a read that may be null (`record["fld_x"]`) keeps a value when there is one and clears the
|
|
263
|
+
field when there is none. Use `coalesce(x, fallback)` only for a real fallback value.
|
|
264
|
+
|
|
265
|
+
## Examples
|
|
266
|
+
|
|
267
|
+
Refuse a duplicate before it is created (a `before_create` lifecycle workflow; the keys come from
|
|
268
|
+
`get_table`):
|
|
269
|
+
|
|
270
|
+
```
|
|
271
|
+
const dup = await query_records({
|
|
272
|
+
table_id: "tbl_orders",
|
|
273
|
+
filters: { node_type: "group", logic: "and", children: [
|
|
274
|
+
{ node_type: "condition", field_key: "fld_ref", operator: "equals", value: record["fld_ref"] },
|
|
275
|
+
{ node_type: "condition", field_key: "fld_closed_at", operator: "is_empty" },
|
|
276
|
+
]},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
validate({ checks: [{
|
|
280
|
+
fail_when: size(dup.records) > 0,
|
|
281
|
+
field_key: "fld_ref",
|
|
282
|
+
message: "An open order already has this reference.",
|
|
283
|
+
}]});
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Compare a link by id. Display text is not unique, and a link reads as a list of ids — `==` against a
|
|
287
|
+
string is a type error:
|
|
288
|
+
|
|
289
|
+
```
|
|
290
|
+
const found = await query_records({
|
|
291
|
+
table_id: "tbl_customers",
|
|
292
|
+
filters: { node_type: "condition", field_key: "fld_name", operator: "equals", value: "ACME Corp" },
|
|
293
|
+
});
|
|
294
|
+
const customer = first(found.records);
|
|
295
|
+
|
|
296
|
+
if (customer && includes(record["fld_customer"], customer.id)) {
|
|
297
|
+
// …
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
## Table lifecycle workflows
|
|
302
|
+
|
|
303
|
+
A lifecycle workflow is bound to one table and one event, and its source has no `on({...})` line:
|
|
304
|
+
|
|
305
|
+
- `before_create` / `after_create` — a create, and a draft's submit (the moment it becomes a record).
|
|
306
|
+
- `before_update` / `after_update` — every field edit, a draft's included: a `before_update` check
|
|
307
|
+
runs while someone fills in a draft, not only once it is submitted.
|
|
308
|
+
- `before_delete` / `after_delete`.
|
|
309
|
+
|
|
310
|
+
A `before_*` workflow runs before the write commits and may refuse it, with `validate` or
|
|
311
|
+
`return({ status: "error", ... })`; its errors come back as field errors on the write. It cannot
|
|
312
|
+
`wait`, `wait_for_event`, `wait_for_approval` or run an `agent` step. An `after_*` workflow runs after
|
|
313
|
+
the commit, with every step; its errors are logged and never fail the write. An `after_update`
|
|
314
|
+
workflow that writes its own table saves with a `loop_potential` warning: its write fires it again.
|
|
315
|
+
|
|
316
|
+
| reads | on |
|
|
317
|
+
|---|---|
|
|
318
|
+
| `record` | every event — the new record on create, the record as it now stands on update, the deleted record on delete |
|
|
319
|
+
| `prev_record` | updates and deletes — the record before |
|
|
320
|
+
| `changes["fld_x"]?.next_value` / `?.prev_value` | updates — the fields that changed |
|
|
321
|
+
|
|
322
|
+
### Gating with `if_source`
|
|
323
|
+
|
|
324
|
+
`if_source` is one JavaScript expression, reading what the body reads, tested before the workflow
|
|
325
|
+
runs. When it is falsy the workflow does not run at all — no execution, no log. Use it so a workflow
|
|
326
|
+
that cares about some events only skips the rest:
|
|
327
|
+
|
|
328
|
+
- a status reaching an option: `changes["fld_status"]?.next_value == "opt_done"`
|
|
329
|
+
- a field cleared: `isNull(record["fld_assignee"])`
|
|
330
|
+
- a direct write, not a workflow's cascade:
|
|
331
|
+
`includes(["member", "chat_agent", "api_client"], runtime.change_origin.type)` — name every
|
|
332
|
+
direct-write origin rather than testing for one: `member` is a person in the browser, and the same
|
|
333
|
+
edit over MCP, the CLI or the API arrives as `api_client`.
|
|
334
|
+
|
|
335
|
+
In `if_source`, `runtime.change_origin` is the origin of the write that fired it; in the body, it is
|
|
336
|
+
the workflow's own.
|