@lotics/cli 0.315.18 → 0.316.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +37 -24
- package/README.md +42 -37
- package/dist/src/cli.js +27215 -48999
- package/dist/src/client.d.ts +12 -88
- package/dist/src/client.js +10 -74
- package/dist/src/invocation.d.ts +1 -1
- package/docs/agent_surface.md +121 -114
- package/docs/app_architecture.md +39 -0
- package/docs/app_bindings.md +24 -62
- package/docs/building_an_app.md +97 -81
- package/docs/cli_reference.md +18 -19
- package/docs/data_model.md +129 -183
- package/docs/design.md +156 -170
- package/docs/document_templates.md +125 -99
- package/docs/examples.md +1599 -1238
- package/docs/field_values.md +14 -11
- package/docs/filters.md +47 -9
- package/docs/first_build.md +180 -0
- package/docs/getting_started.md +15 -0
- package/docs/knowledge_docs.md +73 -209
- package/docs/migration.md +22 -26
- package/docs/workflow_steps.md +22 -579
- package/docs/workflows.md +407 -208
- package/docs/working_in_a_workspace.md +60 -0
- package/package.json +1 -1
package/docs/workflows.md
CHANGED
|
@@ -6,13 +6,27 @@ Only the forms below are accepted; anything else is refused at save with the lin
|
|
|
6
6
|
what to write instead. A save that succeeds may still return `warnings` — advisory hints such as a
|
|
7
7
|
loop that may never end. Read them.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
A workflow changes records, calls integrations, notifies and makes papers. A tool for anything else — views, history,
|
|
10
|
+
tables and apps, members, knowledge, skills, editing a file — is an agent's, on chat, MCP or the CLI, and a body that
|
|
11
|
+
calls one is refused at save with that reason. One saved before is refused at run, before its first step.
|
|
12
|
+
|
|
13
|
+
The same grammar serves an app's workflow, which `set_app_workflow` saves, an automation and the expressions of a table
|
|
14
|
+
check. An app workflow takes its trigger from the app that calls it; only an automation's source opens with a trigger
|
|
15
|
+
declaration.
|
|
12
16
|
|
|
13
17
|
## Triggers
|
|
14
18
|
|
|
15
|
-
An
|
|
19
|
+
An app workflow reads its call as `trigger.app_workflow.inputs.<name>`, or `inputs.<name>`, which is the same read. A Workflow node receives the inputs it declares;
|
|
20
|
+
an Act's `workflow` receives `record_id` (`record_ids` on an act over several rows) and what the Act asks.
|
|
21
|
+
|
|
22
|
+
### Automations
|
|
23
|
+
|
|
24
|
+
An automation answers an event outside the records: a schedule, a webhook, an inbound email. `set_automation` saves one
|
|
25
|
+
from its source — the `on({...})` declaration, then its steps — and, given its `automation_id`, replaces it: the
|
|
26
|
+
trigger config is the one the header states, a key it leaves out removed, and steps left as it runs them are checked again
|
|
27
|
+
only when the call turns it on, so one a later rule refuses can still be switched off. `get_automation` reads that source back with its latest
|
|
28
|
+
runs, and `dry_run_workflow` rehearses a source before it is saved. It runs with its owner's current permissions, a connected service it names is its owner's account, and only its
|
|
29
|
+
owner or an admin reads, changes or deletes it.
|
|
16
30
|
|
|
17
31
|
```
|
|
18
32
|
on({ type: "recurring_schedule", cron_expression: "0 9 * * MON" });
|
|
@@ -27,29 +41,13 @@ All three carry `trigger.trigger_id`.
|
|
|
27
41
|
| `receive_webhook` | `secret`? | `trigger.method`, `trigger.headers[...]`, `trigger.query[...]`, `trigger.body` (any JSON, or raw text when the request is not JSON), `trigger.received_at` |
|
|
28
42
|
| `receive_gmail_email` / `receive_outlook_email` | `connected_account_id`, `filter` | `trigger.email_id`, `trigger.from`, `trigger.to`, `trigger.cc`, `trigger.bcc`, `trigger.subject`, `trigger.date`, `trigger.body` (plain text), `trigger.reply_to`, `trigger.in_reply_to`, `trigger.attachments` (file refs — assign them straight to a files field). One type serves both providers, so `labels`, `thread_id`, `importance` and `conversation_id` are not readable. |
|
|
29
43
|
|
|
30
|
-
A whole automation:
|
|
31
|
-
|
|
32
|
-
```
|
|
33
|
-
on({ type: "recurring_schedule", cron_expression: "0 9 * * MON" });
|
|
34
|
-
|
|
35
|
-
const open = await query_records({
|
|
36
|
-
table_id: "tbl_tickets",
|
|
37
|
-
filters: { node_type: "condition", field_key: "fld_status", operator: "has_any_of", value: ["opt_open"] },
|
|
38
|
-
});
|
|
39
|
-
await send_email({
|
|
40
|
-
to: "team@example.com",
|
|
41
|
-
subject: "Weekly digest",
|
|
42
|
-
body: `${size(open.records)} tickets open as of ${formatDate(now(), "yyyy-MM-dd")}.`,
|
|
43
|
-
});
|
|
44
|
-
```
|
|
45
|
-
|
|
46
44
|
## Steps
|
|
47
45
|
|
|
48
46
|
| step | syntax |
|
|
49
47
|
|---|---|
|
|
50
48
|
| tool call, kept | `const <id> = await <tool>({ ...inputs });` — also `let x = await …` and `x = await …` |
|
|
51
|
-
| tool call | `await <tool>({ ...inputs });` — a connection's tools
|
|
52
|
-
| agent | `const <id> = await agent({ instructions, input,
|
|
49
|
+
| tool call | `await <tool>({ ...inputs });` — the platform's own steps (`send_notification`, `generate_document`, …) and a connection's tools, each a workflow step (`bkav_submit_invoices`, `kiotviet_create_order`, …); a step that fails ends the run with its reason unless a `try`/`catch` around it handles it |
|
|
50
|
+
| agent | `const <id> = await agent({ instructions, input, model, output });` — see **An agent step** |
|
|
53
51
|
| app agent | `const <id> = await app_agent({ alias: "<agent alias>", input: { ... } });` — runs one of the app's declared agents, in an app workflow only, and resolves to its declared outputs or its final text. `wait: false` only starts the run and resolves to `{ run_id }`; the run's failure is then its own, not the workflow's. Refused in a run an agent started. |
|
|
54
52
|
| bind | `const <id> = <expression>;` — evaluated once; later steps read `<id>`. Bind any expression used twice. |
|
|
55
53
|
| if / else | `if (<expr>) { ... } else { ... }` — `else` optional, `else if` chains allowed |
|
|
@@ -58,16 +56,25 @@ await send_email({
|
|
|
58
56
|
| wait | `await wait({ duration_in_minutes: 5 });` |
|
|
59
57
|
| wait for an event | `await wait_for_event({ event_type: "webhook", event_ref: <expr>, timeout_in_minutes: 60 });` |
|
|
60
58
|
| wait for an approval | `await wait_for_approval({ approvers: <expr>, prompt: <expr> });` — see **Waiting for an approval** |
|
|
61
|
-
| return | `return({ status: "success" \| "error", message: <expr>, field_errors: { ... }? });` |
|
|
59
|
+
| return | `return({ status: "success" \| "error", message: <expr>, field_errors: { ... }?, data: { ... }? });` — `data` holds the outputs a Workflow node declares |
|
|
62
60
|
| validate | `validate({ checks: [{ fail_when: <expr>, field_key: "...", message: <expr> }, ...] });` |
|
|
63
61
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
A delayed run is never cancelled from a workflow: after a `wait`, it reads the row's state and acts only if it still holds.
|
|
63
|
+
|
|
64
|
+
A tool's inputs and answer are in its own description; the steps only a body runs — a connection's, a mail's — are each
|
|
65
|
+
an entry of `workflow_steps`. A person connects a service in Lotics under Settings → Connections; a step names that
|
|
66
|
+
connection by its `connected_account_id`, which `get_workspace` with `kind: "connection"` lists for the connections the
|
|
67
|
+
caller can use. The app's owner and whoever saves the body must both be able to use that connection.
|
|
68
|
+
|
|
69
|
+
In a workflow, email reaches a member of the workspace through the step `send_notification`: Lotics shows it to them and
|
|
70
|
+
mails it to their own address, with no connection. Its recipients are members by `member_id` (`get_workspace` with
|
|
71
|
+
`kind: "member"` lists them), member groups or the whole organization. Mail to anyone else goes from a person's
|
|
72
|
+
connected Gmail or Outlook (`gmail_send_email`, `outlook_send_email`), which leaves an unsent draft unless `draft` is
|
|
73
|
+
`false`.
|
|
67
74
|
|
|
68
75
|
`return` is a call, not a JavaScript `return` statement. `validate` ends the workflow with an error
|
|
69
76
|
when a check's `fail_when` is truthy; the check's `field_key` names the control its message lands on —
|
|
70
|
-
in an app workflow a declared input, or the
|
|
77
|
+
in an app workflow a declared input, or the alias of a field on a table the body names; in an
|
|
71
78
|
automation it is not checked.
|
|
72
79
|
|
|
73
80
|
The name in `const x = await tool({...})` is the step's id; later expressions read its output as
|
|
@@ -82,8 +89,8 @@ test, a `validate` check — is refused; write the `if`.
|
|
|
82
89
|
|
|
83
90
|
**Names are block-scoped, as in JavaScript** — `const`, `let`, loop items and `catch (e)`: sibling
|
|
84
91
|
blocks may reuse a name and an inner block may shadow an outer one. A local may take a helper's name
|
|
85
|
-
(`const size = 3;`), but calling `size(...)` while it is in scope is refused.
|
|
86
|
-
roots, `linked` and `_s` followed by digits cannot be bound.
|
|
92
|
+
(`const size = 3;`), but calling `size(...)` while it is in scope is refused. The names of the tools a
|
|
93
|
+
workflow calls, the reserved roots, `linked` and `_s` followed by digits cannot be bound.
|
|
87
94
|
|
|
88
95
|
### Waiting for an approval
|
|
89
96
|
|
|
@@ -112,34 +119,63 @@ is.
|
|
|
112
119
|
|
|
113
120
|
### An agent step
|
|
114
121
|
|
|
115
|
-
`const x = await agent({ instructions, input,
|
|
116
|
-
|
|
122
|
+
`const x = await agent({ instructions, input, model, output });` runs a model as one step — read a paper, classify,
|
|
123
|
+
draft text. It calls no tool: the workflow's own steps write, send and notify with what it returns, and a step that
|
|
124
|
+
states `tools` is refused.
|
|
117
125
|
|
|
118
126
|
- `instructions` — a fixed string.
|
|
119
127
|
- `input` — an object literal; its fields are expressions, evaluated and handed to the model (`{}` for
|
|
120
128
|
none). It is the ONLY workflow data the agent sees: read the trigger, prior steps, `runtime` or the loop
|
|
121
|
-
item here, never inside `instructions
|
|
122
|
-
|
|
129
|
+
item here, never inside `instructions`, which is a fixed literal and reads no binding. A field holding a file, or a
|
|
130
|
+
list of files (a files field, a mail's attachments), hands the agent the files themselves: a picture or a PDF it
|
|
131
|
+
sees, any other paper read into text.
|
|
123
132
|
- `model` — optional; omit it to follow the platform's default model.
|
|
124
133
|
- `output` — `{ mode: "text" }` resolves to a string; `{ mode: "object", schema }` to a typed object.
|
|
125
134
|
|
|
126
135
|
Only `x` comes back: in text mode `x` is the string itself, in object mode `x.field` reads a field the
|
|
127
|
-
`schema` declares.
|
|
128
|
-
|
|
129
|
-
##
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
`schema` declares.
|
|
137
|
+
|
|
138
|
+
## Naming tables and templates
|
|
139
|
+
|
|
140
|
+
A body names a table or document template by its alias (`query_records({ table_id: "orders" })`); ids still work. `get_app_workflow` and `get_automation` show aliases. An app's workflow reaches only the tables the app uses.
|
|
141
|
+
|
|
142
|
+
## Fields and options
|
|
143
|
+
|
|
144
|
+
1. **A body names a table's fields and select options by alias**, the names the table's document states: a field reads
|
|
145
|
+
as `record["status"]`, an option as `"done"`, a linked field as `linked(record["customer"])[0]["name"]`, in the
|
|
146
|
+
body of a Workflow node, of an Act's own `workflow`, of an automation (`set_automation`) and of a table's workflow. It is stored by key and `get_app_workflow`, `get_automation` and `get_table` show it by alias
|
|
147
|
+
again. A field's or option's key is accepted where its alias is and is kept as written; a display name is refused with the
|
|
148
|
+
aliases there are, and a row that names one field by both its alias and its key is refused. A row written to a table is
|
|
149
|
+
stored by field key wherever the save can follow it to the record tool that writes it: a row or list written in the call,
|
|
150
|
+
a conditional of them, a name holding them, pushed to a `let`, and through `map`, `flatMap`, `concat`, `filter`, `sortBy`,
|
|
151
|
+
`slice` and `reverse`. A field of such rows is read by alias as well (`picks.toSorted((a, b) => a.quantity - b.quantity)`,
|
|
152
|
+
`sortBy(picks, "quantity")`, `rows[0].note`) and stored by key. A body that cannot be stored by key is refused at save,
|
|
153
|
+
naming the statement.
|
|
154
|
+
2. **An Act's own `workflow`** has the app's guards placed at the top of the body, between `<lotics:guards>` tags,
|
|
155
|
+
replaced on every save: a save that leaves them out or edits them gets the app's again, at the top wherever the tags
|
|
156
|
+
sat, so the act's row rule, `when`, `requires`, checks and locks always run first.
|
|
157
|
+
3. **A value is read as the record tools store it.** A single select reads as its option and a member as its id; an
|
|
158
|
+
option is written by its alias wherever it meets a select — compared, listed, switched on, written — also beside a
|
|
159
|
+
name set from the field (`const s = record.status; s == "done"`), and a select
|
|
160
|
+
value handed on whole (returned, written to a text field) is its key. An option's alias searched for in a list the
|
|
161
|
+
save cannot place it against (`includes(filter(record.tags, …), "hot")`) is refused: search the field itself. A link reads as the list of its row ids, a
|
|
162
|
+
one-row link too: `first(record.customer)` is the one id, and `record.customer` used as one id is refused at save,
|
|
163
|
+
naming `first(record.customer)`. A one-row link is written with its one id or a list of one; a many-row link with a list. A lookup reads as one flat list of the values it looks up: `includes(record.owner,
|
|
164
|
+
me)`. A select's value is one of another select's options only where that table shares its keys, by having been
|
|
165
|
+
duplicated from it, not where an option merely shares its alias. A row that takes its parent's option gets it from
|
|
166
|
+
`default_from`, and a lookup shows it.
|
|
167
|
+
4. **What an app sends takes an option by alias or key.** An input or output drawn from a field (`field`, written
|
|
168
|
+
`<table id>.<alias>`) compares and returns by alias in the body, and the run hands it on as the option's key. A
|
|
169
|
+
refusal's `field_errors` and a `validate` check's `field_key` name a declared input or a field's alias.
|
|
170
|
+
5. **`==` against a multi-select means "includes".** `record["tags"] == "urgent"` on a multi-select is stored as
|
|
171
|
+
`includes(record["tags"], "urgent")`; either spelling works.
|
|
172
|
+
6. **Text renders labels.** Inside a template or a text tool input, a select, member or link value renders its display
|
|
173
|
+
text: `` `${record["status"]}` `` writes "Done", not `done`.
|
|
138
174
|
|
|
139
175
|
## What an expression reads
|
|
140
176
|
|
|
141
177
|
- `record`, `prev_record`, `changes` — in a table check, the write it checks (see **Table checks**).
|
|
142
|
-
`changes` is a PARTIAL map: `changes["
|
|
178
|
+
`changes` is a PARTIAL map: `changes["x"]?.next_value` and `?.prev_value`, the `?.` required.
|
|
143
179
|
- `trigger` — what the trigger carries (see **Triggers**).
|
|
144
180
|
- a loop's item name, and `index`, the iteration's 0-based index.
|
|
145
181
|
- `<step id>` — an earlier step's output (`dup.records`).
|
|
@@ -149,17 +185,18 @@ Only `x` comes back: in text mode `x` is the string itself, in object mode `x.fi
|
|
|
149
185
|
|
|
150
186
|
## Paths
|
|
151
187
|
|
|
152
|
-
- `record.
|
|
153
|
-
- A path may start at an awaited call
|
|
154
|
-
|
|
188
|
+
- `record.x` and `record["x"]` read a field; `[n]` reads a list's n-th item.
|
|
189
|
+
- A path may start at any expression: an awaited call `(await get_record({...})).data["x"]`, a helper's
|
|
190
|
+
result `first(found.records).id`, a filter `rows.filter((r) => r.ok)[0].n`, a `(a ?? b).c`. `?.` reads
|
|
191
|
+
through a null as usual, except on a value drawn at random (`sample(xs)?.id` is refused: bind it first).
|
|
155
192
|
- A record reads in its declared shape wherever it comes from — the trigger, a tool result, a `let`, a
|
|
156
|
-
callback parameter: a single select
|
|
193
|
+
callback parameter: a single select as its option alias or null, a member as its id, a link as its ids. (The record
|
|
157
194
|
tools called outside a workflow return the stored arrays instead.)
|
|
158
|
-
- `linked(record["
|
|
159
|
-
`linked(record["
|
|
160
|
-
a link field and carries the guard: `linked(record?.["
|
|
161
|
-
- `changes["
|
|
162
|
-
read `linked(record["
|
|
195
|
+
- `linked(record["link"])[n]["x"]` fetches the n-th linked row and reads a field of it; bare
|
|
196
|
+
`linked(record["link"])` is every linked row, one fetch per row. Its argument is a path ending on
|
|
197
|
+
a link field and carries the guard: `linked(record?.["link"])[0]`.
|
|
198
|
+
- `changes["link"]?.next_value` is the same list of ids; `linked()` over a change is refused —
|
|
199
|
+
read `linked(record["link"])` or `linked(prev_record["link"])`.
|
|
163
200
|
|
|
164
201
|
## Operators and statements
|
|
165
202
|
|
|
@@ -174,9 +211,9 @@ prefix `! -`.
|
|
|
174
211
|
- `??` is `coalesce(left, right)`; `?.` reads through a null (`a?.b`, `a?.[0]`, and `s?.trim()` for the
|
|
175
212
|
method names listed under **Helpers**). `x?.()` is refused: helpers and tools are not values.
|
|
176
213
|
- Templates use backticks and `${...}`.
|
|
177
|
-
- Destructuring — `const {
|
|
178
|
-
(`{
|
|
179
|
-
(`const [first, , third] = xs;`) and defaults (`{
|
|
214
|
+
- Destructuring — `const { status, name } = record;` binds each name. Renames
|
|
215
|
+
(`{ status: s }`), quoted keys (`{ "ref": ref }`), array patterns with holes
|
|
216
|
+
(`const [first, , third] = xs;`) and defaults (`{ note = "" }`, which apply on null too) work on
|
|
180
217
|
`const`, `let` and `for (const { id } of rows)`. A tool result destructures directly:
|
|
181
218
|
`const { records } = await query_records({...});`. `const a = 1, b = 2;` declares both. Nested
|
|
182
219
|
patterns and rest are refused.
|
|
@@ -186,9 +223,11 @@ prefix `! -`.
|
|
|
186
223
|
- `xs.push(a, b);` appends, also on a key (`o.items.push(v)`); `o.a.b = v;` sets a nested key;
|
|
187
224
|
`x ??= v`, `x ||= v` and `x &&= v` assign. A `const` may be pushed to or have a key set, as in
|
|
188
225
|
JavaScript; a loop item is read-only.
|
|
189
|
-
- `undefined` is the same value as `null
|
|
190
|
-
|
|
191
|
-
|
|
226
|
+
- `undefined` is the same value as `null`, so `x === undefined` is `isNull(x)` and `x !== undefined` is
|
|
227
|
+
`isNotNull(x)`; a key set to null and a key never sent are not told apart (`"k" in o` tells a key that
|
|
228
|
+
is there).
|
|
229
|
+
- `typeof x` answers JavaScript's words, and a null (a missing value too) is `"object"`. `k in o` tests an
|
|
230
|
+
object's own keys, or a list's indexes and `length`.
|
|
192
231
|
- A filter node may leave out `node_type` when its keys say which it is: `field_key` / `operator` /
|
|
193
232
|
`value` a condition, `logic` / `children` a group, `path` / `condition` a traversal.
|
|
194
233
|
- `function name(p = <default>) { return <expr>; }` — top level only, expanded at every call (a call
|
|
@@ -211,16 +250,52 @@ on the innermost loop; labels and `for-in` are refused.
|
|
|
211
250
|
the same block is refused; shadowing in a nested block is allowed. A `let` keeps its value across a
|
|
212
251
|
`wait`, `wait_for_event` or `wait_for_approval`.
|
|
213
252
|
|
|
214
|
-
**Refused
|
|
215
|
-
|
|
253
|
+
**Refused, each because a save must know what runs and a run must give the same answer twice:** regex
|
|
254
|
+
(unbounded matching: use the string methods), `Math.random`, `Date.now()` and `new Date()` (use `now()` and
|
|
255
|
+
`parseDate`), a tool or helper as a value or a computed callee (callees are named), `new` of anything,
|
|
256
|
+
`instanceof`, `delete`, `void`, rest elements, computed keys, classes, `throw`, `finally`, `import`,
|
|
257
|
+
`export`, `eval`, labels, and a function as a value (`const f = (x) => ...`: pass the arrow to the helper
|
|
258
|
+
that takes it). `xs.sort(...)` on a name or path is refused too, since a value is never changed in place:
|
|
259
|
+
write `xs.toSorted(...)` or `[...xs].sort(...)`.
|
|
260
|
+
|
|
261
|
+
## Typing
|
|
262
|
+
|
|
263
|
+
- The body is type-checked as TypeScript before it is read: annotations, `as`, `satisfies` and a non-null `!`
|
|
264
|
+
are accepted and erased, and the checker is strict about null. The stored body keeps none of them and is checked again as
|
|
265
|
+
stored, by field key, as every later change to its app or tables checks it: a body that types only through one of them is
|
|
266
|
+
refused at save.
|
|
267
|
+
- Narrow what is not typed (a connector's JSON is `unknown`) with `typeof`, `in`, `Array.isArray` and `isNull`.
|
|
268
|
+
- `first(xs)` may be undefined; `requireFirst(xs)` is the list's first item or an error.
|
|
269
|
+
- `obj[key]` needs `key` to be a key of `obj`, or `obj` to be a record with a string index.
|
|
216
270
|
|
|
217
271
|
## Helpers
|
|
218
272
|
|
|
219
|
-
|
|
273
|
+
Ordinary JavaScript works — the standard library below, the array and string methods, `Math`, `JSON`,
|
|
274
|
+
`Object`, `Array`, `Number`, `parseInt` — and means what it means in JavaScript, with the differences
|
|
275
|
+
listed here. Where JavaScript answers NaN, Infinity or undefined, a workflow has none of them and the
|
|
276
|
+
answer is `null`.
|
|
277
|
+
|
|
278
|
+
- Standard library: `Math.abs/ceil/floor/trunc/sign/min/max/pow/sqrt`, `Number(x)`, `String(x)`,
|
|
279
|
+
`Boolean(x)`, `parseInt(s, radix?)`, `parseFloat(s)`, `Number.isFinite/isInteger`,
|
|
280
|
+
`Object.keys/values/entries/fromEntries`, `Array.isArray/from` (a list, text, or `{ length: n }`),
|
|
281
|
+
`JSON.parse/stringify`.
|
|
282
|
+
- Arrays: `length, map, filter, find, findIndex, findLast, some, every, reduce, includes, indexOf, join,
|
|
283
|
+
slice, concat, flat, flatMap, at, reverse, toReversed, toSorted` and `[...xs].sort(compare?)`.
|
|
284
|
+
- Strings: `length, toLowerCase, toUpperCase, trim, trimStart, trimEnd, startsWith, endsWith, includes,
|
|
285
|
+
indexOf, slice, substring, split, replace, replaceAll, padStart, padEnd, repeat, charAt, at,
|
|
286
|
+
localeCompare` (en-US order, wherever it runs). Numbers: `toFixed, toString`.
|
|
287
|
+
- Differences from JavaScript: `Math.round` is the engine's `round` (a half goes away from zero:
|
|
288
|
+
`Math.round(-2.5)` is `-3`); `Number(x)` and `parseFloat(x)` are `toNumber`, which refuses text that is no
|
|
289
|
+
number; `String(null)` is `""`; `join` writes a null item as `null`; `concat` skips a null; `toFixed`
|
|
290
|
+
rounds the decimal, not the binary double; `replaceAll` writes the replacement as text
|
|
291
|
+
(`$&` stays as typed), while `replace` expands `$&`, `$$`, `` $` `` and `$'` in it as JavaScript does; `split` takes no limit; `sort` without a comparator orders by text, as JavaScript does. `==`, `!=`, `===` and `!==` between two list values (files, a multi-select, a link of any cardinality, a lookup) are refused at save, since a list compares by identity, not by its items: `!isNull(changes.<field>)` asks whether one changed.
|
|
292
|
+
|
|
293
|
+
The same work can be written with the helpers below, which are additions to JavaScript. They are called as
|
|
294
|
+
`size(arr)`; the method form works only where the name is also a JavaScript method —
|
|
220
295
|
`x.trim()`, `arr.includes(v)`, `arr.at(-1)`, `arr.map(fn)`, `s.split(",")`; `arr.size()` is refused.
|
|
221
296
|
|
|
222
297
|
- **Null and type**: `isNull`, `isNotNull`, `isEmpty`, `isString`, `isNumber`, `isBoolean`, `isArray`,
|
|
223
|
-
`isObject`, `coalesce`, `get`, `toNumber`, `
|
|
298
|
+
`isObject`, `coalesce`, `get`, `toNumber`, `toFigure`, `toString` (a text, number, yes/no or date; a record, file or list is refused at save, an object at run), `typeOf`, `parseJson`, `toJson`. `isNull` is strict (`null` and `undefined` only), but on a list field of the record (a link, files, a multi-select) it asks whether the list holds nothing, since a cleared list is `[]`: `!isNull(record.vehicle)` reads as `!isEmpty(record.vehicle)`. `isNull(changes.<field>)` stays strict: null there means unchanged
|
|
224
299
|
- **Lists**: `size`, `first`, `requireFirst` (the first item, refusing an empty list — after a
|
|
225
300
|
`validate` on the size it saves an `if`), `last`, `nth`, `at` (`at(arr, -1)` counts from the end),
|
|
226
301
|
`includes`, `filter`, `find`, `some`, `every`, `pluck`, `sortBy`, `groupBy`, `countBy`, `unique`,
|
|
@@ -234,15 +309,19 @@ Called as `size(arr)`. The method form works only where the name is also a JavaS
|
|
|
234
309
|
`padEnd(str, length, char)`, `formatNumber(x, decimals)` (ungrouped; rounds the decimal, a half away
|
|
235
310
|
from zero: `formatNumber(1.005, 2)` is `1.01`),
|
|
236
311
|
`formatDecimal(x, decimals, locale)` (grouped: `formatDecimal(151000, 0, "vi-VN")` is `151.000`),
|
|
237
|
-
`numberToWords(x, lang?)` (an integer in words: Vietnamese, or English with `"en"`)
|
|
312
|
+
`numberToWords(x, lang?)` (an integer in words: Vietnamese, or English with `"en"`),
|
|
313
|
+
`uuidFrom(text)` (the same UUID for the same text, refusing null and empty text: `uuidFrom(record.id)`)
|
|
238
314
|
- **Objects**: `keys`, `values`, `entries`, `nonNullKeys`, `pick`, `omit`, `merge`
|
|
239
315
|
- **Dates**, in the workspace's timezone: `now`, `formatDate`, `parseDate`, `addDays`, `subDays`,
|
|
240
316
|
`addHours`, `subHours`, `addMinutes`, `subMinutes`, `startOfDay`, `endOfDay`,
|
|
241
317
|
`differenceInCalendarDays`, `differenceInHours`, `differenceInMinutes`, `isBefore`, `isAfter`,
|
|
242
|
-
`isSameDay`, `isToday`, `isWithinRange`
|
|
318
|
+
`isSameDay`, `isToday`, `isWithinRange`, `meetsBounds`
|
|
243
319
|
- **Other**: `formatCurrency(amount, locale, currency)`, `randomNumber(len)`,
|
|
244
320
|
`randomAlphaNumeric(len)`, `sample(items)` (a random item)
|
|
245
321
|
|
|
322
|
+
Over a null or empty list `mean`, `meanBy`, `min`, `max`, `minBy` and `maxBy` answer null, while `sum`
|
|
323
|
+
and `sumBy` answer 0: `maxBy(pieces, "reach")?.Name` prints a blank for a month with no pieces.
|
|
324
|
+
|
|
246
325
|
A date helper given a null or empty date returns null, so guard its result before comparing or
|
|
247
326
|
writing it: `differenceInCalendarDays(a, b) ?? 0`.
|
|
248
327
|
|
|
@@ -261,12 +340,12 @@ rows once, then read each group by its key. `groupBy` answers an object of lists
|
|
|
261
340
|
is the group a code names, or an empty list:
|
|
262
341
|
|
|
263
342
|
```
|
|
264
|
-
const parties = await query_records({ table_id: "
|
|
265
|
-
const lines = await query_records({ table_id: "
|
|
266
|
-
const byCode = groupBy(parties.records, (p) => p.data["
|
|
343
|
+
const parties = await query_records({ table_id: "parties" });
|
|
344
|
+
const lines = await query_records({ table_id: "sheet_lines" });
|
|
345
|
+
const byCode = groupBy(parties.records, (p) => p.data["code"] ?? "");
|
|
267
346
|
const holders = lines.records.map((line) => ({
|
|
268
347
|
line: line.id,
|
|
269
|
-
parties: (byCode[line.data["
|
|
348
|
+
parties: (byCode[line.data["code"] ?? ""] ?? []).map((p) => p.id),
|
|
270
349
|
}));
|
|
271
350
|
```
|
|
272
351
|
|
|
@@ -281,7 +360,7 @@ In `update_records`' `set` and `create_records`' `records`:
|
|
|
281
360
|
- `null` clears the field.
|
|
282
361
|
- `undefined`, or leaving the key out, leaves the field as it is.
|
|
283
362
|
|
|
284
|
-
So passing a read that may be null (`record["
|
|
363
|
+
So passing a read that may be null (`record["x"]`) keeps a value when there is one and clears the
|
|
285
364
|
field when there is none. Use `coalesce(x, fallback)` only for a real fallback value.
|
|
286
365
|
|
|
287
366
|
## Examples
|
|
@@ -291,13 +370,13 @@ string is a type error:
|
|
|
291
370
|
|
|
292
371
|
```
|
|
293
372
|
const found = await query_records({
|
|
294
|
-
table_id: "
|
|
295
|
-
filters: { node_type: "condition", field_key: "
|
|
373
|
+
table_id: "customers",
|
|
374
|
+
filters: { node_type: "condition", field_key: "name", operator: "equals", value: "ACME Corp" },
|
|
296
375
|
});
|
|
297
376
|
const customer = first(found.records);
|
|
298
|
-
const order = await get_record({ table_id: "
|
|
377
|
+
const order = await get_record({ table_id: "orders", record_id: trigger.app_workflow.inputs.order_id });
|
|
299
378
|
|
|
300
|
-
if (customer && includes(order.data["
|
|
379
|
+
if (customer && includes(order.data["customer"], customer.id)) {
|
|
301
380
|
// …
|
|
302
381
|
}
|
|
303
382
|
```
|
|
@@ -314,11 +393,11 @@ A sheet past what one call reads is refused with its size, never cut short: the
|
|
|
314
393
|
names the fields that identify a row, compared as trimmed text with case ignored: a row matching no live
|
|
315
394
|
row is added, one matching a row changes only the cells that differ, and one that changes nothing is
|
|
316
395
|
counted and not written, so the same file run twice writes nothing the second time. `values` holds a
|
|
317
|
-
row's cells by field
|
|
318
|
-
it, and `null` is an empty cell, which differs from a filled one as any other value does.
|
|
396
|
+
row's cells by field alias, as `create_records` takes them; a key the row omits is left as the table holds
|
|
397
|
+
it, and `null` is an empty cell, which differs from a filled one as any other value does. `dry_run: true` judges the import as a real one would be, rolls it back and answers what it would write or why it is refused.
|
|
319
398
|
|
|
320
399
|
A `match` field may also be a link field holding one row, compared by the row linked: lines keyed by
|
|
321
|
-
their parent and a line number state `match: ["
|
|
400
|
+
their parent and a line number state `match: ["invoice", "line"]`, the parent given as its
|
|
322
401
|
record id or as `{ lookup: "<text>" }`.
|
|
323
402
|
|
|
324
403
|
What the step is not certain of is held — answered with why, and not written: a row carrying `hold`
|
|
@@ -352,25 +431,15 @@ reach of the table, and a workflow hands its caller only what it returns. Two ca
|
|
|
352
431
|
by the same fields take turns, so the second matches the rows the first added. The step's entry in
|
|
353
432
|
`workflow_steps` states its bounds.
|
|
354
433
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
A row the run brought back from the trash stays out of it.
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
what the run wrote on it cannot be read, or the run brought it back from the trash.
|
|
365
|
-
|
|
366
|
-
Two more workflow steps read and settle one record's saves: `get_record_activity`, a workflow step,
|
|
367
|
-
lists them, and `undo_record_change`, a workflow step, puts one back. What they read is the run's to
|
|
368
|
-
read; inside an app's workflow it is what the app reads — the fields of the app's entity over the table,
|
|
369
|
-
a record that entity's own read reaches. An undo, of one save or of a run's writes, is the own save of the
|
|
370
|
-
member the run acts for — whoever started the app's workflow, pressed the button, or made the write a
|
|
371
|
-
table's workflow followed: that member holds the table's write as the step runs, each row within the
|
|
372
|
-
rows they may write, and the table's locks, checks and write rules answer for them. A run that acts for
|
|
373
|
-
no member — a schedule's, a public visitor's — reads, and its undos are refused.
|
|
434
|
+
A workflow does not put an import back: that reads the history of what the run wrote, which a member's agent reads
|
|
435
|
+
under the member's own access to the table and no workflow does. On chat, MCP or the CLI, `undo_record_changes` takes the
|
|
436
|
+
table, up to 500 record ids and the run — what `runtime.execution_id` held in it, which a body keeps on a row of its own —
|
|
437
|
+
and puts back what that run wrote, as the member's own writes. A field goes back where the row still holds what the run
|
|
438
|
+
wrote. A row the run created goes to the trash where nothing else has written it or linked a row to it since; in a table
|
|
439
|
+
where a workflow writes each new row, every row created reads as written since, and is kept. A row the run moved to the
|
|
440
|
+
trash comes back where nothing has touched it since. A row the run brought back from the trash stays out of it. What the
|
|
441
|
+
tool leaves as it stands is answered in `kept`, one entry a record and reason. `undo_record_change` puts back one edit
|
|
442
|
+
named by its audit-log entry, the same way.
|
|
374
443
|
|
|
375
444
|
What a business keeps of an import — the file, its counts, the lines waiting for a person — is rows of
|
|
376
445
|
its own tables, written by the workflow that ran the step. A garden centre brings its grower's price
|
|
@@ -379,8 +448,8 @@ holding many rows: one holding a single row would make the line that plant's own
|
|
|
379
448
|
lines belong to is not moved to the trash.
|
|
380
449
|
|
|
381
450
|
These tables are shown beside the plants, in the same app: the sheets as a log of the plants' register
|
|
382
|
-
(
|
|
383
|
-
and what it holds now in two fields reads one against the other (`compare`, `
|
|
451
|
+
(a further List at the root, `model/app-node`), a sheet's lines on the page it opens. A line keeping what a cell held
|
|
452
|
+
and what it holds now in two fields reads one against the other (`compare`, `model/list-node`).
|
|
384
453
|
|
|
385
454
|
```
|
|
386
455
|
const sheet = await excel_get_rows({ // a workflow step
|
|
@@ -389,57 +458,57 @@ const sheet = await excel_get_rows({ // a workflow step
|
|
|
389
458
|
});
|
|
390
459
|
|
|
391
460
|
const imported = await import_rows({ // a workflow step
|
|
392
|
-
table_id: "
|
|
393
|
-
match: ["
|
|
461
|
+
table_id: "plants",
|
|
462
|
+
match: ["code"],
|
|
394
463
|
rows: sheet.rows.map((row, at) => ({
|
|
395
464
|
line: at + 2,
|
|
396
465
|
hold: isNull(row["Price"]) ? "The sheet states no price" : null,
|
|
397
466
|
values: {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
467
|
+
code: toString(row["Code"]),
|
|
468
|
+
name: toString(row["Name"]),
|
|
469
|
+
price: toNumber(row["Price"]),
|
|
401
470
|
},
|
|
402
471
|
})),
|
|
403
472
|
});
|
|
404
473
|
|
|
405
474
|
const kept = await create_records({
|
|
406
|
-
table_id: "
|
|
475
|
+
table_id: "price_sheets",
|
|
407
476
|
records: [{
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
477
|
+
file: [trigger.app_workflow.inputs.file],
|
|
478
|
+
run: runtime.execution_id,
|
|
479
|
+
read: imported.read,
|
|
480
|
+
created: imported.created,
|
|
481
|
+
changed: imported.changed,
|
|
482
|
+
held: imported.held,
|
|
414
483
|
}],
|
|
415
484
|
});
|
|
416
485
|
const sheet_id = kept.record_ids[0];
|
|
417
486
|
|
|
418
487
|
for (let start = 0; start < size(imported.written); start += 50) {
|
|
419
488
|
await create_records({
|
|
420
|
-
table_id: "
|
|
489
|
+
table_id: "sheet_lines",
|
|
421
490
|
records: slice(imported.written, start, start + 50).map((one) => ({
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
491
|
+
sheet: [sheet_id],
|
|
492
|
+
plant: [one.record_id],
|
|
493
|
+
line: one.line,
|
|
494
|
+
outcome: one.kind,
|
|
495
|
+
changes: join(one.changes.map((change) => `${change.label}: ${change.before ?? "—"} → ${change.after ?? "—"}`), "; "),
|
|
427
496
|
})),
|
|
428
497
|
});
|
|
429
498
|
}
|
|
430
499
|
for (let start = 0; start < size(imported.held_rows); start += 50) {
|
|
431
500
|
await create_records({
|
|
432
|
-
table_id: "
|
|
501
|
+
table_id: "sheet_lines",
|
|
433
502
|
records: slice(imported.held_rows, start, start + 50).map((one) => ({
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
503
|
+
sheet: [sheet_id],
|
|
504
|
+
plant: isNull(one.record_id) ? [] : [one.record_id],
|
|
505
|
+
line: one.line,
|
|
506
|
+
outcome: "held",
|
|
507
|
+
why: one.message,
|
|
508
|
+
code: toString(sheet.rows[coalesce(one.line, 2) - 2]["Code"]),
|
|
509
|
+
name: toString(sheet.rows[coalesce(one.line, 2) - 2]["Name"]),
|
|
510
|
+
price: toNumber(sheet.rows[coalesce(one.line, 2) - 2]["Price"]),
|
|
511
|
+
changes: join(one.changes.map((change) => `${change.label}: ${change.before ?? "—"} → ${change.after ?? "—"}`), "; "),
|
|
443
512
|
})),
|
|
444
513
|
});
|
|
445
514
|
}
|
|
@@ -447,22 +516,23 @@ for (let start = 0; start < size(imported.held_rows); start += 50) {
|
|
|
447
516
|
return({ status: "success", message: `${imported.created} added, ${imported.changed} changed, ${imported.held} held` });
|
|
448
517
|
```
|
|
449
518
|
|
|
450
|
-
A held line keeps the
|
|
451
|
-
fields of its own.
|
|
519
|
+
A held line keeps the cells the sheet states for it, found by the `line` the step answered, in
|
|
520
|
+
fields of its own. A held row's `key` and `values` name fields by their keys, as every record a step answers a body does, so
|
|
521
|
+
the body reads the sheet again rather than those names. It is applied by the act on it, whose workflow hands the step those values again and
|
|
452
522
|
lets it write over what the plant holds. A cell the line holds none of is left out, so a held line
|
|
453
523
|
never empties a cell of the plant:
|
|
454
524
|
|
|
455
525
|
```
|
|
456
|
-
const line = await get_record({ table_id: "
|
|
526
|
+
const line = await get_record({ table_id: "sheet_lines", record_id: trigger.app_workflow.inputs.record_id });
|
|
457
527
|
const values = {
|
|
458
|
-
|
|
459
|
-
...(isEmpty(line.data["
|
|
460
|
-
...(isNull(line.data["
|
|
528
|
+
code: line.data["code"],
|
|
529
|
+
...(isEmpty(line.data["name"]) ? {} : { name: line.data["name"] }),
|
|
530
|
+
...(isNull(line.data["price"]) ? {} : { price: line.data["price"] }),
|
|
461
531
|
};
|
|
462
532
|
|
|
463
533
|
const applied = await import_rows({ // a workflow step
|
|
464
|
-
table_id: "
|
|
465
|
-
match: ["
|
|
534
|
+
table_id: "plants",
|
|
535
|
+
match: ["code"],
|
|
466
536
|
conflicts: { default: "overwrite" },
|
|
467
537
|
rows: [{ values }],
|
|
468
538
|
});
|
|
@@ -470,47 +540,12 @@ if (applied.held > 0) {
|
|
|
470
540
|
return({ status: "error", message: applied.held_rows[0].message });
|
|
471
541
|
}
|
|
472
542
|
|
|
473
|
-
await update_records({ table_id: "
|
|
543
|
+
await update_records({ table_id: "sheet_lines", record_ids: [line.id], set: { outcome: "applied" } });
|
|
474
544
|
return({ status: "success", message: "Applied." });
|
|
475
545
|
```
|
|
476
546
|
|
|
477
|
-
A sheet is
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
```
|
|
481
|
-
const sheet = await get_record({ table_id: "tbl_price_sheets", record_id: trigger.app_workflow.inputs.record_id });
|
|
482
|
-
|
|
483
|
-
let reverted = 0;
|
|
484
|
-
let left = 0;
|
|
485
|
-
let offset = 0;
|
|
486
|
-
let more = true;
|
|
487
|
-
while (more) {
|
|
488
|
-
const lines = await query_records({
|
|
489
|
-
table_id: "tbl_sheet_lines",
|
|
490
|
-
filters: {
|
|
491
|
-
logic: "and",
|
|
492
|
-
children: [
|
|
493
|
-
{ field_key: "fld_sheet", operator: "has_any_of", value: [sheet.id] },
|
|
494
|
-
{ field_key: "fld_outcome", operator: "is_any_of", value: ["created", "changed"] },
|
|
495
|
-
],
|
|
496
|
-
},
|
|
497
|
-
pagination: { offset, limit: 500 },
|
|
498
|
-
});
|
|
499
|
-
if (size(lines.records) > 0) {
|
|
500
|
-
const undone = await undo_record_changes({ // a workflow step
|
|
501
|
-
table_id: "tbl_plants",
|
|
502
|
-
record_ids: flatten(lines.records.map((line) => line.data["fld_plant"])),
|
|
503
|
-
workflow_execution_id: toString(sheet.data["fld_run"]),
|
|
504
|
-
});
|
|
505
|
-
reverted += undone.reverted;
|
|
506
|
-
left += size(undone.kept);
|
|
507
|
-
}
|
|
508
|
-
more = size(lines.records) == 500;
|
|
509
|
-
offset += 500;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
return({ status: "success", message: `${reverted} put back, ${left} left as they stand` });
|
|
513
|
-
```
|
|
547
|
+
A sheet is put back by a member's agent on chat, MCP or the CLI: `undo_record_changes` over the plants its lines name,
|
|
548
|
+
500 at a time, with the run the sheet kept.
|
|
514
549
|
|
|
515
550
|
A shop matches a bank statement to the notes it is owed by an act that states `review`. That act's workflow only
|
|
516
551
|
proposes: its body calls steps that read and no other, and runs no agent — either is refused when the body is saved —
|
|
@@ -530,7 +565,7 @@ Each row's `data` holds only stored fields that change — never one the platfor
|
|
|
530
565
|
a `note` and a `ticked` the reader starts from, and `unplaced` lists what was read and matched nothing. Every approved
|
|
531
566
|
row is checked against its fields before any is written, and they are written in one transaction, so a flawed one
|
|
532
567
|
writes none. The status history of every row written that moved, or was made holding a status, is recorded in the
|
|
533
|
-
same run, so the
|
|
568
|
+
same run. The answer's `applied.previous` holds what each updated record's written fields held before, so one `update_records` of it puts the values back, and `delete_records` of a `created` record takes that row away. A body that stops proposing a row once it is
|
|
534
569
|
written — it matches against what the rows hold now — makes approving twice write once.
|
|
535
570
|
|
|
536
571
|
```
|
|
@@ -541,13 +576,13 @@ const sheet = await excel_get_rows({ // a workflow step
|
|
|
541
576
|
columns: ["Reference", "Amount", "Paid on"],
|
|
542
577
|
});
|
|
543
578
|
const unpaid = await query_records({
|
|
544
|
-
table_id: "
|
|
545
|
-
filters: { field_key: "
|
|
579
|
+
table_id: "notes",
|
|
580
|
+
filters: { field_key: "paid_on", operator: "is_empty" },
|
|
546
581
|
pagination: { offset: 0, limit: 500 },
|
|
547
582
|
});
|
|
548
583
|
const acted = groupBy(inputs.record_ids, (id) => toString(id));
|
|
549
584
|
const owed = filter(unpaid.records, (one) => size(acted[toString(one.id)] ?? []) > 0);
|
|
550
|
-
const byRef = groupBy(owed, (one) => toString(one.data["
|
|
585
|
+
const byRef = groupBy(owed, (one) => toString(one.data["ref"]));
|
|
551
586
|
|
|
552
587
|
const lines = sheet.rows.map((line) => ({
|
|
553
588
|
line,
|
|
@@ -556,8 +591,8 @@ const lines = sheet.rows.map((line) => ({
|
|
|
556
591
|
const updates = filter(lines, (one) => !isNull(one.note)).map((one) => ({
|
|
557
592
|
record_id: toString(one.note?.id),
|
|
558
593
|
note: `Transfer of ${toString(one.line["Amount"])}`,
|
|
559
|
-
ticked: toNumber(one.line["Amount"]) == one.note?.data["
|
|
560
|
-
data: {
|
|
594
|
+
ticked: toNumber(one.line["Amount"]) == sumBy(filter(owed, (owing) => owing.id == one.note?.id), (owing) => coalesce(owing.data["amount"], 0)),
|
|
595
|
+
data: { paid_on: toString(one.line["Paid on"]) },
|
|
561
596
|
}));
|
|
562
597
|
const unplaced = filter(lines, (one) => isNull(one.note)).map((one) => ({
|
|
563
598
|
title: `${toString(one.line["Reference"])}: ${toString(one.line["Amount"])}`,
|
|
@@ -567,7 +602,7 @@ const unplaced = filter(lines, (one) => isNull(one.note)).map((one) => ({
|
|
|
567
602
|
return({
|
|
568
603
|
status: "success",
|
|
569
604
|
message: `${size(updates)} of ${size(lines)} transfers match a note`,
|
|
570
|
-
data: { review: { version: 1, table_id: "
|
|
605
|
+
data: { review: { version: 1, table_id: "notes", creates: [], updates, unplaced } },
|
|
571
606
|
});
|
|
572
607
|
```
|
|
573
608
|
|
|
@@ -606,16 +641,16 @@ const sheet = await excel_get_rows({ // a workflow step
|
|
|
606
641
|
});
|
|
607
642
|
|
|
608
643
|
const saved = await save_record({ // a workflow step
|
|
609
|
-
table_id: "
|
|
610
|
-
match: {
|
|
611
|
-
set: {
|
|
644
|
+
table_id: "release_orders",
|
|
645
|
+
match: { number: inputs.number },
|
|
646
|
+
set: { note: "Read from the paper" },
|
|
612
647
|
lines: [{
|
|
613
|
-
table_id: "
|
|
614
|
-
link: "
|
|
615
|
-
match: ["
|
|
648
|
+
table_id: "release_lines",
|
|
649
|
+
link: "order",
|
|
650
|
+
match: ["size"],
|
|
616
651
|
rows: sheet.rows.map((row) => ({
|
|
617
|
-
|
|
618
|
-
|
|
652
|
+
size: toString(row["Size"]),
|
|
653
|
+
quantity: toNumber(row["Quantity"]),
|
|
619
654
|
})),
|
|
620
655
|
}],
|
|
621
656
|
});
|
|
@@ -627,6 +662,43 @@ return({
|
|
|
627
662
|
});
|
|
628
663
|
```
|
|
629
664
|
|
|
665
|
+
## Reaching a connected service
|
|
666
|
+
|
|
667
|
+
A service is reached only by a workflow step, and a step is called inside an app's workflow or an automation. From
|
|
668
|
+
nothing to an app's run:
|
|
669
|
+
|
|
670
|
+
1. `get_workspace` with `kind: "connection"` lists each `connected_account_id`.
|
|
671
|
+
2. `set_app` declares a Workflow node that states what a call takes and hands back:
|
|
672
|
+
|
|
673
|
+
```json
|
|
674
|
+
{
|
|
675
|
+
"type": "Workflow",
|
|
676
|
+
"key": "invoice_order",
|
|
677
|
+
"description": "Issues the KiotViet invoice for a confirmed order and hands back its code.",
|
|
678
|
+
"inputs": { "order": { "type": "record_link", "entity": "order" } },
|
|
679
|
+
"outputs": { "invoice_code": { "type": "text" } }
|
|
680
|
+
}
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
3. `set_app_workflow` with the app, the alias `invoice_order` and this `source` (the step's entry is in `workflow_steps`):
|
|
684
|
+
|
|
685
|
+
```
|
|
686
|
+
const order = await get_record({ table_id: "orders", record_id: trigger.app_workflow.inputs.order });
|
|
687
|
+
const made = await kiotviet_create_invoice({ // a workflow step
|
|
688
|
+
connected_account_id: "cac_…",
|
|
689
|
+
branch_id: 1,
|
|
690
|
+
sold_by_id: 7,
|
|
691
|
+
lines: [{ product_id: 42, quantity: toNumber(order.data["quantity"]), price: toNumber(order.data["price"]) }],
|
|
692
|
+
});
|
|
693
|
+
return({ status: "success", message: "Invoiced", data: { invoice_code: made.invoice.code } });
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
4. `run_app_workflow` with `dry_run: true`, then the same call without it.
|
|
697
|
+
|
|
698
|
+
A step wants the provider's own ids (`branch_id`, `sold_by_id`, `product_id`) and no tool lists them: declare a Workflow
|
|
699
|
+
whose body calls the provider's `list_*` workflow step (`kiotviet_list_branches`), run it, and read the ids from its answer.
|
|
700
|
+
No step lists KiotViet's users: read `sold_by_id` off a row `kiotviet_list_invoices` returns (KiotViet's `soldById`), or ask the owner.
|
|
701
|
+
|
|
630
702
|
## Table checks
|
|
631
703
|
|
|
632
704
|
A table check is one condition its table's record writes must not meet, declared beside the table's
|
|
@@ -635,54 +707,181 @@ cannot change data. It runs inside the write, on every create, update or delete
|
|
|
635
707
|
surface makes it — a person, the API, the CLI, an agent, a workflow. A refusal writes nothing and
|
|
636
708
|
answers with the check's message, under its field when the check names one, and names the check.
|
|
637
709
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
checks
|
|
710
|
+
A check is stated in its table's document, under `checks`, each under an alias of its own, and
|
|
711
|
+
`set_tables` writes it with the rest of the table: a merge patch edits one, `null` removes it, and a
|
|
712
|
+
call that makes a table can state its checks, a lookup naming another table of the call by its `ref`.
|
|
713
|
+
`get_table` states a table's checks in its document.
|
|
641
714
|
|
|
642
715
|
| part | what it holds |
|
|
643
716
|
|---|---|
|
|
644
717
|
| `on` | the writes it checks: any of `create`, `update`, `delete` |
|
|
645
718
|
| `when` | optional expression; the check runs only on a write where it is truthy. It cannot read lookups. |
|
|
646
|
-
| `lookups` | optional, by name, the rows to read: `{
|
|
719
|
+
| `lookups` | optional, by name, the rows to read: `{ table, filter, limit }`, `table` an id or the `ref` of a table of the call, the filter as `query_records` takes it with fields and select options named by alias (`id` matches a row by its own record id), `limit` from 1 to 50 (50 when left out). A filter value that reads the record is `{ "$expr": "record.code" }`. |
|
|
647
720
|
| `fail_when` | expression; truthy refuses the write |
|
|
648
721
|
| `message` | expression for the refusal's text — a fixed text is a quoted string |
|
|
649
|
-
| `
|
|
722
|
+
| `field` | optional; the alias of the field the refusal is shown under |
|
|
723
|
+
| `enabled` | `false` keeps the check without deciding writes; absent, it decides |
|
|
650
724
|
|
|
651
|
-
Every expression is written in the grammar above and
|
|
725
|
+
Every expression is written in the grammar above, names a table's fields and select options by
|
|
726
|
+
alias (`record.status == "closed"`), and reads:
|
|
652
727
|
|
|
653
728
|
- `record` — the record as it will be stored; on a delete, the record being deleted.
|
|
654
729
|
- `prev_record` — on an update or a delete, the record before.
|
|
655
|
-
- `changes` — on an update, the fields it changes.
|
|
656
|
-
- `lookups.<name>` — in `fail_when` and `message`, the rows that lookup read.
|
|
730
|
+
- `changes` — on an update, the fields it changes. `!isNull(changes.photos)` asks whether a field changed.
|
|
731
|
+
- `lookups.<name>` — in `fail_when` and `message`, the rows that lookup read. A row reads as `record` does: its fields by alias beside `id` (`row.start_on`), and a lookup field as the flat list of what it looks up (`first(row.booking_start)` through a one-row link). A lookup over the written table never returns the row being checked, and finds its other rows as the write leaves them, this call's other rows included. A field given to a helper as text names it by alias too (`sumBy(lookups.paid, "amount")`); text that names no field of the rows' table is refused when the check is saved.
|
|
657
732
|
- `runtime.timezone`, `runtime.workspace_id`, `runtime.organization_id`, `runtime.change_origin` (the
|
|
658
733
|
write's origin), `runtime.triggered_by_member_id` (the member writing; null for a write no member
|
|
659
|
-
made), and `
|
|
734
|
+
made), `now()`, and `member_of("grp_…")` — true when the member writing belongs to the group.
|
|
735
|
+
|
|
736
|
+
In a check a select reads as its one value (`record.kind == "retail"`), and a link (a one-row link too) and a lookup read as lists (`first(record.customer)`).
|
|
660
737
|
|
|
661
738
|
A check on several operations reads only what all of them carry, so one on creates and updates reads
|
|
662
739
|
neither `prev_record` nor `changes`.
|
|
663
740
|
|
|
741
|
+
A write that recomputes another table's rows (a rollup over lines, a lookup or a formula over a parent) judges each recomputed row as an update, by the checks `on` `update` that read a field the recompute changed; `changes` is what the recompute changed. A check that refuses on `changes` or on a status the row holds can therefore refuse the write that moved the parent, so it is gated on the field it means (`!isNull(changes.status)`).
|
|
742
|
+
|
|
664
743
|
A lookup reads with the access of the member who last saved the check, so saving one requires reading
|
|
665
744
|
every table its lookups read. It reads committed rows: two writes at the same moment can each pass a
|
|
666
745
|
check the other would fail, so values no two rows may share are the table's `unique`, not a check.
|
|
667
746
|
|
|
668
747
|
A `when`, a lookup or a `fail_when` that cannot decide — it fails, or the write's time runs out —
|
|
669
|
-
refuses the write
|
|
748
|
+
refuses the write, naming the part it was reading and, for an expression or a filter, what it met
|
|
749
|
+
(`lookups.clash: Filter on "start_on": date 'before' value … is not a date`). A draft is checked when it is submitted, as a create; its edits before that are
|
|
670
750
|
not. A restored record is checked as a create. Cloning a table copies its checks to the member who
|
|
671
751
|
clones it, and is refused when that member cannot read a table a check's lookup reads.
|
|
672
752
|
|
|
673
753
|
One open order per reference, a row never counting against itself:
|
|
674
754
|
|
|
755
|
+
```json
|
|
756
|
+
{
|
|
757
|
+
"tables": [
|
|
758
|
+
{
|
|
759
|
+
"ref": "orders",
|
|
760
|
+
"label": "Orders",
|
|
761
|
+
"fields": {
|
|
762
|
+
"reference": { "type": "text", "label": "Reference" },
|
|
763
|
+
"closed_at": { "type": "date", "label": "Closed at" }
|
|
764
|
+
},
|
|
765
|
+
"checks": {
|
|
766
|
+
"one_open_order_per_reference": {
|
|
767
|
+
"on": ["create", "update"],
|
|
768
|
+
"when": "isNull(record.closed_at)",
|
|
769
|
+
"lookups": {
|
|
770
|
+
"same_ref": {
|
|
771
|
+
"table": "orders",
|
|
772
|
+
"filter": {
|
|
773
|
+
"node_type": "group",
|
|
774
|
+
"logic": "and",
|
|
775
|
+
"children": [
|
|
776
|
+
{ "node_type": "condition", "field_key": "reference", "operator": "equals", "value": { "$expr": "record.reference" } },
|
|
777
|
+
{ "node_type": "condition", "field_key": "closed_at", "operator": "is_empty" }
|
|
778
|
+
]
|
|
779
|
+
},
|
|
780
|
+
"limit": 2
|
|
781
|
+
}
|
|
782
|
+
},
|
|
783
|
+
"fail_when": "some(lookups.same_ref, (r) => r.id != record.id)",
|
|
784
|
+
"message": "\"An open order already has this reference.\"",
|
|
785
|
+
"field": "reference"
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
]
|
|
790
|
+
}
|
|
675
791
|
```
|
|
792
|
+
|
|
793
|
+
## Table workflows
|
|
794
|
+
|
|
795
|
+
A table workflow is what follows every committed write of a table's rows, whoever makes it: the grid, an import, an agent or
|
|
796
|
+
any app. An effect that must hold for all of them belongs to the table. A button on one screen is that app's act, a value fixed
|
|
797
|
+
when a row is made is `default_from`, and a rule that refuses a write is a check; a table workflow runs after the write has
|
|
798
|
+
committed and cannot refuse it.
|
|
799
|
+
|
|
800
|
+
It is stated in the table's document, under `workflows`, each under an alias of its own, and `set_tables` writes it with the
|
|
801
|
+
rest of the table: a merge patch edits one, `null` removes it, and `get_table` states a table's workflows in its document, with
|
|
802
|
+
the failures of the last 7 days beside them, to whoever manages the table. A dry run of `set_tables` with a `record_id` says what
|
|
803
|
+
each workflow the call adds or changes would write on that row; reads are real and nothing is written.
|
|
804
|
+
|
|
805
|
+
| part | what it holds |
|
|
806
|
+
|---|---|
|
|
807
|
+
| `on` | the write it follows: `create`, `update` or `delete` |
|
|
808
|
+
| `when` | optional expression over `record`, `prev_record` and `changes`; the workflow runs only after a write where it is truthy |
|
|
809
|
+
| `changed` | optional, with `on: update`: field aliases; the workflow runs only after an update that changes any one of them |
|
|
810
|
+
| `body` | the steps, written as an app workflow's body is |
|
|
811
|
+
| `enabled` | `false` keeps the workflow without running it; absent, it runs |
|
|
812
|
+
|
|
813
|
+
```json
|
|
676
814
|
{
|
|
677
|
-
"
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
815
|
+
"tables": [
|
|
816
|
+
{
|
|
817
|
+
"ref": "repairs",
|
|
818
|
+
"label": "Repairs",
|
|
819
|
+
"fields": {
|
|
820
|
+
"status": { "type": "select", "label": "Status", "options": [{ "alias": "queued", "label": "Queued" }, { "alias": "fixed", "label": "Fixed" }] },
|
|
821
|
+
"fixed_on": { "type": "date", "label": "Fixed on" }
|
|
822
|
+
},
|
|
823
|
+
"workflows": {
|
|
824
|
+
"stamp_fixed": {
|
|
825
|
+
"on": "update",
|
|
826
|
+
"changed": ["status"],
|
|
827
|
+
"when": "record.status == \"fixed\"",
|
|
828
|
+
"body": "await update_records({ table_id: \"repairs\", record_ids: [trigger.record_id], set: { fixed_on: formatDate(now(), \"yyyy-MM-dd\") } });"
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
]
|
|
687
833
|
}
|
|
688
834
|
```
|
|
835
|
+
|
|
836
|
+
The body names the table's fields, select options and other tables by alias, and reads the write as `record`,
|
|
837
|
+
`prev_record` and `changes` and the row's id as `trigger.record_id`. It operates on records and integrations: a body
|
|
838
|
+
that calls a tool changing a table, an app or any other structure is refused when it is saved. It runs as the table's owner, so
|
|
839
|
+
it reaches the tables the owner reaches, and its writes meet the table's checks like any other write; a write refused by a
|
|
840
|
+
check fails that run and leaves the row as it was. A body names a field by alias where it can tell which table's field it is: a
|
|
841
|
+
read of `record`, `prev_record` or `changes`, a row from `get_record` or `query_records` called with a literal `table_id`, and a
|
|
842
|
+
loop over such rows. A name it cannot tie to a table, and an option compared to a value of unknown type (not a read of a field, and
|
|
843
|
+
not a text, number or other value the type proves holds no option), are refused with what to change. A link reads as the list of the row ids it holds, a one-row link too: compare `first(record.customer) == id`. A lookup reads as one flat list of what it looks up: `record.x != prev_record.x` on one is refused, so compare `first(record.x)` with `first(prev_record.x)`. A single member, a single select, a number or a text reads as the value itself: `first(record.technician)` is refused at save, naming the read.
|
|
844
|
+
|
|
845
|
+
A write the workflow itself makes sets off the workflows of its table in turn, and a chain of them stops after five. A workflow
|
|
846
|
+
that writes the row it follows is gated by a `changed` or a `when` that its own write does not satisfy. One run is queued for a
|
|
847
|
+
row and a workflow at a time, so a burst of writes to one row runs it once more after the run in flight. A field a workflow
|
|
848
|
+
reads cannot be deleted or retyped until the workflow is changed or removed in the same call or before. Cloning a table copies its
|
|
849
|
+
workflows for whoever manages it (`include_workflows: false` leaves them), each with a body of its own, and is refused when a
|
|
850
|
+
body names a table the cloner cannot reach. A workflow runs as the table's owner, so whoever saves or rehearses one must manage the
|
|
851
|
+
table and read every table it reads in full: the body's, the gate's links', the written table. An option is written where
|
|
852
|
+
it is compared to, listed against or set on its field; a constant holding one is refused.
|
|
853
|
+
|
|
854
|
+
A lock is the table's: a table workflow calls `lock_records` on a row (an app's act may too), and every door then refuses
|
|
855
|
+
the row's writes until whoever manages the table calls `unlock_records` — in an app's run, judged as the member who pressed.
|
|
856
|
+
A locked row reads locked in every app. An app's own Check or `edit: false` only makes a row read-only in that app.
|
|
857
|
+
|
|
858
|
+
## Runs
|
|
859
|
+
|
|
860
|
+
Each start of a workflow is a run: an app's workflow or act, a table workflow, a button, an automation. An agent finds,
|
|
861
|
+
reads, retries and stops runs on chat, MCP and the CLI. A run is read by whoever manages what its workflow belongs to: its
|
|
862
|
+
automation or its table, and for an app's workflow the app's owner or an admin, never a manager of the app.
|
|
863
|
+
|
|
864
|
+
- **Find.** `query_workflow_executions` lists one workflow's runs (`workflow_id`), every run started for one row
|
|
865
|
+
(`record_id`) — a table workflow's written row, a pressed button's row, an app run's `record_id` input — or both, newest
|
|
866
|
+
first, a page at a time (`next_cursor`). Each run names its workflow and kind, what started it, its row and how it ended:
|
|
867
|
+
for a failure, the step that threw, its tool and the error; for a waiting run, when it wakes, or the approval and the
|
|
868
|
+
approvers it waits on, or the payment. A run started before runs recorded their row carries no `record_id`.
|
|
869
|
+
- **Read.** `get_workflow_execution` gives one run's steps in order with each one's tool, input, output and error, what it
|
|
870
|
+
returned, how many rows it wrote (a row's own history is in `query_audit_logs`) and the runs its writes started. Ninety days
|
|
871
|
+
after a run starts, its trigger and its steps' input and output are pruned; the read says so and gives the rest.
|
|
872
|
+
`run_app_workflow` answers each run it makes with its `execution_id`.
|
|
873
|
+
- **Retry.** `retry_workflow_execution` runs a failed, refused or cancelled run again as a new run linked to the first
|
|
874
|
+
(`retry_of`): the first run's trigger and row, the workflow's current steps and its owner's current access, with the
|
|
875
|
+
member who retries it as the one who started it. A run is retried once; a retry that fails is retried in turn. It asks
|
|
876
|
+
first when the run, or a run it retried, already wrote rows or called a service no delete reverses. An automation's or a
|
|
877
|
+
table workflow's retry runs in the background, as its runs do, and answers `queued`; a button's and an app's run while
|
|
878
|
+
the call waits. An app's call is checked again against the workflow's current inputs. A pruned run, a run of a workflow
|
|
879
|
+
that is off, a reviewed act's run (call the act again) and a recording's filing (the recording retries its delivery)
|
|
880
|
+
are not run again.
|
|
881
|
+
- **Stop.** `cancel_workflow_execution` stops a waiting run and withdraws its pending approvals.
|
|
882
|
+
|
|
883
|
+
Off means off: a waiting run whose workflow was turned off, deleted, or taken off its automation, table, button or app ends
|
|
884
|
+
`cancelled` when it would resume, saying why, and runs no further step.
|
|
885
|
+
|
|
886
|
+
A waiting run keeps its version: when it resumes, it runs the steps of the version it started with, so an edit saved while it
|
|
887
|
+
waited changes later runs only. When that version is gone, the run ends failed and says why.
|