@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/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` |
|