@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/AGENTS.md +49 -397
- package/docs/ai.md +201 -0
- package/docs/data_fetching.md +349 -0
- package/docs/files.md +312 -0
- package/docs/members_and_options.md +307 -0
- package/docs/mutations.md +438 -0
- package/docs/navigation_and_state.md +289 -0
- package/docs/queries.md +916 -0
- package/docs/runtime.md +432 -0
- package/docs/security.md +116 -0
- package/package.json +3 -2
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
file
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
-
|
|
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`.
|