@lotics/app-sdk 0.58.1 → 0.58.4

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 CHANGED
@@ -17,6 +17,7 @@ signature; open the file.**
17
17
  | [docs/queries.md](./docs/queries.md) | **The query engine authoring reference** — AST node kinds, per-field-type operator support, filters/params/pruning, free-text search, combining tables (join/union/link/`unnest`/`record_id`), shaping (aggregates, date buckets, windows), runtime refinement bounds, limits & the efficiency playbook. |
18
18
  | [docs/data_fetching.md](./docs/data_fetching.md) | The three read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`), cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `useFieldOptions`, data discipline, the search-as-you-type + record-picker patterns. |
19
19
  | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract, typed inputs, diff-before-update, locked records, `useOptimistic`. |
20
+ | [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY authoring reference** — the JS subset a `src/workflows/<alias>.ts` body may use: the parse-at-save/never-execute model, opaque `fld_*`/`opt_*` keys, expression sources + link descent, every step form (tool call, `agent`, waits, `validate`, `return`), the accepted sugar and its canonical lowering, helpers + callback rules, record-write surfaces, the traps, the bright line, and the `lotics app workflow check` loop. |
20
21
  | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs, workflow-generated files, preview pairing, filter operators, the server-side delivery bounds. **Uploads declare a `fidelity`** (`standard` / `high` / `original`) — the app picks how much of the image survives storage; use `high` whenever text must stay legible. |
21
22
  | [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions`, `useViewer`, `useComments`, and the `@lotics/ui` components they feed. |
22
23
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
package/docs/mutations.md CHANGED
@@ -6,8 +6,10 @@ inputs (file/member/optional inputs), returning structured data with `return({ d
6
6
  refetching after a mutation, the diff-before-update discipline, locked records
7
7
  (`readLocked` + `request_locked_record_change`), and optimistic reconciliation
8
8
  (`useOptimistic`). Read this before building any screen that creates, updates, or deletes
9
- records. Reading data is [queries](./queries.md) + [data_fetching](./data_fetching.md);
10
- uploads are [files](./files.md); who a write runs as is [security](./security.md).
9
+ records. What may be written **inside** the workflow body — the JS subset, steps, helpers,
10
+ traps is [workflows](./workflows.md). Reading data is [queries](./queries.md) +
11
+ [data_fetching](./data_fetching.md); uploads are [files](./files.md); who a write runs as is
12
+ [security](./security.md).
11
13
 
12
14
  ## The write model
13
15
 
@@ -124,9 +126,10 @@ separate, optional step. Previewing/downloading files: [files](./files.md).
124
126
 
125
127
  ### Structured results: `return({ data })`
126
128
 
127
- A workflow body ends with `return({ status, message?, data? })`. `data` is arbitrary
128
- structured data (computed totals, row lists, status objects) the app reads back as
129
- `result.data`:
129
+ A workflow body ends with a `return({ status, message, field_errors?, data? })` step (the
130
+ envelope's grammar `message` is required, `field_errors` never reaches the app is
131
+ [workflows](./workflows.md#return--the-envelope)). `data` is arbitrary structured data
132
+ (computed totals, row lists, status objects) the app reads back as `result.data`:
130
133
 
131
134
  - **Typed for free — persisted at `set`.** The alias's `outputs` schema is derived at save
132
135
  time from the inferred TypeScript type of the body's `return({ data })` — the return *is*
@@ -289,7 +292,9 @@ await update_records({
289
292
  `set_skip_null` **skips** it. That is the per-field "clear vs. leave unchanged" choice, so a field
290
293
  may appear in **at most one** of `set` / `set_skip_null` (naming it in both is rejected at run
291
294
  time). An all-absent `set_skip_null` with no other write surface is a no-op — no records touched,
292
- no `before_update` hooks.
295
+ no `before_update` hooks. The full write surface (`set`, `set_skip_null`, the surgical
296
+ `add_to`/`remove_from`/`replace` ops, `field_edits`, and the exact value shape per field type)
297
+ is [workflows](./workflows.md#writing-records).
293
298
 
294
299
  ## Refetch after a mutation
295
300
 
@@ -0,0 +1,677 @@
1
+ # Workflows — the body-authoring reference
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.
9
+
10
+ ## The mental model
11
+
12
+ A workflow body is **a strict subset of JavaScript that is never executed**. At save time the
13
+ source is parsed into a typed step tree, type-checked against a generated `.d.ts` of your
14
+ workspace, resolved, linted, and stored as data. At run time the engine walks that tree — no JS
15
+ engine, no `eval`, no sandbox. Four consequences shape everything below:
16
+
17
+ - **Only the listed forms exist.** Anything outside them fails the save with a precise error
18
+ naming the rule and, where one exists, the substitute. A save never partially succeeds.
19
+ - **A successful save may still return `warnings[]`** — advisory lint that does not block
20
+ (a possible infinite loop, a link path more than three hops deep, a button action with no
21
+ `validate` guard). Read them; they are the failures that only show up in production.
22
+ - **One canonical form per concept.** Several JS spellings are accepted and *lowered* to one
23
+ stored form. The stored tree renders back to source on `lotics app pull`, so a pulled body
24
+ shows the canonical spelling, not the sugar you typed (see *Round-tripping* below).
25
+ - **Opaque keys, never display names.** Fields are `fld_*`, select options are `opt_*`, tables
26
+ are `tbl_*`, groups are `grp_*`. A display name is rejected at save with an error pointing at
27
+ the right key. The generated types carry the human-readable name in a JSDoc comment; the
28
+ *value* is always the key.
29
+
30
+ ```js
31
+ // A field read by its opaque key, compared against one of that field's option keys:
32
+ if (order.data["fld_status"] == "opt_open") { … }
33
+ ```
34
+
35
+ ## Where a body lives
36
+
37
+ `lotics app pull` writes each bound alias to `src/workflows/<alias>.ts` — the filename **is**
38
+ the alias — wrapped in an `async function __workflow()` envelope under a generated header that
39
+ references `.lotics/workflows/<alias>.globals.d.ts`. Edit only between the wrapper lines.
40
+
41
+ | Command | What it does |
42
+ |---|---|
43
+ | `lotics app codegen` | (re)generates the per-alias `.d.ts` so the body is locally typed |
44
+ | `lotics app workflow check [alias]` | reproduces the server's save-time verdict locally (below) |
45
+ | `lotics app workflow set <alias>` | strips the envelope and pushes the body; the **server re-verifies** |
46
+ | `lotics app deploy` | ships code, queries, capabilities — **never** binds or updates a workflow |
47
+
48
+ For a brand-new alias, declare it in `package.json#lotics.workflows.<alias>` first (its `inputs`;
49
+ leave `outputs` off to let the body's `return({ data })` derive it), write the body, `codegen`,
50
+ `check`, then `set`. Input declaration vocabulary and validation:
51
+ [mutations](./mutations.md#declaring-workflow-inputs).
52
+
53
+ An app workflow body carries **no trigger declaration** — the binding supplies the trigger
54
+ context. A stray `on({ … })` line is rejected.
55
+
56
+ ## Expression sources
57
+
58
+ Every read starts from one of a fixed set of roots. Which roots exist depends on what the
59
+ workflow is attached to — these are *ambient declarations*, so an unavailable root is a
60
+ compile-time "cannot find name", not a runtime `undefined`.
61
+
62
+ | Root | Available in | Value |
63
+ |---|---|---|
64
+ | `trigger` | every context | the trigger namespace. For an app workflow: `trigger.app_workflow.inputs.<name>` |
65
+ | `runtime` | every context | execution context — see the key table below |
66
+ | `record` | button + table-lifecycle workflows | the record the trigger fired on (merged data on update) |
67
+ | `prev_record` | table `*_update` / `*_delete` | the prior record state — same shape as `record`, link descent included |
68
+ | `changes` | table `*_update` | per-field diff — a *partial* map, so read it `changes["fld_x"]?.next_value` / `?.prev_value` |
69
+ | `index` | inside a `for-of` body | the current 0-based iteration index |
70
+ | `<bind_name>` | inside a `for-of` body | the current item |
71
+ | `<step_id>` | after the step | that step's output (`rows.records`, `approval.status`, …) |
72
+ | `<let_name>` | its block | a mutable `let` binding |
73
+ | `<param>` | inside a helper callback | that lambda's parameter |
74
+
75
+ `record`, `prev_record` and `changes` are the **canonical** roots — the spelling a `pull` hands
76
+ back. The raw trigger payload carries the same three views under `trigger.data`,
77
+ `trigger.prev_data` and `trigger.changes`; those spellings are **accepted and mean exactly the
78
+ same thing** (the parser lowers each onto the root, so both save to one AST and read one value),
79
+ but a saved body prints back in the bare form. Write the bare form.
80
+
81
+ A **body in `src/workflows/`** is an app workflow: it sees `trigger` and `runtime` only. There is
82
+ no `record` — an app workflow is not attached to a table. Pass the record id in as a
83
+ `record_link` input and `get_record` it.
84
+
85
+ `runtime` keys: `timezone`, `now`, `workflow_id`, `execution_id`, `workspace_id`,
86
+ `organization_id`, `triggered_by_member_id` (`null` for a system trigger or an anonymous
87
+ public-app caller), and `change_origin` — a discriminated union over how the **execution** was
88
+ started: `button` · `automation` · `app_action` · `app_workflow` · `table_workflow`. It describes
89
+ the dispatch, never the person, and it is fixed per attachment — an app workflow always reads
90
+ `app_workflow`, a table workflow always `table_workflow`. To branch on *who wrote the record*,
91
+ read `trigger.change_origin` on a table workflow (that one carries the originating write's
92
+ origin, and spans every write path: `member`, `chat_agent`, `app_workflow`, …). `runtime`
93
+ is never null, so `runtime?.x` is rejected outright. Prefer the helper `now()` over
94
+ `runtime.now`.
95
+
96
+ Authorizing the *person* who triggered the run is a separate concern from the authority the run
97
+ executes under — see [security](./security.md) and `current_member_in_any_group` below.
98
+
99
+ ### Path access
100
+
101
+ | Form | Meaning |
102
+ |---|---|
103
+ | `["fld_x"]` / `.fld_x` | field access by opaque key — both spellings work; brackets read better |
104
+ | `[n]` / `[<expr>]` | array index, literal or computed |
105
+ | `["fld_link"][0]["fld_name"]` | descend into the *n*-th linked record and read one of its fields |
106
+ | `.key` | plain object key on a step output (`rows.records`, `doc.file_id`) |
107
+
108
+ **A path must start at a root.** It cannot hang off a helper call: `first(rows.records).id` is
109
+ rejected. Bind the call (`const top = first(rows.records);` then `top.id`) or index the array
110
+ directly (`rows.records[0].id`).
111
+
112
+ **Link descent is a lazy fetch, and it must be one unbroken path.**
113
+ `order.data["fld_link"][0]["fld_name"]` works because the walker fetches the linked row where the
114
+ `[index]` is syntactically attached — scoped to the run's authority, null-tolerant, cached per
115
+ execution. It collapses along a single path from a record read: `record` / `prev_record`, a
116
+ `for-of` item, a lambda parameter, a `let` binding, or a `get_record` / `query_records` step
117
+ output — the last of these only when the call passed a **literal** `table_id`, since that is what
118
+ lets the server resolve the table at save.
119
+
120
+ The moment you *materialize* the array (`const ids = order.data["fld_link"]`), `ids` is what it
121
+ always was at runtime: a plain `string[]` of record ids. `ids[0]` is a bare `rec_*` **string**,
122
+ and reading a field off it is a loud runtime error — one the type checker does **not** catch,
123
+ because `ids` keeps the linked-record type it had on the path. Pass it where a record id is wanted
124
+ (`record_id: ids[0]`), `get_record` it, or keep the read as one path. Descent also terminates
125
+ after **one hop** in the type system: a linked row's own link fields type as ids. Deeper reads
126
+ take the id and `get_record` it.
127
+
128
+ ## Step forms
129
+
130
+ The body is a sequence of statements, each of which maps 1:1 onto a stored step.
131
+
132
+ | Form | Syntax |
133
+ |---|---|
134
+ | Tool call, with output | `const <id> = await <tool>({ …kwargs });` |
135
+ | Tool call, no output | `await <tool>({ …kwargs });` |
136
+ | Bind (compute once, reuse many) | `const <id> = <expression>;` |
137
+ | Mutable binding | `let x = <expr>;` then `x = …`, `x += …`, `x++` |
138
+ | Branch | `if (<expr>) { … } else if (<expr>) { … } else { … }` |
139
+ | Switch | `switch (<expr>) { case "X": { … } default: { … } }` |
140
+ | Iterate | `for (const item of <expr>) { … }` |
141
+ | Count / condition loops | `for (let i = 0; i < n; i++) { … }`, `while (…) { … }`, `do { … } while (…);` |
142
+ | Loop control | `break;` / `continue;` |
143
+ | Error handling | `try { … } catch (e) { … }` |
144
+ | Pause | `await wait({ duration_in_minutes: 5 });` |
145
+ | Pause for an event | `await wait_for_event({ event_type: "payment" \| "webhook", event_ref, timeout_in_minutes? });` |
146
+ | Pause for a decision | `const a = await wait_for_approval({ approvers, prompt, timeout_in_minutes? });` |
147
+ | LLM reasoning | `const x = await agent({ instructions, input, tools, model?, output });` |
148
+ | Guard | `validate({ checks: [{ fail_when, field_key?, message }, …] });` |
149
+ | End the run | `return({ status, message, field_errors?, data? });` |
150
+
151
+ `validate` and `return` are **calls**, not JS keywords in the usual sense — `return` happens to
152
+ be spelled with the JS `return` statement, but its argument is a single object literal and the
153
+ key set is closed. A key outside `{status, message, field_errors, data}` is rejected.
154
+
155
+ ### Step ids, names, and comments
156
+
157
+ The binding name on `const x = await tool({…})` **becomes the step id**, which is how later
158
+ expressions read the output (`x.records`). A `// id: my_step` comment immediately above a
159
+ statement sets an explicit id — the name must be a plain identifier (`[A-Za-z_][A-Za-z0-9_]*`),
160
+ and a line that misses that shape is **not** an error: it falls through and becomes description,
161
+ leaving the step id as the binding name. Any other `//` comment lines above a statement become
162
+ the step's description, which is what the execution log shows.
163
+
164
+ ```js
165
+ // id: find_duplicates
166
+ // Block the write when the code is already in use
167
+ const dup = await query_records({ table_id: "tbl_orders", filters: { … } });
168
+ ```
169
+
170
+ Names live in one flat namespace. A binding, step id, `for-of` bind, or lambda parameter may not
171
+ collide with a tool, a helper (`size`, `first`, `filter`, … are all taken), a reserved root, or a
172
+ declared function. **Step ids are unique across the whole body** — a `const` inside an `if`
173
+ claims its name everywhere, so a second `const a` in any block is rejected. Only `let` bindings
174
+ and `for-of` binds are block-scoped: each may shadow an outer one of the same name, neither leaks
175
+ out of the `if` / loop / `try` that declares it, and neither may take a name a step id already
176
+ holds. A lambda parameter must likewise be free of every step id, enclosing `for-of` bind, and
177
+ enclosing lambda parameter.
178
+
179
+ ### `return` — the envelope
180
+
181
+ ```js
182
+ return({ status: "success", message: "Order created.", data: { total: subtotal } });
183
+ ```
184
+
185
+ - `status` is a literal `"success"` or `"error"`. `message` is **required** (any string
186
+ expression). Both `field_errors` and `data` are optional.
187
+ - `data` is what the app reads back as `result.data`; the alias's `outputs` schema is derived at
188
+ save from its inferred type. Full contract: [mutations](./mutations.md) § "Structured results".
189
+ - `field_errors` annotates a *rejected record write* — it is consumed by the table-workflow
190
+ surface for cell-level error rendering. An app-invoked workflow surfaces only `status`,
191
+ `message`, `data`, and `files`, so put anything the app must read under `data`.
192
+ - A body that completes without hitting a `return` resolves `status: "success"` with no `data`.
193
+ - Generated documents never travel through `data`; they are collected into `result.files[]`
194
+ automatically ([files](./files.md)).
195
+
196
+ ### `validate` — the guard
197
+
198
+ ```js
199
+ validate({ checks: [{
200
+ fail_when: size(dup.records) > 0,
201
+ field_key: "fld_order_code",
202
+ message: "This order code already exists.",
203
+ }]});
204
+ ```
205
+
206
+ `fail_when` rejects when truthy. `field_key` is an optional literal `fld_*`. A failing check ends
207
+ the run as `status: "error"` with the failing messages joined — cleanly, not as a crash: it is
208
+ control flow, so **`try`/`catch` does not catch it** (nor `return`).
209
+
210
+ ### `wait_for_approval` — the one wait worth binding
211
+
212
+ ```js
213
+ const approval = await wait_for_approval({
214
+ approvers: trigger.app_workflow.inputs.approvers,
215
+ prompt: `Approve this order?`,
216
+ });
217
+
218
+ if (approval.status == "approved") { … } else { … }
219
+ ```
220
+
221
+ Output: `{ status: "approved" | "rejected" | "timed_out", decided_by: MemberId | null,
222
+ decided_at: string, decision_comment: string | null }`. `approvers` takes loose forms — bare
223
+ `"mbr_*"` and `"grp_*"` strings are wrapped by prefix, full principal objects
224
+ (`{ type: "member" | "member_group" | "organization", id }`) pass through — so a `select_member`
225
+ field value or a `member` input works unwrapped. An approvers value that resolves to nobody ends
226
+ the run legibly as `status: "error"` naming the bad value; it does not throw.
227
+
228
+ Plain `wait` and `wait_for_event` carry no payload and are statement-only. `wait.duration_in_minutes`
229
+ and every `timeout_in_minutes` must be a **positive number literal**, not an expression, and
230
+ `wait_for_event.event_type` is a string literal from a closed set — `"payment"` or `"webhook"`.
231
+
232
+ ### `agent` — LLM reasoning as one step
233
+
234
+ ```js
235
+ const order = await get_record({ table_id: "tbl_orders", record_id: i.order_id });
236
+
237
+ const summary = await agent({
238
+ instructions: "Summarize the order in one sentence for an internal digest.",
239
+ input: { code: order.data["fld_order_code"], total: order.data["fld_total"] },
240
+ tools: [],
241
+ output: { mode: "text" },
242
+ });
243
+ ```
244
+
245
+ Two scoping rules govern what crosses into and out of the step; both surface as "cannot find
246
+ name" when broken:
247
+
248
+ - **Only `input` sees the workflow.** It must be an **object literal**; its field values are
249
+ ordinary expressions and are the *only* workflow data the agent receives. `instructions` is a
250
+ static string literal and `tools` a literal array of tool names — neither may read a binding,
251
+ so do not interpolate a record into `instructions`; move it into `input`.
252
+ - **Only the bound name comes back.** In `{ mode: "text" }` the result *is* the string — use it
253
+ directly. In `{ mode: "object", schema }` the `schema` is itself a top-level object declaration
254
+ (`{ type: "object", fields: { total: { type: "number" }, … } }`, the same vocabulary as an
255
+ alias's `outputs`); read the declared fields as `x.field`, and a name not in `schema` does not
256
+ exist. The agent's own tool calls and reasoning are not readable.
257
+
258
+ `model` is an optional pin — omit it to follow the platform default. The step is atomic (no wait
259
+ or approval inside it).
260
+
261
+ ### `try` / `catch`
262
+
263
+ ```js
264
+ try {
265
+ await send_email({ to: recipient, subject: "Receipt", body: text });
266
+ } catch (e) {
267
+ await update_records({ table_id: "tbl_orders", record_ids: [id], set: { fld_note: e.message } });
268
+ }
269
+ ```
270
+
271
+ `e` is shaped `{ message, type, step_id?, detail? }`. Caught: tool errors and expression errors
272
+ thrown inside the body. **Not caught:** `validate` failures and `return` (control flow), and
273
+ errors from steps that run on *resume* after a `wait` inside the body — the wrapper spans one
274
+ execution pass. There is no `finally`: put always-run steps after the `try`, always-on-failure
275
+ steps in the `catch`.
276
+
277
+ ### Loops
278
+
279
+ `for-of` is the workhorse; `while`, `do…while`, and counter `for` loops all work, each with a
280
+ runtime cap of 10 000 iterations and a re-checked wall-clock budget. `for-in` is not supported.
281
+ Bodies may not be empty. `break` / `continue` must sit inside a loop body, and labels
282
+ (`break outer;`) are not supported.
283
+
284
+ ```js
285
+ let created = [];
286
+ for (const line of trigger.app_workflow.inputs.lines) {
287
+ const row = await create_records({ table_id: "tbl_lines", records: [{ fld_qty: line.qty }] });
288
+ created.push(line.qty); // the accumulator idiom
289
+ }
290
+ ```
291
+
292
+ ## Operators
293
+
294
+ Precedence is JS's: `? :`, `||`, `??`, `&&`, `== != === !==`, `< <= > >=`, `+ -`, `* / %`,
295
+ prefix `!` and `-`. Both equality forms work as in JS — `==`/`!=` loose, `===`/`!==` strict.
296
+
297
+ Not supported: `**`, bitwise operators, `||=` / `&&=` / `??=`, `in`, `typeof`, `instanceof`,
298
+ `delete`, `void`.
299
+
300
+ **Ordered comparison against null is `false`, never a throw.** `<`/`>`/`<=`/`>=` with a
301
+ null/undefined operand evaluate to `false`. A mismatch between two *non-null* operands still
302
+ throws, naming the offending value.
303
+
304
+ ## The sugar list
305
+
306
+ Each of these is accepted and lowered to a canonical stored form at save. Knowing the list
307
+ matters twice: it is what you may write, and it is why a pulled body does not look like what you
308
+ typed.
309
+
310
+ | You write | It stores as |
311
+ |---|---|
312
+ | `a ?? b` | `coalesce(a, b)` |
313
+ | `a?.b`, `a?.["k"]`, `a?.[0]`, `a?.length`, `a?.trim()` | `isNull(a) ? null : …` — one guard per `?.` |
314
+ | `` `Total: ${x}` `` | a template expression |
315
+ | `x.trim()`, `arr.includes(v)` | `trim(x)`, `includes(arr, v)` — the method form, and only for a helper that is also a real JS method (see *Helpers*) |
316
+ | `arr.map(fn)` | `pluck(arr, fn)` |
317
+ | `x.length` (dot only) | `length(x)` — strings and arrays. `x["length"]` still reads a field |
318
+ | `String(x)`, `Number(x)`, `parseFloat(x)`, `+x` | `toString(x)` / `toNumber(x)` |
319
+ | `x.toUpperCase()`, `x.toLowerCase()` | `upper(x)`, `lower(x)` |
320
+ | `x.toFixed(2)` | `formatNumber(x, 2)` |
321
+ | `Math.round/min/max/abs/floor/ceil/pow/sqrt(…)` | the like-named helper |
322
+ | `Math.max(...arr)` / `Math.min(...arr)` | `max(arr)` / `min(arr)` |
323
+ | `JSON.parse`, `JSON.stringify` | `parseJson`, `toJson` |
324
+ | `Array.isArray`, `Object.keys/values/entries` | `isArray`, `keys`/`values`/`entries` |
325
+ | `arr.at(i)` | `at(arr, i)` — negative counts from the end |
326
+ | `arr.filter(Boolean)` | `compact(arr)` — drops every falsy element |
327
+ | `[...a, b]` | `concat(a, [b])` |
328
+ | `{ ...a, b: 1 }` | `merge(a, { b: 1 })` — later sources win, as in JS |
329
+ | `{ table_id }` | `{ table_id: table_id }` — in expressions **and** tool inputs |
330
+ | `xs.push(a, b);` | `xs = concat(xs, [a, b])` — statement form, on a `let` binding |
331
+ | `x += 1`, `i++`, `--i` | `x = x + 1`, … (also in a c-`for` init/update) |
332
+ | `const { records } = await query_records({…});` | one bind per name off a synthetic step id |
333
+ | `function (x) { return e; }` in callback position | the same lambda node as `(x) => e` |
334
+
335
+ `parseInt` is **rejected** rather than aliased: `toNumber` has `parseFloat` semantics and would
336
+ silently drop the radix. Parse with `toNumber(x)`, then `floor` / `ceil` / `round`.
337
+
338
+ **Spread is rejected anywhere inside a tool input**, at any nesting depth — static analysis reads
339
+ those keys to extract table and file ids. Bind the merged value first, then pass the binding.
340
+ Computed property keys (`{ [k]: v }`) are rejected everywhere for the same reason; to write a
341
+ field chosen at runtime, use `field_edits` (below).
342
+
343
+ ### Destructuring
344
+
345
+ ```js
346
+ const { records } = await query_records({ table_id: "tbl_orders" });
347
+ const { fld_status: status, "fld_order_code": code, fld_note = "" } = order.data;
348
+ for (const { id, fld_qty } of records) { … }
349
+ const a = 1, b = 2;
350
+ ```
351
+
352
+ Object and array patterns work on `const`, `let`, and `for-of`, with rename, string-literal keys,
353
+ array holes (`const [head, , third] = records;`), and defaults. A default lowers to `coalesce`,
354
+ so it fires on **null** as well as undefined. The right-hand side must be **read-shaped** — a
355
+ root, a step output, or a binding; compute first, then destructure (`const [a] = [1, 2];` is
356
+ rejected). Nested patterns and rest (`...rest`) are not supported.
357
+
358
+ ### Top-level `function` declarations
359
+
360
+ ```js
361
+ const q = await query_records({ table_id: "tbl_lines", filters: { … } });
362
+
363
+ function lineTotal(l = {}) { return l.data["fld_qty"] * l.data["fld_price"]; }
364
+
365
+ const subtotal = sumBy(q.records, (l) => lineTotal(l));
366
+ ```
367
+
368
+ A single-expression helper, **top level only**, inlined at every call site.
369
+
370
+ - **Every parameter needs a default.** The subset has no type annotations, so the default is what
371
+ makes the parameter's type inferable; a bare parameter is rejected at parse.
372
+ - The body is exactly one `return <expr>;` and sees only its own parameters, helpers, and the
373
+ reserved roots — never the caller's bindings, and never `index` (it is loop-local, so an
374
+ inlined body would mean something different at each call site).
375
+ - A call may precede the declaration (declarations are collected first). Recursion, the method
376
+ form (`x.fn()`), a bare reference (`const g = fn`), a name that shadows a helper/tool/reserved
377
+ root, and a declaration that is **never called** are all rejected.
378
+ - An omitted argument uses the default; a provided one wins outright (presence is syntactic —
379
+ unlike a destructuring default, there is no null guard here).
380
+ - **A parameter you read a property off must be handed a record or a binding**, not a computed
381
+ value: `lineTotal({ qty: 1, price: 2 })` is rejected because inlining would splice a path onto
382
+ an object literal. Bind it first (`const l = { … };` then `lineTotal(l)`), or pass a row.
383
+
384
+ **A declared `function` does not survive a pull.** Inlining erases it: `pull` returns the body
385
+ repeated at each call site and no `function` at all. Reach for one when the *saved* logic is what
386
+ matters, and expect to re-extract it if you edit the pulled source.
387
+
388
+ ### Round-tripping
389
+
390
+ `pull → set` is a fixed point on the stored tree, not on your text. A pulled body prints the
391
+ canonical form — `concat` for a spread, `merge` for an object spread, `{x: x}` for shorthand,
392
+ `toString`/`lower` for a cast or a method call, the arrow spelling for either callback form, and
393
+ the inlined body at each call site of a declared `function`. `?.` and `??` re-sugar, and a
394
+ tool-result destructure prints back as the destructuring statement (`const { records } = await
395
+ query_records({…});`, with renames and defaults intact); where a `?.` guard lands mid-path it
396
+ prints as the explicit `isNull(…) ? null : …` ternary, which re-parses to the same tree.
397
+
398
+ ## Helpers
399
+
400
+ Helpers are the only callable things an expression may reach — a tool is never callable from an
401
+ expression. **The call form always works.** The method form is lowered to it (`x.trim()` →
402
+ `trim(x)`; `x?.trim()` null-guards the receiver), but only for the names that are *also* real JS
403
+ methods — `trim`, `toUpperCase`, `toLowerCase`, `replace`, `replaceAll`, `split`, `join`,
404
+ `substring`, `padStart`, `padEnd`, `startsWith`, `endsWith`, `includes`, `slice`, `at`, `concat`,
405
+ `reverse`, `map`, `filter`, `find`, `some`, `every`, `reduce`, `toFixed`, `toString`. A
406
+ helper-only method — `arr.size()`, `s.upper()`, `n.round()`, `d.addDays(1)` — is **rejected at
407
+ save** with the call form to write instead (`size(arr)`). `.length` is a property, not a method:
408
+ write `x.length` (no parentheses) or `length(x)`. Naming an unknown helper fails the save with the
409
+ full menu by category, so a miss is one informed retry.
410
+
411
+ | Category | Helpers |
412
+ |---|---|
413
+ | **Type / null** | `isNull`, `isNotNull`, `isEmpty`, `isString`, `isNumber`, `isBoolean`, `isArray`, `isObject`, `coalesce`, `toNumber`, `toString`, `typeOf`, `parseJson`, `toJson` |
414
+ | **Array** | `size`, `first`, `requireFirst`, `last`, `nth`, `at`, `slice`, `includes`, `filter`, `find`, `some`, `every`, `pluck`, `sortBy`, `groupBy`, `countBy`, `unique`, `uniqueBy`, `compact`, `flatten`, `reverse`, `concat`, `difference`, `differenceBy`, `intersection`, `intersectionBy`, `list`, `range`, `reduce` |
415
+ | **Number** | `sum`, `sumBy`, `mean`, `meanBy`, `min`, `max`, `minBy`, `maxBy`, `round`, `ceil`, `floor`, `abs`, `mod`, `pow`, `sqrt`, `clamp`, `percentage` |
416
+ | **String** | `upper`, `lower`, `capitalize`, `trim`, `contains`, `startsWith`, `endsWith`, `replace`, `replaceAll`, `substring`, `length`, `split`, `join`, `padStart`, `padEnd`, `formatNumber`, `numberToWords` |
417
+ | **Object** | `keys`, `values`, `entries`, `get`, `pick`, `omit`, `merge`, `nonNullKeys` |
418
+ | **Date** | `now`, `formatDate`, `parseDate`, `addDays`, `subDays`, `addHours`, `subHours`, `addMinutes`, `subMinutes`, `startOfDay`, `endOfDay`, `differenceInCalendarDays`, `differenceInHours`, `differenceInMinutes`, `isBefore`, `isAfter`, `isSameDay`, `isToday`, `isWithinRange` |
419
+ | **Other** | `formatCurrency(amount, locale, currency)`, `randomNumber(len)`, `randomAlphaNumeric(len)`, `sample(items)`, `current_member_in_any_group(["grp_…"])` |
420
+
421
+ A few signatures worth knowing: `requireFirst(arr, message?)` asserts non-empty and returns `T`
422
+ rather than `T | undefined` — pair it with a `validate` on `size(x) == 0` instead of wrapping
423
+ every read in `if (x)`. `at(arr, -1)` counts from the end. `range(end)` / `range(start, end)`.
424
+ `sortBy(arr, keyOrFn, "asc" | "desc")`. `formatNumber(x, decimals)`. `get(obj, "a.b", fallback)`.
425
+ Exact declarations reach you through the generated `.lotics/workflows/<alias>.globals.d.ts` —
426
+ open it rather than guessing an arity.
427
+
428
+ ### Callbacks
429
+
430
+ `filter`, `find`, `some`, `every`, `pluck`, `sortBy`, `sumBy`, `meanBy`, `minBy`, `maxBy`,
431
+ `groupBy`, `countBy`, `uniqueBy`, `differenceBy`, `intersectionBy`, and `reduce` take a callback.
432
+ Everything else does not — a lambda anywhere else is rejected.
433
+
434
+ - Both spellings lower to the same node: `(x) => <expr>`, `(x) => { return <expr>; }`, and
435
+ `function (x) { return <expr>; }`. A multi-statement body, `async`, a generator, and a named
436
+ function expression are rejected. Fold branches into one expression (a ternary).
437
+ - Parameters are plain identifiers, positional per item — `(item, idx) => …`; `reduce` gets
438
+ `(acc, item, idx)`. No destructuring or defaults in a lambda parameter list.
439
+ - A body may read the reserved roots, step outputs, `let` bindings, enclosing `for-of` binds, and
440
+ an enclosing lambda's parameters. Parameter names must be unique across the nesting — a name
441
+ already bound in scope is rejected.
442
+ - `reduce` is callback-only and its **initial value is required** (it is what gives the
443
+ accumulator a type).
444
+ - Most of these also accept a **path-string** form instead of a callback, resolved with `get`
445
+ semantics against each item: `filter(lines, "status", "open")`, `sumBy(lines, "amount")`,
446
+ `sortBy(lines, "due_on", "desc")`. On a `query_records` result the items are
447
+ `{ id, data }`, so the path is `"data.fld_amount"` — the callback form
448
+ (`sumBy(rows, (r) => r.data["fld_amount"])`) usually reads better there.
449
+
450
+ ### Dates are null-safe, which the types make you handle
451
+
452
+ Date fields are `string | null`. The arithmetic, difference, and parse helpers **return null** on
453
+ a null or empty input, and they are *declared* that way — so save-time TypeScript forces the
454
+ guard rather than letting an empty cell crash at run time:
455
+
456
+ ```js
457
+ const overdue = (differenceInCalendarDays(now(), record["fld_due"]) ?? 0) > 30;
458
+ ```
459
+
460
+ A `formatDate(…)` call with a **literal** format string is probed against the real formatter at
461
+ save, so a bad token (`YYYY`, `DD`) blocks the save with guidance. A computed format string stays
462
+ a run-time concern.
463
+
464
+ String ordering in `sortBy` / `minBy` / `maxBy` is `en-US`-pinned for reproducibility, which can
465
+ differ from `<` / `>` (code-unit order) and from the database's collation.
466
+
467
+ ## Writing records
468
+
469
+ `update_records` carries four composable surfaces. A field may appear in **at most one** of them.
470
+
471
+ | Surface | Shape | Effect |
472
+ |---|---|---|
473
+ | `set` | `{ fld_x: value }` | write the whole value. `null` **clears** the field |
474
+ | `set_skip_null` | `{ fld_x: value }` | same shape, but `null`/`undefined` entries are **dropped** — absent means "leave unchanged" |
475
+ | `add_to` / `remove_from` / `replace` | `{ fld_x: [items] }` | surgical edits on multi-value fields (files, multi select, multi member, record links) |
476
+ | `field_edits` | `[{ field, op, value }]` | the same surgical ops with the field named by a **string expression** — the only way to target a field chosen at run time |
477
+
478
+ Inside `set` and `create_records.records`: `null` clears (persisted), `undefined` or an omitted
479
+ key preserves. So passing a possibly-null read straight through is safe. Use
480
+ `coalesce(x, fallback)` only when you want a real fallback, never to "strip" null.
481
+
482
+ `set_skip_null` is what turns a bag of optional workflow inputs into a diff-write without a guard
483
+ per field; naming the same field in both `set` and `set_skip_null` is an error, and an
484
+ all-dropped `set_skip_null` with no other surface is a no-op (no records touched, no
485
+ `before_update` hooks). Why diffs and not snapshots, plus locked records and the
486
+ `request_locked_record_change` path: [mutations](./mutations.md#diff-before-update--send-only-what-changed).
487
+
488
+ Value shapes, which the generated types enforce exactly:
489
+
490
+ | Field | Read | Write |
491
+ |---|---|---|
492
+ | single `select` | one `opt_*` key, or `null` | the key: `fld_status: "opt_open"` |
493
+ | multi `select` | array of `opt_*` keys | array: `fld_tags: ["opt_urgent"]` |
494
+ | single `select_member` | one member id, or `null` | the id |
495
+ | multi `select_member` | array of member ids | array |
496
+ | `select_record_link` | array of linked-record handles — one hop of `[0]["fld_x"]` descent | array of ids: `fld_customer: [customer.id]` — never a record object |
497
+ | `files` | array of file refs | array of file ids |
498
+
499
+ Ids are **branded** on the write side: a member id types as `MemberId`, a file id as `FileId`, a
500
+ linked record id as `RecordId<"tbl_…">`. They come out branded from a record read (`customer.id`)
501
+ or from a workflow input declared `member` / `file` / `record_link` — a `rec_*` string carried in
502
+ a plain `text` input does *not* type-check into a link field, so declare the input for what it is.
503
+
504
+ A link **read** is not an id array either, so it does not feed straight back:
505
+ `set: { fld_link: record["fld_link"] }` is rejected ("write an ID array, e.g. [x.id]") — and
506
+ `prev_record["fld_link"]` is the same read, so the same rejection. The one id-shaped surface is
507
+ the **diff**: `changes["fld_link"]?.next_value` / `?.prev_value` are plain id arrays (a diff value
508
+ has nowhere to attach a fetch, so it cannot descend either), and they feed `set:` directly.
509
+ Everywhere else, write `[x.id]`.
510
+
511
+ ### Authorizing the caller
512
+
513
+ A workflow runs under the **app owner's** authority, so its own principal tells you nothing about
514
+ who pressed the button. `runtime.triggered_by_member_id` is that person (null for a system
515
+ trigger or an anonymous public caller), and `current_member_in_any_group(["grp_…"])` authorizes
516
+ them by group. It fails closed — unknown group, deleted group, or no triggering member → `false`
517
+ — and `grp_` literals are validated at save.
518
+
519
+ ```js
520
+ if (!current_member_in_any_group(["grp_managers"])) {
521
+ return({ status: "error", message: "Only managers can approve this." });
522
+ }
523
+ ```
524
+
525
+ Full model, including what a public app must never expose: [security](./security.md).
526
+
527
+ ## Traps
528
+
529
+ The rules that are easy to get wrong because the failing code looks correct.
530
+
531
+ - **Link read/write asymmetry, and the materialized-array cliff.** Descent works only on one
532
+ unbroken path from a record read. `const ids = record["fld_link"]; ids[0]["fld_name"]`
533
+ type-checks **clean** and throws at run time — `ids[0]` is a bare id string, and the type it
534
+ kept says otherwise. Write links as id arrays.
535
+ - **You cannot `for-of` a link field.** `for (const x of record["fld_link"])` is **rejected at
536
+ save**: a `for-of` has no `[index]` for the fetch to attach to, so the loop would bind bare
537
+ `rec_*` ids. Read one linked record as a path (`record["fld_link"][0]["fld_x"]`), or
538
+ `query_records` the linked table and iterate its `records`. Iterating a link field on a row you
539
+ already descended into (`record["fld_a"][0]["fld_b"]`) is fine — those *are* ids, so
540
+ `get_record` each one.
541
+ - **`create_records` ids are not reusable downstream.** Storage-commit timing makes the returned
542
+ `record_ids` array unreliable for a follow-up foreign-key write, and the lint **rejects**
543
+ `created.record_ids[0]`. Re-query the table by a unique field you just wrote, and use that.
544
+ - **`get_record` needs a literal `table_id` to narrow.** Without it, `.data` degrades to a union
545
+ of every table and no field read type-checks. `table_id` stays optional at run time; this is a
546
+ save-time typing requirement.
547
+ - **Date helpers return null.** Guard with `?? 0` before comparing or writing (above).
548
+ - **Waits are disallowed inside any loop body** — `for-of`, `while`, `do…while`, and a c-`for`'s
549
+ init/update included. Resume re-enters *after* the whole loop, so a wait inside would run
550
+ iteration 0 and silently skip every remaining iteration and its mutations. Move the wait out,
551
+ or split the loop into separately triggered runs.
552
+ - **Waits and `agent` steps are disallowed in `before_*` table workflows.** Those run inside the
553
+ mutation pipeline and must complete synchronously; an LLM loop cannot return an accept/reject
554
+ verdict in time.
555
+ - **String contexts coerce to display text.** A read inside a template or a string-typed argument
556
+ renders the *label*; everywhere else it is the raw key. So
557
+ `` `Status: ${record["fld_status"]}` `` prints "Done" while
558
+ `record["fld_status"] == "opt_done"` compares keys — both correct, and neither substitutes for
559
+ the other. A change-diff read (`changes["fld_status"]?.next_value`) behaves exactly like the
560
+ same field read off the record.
561
+ - **Multi-select `==` quietly desugars.** On a multi-select field,
562
+ `record["fld_tags"] == "opt_urgent"` rewrites to `includes(…)` at save. Either spelling is
563
+ fine — but a `switch` on a multi-select discriminant is **rejected**, because its value is an
564
+ array and no string `case` could ever match. Use `if (includes(…))` branches. `switch` cases
565
+ must be string literals, each in its own `{ }` block; there is no fallthrough, and a case on a
566
+ single-select discriminant is option-key-validated.
567
+ - **`let` survives a wait, so its values must survive JSON.** The binding stack is snapshotted at
568
+ the pause and hydrated on resume — no Dates, functions, or Maps in a `let` initializer.
569
+ - **An empty accumulator can't infer its element type.** `let xs = []` then
570
+ `xs = concat(xs, someArray)` fails to typecheck; appending a whole *array* has no `push`
571
+ spelling. Start that binding from the first array instead of seeding it `[]`. Appending single
572
+ **items** (`xs.push(item)`) works on a bare `[]` seed.
573
+ - **A mutation on the trigger table of an `after_*` workflow can re-trigger itself** — a lint
574
+ warning, not an error. Gate it on `trigger.change_origin.type == "member"` (the originating
575
+ write's origin). Not `runtime.change_origin`: that is the execution's own origin, always
576
+ `table_workflow` here, so it discriminates nothing.
577
+
578
+ ## The bright line
579
+
580
+ Three invariants make the engine safe, and nothing crosses them: an expression computes a value
581
+ but **cannot call a tool**; every callee is **statically named** so save-time analysis can extract
582
+ table and file ids; evaluation is **deterministic and pure**. So these are boundaries, not gaps:
583
+
584
+ `Math.random()`, `Date.now()`, `new Date()`, `new` at all (non-deterministic) · regex literals
585
+ (no engine, and ReDoS) · functions as values — a function passed, returned, or bound to a name
586
+ (`const f = (x) => …`) · recursion · `throw` · `import` / `export` · classes · `for-in` ·
587
+ computed property keys · loops or assignment inside an expression · `await` anywhere but a step.
588
+
589
+ The two `function` forms the subset *does* accept do not cross the line: a callback becomes a
590
+ lambda node, a declaration is inlined — neither survives to run time as a value.
591
+
592
+ The substitutes: a `bind` step for a value used twice, a top-level `function` for logic used
593
+ twice, a `for-of` step for repetition, `reduce` for a fold, and a separate workflow for anything
594
+ genuinely recursive.
595
+
596
+ ## The check loop
597
+
598
+ `lotics app workflow check [alias]` runs the server's **own** parse and type passes locally: the
599
+ same subset parser, the same generated `.d.ts` and envelope, the same compiler options, one
600
+ isolated program per alias. **Green means pushable and red means real** — do not push through a
601
+ red check.
602
+
603
+ Order matters. A subset rejection is reported *alone* and the compiler is skipped, because a body
604
+ the parser refuses never reaches the type checker on the server anyway. Diagnostics carry
605
+ `<file>:<line>:<col>` at the physical position in `src/workflows/<alias>.ts`.
606
+
607
+ What only the **server** can decide, so `check` stays green and `set` may still refuse:
608
+
609
+ - whether an identifier is a **registered tool** (the CLI ships no tool registry), and whether a
610
+ tool's kwargs are the right ones;
611
+ - **field, option, table, and group resolution** — a `fld_*` that doesn't exist, an `opt_*` that
612
+ isn't one of that field's options, a display name where a key belongs;
613
+ - **`switch` case validation** against a single-select's options, and the multi-select rejection;
614
+ - the **literal `formatDate` format probe**;
615
+ - **wait inside a loop**, and the `before_*` restrictions on waits and `agent` steps;
616
+ - the **lint** — `create_records` id reuse (an error), possible self-retrigger, deep link chains,
617
+ a button action with no `validate` guard (warnings).
618
+
619
+ There is no separate verify endpoint: the loop is `set` → read the returned diagnostics → fix →
620
+ `set`. Diagnostics arrive **batched** — independent errors across the whole body come back in one
621
+ round trip, not one per fix — and raw TypeScript shape errors are rewritten into field-naming,
622
+ fix-stating messages before you see them.
623
+
624
+ ## A worked body
625
+
626
+ An app action that creates an order after checking for a duplicate, then returns the new id.
627
+
628
+ ```js
629
+ const i = trigger.app_workflow.inputs;
630
+
631
+ const dup = await query_records({
632
+ table_id: "tbl_orders",
633
+ filters: {
634
+ node_type: "condition",
635
+ field_key: "fld_order_code",
636
+ operator: "equals",
637
+ value: i.order_code,
638
+ },
639
+ });
640
+
641
+ validate({ checks: [{
642
+ fail_when: size(dup.records) > 0,
643
+ field_key: "fld_order_code",
644
+ message: `Order code ${i.order_code} already exists.`,
645
+ }]});
646
+
647
+ await create_records({
648
+ table_id: "tbl_orders",
649
+ records: [{
650
+ fld_order_code: i.order_code,
651
+ fld_customer: [i.customer_id],
652
+ fld_status: "opt_open",
653
+ fld_opened_on: formatDate(now(), "yyyy-MM-dd"),
654
+ fld_note: i.note, // omitted optional input → undefined → key dropped
655
+ }],
656
+ });
657
+
658
+ // create_records ids are not reusable — re-read by the unique code we just wrote.
659
+ const created = await query_records({
660
+ table_id: "tbl_orders",
661
+ filters: {
662
+ node_type: "condition",
663
+ field_key: "fld_order_code",
664
+ operator: "equals",
665
+ value: i.order_code,
666
+ },
667
+ });
668
+
669
+ // A path can't hang off a helper call — bind it, then read.
670
+ const top = requireFirst(created.records);
671
+
672
+ return({
673
+ status: "success",
674
+ message: "Order created.",
675
+ data: { order_id: top.id },
676
+ });
677
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.58.1",
3
+ "version": "0.58.4",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {