@lotics/app-sdk 0.100.1 → 0.101.1
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 +32 -47
- package/dist/agent_stream.d.ts +131 -0
- package/dist/ask_ai.d.ts +27 -0
- package/dist/attachments.d.ts +58 -0
- package/dist/chunk-ARV5FAU5.js +1132 -0
- package/dist/comments.d.ts +89 -0
- package/dist/error_report.d.ts +9 -0
- package/dist/folder_pick.d.ts +8 -0
- package/dist/geolocation.d.ts +42 -0
- package/dist/hooks.d.ts +251 -0
- package/dist/{src/index.d.ts → index.d.ts} +13 -22
- package/dist/index.js +31331 -0
- package/dist/index.js.LEGAL.txt +11 -0
- package/dist/members.d.ts +32 -0
- package/dist/mock.d.ts +37 -0
- package/dist/mount.d.ts +19 -0
- package/dist/new_record.d.ts +37 -0
- package/dist/open_app.d.ts +12 -0
- package/dist/open_external.d.ts +10 -0
- package/dist/overlay.d.ts +25 -0
- package/dist/queries.d.ts +231 -0
- package/dist/recording.d.ts +47 -0
- package/dist/recording_state.d.ts +43 -0
- package/dist/rename_file.d.ts +13 -0
- package/dist/router.d.ts +10 -0
- package/dist/router.js +97 -0
- package/dist/row.d.ts +87 -0
- package/dist/rpc.d.ts +114 -0
- package/dist/select.d.ts +24 -0
- package/dist/shared_types.d.ts +8 -0
- package/dist/store.d.ts +43 -0
- package/dist/types.d.ts +36 -0
- package/dist/upload/optimize.d.ts +30 -0
- package/dist/upload/pipeline.d.ts +36 -0
- package/dist/upload/transport.d.ts +19 -0
- package/dist/url_params.d.ts +55 -0
- package/dist/use_recents.d.ts +15 -0
- package/dist/{src/use_url_state.d.ts → use_url_state.d.ts} +0 -2
- package/dist/viewer.d.ts +41 -0
- package/dist/written.d.ts +79 -0
- package/docs/ai.md +74 -133
- package/docs/data_fetching.md +209 -290
- package/docs/files.md +61 -51
- package/docs/members_and_options.md +92 -62
- package/docs/mutations.md +136 -205
- package/docs/navigation_and_state.md +26 -35
- package/docs/queries.md +144 -207
- package/docs/recipes.md +21 -45
- package/docs/runtime.md +74 -137
- package/docs/security.md +8 -11
- package/docs/workflows.md +189 -174
- package/package.json +27 -28
- package/dist/src/agent_stream.d.ts +0 -200
- package/dist/src/agent_stream.js +0 -314
- package/dist/src/ask_ai.d.ts +0 -40
- package/dist/src/ask_ai.js +0 -35
- package/dist/src/attachments.d.ts +0 -68
- package/dist/src/attachments.js +0 -93
- package/dist/src/comments.d.ts +0 -127
- package/dist/src/comments.js +0 -192
- package/dist/src/download.js +0 -54
- package/dist/src/geolocation.d.ts +0 -64
- package/dist/src/geolocation.js +0 -96
- package/dist/src/hooks.d.ts +0 -781
- package/dist/src/hooks.js +0 -860
- package/dist/src/index.js +0 -34
- package/dist/src/members.d.ts +0 -105
- package/dist/src/members.js +0 -62
- package/dist/src/mock.d.ts +0 -118
- package/dist/src/mock.js +0 -124
- package/dist/src/mount.d.ts +0 -47
- package/dist/src/mount.js +0 -34
- package/dist/src/new_record.d.ts +0 -74
- package/dist/src/new_record.js +0 -117
- package/dist/src/open_app.d.ts +0 -15
- package/dist/src/open_app.js +0 -18
- package/dist/src/open_external.d.ts +0 -16
- package/dist/src/open_external.js +0 -19
- package/dist/src/recording.d.ts +0 -59
- package/dist/src/recording.js +0 -30
- package/dist/src/recording_state.d.ts +0 -59
- package/dist/src/recording_state.js +0 -94
- package/dist/src/router.d.ts +0 -17
- package/dist/src/router.js +0 -144
- package/dist/src/row.d.ts +0 -159
- package/dist/src/row.js +0 -254
- package/dist/src/rpc.d.ts +0 -207
- package/dist/src/rpc.js +0 -904
- package/dist/src/select.d.ts +0 -48
- package/dist/src/select.js +0 -40
- package/dist/src/types.d.ts +0 -115
- package/dist/src/types.js +0 -1
- package/dist/src/upload/optimize.d.ts +0 -54
- package/dist/src/upload/optimize.js +0 -207
- package/dist/src/upload/pipeline.d.ts +0 -55
- package/dist/src/upload/pipeline.js +0 -52
- package/dist/src/upload/transport.d.ts +0 -42
- package/dist/src/upload/transport.js +0 -128
- package/dist/src/url_params.d.ts +0 -93
- package/dist/src/url_params.js +0 -215
- package/dist/src/use_optimistic.d.ts +0 -27
- package/dist/src/use_optimistic.js +0 -27
- package/dist/src/use_recents.d.ts +0 -19
- package/dist/src/use_recents.js +0 -71
- package/dist/src/use_url_state.js +0 -73
- package/dist/src/viewer.d.ts +0 -26
- package/dist/src/viewer.js +0 -47
- /package/dist/{src/download.d.ts → download.d.ts} +0 -0
package/docs/workflows.md
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
# Workflows — the body-authoring reference
|
|
2
2
|
|
|
3
|
-
The grammar of a **workflow body**: what
|
|
4
|
-
JS subset that is accepted, and the rules that decide whether a body saves.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
declaring typed inputs, reading `result.data` / `result.files`); this document owns everything
|
|
8
|
-
between the braces. Read it before authoring or editing any body.
|
|
3
|
+
The grammar of a **workflow body**: what `set_app_workflow` takes as an alias's `source`, the
|
|
4
|
+
JS subset that is accepted, and the rules that decide whether a body saves.
|
|
5
|
+
[mutations](./mutations.md) owns the *call* side; this document owns everything between the
|
|
6
|
+
braces.
|
|
9
7
|
|
|
10
8
|
## The mental model
|
|
11
9
|
|
|
@@ -19,8 +17,8 @@ engine, no `eval`, no sandbox. Four consequences shape everything below:
|
|
|
19
17
|
- **A successful save may still return `warnings[]`** — advisory lint that does not block.
|
|
20
18
|
Read them; they are the failures that only show up in production.
|
|
21
19
|
- **One canonical form per concept.** Several JS spellings are accepted and *lowered* to one
|
|
22
|
-
stored form. The stored tree renders back to source
|
|
23
|
-
shows the canonical spelling, not the sugar you typed (see *Round-tripping* below).
|
|
20
|
+
stored form. The stored tree renders back to source when it is read (`get_app_workflow`), so a
|
|
21
|
+
read body shows the canonical spelling, not the sugar you typed (see *Round-tripping* below).
|
|
24
22
|
- **Opaque keys, never display names.** Fields are `fld_*`, select options are `opt_*`, tables
|
|
25
23
|
are `tbl_*`, groups are `grp_*`. A display name is rejected at save with an error pointing at
|
|
26
24
|
the right key. The generated types carry the human-readable name in a JSDoc comment; the
|
|
@@ -33,29 +31,13 @@ if (order.data["fld_status"] == "opt_open") { … }
|
|
|
33
31
|
|
|
34
32
|
## Where a body lives
|
|
35
33
|
|
|
36
|
-
|
|
37
|
-
the
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|---|---|
|
|
42
|
-
| `lotics app codegen` | (re)generates the per-alias `.d.ts` so the body is locally typed |
|
|
43
|
-
| `lotics app workflow check [alias]` | reproduces the server's save-time verdict locally (below) |
|
|
44
|
-
| `lotics app workflow set <alias>` | strips the envelope and pushes the body; the **server re-verifies** |
|
|
45
|
-
| `lotics app deploy` | ships code, queries, capabilities — **never** binds or updates a workflow |
|
|
46
|
-
|
|
47
|
-
For a brand-new alias, declare it in `package.json#lotics.workflows.<alias>` first (its `inputs`;
|
|
48
|
-
leave `outputs` off to let the body's `return({ data })` derive it), write the body, `codegen`,
|
|
49
|
-
`check`, then `set`. Input declaration vocabulary and validation:
|
|
34
|
+
A body is the app's, stored on the server and bound to an alias: `set_app_workflow` takes the
|
|
35
|
+
alias, the body's `source`, and the alias's `inputs` (leave `outputs` off to let the body's
|
|
36
|
+
`return({ data })` derive it). The server verifies the body and the write lands live, as a new
|
|
37
|
+
version of the app; `get_app_workflow` reads it back, and `rollback_app` returns the app to a
|
|
38
|
+
version before it. Input declaration vocabulary and validation:
|
|
50
39
|
[mutations](./mutations.md#declaring-workflow-inputs).
|
|
51
40
|
|
|
52
|
-
**Adding an input to an alias that is already bound reverses that order.** `codegen` types a
|
|
53
|
-
*registered* alias from the server's **bound** schema — the manifest declaration is only the
|
|
54
|
-
fallback for an alias the server has never seen — so a newly-declared input is absent from the
|
|
55
|
-
local globals and `check` rejects every read of it, however correct the body is. `set` pushes the
|
|
56
|
-
manifest's `inputs` with the body and the server verifies against *those*, so the working order
|
|
57
|
-
is `set` → `codegen` → `check`.
|
|
58
|
-
|
|
59
41
|
An app workflow body carries **no trigger declaration** — the binding supplies the trigger
|
|
60
42
|
context. A stray `on({ … })` line is rejected.
|
|
61
43
|
|
|
@@ -74,17 +56,16 @@ compile-time "cannot find name", not a runtime `undefined`.
|
|
|
74
56
|
| `changes` | table `*_update` | per-field diff — a *partial* map, so read it `changes["fld_x"]?.next_value` / `?.prev_value` |
|
|
75
57
|
| `index` | inside a `for-of` body | the current 0-based iteration index — always the **innermost** loop's. To use an outer loop's index in a nested body, bind it in the outer one (`const outerIdx = index;`) and read that |
|
|
76
58
|
| `<bind_name>` | inside a `for-of` body | the current item. Each loop keeps its own, so a nested body reads the outer loop's item by its own bind name |
|
|
77
|
-
| `<
|
|
59
|
+
| `<name>` | its block, after the declaration | a `const` — a step's output (`rows.records`, `approval.status`, …) or a bound value |
|
|
78
60
|
| `<let_name>` | its block | a mutable `let` binding |
|
|
79
61
|
| `<param>` | inside a helper callback | that lambda's parameter |
|
|
62
|
+
| `(await <tool>({…}))` | anywhere the call runs unconditionally | that call's output, read in place (see *`await` inside a statement*) |
|
|
80
63
|
|
|
81
|
-
`record`, `prev_record` and `changes` are the **canonical** roots
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
same thing** (the parser lowers each onto the root, so both save to one AST and read one value),
|
|
85
|
-
but a saved body prints back in the bare form. Write the bare form.
|
|
64
|
+
`record`, `prev_record` and `changes` are the **canonical** roots. `trigger.data`,
|
|
65
|
+
`trigger.prev_data` and `trigger.changes` are accepted and lower onto them, so a saved body prints
|
|
66
|
+
back in the bare form. Write the bare form.
|
|
86
67
|
|
|
87
|
-
A
|
|
68
|
+
A body bound to an app alias is an app workflow: it sees `trigger` and `runtime` only. There is
|
|
88
69
|
no `record` — an app workflow is not attached to a table. Pass the record id in as a
|
|
89
70
|
`record_link` input and `get_record` it.
|
|
90
71
|
|
|
@@ -99,9 +80,6 @@ origin, and spans every write path: `member`, `chat_agent`, `app_workflow`, …)
|
|
|
99
80
|
is never null, so `runtime?.x` is rejected outright. Prefer the helper `now()` over
|
|
100
81
|
`runtime.now`.
|
|
101
82
|
|
|
102
|
-
Authorizing the *person* who triggered the run is a separate concern from the authority the run
|
|
103
|
-
executes under — see [security](./security.md) and `current_member_in_any_group` below.
|
|
104
|
-
|
|
105
83
|
### Path access
|
|
106
84
|
|
|
107
85
|
| Form | Meaning |
|
|
@@ -111,10 +89,20 @@ executes under — see [security](./security.md) and `current_member_in_any_grou
|
|
|
111
89
|
| `linked(record["fld_link"])[0]["fld_name"]` | fetch the linked rows and read a field off one — see below |
|
|
112
90
|
| `.key` | plain object key on a step output (`rows.records`, `doc.file_id`) |
|
|
113
91
|
|
|
114
|
-
**A path
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
92
|
+
**A path starts at a root, a name, or an awaited tool call** —
|
|
93
|
+
`(await get_record({ … })).data["fld_x"]` reads the call's output in place. It cannot hang off a
|
|
94
|
+
helper call: `first(rows.records).id` is refused. Bind the call (`const top = first(rows.records);`
|
|
95
|
+
then `top.id`) or index the array directly (`rows.records[0].id`). `linked(...)` is the exception
|
|
96
|
+
because it is grammar rather than a helper — the path continues through it.
|
|
97
|
+
|
|
98
|
+
**A record reads in its declared shape, however it reached you.** Every row a records tool
|
|
99
|
+
returns, the trigger record and its diff, and every row `linked(...)` fetches arrive shaped as the
|
|
100
|
+
generated types declare them: a single select as its option key or `null`, one member as its id
|
|
101
|
+
or `null`, a link as its ids. So a row read through a `let`, a ternary, a callback parameter, a
|
|
102
|
+
helper result, a table id held in a variable, or a value that waited through a pause reads the
|
|
103
|
+
same as one read straight off the call. (A record *tool* run from the CLI or chat serves what is
|
|
104
|
+
stored — a single select as `["opt_x"]` — so take a field's shape from the generated types, never
|
|
105
|
+
from a transcript.)
|
|
118
106
|
|
|
119
107
|
**A link field reads as an array of ids, in every spelling.** `order.data["fld_link"]` is
|
|
120
108
|
`RecordId<"tbl_…">[]` whether you read it on a path, bind it to a `const`, take it as a `for-of`
|
|
@@ -132,8 +120,8 @@ row `P[i]` names.
|
|
|
132
120
|
|
|
133
121
|
The argument is a path ending on a link field, off anything record-shaped: `record` /
|
|
134
122
|
`prev_record`, a `for-of` item, a lambda parameter, a `let` binding, or a `get_record` /
|
|
135
|
-
`query_records` output
|
|
136
|
-
|
|
123
|
+
`query_records` output. The server checks the far field at save only where it can name the table
|
|
124
|
+
before the run — a call with a **literal** `table_id` — and the fetch works either way. Guard **inside** the
|
|
137
125
|
argument — `linked(record?.["fld_link"])[0]` — never on the call: `linked(P)?.[i]` is rejected,
|
|
138
126
|
because the call always returns an array and the guard could never fire. Two more rejections:
|
|
139
127
|
`const ids = record["fld_link"]; linked(ids)`, since the field name comes from the argument's last
|
|
@@ -148,10 +136,10 @@ The body is a sequence of statements, each of which maps 1:1 onto a stored step.
|
|
|
148
136
|
|
|
149
137
|
| Form | Syntax |
|
|
150
138
|
|---|---|
|
|
151
|
-
| Tool call, with output | `const <id> = await <tool>({ …kwargs });` |
|
|
139
|
+
| Tool call, with output | `const <id> = await <tool>({ …kwargs });` — or `let x = await …`, `x = await …` |
|
|
152
140
|
| Tool call, no output | `await <tool>({ …kwargs });` |
|
|
153
141
|
| Bind (compute once, reuse many) | `const <id> = <expression>;` |
|
|
154
|
-
| Mutable binding | `let x = <expr>;` then `x = …`, `x += …`, `x
|
|
142
|
+
| Mutable binding | `let x = <expr>;` then `x = …`, `x += …`, `x++`, `x ??= …`, `o.a.b = …`, `xs.push(…)` |
|
|
155
143
|
| Branch | `if (<expr>) { … } else if (<expr>) { … } else { … }` |
|
|
156
144
|
| Switch | `switch (<expr>) { case "X": { … } default: { … } }` |
|
|
157
145
|
| Iterate | `for (const item of <expr>) { … }` |
|
|
@@ -169,6 +157,33 @@ The body is a sequence of statements, each of which maps 1:1 onto a stored step.
|
|
|
169
157
|
be spelled with the JS `return` statement, but its argument is a single object literal and the
|
|
170
158
|
key set is closed. A key outside `{status, message, field_errors, data}` is rejected.
|
|
171
159
|
|
|
160
|
+
### `await` inside a statement
|
|
161
|
+
|
|
162
|
+
A tool call is always a step of its own, so an `await` is accepted wherever the statement around
|
|
163
|
+
it runs the call **every time it runs**: the call becomes a step placed just before the
|
|
164
|
+
statement, and its output is read where you wrote the `await`, in JavaScript's evaluation order.
|
|
165
|
+
|
|
166
|
+
```js
|
|
167
|
+
let supplier = null;
|
|
168
|
+
if (!isNull(i.supplier_id)) {
|
|
169
|
+
supplier = await get_record({ table_id: "tbl_suppliers", record_id: i.supplier_id });
|
|
170
|
+
}
|
|
171
|
+
const n = size((await query_records({ table_id: "tbl_orders" })).records);
|
|
172
|
+
if ((await get_record({ table_id: "tbl_orders", record_id: i.id })).data["fld_paid"]) { … }
|
|
173
|
+
const owner = i.owner_id ? await get_record({ table_id: "tbl_people", record_id: i.owner_id }) : null;
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
That covers an assignment, a `let` initializer, a helper or tool argument, an `if` or `switch`
|
|
177
|
+
test, a `return` value, a `for-of` iterable, a template, and a counter loop's init. A ternary
|
|
178
|
+
that awaits in a branch is accepted when it is the **whole** value of a `const`, `let` or
|
|
179
|
+
assignment — it is stored as the `if` it means.
|
|
180
|
+
|
|
181
|
+
An `await` that would run on some paths only is refused, with the `if` form to write instead:
|
|
182
|
+
inside `&&`, `||`, `??`, `??=` / `||=` / `&&=`, `?.`, a ternary nested in a larger expression, a
|
|
183
|
+
destructuring default, a `validate` check, a `while` / `do…while` test, and a counter loop's test
|
|
184
|
+
or update (those run on every pass — put the call in the body). `wait` and `wait_for_event` have
|
|
185
|
+
no value, so they stay statements.
|
|
186
|
+
|
|
172
187
|
### Step ids, names, and comments
|
|
173
188
|
|
|
174
189
|
The binding name on `const x = await tool({…})` **becomes the step id**, which is how later
|
|
@@ -184,14 +199,18 @@ the step's description, which is what the execution log shows.
|
|
|
184
199
|
const dup = await query_records({ table_id: "tbl_orders", filters: { … } });
|
|
185
200
|
```
|
|
186
201
|
|
|
187
|
-
Names
|
|
188
|
-
|
|
189
|
-
`
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
a
|
|
194
|
-
`
|
|
202
|
+
**Names are block-scoped, as in JavaScript.** A `const`, a `let`, a `for-of` item, a destructured
|
|
203
|
+
name and a `catch (e)` binding live in the block that declares them: two sibling blocks may each
|
|
204
|
+
declare `res`, an inner block may shadow an outer `res`, and a name read outside its block is an
|
|
205
|
+
unknown identifier. A lambda parameter shadows an outer name of the same spelling inside its
|
|
206
|
+
body. Each binding is stored under its own step id; a read prints the names you wrote.
|
|
207
|
+
|
|
208
|
+
**A local may take a helper's name** — `const size = 3;` is fine. While it is in scope, calling
|
|
209
|
+
`size(…)` is refused, as TypeScript refuses it: rename the local to call the helper. The method
|
|
210
|
+
and namespace forms (`xs.includes(v)`, `Math.max(…)`) still reach the helper. A name may not
|
|
211
|
+
take a tool's name, a reserved root (`record`, `trigger`, `runtime`, `index`, …), `linked`, or a
|
|
212
|
+
declared function's name, and `_s` followed by digits is the parser's own spelling for a hidden
|
|
213
|
+
step, so no binding may use it.
|
|
195
214
|
|
|
196
215
|
### `return` — the envelope
|
|
197
216
|
|
|
@@ -205,9 +224,10 @@ return({ status: "success", message: "Order created.", data: { total: subtotal }
|
|
|
205
224
|
save from its inferred type. Full contract: [mutations](./mutations.md) § "Structured results".
|
|
206
225
|
- `field_errors` maps a key to a message and reaches the app on `result.field_errors`, so a
|
|
207
226
|
form draws each one against the control it names. **The key names the control on the surface
|
|
208
|
-
that renders it**:
|
|
209
|
-
|
|
210
|
-
|
|
227
|
+
that renders it**: an app's form places it under the control for that declared INPUT name, or
|
|
228
|
+
under the control that writes that `fld_` field; the record editor behind a table-triggered
|
|
229
|
+
workflow places it under that `fld_` field. The runtime keys the map by whatever string you
|
|
230
|
+
write, so a key naming no control reaches the dialog and lands on nothing.
|
|
211
231
|
- A body that completes without hitting a `return` resolves `status: "success"` with no `data`.
|
|
212
232
|
- Generated documents never travel through `data`; they are collected into `result.files[]`
|
|
213
233
|
automatically ([files](./files.md)).
|
|
@@ -227,12 +247,20 @@ failing messages joined — cleanly, not as a crash: it is control flow, so **`t
|
|
|
227
247
|
not catch it** (nor `return`).
|
|
228
248
|
|
|
229
249
|
`field_key` is optional, a string literal, and carries the same key `return({ field_errors })`
|
|
230
|
-
does: **the control the message belongs to, on the surface that renders it
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
250
|
+
does: **the control the message belongs to, on the surface that renders it**. Each failing check
|
|
251
|
+
that names one contributes an entry to `field_errors`, so the form marks that control instead of
|
|
252
|
+
only the dialog. What it may name depends on who renders the refusal:
|
|
253
|
+
|
|
254
|
+
| Workflow | `field_key` may name |
|
|
255
|
+
|---|---|
|
|
256
|
+
| app workflow | a declared INPUT name (`order_code`, a key of the binding's `inputs`), or the `fld_` key of a field on a table the body names as a literal (a `table_id: "tbl_…"`, or a `const` holding one) |
|
|
257
|
+
| table-triggered (lifecycle, button) | the `fld_` key of a field on the trigger table |
|
|
258
|
+
| recordless automation (schedule, webhook, email) | anything — nothing renders it, so nothing checks it |
|
|
259
|
+
|
|
260
|
+
An app workflow whose alias declares no inputs leaves input-shaped keys unchecked, so a body can
|
|
261
|
+
be written before its declaration. A key outside its row is refused when the body is SAVED — by
|
|
262
|
+
`set_app_workflow`, by an agent binding one, by a table workflow's save — and by a
|
|
263
|
+
`verify_only` call before that. `return({ field_errors })` keys are not checked.
|
|
236
264
|
|
|
237
265
|
### `wait_for_approval` — the one wait worth binding
|
|
238
266
|
|
|
@@ -321,8 +349,13 @@ for (const line of trigger.app_workflow.inputs.lines) {
|
|
|
321
349
|
Precedence is JS's: `? :`, `||`, `??`, `&&`, `== != === !==`, `< <= > >=`, `+ -`, `* / %`,
|
|
322
350
|
prefix `!` and `-`. Both equality forms work as in JS — `==`/`!=` loose, `===`/`!==` strict.
|
|
323
351
|
|
|
324
|
-
Not supported: `**`, bitwise operators,
|
|
325
|
-
|
|
352
|
+
Not supported: `**`, bitwise operators, `in`, `typeof`, `instanceof`, `delete`, `void`.
|
|
353
|
+
`??=`, `||=` and `&&=` are statements (see *The sugar list*), not expressions.
|
|
354
|
+
|
|
355
|
+
**`undefined` is `null`.** The name is accepted and means the same value, so `x == undefined` is
|
|
356
|
+
`x == null`. `===` and `!==` against `undefined` are refused (`strict_undefined`): they cannot tell
|
|
357
|
+
a missing key from a null one. Test either with `isNull(x)`, and whether a key was sent with
|
|
358
|
+
`includes(keys(o), "k")` (see *Writing records*).
|
|
326
359
|
|
|
327
360
|
**Ordered comparison against null is `false`, never a throw.** `<`/`>`/`<=`/`>=` with a
|
|
328
361
|
null/undefined operand evaluate to `false`. A mismatch between two *non-null* operands still
|
|
@@ -330,9 +363,8 @@ throws, naming the offending value.
|
|
|
330
363
|
|
|
331
364
|
## The sugar list
|
|
332
365
|
|
|
333
|
-
Each of these is accepted and lowered to a canonical stored form at save
|
|
334
|
-
|
|
335
|
-
typed.
|
|
366
|
+
Each of these is accepted and lowered to a canonical stored form at save, which is why a pulled
|
|
367
|
+
body does not look like what you typed.
|
|
336
368
|
|
|
337
369
|
| You write | It stores as |
|
|
338
370
|
|---|---|
|
|
@@ -354,14 +386,27 @@ typed.
|
|
|
354
386
|
| `[...a, b]` | `concat(a, [b])` |
|
|
355
387
|
| `{ ...a, b: 1 }` | `merge(a, { b: 1 })` — later sources win, as in JS |
|
|
356
388
|
| `{ table_id }` | `{ table_id: table_id }` — in expressions **and** tool inputs |
|
|
357
|
-
| `
|
|
389
|
+
| `undefined` | `null` |
|
|
390
|
+
| `xs.push(a, b);` | `xs = concat(xs, [a, b])` — statement form |
|
|
391
|
+
| `o.a.items.push(v);` | `o = merge(o, { a: merge(o.a, { items: concat(o.a.items, [v]) }) })` |
|
|
392
|
+
| `o.a.b = v;` | `o = merge(o, { a: merge(o.a, { b: v }) })` — also `o.a.b += v`, `o.n++` |
|
|
358
393
|
| `x += 1`, `i++`, `--i` | `x = x + 1`, … (also in a c-`for` init/update) |
|
|
359
|
-
| `
|
|
394
|
+
| `x ??= v`, `x \|\|= v`, `x &&= v` | `x = coalesce(x, v)`, `x = x \|\| v`, `x = x && v` |
|
|
395
|
+
| a `const` the body pushes to, or assigns a key of | a `let` (reassigning the `const` itself is refused, as in JavaScript) |
|
|
396
|
+
| `x = await t({…});`, `let x = await t({…});` | the call as its own step, then the assignment |
|
|
397
|
+
| `const x = c ? await t({…}) : d;` | `let x = null; if (c) { x = await t({…}); } else { x = d; }` |
|
|
398
|
+
| `const { records } = await query_records({…});` | one bind per name off a synthetic step id (also `let { … } = await …`) |
|
|
360
399
|
| `function (x) { return e; }` in callback position | the same lambda node as `(x) => e` |
|
|
400
|
+
| a filter node without `node_type` | the one shape its keys name: `field_key`/`operator`/`value` a condition, `logic`/`children` a group, `path`/`condition` a traversal |
|
|
361
401
|
|
|
362
402
|
`parseInt` is **rejected** rather than aliased: `toNumber` has `parseFloat` semantics and would
|
|
363
403
|
silently drop the radix. Parse with `toNumber(x)`, then `floor` / `ceil` / `round`.
|
|
364
404
|
|
|
405
|
+
A mutation lands on a name the body declared: a key assignment or a push names its path with
|
|
406
|
+
written keys (`o[k] = v` with a computed `k` is refused), and a loop item is read-only — copy it
|
|
407
|
+
into a `let` first. Arrays and objects stay immutable underneath; each form rebuilds the value
|
|
408
|
+
and reassigns the name.
|
|
409
|
+
|
|
365
410
|
**Spread is rejected anywhere inside a tool input**, at any nesting depth — static analysis reads
|
|
366
411
|
those keys to extract table and file ids. Bind the merged value first, then pass the binding.
|
|
367
412
|
Computed property keys (`{ [k]: v }`) are rejected everywhere for the same reason; to write a
|
|
@@ -408,19 +453,23 @@ A single-expression helper, **top level only**, inlined at every call site.
|
|
|
408
453
|
value: `lineTotal({ qty: 1, price: 2 })` is rejected because inlining would splice a path onto
|
|
409
454
|
an object literal. Bind it first (`const l = { … };` then `lineTotal(l)`), or pass a row.
|
|
410
455
|
|
|
411
|
-
**A declared `function` does not survive a
|
|
412
|
-
repeated at each call site and no `function` at all. Reach for one when the *saved* logic
|
|
413
|
-
matters, and expect to re-extract it if you edit the
|
|
456
|
+
**A declared `function` does not survive a read.** Inlining erases it: `get_app_workflow` returns
|
|
457
|
+
the body repeated at each call site and no `function` at all. Reach for one when the *saved* logic
|
|
458
|
+
is what matters, and expect to re-extract it if you edit the source read back.
|
|
414
459
|
|
|
415
460
|
### Round-tripping
|
|
416
461
|
|
|
417
|
-
`
|
|
462
|
+
`get → set` is a fixed point on the stored tree, not on your text. A body read back prints the
|
|
418
463
|
canonical form — `concat` for a spread, `merge` for an object spread, `{x: x}` for shorthand,
|
|
419
|
-
`toString`/`lower` for a cast or a method call, the arrow spelling for either callback form,
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
query_records({…});`, with renames and
|
|
423
|
-
prints as the explicit `isNull(…) ? null :
|
|
464
|
+
`toString`/`lower` for a cast or a method call, the arrow spelling for either callback form,
|
|
465
|
+
`null` for `undefined`, `let` for a `const` the body mutates, and the inlined body at each call
|
|
466
|
+
site of a declared `function`. `?.` and `??` re-sugar, and a tool-result destructure prints back
|
|
467
|
+
as the destructuring statement (`const { records } = await query_records({…});`, with renames and
|
|
468
|
+
defaults intact); where a `?.` guard lands mid-path it prints as the explicit `isNull(…) ? null :
|
|
469
|
+
…` ternary, which re-parses to the same tree. Awaits print where you wrote them, a ternary that
|
|
470
|
+
awaits prints as the ternary, pushes, key assignments and `??=` print as written, and each
|
|
471
|
+
block-scoped name prints as its own spelling. A local that shadows a helper whose call has no
|
|
472
|
+
other spelling is renamed on read, with a `// id:` line keeping its step id.
|
|
424
473
|
|
|
425
474
|
## Helpers
|
|
426
475
|
|
|
@@ -449,17 +498,15 @@ full menu by category, so a miss is one informed retry.
|
|
|
449
498
|
A cleared cell does not always reach a workflow that way: the platform stores a cleared date or
|
|
450
499
|
text as `""` and a cleared select, link or files cell as `[]`, so `isNull(record.data.ngay_doi_soat)`
|
|
451
500
|
is **false** on a date the user emptied. Reach for `isEmpty` for the "has the user filled this in?"
|
|
452
|
-
question — it covers `null`, `undefined`, `""` and `[]`.
|
|
453
|
-
declared as a type predicate and every `?.` in a body lowers to it, so widening it would narrow
|
|
454
|
-
`""` out of a branch that still receives it.) A FORMULA field is the one surface where the two
|
|
501
|
+
question — it covers `null`, `undefined`, `""` and `[]`. A FORMULA field is the one surface where the two
|
|
455
502
|
agree: its evaluation context normalizes every unset cell to `null` before the expression runs.
|
|
456
503
|
|
|
457
504
|
A few signatures worth knowing: `requireFirst(arr, message?)` asserts non-empty and returns `T`
|
|
458
505
|
rather than `T | undefined` — pair it with a `validate` on `size(x) == 0` instead of wrapping
|
|
459
506
|
every read in `if (x)`. `at(arr, -1)` counts from the end. `range(end)` / `range(start, end)`.
|
|
460
507
|
`sortBy(arr, keyOrFn, "asc" | "desc")`. `formatNumber(x, decimals)`. `get(obj, "a.b", fallback)`.
|
|
461
|
-
Exact declarations
|
|
462
|
-
|
|
508
|
+
Exact declarations are the server's; a wrong arity is refused at save with the signature it
|
|
509
|
+
expected, so read that message rather than guessing.
|
|
463
510
|
|
|
464
511
|
### Callbacks
|
|
465
512
|
|
|
@@ -472,9 +519,10 @@ Everything else does not — a lambda anywhere else is rejected.
|
|
|
472
519
|
function expression are rejected. Fold branches into one expression (a ternary).
|
|
473
520
|
- Parameters are plain identifiers, positional per item — `(item, idx) => …`; `reduce` gets
|
|
474
521
|
`(acc, item, idx)`. No destructuring or defaults in a lambda parameter list.
|
|
475
|
-
- A body may read the reserved roots,
|
|
476
|
-
|
|
477
|
-
|
|
522
|
+
- A body may read the reserved roots, the names in scope, enclosing `for-of` items, and an
|
|
523
|
+
enclosing lambda's parameters. A parameter spelled like an outer name shadows it inside the body.
|
|
524
|
+
- A callback's item reads in its declared shape (see *Path access*), so a callback over rows
|
|
525
|
+
reads `r.data["fld_status"]` as the option key whatever the rows came from.
|
|
478
526
|
- `reduce` is callback-only and its **initial value is required** (it is what gives the
|
|
479
527
|
accumulator a type).
|
|
480
528
|
- Most of these also accept a **path-string** form instead of a callback, resolved with `get`
|
|
@@ -513,12 +561,10 @@ differ from `<` / `>` (code-unit order) and from the database's collation.
|
|
|
513
561
|
| `field_edits` | `[{ field, op, value }]` | the multi-value ops with the field named by a **string expression** — the only way to target a field chosen at run time. `increment` has no value-form: its payload is a scalar, not an item list |
|
|
514
562
|
|
|
515
563
|
`add_to`, `remove_from` and `increment` are RELATIVE: the server resolves them against the record
|
|
516
|
-
as it stands when the write lands, inside the lock it already takes
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
ordinary (a trigger firing twice, an agent emitting two calls in one step), so reach for the ops
|
|
521
|
-
rather than a read-modify-write whenever the workflow is CHANGING a field by an amount rather than
|
|
564
|
+
as it stands when the write lands, inside the lock it already takes, so two runs changing one field
|
|
565
|
+
at once each keep their effect — where a read-modify-write through `set` drops the earlier, both
|
|
566
|
+
reporting success. Parallel runs are ordinary (a trigger firing twice, an agent emitting two calls
|
|
567
|
+
in one step), so reach for the ops whenever the workflow CHANGES a field by an amount rather than
|
|
522
568
|
stating its value.
|
|
523
569
|
|
|
524
570
|
`increment` counts an empty or unset field as 0, so a first decrement leaves a negative — oversold,
|
|
@@ -526,8 +572,7 @@ and visibly so. It is rejected on formula, rollup, lookup and autonumber fields:
|
|
|
526
572
|
their own definition, so change what they aggregate instead.
|
|
527
573
|
|
|
528
574
|
A **different** delta per record is per-record mode with `row_op: "increment"` — every row value is
|
|
529
|
-
read as a delta rather than a value to write
|
|
530
|
-
moves by its own quantity:
|
|
575
|
+
read as a delta rather than a value to write:
|
|
531
576
|
|
|
532
577
|
```ts
|
|
533
578
|
await update_records({
|
|
@@ -539,9 +584,6 @@ await update_records({
|
|
|
539
584
|
});
|
|
540
585
|
```
|
|
541
586
|
|
|
542
|
-
It also removes the read: the deltas are what the workflow already computed, so there is nothing to
|
|
543
|
-
look up first.
|
|
544
|
-
|
|
545
587
|
Inside `set` and `create_records.records`: `null` clears (persisted), `undefined` or an omitted
|
|
546
588
|
key preserves. So passing a possibly-null read straight through is safe. Use
|
|
547
589
|
`coalesce(x, fallback)` only when you want a real fallback, never to "strip" null.
|
|
@@ -575,13 +617,9 @@ if (includes(keys(i), "phone")) { // NOT `isNull(i.phone)` — that swallows t
|
|
|
575
617
|
```
|
|
576
618
|
|
|
577
619
|
An omitted input carries no key at all and a cleared one carries the key with `null`, so presence
|
|
578
|
-
is the only test that separates them
|
|
579
|
-
`
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
`""` is never a clear on any surface: an HTML input emits it for "the user skipped this", so the
|
|
583
|
-
validator coerces it to omitted before the body runs. Reaching for `""` to blank a field fails
|
|
584
|
-
silently — the write reports success and the old value stays. Send `null`.
|
|
620
|
+
is the only test that separates them — and it has to be `keys`: a body reads `undefined` as `null`,
|
|
621
|
+
so `i.phone === undefined` is refused rather than read as a test of whether the input was sent.
|
|
622
|
+
`""` is never a clear: the validator coerces it to omitted before the body runs.
|
|
585
623
|
|
|
586
624
|
Value shapes, which the generated types enforce exactly:
|
|
587
625
|
|
|
@@ -627,24 +665,15 @@ if (i.undo) {
|
|
|
627
665
|
```
|
|
628
666
|
|
|
629
667
|
There is no version to restore a cell from, so prove it before anyone can call
|
|
630
|
-
it: `
|
|
631
|
-
|
|
668
|
+
it: `run_app_workflow` the same input twice, and confirm the second run changed
|
|
669
|
+
nothing.
|
|
632
670
|
|
|
633
671
|
### Authorizing the caller
|
|
634
672
|
|
|
635
|
-
A workflow runs under the **app owner's** authority, so
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
— and `grp_` literals are validated at save.
|
|
640
|
-
|
|
641
|
-
```js
|
|
642
|
-
if (!current_member_in_any_group(["grp_managers"])) {
|
|
643
|
-
return({ status: "error", message: "Only managers can approve this." });
|
|
644
|
-
}
|
|
645
|
-
```
|
|
646
|
-
|
|
647
|
-
Full model, including what a public app must never expose: [security](./security.md).
|
|
673
|
+
A workflow runs under the **app owner's** authority, so the person who pressed the button is
|
|
674
|
+
`runtime.triggered_by_member_id`, authorized by group with `current_member_in_any_group(["grp_…"])`
|
|
675
|
+
— fail-closed, its `grp_` literals validated at save
|
|
676
|
+
([security](./security.md#gating-privileged-writes-current_member_in_any_group)).
|
|
648
677
|
|
|
649
678
|
## Every `await` is a round trip
|
|
650
679
|
|
|
@@ -691,10 +720,9 @@ await create_records({ records: rows, … });
|
|
|
691
720
|
// Type 'string' is not assignable to '"opt_bVPEMB" | "opt_nx0KAL" | … | null'.
|
|
692
721
|
```
|
|
693
722
|
|
|
694
|
-
`concat` widens a select's option key to `string` the moment it leaves the literal, and
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
to let the call site supply the type. Keep the loop for FINDING values and put the writing after it.
|
|
723
|
+
`concat` widens a select's option key to `string` the moment it leaves the literal, and an
|
|
724
|
+
annotation is not in the subset. Let the call site supply the type: keep the loop for FINDING
|
|
725
|
+
values and put the writing after it.
|
|
698
726
|
|
|
699
727
|
### Don't buy the same row twice
|
|
700
728
|
|
|
@@ -717,11 +745,9 @@ existed only to feed it, which is usually where the round trips were hiding.
|
|
|
717
745
|
|
|
718
746
|
### Measuring, if you do
|
|
719
747
|
|
|
720
|
-
`query_workflow_executions` records `started_at` / `completed_at` per run,
|
|
721
|
-
duration
|
|
722
|
-
|
|
723
|
-
and interleave the two versions rather than measuring one after the other. A single before/after
|
|
724
|
-
pair will happily show an improvement that is only drift.
|
|
748
|
+
`query_workflow_executions` records `started_at` / `completed_at` per run, the server's own
|
|
749
|
+
duration. **Run-to-run variance is large** (a second, minutes apart), so compare medians of several
|
|
750
|
+
interleaved runs, never one before/after pair.
|
|
725
751
|
|
|
726
752
|
## Traps
|
|
727
753
|
|
|
@@ -733,9 +759,13 @@ The rules that are easy to get wrong because the failing code looks correct.
|
|
|
733
759
|
- **`for-of` over a link field iterates ids, over `linked(...)` iterates rows.** Both are legal, so
|
|
734
760
|
pick by what the body needs — `get_record` and a link write take the id, a field read takes the
|
|
735
761
|
row.
|
|
736
|
-
- **`get_record` needs a literal `table_id` to narrow
|
|
737
|
-
of every table and no field read type-checks.
|
|
738
|
-
save-time typing requirement.
|
|
762
|
+
- **`get_record` needs a literal `table_id` to narrow** — a string literal, or a `const` holding
|
|
763
|
+
one. Without it, `.data` degrades to a union of every table and no field read type-checks.
|
|
764
|
+
`table_id` stays optional at run time; this is a save-time typing requirement.
|
|
765
|
+
- **A read the server cannot place before the run gets no save-time help.** Off a row whose
|
|
766
|
+
table only the run knows (a `let` holding rows of two tables, a `table_id` from an input), a
|
|
767
|
+
field read still returns the declared shape, but its option literals are not checked at save and
|
|
768
|
+
a multi-select `==` is not rewritten to `includes(…)` — write `includes(…)` there.
|
|
739
769
|
- **Date helpers return null.** Guard with `?? 0` before comparing or writing (above).
|
|
740
770
|
- **Waits are disallowed inside any loop body** — `for-of`, `while`, `do…while`, and a c-`for`'s
|
|
741
771
|
init/update included. Resume re-enters *after* the whole loop, so a wait inside would run
|
|
@@ -748,8 +778,9 @@ The rules that are easy to get wrong because the failing code looks correct.
|
|
|
748
778
|
renders the *label*; everywhere else it is the raw key. So
|
|
749
779
|
`` `Status: ${record["fld_status"]}` `` prints "Done" while
|
|
750
780
|
`record["fld_status"] == "opt_done"` compares keys — both correct, and neither substitutes for
|
|
751
|
-
the other.
|
|
752
|
-
|
|
781
|
+
the other. It holds on every route a record arrives by — a `let`, a callback parameter, a row
|
|
782
|
+
from a variable `table_id` — and a change-diff read (`changes["fld_status"]?.next_value`)
|
|
783
|
+
behaves exactly like the same field read off the record.
|
|
753
784
|
- **Multi-select `==` quietly desugars.** On a multi-select field,
|
|
754
785
|
`record["fld_tags"] == "opt_urgent"` rewrites to `includes(…)` at save. Either spelling is
|
|
755
786
|
fine — but a `switch` on a multi-select discriminant is **rejected**, because its value is an
|
|
@@ -781,7 +812,11 @@ table and file ids; evaluation is **deterministic and pure**. So these are bound
|
|
|
781
812
|
`Math.random()`, `Date.now()`, `new Date()`, `new` at all (non-deterministic) · regex literals
|
|
782
813
|
(no engine, and ReDoS) · functions as values — a function passed, returned, or bound to a name
|
|
783
814
|
(`const f = (x) => …`) · recursion · `throw` · `import` / `export` · classes · `for-in` ·
|
|
784
|
-
computed property keys · loops or assignment inside an expression · `await`
|
|
815
|
+
computed property keys · loops or assignment inside an expression · an `await` that would run on
|
|
816
|
+
some paths only.
|
|
817
|
+
|
|
818
|
+
An `await` inside a statement does not cross the first line: the call it names runs as a step of
|
|
819
|
+
its own before the statement, and the expression reads that step's output.
|
|
785
820
|
|
|
786
821
|
The two `function` forms the subset *does* accept do not cross the line: a callback becomes a
|
|
787
822
|
lambda node, a declaration is inlined — neither survives to run time as a value.
|
|
@@ -792,38 +827,22 @@ genuinely recursive.
|
|
|
792
827
|
|
|
793
828
|
## The check loop
|
|
794
829
|
|
|
795
|
-
`
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
it
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
What only the **server** can decide, so `check` stays green and `set` may still refuse:
|
|
807
|
-
|
|
808
|
-
- whether an identifier is a **registered tool** (the CLI ships no tool registry), and whether a
|
|
809
|
-
tool's kwargs are the right ones;
|
|
810
|
-
- **field, option, table, and group resolution** — a `fld_*` that doesn't exist, an `opt_*` that
|
|
811
|
-
isn't one of that field's options, a display name where a key belongs;
|
|
812
|
-
- **`switch` case validation** against a single-select's options, and the multi-select rejection;
|
|
813
|
-
- the **literal `formatDate` format probe**;
|
|
814
|
-
- **wait inside a loop**, and the `before_*` restrictions on waits and `agent` steps;
|
|
815
|
-
- the **lint** — deep link chains, an input the alias declares that this body never reads, and
|
|
816
|
-
an unguarded `set` in a body that generates a document (all warnings).
|
|
817
|
-
|
|
818
|
-
There is no separate verify endpoint: the loop is `set` → read the returned diagnostics → fix →
|
|
819
|
-
`set`. Diagnostics arrive **batched** — independent errors across the whole body come back in one
|
|
820
|
-
round trip, not one per fix — and raw TypeScript shape errors are rewritten into field-naming,
|
|
830
|
+
`set_app_workflow` with `verify_only: true` gives the verdict a save would, and writes nothing. The
|
|
831
|
+
server runs every check a save runs — the subset parse, the type pass against the alias's
|
|
832
|
+
declared inputs, tool and kwarg resolution, field, option, table and group resolution, `switch`
|
|
833
|
+
case validation, the literal `formatDate` probe, waits in loops, the `before_*` restrictions,
|
|
834
|
+
table reach, the `field_key` rule and the published-API guard. **Green means the save will take
|
|
835
|
+
it, red means real** — do not push through a red check. Lint comes back as warnings, which do not
|
|
836
|
+
fail it.
|
|
837
|
+
|
|
838
|
+
Diagnostics arrive **batched** — independent errors across the whole body come back in one round
|
|
839
|
+
trip, not one per fix — and raw TypeScript shape errors are rewritten into field-naming,
|
|
821
840
|
fix-stating messages before you see them.
|
|
822
841
|
|
|
823
842
|
### Static green is not a run
|
|
824
843
|
|
|
825
|
-
`check` and `set` prove parse, types, name resolution, and lint — **none of them
|
|
826
|
-
expression**. A body that saves clean can still take the wrong branch, hand a tool a filter it
|
|
844
|
+
`check` and `set` prove parse, types, name resolution, structure, and lint — **none of them
|
|
845
|
+
evaluates an expression**. A body that saves clean can still take the wrong branch, hand a tool a filter it
|
|
827
846
|
rejects, or read a path that is null on real data. The rehearsal for that is `dry_run_workflow`
|
|
828
847
|
(`lotics run dry_run_workflow '<json>'`): pass the `source`, `trigger_type: "app_workflow"`,
|
|
829
848
|
`table_id: null`, the input **values** as `trigger_payload`, and the declared schema as
|
|
@@ -836,14 +855,10 @@ recorded, never dispatched**, and nothing is persisted.
|
|
|
836
855
|
|
|
837
856
|
**Add `live_reads: true` whenever the body READS.** By default the read-only tools
|
|
838
857
|
(`query_records`, `get_record`, `aggregate_records`) return stubs — an empty result set, a blank
|
|
839
|
-
record — so any branch gated on stored data takes the empty path
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
while writes stay recorded-only. (A table-triggered body gets this from `record_id` + `table_id`,
|
|
844
|
-
which supply the real record as the payload AND turn live reads on; an app workflow has no trigger
|
|
845
|
-
record — its payload is its inputs — so it asks for the reads directly.) Do this before the first
|
|
846
|
-
live run of anything that writes.
|
|
858
|
+
record — so any branch gated on stored data takes the empty path. With `live_reads: true` those
|
|
859
|
+
three dispatch against the real workspace under YOUR authority while writes stay recorded-only. (A
|
|
860
|
+
table-triggered body gets this from `record_id` + `table_id`; an app workflow has no trigger
|
|
861
|
+
record, so it asks directly.) Do this before the first live run of anything that writes.
|
|
847
862
|
|
|
848
863
|
**`return_value` is what the CALLER receives**, not a summary of it: `{ status, message }` always,
|
|
849
864
|
plus `data` when the body returns one and `field_errors` when it returns those — the same map a
|
|
@@ -867,7 +882,7 @@ A workflow that files a recording ([mutations](./mutations.md#recording-into-a-w
|
|
|
867
882
|
declares the platform's `recording` input in exactly this shape, beside its own inputs:
|
|
868
883
|
|
|
869
884
|
```jsonc
|
|
870
|
-
//
|
|
885
|
+
// set_app_workflow { "alias": "log_visit", "inputs": … }
|
|
871
886
|
"inputs": {
|
|
872
887
|
"site": { "type": "record_link", "table_id": "tbl_sites" },
|
|
873
888
|
"recording": { "type": "object", "fields": {
|
|
@@ -926,7 +941,7 @@ const dup = await query_records({
|
|
|
926
941
|
|
|
927
942
|
validate({ checks: [{
|
|
928
943
|
fail_when: size(dup.records) > 0,
|
|
929
|
-
// The
|
|
944
|
+
// The input the form's control sends; the fld_ key of the field it writes works too.
|
|
930
945
|
field_key: "order_code",
|
|
931
946
|
message: `Order code ${i.order_code} already exists.`,
|
|
932
947
|
}]});
|