@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/docs/files.md ADDED
@@ -0,0 +1,312 @@
1
+ # Files
2
+
3
+ Files end to end in a custom-code app: uploading bytes (`useFileUpload`), composer attachments
4
+ with instant previews (`useAttachments`), decoding `files` cells from query results (`readFiles` →
5
+ `AppFile`), documents a workflow generates (`WorkflowResult.files`), previewing with `@lotics/ui`,
6
+ filtering on files fields, and the server-side delivery bounds that decide whether a file-bearing
7
+ query succeeds at all. Read this before building any screen that shows, collects, or generates
8
+ files. Query mechanics (projection, unnest, aggregates) live in [queries](./queries.md); fetching
9
+ discipline in [data fetching](./data_fetching.md).
10
+
11
+ ## The lifecycle in one table
12
+
13
+ | Stage | Surface | What you hold |
14
+ |---|---|---|
15
+ | Collect bytes from the visitor | `useFileUpload().upload(file)` | `UploadedFile` — a stored, **unattached** file id + presigned serving URLs |
16
+ | Collect several with live previews | `useAttachments()` | `AttachedFile[]` with instant local previews; `fileIds` when done |
17
+ | Attach to a record | a declared workflow with a `{ type: "file" }` input | the workflow writes the id(s) into a `files` field — the **only** write path |
18
+ | Read back from records | `useQuery` + `readFiles(cell)` | `AppFile[]` — presigned `url`/`thumbnail_url` (24 h) + `size`/`created_at` |
19
+ | Receive a generated document | `useWorkflow` → `WorkflowResult.files` | presigned files auto-extracted from the run |
20
+ | Show it | `@lotics/ui` `FileThumbnail` / `FileGrid` / `FileGalleryModal` | map to `DisplayFile` (see below) |
21
+ | Save browser-built bytes | `downloadFile(filename, data, mimeType?)` | a client-side download (see [runtime](./runtime.md)) |
22
+
23
+ An uploaded file is **inert until a workflow attaches it** — it has an id and serving URLs, but
24
+ belongs to no record. There is no direct-write path from app code; attaching goes through a
25
+ declared workflow (see [mutations](./mutations.md)).
26
+
27
+ ## Uploading — `useFileUpload`
28
+
29
+ ```tsx
30
+ const { upload, uploading, error } = useFileUpload();
31
+ const submit = useWorkflow("submitOrder");
32
+
33
+ const photo = await upload(file); // File → UploadedFile
34
+ await submit({ ...fields, photo_file_id: photo.id });
35
+ ```
36
+
37
+ Signature: `dist/src/hooks.d.ts`. Returns `{ upload, uploading, error }`:
38
+
39
+ - `upload(file: File): Promise<UploadedFile>` — resolves to the stored file; rejects on failure
40
+ (the file is never partially stored). `UploadedFile` is
41
+ `{ id, filename, mime_type, url?, thumbnail_url? }` — `url`/`thumbnail_url` are presigned
42
+ (24 h) and load directly in the sandboxed iframe, so a just-uploaded image previews without a
43
+ round-trip.
44
+ - `uploading` — true while **any** upload from this hook is in flight.
45
+ - `error` — message of the most recent failed upload; cleared when a new one starts.
46
+
47
+ Works identically in a member-facing (embedded) app and a public (anonymous) app — the gate is
48
+ use-access to the app itself.
49
+
50
+ ### What `upload` does under the hood
51
+
52
+ The pipeline is: optimize (images only) → mint a presigned upload URL → `PUT` the bytes straight
53
+ to object storage → finalize. The API server never proxies the bytes.
54
+
55
+ - **Image optimization.** JPEG/PNG/WebP/HEIC/HEIF images larger than 1280 px on the long edge are
56
+ resized to ≤1280 px and re-encoded as JPEG at quality 0.75 before upload — phone photos
57
+ typically shrink 10–20×. Non-image files pass through unchanged, and any optimization failure
58
+ falls back to uploading the original bytes (never an upload error).
59
+ **Warning:** PNG/WebP/HEIC/HEIF larger than 1280 px on the long edge are *converted to JPEG* —
60
+ transparency is lost and the stored filename's extension becomes `.jpg`. If you need lossless
61
+ originals, keep images ≤1280 px or upload them as non-image MIME types.
62
+ - **Transport resilience.** The storage `PUT` has a 5-minute per-request timeout and retries
63
+ network errors / timeouts / 5xx up to 3 attempts with 1 s → 2 s backoff between attempts. 4xx
64
+ responses are terminal (no retry). The presigned upload URL itself is valid for 10 minutes.
65
+ - **Server-side verification.** The finalize step verifies the actually-stored object: an object
66
+ larger than the limit (or of unverifiable size) is rejected **and its bytes deleted** — the
67
+ file record is never created.
68
+
69
+ ### Upload limits
70
+
71
+ | Bound | Value |
72
+ |---|---|
73
+ | Max file size | 25 MiB (26,214,400 bytes) — enforced at URL mint *and* re-verified at finalize |
74
+ | Allowed MIME types | **any** — type is unrestricted at upload |
75
+ | Upload-URL validity | 10 minutes |
76
+
77
+ Any MIME is safe to accept because serving is hardened downstream: HTML, XHTML, and SVG are always
78
+ served with `Content-Disposition: attachment` (forced download) on every path, so an uploaded file
79
+ can never execute as active content. See [security](./security.md).
80
+
81
+ ### File workflow inputs
82
+
83
+ Declare the receiving workflow input as `{ type: "file" }` (body receives one `FileId`) or
84
+ `{ type: "file", multi: true }` (body receives `ReadonlyArray<FileId>`, directly assignable to a
85
+ `files` field — the way to attach several uploads to one record). Files cells are arrays, so the
86
+ workflow body writes a single-id input by wrapping it itself — `some_files_field: [inputs.photo]`
87
+ — a bare id is rejected at the write.
88
+
89
+ File ids are validated at execute time: a `file` input must reference a file in the app's own
90
+ workspace, or the run is rejected — you cannot attach a foreign or fabricated id.
91
+
92
+ For **agent** aliases the same `file` inputs are materialized into the model's vision: images
93
+ become image parts, PDFs file parts; other types stay an id the agent opens with its own tools.
94
+ Pair `useFileUpload` with `useAgentRun` for photo→extraction flows — see [ai](./ai.md).
95
+
96
+ ## Composer attachments — `useAttachments`
97
+
98
+ The optimistic-preview UX in one hook: a local object-URL preview shows the *instant* a file is
99
+ added, the upload runs in the background (through the same `useFileUpload` pipeline, image
100
+ optimization included), and the stored `file_id` lands when it completes. Don't hand-roll the
101
+ `createObjectURL` → upload → id → revoke lifecycle per app.
102
+
103
+ ```tsx
104
+ const { files, add, remove, clear, uploading, fileIds } = useAttachments();
105
+ const design = useWorkflow("design");
106
+
107
+ // attach: picking is the app's choice — button, paste, or drop
108
+ <Button icon="paperclip" onPress={() => pickFiles({ accept: "image/*" }).then(add)} />
109
+
110
+ // preview: map each AttachedFile to a @lotics/ui DisplayFile (snake → camel)
111
+ {files.map((f) => (
112
+ <FileThumbnail
113
+ key={f.id}
114
+ file={{ id: f.id, filename: f.filename, mimeType: f.mime_type, url: f.preview_url }}
115
+ uploading={f.status === "uploading"}
116
+ onRemove={() => remove(f.id)}
117
+ />
118
+ ))}
119
+
120
+ // send: gate on `uploading`, payload is `fileIds`
121
+ await design({ photos: fileIds }); // multi:true input
122
+ clear();
123
+ ```
124
+
125
+ Contract (`dist/src/hooks.d.ts`):
126
+
127
+ | Member | Behavior |
128
+ |---|---|
129
+ | `files: AttachedFile[]` | Current attachments, in the order added |
130
+ | `add(files: File[])` | Each file shows its local preview at once and uploads in the background |
131
+ | `remove(id)` | Removes one attachment and revokes its preview object-URL |
132
+ | `clear()` | Removes all and revokes every preview URL |
133
+ | `uploading` | True while any attachment is still uploading — gate Send on it |
134
+ | `fileIds: string[]` | Stored file ids of the **completed** uploads — the workflow/agent payload |
135
+
136
+ Each `AttachedFile` is `{ id, filename, mime_type, preview_url, status, file_id? }`:
137
+
138
+ - `id` — a stable *local* id (the React key and the `remove(id)` handle), **not** the stored file
139
+ id.
140
+ - `preview_url` — a local object-URL, available before the upload finishes. Revoked on
141
+ remove/clear — don't hold it past the attachment's lifetime.
142
+ - `status` — `"uploading" | "ready" | "error"`. A failed upload stays in `files` with
143
+ `status: "error"` (and no `file_id`); there is no auto-retry — offer remove + re-add.
144
+ - `file_id` — the stored id, set once `status` is `"ready"`. `fileIds` includes only ready
145
+ entries, so sending while `uploading` is true would silently drop in-flight files — gate on it.
146
+
147
+ Wiring to `@lotics/ui`: in a `Composer`, trigger picking from `actionsButton` (via `pickFiles`),
148
+ render the attachment pills with `FileThumbnail` as above, and gate `sendDisabled` on `uploading`.
149
+ For a full add-files *screen* (not a composer pill), map each `AttachedFile` to a `FileGrid`
150
+ `FileUpload` entry — ready → `{ status: "complete", id: file_id, file: <DisplayFile> }`, else
151
+ `{ status, id, filename, mimeType: mime_type, previewUrl: preview_url }` — and `FileGrid` renders
152
+ the uploading/error/retry tiles itself.
153
+
154
+ ## File cells in query results — `readFiles` and `AppFile`
155
+
156
+ A projected `files` column arrives as an array of file objects, and the server enriches **every
157
+ file-shaped value anywhere in the result rows** at read time:
158
+
159
+ - `url` — a presigned serving URL, valid **24 hours**, anonymous-fetchable. Loads directly from
160
+ the sandboxed app iframe and for public-app visitors — no session, no proxy, no URL derivation.
161
+ - `thumbnail_url` — a presigned small-variant URL, emitted for every image.
162
+ - `size` (bytes) and `created_at` (ISO upload timestamp) — resolved from the file object at
163
+ serving time, batch-loaded per response.
164
+
165
+ Decode with `readFiles(cell)` → `AppFile[]` (`dist/src/row.d.ts`):
166
+
167
+ | Field | Type | Notes |
168
+ |---|---|---|
169
+ | `id` | `string` | The stored file id |
170
+ | `filename` | `string` | Original filename (post-optimization name for converted images) |
171
+ | `mime_type` | `string` | |
172
+ | `url` | `string` | Presigned serving URL — render, or pass to `openExternal` |
173
+ | `thumbnail_url` | `string?` | Presigned image thumbnail (see the 404 note below) |
174
+ | `size` | `number?` | Byte size — **absent on older files not yet backfilled**; render only when present |
175
+ | `created_at` | `string?` | ISO upload timestamp |
176
+
177
+ `readFiles` skips entries the server didn't presign (no `url`), so you never render an unservable
178
+ file. It is pure and never throws.
179
+
180
+ - **Thumbnails are optimistic.** `thumbnail_url` is emitted for every image without checking that
181
+ the variant exists; a not-yet-generated variant 404s on fetch. `@lotics/ui`'s `FileThumbnail`
182
+ falls back to `url` on image error — a hand-rolled `<img src={thumbnail_url}>` must do the same.
183
+ - **Don't persist the URLs.** They expire in 24 h. Apps re-query on load, so the TTL refreshes
184
+ naturally; store `id`s, never `url`s.
185
+ - **Warning:** SVG, HTML, and XHTML files are presigned with a forced-download disposition — their
186
+ `url` downloads instead of rendering inline (stored-XSS defense). An SVG in an `<img>` will not
187
+ display; treat these types as download-only.
188
+ - **Display convention:** surface `size` and `created_at` as dedicated, sortable `Table` columns
189
+ over the **raw** values (right-aligned Size formatted at display; Added via a date formatter) —
190
+ not a crammed meta string, which sorts wrong (`"8.4 MB" < "96 KB"` lexically).
191
+
192
+ ## The presign ceiling — project files only where rendered
193
+
194
+ Every file entry in a query response costs server-side signing work (1–2 signings per entry,
195
+ duplicates included). The server counts file entries **before** signing and fails the whole
196
+ request when the count exceeds the per-response ceiling (**2,000 file entries** by default) with
197
+ an actionable error:
198
+
199
+ > Query "…" returned N file entries, above the 2000-entry ceiling for signed file URLs per
200
+ > response. Project the files column only in the query that renders it, narrow the result with a
201
+ > filter, or paginate with limit/offset.
202
+
203
+ The request fails whole — no partial or unsigned rows — and surfaces as the hook's `error`.
204
+ Design around it:
205
+
206
+ - **Project `files` columns only in the query that renders them.** A bare `from_table` with no
207
+ `project` ships every column — including `files` — so a wide list query over a file-heavy table
208
+ hits the ceiling (and over-exposes storage metadata). Keep list queries file-free; fetch files
209
+ in the detail query for the selected row.
210
+ - **Paginate.** The row `limit` defaults to the server's 10,000-row cap; a files projection at
211
+ that size is thousands of entries. `usePaginatedQuery`/`useInfiniteQuery` with a modest page
212
+ size keeps each response far under the ceiling.
213
+ - **Push counts and per-file analysis into the query.** You never need the file *objects* to
214
+ count them: `has_file_count`/`is_empty` filters, the `filled`/`empty`/`percent_*` aggregates,
215
+ and the `unnest` node (one output row per file, emitting the file **id** as text; `keep_empty`
216
+ preserves zero-file rows) are all evaluated server-side without triggering any presigning. See
217
+ [queries](./queries.md).
218
+
219
+ **Warning:** an aggregate `unique` over a files column counts distinct whole cells (file *sets*),
220
+ not distinct files — per-file distinct counts go through `unnest` first.
221
+
222
+ Emptiness is one contract everywhere: a cleared files cell persists as `[]` and counts as empty in
223
+ `is_empty` filters **and** in the `filled`/`empty`/`percent_*` aggregates.
224
+
225
+ ## Workflow-returned files — `WorkflowResult.files`
226
+
227
+ When a workflow run completes, the platform scans the run's completed tool-call steps and
228
+ auto-extracts every output carrying a string `file_id` (document-generation tools emit these),
229
+ dedupes them, and returns them on the execute response as `WorkflowResult.files?: UploadedFile[]`
230
+ — each presigned with the same 24 h TTL as query file cells. A run that produced no file resolves
231
+ with `files` absent.
232
+
233
+ ```tsx
234
+ const generate = useWorkflow("generateInvoice");
235
+ const result = await generate({ record_id: row.__source_record_id });
236
+ if (result.status === "success" && result.files?.length) {
237
+ await openExternal(result.files[0].url);
238
+ }
239
+ ```
240
+
241
+ - **Don't hand back a `url` or `file_id` via `return({ data })`** — extraction is automatic, and
242
+ the extracted entry is presigned while a hand-returned id is not.
243
+ - The presigned URLs work for **anonymous public-app viewers** — a public visitor can download a
244
+ document the workflow generated for them.
245
+ - `openExternal(url)` is the way to open one: the embedded iframe is sandboxed without popups, so
246
+ a direct `window.open` is silently dropped; `openExternal` routes the open to the host
247
+ (scheme-validated, `http`/`https` only).
248
+ - For bytes the app builds *in the browser* (a client-side .xlsx/CSV export), the counterpart is
249
+ `downloadFile(filename, data, mimeType?)` — see [runtime](./runtime.md).
250
+
251
+ ## Previewing — wiring to `@lotics/ui`
252
+
253
+ Render any file inline — image, PDF, video, audio, Word, Excel, CSV — with `@lotics/ui`. Never
254
+ hand-roll per-type rendering, and don't use `openExternal` as a preview (that's "open elsewhere",
255
+ not viewing). Component contracts live in the `@lotics/ui` reference
256
+ ([`../../ui/AGENTS.md`](../../ui/AGENTS.md)); the SDK-side contract is only the data mapping:
257
+
258
+ | `AppFile` (SDK) | `DisplayFile` (`@lotics/ui`) |
259
+ |---|---|
260
+ | `id` | `id` |
261
+ | `filename` | `filename` |
262
+ | `mime_type` | `mimeType` |
263
+ | `url` | `url` |
264
+ | `thumbnail_url` | `thumbnailUrl` |
265
+
266
+ For an `AttachedFile` still uploading, map `preview_url` → `url` (there is no server URL yet).
267
+ The app owns this data→UI adapter — the SDK deliberately never imports `@lotics/ui`.
268
+
269
+ - **`FileThumbnail`** — one square tile; `uploading` overlays a spinner, images fall back from
270
+ `thumbnailUrl` to `url` on error.
271
+ - **`FileGrid`** — a grid of completed files plus a live `uploads` queue (it renders
272
+ uploading/error/retry tiles itself).
273
+ - **`FileGalleryModal`** — the full-screen viewer (filename · counter · actions · close, with
274
+ prev/next + ESC), delegating per-file to **`FilePreview`**, which dispatches by MIME. Wire
275
+ `onFilePress` → a `number | null` `activeIndex`.
276
+ - PDF renders **inline to a canvas** (a nested PDF browsing context is blocked in the sandboxed
277
+ iframe; a canvas isn't). The PDF, Word, and Excel/CSV engines ship as `@lotics/ui`
278
+ dependencies — no separate install — and are lazy-loaded, so apps that never preview a type pay
279
+ no bundle cost.
280
+ - The gallery's "open in new tab" action can't pop a window in the sandbox — pass
281
+ `onOpenExternal` wired to the SDK's `openExternal` (omit it and the action hides).
282
+
283
+ ## Filtering on files fields
284
+
285
+ Files fields support these operators — in the query template's `from_table.filter` **and** in the
286
+ runtime `filter` option over a projected files output column:
287
+
288
+ | Operator | Value | Matches records where… |
289
+ |---|---|---|
290
+ | `is_empty` | — | the cell is absent or cleared (`[]`) |
291
+ | `is_not_empty` | — | at least one file is attached |
292
+ | `has_file_count` | numeric string (`"2"`) | **exactly** that many files are attached (no more/fewer-than variants) |
293
+ | `has_filename` | string | any attached file's filename contains the term (case-insensitive substring) |
294
+ | `has_mime_type` | string | any attached file's MIME type contains the term (e.g. `"image/"`, `"pdf"`) |
295
+
296
+ - Values are **strings at both layers** — a JSON number for `has_file_count` fails validation;
297
+ send the count as a string. An empty/absent search term or a non-numeric count string makes the
298
+ condition constrain nothing.
299
+ - Membership operators (`has_any_of`/`has_all_of`/`has_none_of`) are **not** valid on files
300
+ columns — the runtime layer rejects them explicitly.
301
+ - The emptiness contract is uniform: cleared `[]` cells count empty at both layers (the runtime
302
+ layer additionally treats a JSON `null` produced by a derived column as empty).
303
+ - **Limitation:** the runtime `filter`/`sort` options reach the server only on the embedded
304
+ transport; a standalone public app's transport sends only `alias`/`params`/`limit`/`offset`.
305
+ For public apps, put files predicates in the query template (parameterized where needed). See
306
+ [data fetching](./data_fetching.md).
307
+
308
+ ## Files and free-text search
309
+
310
+ `from_table.search` (the record-level free-text search) does **not** match file cells — the
311
+ indexed search document excludes files (and booleans) by design. To find records by an attached
312
+ file's name or type, filter with `has_filename` / `has_mime_type` instead.
@@ -0,0 +1,307 @@
1
+ # Members & select options
2
+
3
+ How an app renders and picks **people** and **select-field options**, plus the **record comments**
4
+ surface. Covers the two cell readers (`readSelect`, `readMembers`), the two catalog hooks
5
+ (`useFieldOptions`, `useMembers`), the viewer identity hook (`useViewer`), comments
6
+ (`useComments`, `useCommentCounts`), and the `@lotics/ui` components they feed. Read this before
7
+ building an assign picker, a colored status badge, a per-viewer ("my records") screen, or a
8
+ comment thread. Query mechanics live in [queries](./queries.md); the authority model in
9
+ [security](./security.md).
10
+
11
+ ## Cells vs. catalogs — the model
12
+
13
+ Every select and member value reaches the app in one of two shapes, and most screens need both:
14
+
15
+ | You need | Reach for | What it carries |
16
+ | --- | --- | --- |
17
+ | Render a stored select value | `readSelect(cell)` | The options the record actually holds — `{ key, label }`, **no color** |
18
+ | Populate a select picker, or color a stored value | `useFieldOptions(alias)` | Each select column's **complete** option list — `{ key, label, color }` — plus a `byKey` index |
19
+ | Render a stored member value | `readMembers(cell)` | The members the record actually holds — `{ id, name, email? }`, **no avatar** |
20
+ | Populate a member picker (assign UIs) | `useMembers(opts?)` | The org roster — `{ id, name, email, image }` (avatar included) |
21
+
22
+ Cells are **self-describing**: the server rewrites raw storage shapes into resolved objects before
23
+ rows reach the app, so an app never maintains a hardcoded key→label or id→name map. Catalogs are
24
+ **complete**: they come from live field config / the member directory, so a freshly added option or
25
+ member appears without an app change.
26
+
27
+ ## Select option cells (`readSelect`)
28
+
29
+ A `select` column's storage shape is a bare array of option keys. The server rewrites every
30
+ projected `select` cell to `Array<{ key, label }>` before the row reaches the app:
31
+
32
+ - **`label`** comes from the column's source field's option config. An option deleted after the
33
+ cell was written resolves with `label` equal to the key — the stale state is visible, not hidden.
34
+ - **Heterogeneous UNION outputs still resolve.** When a query UNIONs arms whose select columns come
35
+ from different tables/fields, each row's labels resolve from that row's own source field. Only a
36
+ *genuinely computed* select column (no source field at all) keeps bare keys.
37
+ - **Order** is the cell's own stored order.
38
+
39
+ Decode with **`readSelect(cell)`** (`dist/src/select.d.ts`) → `ResolvedOption[]`:
40
+
41
+ - Returns `[]` for `null`/`undefined`/empty cells and for any unexpected shape — iterate without
42
+ null-checks.
43
+ - A bare-string entry (an unresolvable column's key) becomes `{ key, label: key }`, so single-value
44
+ reads still work.
45
+ - Malformed entries are dropped, never returned partially.
46
+
47
+ **Limitation:** a cell never carries `color`. `ResolvedOption.color` is optional and populated only
48
+ by `useFieldOptions` — to render a stored value with its configured color, pair the two (idiom
49
+ below).
50
+
51
+ ## The full option set: `useFieldOptions(alias)`
52
+
53
+ The picker companion to `useQuery` (`dist/src/hooks.d.ts`). Where a cell carries only the options a
54
+ record holds (key + label), this resolves each select **output column's complete option list with
55
+ colors**, straight from field config — so it populates a dropdown, colors stored values, and picks
56
+ up table edits with no app change.
57
+
58
+ ```tsx
59
+ const { fields } = useFieldOptions("orders"); // same alias you query
60
+ // populate + color a picker (Select is @lotics/ui's rich select; its options
61
+ // are { value, label }, so map the option key to `value`):
62
+ <Select
63
+ options={(fields.status?.options ?? []).map((o) => ({ value: o.key, label: o.label }))}
64
+ renderOptionContent={(o) => <OptionBadge value={fields.status?.byKey(o.value)} />}
65
+ value={status} onValueChange={setStatus}
66
+ />
67
+ ```
68
+
69
+ ### What comes back
70
+
71
+ `fields` is a `Record<outputColumnName, FieldOptions>` — keyed by the query's **output column
72
+ name**, not the field key. Each `FieldOptions`:
73
+
74
+ | Property | Meaning |
75
+ | --- | --- |
76
+ | `label` | The source field's display name — a ready picker/section label |
77
+ | `options` | Every option of the field — `{ key, label, color }` — in field-config order, **including options not present in any current row** |
78
+ | `byKey(key)` | Resolve one option by key; `undefined` for an unknown key (option removed after the cell was written) |
79
+
80
+ - `color` is a named palette token (e.g. `"blue"`, `"emerald"`). Pass the option straight to
81
+ `@lotics/ui`'s `OptionBadge`; a missing/unrecognized token degrades to a neutral badge.
82
+ - **A column the server can't map to a single source select field is simply absent** from
83
+ `fields` — a UNION output whose arms disagree on the source field, or a computed column. Read
84
+ defensively: `fields.status?.options ?? []`.
85
+ - Addressed by the same alias you query, and scoped exactly like running that query — it exposes
86
+ nothing the query itself doesn't. Params are irrelevant (they only fill filter values, never
87
+ change the projection), so no params argument exists.
88
+ - Works in embedded **and** standalone/public apps.
89
+
90
+ ### Caching & freshness
91
+
92
+ Field config is slow-changing, so the hook fetches once per alias and does **not** revalidate on
93
+ window focus or reconnect. The option set is resolved live from field config at fetch time — an
94
+ option added, renamed, or recolored in the table flows through on the next fetch (a remount, a full
95
+ reload, or an explicit `refetch()`), with no app redeploy. `opts.enabled` defers the fetch (e.g.
96
+ until an edit drawer opens). State: `{ fields, loading, isValidating, error, refetch }`.
97
+
98
+ ### Coloring a stored value (the `byKey` idiom)
99
+
100
+ ```tsx
101
+ const opt = readSelect(row.status)[0];
102
+ <OptionBadge value={opt ? (fields.status?.byKey(opt.key) ?? opt) : null} />
103
+ ```
104
+
105
+ `byKey` hit → the configured color. `byKey` miss (option removed post-write) → fall back to the
106
+ cell's own `{ key, label }`, which renders as a neutral badge. Never hand-map option key → color.
107
+
108
+ ## Member cells (`readMembers`)
109
+
110
+ A `select_member` column's storage shape is a bare array of member ids. The server rewrites every
111
+ projected `select_member` cell to `Array<{ id, name, email? }>`:
112
+
113
+ - **`email` is present only for authenticated members of the app's own organization.** Anonymous
114
+ visitors to a public app — and members of *other* orgs viewing a publicly shared app — get
115
+ `{ id, name }` only. No PII crosses to viewers outside the org.
116
+ - **An id that no longer resolves** (removed member, id outside the org) comes back as
117
+ `{ id, name: null }` — an explicit missing state, never an empty string. Render a placeholder.
118
+ - **Cells never carry the avatar.** `ResolvedMember.image` exists only on the `useMembers` roster;
119
+ avatar URLs are presigned files and stay in that one bounded list rather than being fattened onto
120
+ every query cell.
121
+ - Only ids already present in the projected rows are resolved — a member cell never exposes the
122
+ wider directory.
123
+
124
+ Decode with **`readMembers(cell)`** (`dist/src/members.d.ts`) → `ResolvedMember[]`. Same defensive
125
+ contract as `readSelect`: `[]` for null/empty/unexpected cells, malformed entries dropped.
126
+
127
+ ## The org roster: `useMembers(opts?)`
128
+
129
+ The candidate set for an "assign to a member" picker (`dist/src/hooks.d.ts`):
130
+
131
+ ```tsx
132
+ const { members, loading, error } = useMembers({ group: "grp_fulfillment" });
133
+ <MemberSelect members={members} value={assignee} onValueChange={setAssignee} />
134
+ ```
135
+
136
+ Each member is `{ id, name, email, image }`. `image` is the avatar URL (a presigned URL valid 24
137
+ hours, or the member's external OAuth photo) and may be `null`. `name` may be null/empty for
138
+ members without a display name — fall back to `email`. On failure the hook does not throw: it
139
+ resolves `{ members: [], loading: false, error }`.
140
+
141
+ **Limitation:** `useMembers` is not SWR-cached — every mounted hook instance issues its own
142
+ request. Call it once near the top of the screen and pass the roster down; don't call it per row.
143
+
144
+ ### Access gates (when it errors)
145
+
146
+ A member roster (with emails) must not leak through an arbitrary app, so the call is gated. All
147
+ three gates are observable as an `error` on the hook:
148
+
149
+ 1. **Members-only, same-org.** The viewer must be an authenticated member of the app's own
150
+ organization. Anonymous visitors (every standalone/public app view) and members of other orgs
151
+ get an error. In `lotics app dev` the call runs under the owner's key and succeeds.
152
+ 2. **Declared member access.** The app must declare that it works with members: at least one
153
+ workflow input **or** query param of `{ "type": "member" }` in its manifest. An app with no
154
+ member declaration gets: *"This app has not declared member access — listing members requires a
155
+ declared workflow `member` input or query `member` param."* Member access is a declared,
156
+ auditable surface, like queries and workflows.
157
+ 3. **Declared groups only.** `{ group: "grp_…" }` restricts the roster to one member group — but
158
+ only a group declared on some member input's `group` property. An undeclared group errors; an
159
+ app can only enumerate groups it actually assigns into. A declared group with no members
160
+ resolves to `{ members: [] }`.
161
+
162
+ ```jsonc
163
+ // package.json → "lotics" — the declaration that unlocks useMembers
164
+ "workflows": {
165
+ "assignOrder": {
166
+ "workflow_id": "wfl_x",
167
+ "inputs": {
168
+ "record_id": { "type": "record_link", "table_id": "tbl_orders" },
169
+ "assignee": { "type": "member", "group": "grp_fulfillment" }
170
+ }
171
+ }
172
+ }
173
+ ```
174
+
175
+ The same `group` declaration also constrains what the workflow **accepts** at execution — a
176
+ submitted member outside the group is rejected server-side (see [mutations](./mutations.md)).
177
+
178
+ ### Per-viewer apps: resolve people from cells, not the roster
179
+
180
+ A per-viewer app — queries scoped by the `is_current_member` operator, no member inputs — fails
181
+ gate 2 **by design**: its viewer has no business enumerating the whole org. **Do not add a dummy
182
+ member param to unlock `useMembers`.** Resolve people from the viewer's own rows instead: project
183
+ the `select_member` field in the query, and the cell already carries the name.
184
+
185
+ ```tsx
186
+ // No useMembers. The projected cell is the member source.
187
+ {readMembers(row.assignee).map((m) => (
188
+ <MemberChip key={m.id} name={m.name ?? "—"} />
189
+ ))}
190
+ ```
191
+
192
+ Names render; only the avatar photo is unavailable (`MemberChip` falls back to initials). This
193
+ respects the privacy boundary the gate enforces. If a chip renders blank, fix the query projection
194
+ (project the member field) — never widen the manifest to "fix" it.
195
+
196
+ ## `useViewer()` — display-only identity
197
+
198
+ `useViewer()` (`dist/src/viewer.d.ts`) → `{ memberId, loading }` — the signed-in member currently
199
+ viewing the app. Under an admin's "View as" (product UI or `lotics app dev --view-as`) it returns
200
+ the **view-as target**. It is `null` for a standalone/public visitor, and until the context
201
+ resolves — gate viewer-dependent UI on `loading`.
202
+
203
+ Use it to **personalize**: greet the member, default an assignment picker to them.
204
+
205
+ **Never use it as an authorization fact or for data scoping.** Per-row scoping belongs in the query
206
+ template via the `is_current_member` filter operator — the server binds the same viewer (honoring
207
+ view-as) with nothing client-supplied to spoof. Write attribution belongs server-side in the
208
+ workflow body (`runtime.triggered_by_member_id`). Full model: [security](./security.md).
209
+
210
+ ## Comments: `useComments` / `useCommentCounts`
211
+
212
+ Record comments — member-to-member discussion attached to any record the app reaches
213
+ (`dist/src/comments.d.ts`).
214
+
215
+ ### Enabling comments
216
+
217
+ Opt in via the manifest; the flag syncs on deploy:
218
+
219
+ ```jsonc
220
+ // package.json → "lotics"
221
+ "capabilities": { "comments": true }
222
+ ```
223
+
224
+ Both hooks expose **`available`** — `true` only when a member is signed in **and** the app declared
225
+ the capability. Gate the composer on it. When `false` (any standalone/public app view, or an app
226
+ that didn't opt in) lists stay empty, `loading` settles to `false`, and every mutation rejects —
227
+ client-side up front, and re-checked server-side.
228
+
229
+ ### Authority & attribution
230
+
231
+ - **Access runs under the app, not the member's table IAM.** A member who can use the app (and the
232
+ app declares `comments`) may read/write comments on any record the app's workspace holds,
233
+ regardless of their own table access. The one floor: the record must live in the app's own
234
+ workspace — a crafted foreign `record_id` errors.
235
+ - **The author is always the real signed-in member** — correct attribution, enforced server-side.
236
+ **Warning:** under "View as", comments still author as the real member (the admin), not the
237
+ view-as target — unlike `useViewer` and `is_current_member` scoping, which follow the target.
238
+ - **Edit and delete are author-only**, checked server-side against the viewer. `CommentList`'s
239
+ `currentMemberId` prop drives the matching affordance client-side.
240
+
241
+ ### Reading & writing
242
+
243
+ ```tsx
244
+ const { comments, available, createComment } = useComments({
245
+ record_id: row.__source_record_id,
246
+ });
247
+ ```
248
+
249
+ `record_id` comes from the row's `__source_record_id` addressing column (see
250
+ [queries](./queries.md)). State: `{ comments, loading, error, available, createComment,
251
+ updateComment, deleteComment, refetch }`.
252
+
253
+ - `comments` — newest first on the wire (server order). Pass the array as-is to `@lotics/ui`'s
254
+ `CommentList`, which re-sorts oldest-first for display. Each `AppComment`: `{ id, record_id,
255
+ table_id, member_id, content, files, workspace_id, created_at, updated_at }`. Attachments
256
+ (`AppCommentFile`) carry `id` / `filename` / `mime_type` plus `file_storage_key` (needed when
257
+ re-sending files on edit). The `url` / `thumbnail_url` / `preview_url` fields exist on the type
258
+ but the server does not populate them today — render attachments by name and type (what
259
+ `CommentList`'s default file row does), never by counting on a fetchable URL.
260
+ - `createComment({ content, file_ids? })` — posts as the viewing member. `file_ids` come from
261
+ `useFileUpload` / `useAttachments` (see [files](./files.md)). A comment must have content or at
262
+ least one file (empty input is a client no-op; the server enforces the same rule). Content max
263
+ 10,000 characters.
264
+ - `updateComment(id, { content, files? })` — omit `files` to keep the current attachment set (a
265
+ text-only edit never drops attachments); pass `files` to replace the whole set.
266
+ - All three mutations apply **optimistically** with rollback on error, then repopulate from the
267
+ server. **Limitation:** an optimistic create renders a placeholder row (temporary id, empty
268
+ `table_id`/`workspace_id`, `files: null`) until the refetch lands — don't persist anything keyed
269
+ on it.
270
+ - **Limitation:** a comment carries `member_id` only — resolving the author's display name needs a
271
+ member source: the `useMembers` roster (requires the member-access declaration above) or member
272
+ cells in your own data. An unresolvable id should render a fallback (`CommentList` has an
273
+ `unknownMember` label for exactly this).
274
+ - **Freshness:** SWR-cached, revalidates on focus/reconnect; there is **no realtime push** to apps,
275
+ so another viewer's comment appears on the next focus or explicit `refetch()`.
276
+
277
+ ### Counts
278
+
279
+ `useCommentCounts({ table_id })` → `{ counts, loading, error, available, refetch }` where `counts`
280
+ is `{ record_id: count }` for the whole table — row badges without shipping any comment content
281
+ (one server-side aggregate). Same gate and authority as `useComments`. `table_id` is the source
282
+ table's id — the same id the app's queries are declared over.
283
+
284
+ ## Pairing with `@lotics/ui`
285
+
286
+ The SDK ships zero components; these `@lotics/ui` components are shaped to accept SDK values
287
+ directly (full props: `node_modules/@lotics/ui/AGENTS.md` and its `docs/`):
288
+
289
+ | Value | Component | Feed it |
290
+ | --- | --- | --- |
291
+ | A select value (stored or picker option) | `OptionBadge` | A `useFieldOptions` option, or `byKey(readSelect(cell)[0]?.key)`; accepts a single option, an array (multi → one badge each), or null (renders nothing). Missing/unknown color → neutral. |
292
+ | A person, inline | `MemberChip` | `name` / `image` resolved from the roster or a cell; no image → initials |
293
+ | A member picker | `MemberSelect` | `members={useMembers().members}` — renders each option as a `MemberChip`; `MEMBER_UNASSIGNED` marks its optional "unassigned" option |
294
+ | A comment thread | `CommentList` + `CommentComposer` | `useComments` state; `resolveMember` bridges `member_id` → your member source |
295
+
296
+ The SDK never imports `@lotics/ui` — the app owns the (thin) data→UI adapter in each row above.
297
+
298
+ ## Where each hook works
299
+
300
+ | Surface | Embedded (signed-in member) | Standalone / public (anonymous) |
301
+ | --- | --- | --- |
302
+ | `readSelect` cell enrichment | ✓ | ✓ |
303
+ | `readMembers` cell enrichment | ✓ (email included, same-org) | ✓ name-only, no email |
304
+ | `useFieldOptions` | ✓ | ✓ |
305
+ | `useMembers` | ✓ (same-org + declaration gates) | ✗ errors |
306
+ | `useViewer` | member id (view-as target) | `null` |
307
+ | `useComments` / `useCommentCounts` | ✓ when capability declared | ✗ `available: false` |