@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.
Files changed (108) hide show
  1. package/AGENTS.md +32 -47
  2. package/dist/agent_stream.d.ts +131 -0
  3. package/dist/ask_ai.d.ts +27 -0
  4. package/dist/attachments.d.ts +58 -0
  5. package/dist/chunk-ARV5FAU5.js +1132 -0
  6. package/dist/comments.d.ts +89 -0
  7. package/dist/error_report.d.ts +9 -0
  8. package/dist/folder_pick.d.ts +8 -0
  9. package/dist/geolocation.d.ts +42 -0
  10. package/dist/hooks.d.ts +251 -0
  11. package/dist/{src/index.d.ts → index.d.ts} +13 -22
  12. package/dist/index.js +31331 -0
  13. package/dist/index.js.LEGAL.txt +11 -0
  14. package/dist/members.d.ts +32 -0
  15. package/dist/mock.d.ts +37 -0
  16. package/dist/mount.d.ts +19 -0
  17. package/dist/new_record.d.ts +37 -0
  18. package/dist/open_app.d.ts +12 -0
  19. package/dist/open_external.d.ts +10 -0
  20. package/dist/overlay.d.ts +25 -0
  21. package/dist/queries.d.ts +231 -0
  22. package/dist/recording.d.ts +47 -0
  23. package/dist/recording_state.d.ts +43 -0
  24. package/dist/rename_file.d.ts +13 -0
  25. package/dist/router.d.ts +10 -0
  26. package/dist/router.js +97 -0
  27. package/dist/row.d.ts +87 -0
  28. package/dist/rpc.d.ts +114 -0
  29. package/dist/select.d.ts +24 -0
  30. package/dist/shared_types.d.ts +8 -0
  31. package/dist/store.d.ts +43 -0
  32. package/dist/types.d.ts +36 -0
  33. package/dist/upload/optimize.d.ts +30 -0
  34. package/dist/upload/pipeline.d.ts +36 -0
  35. package/dist/upload/transport.d.ts +19 -0
  36. package/dist/url_params.d.ts +55 -0
  37. package/dist/use_recents.d.ts +15 -0
  38. package/dist/{src/use_url_state.d.ts → use_url_state.d.ts} +0 -2
  39. package/dist/viewer.d.ts +41 -0
  40. package/dist/written.d.ts +79 -0
  41. package/docs/ai.md +74 -133
  42. package/docs/data_fetching.md +209 -290
  43. package/docs/files.md +61 -51
  44. package/docs/members_and_options.md +92 -62
  45. package/docs/mutations.md +136 -205
  46. package/docs/navigation_and_state.md +26 -35
  47. package/docs/queries.md +144 -207
  48. package/docs/recipes.md +21 -45
  49. package/docs/runtime.md +74 -137
  50. package/docs/security.md +8 -11
  51. package/docs/workflows.md +189 -174
  52. package/package.json +27 -28
  53. package/dist/src/agent_stream.d.ts +0 -200
  54. package/dist/src/agent_stream.js +0 -314
  55. package/dist/src/ask_ai.d.ts +0 -40
  56. package/dist/src/ask_ai.js +0 -35
  57. package/dist/src/attachments.d.ts +0 -68
  58. package/dist/src/attachments.js +0 -93
  59. package/dist/src/comments.d.ts +0 -127
  60. package/dist/src/comments.js +0 -192
  61. package/dist/src/download.js +0 -54
  62. package/dist/src/geolocation.d.ts +0 -64
  63. package/dist/src/geolocation.js +0 -96
  64. package/dist/src/hooks.d.ts +0 -781
  65. package/dist/src/hooks.js +0 -860
  66. package/dist/src/index.js +0 -34
  67. package/dist/src/members.d.ts +0 -105
  68. package/dist/src/members.js +0 -62
  69. package/dist/src/mock.d.ts +0 -118
  70. package/dist/src/mock.js +0 -124
  71. package/dist/src/mount.d.ts +0 -47
  72. package/dist/src/mount.js +0 -34
  73. package/dist/src/new_record.d.ts +0 -74
  74. package/dist/src/new_record.js +0 -117
  75. package/dist/src/open_app.d.ts +0 -15
  76. package/dist/src/open_app.js +0 -18
  77. package/dist/src/open_external.d.ts +0 -16
  78. package/dist/src/open_external.js +0 -19
  79. package/dist/src/recording.d.ts +0 -59
  80. package/dist/src/recording.js +0 -30
  81. package/dist/src/recording_state.d.ts +0 -59
  82. package/dist/src/recording_state.js +0 -94
  83. package/dist/src/router.d.ts +0 -17
  84. package/dist/src/router.js +0 -144
  85. package/dist/src/row.d.ts +0 -159
  86. package/dist/src/row.js +0 -254
  87. package/dist/src/rpc.d.ts +0 -207
  88. package/dist/src/rpc.js +0 -904
  89. package/dist/src/select.d.ts +0 -48
  90. package/dist/src/select.js +0 -40
  91. package/dist/src/types.d.ts +0 -115
  92. package/dist/src/types.js +0 -1
  93. package/dist/src/upload/optimize.d.ts +0 -54
  94. package/dist/src/upload/optimize.js +0 -207
  95. package/dist/src/upload/pipeline.d.ts +0 -55
  96. package/dist/src/upload/pipeline.js +0 -52
  97. package/dist/src/upload/transport.d.ts +0 -42
  98. package/dist/src/upload/transport.js +0 -128
  99. package/dist/src/url_params.d.ts +0 -93
  100. package/dist/src/url_params.js +0 -215
  101. package/dist/src/use_optimistic.d.ts +0 -27
  102. package/dist/src/use_optimistic.js +0 -27
  103. package/dist/src/use_recents.d.ts +0 -19
  104. package/dist/src/use_recents.js +0 -71
  105. package/dist/src/use_url_state.js +0 -73
  106. package/dist/src/viewer.d.ts +0 -26
  107. package/dist/src/viewer.js +0 -47
  108. /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 may be written inside `src/workflows/<alias>.ts`, the
4
- JS subset that is accepted, and the rules that decide whether a body saves. Every write an app
5
- performs runs through one of these bodies, so this is the other half of the write path —
6
- [mutations](./mutations.md) owns the *call* side (`useWorkflow`, the `WorkflowResult` contract,
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 on `lotics app pull`, so a pulled body
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
- `lotics app pull` writes each bound alias to `src/workflows/<alias>.ts` — the filename **is**
37
- the alias — wrapped in an `async function __workflow()` envelope under a generated header that
38
- references `.lotics/workflows/<alias>.globals.d.ts`. Edit only between the wrapper lines.
39
-
40
- | Command | What it does |
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
- | `<step_id>` | after the step | that step's output (`rows.records`, `approval.status`, …) |
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 — the spelling a `pull` hands
82
- back. The raw trigger payload carries the same three views under `trigger.data`,
83
- `trigger.prev_data` and `trigger.changes`; those spellings are **accepted and mean exactly the
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 **body in `src/workflows/`** is an app workflow: it sees `trigger` and `runtime` only. There is
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 must start at a root.** It cannot hang off a helper call: `first(rows.records).id` is
115
- rejected. Bind the call (`const top = first(rows.records);` then `top.id`) or index the array
116
- directly (`rows.records[0].id`). `linked(...)` is the exception because it is grammar rather than
117
- a helper — the path continues through it.
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 (that one resolves its table only when the call passed a **literal**
136
- `table_id`, which is what lets the server validate the far field at save). Guard **inside** the
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 live in one flat namespace. A binding, step id, `for-of` bind, or lambda parameter may not
188
- collide with a tool, a helper (`size`, `first`, `filter`, … are all taken), a reserved root,
189
- `linked`, or a declared function. **Step ids are unique across the whole body** — a `const`
190
- inside an `if` claims its name everywhere, so a second `const a` in any block is rejected. Only
191
- `let` bindings and `for-of` binds are block-scoped: each may shadow an outer one of the same
192
- name, neither leaks out of the `if` / loop / `try` that declares it, and neither may take a name
193
- a step id already holds. A lambda parameter must likewise be free of every step id, enclosing
194
- `for-of` bind, and enclosing lambda parameter.
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**: for an app workflow that is the alias's declared INPUT name, for a
209
- table-triggered one it is the record's `fld_` field key. The runtime keys the map by whatever
210
- string you write, so a key naming neither reaches the dialog and lands on nothing.
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** — an app workflow's
231
- declared INPUT name (`order_code`, the key under `lotics.workflows.<alias>.inputs`), a
232
- table-triggered workflow's `fld_` field key. Each failing check that names one contributes an
233
- entry to `field_errors`, so the app's form marks that control instead of only the dialog.
234
- A key naming neither is refused when the body is SAVED — by `lotics app workflow set`, by an
235
- agent binding one, by a table workflow's save — and by `lotics app workflow check` before that.
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, `||=` / `&&=` / `??=`, `in`, `typeof`, `instanceof`,
325
- `delete`, `void`.
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. Knowing the list
334
- matters twice: it is what you may write, and it is why a pulled body does not look like what you
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
- | `xs.push(a, b);` | `xs = concat(xs, [a, b])` — statement form, on a `let` binding |
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
- | `const { records } = await query_records({…});` | one bind per name off a synthetic step id |
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 pull.** Inlining erases it: `pull` returns the body
412
- repeated at each call site and no `function` at all. Reach for one when the *saved* logic is what
413
- matters, and expect to re-extract it if you edit the pulled source.
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
- `pull → set` is a fixed point on the stored tree, not on your text. A pulled body prints the
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, and
420
- the inlined body at each call site of a declared `function`. `?.` and `??` re-sugar, and a
421
- tool-result destructure prints back as the destructuring statement (`const { records } = await
422
- query_records({…});`, with renames and defaults intact); where a `?.` guard lands mid-path it
423
- prints as the explicit `isNull(…) ? null : …` ternary, which re-parses to the same tree.
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 `[]`. (`isNull` stays narrow on purpose: it is
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 reach you through the generated `.lotics/workflows/<alias>.globals.d.ts` —
462
- open it rather than guessing an arity.
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, step outputs, `let` bindings, enclosing `for-of` binds, and
476
- an enclosing lambda's parameters. Parameter names must be unique across the nesting — a name
477
- already bound in scope is rejected.
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. Two runs changing one field at
517
- the same time therefore each keep their effect — which reading the field and writing the result back
518
- through `set` does not, since both fold onto the value they read and the later write drops the
519
- earlier, both reporting success. On a stock count that is a sale that vanishes. Parallel runs are
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. That is the shape a stock move needs, since each item
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. It has to be `keys` rather than a comparison against
579
- `undefined`: the expression subset has no `undefined` identifier, and a body naming it fails to
580
- save with `Unknown identifier "undefined"`.
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: `lotics app workflow run <alias>` the same input twice, and confirm the
631
- second run changed nothing.
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 its own principal tells you nothing about
636
- who pressed the button. `runtime.triggered_by_member_id` is that person (null for a system
637
- trigger or an anonymous public caller), and `current_member_in_any_group(["grp_…"])` authorizes
638
- them by group. It fails closed — unknown group, deleted group, or no triggering member → `false`
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 the obvious
695
- repair — `let rows: SomeRecordWrite[] = []` — is rejected by the next pass, because the stored body
696
- is parsed as a JS subset and type syntax is not in it. The way through is not an annotation: it is
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, which is the server's own
721
- duration with your network and CLI startup excluded. **Run-to-run variance is large** — the same
722
- body measured twice, minutes apart, can differ by a second — so compare medians of several runs,
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.** Without it, `.data` degrades to a union
737
- of every table and no field read type-checks. `table_id` stays optional at run time; this is a
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. A change-diff read (`changes["fld_status"]?.next_value`) behaves exactly like the
752
- same field read off the record.
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` anywhere but a step.
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
- `lotics app workflow check [alias]` runs the server's **own** parse and type passes locally: the
796
- same subset parser, the same generated `.d.ts` and envelope, the same compiler options, one
797
- isolated program per alias. **Green means pushable and red means real** — do not push through a
798
- red check. It is also the *only* local gate on a body: the app project's own `npm run typecheck`
799
- excludes `src/workflows` (bodies compile against the server's globals, not the app's DOM lib), so
800
- it never sees one — a fully green app typecheck says nothing about any workflow.
801
-
802
- Order matters. A subset rejection is reported *alone* and the compiler is skipped, because a body
803
- the parser refuses never reaches the type checker on the server anyway. Diagnostics carry
804
- `<file>:<line>:<col>` at the physical position in `src/workflows/<alias>.ts`.
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 evaluates an
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 and the rehearsal quietly proves
840
- nothing about the branch that matters. A duplicate check finds no duplicate; a lookup that should
841
- skip because a field is already set doesn't skip. With `live_reads: true` those three dispatch
842
- against the real workspace under YOUR authority, so the gates are exercised against actual rows
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
- // package.json#lotics.workflows.log_visit
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 INPUT name, not the field key: this is what the app's form marks.
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
  }]});