@lotics/app-sdk 0.88.1 → 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 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 + 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 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`. |
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/queries.md CHANGED
@@ -538,7 +538,10 @@ Negation-shaped inner operators (`has_none_of`, `not_equals`, `is_none_of`,
538
538
  "any record matches the negation".
539
539
 
540
540
  Traversals are **source-layer only** (rejected on derived columns — push them into
541
- `from_table.filter`). Two access classes:
541
+ `from_table.filter`). Each hop reaches the linked rows by primary key from the ids in the link
542
+ cell. Under an OR beside column conditions a traversal runs once per row and takes the column
543
+ conditions off their indexes with it; give it a `union` arm of its own when the other arms must
544
+ stay index-served. Two access classes:
542
545
 
543
546
  - **Self-scoped** — inner operator `is_current_member` / `is_not_current_member`: tests only
544
547
  the viewer's own membership on the linked row, leaks nothing, and is exempt from the linked
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`, link descent included |
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"]` | descend into the *n*-th linked record and read one of its fields |
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
- **Link descent is a lazy fetch, and it must be one unbroken path.**
120
- `order.data["fld_link"][0]["fld_name"]` works because the walker fetches the linked row where the
121
- `[index]` is syntactically attached — scoped to the run's authority, null-tolerant, cached per
122
- execution. It collapses along a single path from a record read: `record` / `prev_record`, a
123
- `for-of` item, a lambda parameter, a `let` binding, or a `get_record` / `query_records` step
124
- output — the last of these only when the call passed a **literal** `table_id`, since that is what
125
- lets the server resolve the table at save.
126
-
127
- The moment you *materialize* the array (`const ids = order.data["fld_link"]`), `ids` is what it
128
- always was at runtime: a plain `string[]` of record ids. `ids[0]` is a bare `rec_*` **string**,
129
- and reading a field off it is a loud runtime error — one the type checker does **not** catch,
130
- because `ids` keeps the linked-record type it had on the path. Pass it where a record id is wanted
131
- (`record_id: ids[0]`), `get_record` it, or keep the read as one path. Descent also terminates
132
- after **one hop** in the type system: a linked row's own link fields type as ids. Deeper reads
133
- take the id and `get_record` it.
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, or a
179
- declared function. **Step ids are unique across the whole body** — a `const` inside an `if`
180
- claims its name everywhere, so a second `const a` in any block is rejected. Only `let` bindings
181
- and `for-of` binds are block-scoped: each may shadow an outer one of the same name, neither leaks
182
- out of the `if` / loop / `try` that declares it, and neither may take a name a step id already
183
- holds. A lambda parameter must likewise be free of every step id, enclosing `for-of` bind, and
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 linked-record handles — one hop of `[0]["fld_x"]` descent | array of ids: `fld_customer: [customer.id]` — never a record object |
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 not an id array either, so it does not feed straight back:
583
- `set: { fld_link: record["fld_link"] }` is rejected ("write an ID array, e.g. [x.id]") — and
584
- `prev_record["fld_link"]` is the same read, so the same rejection. The one id-shaped surface is
585
- the **diff**: `changes["fld_link"]?.next_value` / `?.prev_value` are plain id arrays (a diff value
586
- has nowhere to attach a fetch, so it cannot descend either), and they feed `set:` directly.
587
- Everywhere else, write `[x.id]`.
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
- - **Link read/write asymmetry, and the materialized-array cliff.** Descent works only on one
711
- unbroken path from a record read. `const ids = record["fld_link"]; ids[0]["fld_name"]`
712
- type-checks **clean** and throws at run time — `ids[0]` is a bare id string, and the type it
713
- kept says otherwise. Write links as id arrays.
714
- - **You cannot `for-of` a link field.** `for (const x of record["fld_link"])` is **rejected at
715
- save**: a `for-of` has no `[index]` for the fetch to attach to, so the loop would bind bare
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** — `create_records` id reuse (an error), possible self-retrigger, deep link chains,
803
- a button action with no `validate` guard (warnings).
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: top.id },
892
+ data: { order_id: created.record_ids[0] },
903
893
  });
904
894
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.88.1",
3
+ "version": "0.89.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps \u2014 typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {