@lotics/app-sdk 0.46.1 → 0.47.1

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/docs/ai.md ADDED
@@ -0,0 +1,201 @@
1
+ # AI in apps
2
+
3
+ An app has two AI surfaces, and they answer different questions. **`useAgentRun(alias)`** runs an agent *declared on the app* — a streaming, tool-looping run whose result lands back **in the app** (a typed structured output, or free-text prose) for the app to review and commit through its own [workflows](./mutations.md). **`askAi(args)`** is a *handoff* — it opens the Lotics chat messenger seeded with files, records, and a prefilled prompt, and the outcome lands **in chat**, under the signed-in member's control. Read this doc when adding any AI-driven feature to an app; read [security](./security.md) first for the authority model agent runs execute under. Exact signatures: `dist/src/hooks.d.ts` (`useAgentRun`, `useAgentRuns`), `dist/src/agent_stream.d.ts` (`AgentRunItem`, `AgentRunStep`, `AgentRunState`), `dist/src/ask_ai.d.ts` (`AskAiArgs`).
4
+
5
+ ## Choosing the surface — the fields-vs-file razor
6
+
7
+ - **The outcome lands in *fields*** — extract, check, match, rank, classify; structured judgment the app commits into its own data behind a review surface → **`useAgentRun`**. The run's `output` is typed against a declared schema, and the app owns the commit path (show the proposal, let the user correct it, then write via a workflow).
8
+ - **The outcome is a *file* or an *open-ended answer*** — "edit this invoice", "draft an email about this order", iterating on a document, a question with no fixed shape → **`askAi`**. Dialogue-shaped work belongs in chat; its outcome never flows back into the app's workflows.
9
+
10
+ Don't run a structured extraction through `askAi` (the result is stranded in a chat thread), and don't wedge document-editing dialogue into `useAgentRun` (a flat run log is not a conversation surface).
11
+
12
+ ---
13
+
14
+ ## Declared agents — what `useAgentRun` runs
15
+
16
+ An agent is **declared on the app server-side, by alias**, with the `set_app_agent` tool (removed with `remove_app_agent`). `lotics app deploy` ships code and queries only — it never binds agents. The `lotics.agents` map in `package.json` is a **read-only reflection** written by `lotics app pull`; hand-editing it does nothing. Invoking an alias that isn't bound fails with a "no agent alias" error naming `set_app_agent`. (After a deploy, the CLI warns about any manifest alias not bound on the server.)
17
+
18
+ A declaration carries:
19
+
20
+ | Field | Meaning |
21
+ |---|---|
22
+ | `instructions` | System instructions — the task the agent performs per run |
23
+ | `tool_names` | The tools the agent may call, resolved against the platform's automation tool registry. **This is the capability boundary** — the run can use nothing else. May be empty for a pure-reasoning agent |
24
+ | `model_id` | The chat model the agent runs on |
25
+ | `effort_level` | Optional reasoning depth for adaptive-thinking models |
26
+ | `inputs` | Optional typed input schema for one run — the same vocabulary as workflow inputs (`text`, `number`, `file`, `member`, `record_link`, `select`, …). The server validates every run payload against it |
27
+ | `outputs` | Optional typed output schema. Declared → **structured agent** (the run must emit a matching result); omitted → **free-text agent** (the answer is the final prose) |
28
+
29
+ **Structured vs free-text is the load-bearing split.** A structured agent's result arrives in `run.output` (the server strictly validates the submitted result against the declared schema — see the `output` typing section for the exact guarantee); a free-text agent's answer is the transcript's prose (`run.text`) and its `output` stays `undefined` — never a stray string, so a consumer reading `output.<field>` can't crash on a free-text answer.
30
+
31
+ **Input validation and tenant bounds.** Run inputs get the same server-side enforcement as workflow inputs: `record_link` ids must live in the declared table, `member` ids in the declared group, `file` ids in the app's workspace (see [the caller boundary](./security.md)). The run executes under the **app owner's** authority in the app's own workspace.
32
+
33
+ **Typing.** `lotics app pull` / `lotics app codegen` emit `.lotics/app_agents.d.ts`, which augments the SDK's `AppAgents` (alias → input shape) and `AppAgentResults` (alias → declared output shape) interfaces — `useAgentRun("recognize")` then types both `run(input)` and `output`. An alias with no codegen falls back to `Record<string, unknown>` input / `unknown` output.
34
+
35
+ ---
36
+
37
+ ## `useAgentRun(alias)`
38
+
39
+ ```tsx
40
+ import { useAgentRun } from "@lotics/app-sdk";
41
+
42
+ const recognize = useAgentRun("recognize");
43
+ await recognize.run({ image_file_id: fileId }, { sessionId });
44
+ // live: recognize.status, recognize.items
45
+ // done: recognize.output (structured) or recognize.text (free-text)
46
+ ```
47
+
48
+ ### The return surface
49
+
50
+ | Member | Type | What it is |
51
+ |---|---|---|
52
+ | `run` | `(input, { sessionId }) => Promise<TOutput \| undefined>` | Start a run. Streams progress into the hook's state and resolves to the structured output (`undefined` for a free-text or failed run). Calling it again **aborts any run still in flight** |
53
+ | `cancel` | `() => void` | Stop the run **server-side** (saves tokens) and locally. Wire a user-facing Stop button to this |
54
+ | `abort` | `() => void` | Stop listening **locally only** — the run keeps executing server-side and its result is still persisted. This is the unmount path (the hook calls it automatically on unmount) |
55
+ | `status` | `"idle" \| "streaming" \| "completed" \| "error"` | Whole-run state. `abort`/`cancel` reset it to `"idle"` (and clear the partial transcript) |
56
+ | `items` | `AgentRunItem[]` | The ordered live transcript — answer prose, thinking, and tool steps, in stream order. The single source of truth for the feed |
57
+ | `text` | `string` | The agent's **answer prose** (every `text` segment concatenated), accumulating live. Excludes thinking. For a free-text agent this IS the result |
58
+ | `steps` | `AgentRunStep[]` | Only the tool/step items — a backward-compat view derived from `items`. Prefer `items` (it keeps the text↔reasoning↔tool ordering) |
59
+ | `output` | `TOutput \| undefined` | The structured result once the run completes. `undefined` when the run produced none |
60
+ | `error` | `string \| undefined` | The failure message when `status` is `"error"` |
61
+
62
+ ### The `items` transcript → `@lotics/ui` `AgentRun`
63
+
64
+ `AgentRunItem` is a discriminated union in stream order:
65
+
66
+ | `type` | Carries | Rendering intent |
67
+ |---|---|---|
68
+ | `"text"` | `id`, `text` | Answer prose, grows as it streams |
69
+ | `"reasoning"` | `id`, `text` | The agent's thinking — a **distinct segment**, kept out of `text`, so the UI can show it collapsed / revealed on demand |
70
+ | `"step"` | `AgentRunStep`: `id`, `label` (raw tool name), `status` (`"running" \| "done" \| "error"`), `kind`, `input`, `output`, `errorText`, `detail`, `streamedChars` | One tool call — opens `"running"` the moment it fires, settles when its result arrives. `input`/`output` ride along for an on-demand reveal, not for the feed. While a call's arguments are still generating, `detail` carries a live size ("18 KB", accumulated in `streamedChars`) so a long generation reads as progressing; both clear when the call's output arrives (a call that settles `"error"` keeps its last size) |
71
+
72
+ The shape is structurally the `AgentRunItem` that `@lotics/ui`'s `AgentRun` component takes, so the pairing needs **no adapter and no hand-assembly**:
73
+
74
+ ```tsx
75
+ <AgentRun
76
+ items={run.items}
77
+ state={run.status === "streaming" ? "streaming" : run.status === "error" ? "error" : "done"}
78
+ labelForTool={(name) => toolLabels[name]} // optional: localize tool labels
79
+ />
80
+ ```
81
+
82
+ `AgentRun` renders thinking collapsed, groups consecutive tool steps, and shows each tool's input/output in a press-to-open peek — all for free. Never rebuild this feed from `text` + `steps`.
83
+
84
+ ### `output` typing and the inner-field caveat
85
+
86
+ The server validates the agent's submitted result strictly (unknown keys rejected, required fields present, types checked, `select` values bound to the declared options); an invalid submission is rejected back to the agent with the validation errors, and the agent retries. A run only settles **completed** with a result that passed this — so on a completed run, `output.items.map(...)` on a declared array field is safe. What that does **not** guarantee:
87
+
88
+ - a field declared `required: false` may be absent;
89
+ - a `json`-typed output field is passthrough — anything goes inside it;
90
+ - `date` / `datetime` / `email` outputs are validated only as strings, not as well-formed values;
91
+ - every **value** was authored by the model — a schema-valid string can still be semantically wrong;
92
+ - one live-stream corner: the hook adopts `output` from the submit call's **arguments as they stream**, before the server-side validation runs. Normally the loud rejection makes the agent retry and the last (valid) submit overwrites it — but a run whose *final* submit was rejected ends the live stream with that invalid attempt still in `output` (the persisted run settles as an error). The review surface below is the backstop.
93
+
94
+ Treat `output` as a trusted *shape* carrying untrusted *values*: render it into a review surface and let the user confirm before a workflow commits it. Never write model output straight to records without a review step.
95
+
96
+ ### Errors — two channels, handle both
97
+
98
+ 1. **`run()` rejects (throws)** when the run can't start or the stream fails unrecoverably: AI-credit quota exhausted, the per-member concurrency cap, an undeclared alias, input validation failure, or a network error. Wrap the call in try/catch and surface the message.
99
+ 2. **An in-run failure resolves.** A model or tool error mid-run arrives as a stream frame: `run()` resolves `undefined`, `status` becomes `"error"`, and `error` carries the message. Checking only try/catch misses this path; checking only `status` misses the first.
100
+
101
+ ```tsx
102
+ try {
103
+ const output = await recognize.run(input, { sessionId });
104
+ if (recognize.status === "error" || output === undefined) { /* surface recognize.error */ }
105
+ } catch (err) { /* quota / network / validation — surface err.message */ }
106
+ ```
107
+
108
+ Server-side failure modes are not all equally loud. A mid-run model/tool exception and the max-duration cap surface through channel 2 (the live run errors; a capped run's live message is a generic "The run was stopped." — the persisted run carries "Run exceeded the 20-minute limit."). But two modes are **quiet on the live stream**: a structured agent that finished without ever submitting a result, and a free-text agent that produced no text, end the live stream with `status` `"completed"` and no result — only the *persisted* run settles as an error ("Run ended without producing a structured result." / "Run produced no output."). On the live hook the only signal is the missing output, which is why the example checks `output === undefined` and not just `status`.
109
+
110
+ ### `cancel` vs `abort`
111
+
112
+ | | `cancel()` | `abort()` |
113
+ |---|---|---|
114
+ | Server effect | Stops the run server-side — saves tokens; the run settles as `aborted` | **None** — the run keeps executing to completion and its result is persisted |
115
+ | Local effect | Clears state to `idle` | Clears state to `idle` |
116
+ | Use for | A user-facing **Stop** button | Unmount / navigating away (the hook already calls it on unmount) |
117
+
118
+ Both settle the in-flight `run()` promise cleanly with `undefined` — a stop is not a failure. A dropped connection does **not** cancel a run; `cancel()` is the only stop path.
119
+
120
+ **Warning:** server-side cancellation is honored *between agent steps* — a cancel issued mid-way through one long generation takes effect at the next step boundary, not instantly. The UI should reflect "stopping" optimistically (the local state clears at once).
121
+
122
+ ### Runs survive dropped connections
123
+
124
+ The run's lifetime is decoupled from the stream: the server drives it to completion and persists the result even if the connection drops. The hook reads the run id from the stream's start; if the connection then fails, it **polls the persisted run to completion** (every 2.5 s, bounded at 11 minutes) and resolves with the settled result instead of surfacing a network error — a long extraction is never lost to a flaky connection. Only when no run id was ever received (the run never started) does the failure reject.
125
+
126
+ **Warning:** on this recovery path the hook adopts the *persisted* run's output — and a **free-text** run is persisted with its final answer text as the output. So after a connection-drop recovery, a free-text agent's `output` can carry the answer **string** (the one place the "never a stray string" rule doesn't hold), while `text` keeps only what streamed before the drop. If you consume free-text agents, guard with `typeof output === "string"` on the resolved value.
127
+
128
+ ### Sessions
129
+
130
+ `run()` requires `{ sessionId }` — an **app-minted opaque key** grouping runs into a working session. Each run replays the session's prior **completed** runs (their inputs and outputs, including re-materialized image/PDF inputs) as conversation context, so a follow-up like "make it a bit less" resolves against the previous result. Two rules keep this sane:
131
+
132
+ - **The app owns the state.** Always pass the authoritative current state in `input` — the session context is memory, not the source of truth.
133
+ - **Mint a new `sessionId` to clear context.** There is no reset call; a fresh key is a fresh session.
134
+
135
+ Sessions are scoped to the authenticated member who ran them: two members using the same `sessionId` string share nothing, and a member can never read or extend another member's thread.
136
+
137
+ ### File inputs → vision
138
+
139
+ A declared `file` input (single or `multi`) is materialized into the model's native perception when the type supports it: **`image/*` becomes a vision part, `application/pdf` a document part** — the agent literally sees the file, no OCR tooling needed. Any other type (docx, xlsx, csv, …) is passed as a `file_id` reference instead; the agent can only open it if a file-reading tool is in its declared `tool_names`. Every file in a `multi` input is materialized — nothing is collapsed to the first. Get the ids from [`useFileUpload` / `useAttachments`](./files.md).
140
+
141
+ ### Auth, quota, and bounds
142
+
143
+ - **Member-authenticated, embedded-only.** Agent runs require a signed-in member — an anonymous visitor to a public/standalone app is rejected with an explicit error ("App agent runs require an authenticated member…"). Runs work embedded in the product and in the `lotics app dev` harness (where they run as the CLI key's member).
144
+ - **Owner-billed.** Token usage is recorded against the app's organization's AI credits. The quota is enforced before the first model call (a `run()` rejection) and re-checked between steps (a mid-run exhaustion settles the run as an error).
145
+ - **Hard duration cap: 20 minutes per run.** A capped run settles as an error — but with whatever partial transcript and captured output it produced, recoverable from the persisted run.
146
+ - **Concurrency cap: 5 in-flight runs per member.** Exceeding it rejects with "Too many agent runs in progress".
147
+
148
+ **Limitation (dev harness):** `lotics app dev` streams runs but does not forward the run id, so in the dev loop `cancel()` degrades to a local `abort()` (the run keeps executing server-side) and connection-drop recovery is unavailable. Both work fully in the embedded product.
149
+
150
+ ---
151
+
152
+ ## `useAgentRuns(sessionId, opts?)` — session history
153
+
154
+ Reads a session's persisted run history, oldest-first: `{ runs, loading, error, refetch }`, where each `AgentRunRecord` carries `id`, `agent_alias`, `session_id`, `status`, `input`, `output`, `error_message`, `started_at`, `completed_at`. `opts` takes `{ enabled?, revalidateOnFocus? }`. A persisted record's `output` is the validated structured object for a structured agent and the **final answer string** for a free-text agent.
155
+
156
+ **Limitation:** the embedded product host does not currently implement the history op — `useAgentRuns` errors with "Unknown RPC op: agentRuns" in an embedded app, and the dev harness rejects it the same way (standalone anonymous callers are rejected as unauthenticated). The history *exists* server-side (it is what feeds session context), but this hook cannot read it from any current transport. Do not build a session-log UI on it yet; keep the visible log in app state from the live `useAgentRun` results instead.
157
+
158
+ ---
159
+
160
+ ## `askAi(args)` — the chat handoff
161
+
162
+ ```tsx
163
+ import { askAi } from "@lotics/app-sdk";
164
+
165
+ await askAi({
166
+ file_ids: [file.id],
167
+ record_ids: [row.id],
168
+ prompt: "Update the header of this invoice to match our letterhead.",
169
+ });
170
+ ```
171
+
172
+ Opens the Lotics messenger on a **fresh chat**, seeded with the given payload. The contract:
173
+
174
+ | Arg | What the host does with it |
175
+ |---|---|
176
+ | `prompt` | **Prefills the chat composer.** The user sees it, can edit it, and sends it themselves — **nothing runs until they press send** |
177
+ | `file_ids` | Files to attach (ids from [`readFiles(cell)`](./files.md) or an upload). The host resolves each id itself and attaches the current version; a **single** file also opens in a preview pane beside the chat |
178
+ | `record_ids` | Records the chat should know about (ids from query rows). The host resolves them to table context — the agent can then read and act on them **under the signed-in member's authority** |
179
+ | `context` | Free-text grounding ("Order ORD-2481, customer Acme…"). The host stamps the app's identity (id + name) alongside it automatically |
180
+
181
+ At least one of the four is required — an empty call rejects. The bridge carries only ids and text; the host resolves everything as the signed-in member, so the handoff can only surface what that member could already open. An unresolvable file or record is dropped with a visible toast and the rest of the handoff proceeds.
182
+
183
+ **Name the task in `prompt`.** Word/Excel file bytes are not inlined into the model's context — a clear brief ("update the header of this invoice…") makes the agent read the attached file first instead of asking what to do.
184
+
185
+ **Embedded-only.** `askAi` rejects in standalone mode ("askAi is only available when the app runs inside Lotics") and in the `lotics app dev` harness (unknown op). It also rejects on the rare embedded screen where the chat surface is unavailable ("chat is unavailable on this screen") — treat the returned promise's rejection as a real path and surface it.
186
+
187
+ `askAi` returns once the handoff is delivered; it never reports what the user or agent did afterwards. If the app needs the outcome, that's the razor telling you to use `useAgentRun`.
188
+
189
+ ---
190
+
191
+ ## Version floors
192
+
193
+ The floors below are when each capability shipped in `@lotics/app-sdk`; an app pinned older silently lacks them.
194
+
195
+ | Capability | Minimum version |
196
+ |---|---|
197
+ | `useAgentRun` (with `abort`) + `useAgentRuns` | `@lotics/app-sdk` 0.34 |
198
+ | `cancel()` (server-side stop) + connection-drop recovery | `@lotics/app-sdk` 0.37 |
199
+ | `items` transcript (reasoning segments + per-tool `input`/`output`) | `@lotics/app-sdk` 0.43; rendering pairs with `@lotics/ui` ≥ 7.13 (`AgentRun` `items` prop) |
200
+ | Live streamed-argument size on a running step (`detail`) | `@lotics/app-sdk` 0.44 |
201
+ | `askAi` | `@lotics/app-sdk` 0.45 |
@@ -0,0 +1,349 @@
1
+ # Data fetching
2
+
3
+ How an app reads data: the three read hooks (`useQuery`, `useInfiniteQuery`, `usePaginatedQuery`),
4
+ their caching/revalidation and pagination contracts, the typed cell readers that decode query rows
5
+ (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `useFieldOptions`
6
+ for complete select option sets, the data-discipline rules, and the two load-bearing read patterns
7
+ (search-as-you-type, browse/record-picker). Authoring the named queries these hooks invoke — the
8
+ AST, params, filter operators, aggregation, performance contract — is [./queries.md](./queries.md);
9
+ every write goes through a workflow — [./mutations.md](./mutations.md). Exact signatures:
10
+ `dist/src/hooks.d.ts`, `dist/src/row.d.ts`, `dist/src/select.d.ts`, `dist/src/members.d.ts`.
11
+
12
+ The read model in one paragraph: an app never sends a raw query. It invokes a **named query by
13
+ alias** (declared in `package.json#lotics.queries`) and fills the template's declared `{{params.x}}`
14
+ value holes; the server holds the canonical AST and runs it under the **app owner's** authority
15
+ (per-viewer scoping is a template concern — see [./security.md](./security.md)). Per-app CLI
16
+ codegen augments `AppQueries`, so an undeclared alias is a compile-time error and params are typed
17
+ per the manifest. Each hook is a thin wrapper over the host RPC bridge with an SWR cache in front.
18
+
19
+ ## Choosing a hook
20
+
21
+ | Hook | Result shape | Reach for it when |
22
+ |---|---|---|
23
+ | `useQuery(alias, params?, opts?)` | one fetch, `rows` | a detail read, a dashboard block, a combobox's top-N — anything that is not a long list |
24
+ | `useInfiniteQuery(alias, params?, opts?)` | accumulated `rows` + `loadMore` | infinite scroll / "load more" feeds |
25
+ | `usePaginatedQuery(alias, params?, opts?)` | one page of `rows` + `total` | numbered, jumpable pages behind `@lotics/ui` `Pagination` |
26
+
27
+ One job each — don't overload one. `useQuery` with a big `pageSize` is not pagination; a
28
+ `usePaginatedQuery` whose pages you concatenate yourself is `useInfiniteQuery` done by hand.
29
+
30
+ ## Shared option surface
31
+
32
+ All three hooks accept these (`BaseQueryOptions`):
33
+
34
+ | Option | Type | Default | Effect |
35
+ |---|---|---|---|
36
+ | `enabled` | `boolean` | `true` | `false` = no request is sent, `rows` is `[]`, `loading` is `false`. Flip to `true` to fetch. The gate for search-as-you-type and on-demand detail. Note: disabling also **hides** previously loaded rows (the cache entry survives; it re-renders instantly when re-enabled). |
37
+ | `revalidateOnFocus` | `boolean` | `true` | `false` = no auto-refetch on window focus / tab return / network reconnect (`refetch()` still works). Keep the default for dashboards; turn off for transient queries (a search bound to an ephemeral term) where a refocus re-run is wasted work and a visible reload. |
38
+ | `sort` | `QuerySortKey[]` | — | Runtime sort, applied server-side **after** the named query, over its output columns: `[{ field_key, order: "asc" \| "desc" }]`. An empty/omitted array leaves the query's own order intact. Part of the cache key — changing it re-queries. |
39
+ | `filter` | `QueryFilter` | — | Runtime filter, applied server-side after the named query, over its output columns. A single condition or a recursive `{ node_type: "group", logic: "and" \| "or", children }`. Part of the cache key. Build from per-column UI state with `columnFilterToConditions` (`@lotics/ui/column_filter`). |
40
+
41
+ Per-hook additions:
42
+
43
+ | Option | Hook | Meaning |
44
+ |---|---|---|
45
+ | `pageSize?: number` | `useQuery` | a **cap** on the one request, not pagination; omit to fetch up to the server row cap |
46
+ | `pageSize: number` | `useInfiniteQuery` | rows per appended page — the options type requires it, though omitting `opts` entirely type-checks and defaults to 30 |
47
+ | `pageSize?: number` | `usePaginatedQuery` | rows per page, default 25 |
48
+
49
+ Runtime `sort`/`filter` are **server-bounded to the named query's output columns** — a `field_key`
50
+ the query doesn't project is rejected with an error, so a sortable/filterable UI can never widen
51
+ the app's data exposure. The full runtime-refinement contract (which operators are valid per column
52
+ type, record-link membership filtering, and the field-less system conditions — `record_id` works in
53
+ a runtime filter; `locked` / `current_member` are template-only and rejected at the runtime layer)
54
+ lives in [./queries.md](./queries.md).
55
+
56
+ Refinement order is fixed: the runtime **filter narrows** the named query's result, then **sort
57
+ orders** it, then **limit/offset paginate** it. The template's own filters/sort/limit run first,
58
+ inside the named query.
59
+
60
+ **Limitation:** the exported `QueryFilterCondition` type declares `field_key` as required, but the
61
+ field-less `record_id` system condition documented in [./queries.md](./queries.md) (the one usable
62
+ in a runtime filter) carries no field. To satisfy the type, include `field_key: ""` on it — the
63
+ server validates system conditions by `type` and never reads `field_key` on them.
64
+
65
+ ## Caching, loading states, and errors
66
+
67
+ - **Cache identity** is the tuple `(alias, params, pageSize, sort, filter)` (+ the page index for
68
+ the paged hooks). Object *contents* are hashed, not references — passing a fresh inline
69
+ `{ status: "open" }` each render is the same key; you never need to memoize params.
70
+ - The cache **survives unmount/remount**: returning to a screen renders the cached rows instantly
71
+ and revalidates in the background (stale-while-revalidate). Identical concurrent reads dedupe to
72
+ one request.
73
+ - **`loading`** is `true` only on the *initial* load of a key — a request is in flight and there
74
+ are no rows yet. It stays `false` during background revalidation of a key that already has rows,
75
+ so consumers never blank loaded data to a spinner on refetch. A key *change* (new params, sort,
76
+ filter, or page) is a fresh load: `loading` goes `true` again unless that key is already cached —
77
+ `usePaginatedQuery` keeps the previous page's rows on screen during it, which is why skeletons
78
+ gate on `loading && rows.length === 0`, never `loading` alone. **`isValidating`** is `true`
79
+ whenever any request is in flight — use it for a subtle refresh indicator.
80
+ - **`error`** is a `string | null`. A failed query surfaces immediately — there is **no automatic
81
+ retry** (no retry loop that masks the error). The last successful rows for the same key stay
82
+ rendered. The next focus revalidation or an explicit `refetch()` re-runs it.
83
+ - **`refetch()`** re-runs the query. Call it after a known mutation point — a successful
84
+ `useWorkflow` call — to pull the latest state (see [./mutations.md](./mutations.md)).
85
+ `usePaginatedQuery.refetch()` refreshes both the page and the count.
86
+ - A design-time fixture registered via `mount(<App />, { fixture })` plus the `?__mock=1` URL flag
87
+ short-circuits all three hooks (rows come from the fixture, no request, `loading` stays `false`)
88
+ — see [./runtime.md](./runtime.md).
89
+
90
+ ### Error messages you will actually see
91
+
92
+ | `error` value | Meaning | What to do |
93
+ |---|---|---|
94
+ | `query timed out after 15s — narrow the filter or simplify the query` | The query hit the per-query statement timeout (15 s). Usually an unindexed predicate scanning a large table — filter *shape* drives latency; see the performance contract in [./queries.md](./queries.md). | Narrow with an indexed filter or `search`, reduce the work per request. |
95
+ | `The app is handling too many requests right now. Please retry in a moment.` | The server's bounded-concurrency gate shed the query under load (a 503). Distinct from a query error — nothing is wrong with the query itself. | Retry (e.g. surface a retry button wired to `refetch()`); the hook does not auto-retry. |
96
+ | `query execution failed` | The query failed at the database. Deliberately generic — database internals are never sent to the client. | The app author diagnoses from the platform's server logs; the app surfaces the message. |
97
+ | A specific validation message | e.g. an un-projected `field_key` in runtime `sort`/`filter`, invalid params, an unknown alias. | Fix the call site — these are contract violations, not transient. |
98
+
99
+ ## Pagination realities (offset-based)
100
+
101
+ All paging — `useInfiniteQuery` pages, `usePaginatedQuery` pages, and manual `rpc("query", { limit,
102
+ offset })` — is **offset pagination**. There is no cursor API. The consequences are part of the
103
+ contract:
104
+
105
+ - **Row cap: 10,000.** No single query response returns more than 10,000 rows; a larger `limit`
106
+ (or an omitted `pageSize` on `useQuery`) is clamped to it. To read a bigger result set, page
107
+ through it.
108
+ - **Deep pages cost more.** `offset: n` makes the server produce and discard `n` rows before the
109
+ page — page 200 is materially slower than page 2. Prefer narrowing filters over deep paging.
110
+ - **Pages can shift under concurrent writes.** A row inserted/deleted between two page fetches
111
+ shifts every later offset — a row can appear on two consecutive pages or fall between them. An
112
+ infinite list accumulates pages fetched at different times, so treat duplicates as expected:
113
+ key rows by `__source_record_id` (never by array index) and dedupe if the list must be exact.
114
+ - **Always give a paginated query a deterministic order.** With no `ORDER BY` (neither in the
115
+ template nor runtime `sort`), row order is unspecified and pages may overlap or skip rows even
116
+ without concurrent writes. Sort by a stable column (unique where possible).
117
+ - **`count: true` counts the filtered set**, ignoring sort/limit/offset — `usePaginatedQuery`
118
+ issues it automatically. The count and the page are separate requests, so under concurrent
119
+ writes `total` can briefly disagree with what paging finds.
120
+
121
+ ### `useQuery`
122
+
123
+ ```tsx
124
+ const { rows, loading, error, refetch } = useQuery("openOrders", { status: "open" });
125
+ ```
126
+
127
+ One fetch. `params` is required when the alias declares params, optional otherwise. Returns
128
+ `{ rows, loading, isValidating, error, refetch }`. `rows` is `Array<Record<string, unknown>>` —
129
+ decode cells with the readers below.
130
+
131
+ ### `useInfiniteQuery`
132
+
133
+ ```tsx
134
+ const { rows, loadMore, hasMore, loadingMore } = useInfiniteQuery("feed", {}, { pageSize: 30 });
135
+ ```
136
+
137
+ The first render loads one page; `loadMore()` appends the next, accumulating into `rows`.
138
+
139
+ - `hasMore` is `true` when the last page came back **full** — so a total that is an exact multiple
140
+ of `pageSize` costs one final empty fetch before `hasMore` turns `false`.
141
+ - `loadMore()` is a no-op while `loadingMore` is `true` or when `hasMore` is `false` — safe to wire
142
+ directly to a scroll sentinel.
143
+ - Changing any part of the result-set identity (`params`, `sort`, `filter`, `pageSize`) starts a
144
+ fresh accumulation from the first page.
145
+ - `refetch()` revalidates every loaded page. Appending a page does **not** re-fetch earlier pages,
146
+ so a long-lived list mixes page snapshots taken at different times (see the offset notes above).
147
+
148
+ ### `usePaginatedQuery`
149
+
150
+ ```tsx
151
+ const { rows, total, totalPages, page, setPage, hasMore } =
152
+ usePaginatedQuery("orders", { q }, { pageSize: 25, sort, filter });
153
+ ```
154
+
155
+ The page-model hook behind a numbered table (pairs with `@lotics/ui` `Pagination`). It owns the
156
+ page cursor and fetches two things: the current page, and a `count` over the filtered set.
157
+
158
+ - **Result-set identity is `(params, filter)`.** Changing either resets to page 0 and recounts.
159
+ Changing only `sort` does **neither** — the count is sort-independent and you stay on the same
160
+ page number of the new order (re-sorting never recounts, and never jumps you back to page 0).
161
+ - The count is keyed on `(alias, params, filter)` — page clicks and re-sorts reuse it.
162
+ - `page` is 0-indexed. `setPage` clamps at 0 but has **no upper clamp** — a page past the end
163
+ returns empty rows.
164
+ - `total` and `totalPages` are `undefined` until the count resolves — render pagination controls
165
+ defensively. `totalPages` is `max(1, ceil(total / pageSize))`. Until the count lands, `hasMore`
166
+ falls back to "the current page came back full".
167
+ - A page change **keeps the previous rows on screen** while the next page loads (built-in
168
+ `keepPreviousData`) — gate any skeleton on `loading && rows.length === 0`, never on `loading`
169
+ alone, or every page click collapses the table.
170
+
171
+ ## Decoding query cells
172
+
173
+ `useQuery` rows are `Record<string, unknown>`. The server serializes each output column by type;
174
+ coerce every cell with a typed reader — never re-implement the serialization contract by hand (it
175
+ rots when the wire format evolves; the readers move with it).
176
+
177
+ Wire shapes per output column type:
178
+
179
+ | Column type | Cell value on the wire |
180
+ |---|---|
181
+ | `text` | `string` |
182
+ | `number` | `number` (numeric database strings are normalized server-side; a rare decimal too precise for float64 stays a string — `row.num` parses both) |
183
+ | `boolean` | `boolean` |
184
+ | `date` | `"YYYY-MM-DD"` — a calendar day, no time component |
185
+ | `datetime` | `"YYYY-MM-DDTHH:mm"` — workspace wall-clock, minute precision |
186
+ | `select` | `Array<{ key, label }>` — one entry per selected option |
187
+ | `select_member` | `Array<{ id, name, email? }>` — `email` only for authenticated viewers of the app's own org |
188
+ | `select_record_link` | `Array<{ id, display }>` — target record id + its display text |
189
+ | `files` | `Array<{ id, filename, mime_type, url, thumbnail_url?, size?, created_at? }>` — presigned |
190
+
191
+ Row-level (non-grouped) queries additionally carry system columns: `__source_record_id` /
192
+ `__source_table_id` / `__source_locked` (source addressing — pass `__source_record_id` to
193
+ workflows), `__created_at` / `__updated_at` (record timestamps), and per-projection `__src_field_*`
194
+ metadata. A grouped query collapses rows and emits none of these. Details:
195
+ [./queries.md](./queries.md).
196
+
197
+ ### The readers
198
+
199
+ | Reader | In → out | Semantics |
200
+ |---|---|---|
201
+ | `row.opt(cell)` | select cell → `string \| null` | first option **key** (accepts the legacy bare-string / `{ id }` shapes); `null` when empty |
202
+ | `row.text(cell)` | any → `string` | strings pass through, finite numbers stringify, everything else → `""` |
203
+ | `row.num(cell)` | any → `number` | numbers pass (NaN/Infinity → 0), parseable strings parse, everything else → `0` |
204
+ | `row.bool(cell)` | any → `boolean` | `true` or the string `"true"`; everything else `false` |
205
+ | `row.date(cell)` | date/datetime cell → `Date \| null` | the stored **calendar day** at LOCAL midnight — time stripped |
206
+ | `row.datetime(cell)` | date/datetime cell → `Date \| null` | local `Date` **keeping the stored wall-clock** (minute precision; seconds are dropped); missing time = midnight |
207
+ | `row.link(cell)` | link cell → `{ id, display } \| null` | the FIRST linked record |
208
+ | `readLinks(cell)` | link cell → `{ id, display }[]` | ALL linked records (`[]` when empty) |
209
+ | `readSelect(cell)` | select cell → `ResolvedOption[]` | all selected options as `{ key, label }` (`[]` when empty) |
210
+ | `readMembers(cell)` | member cell → `ResolvedMember[]` | `{ id, name, email? }[]` (`[]` when empty) |
211
+ | `readFiles(cell)` | files cell → `AppFile[]` | attached files with presigned URLs (`[]` when empty) |
212
+ | `readLocked(rowObj)` | the whole **row** → `boolean` | the record's lock state from `__source_locked` |
213
+
214
+ All readers are pure (`unknown` in, value out), never throw, and return their empty value
215
+ (`null` / `""` / `0` / `false` / `[]`) for absent or malformed input — so callers iterate and
216
+ render without null-check pyramids.
217
+
218
+ **`row.date` vs `row.datetime`.** `row.date` parses only the leading `YYYY-MM-DD` and builds a
219
+ LOCAL-midnight `Date` — calendar/gantt placement never shifts across viewer timezones. `row.datetime`
220
+ keeps the stored wall-clock verbatim (no UTC conversion — the stored value is a timezone-less
221
+ workspace wall-clock), so `getHours()` / `toLocaleTimeString()` render the time as written. A
222
+ **`date`-typed output column carries no time on the wire** — `row.datetime` on it returns midnight
223
+ and the UI prints `00:00`. When you need the time, the query must output the column as `datetime`
224
+ (the type-override rules are in [./queries.md](./queries.md)).
225
+
226
+ **`readSelect`.** A query **cell** carries `key` + `label` only — `color` comes from
227
+ `useFieldOptions`, not the cell. An option deleted after the cell was written surfaces as
228
+ `label === key` (the stale state is explicit, never hidden). Render with `@lotics/ui` `OptionBadge`
229
+ — see [./members_and_options.md](./members_and_options.md).
230
+
231
+ **`readMembers`.** `name` is `null` when the id doesn't resolve in the app's org (e.g. a removed
232
+ member) — fall back explicitly. `email` is present only on authenticated responses from the app's
233
+ own org; anonymous public visitors and cross-org viewers get name only (no PII exposure). Cells
234
+ never carry avatar images — the avatar lives on the `useMembers` roster
235
+ ([./members_and_options.md](./members_and_options.md)).
236
+
237
+ **`readLinks` / `row.link`.** `display` is the linked record's primary-field text — render it;
238
+ use `id` to correlate, filter (link `has_any_of`), or fetch detail. `row.link` reads the first
239
+ entry only — on a multi-link field use `readLinks`.
240
+
241
+ **`readFiles`.** Each entry's `url` (and `thumbnail_url` for images) is **presigned with a 24-hour
242
+ TTL** — it renders directly in an `<Image>`/preview and works from the sandboxed iframe and for
243
+ anonymous public-app visitors; re-querying refreshes the TTL naturally. Entries the server didn't
244
+ presign (no `url`) are skipped, so you never render an unservable file. `size` (bytes) and
245
+ `created_at` (ISO upload timestamp) are resolved at serving time; `size` is absent for older files
246
+ not yet backfilled — render it only when present, and surface both as dedicated sortable columns
247
+ over the raw values (a formatted "8.4 MB" string sorts wrong). Previewing and uploading files:
248
+ [./files.md](./files.md).
249
+
250
+ **Warning — the presign ceiling:** signing file URLs is per-entry server work, so a response is
251
+ capped at **2,000 file entries** (server default). A query over the cap **fails** with an error
252
+ naming the count and the remedies — it never silently returns unsigned cells. Project `files`
253
+ columns **only in the query that renders them**, narrow with a filter, or paginate. A bare
254
+ `from_table` with no projection ships every column — including `files` — and is how you hit this.
255
+
256
+ **`readLocked`.** Takes the whole row object, not a cell. A locked record rejects direct writes —
257
+ show the locked state and route edits through the locked-change request flow
258
+ ([./mutations.md](./mutations.md)).
259
+
260
+ ## Complete select option sets
261
+
262
+ A query cell carries only the options a record actually holds (key + label, no color). For the
263
+ **complete** option list of a select column — every option including those in no current row, with
264
+ colors — use **`useFieldOptions`**, and prefer it over deriving options from loaded rows
265
+ (row-derived sets are incomplete until every page loads and carry no colors). Its full contract
266
+ (return shape, `byKey`, `opts.enabled`, freshness), rendering the values (`OptionBadge`,
267
+ `MemberChip`, `MemberSelect`), and the member roster (`useMembers`) live in
268
+ [./members_and_options.md](./members_and_options.md).
269
+
270
+ ## Data discipline
271
+
272
+ These rules prevent whole bug classes; every app follows them.
273
+
274
+ - **Server data is never copied into `useState`.** `useQuery` / `useWorkflow` results are the
275
+ source of truth — derive everything else with `useMemo`. A second copy drifts and serves stale
276
+ values.
277
+ - **Prefer derivation + callbacks over `useEffect`.** A derived value is `useMemo`; "state A
278
+ changed → set state B" is both set in the one triggering callback. `useEffect` is for genuine
279
+ external subscriptions (timers, DOM listeners, storage) — fetching is `useQuery`, not an effect.
280
+ - **No layout shift on load or paging — the UX bar, not a nicety.** (1) First load renders
281
+ `Skeleton` placeholders that mirror the final layout, not a bare spinner; (2) a page change keeps
282
+ the previous rows (`usePaginatedQuery` does this for you) — gate the skeleton on
283
+ `loading && rows.length === 0`, never `loading` alone; (3) a view↔edit toggle reserves the
284
+ input's height so pressing Edit never reflows.
285
+ - **Diff before update.** An edit form snapshots the record at load and sends only the CHANGED
286
+ fields to its update workflow. The full rule and the locked-record path:
287
+ [./mutations.md](./mutations.md).
288
+ - **Reuse the kit's utilities.** `@lotics/ui` ships the formatters (`formatMoney`, `formatDate` /
289
+ `parseDate` / `toISODate`) — never hand-roll `dd/MM` or currency strings. Grep `@lotics/ui`
290
+ exports before writing one.
291
+
292
+ ## Search-as-you-type
293
+
294
+ A search-first picker (type → ranked results → pick) is one `@lotics/ui` component plus three SDK
295
+ pieces. Compose these — don't hand-roll search:
296
+
297
+ - **`Combobox`** (`@lotics/ui/combobox`) owns the interaction — debounced `onSearchChange`, a
298
+ popover listbox with rich rows (`renderOptionContent`), keyboard navigation, `recentOptions`,
299
+ `allowCustom`. (For a known small list with no search box, `Picker`.)
300
+ - **A parameterized `search` query** — a `from_table` with `search: "{{params.q}}"` over the
301
+ maintained search document: **diacritics- and case-insensitive** (`"da nang"` matches
302
+ `"Đà Nẵng"`), trigram-indexed so it stays fast on large tables, and AND-ed with the template's
303
+ `filter` (search within a scope). The full `search` contract is in
304
+ [./queries.md](./queries.md). Reserve an OR-group of per-field `contains` only when you must
305
+ bound exactly which fields match — `contains` is case-insensitive but **accent-sensitive** and
306
+ **unindexed**, so a zero-match keystroke forces a full table-partition scan that hangs the
307
+ picker. **Search-as-you-type uses `search`, never a `contains` OR-group.**
308
+ - **`useQuery(alias, { q }, { enabled })`** — gate on a non-empty term: an empty term applies no
309
+ search constraint and would dump the table on first paint. `enabled` makes "nothing loads until
310
+ you type" true. Add `revalidateOnFocus: false` — re-running an ephemeral search on refocus is
311
+ wasted work.
312
+ - **`useRecents(key, { max })`** — persist the picked option locally; pass its list as
313
+ `recentOptions` ([./navigation_and_state.md](./navigation_and_state.md)).
314
+
315
+ ```tsx
316
+ const [term, setTerm] = useState(""); // live input stays local
317
+ const { rows, loading } = useQuery(
318
+ "searchCustomers",
319
+ { q: term },
320
+ { enabled: term.trim().length > 0, revalidateOnFocus: false },
321
+ );
322
+ ```
323
+
324
+ Fetch detail on select with a **second** parameterized query — a unique-code `equals`, or a link
325
+ `has_any_of [record_id]` — never a bare full-table load. To fetch a record by its **own id** (your
326
+ starting point is a bare id, e.g. a drill row), use the field-less `record_id` system condition —
327
+ contract and example in [./queries.md](./queries.md); prefer a link/join whenever an actual
328
+ relationship exists.
329
+
330
+ ## Browse + sort + filter (the record picker)
331
+
332
+ When the user doesn't know the term — "show me everything, let me narrow it" — build a modal table
333
+ they can browse (numbered pages), search, sort, and filter:
334
+
335
+ - **The screen is app-owned**, composed from `@lotics/ui`: a `Dialog` over `SearchInput` + filter
336
+ pills (`ColumnFilter`) + `Table` + `Pagination`. It needs both `@lotics/ui` and the SDK (which is
337
+ UI-free), so it lives in the app (e.g. a `record_picker.tsx`) — reuse it for any table by passing
338
+ a different `alias` + column config.
339
+ - **`usePaginatedQuery`** drives it: the page of rows, the `total` for "Page 1 of N" (the built-in
340
+ `count` request), and the page cursor.
341
+ - **Filter pills → runtime `filter`** via `columnFilterToConditions` (`@lotics/ui/column_filter`);
342
+ **column-header sort → runtime `sort`** by mapping the table's `{ key, order }` to
343
+ `[{ field_key: key, order }]`. Both are server-bounded to the query's output columns — the picker
344
+ can't widen exposure ([./queries.md](./queries.md)).
345
+ - **Browse needs an unbounded query.** A `limit` baked into the query template caps the *total*
346
+ browsable set — paging then pages within that cap. Drop the template limit and let `pageSize`
347
+ drive (the server row cap still bounds any single page).
348
+ - **Select filter options come from `useFieldOptions`** — the complete set, not options derived
349
+ from loaded rows.