@lotics/app-sdk 0.46.1 → 0.47.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
@@ -1,397 +1,49 @@
1
- # @lotics/app-sdk — the SDK reference
2
-
3
- The **data + RPC** runtime for Lotics custom-code apps: typed React hooks over an
4
- origin-locked postMessage bridge, plus cell decoders and the `mount()` entry point. This
5
- file is the SDK reference the "reach for what" guide + the load-bearing patterns. The
6
- **exact signature** of any hook or reader is its source, which ships with the package: read
7
- `node_modules/@lotics/app-sdk/dist/src/<name>.d.ts`. **Never guess a signature — open the
8
- file.**
9
-
10
- - Import from the package root: `import { useQuery, useWorkflow, row } from "@lotics/app-sdk"`.
11
- - **Data + RPC only — no UI.** The SDK ships zero components (keeps it off the React-Native-Web
12
- tree). Render with **`@lotics/ui`** (read its `node_modules/@lotics/ui/AGENTS.md`); the two
13
- pair SDK fetches/mutates, `@lotics/ui` draws.
14
- - **Two transports, picked automatically.** Embedded (the authenticated product host bridges
15
- RPC over postMessage) vs. standalone (`<slug>.lotics.app`, public, calls the API directly).
16
- App code never branches on it; the SDK does.
17
- - **The app holds no credentials and writes nothing directly.** Every read is a declared named
18
- query; every write is a declared workflow. The model, the IAM/auth rules, and the security
19
- rationale live in **`docs/apps.md`** read it for *why*; this file is *how*.
20
-
21
- ---
22
-
23
- ## Reach by role — the surface
24
-
25
- Pick by intent. ( open the `.d.ts` for the exact signature.)
26
-
27
- - **Read rows** three hooks, one job each; don't overload one:
28
- - **`useQuery(alias, params?, opts?)`** — a single fetch (`pageSize` is a cap, not pagination).
29
- For a detail read or a combobox's top-N. `opts.enabled` gates the fetch (nothing loads until
30
- true); `opts.sort` / `opts.filter` refine over the query's OUTPUT columns.
31
- - **`useInfiniteQuery`** append / load-more (`loadMore`, accumulating `rows`). Infinite scroll.
32
- - **`usePaginatedQuery(alias, params?, { pageSize, sort, filter })`** — page-model with a
33
- `total`, behind `Pagination`. Owns the page cursor; fetches the page AND a `count` keyed on
34
- `(alias, params, filter)` (independent of page+sort, so paging/re-sorting never recount).
35
- `(params, filter)` is the result-set identity changing it resets to page 0 and recounts.
36
- - **A select field's options** **`useFieldOptions(alias)`** per select OUTPUT column, its FULL
37
- `{ key, label, color }` list (incl. options not in any current row) + a `byKey` lookup. Populates
38
- + colors pickers and stored values; tracks the table (a newly added/renamed option just renders).
39
- - **Mutate (the ONLY write path)** — **`useWorkflow(alias)`** → an async fn returning a typed
40
- `WorkflowResult` `{ status, message?, files?, data? }`. `data` is whatever the workflow
41
- `return({ data })`'d (typed automatically from the alias contract). After a known mutation,
42
- call the owning query's `refetch()`. Handle failures by checking `result.status === "error"`,
43
- not just `try/catch`: a transport/gateway failure (a 524 timeout on a long run, any 5xx, a
44
- non-JSON error page) **resolves** with `{ status: "error", message }` (a friendly, body-free
45
- message) it does not throw an HTML body.
46
- - **Upload a file** **`useFileUpload()`** `{ upload, uploading, error }`. Mints a presigned URL
47
- and PUTs the bytes straight to storage; the file is inert until a workflow attaches it to a
48
- `files` field. Emits `app_file_uploaded`.
49
- - **Composer attachments (with instant preview)** **`useAttachments()`** → `{ files, add, remove,
50
- clear, uploading, fileIds }`. The chat's optimistic-preview UX in one hook: `add(File[])` shows a
51
- local object-URL preview AT ONCE and uploads in the background; each `files[]` entry carries
52
- `{ id, filename, mime_type, preview_url, status, file_id? }`; previews are revoked on remove/clear.
53
- Picking is yours (button → `@lotics/ui` `pickFiles`, or paste/drop) — pass the `File[]` to `add`.
54
- Wire to `@lotics/ui` `Composer`: `actionsButton` triggers the pick, then map each `AttachedFile`
55
- to a `DisplayFile` (snake → camel — `mime_type`→`mimeType`, `preview_url`→`url` — the app owns this
56
- data→UI adapter; the SDK never imports `@lotics/ui`) for
57
- `<FileThumbnail file={{ id, filename, mimeType, url }} uploading={f.status === "uploading"} />`;
58
- `sendDisabled` gates on `uploading` and the send payload is `fileIds`. To persist several files into ONE
59
- record, declare the workflow input `{ type: "file", multi: true }` — the body receives `fileIds` as
60
- `ReadonlyArray<FileId>` and writes it straight to a `files` field (a single `file` input is one id → wrap `[id]`).
61
- Don't hand-roll
62
- `createObjectURL`/upload/revoke per app. For a full add-files SCREEN (not the composer pill), map
63
- each `AttachedFile` to a `FileUpload` (ready → `{ status: "complete", id, file }`, else
64
- `{ status, id, filename, mimeType: mime_type, previewUrl: preview_url }`) and feed `@lotics/ui`
65
- `FileGrid`'s `uploads` — it renders the uploading/error/retry tiles itself (see the kit AGENTS.md
66
- Attachments recipe).
67
- - **List members** — **`useMembers(opts?)`** → `{ members: {id,name,email,image}[], loading, error }`
68
- — the candidate set for an assign/picker. Gated: the app must declare a `member`-typed input
69
- (`opts.group` restricts to a declared group). Render with `@lotics/ui` `MemberSelect` / `MemberChip`.
70
- - **Comments on a record** — **`useComments({ record_id })`** / **`useCommentCounts({ table_id })`**.
71
- Capability-gated (`"capabilities": { "comments": true }` in the manifest) AND members-only — check
72
- `available` before showing a composer. Runs under the VIEWING member's authority (author = the
73
- viewer), unlike queries/workflows (owner authority). Render with `@lotics/ui` `comments_thread`.
74
- - **Who's viewing** — **`useViewer()`** → the signed-in member (the *viewed* member under "View as").
75
- Display-only — personalize or attribute; **never** scope rows by it (use an `is_current_member`
76
- query filter server-side). Returns null for an anonymous public visitor.
77
- - **Run an AI agent** — **`useAgentRun(alias)`** → `{ run, cancel, abort, status, items, text, steps,
78
- output, error }` (agent declared in `package.json#lotics.agents`, authored via `set_app_agent`).
79
- `run(input, { sessionId })` streams it. TWO kinds, both first-class: a **structured** agent (declares
80
- `outputs`) lands its typed result in **`output`** (the `submit_result` shape) — `undefined` if the run
81
- produced none, **never a stray string** (so a present `output` is the declared shape; still validate
82
- untrusted INNER fields the model authored, e.g. an array that may be missing/mistyped); a **free-text**
83
- agent's answer is the transcript prose (**`text`**). Feed the live **`items`** transcript straight to
84
- `@lotics/ui` `AgentRun` — `<AgentRun items={run.items} state={run.status === "streaming" ? "streaming"
85
- : run.status === "error" ? "error" : "done"} labelForTool={…} />` — NO hand-assembly (`text`/`steps`
86
- are backward-compat views of `items`). The transcript carries the agent's **thinking** (a distinct
87
- `reasoning` segment, kept OUT of `text`) and each tool's **input / output / running→done→error state**;
88
- `AgentRun` shows thinking collapsed and tool I/O in a press-to-open peek (revealed on demand, not in the
89
- feed) — all for free, no extra wiring. Wire a Stop button to **`cancel()`** (stops the run server-side,
90
- saves tokens; `abort()` is local-only, for unmount). `run()` THROWS on quota/network — try/catch +
91
- surface. Needs an authenticated member (embedded apps only, no anonymous runs). Image/PDF `file`
92
- inputs are materialized into the agent's vision — pair with `useFileUpload`. `items`/`cancel` need
93
- `@lotics/app-sdk` ≥ 0.43; the reasoning + per-tool I/O reveal needs `@lotics/ui` ≥ 7.13.
94
- - **Device location** — **`requestGeofencedLocation(zones)`** → a structured outcome
95
- `{ ok:true, coords } | { ok:false, reason:"denied"|"unavailable"|"outside" }`; **`isWithinZone`**
96
- is the pure check. Read directly in the iframe (host delegates the permission); the gate is
97
- client-side advisory — pass `coords` to a workflow to record where an action happened.
98
- - **Recents** — **`useRecents(key, max)`** → most-recent-first, deduped, capped, `localStorage`-backed
99
- (web-only, which is why it's here, not in `@lotics/ui`). Feeds a `Combobox`'s `recentOptions`.
100
- - **Shareable view-state (filters in the URL)** — **`useUrlState(shape)`** → `[values, setValues]`,
101
- keeping a declared slice of state (filters, search, sort, the active tab) in the **address bar** so a
102
- view survives refresh and is shareable/bookmarkable. `setValues` replaces in place. Build `shape` from
103
- **`urlParam`** codecs. See *Save view-state to the URL*.
104
- - **In-app routing** — **`AppRouter`** (from `@lotics/app-sdk/router`) → wrap a react-router route config;
105
- the app owns its own url (embedded, that's the iframe's own url — never the host's), so navigation never
106
- reloads the app, browser back/forward walk app screens, and screens stay shareable + refresh-survivable.
107
- See *In-app navigation*.
108
- - **Optimistic mutation glue** — **`useOptimistic()`** → reconcile a workflow mutation against the
109
- query cache for an interactive (calendar/kanban/grid) app. See the data-bound recipe.
110
- - **Infra** — **`mount(opts?)`** boots the app + analytics (call once at entry; PostHog is automatic,
111
- no per-app wiring). **`rpc(op, payload)`** is the raw bridge (escape hatch; prefer the hooks).
112
- **`openExternal(url)`** opens a link in a new tab (scheme-validated; host-mediated in the embed).
113
- **`downloadFile(name, data, mime?)`** saves browser-built bytes (a `Uint8Array | Blob | string`) —
114
- call it synchronously from the click that produced them.
115
- - **Chat handoff** — **`askAi({ prompt?, file_ids?, record_ids?, context? })`** opens the Lotics
116
- messenger on a FRESH chat seeded with the given files (attached + previewed beside the chat),
117
- records (the agent can read/act on them under the viewer's authority), a prefilled *editable*
118
- prompt, and free-text grounding; the host stamps the app's identity automatically. Nothing runs
119
- until the user presses send. **The razor**: outcome lands in *fields* → `useAgentRun` + a
120
- review surface (the app's own structured commit path); outcome is a *file or an open-ended
121
- answer* (edit this invoice, draft an email about this shipment) → `askAi`. Embedded-only —
122
- rejects in standalone/dev. Name the task in `prompt` (Word/Excel bytes aren't inlined; a clear
123
- brief makes the agent read the file first).
124
-
125
- ### Decoding query cells — never hand-roll the serialization contract
126
-
127
- `useQuery` rows are `Record<string, unknown>`. Coerce each cell with a typed reader (`row.ts` /
128
- `select.ts` / `members.ts`); never re-implement `firstOpt`/`linkId`/`linkDisplay` — they rot.
129
-
130
- - **`row.opt/text/num/bool/date/datetime/link`** — scalar coercion. `row.date` = calendar day
131
- (LOCAL midnight, time stripped); **`row.datetime`** keeps the wall-clock — use it (and project the
132
- column `type:"datetime"`) when you need the time, or it prints `00:00`.
133
- - **`readSelect(cell)`** → `ResolvedOption[]` `{ key, label, color? }` (a cell carries key+label; the
134
- `color` is populated by `useFieldOptions`, not the cell). **`readMembers(cell)`** → `{ id, name,
135
- email? }[]`. **`readLinks(cell)` / `row.link`** → `{ id, display }`. **`readFiles(cell)`** →
136
- `AppFile[]` with presigned `url` + `thumbnail_url` (24 h) **+ `size` (bytes) + `created_at` (ISO
137
- date-added), resolved from the file object at read**. Surface size/date as **dedicated sortable
138
- `Table` columns over the RAW numeric** — the `tpl_documents` document-register pattern (a `Size`
139
- column `align:"right"` + an `Added` column, formatted only at display; NOT a crammed `FileRow` `meta`
140
- string, which sorts wrong — "8.4 MB" < "96 KB"). `size` is absent for older files until backfilled —
141
- render it only when present. **`readLocked(row)`** → `boolean` from the
142
- `__source_locked` addressing column.
143
- - **`project` only what you render** and `filter` server-side: a bare `from_table` ships every column
144
- — incl. `files` with storage keys — to the client (over-exposure + presign-500 at scale). A code
145
- lookup is a parameterized `filter`, not load-all-then-filter-in-JS.
146
-
147
- ---
148
-
149
- ## Data discipline (every app, prevents whole bug classes)
150
-
151
- - **Server data is never copied into `useState`.** `useQuery` / `useWorkflow` results are the source
152
- of truth — derive everything else with `useMemo`. A second copy drifts and serves stale values.
153
- - **Prefer derivation + callbacks over `useEffect`.** A derived value is `useMemo`; "state A changed
154
- → set state B" is both set in the one triggering callback. `useEffect` is for genuine external
155
- subscriptions (timers, DOM listeners, storage) — fetching is `useQuery`, not an effect.
156
- - **No layout shift on load or paging — the UX bar, not a nicety.** Reserve space while data loads:
157
- (1) first load renders `Skeleton` placeholders that mirror the final layout, not a bare spinner;
158
- (2) a page change KEEPS the previous rows (`usePaginatedQuery` does this via `keepPreviousData`) —
159
- gate the skeleton on `loading && rows.length === 0`, never `loading` alone, or every "next page"
160
- collapses the table; (3) a view↔edit toggle reserves the input's height so pressing Edit never
161
- reflows.
162
- - **Diff before update.** An edit form snapshots the record at load and sends only the CHANGED fields
163
- to its update workflow (declare those inputs optional; an omitted input = not written). A full-form
164
- snapshot re-writes locked fields (blocked even if unchanged), fires `before_update` autofills
165
- spuriously, and clobbers concurrent edits. For a locked record (`readLocked`), the save becomes one
166
- `request_locked_record_change` carrying the diffed `changes` + a reason.
167
- - **Reuse the kit's utilities** — `@lotics/ui` ships the formatters (`formatMoney`, `formatDate`/
168
- `parseDate`/`toISODate`); never hand-roll `dd/MM` or `₫`. Grep `@lotics/ui` exports before writing one.
169
-
170
- ---
171
-
172
- ## Search-as-you-type
173
-
174
- A search-first picker (type → ranked results → pick) is one `@lotics/ui` component + three SDK pieces;
175
- compose these, don't hand-roll search:
176
-
177
- - **`Combobox`** (`@lotics/ui`) owns the interaction — debounced `onSearchChange`, a popover listbox
178
- with rich rows (`renderOptionContent` reading `PickerOption.data`), keyboard nav, `recentOptions`,
179
- `allowCustom`. (For a known small list with no search box, `Picker`.)
180
- - **A parameterized `search` query** — a `from_table` with `search: "{{params.q}}"` over the maintained
181
- `search_document`: **diacritics- & case-insensitive** (`"da nang"` matches `"Đà Nẵng"`), trigram-
182
- indexed (scales), privacy-aware. AND-s with `filter` (search within a scope). Reserve an OR-group of
183
- per-field `contains` (accent-*sensitive*, unindexed) only when you must bound exactly which fields
184
- match. **Search-as-you-type uses `search`, never a `contains` OR-group** — a zero-match keystroke on
185
- `contains` forces a full-table scan that hangs the picker.
186
- - **`useQuery(alias, params, { enabled })`** — gate on a non-empty term; an empty term matches
187
- everything (`ILIKE '%%'`) and dumps the table on first paint. `enabled` makes "nothing loads until
188
- you type" true.
189
- - **`useRecents`** — persist the picked option; pass its list as `recentOptions`.
190
-
191
- Fetch detail on select with a SECOND parameterized query (a unique-code `equals`, or a link
192
- `has_any_of [record_id]`) — never a bare full-table load. To fetch a record by its **own id**, use the
193
- field-less **`record_id` system condition** (a sibling of `locked`/`current_member`, NOT a `field_key`):
194
- `useQuery(alias, {}, { filter: { node_type: "condition", type: "record_id", operator: "is_any_of", value:
195
- [id] } })` (`is_none_of` excludes). Works on any row-level query; a *grouped* query collapses rows so it's
196
- rejected there. A link `has_any_of [record_id]` matches a *related* record; `record_id` matches the row's
197
- own id — the only way, since a record has no field holding its own id. **Prefer a link/join when an actual
198
- relationship exists**; reach for `record_id` only when your starting point is a bare id (e.g. a drill row).
199
-
200
- ## Browse + sort + filter (the record picker)
201
-
202
- When the user doesn't know the term — "show me everything, let me narrow it" — a modal table you can
203
- browse (numbered pages), search, sort, and filter:
204
-
205
- - **`TablePicker`** (`@lotics/ui`) is the data-agnostic base (a `Dialog` over `SearchInput` + filter
206
- pills + `Table` + `Pagination`); it owns nothing about records — the consumer passes `columns` + one
207
- page of `rows` and owns the search/sort/filter/page state. Filter pills are `ColumnFilter` +
208
- `columnFilterToConditions`.
209
- - **A records wrapper lives in the app** (e.g. `record_picker.tsx`) — it needs BOTH `@lotics/ui` and
210
- the SDK (which is UI-free), so it can't be a package. It runs a named query via `usePaginatedQuery`
211
- and feeds the page into `TablePicker`. Reuse it for any table by passing a different `alias` + `columns`.
212
-
213
- The pieces that make it work:
214
- - **Runtime `sort`/`filter` are server-bounded to the query's OUTPUT columns** (an un-projected
215
- `field_key` → 400) — the picker can't widen exposure. Build `filter` with `columnFilterToConditions`;
216
- map sort `{key,order}` → `[{field_key, order}]`.
217
- - **The total comes from `count: true`** — a single-row COUNT over the filtered set (ignores
218
- sort/limit/offset), driving "Page 1 of N".
219
- - **Browse needs an unbounded query** — a baked-in AST `limit` caps the *total*; drop it and let
220
- `pageSize` drive.
221
- - **Select filter options:** prefer `useFieldOptions` for the COMPLETE set (not options derived from
222
- loaded rows, which are incomplete until every page loads).
223
-
224
- ## Rendering table primitives — select colors & members
225
-
226
- Two of the most common cells render the platform way with zero hand-rolling; hand the value to its
227
- `@lotics/ui` component — never re-derive an option→color map or a hand-built avatar (both rot):
228
-
229
- - **Select value** → `OptionBadge` (`@lotics/ui`) with its CONFIGURED color. Stored value:
230
- `byKey(readSelect(cell)[0]?.key)` from `useFieldOptions`; picker option: a `useFieldOptions` option.
231
- Multi wraps; a missing/unknown color degrades to neutral.
232
- - **Member** → `MemberChip` (avatar + name; the universal person render) and `MemberSelect` (the ready
233
- picker — a `Picker` of `MemberChip`s). Feed from `useMembers`. (Avatars stay in the bounded, cached
234
- roster — presigned files, never fattened onto every query cell; select color is a cheap token, so it
235
- rides in `useFieldOptions`.) Full props: `@lotics/ui/AGENTS.md`.
236
-
237
- ## Previewing files
238
-
239
- Render any uploaded file inline — image, PDF, video, audio, Word, Excel, CSV — with `@lotics/ui`; never
240
- hand-roll per-type rendering, and don't fall back to `openExternal` for a preview (that's "download /
241
- open elsewhere", not viewing).
242
-
243
- - **`FileGalleryModal`** (full-screen viewer: toolbar = filename · counter · ⋯actions · close-✕, plus
244
- prev/next + ESC) delegates to **`FilePreview`** (single-file), dispatching by MIME; the frontend
245
- gallery uses the same renderer. Wire `onFilePress` → a `number|null` `activeIndex`.
246
- - **PDF renders INLINE** — bytes fetched and painted to a canvas (with a selectable text layer) via
247
- `pdfjs-dist`, NOT a native `<iframe>` viewer: a nested PDF browsing context is blocked inside the
248
- sandboxed, cross-origin app iframe; a canvas isn't, so preview works. `pdfjs-dist` ships in the app
249
- starter (lazy-loaded — non-PDF apps pay no bundle cost).
250
- - File cells already carry presigned URLs — decode with **`readFiles`** → `AppFile[]`, map to
251
- `DisplayFile` (`mime_type`→`mimeType`, `thumbnail_url`→`thumbnailUrl`). No round-trip, no URL
252
- derivation, no server-side doc→PDF (rendering is client-side).
253
- - PDF (`pdfjs-dist`), Word (`@lotics/docx`), and Excel/CSV (`@lotics/xlsx`) all ship in the app starter,
254
- lazy-imported — an app that never previews a type pays no bundle cost. Excel renders to a canvas with
255
- live formula recalc; Word renders OOXML to the DOM. `@lotics/ui` is i18n/analytics-free: pass `labels`
256
- + an `onError`. The toolbar's "open in new tab" can't pop a window in the sandbox — pass `onOpenExternal`
257
- wired to the SDK's `openExternal` (omit it and the action hides).
258
-
259
- ## Composable optional filters (one query, many scopes)
260
-
261
- Expose several INDEPENDENT filter axes the caller mixes freely from ONE named query — don't shard into
262
- a query-per-combination. Mark each scoping param `required: false`; the server **prunes every filter
263
- condition whose `{{params.x}}` the caller didn't pass** (then collapses empty groups), so an unset axis
264
- stops constraining instead of erroring (no-op for all-required queries).
265
-
266
- ```jsonc
267
- "search": {
268
- "ast": { "kind": "project", "from": { "kind": "from_table", "table_id": "tbl_items", "filter": {
269
- "node_type": "group", "logic": "and", "children": [
270
- { "node_type": "condition", "field_key": "status", "operator": "has_any_of", "value": ["{{params.status}}"] },
271
- // keyword over two fields — the whole OR-group prunes when `keyword` is absent
272
- { "node_type": "group", "logic": "or", "children": [
273
- { "node_type": "condition", "field_key": "title", "operator": "contains", "value": "{{params.keyword}}" },
274
- { "node_type": "condition", "field_key": "notes", "operator": "contains", "value": "{{params.keyword}}" } ] },
275
- // date range — each bound is its OWN single-param condition, so an open-ended range prunes one side
276
- { "node_type": "condition", "field_key": "created", "operator": "on_or_after",
277
- "value": { "type": "exact", "date": "{{params.from}}", "time": null } },
278
- { "node_type": "condition", "field_key": "created", "operator": "on_or_before",
279
- "value": { "type": "exact", "date": "{{params.to}}", "time": null } } ] } }, "columns": [ /* … */ ] },
280
- "params": {
281
- "status": { "type": "select", "options": [ /* … */ ], "required": false },
282
- "keyword": { "type": "text", "required": false },
283
- "from": { "type": "date", "required": false },
284
- "to": { "type": "date", "required": false } }
285
- }
286
- ```
287
-
288
- `useQuery("search", { keyword })` filters by keyword only; `{}` returns everything. **Dates:** there's no
289
- `date_range` param — embed a `date`/`text` param in a hand-built `DateTimePoint`
290
- (`{type:"exact", date:"{{params.from}}", time:null}`), one condition per bound so each prunes
291
- independently. Keep a scope that must always apply `required` (a missing required param 400s). Typos
292
- can't widen — deploy rejects a `{{params.x}}` with no declared param.
293
-
294
- ## Save view-state to the URL (`useUrlState`)
295
-
296
- The filters above are the query's *server* params; **`useUrlState`** is their *client* home — save them to
297
- the host's address bar and a filtered view survives refresh and is shareable/bookmarkable as a link. Writes
298
- always **replace** in place (filter changes shouldn't add history entries); screen-to-screen navigation and
299
- history are the router's job (next section). The hook drives the host's URL over the bridge; the app can't
300
- touch the cross-origin host URL itself. Standalone apps drive their own URL — same code.
301
-
302
- ```tsx
303
- const [filters, setFilters] = useUrlState({
304
- q: urlParam.string.withDefault(""),
305
- status: urlParam.enum(["open", "won", "lost"]), // optional → omitted when unset
306
- tags: urlParam.arrayOf(urlParam.string).withDefault([]),
307
- page: urlParam.number.withDefault(1),
308
- });
309
- // filters → { q: string; status?: "open"|"won"|"lost"; tags: string[]; page: number }
310
- const { rows } = usePaginatedQuery("search", { keyword: filters.q, status: filters.status }, { pageSize: 50 });
311
- setFilters({ status: "won" }); // merge into the address bar → ?status=won
312
- setFilters({ page: 2 }); // merge (replace; no history entry)
313
- ```
314
-
315
- - **`urlParam`** codecs: `string` / `number` / `boolean` / `isoDate` / `enum([...])` / `arrayOf(inner)`,
316
- each `.withDefault(v)` to make it required. **Defaults are omitted from the URL** (`page=1` never
317
- appears) — links carry only what changed.
318
- - **The URL is the only store** — `filters` is decoded fresh each render; don't mirror it into `useState`.
319
- - **Declared keys only.** The app touches just the keys in `shape`; every other param (a second
320
- `useUrlState`, the framework's own) is preserved on write — no namespace rule to remember.
321
- - **Search box:** keep the live input in local `useState` and commit to `setFilters` on a **debounce** —
322
- in an embedded app each `setFilters` is a cross-frame write.
323
-
324
- ## In-app navigation (`AppRouter`)
325
-
326
- For a multi-screen app, write plain react-router and wrap your routes in **`AppRouter`** (from
327
- `@lotics/app-sdk/router`) — it makes screens real, addressable URLs in both modes, with no per-mode code:
328
-
329
- ```tsx
330
- import { AppRouter } from "@lotics/app-sdk/router";
331
-
332
- export default function App() {
333
- return <AppRouter routes={[
334
- { path: "/", element: <List /> },
335
- { path: "/item/:id", element: <Detail /> },
336
- ]} />;
337
- }
338
- ```
339
-
340
- Inside, use react-router normally — `useNavigate`, `useParams`, `<Link>`.
341
-
342
- - **Embedded** (in the Lotics host): the app drives the **iframe's own url** (same-origin to itself) via
343
- `pushState`. The user sees the host's address bar, never the iframe's, so the screen url is invisible — and
344
- the host never sees it, so navigation never reloads the app. The iframe's history participates in the
345
- session history, so the **browser Back/Forward buttons walk app screens** (then leave the app). `AppRouter`
346
- also mirrors the current screen into the host url (a non-remounting write) and the host bakes it back into
347
- the iframe src on load, so a screen is **shareable and survives a full refresh** — no reload, no extra code.
348
- - **Standalone** (`<slug>.lotics.app`): a normal browser router — real path URLs, native browser back/forward,
349
- refresh-survivable.
350
-
351
- `useUrlState` is the complement: in-app *routing* (screens) is automatic via `AppRouter`; reach for
352
- `useUrlState` for *filters/search* you want as first-class, typed, shareable query keys. `react-router-dom` is
353
- an optional peer — only apps that import `@lotics/app-sdk/router` pull it in.
354
-
355
- ---
356
-
357
- ## Recipes (app actions beyond the hooks)
358
-
359
- Reverse-engineered once; full code in the `/app` skill's `references/recipes.md`.
360
-
361
- - **Generate a document → download** — the file returns in `WorkflowResult.files[]` (auto-extracted from
362
- any step's `file_id`); open via `openExternal(files[0].url)`. Don't hand back a `url`/`file_id` from
363
- `return()`.
364
- - **Export displayed data → .xlsx (client-side)** — `buildDataWorkbook({columns, rows})` (`@lotics/xlsx`)
365
- → `exportWorkbook` → `downloadFile`. To export the WHOLE result (not one page), page the query
366
- imperatively with `rpc("query", { alias, params, limit, offset })` to exhaustion (embedded-app only —
367
- the public transport drops `sort`/`filter`).
368
- - **Workflow returns data → fill the UI** — `return({ data })` hands back any structured result, read as
369
- a typed `result.data` (no schema needed; narrow before use — a fall-through completion returns none).
370
- - **Look up one record by a typed code** — a parameterized `{{params.x}}` filter + `useQuery(alias, {...})`,
371
- so the client never receives other rows. Autonumber fields filter as text. (Public-app IDOR caveat:
372
- `docs/apps.md`.)
373
- - **Interactive (read + mutate) app** — `row.*` to coerce + `useOptimistic` to reconcile; `@lotics/ui`
374
- provides the drag-enabled calendar/gantt/kanban/grid.
375
-
376
- ---
377
-
378
- ## Authority & scoping (the one-paragraph rule — full model in `docs/apps.md`)
379
-
380
- App data ops run under the **app owner's** principal, not the viewer's — so who-is-acting and per-user
381
- scoping are the author's job, never inferred. **Reads:** scope "my X" with the `is_current_member`
382
- operator in the query TEMPLATE (the server binds the signed-in viewer / the view-as subject) — never a
383
- client-supplied `member_id` (that's an IDOR; per-user data must not ship in a *public* app). **Writes:**
384
- derive the actor server-side — read `runtime.triggered_by_member_id` in the workflow body; a client
385
- `member` input is spoofable. **Privileged writes** (a role/group gate) authorize the caller in the
386
- workflow with `current_member_in_any_group(...)`, paired with a manager-only read. `useViewer()` is
387
- display-only, not an authorization fact. The full IAM model, public-access bounds, and the security
388
- rationale: **`docs/apps.md`**.
389
-
390
- ---
391
-
392
- ## Keeping this file current
393
-
394
- This is the published SDK reference — agents read `node_modules/@lotics/app-sdk/AGENTS.md` when building
395
- apps. **Codify every new hook, reader, and load-bearing pattern here** in the same change that adds it
396
- (and bump the package version so it ships). Keep it tight: the catalog points at the `.d.ts` for exact
397
- signatures; the model/IAM/security rationale stays in `docs/apps.md` — link, don't duplicate.
1
+ # @lotics/app-sdk — the SDK reference index
2
+
3
+ The **data + RPC** runtime for Lotics custom-code apps: typed React hooks over an origin-locked
4
+ postMessage bridge, cell decoders, and the `mount()` entry point. **Data + RPC only — no UI.**
5
+ Render with **`@lotics/ui`** (read `node_modules/@lotics/ui/AGENTS.md`); the two pair the SDK
6
+ fetches/mutates, `@lotics/ui` draws.
7
+
8
+ This file is the index. The comprehensive contract references live in **`docs/`** — read the
9
+ owning area doc before building; it is the contract for a backend you cannot see. The **exact
10
+ signature** of any hook or reader is its shipped source: `dist/src/<name>.d.ts` **never guess a
11
+ signature; open the file.**
12
+
13
+ ## The area references
14
+
15
+ | Doc | Read it for |
16
+ |---|---|
17
+ | [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. |
18
+ | [docs/data_fetching.md](./docs/data_fetching.md) | The three read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`), cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `useFieldOptions`, data discipline, the search-as-you-type + record-picker patterns. |
19
+ | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract, typed inputs, diff-before-update, locked records, `useOptimistic`. |
20
+ | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs, workflow-generated files, preview pairing, filter operators, the server-side delivery bounds. |
21
+ | [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. |
22
+ | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
23
+ | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming `items` → `AgentRun`) and `askAi` plus the fields-vs-file razor for choosing between them. |
24
+ | [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. |
25
+ | [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, `rpc()`, `openExternal`/`downloadFile`, geofencing, analytics, and the publish chain for package contributors. |
26
+
27
+ ## Non-negotiables (each detailed in its doc)
28
+
29
+ - **Authorization is server-side, on every path.** Apps run under the OWNER principal scope
30
+ per-user reads with `is_current_member` in the query template, attribute writes from
31
+ `runtime.triggered_by_member_id`, gate privileged writes with `current_member_in_any_group`.
32
+ A client-supplied member id is an IDOR. [security](./docs/security.md)
33
+ - **Server data is never copied into `useState`** — hooks are the source of truth; derive with
34
+ `useMemo`. [data_fetching](./docs/data_fetching.md)
35
+ - **Update writes are diffs**send only changed fields. [mutations](./docs/mutations.md)
36
+ - **`project` only what you render; filter server-side.** A bare `from_table` over-ships every
37
+ column, including files. [queries](./docs/queries.md)
38
+ - **Never hand-roll the serialization contract** decode cells with the typed readers.
39
+ [data_fetching](./docs/data_fetching.md)
40
+ - **Errors fail loud** no swallowed catches, no silent fallbacks.
41
+
42
+ ## Keeping this reference current
43
+
44
+ These docs are the published contract agents build apps from `node_modules` and cannot read the
45
+ platform source, so a wrong or missing claim here becomes their bug. **Codify every new hook,
46
+ reader, pattern, or contract change into its OWNING area doc + the index line above in the same
47
+ change that adds it, and bump the package version** (publish CI fires on a version diff; `files`
48
+ ships only on publish). Each fact lives in exactly one doc — link, never duplicate. The
49
+ platform-internal model/IAM rationale stays in the repo's `docs/apps.md`.