@lotics/app-sdk 0.88.2 → 0.89.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 +1 -1
- package/docs/workflows.md +54 -64
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -18,7 +18,7 @@ signature; open the file.**
|
|
|
18
18
|
| [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. |
|
|
19
19
|
| [docs/data_fetching.md](./docs/data_fetching.md) | The four read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`/`useCount` — the last for a number with no rows, sharing the `(alias, params, filter)` count key the paginated hook uses, so a list and a badge over one set buy one count; a count is a full scan and stays its OWN request so rows paint without waiting for it, and `rows.length` is never a count since rows truncate silently at 10,000), the `QueryRow` shape (projected columns `unknown`; `__source_record_id`/`__source_table_id` typed but optional), runtime `sort`/`filter` keys typed against the query's own projection (`AppQueryColumns`, `ColumnKeyOf`), cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `useFieldOptions`, caching — **arrival revalidates** (a re-mount renders cache *and* refreshes it in the background, `loading` never flips) — **realtime push** (a table one of your queries reads changes and that query refetches within about a second, alias-precise, records-only, host-embedded apps only) — and the fourth source, **this app's own successful write**, which re-reads every mounted query immediately rather than waiting on that push (→ [mutations](./docs/mutations.md)), data discipline, the pagination count as a second full execution (and `total` to suppress it), the search-as-you-type + record-picker patterns. |
|
|
20
20
|
| [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract, typed inputs, the automatic re-read a SUCCESSFUL write triggers over every mounted query (so a screen never waits on the host push to see its own write), diff-before-update, locked records, `useOptimistic`, `useNewRecord` (client-minted `rec_*` id so a new-record surface never remounts on its first save), read-after-write ordering (a re-read must not overtake an in-flight write). |
|
|
21
|
-
| [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 +
|
|
21
|
+
| [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 + explicit `linked()` 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 verify loop — `check` (the only local gate: the app's own `npm run typecheck` never sees a body) → `dry_run_workflow` (static green is not a run) → `set`. |
|
|
22
22
|
| [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs (**a bearer credential for the bytes** — never logged, reported, or persisted), workflow-generated files, **naming a zip's entries** (`{ id, name }` per file — a file name, never a path), 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. |
|
|
23
23
|
| [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. |
|
|
24
24
|
| [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
|
package/docs/workflows.md
CHANGED
|
@@ -71,7 +71,7 @@ compile-time "cannot find name", not a runtime `undefined`.
|
|
|
71
71
|
| `trigger` | every context | the trigger namespace. For an app workflow: `trigger.app_workflow.inputs.<name>` |
|
|
72
72
|
| `runtime` | every context | execution context — see the key table below |
|
|
73
73
|
| `record` | button + table-lifecycle workflows | the record the trigger fired on (merged data on update) |
|
|
74
|
-
| `prev_record` | table `*_update` / `*_delete` | the prior record state — same shape as `record
|
|
74
|
+
| `prev_record` | table `*_update` / `*_delete` | the prior record state — same shape as `record` |
|
|
75
75
|
| `changes` | table `*_update` | per-field diff — a *partial* map, so read it `changes["fld_x"]?.next_value` / `?.prev_value` |
|
|
76
76
|
| `index` | inside a `for-of` body | the current 0-based iteration index — always the **innermost** loop's. To use an outer loop's index in a nested body, bind it in the outer one (`const outerIdx = index;`) and read that |
|
|
77
77
|
| `<bind_name>` | inside a `for-of` body | the current item. Each loop keeps its own, so a nested body reads the outer loop's item by its own bind name |
|
|
@@ -109,28 +109,39 @@ executes under — see [security](./security.md) and `current_member_in_any_grou
|
|
|
109
109
|
|---|---|
|
|
110
110
|
| `["fld_x"]` / `.fld_x` | field access by opaque key — both spellings work; brackets read better |
|
|
111
111
|
| `[n]` / `[<expr>]` | array index, literal or computed |
|
|
112
|
-
| `["fld_link"][0]["fld_name"]` |
|
|
112
|
+
| `linked(record["fld_link"])[0]["fld_name"]` | fetch the linked rows and read a field off one — see below |
|
|
113
113
|
| `.key` | plain object key on a step output (`rows.records`, `doc.file_id`) |
|
|
114
114
|
|
|
115
115
|
**A path must start at a root.** It cannot hang off a helper call: `first(rows.records).id` is
|
|
116
116
|
rejected. Bind the call (`const top = first(rows.records);` then `top.id`) or index the array
|
|
117
|
-
directly (`rows.records[0].id`).
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
`order.data["fld_link"]
|
|
121
|
-
`[
|
|
122
|
-
|
|
123
|
-
`
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
and
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
117
|
+
directly (`rows.records[0].id`). `linked(...)` is the exception because it is grammar rather than
|
|
118
|
+
a helper — the path continues through it.
|
|
119
|
+
|
|
120
|
+
**A link field reads as an array of ids, in every spelling.** `order.data["fld_link"]` is
|
|
121
|
+
`RecordId<"tbl_…">[]` whether you read it on a path, bind it to a `const`, take it as a `for-of`
|
|
122
|
+
item or a lambda parameter, or read it off a diff. An id goes wherever an id is wanted —
|
|
123
|
+
`record_id: ids[0]`, a `record_ids` input, a link write — with no `.id` and no unwrapping.
|
|
124
|
+
|
|
125
|
+
**To read a linked row's own fields, fetch it: `linked(...)`.**
|
|
126
|
+
`linked(order.data["fld_link"])[0]["fld_name"]` fetches that row and reads a field off it; bare
|
|
127
|
+
`linked(order.data["fld_link"])` is every linked row and is legal wherever an array is — a helper
|
|
128
|
+
argument, a `for-of` iterable, a step input. The fetch is scoped to the run's authority,
|
|
129
|
+
null-tolerant, and cached per execution, but it is never free: one row per fetch, and a second hop
|
|
130
|
+
is a second call (`linked(linked(order.data["fld_a"])[0]["fld_b"])[0]`). A linked row that was
|
|
131
|
+
deleted, or that the run may not read, stays a positional `null`, so `linked(P)[i]` is always the
|
|
132
|
+
row `P[i]` names.
|
|
133
|
+
|
|
134
|
+
The argument is a path ending on a link field, off anything record-shaped: `record` /
|
|
135
|
+
`prev_record`, a `for-of` item, a lambda parameter, a `let` binding, or a `get_record` /
|
|
136
|
+
`query_records` output (that one resolves its table only when the call passed a **literal**
|
|
137
|
+
`table_id`, which is what lets the server validate the far field at save). Guard **inside** the
|
|
138
|
+
argument — `linked(record?.["fld_link"])[0]` — never on the call: `linked(P)?.[i]` is rejected,
|
|
139
|
+
because the call always returns an array and the guard could never fire. Two more rejections:
|
|
140
|
+
`const ids = record["fld_link"]; linked(ids)`, since the field name comes from the argument's last
|
|
141
|
+
member access (`linked(row["fld_link"])` off a bound ROW is fine), and
|
|
142
|
+
`linked(changes["fld_link"]?.next_value)`, since a diff is not a record — read
|
|
143
|
+
`linked(record["fld_link"])` or `linked(prev_record["fld_link"])`, which hold the same ids.
|
|
144
|
+
`linked` is grammar, not a helper: it cannot be shadowed, bound, called as a method, or awaited.
|
|
134
145
|
|
|
135
146
|
## Step forms
|
|
136
147
|
|
|
@@ -175,13 +186,13 @@ const dup = await query_records({ table_id: "tbl_orders", filters: { … } });
|
|
|
175
186
|
```
|
|
176
187
|
|
|
177
188
|
Names live in one flat namespace. A binding, step id, `for-of` bind, or lambda parameter may not
|
|
178
|
-
collide with a tool, a helper (`size`, `first`, `filter`, … are all taken), a reserved root,
|
|
179
|
-
declared function. **Step ids are unique across the whole body** — a `const`
|
|
180
|
-
claims its name everywhere, so a second `const a` in any block is rejected. Only
|
|
181
|
-
and `for-of` binds are block-scoped: each may shadow an outer one of the same
|
|
182
|
-
out of the `if` / loop / `try` that declares it, and neither may take a name
|
|
183
|
-
holds. A lambda parameter must likewise be free of every step id, enclosing
|
|
184
|
-
enclosing lambda parameter.
|
|
189
|
+
collide with a tool, a helper (`size`, `first`, `filter`, … are all taken), a reserved root,
|
|
190
|
+
`linked`, or a declared function. **Step ids are unique across the whole body** — a `const`
|
|
191
|
+
inside an `if` claims its name everywhere, so a second `const a` in any block is rejected. Only
|
|
192
|
+
`let` bindings and `for-of` binds are block-scoped: each may shadow an outer one of the same
|
|
193
|
+
name, neither leaks out of the `if` / loop / `try` that declares it, and neither may take a name
|
|
194
|
+
a step id already holds. A lambda parameter must likewise be free of every step id, enclosing
|
|
195
|
+
`for-of` bind, and enclosing lambda parameter.
|
|
185
196
|
|
|
186
197
|
### `return` — the envelope
|
|
187
198
|
|
|
@@ -571,7 +582,7 @@ Value shapes, which the generated types enforce exactly:
|
|
|
571
582
|
| multi `select` | array of `opt_*` keys | array: `fld_tags: ["opt_urgent"]` |
|
|
572
583
|
| single `select_member` | one member id, or `null` | the id |
|
|
573
584
|
| multi `select_member` | array of member ids | array |
|
|
574
|
-
| `select_record_link` | array of
|
|
585
|
+
| `select_record_link` | array of ids — `linked(...)` fetches the rows | array of ids: `fld_customer: [customer.id]`, or a link read fed straight back |
|
|
575
586
|
| `files` | array of file refs | array of file ids |
|
|
576
587
|
|
|
577
588
|
Ids are **branded** on the write side: a member id types as `MemberId`, a file id as `FileId`, a
|
|
@@ -579,12 +590,12 @@ linked record id as `RecordId<"tbl_…">`. They come out branded from a record r
|
|
|
579
590
|
or from a workflow input declared `member` / `file` / `record_link` — a `rec_*` string carried in
|
|
580
591
|
a plain `text` input does *not* type-check into a link field, so declare the input for what it is.
|
|
581
592
|
|
|
582
|
-
A link **read** is
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
593
|
+
A link **read** is an id array, so it feeds straight back: `set: { fld_link: record["fld_link"] }`,
|
|
594
|
+
and the same for `prev_record["fld_link"]` and for a diff's `changes["fld_link"]?.next_value` /
|
|
595
|
+
`?.prev_value`. What does not feed back is a ROW: a link field takes ids, so from rows you already
|
|
596
|
+
hold write `pluck(rows.records, (r) => r.id)`, or `[x.id]` for one. Never route a link read through
|
|
597
|
+
`linked(...)` to get its ids back — it holds them already, and the fetch costs one read per row and
|
|
598
|
+
yields `null` where a row is deleted or unreadable, which the write then rejects.
|
|
588
599
|
|
|
589
600
|
### A write that will be re-run
|
|
590
601
|
|
|
@@ -707,19 +718,12 @@ pair will happily show an improvement that is only drift.
|
|
|
707
718
|
|
|
708
719
|
The rules that are easy to get wrong because the failing code looks correct.
|
|
709
720
|
|
|
710
|
-
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
`rec_*` ids. Read one linked record as a path (`record["fld_link"][0]["fld_x"]`), or
|
|
717
|
-
`query_records` the linked table and iterate its `records`. Iterating a link field on a row you
|
|
718
|
-
already descended into (`record["fld_a"][0]["fld_b"]`) is fine — those *are* ids, so
|
|
719
|
-
`get_record` each one.
|
|
720
|
-
- **`create_records` ids are not reusable downstream.** Storage-commit timing makes the returned
|
|
721
|
-
`record_ids` array unreliable for a follow-up foreign-key write, and the lint **rejects**
|
|
722
|
-
`created.record_ids[0]`. Re-query the table by a unique field you just wrote, and use that.
|
|
721
|
+
- **`linked(...)` inside a loop body is a fetch per linked row per iteration.** Over 200 rows a
|
|
722
|
+
`linked` read in the body is 200 fetches; the per-execution cache only helps when the same ids
|
|
723
|
+
come round again. Hoist it, or `query_records` the linked table once and match on the ids.
|
|
724
|
+
- **`for-of` over a link field iterates ids, over `linked(...)` iterates rows.** Both are legal, so
|
|
725
|
+
pick by what the body needs — `get_record` and a link write take the id, a field read takes the
|
|
726
|
+
row.
|
|
723
727
|
- **`get_record` needs a literal `table_id` to narrow.** Without it, `.data` degrades to a union
|
|
724
728
|
of every table and no field read type-checks. `table_id` stays optional at run time; this is a
|
|
725
729
|
save-time typing requirement.
|
|
@@ -799,8 +803,8 @@ What only the **server** can decide, so `check` stays green and `set` may still
|
|
|
799
803
|
- **`switch` case validation** against a single-select's options, and the multi-select rejection;
|
|
800
804
|
- the **literal `formatDate` format probe**;
|
|
801
805
|
- **wait inside a loop**, and the `before_*` restrictions on waits and `agent` steps;
|
|
802
|
-
- the **lint** —
|
|
803
|
-
|
|
806
|
+
- the **lint** — possible self-retrigger, deep link chains, a button action with no `validate`
|
|
807
|
+
guard (all warnings).
|
|
804
808
|
|
|
805
809
|
There is no separate verify endpoint: the loop is `set` → read the returned diagnostics → fix →
|
|
806
810
|
`set`. Diagnostics arrive **batched** — independent errors across the whole body come back in one
|
|
@@ -871,7 +875,7 @@ validate({ checks: [{
|
|
|
871
875
|
message: `Order code ${i.order_code} already exists.`,
|
|
872
876
|
}]});
|
|
873
877
|
|
|
874
|
-
await create_records({
|
|
878
|
+
const created = await create_records({
|
|
875
879
|
table_id: "tbl_orders",
|
|
876
880
|
records: [{
|
|
877
881
|
fld_order_code: i.order_code,
|
|
@@ -882,23 +886,9 @@ await create_records({
|
|
|
882
886
|
}],
|
|
883
887
|
});
|
|
884
888
|
|
|
885
|
-
// create_records ids are not reusable — re-read by the unique code we just wrote.
|
|
886
|
-
const created = await query_records({
|
|
887
|
-
table_id: "tbl_orders",
|
|
888
|
-
filters: {
|
|
889
|
-
node_type: "condition",
|
|
890
|
-
field_key: "fld_order_code",
|
|
891
|
-
operator: "equals",
|
|
892
|
-
value: i.order_code,
|
|
893
|
-
},
|
|
894
|
-
});
|
|
895
|
-
|
|
896
|
-
// A path can't hang off a helper call — bind it, then read.
|
|
897
|
-
const top = requireFirst(created.records);
|
|
898
|
-
|
|
899
889
|
return({
|
|
900
890
|
status: "success",
|
|
901
891
|
message: "Order created.",
|
|
902
|
-
data: { order_id:
|
|
892
|
+
data: { order_id: created.record_ids[0] },
|
|
903
893
|
});
|
|
904
894
|
```
|