@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 +2 -2
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/open_app.d.ts +15 -0
- package/dist/src/open_app.js +18 -0
- package/dist/src/rpc.d.ts +1 -1
- package/dist/src/rpc.js +4 -0
- package/docs/navigation_and_state.md +1 -0
- package/docs/runtime.md +34 -0
- package/docs/workflows.md +54 -64
- package/package.json +1 -1
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 +
|
|
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
|
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
```
|