@lotics/app-sdk 0.58.0 → 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 +1 -0
- package/docs/ai.md +2 -2
- package/docs/mutations.md +11 -6
- package/docs/workflows.md +677 -0
- package/package.json +1 -1
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/ai.md
CHANGED
|
@@ -21,8 +21,8 @@ A declaration carries:
|
|
|
21
21
|
|---|---|
|
|
22
22
|
| `instructions` | System instructions — the task the agent performs per run |
|
|
23
23
|
| `tool_names` | The tools the agent may call, resolved against the platform's automation tool registry. **This is the capability boundary** — the run can use nothing else. May be empty — including for an agent that reads documents, since a [`file` input carries its own content](#file-inputs--what-the-agent-can-actually-see) |
|
|
24
|
-
| `model_id` |
|
|
25
|
-
| `effort_level` | Optional reasoning depth for adaptive-thinking models |
|
|
24
|
+
| `model_id` | Optional chat model pin. Omit (preferred) to follow the platform default chat model, resolved at run time — the agent tracks model generations with no rewrite. Pin only a deliberate, tested choice |
|
|
25
|
+
| `effort_level` | Optional reasoning depth for adaptive-thinking models. Requires an explicit `model_id` pin — effort is tuned per model |
|
|
26
26
|
| `inputs` | Optional typed input schema for one run — the same vocabulary as workflow inputs (`text`, `number`, `file`, `member`, `record_link`, `select`, …). The server validates every run payload against it |
|
|
27
27
|
| `outputs` | Optional typed output schema. Declared → **structured agent** (the run must emit a matching result); omitted → **free-text agent** (the answer is the final prose) |
|
|
28
28
|
|
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.
|
|
10
|
-
|
|
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? })
|
|
128
|
-
|
|
129
|
-
`
|
|
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
|
+
```
|