@lotics/app-sdk 0.88.2 → 0.90.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,13 +18,13 @@ 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`. |
25
25
  | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `AgentRun`, the agent's ask-back — `pendingChoice`/`answerChoice` over the parked `awaiting_input` state — and the `AgentRunLanding` every leg resolves), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics; a chat mutation refetches your queries through the realtime channel, not a separate poke). **A `file` input carries its own content** — no reader tool to declare. **An agent reaches record DATA only through its declared `query_aliases` / `workflow_aliases`.** Also **what the member's own chat agent can do with your app while it is open** — the alias catalog it reads and how to shape a mutating alias for it. |
26
26
  | [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, what runtime refinement cannot widen, and why a per-input bound is a tenancy floor rather than an authorization check (a caller-supplied id must be intersected with the record server-side). |
27
- | [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, `rpc()`, the design-time mock harness (`fixture` + `?__mock=1` — queries AND workflows, so an AI screen's in-flight/done/error states are reviewable without running or paying for anything), `openExternal`/`downloadFile`, geofencing, and the publish chain for SDK contributors. |
27
+ | [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, `rpc()`, the design-time mock harness (`fixture` + `?__mock=1` — queries AND workflows, so an AI screen's in-flight/done/error states are reviewable without running or paying for anything), `openExternal`/`openApp`/`downloadFile`, geofencing, and the publish chain for SDK contributors. |
28
28
 
29
29
  ## Non-negotiables (each detailed in its doc)
30
30
 
@@ -26,6 +26,7 @@ export type { GeofenceZone, GeoCoords, GeofenceOutcome, GeofenceOptions } from "
26
26
  export { rpc, isEmbedded } from "./rpc.js";
27
27
  export type { RpcOp, AiContextValue, AiContextRecordRef } from "./rpc.js";
28
28
  export { openExternal } from "./open_external.js";
29
+ export { openApp } from "./open_app.js";
29
30
  export { askAi, type AskAiArgs } from "./ask_ai.js";
30
31
  export { downloadFile } from "./download.js";
31
32
  export { readMembers } from "./members.js";
package/dist/src/index.js CHANGED
@@ -21,6 +21,7 @@ export { useViewer } from "./viewer.js";
21
21
  export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
22
22
  export { rpc, isEmbedded } from "./rpc.js";
23
23
  export { openExternal } from "./open_external.js";
24
+ export { openApp } from "./open_app.js";
24
25
  export { askAi } from "./ask_ai.js";
25
26
  export { downloadFile } from "./download.js";
26
27
  export { readMembers } from "./members.js";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Open a record's screen in the sibling app that owns it — the cross-app hop.
3
+ * The host owns app routing and lands the viewer on `route` inside `appId`,
4
+ * same tab, chrome kept. `route` is the target app's own in-app path, exactly
5
+ * as its router declares it (`/` for its register).
6
+ *
7
+ * ```tsx
8
+ * import { openApp } from "@lotics/app-sdk";
9
+ * await openApp(PLANNING_APP_ID, `/plan/${record.id}`);
10
+ * ```
11
+ *
12
+ * Standalone (`<slug>.lotics.app`) has no sibling apps, so the call rejects
13
+ * there — gate the control on `isEmbedded()`.
14
+ */
15
+ export declare function openApp(appId: string, route?: string): Promise<void>;
@@ -0,0 +1,18 @@
1
+ import { rpc } from "./rpc.js";
2
+ /**
3
+ * Open a record's screen in the sibling app that owns it — the cross-app hop.
4
+ * The host owns app routing and lands the viewer on `route` inside `appId`,
5
+ * same tab, chrome kept. `route` is the target app's own in-app path, exactly
6
+ * as its router declares it (`/` for its register).
7
+ *
8
+ * ```tsx
9
+ * import { openApp } from "@lotics/app-sdk";
10
+ * await openApp(PLANNING_APP_ID, `/plan/${record.id}`);
11
+ * ```
12
+ *
13
+ * Standalone (`<slug>.lotics.app`) has no sibling apps, so the call rejects
14
+ * there — gate the control on `isEmbedded()`.
15
+ */
16
+ export function openApp(appId, route = "/") {
17
+ return rpc("openApp", { app_id: appId, route });
18
+ }
package/dist/src/rpc.d.ts CHANGED
@@ -20,7 +20,7 @@ import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
20
20
  * app → host: { id, op, payload }
21
21
  * host → app: { id, type: "result", data } | { id, type: "error", message }
22
22
  */
23
- export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "askAi" | "urlState.get" | "urlState.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
23
+ export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "openApp" | "askAi" | "urlState.get" | "urlState.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
24
24
  /** Payload for starting a streaming agent run. */
25
25
  export interface AgentRunPayload {
26
26
  alias: string;
package/dist/src/rpc.js CHANGED
@@ -666,6 +666,10 @@ function rpcStandalone(op, payload) {
666
666
  return standaloneContext();
667
667
  case "openExternal":
668
668
  return standaloneOpenExternal(payload);
669
+ case "openApp":
670
+ // A standalone app is one page at its own address: no host routing
671
+ // between apps, no sibling to reach.
672
+ return Promise.reject(new Error("openApp needs the Lotics host: a standalone app has no sibling apps to open. Gate the control on isEmbedded()."));
669
673
  case "askAi":
670
674
  // The chat surface lives in the Lotics host — a standalone page has
671
675
  // nowhere to hand off to.
@@ -287,3 +287,4 @@ default `JSON.stringify`) and `max` (default **5**).
287
287
  | `urlParam`, `UrlParamCodec`, `OptionalUrlParamCodec`, `UrlParams`, `UrlParamValue` | `@lotics/app-sdk` | `dist/src/url_params.d.ts` |
288
288
  | `useRecents`, `RecentsApi`, `RecentsOptions` | `@lotics/app-sdk` | `dist/src/use_recents.d.ts` |
289
289
  | `isEmbedded` | `@lotics/app-sdk` | `dist/src/rpc.d.ts` |
290
+ | `openApp` | `@lotics/app-sdk` | `dist/src/open_app.d.ts` |
package/docs/runtime.md CHANGED
@@ -185,6 +185,7 @@ surfaces:
185
185
  | `agentRun.get`, `agentRun.cancel` | yes | **no** — the dev forwarder doesn't implement them (`"Unknown RPC op: …"`) | yes |
186
186
  | `agentRuns` (session history) | **no** — the product host doesn't implement it either (`"Unknown RPC op: agentRuns"`) | **no** | yes |
187
187
  | `askAi` | yes | **no** — `"Unknown RPC op: askAi"` | rejects — `"askAi is only available when the app runs inside Lotics"` |
188
+ | `openApp` | yes — routes to the sibling in the same tab | yes — opens the sibling on the web app in a new tab (one app is served locally) | rejects — `"openApp needs the Lotics host …"` |
188
189
 
189
190
  **Limitation:** Don't build a session-log UI on `useAgentRuns`; keep the log in app
190
191
  state from live `useAgentRun` results (see [ai](./ai.md)). Server-side *cancel*
@@ -226,6 +227,7 @@ semantics are documented:
226
227
  | `members` | `{ group? }` | `{ members }` | [members & options](./members_and_options.md) |
227
228
  | `context` | `{}` | app identity (below) | this doc |
228
229
  | `openExternal` | `{ url }` | `void` | this doc |
230
+ | `openApp` | `{ app_id, route }` | `void` | this doc |
229
231
  | `askAi` | prompt/files/records seed | `void` | [ai](./ai.md) |
230
232
  | `agentRuns` | `{ session_id, limit?, offset? }` | `{ runs }` | [ai](./ai.md) |
231
233
  | `agentRun.get` | `{ run_id }` | `{ run }` | [ai](./ai.md) |
@@ -267,6 +269,38 @@ page (standalone). Opens in a new tab with `noopener,noreferrer`.
267
269
  `FilePreview`/`FileGalleryModal` (see [files](./files.md)); `openExternal` is
268
270
  "leave the app".
269
271
 
272
+ ## `openApp()` — the cross-app hop
273
+
274
+ ```tsx
275
+ import { openApp, isEmbedded } from "@lotics/app-sdk";
276
+ await openApp(PLANNING_APP_ID, `/plan/${record.id}`);
277
+ ```
278
+
279
+ `openApp(appId: string, route?: string): Promise<void>` (`dist/src/open_app.d.ts`).
280
+ A workspace built as several apps around one spine shows another app's record
281
+ read-only with a way THROUGH to the app that owns it; this is the way through.
282
+ The host owns app routing, so it lands the viewer on `route` inside `appId` in
283
+ the **same tab**, chrome kept — `_loc` carries the screen exactly as
284
+ `AppRouter` mirrors it, so the target boots at that record rather than at its
285
+ root. `route` is the target app's own in-app path, exactly as its router
286
+ declares it (`/` for its register); it is the target's to define.
287
+
288
+ - **Checked at the bridge, host-side**: the id by shape (`app_…`), the route as
289
+ an in-app path — starts with `/`, never `//` or a scheme. An app never
290
+ assembles the host's URL itself.
291
+ - **By transport**: embedded routes in place; `lotics app dev` serves one app
292
+ and has no shell to route inside, so it opens the sibling on the web app in a
293
+ new tab; standalone rejects (`openApp needs the Lotics host …`) — gate the
294
+ control on `isEmbedded()`.
295
+ - A sibling's id is this workspace's: a copy of the suite has different ones,
296
+ and the portability gate refuses a concrete `app_` in `src/` and in every
297
+ `.md`. The id reaches the app as data — a field the model binds, a query's
298
+ row — never as a literal.
299
+ - Put it on a control the person presses. A hop on mount pushes the host's
300
+ history without a gesture, and two apps that each do it leave Back nowhere
301
+ to go. A hop to the app's own id is refused — navigate inside an app with
302
+ its own router.
303
+
270
304
  ## `downloadFile()` — save browser-built bytes
271
305
 
272
306
  ```tsx
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.2",
3
+ "version": "0.90.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": {