@lotics/app-sdk 0.100.0 → 0.101.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +32 -47
- package/dist/agent_stream.d.ts +131 -0
- package/dist/ask_ai.d.ts +27 -0
- package/dist/attachments.d.ts +58 -0
- package/dist/chunk-ARV5FAU5.js +1132 -0
- package/dist/comments.d.ts +89 -0
- package/dist/error_report.d.ts +9 -0
- package/dist/folder_pick.d.ts +8 -0
- package/dist/geolocation.d.ts +42 -0
- package/dist/hooks.d.ts +251 -0
- package/dist/{src/index.d.ts → index.d.ts} +13 -22
- package/dist/index.js +31309 -0
- package/dist/index.js.LEGAL.txt +11 -0
- package/dist/members.d.ts +32 -0
- package/dist/mock.d.ts +37 -0
- package/dist/mount.d.ts +19 -0
- package/dist/new_record.d.ts +37 -0
- package/dist/open_app.d.ts +12 -0
- package/dist/open_external.d.ts +10 -0
- package/dist/overlay.d.ts +25 -0
- package/dist/queries.d.ts +231 -0
- package/dist/recording.d.ts +47 -0
- package/dist/recording_state.d.ts +43 -0
- package/dist/rename_file.d.ts +13 -0
- package/dist/router.d.ts +10 -0
- package/dist/router.js +97 -0
- package/dist/row.d.ts +87 -0
- package/dist/rpc.d.ts +114 -0
- package/dist/select.d.ts +24 -0
- package/dist/shared_types.d.ts +8 -0
- package/dist/store.d.ts +43 -0
- package/dist/types.d.ts +36 -0
- package/dist/upload/optimize.d.ts +30 -0
- package/dist/upload/pipeline.d.ts +36 -0
- package/dist/upload/transport.d.ts +19 -0
- package/dist/url_params.d.ts +55 -0
- package/dist/use_recents.d.ts +15 -0
- package/dist/{src/use_url_state.d.ts → use_url_state.d.ts} +0 -2
- package/dist/viewer.d.ts +41 -0
- package/dist/written.d.ts +77 -0
- package/docs/ai.md +74 -133
- package/docs/data_fetching.md +209 -290
- package/docs/files.md +61 -51
- package/docs/members_and_options.md +93 -63
- package/docs/mutations.md +135 -205
- package/docs/navigation_and_state.md +26 -35
- package/docs/queries.md +144 -207
- package/docs/recipes.md +21 -45
- package/docs/runtime.md +74 -137
- package/docs/security.md +8 -11
- package/docs/workflows.md +189 -174
- package/package.json +27 -28
- package/dist/src/agent_stream.d.ts +0 -200
- package/dist/src/agent_stream.js +0 -314
- package/dist/src/ask_ai.d.ts +0 -40
- package/dist/src/ask_ai.js +0 -35
- package/dist/src/attachments.d.ts +0 -68
- package/dist/src/attachments.js +0 -93
- package/dist/src/comments.d.ts +0 -127
- package/dist/src/comments.js +0 -192
- package/dist/src/download.js +0 -54
- package/dist/src/geolocation.d.ts +0 -64
- package/dist/src/geolocation.js +0 -96
- package/dist/src/hooks.d.ts +0 -781
- package/dist/src/hooks.js +0 -860
- package/dist/src/index.js +0 -34
- package/dist/src/members.d.ts +0 -105
- package/dist/src/members.js +0 -62
- package/dist/src/mock.d.ts +0 -118
- package/dist/src/mock.js +0 -124
- package/dist/src/mount.d.ts +0 -47
- package/dist/src/mount.js +0 -34
- package/dist/src/new_record.d.ts +0 -74
- package/dist/src/new_record.js +0 -117
- package/dist/src/open_app.d.ts +0 -15
- package/dist/src/open_app.js +0 -18
- package/dist/src/open_external.d.ts +0 -16
- package/dist/src/open_external.js +0 -19
- package/dist/src/recording.d.ts +0 -59
- package/dist/src/recording.js +0 -30
- package/dist/src/recording_state.d.ts +0 -59
- package/dist/src/recording_state.js +0 -94
- package/dist/src/router.d.ts +0 -17
- package/dist/src/router.js +0 -144
- package/dist/src/row.d.ts +0 -159
- package/dist/src/row.js +0 -254
- package/dist/src/rpc.d.ts +0 -207
- package/dist/src/rpc.js +0 -904
- package/dist/src/select.d.ts +0 -34
- package/dist/src/select.js +0 -40
- package/dist/src/types.d.ts +0 -115
- package/dist/src/types.js +0 -1
- package/dist/src/upload/optimize.d.ts +0 -54
- package/dist/src/upload/optimize.js +0 -207
- package/dist/src/upload/pipeline.d.ts +0 -55
- package/dist/src/upload/pipeline.js +0 -52
- package/dist/src/upload/transport.d.ts +0 -42
- package/dist/src/upload/transport.js +0 -128
- package/dist/src/url_params.d.ts +0 -93
- package/dist/src/url_params.js +0 -215
- package/dist/src/use_optimistic.d.ts +0 -27
- package/dist/src/use_optimistic.js +0 -27
- package/dist/src/use_recents.d.ts +0 -19
- package/dist/src/use_recents.js +0 -71
- package/dist/src/use_url_state.js +0 -73
- package/dist/src/viewer.d.ts +0 -26
- package/dist/src/viewer.js +0 -47
- /package/dist/{src/download.d.ts → download.d.ts} +0 -0
package/docs/files.md
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
# Files
|
|
2
2
|
|
|
3
|
-
Files end to end
|
|
4
|
-
|
|
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
|
|
3
|
+
Files end to end: uploading, the add-queue, `files` cells, generated documents, previewing,
|
|
4
|
+
filtering, and the delivery bounds that decide whether a file-bearing query succeeds. Query mechanics (projection, unnest, aggregates) live in [queries](./queries.md); fetching
|
|
9
5
|
discipline in [data fetching](./data_fetching.md).
|
|
10
6
|
|
|
11
7
|
## The lifecycle in one table
|
|
@@ -14,18 +10,29 @@ discipline in [data fetching](./data_fetching.md).
|
|
|
14
10
|
|---|---|---|
|
|
15
11
|
| Collect bytes from the visitor | `useFileUpload().upload(file)` | `UploadedFile` — a stored, **unattached** file id + presigned serving URLs |
|
|
16
12
|
| Collect several with live previews | `useAttachments()` | `AttachedFile[]` with instant local previews; `fileIds` when done |
|
|
17
|
-
|
|
18
|
-
`useAttachments().add(files, { fidelity })` and `.attach(files, { fidelity })` take the same
|
|
19
|
-
`fidelity` as `upload`.
|
|
20
13
|
| 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 |
|
|
21
14
|
| Read back from records | `useQuery` + `readFiles(cell)` | `AppFile[]` — presigned `url`/`thumbnail_url` (24 h) + `size`/`created_at` |
|
|
22
15
|
| Receive a generated document | `useWorkflow` → `WorkflowResult.files` | presigned files auto-extracted from the run |
|
|
23
16
|
| Show it | `@lotics/ui` `FileThumbnail` / `FileThumbnailGrid` / `FileGalleryDialog` | map to `DisplayFile` (see below) |
|
|
24
17
|
| Save browser-built bytes | `downloadFile(filename, data, mimeType?)` | a client-side download (see [runtime](./runtime.md)) |
|
|
18
|
+
| Rename one a record holds | `renameFile(file_id, filename)` | an `AppFile` — a new, unattached file over the same bytes |
|
|
25
19
|
|
|
26
20
|
An uploaded file is **inert until a workflow attaches it** — it has an id and serving URLs, but
|
|
27
|
-
belongs to no record.
|
|
28
|
-
|
|
21
|
+
belongs to no record ([mutations](./mutations.md)).
|
|
22
|
+
|
|
23
|
+
## Renaming — `renameFile`
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
const renamed = await renameFile(file.id, "Invoice 9.pdf");
|
|
27
|
+
await save({ record_id, papers_added: [renamed.id], papers_removed: [file.id] });
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
A file is never renamed in place: `renameFile` answers a NEW file over the same bytes (same
|
|
31
|
+
storage, size and upload day), which is inert like an upload until a save puts it in the old one's
|
|
32
|
+
place. A save carrying `_added` and `_removed` of one field puts the added file where the removed
|
|
33
|
+
one stood, so a rename or a replace keeps its position. The name keeps the file's extension
|
|
34
|
+
exactly — `"scan.pdf"` → `"Invoice 9.pdf"`; `.html` is refused — and holds no `/`, `\` or control
|
|
35
|
+
character. Offer it only where the app writes that field: the save is what may be refused.
|
|
29
36
|
|
|
30
37
|
## Uploading — `useFileUpload`
|
|
31
38
|
|
|
@@ -38,23 +45,21 @@ const surveyPhoto = await upload(file, { fidelity: "standard" }); // bulk captur
|
|
|
38
45
|
await submit({ ...fields, invoice_file_id: invoiceShot.id });
|
|
39
46
|
```
|
|
40
47
|
|
|
41
|
-
Signature: `dist/
|
|
48
|
+
Signature: `dist/hooks.d.ts`. Returns `{ upload, uploading, error }`:
|
|
42
49
|
|
|
43
50
|
- `upload(file: File, options?: { fidelity }): Promise<UploadedFile>` — resolves to the stored file; rejects on failure
|
|
44
51
|
(the file is never partially stored). `UploadedFile` is
|
|
45
52
|
`{ id, filename, mime_type, url?, thumbnail_url? }` — `url`/`thumbnail_url` are presigned
|
|
46
53
|
(24 h) and load directly in the sandboxed iframe, so a just-uploaded image previews without a
|
|
47
54
|
round-trip.
|
|
48
|
-
- `fidelity` — **how faithful the stored image must be
|
|
49
|
-
each step and
|
|
55
|
+
- `fidelity` — **how faithful the stored image must be**; the platform owns the pixels behind
|
|
56
|
+
each step. `useAttachments().add` and `.attach` take it too.
|
|
50
57
|
|
|
51
58
|
Defaults to `"high"` — enough to resolve the text of a photographed document. Drop to
|
|
52
59
|
`"standard"` when collecting in volume.
|
|
53
60
|
|
|
54
61
|
**This only affects photographs.** A PDF, Word, Excel or CSV file is stored untouched at every
|
|
55
|
-
step, so `"original"` is not what keeps a document intact — it already is.
|
|
56
|
-
(what an iPhone camera produces) is transcoded to JPEG server-side whatever you choose, because
|
|
57
|
-
no browser can render it.
|
|
62
|
+
step, so `"original"` is not what keeps a document intact — it already is.
|
|
58
63
|
|
|
59
64
|
| `fidelity` | Use it when | Stored at |
|
|
60
65
|
|---|---|---|
|
|
@@ -62,10 +67,9 @@ Signature: `dist/src/hooks.d.ts`. Returns `{ upload, uploading, error }`:
|
|
|
62
67
|
| `"high"` *(default)* | The image is READ — invoices, release orders, screenshots of dense tables — by a model or a person. Near-lossless, because a model's token cost depends on dimension alone, so quality is free accuracy. | 1568 px, q 0.95 |
|
|
63
68
|
| `"original"` | The bytes themselves matter: evidence of record, or anything that may be re-read at a higher fidelity later. | unchanged |
|
|
64
69
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
a picture of it — it is the one step whose meaning survives a change to these numbers.
|
|
70
|
+
**If text on the image has to be legible, or a person may zoom, use `"high"`.** Reach for
|
|
71
|
+
`"original"` only when the file IS the record rather than a picture of it — it is the one step
|
|
72
|
+
whose meaning survives a change to these numbers.
|
|
69
73
|
- `uploading` — true while **any** upload from this hook is in flight.
|
|
70
74
|
- `error` — message of the most recent failed upload; cleared when a new one starts.
|
|
71
75
|
|
|
@@ -77,18 +81,18 @@ use-access to the app itself.
|
|
|
77
81
|
The pipeline is: optimize (images only) → mint a presigned upload URL → `PUT` the bytes straight
|
|
78
82
|
to object storage → finalize. The API server never proxies the bytes.
|
|
79
83
|
|
|
80
|
-
- **Image optimization.** JPEG/PNG/WebP
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
server converts it at finalize
|
|
90
|
-
|
|
91
|
-
|
|
84
|
+
- **Image optimization.** A JPEG/PNG/WebP image longer than its `fidelity`'s ceiling is resized
|
|
85
|
+
and re-encoded as JPEG at that step's quality before upload; one at or below it, and every
|
|
86
|
+
`"original"`, is stored untouched. Non-image files pass through unchanged, and so does an image
|
|
87
|
+
the browser cannot resize (no canvas or decoder, or a canvas that exports nothing). Any other
|
|
88
|
+
optimization error fails a standalone app's upload; a member-facing app's host uploads the
|
|
89
|
+
original bytes instead.
|
|
90
|
+
**Warning:** a resized PNG/WebP is *converted to JPEG* — transparency is lost and the stored
|
|
91
|
+
filename's extension becomes `.jpg`. For lossless originals, upload at `"original"`.
|
|
92
|
+
- **HEIC/HEIF always becomes JPEG, at every `fidelity`.** Where the browser can't convert it
|
|
93
|
+
client-side the server converts it at finalize (conversion failure stores the original bytes —
|
|
94
|
+
never an upload error). Either way the stored file — and the file identity your app gets back —
|
|
95
|
+
is `name.jpg` with `mime_type: "image/jpeg"`.
|
|
92
96
|
- **Transport resilience.** The storage `PUT` has a 5-minute per-request timeout and retries
|
|
93
97
|
network errors / timeouts / 5xx up to 3 attempts with 1 s → 2 s backoff between attempts. 4xx
|
|
94
98
|
responses are terminal (no retry). The presigned upload URL itself is valid for 10 minutes.
|
|
@@ -183,11 +187,11 @@ const onAdd = async (picked: File[]) => {
|
|
|
183
187
|
/>
|
|
184
188
|
```
|
|
185
189
|
|
|
186
|
-
Contract (`dist/
|
|
190
|
+
Contract (`dist/attachments.d.ts`):
|
|
187
191
|
|
|
188
192
|
| Member | Behavior |
|
|
189
193
|
|---|---|
|
|
190
|
-
| `files: AttachedFile[]` | Current attachments, in the order added |
|
|
194
|
+
| `files: readonly AttachedFile[]` | Current attachments, in the order added |
|
|
191
195
|
| `add(files: File[])` | Each file shows its local preview at once and uploads in the background. Never rejects — a failure is the entry's `status` |
|
|
192
196
|
| `attach(files: File[])` | The same add, resolved with the stored ids once **every** one is up. Rejects with the first refusal rather than answering short |
|
|
193
197
|
| `remove(id)` | Removes one attachment and revokes its preview object-URL |
|
|
@@ -196,6 +200,11 @@ Contract (`dist/src/attachments.d.ts`):
|
|
|
196
200
|
| `fileIds: string[]` | Stored file ids of the **completed** uploads — the workflow/agent payload |
|
|
197
201
|
| `useAttachments({ landed })` | Stored ids the destination now holds; each matching entry leaves the queue and its preview is revoked |
|
|
198
202
|
|
|
203
|
+
Several piles from one list — a record's file sections, a form's file inputs — take one
|
|
204
|
+
`useAttachmentPiles({ landed })` rather than a `useAttachments` per pile: a hook called in a loop
|
|
205
|
+
shifts every later hook when the list changes. `of(pile)` is that pile's queue, the contract above
|
|
206
|
+
with verbs stable per pile; `landed` is keyed by pile; `clearAll()` empties every pile.
|
|
207
|
+
|
|
199
208
|
Each `AttachedFile` is `{ id, filename, mime_type, preview_url, status, file_id? }`:
|
|
200
209
|
|
|
201
210
|
- `id` — a stable *local* id (the React key and the `remove(id)` handle), **not** the stored file
|
|
@@ -223,10 +232,15 @@ file-shaped value anywhere in the result rows** at read time:
|
|
|
223
232
|
- `url` — a presigned serving URL, valid **24 hours**, anonymous-fetchable. Loads directly from
|
|
224
233
|
the sandboxed app iframe and for public-app visitors — no session, no proxy, no URL derivation.
|
|
225
234
|
- `thumbnail_url` — a presigned small-variant URL, emitted for every image.
|
|
226
|
-
- `size` (bytes)
|
|
227
|
-
serving time, batch-loaded per
|
|
235
|
+
- `size` (bytes), `created_at` (ISO upload timestamp) and `document_template_id` (the template a
|
|
236
|
+
generated file was made from) — resolved from the file object at serving time, batch-loaded per
|
|
237
|
+
response.
|
|
238
|
+
|
|
239
|
+
A caller outside the app's organization receives only `id`, `filename`, `mime_type`, `url`,
|
|
240
|
+
`thumbnail_url`, `preview_url`, `size`, `created_at` and `document_template_id` — never the storage
|
|
241
|
+
address.
|
|
228
242
|
|
|
229
|
-
Decode with `readFiles(cell)` → `AppFile[]` (`dist/
|
|
243
|
+
Decode with `readFiles(cell)` → `AppFile[]` (`dist/row.d.ts`):
|
|
230
244
|
|
|
231
245
|
| Field | Type | Notes |
|
|
232
246
|
|---|---|---|
|
|
@@ -237,6 +251,7 @@ Decode with `readFiles(cell)` → `AppFile[]` (`dist/src/row.d.ts`):
|
|
|
237
251
|
| `thumbnail_url` | `string?` | Presigned image thumbnail (see the 404 note below) |
|
|
238
252
|
| `size` | `number?` | Byte size — **absent on older files not yet backfilled**; render only when present |
|
|
239
253
|
| `created_at` | `string?` | ISO upload timestamp |
|
|
254
|
+
| `document_template_id` | `string?` | The document template (`dtl_…`) the file was generated from — absent on a file a person uploaded, whatever its name |
|
|
240
255
|
|
|
241
256
|
`readFiles` skips entries the server didn't presign (no `url`), so you never render an unservable
|
|
242
257
|
file. It is pure and never throws.
|
|
@@ -244,9 +259,8 @@ file. It is pure and never throws.
|
|
|
244
259
|
- **A LOOKUP of a files field is one list per linked row.** The cell holds one entry per linked
|
|
245
260
|
record and each entry is that record's whole files cell, so the value arrives one level deeper
|
|
246
261
|
than a stored column's — exactly as a looked-up link does. `readFiles` opens that level itself,
|
|
247
|
-
in link order, naming a file two linked rows both carry once
|
|
248
|
-
|
|
249
|
-
falling back to its glyph while the bytes are plainly there.
|
|
262
|
+
in link order, naming a file two linked rows both carry once; read entry-by-entry it decodes to
|
|
263
|
+
nothing.
|
|
250
264
|
|
|
251
265
|
- **Thumbnails are optimistic.** `thumbnail_url` is emitted for every image without checking that
|
|
252
266
|
the variant exists; a not-yet-generated variant 404s on fetch. `@lotics/ui`'s `FileThumbnail`
|
|
@@ -282,8 +296,8 @@ Design around it:
|
|
|
282
296
|
hits the ceiling (and over-exposes storage metadata). Keep list queries file-free; fetch files
|
|
283
297
|
in the detail query for the selected row.
|
|
284
298
|
- **Paginate.** The row `limit` defaults to the server's 10,000-row cap; a files projection at
|
|
285
|
-
that size is thousands of entries. `
|
|
286
|
-
|
|
299
|
+
that size is thousands of entries. `useQuery` with a modest `page` or `more` keeps each
|
|
300
|
+
response far under the ceiling.
|
|
287
301
|
- **Push counts and per-file analysis into the query.** You never need the file *objects* to
|
|
288
302
|
count them: `has_file_count`/`is_empty` filters, the `filled`/`empty`/`percent_*` aggregates,
|
|
289
303
|
and the `unnest` node (one output row per file, emitting the file **id** as text; `keep_empty`
|
|
@@ -319,11 +333,8 @@ if (result.status === "success" && result.files?.length) {
|
|
|
319
333
|
the extracted entry is presigned while a hand-returned id is not.
|
|
320
334
|
- The presigned URLs work for **anonymous public-app viewers** — a public visitor can download a
|
|
321
335
|
document the workflow generated for them.
|
|
322
|
-
- `openExternal(url)
|
|
323
|
-
|
|
324
|
-
(scheme-validated, `http`/`https` only).
|
|
325
|
-
- For bytes the app builds *in the browser* (a client-side .xlsx/CSV export), the counterpart is
|
|
326
|
-
`downloadFile(filename, data, mimeType?)` — see [runtime](./runtime.md).
|
|
336
|
+
- Open one with `openExternal(url)`, never `window.open`; bytes the app builds *in the browser*
|
|
337
|
+
leave through `downloadFile` — both [runtime](./runtime.md).
|
|
327
338
|
|
|
328
339
|
### Naming what goes into a zip
|
|
329
340
|
|
|
@@ -353,8 +364,8 @@ means "no name", and that entry keeps the stored filename.
|
|
|
353
364
|
|
|
354
365
|
Render any file inline — image, PDF, video, audio, Word, Excel, CSV — with `@lotics/ui`. Never
|
|
355
366
|
hand-roll per-type rendering, and don't use `openExternal` as a preview (that's "open elsewhere",
|
|
356
|
-
not viewing).
|
|
357
|
-
|
|
367
|
+
not viewing). The kit is installed only in a sandbox session (see [AGENTS.md](../AGENTS.md)); the
|
|
368
|
+
SDK-side contract is only the data mapping:
|
|
358
369
|
|
|
359
370
|
| `AppFile` (SDK) | `DisplayFile` (`@lotics/ui`) |
|
|
360
371
|
|---|---|
|
|
@@ -365,7 +376,6 @@ not viewing). Component contracts live in the `@lotics/ui` reference
|
|
|
365
376
|
| `thumbnail_url` | `thumbnailUrl` |
|
|
366
377
|
|
|
367
378
|
For an `AttachedFile` still uploading, map `preview_url` → `url` (there is no server URL yet).
|
|
368
|
-
The app owns this data→UI adapter — the SDK deliberately never imports `@lotics/ui`.
|
|
369
379
|
|
|
370
380
|
- **`FileThumbnail`** — one square tile; `uploading` takes the queue status and overlays the
|
|
371
381
|
scrim, the spinner or the retry, images fall back from `previewUrl` to `thumbnailUrl` to `url`.
|
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
# Members & select options
|
|
2
2
|
|
|
3
|
-
How an app renders and picks **people** and **select-field options**,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
(`useComments`, `useCommentCounts`), and the `@lotics/ui` components they feed. Read this before
|
|
7
|
-
building an assign picker, a colored `Status` mark, 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).
|
|
3
|
+
How an app renders and picks **people** and **select-field options**, the host context they are
|
|
4
|
+
read from, and **record comments**. Query mechanics live in [queries](./queries.md); the authority
|
|
5
|
+
model in [security](./security.md).
|
|
10
6
|
|
|
11
7
|
## Cells vs. catalogs — the model
|
|
12
8
|
|
|
@@ -16,6 +12,7 @@ Every select and member value reaches the app in one of two shapes, and most scr
|
|
|
16
12
|
| --- | --- | --- |
|
|
17
13
|
| Render a stored select value | `readSelect(cell)` | The options the record actually holds — `{ key, label }`, **no color** |
|
|
18
14
|
| 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 |
|
|
15
|
+
| Filter a number each row reads in its own unit | `useFieldOptions(alias).units` | The units or currencies a range on it compares in — `{ vocabulary, options }` |
|
|
19
16
|
| Render a stored member value | `readMembers(cell)` | The members the record actually holds |
|
|
20
17
|
| Populate a member picker (assign UIs) | `useMembers(opts?)` | The org roster — every member, not only the referenced ones |
|
|
21
18
|
|
|
@@ -36,7 +33,7 @@ projected `select` cell to `Array<{ key, label }>` before the row reaches the ap
|
|
|
36
33
|
*genuinely computed* select column (no source field at all) keeps bare keys.
|
|
37
34
|
- **Order** is the cell's own stored order.
|
|
38
35
|
|
|
39
|
-
Decode with **`readSelect(cell)`** (`dist/
|
|
36
|
+
Decode with **`readSelect(cell)`** (`dist/select.d.ts`) → `ResolvedOption[]`:
|
|
40
37
|
|
|
41
38
|
- Returns `[]` for `null`/`undefined`/empty cells and for any unexpected shape — iterate without
|
|
42
39
|
null-checks.
|
|
@@ -50,7 +47,7 @@ below).
|
|
|
50
47
|
|
|
51
48
|
## The full option set: `useFieldOptions(alias)`
|
|
52
49
|
|
|
53
|
-
The picker companion to `useQuery` (`dist/
|
|
50
|
+
The picker companion to `useQuery` (`dist/hooks.d.ts`). Where a cell carries only the options a
|
|
54
51
|
record holds (key + label), this resolves each select **output column's complete option list with
|
|
55
52
|
colors**, straight from field config — so it populates a dropdown, colors stored values, and picks
|
|
56
53
|
up table edits with no app change.
|
|
@@ -70,33 +67,46 @@ const { fields } = useFieldOptions("orders"); // same alias you query
|
|
|
70
67
|
|
|
71
68
|
`fields` is keyed by the query's **output column name**, not the field key — and by *that alias's*
|
|
72
69
|
columns: `Partial<Record<ColumnKeyOf<"alias">, FieldOptions>>`. A key the alias does not project is
|
|
73
|
-
a compile error,
|
|
74
|
-
rendering with no options; and every key is optional, so the read is through `?.` and never a bare
|
|
75
|
-
`.options`. Each `FieldOptions`:
|
|
70
|
+
a compile error, and every key is optional. Each `FieldOptions`:
|
|
76
71
|
|
|
77
72
|
| Property | Meaning |
|
|
78
73
|
| --- | --- |
|
|
79
74
|
| `label` | The source field's display name — a ready picker/section label |
|
|
80
|
-
| `options` | Every option of the field — `{ key, label, color }` — in field-config order, **including options not present in any current row** |
|
|
75
|
+
| `options` | Every option of the field — `{ key, label, color, mark? }` (the option's own brand or glyph, where the field marks every option) — in field-config order, **including options not present in any current row** |
|
|
81
76
|
| `byKey(key)` | Resolve one option by key; `undefined` for an unknown key (option removed after the cell was written) |
|
|
82
77
|
|
|
83
78
|
- `color` is a named palette token (e.g. `"blue"`, `"emerald"`). Pass the option straight to
|
|
84
79
|
`@lotics/ui`'s `Status`; a missing/unrecognized token degrades to a neutral badge.
|
|
85
80
|
- **A column the server can't map to a single source select field is simply absent** from
|
|
86
|
-
`fields` — a UNION output whose arms disagree on the source field, or a computed column
|
|
87
|
-
|
|
81
|
+
`fields` — a UNION output whose arms disagree on the source field, or a computed column:
|
|
82
|
+
`fields.status?.options ?? []`.
|
|
88
83
|
- Addressed by the same alias you query, and scoped exactly like running that query — it exposes
|
|
89
84
|
nothing the query itself doesn't. Params are irrelevant (they only fill filter values, never
|
|
90
85
|
change the projection), so no params argument exists.
|
|
91
86
|
- Works in embedded **and** standalone/public apps.
|
|
92
87
|
|
|
88
|
+
### A figure read in each row's own unit: `units`
|
|
89
|
+
|
|
90
|
+
A number whose field names a `unit_field` or `currency_field` reads each row in the option a select
|
|
91
|
+
on that row holds, so a comparison on it states which one as `unit_option` — the server refuses one
|
|
92
|
+
that does not, and keeps only the rows in that unit (a measured unit's kin, `kg` beside `t`,
|
|
93
|
+
converted). `units` is keyed like `fields`: `{ vocabulary: "unit" | "currency", options: { key, label }[] }`
|
|
94
|
+
per such column, whether or not the query projects the select. Pass it whole to a range filter:
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
const { units } = useFieldOptions("loads");
|
|
98
|
+
<FilterChip column={{ key: "weight", label: "Weight", type: "number", units: units.weight }}
|
|
99
|
+
value={weight} onChange={setWeight} />
|
|
100
|
+
// columnFilterToConditions puts the picked unit on each condition as `unit_option`
|
|
101
|
+
```
|
|
102
|
+
|
|
93
103
|
### Caching & freshness
|
|
94
104
|
|
|
95
105
|
Field config is slow-changing, so the hook does **not** revalidate on window focus or reconnect — it
|
|
96
106
|
re-reads on arrival (a remount) like the query hooks, and on an explicit `refetch()`. The option set
|
|
97
107
|
is resolved live from field config at fetch time, so an option added, renamed, or recolored in the
|
|
98
108
|
table flows through on the next fetch, with no app redeploy. `opts.enabled` defers the fetch (e.g.
|
|
99
|
-
until an edit drawer opens). State: `{ fields, loading, isValidating, error, refetch }`.
|
|
109
|
+
until an edit drawer opens). State: `{ fields, units, loading, isValidating, error, refetch }`.
|
|
100
110
|
|
|
101
111
|
### Coloring a stored value (the `byKey` idiom)
|
|
102
112
|
|
|
@@ -142,12 +152,12 @@ projected `select_member` cell to `Array<{ id, name, email?, image?, groups?, ro
|
|
|
142
152
|
- Only ids already present in the projected rows are resolved — a member cell never exposes the
|
|
143
153
|
wider directory, and only their avatars are signed.
|
|
144
154
|
|
|
145
|
-
Decode with **`readMembers(cell)`** (`dist/
|
|
155
|
+
Decode with **`readMembers(cell)`** (`dist/members.d.ts`) → `ResolvedMember[]`. Same defensive
|
|
146
156
|
contract as `readSelect`: `[]` for null/empty/unexpected cells, malformed entries dropped.
|
|
147
157
|
|
|
148
158
|
## The org roster: `useMembers(opts?)`
|
|
149
159
|
|
|
150
|
-
The candidate set for an "assign to a member" picker (`dist/
|
|
160
|
+
The candidate set for an "assign to a member" picker (`dist/hooks.d.ts`):
|
|
151
161
|
|
|
152
162
|
```tsx
|
|
153
163
|
const { members, loading, error } = useMembers({ group: GRP.fulfillment });
|
|
@@ -155,15 +165,13 @@ const { members, loading, error } = useMembers({ group: GRP.fulfillment });
|
|
|
155
165
|
```
|
|
156
166
|
|
|
157
167
|
Each member is the same `ResolvedMember` a cell carries — `{ id, name, email, image, groups, role, joined }`.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
resolved CELL is the display door and keeps naming them.
|
|
168
|
+
It never carries `archived`: this roster answers "who may I ASSIGN?", so a departed member is not
|
|
169
|
+
in it, while a resolved CELL keeps naming them.
|
|
161
170
|
`image` is the avatar URL (a presigned URL valid 24 hours, or the member's external OAuth photo) and
|
|
162
171
|
may be `null`. `name` may be null/empty for members without a display name — fall back to `email`. On failure the hook does not throw: it
|
|
163
172
|
resolves `{ members: [], loading: false, error }`.
|
|
164
173
|
|
|
165
|
-
|
|
166
|
-
request. Call it once near the top of the screen and pass the roster down; don't call it per row.
|
|
174
|
+
Every hook reading one `group` shares one roster: it is read again when a screen reading it mounts, never on focus.
|
|
167
175
|
|
|
168
176
|
### Access gates (when it errors)
|
|
169
177
|
|
|
@@ -172,9 +180,9 @@ three gates are observable as an `error` on the hook:
|
|
|
172
180
|
|
|
173
181
|
1. **Members-only, same-org.** The viewer must be an authenticated member of the app's own
|
|
174
182
|
organization. Anonymous visitors (every standalone/public app view) and members of other orgs
|
|
175
|
-
get an error.
|
|
183
|
+
get an error.
|
|
176
184
|
2. **Declared member access.** The app must declare that it works with members: at least one
|
|
177
|
-
workflow input **or** query param of `{ "type": "member" }` in its
|
|
185
|
+
workflow input **or** query param of `{ "type": "member" }` in its bindings. An app with no
|
|
178
186
|
member declaration gets: *"This app has not declared member access — listing members requires a
|
|
179
187
|
declared workflow `member` input or query `member` param."* Member access is a declared,
|
|
180
188
|
auditable surface, like queries and workflows.
|
|
@@ -183,21 +191,16 @@ three gates are observable as an `error` on the hook:
|
|
|
183
191
|
app can only enumerate groups it actually assigns into. A declared group with no members
|
|
184
192
|
resolves to `{ members: [] }`.
|
|
185
193
|
|
|
186
|
-
Name the group `
|
|
187
|
-
|
|
188
|
-
app. A pasted `grp_…` id is an id one workspace minted: it resolves to nothing anywhere else, and
|
|
189
|
-
`lotics app check` refuses it. (The manifest DECLARATION below is the exception — it carries the
|
|
190
|
-
concrete id, and publish inverts it.)
|
|
194
|
+
Name the group by the `grp_…` id the workflow input's `group` declares — the binding below, set
|
|
195
|
+
with `set_app_workflow`:
|
|
191
196
|
|
|
192
197
|
```jsonc
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
"
|
|
196
|
-
|
|
197
|
-
"
|
|
198
|
-
|
|
199
|
-
"assignee": { "type": "member", "group": "grp_fulfillment" }
|
|
200
|
-
}
|
|
198
|
+
// set_app_workflow — the declaration that unlocks useMembers
|
|
199
|
+
{
|
|
200
|
+
"alias": "assignOrder",
|
|
201
|
+
"inputs": {
|
|
202
|
+
"record_id": { "type": "record_link", "table_id": "tbl_orders" },
|
|
203
|
+
"assignee": { "type": "member", "group": "grp_fulfillment" }
|
|
201
204
|
}
|
|
202
205
|
}
|
|
203
206
|
```
|
|
@@ -219,36 +222,66 @@ the `select_member` field in the query, and the cell already carries the name.
|
|
|
219
222
|
))}
|
|
220
223
|
```
|
|
221
224
|
|
|
222
|
-
Names render; only the avatar photo is unavailable (`MemberChip` falls back to initials).
|
|
223
|
-
|
|
224
|
-
(project the member field) — never widen the manifest to "fix" it.
|
|
225
|
+
Names render; only the avatar photo is unavailable (`MemberChip` falls back to initials). If a chip
|
|
226
|
+
renders blank, fix the query projection — never widen the bindings.
|
|
225
227
|
|
|
226
228
|
## `useViewer()` — display-only identity
|
|
227
229
|
|
|
228
|
-
`useViewer()` (`dist/
|
|
229
|
-
viewing the app. Under an admin's "View as"
|
|
230
|
+
`useViewer()` (`dist/viewer.d.ts`) → `{ memberId, loading }` — the signed-in member currently
|
|
231
|
+
viewing the app. Under an admin's "View as" in the product it returns
|
|
230
232
|
the **view-as target**. It is `null` for a standalone/public visitor, and until the context
|
|
231
233
|
resolves — gate viewer-dependent UI on `loading`.
|
|
232
234
|
|
|
233
235
|
Use it to **personalize**: greet the member, default an assignment picker to them.
|
|
234
236
|
|
|
235
|
-
**Never
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
237
|
+
**Never an authorization fact or a scoping mechanism** —
|
|
238
|
+
[security](./security.md#useviewer-is-display-only).
|
|
239
|
+
|
|
240
|
+
`loading` settles to `false` when the context read fails too: `memberId` is then `null`, and the
|
|
241
|
+
failure is logged to the console.
|
|
242
|
+
|
|
243
|
+
## `useAppContext()` — the host's context, and whether it could be read
|
|
244
|
+
|
|
245
|
+
`useAppContext()` (`dist/viewer.d.ts`) is the one read every hook above is a view of: the
|
|
246
|
+
signed-in member, whether comments are enabled, the workspace's zone and currency, and three
|
|
247
|
+
states — `resolved` once it answered, `settled` once it answered OR failed, and `failure` —
|
|
248
|
+
`{ message, retry }`, `null` unless the read failed. Where it failed every fact is unknown, so a
|
|
249
|
+
surface that dates or counts by them draws the failure rather than guessing the reader's zone.
|
|
250
|
+
|
|
251
|
+
## The workspace's zone at the root
|
|
252
|
+
|
|
253
|
+
An app that draws with `@lotics/ui` mounts the kit's `LoticsLocaleProvider` at the root of
|
|
254
|
+
`src/main.tsx` with `zone` set to `useAppContext().timezone`, and draws nothing until the context
|
|
255
|
+
has `settled` — where it failed, it draws `failure` with its `retry` in place of the app. Then every
|
|
256
|
+
instant the kit prints (`useFormatDate`) and every day it files by or counts to (`useCalendarDay`)
|
|
257
|
+
is the workspace's, never the browser's. The sandbox starter's `src/main.tsx` does exactly this.
|
|
258
|
+
|
|
259
|
+
## `useWorkspaceTimezone()` / `useWorkspaceCurrency()` — what the workspace states
|
|
260
|
+
|
|
261
|
+
Both read the same context as `useViewer()` (`dist/viewer.d.ts`), and both are `undefined`
|
|
262
|
+
until it resolves, where it failed, and from a host that predates them.
|
|
263
|
+
|
|
264
|
+
- **`useWorkspaceTimezone()`** → the workspace's IANA zone (`Asia/Ho_Chi_Minh`). It dates an
|
|
265
|
+
INSTANT — a record's own `__created_at`, a comment's time. A stored `date`/`datetime` cell is a
|
|
266
|
+
wall clock and needs none. The kit reads it through the root locale provider (above) — never
|
|
267
|
+
passed to a date call by hand.
|
|
268
|
+
- **`useWorkspaceCurrency()`** → the workspace's ISO 4217 code (`VND`), what money is counted in
|
|
269
|
+
where the field states no code of its own. Pass it as the money's `currency`: a money format
|
|
270
|
+
given no code prints the kit's home market, which says nothing about this workspace.
|
|
239
271
|
|
|
240
272
|
## Comments: `useComments` / `useCommentCounts`
|
|
241
273
|
|
|
242
274
|
Record comments — member-to-member discussion attached to any record the app reaches
|
|
243
|
-
(`dist/
|
|
275
|
+
(`dist/comments.d.ts`).
|
|
244
276
|
|
|
245
277
|
### Enabling comments
|
|
246
278
|
|
|
247
|
-
Opt in
|
|
279
|
+
Opt in on the app — a JSON app states it in its model; a custom-code app sets it live, as a new
|
|
280
|
+
version:
|
|
248
281
|
|
|
249
282
|
```jsonc
|
|
250
|
-
//
|
|
251
|
-
"capabilities": { "comments": true }
|
|
283
|
+
// update_app
|
|
284
|
+
{ "app_id": "app_…", "capabilities": { "comments": true } }
|
|
252
285
|
```
|
|
253
286
|
|
|
254
287
|
Both hooks expose **`available`** — `true` only when a member is signed in **and** the app declared
|
|
@@ -305,17 +338,15 @@ updateComment, deleteComment, refetch }`.
|
|
|
305
338
|
on it.
|
|
306
339
|
- **The author names itself.** `author` carries `{ name, image? }`, resolved server-side on every
|
|
307
340
|
comment the server returns — list, create and update alike — including one written from another
|
|
308
|
-
app by someone the record references nowhere
|
|
309
|
-
|
|
310
|
-
string. `name` is `null` when the id no longer resolves in the org (a removed member) — render a
|
|
341
|
+
app by someone the record references nowhere, so a thread needs no declared member access.
|
|
342
|
+
`name` is `null` when the id no longer resolves in the org (a removed member) — render a
|
|
311
343
|
fallback (`Timeline` has an `unknownMember` label for exactly this). The one comment with no
|
|
312
344
|
`author` is the optimistic row `createComment` renders locally: the SDK knows the viewer's id and
|
|
313
345
|
not their name, and it will not invent one. The server's row replaces it when the refetch lands.
|
|
314
|
-
- **Freshness:**
|
|
315
|
-
the next focus or explicit `refetch()` — not on its own
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
([data_fetching.md](./data_fetching.md)) — don't read this line as "no push" and build a poll.
|
|
346
|
+
- **Freshness:** cached like a query — re-read on arrival and on focus/reconnect, so another
|
|
347
|
+
viewer's comment appears on the next arrival, focus or explicit `refetch()` — not on its own: the realtime channel carries **record**
|
|
348
|
+
changes, and a comment emits no record event. Your queries DO get pushed
|
|
349
|
+
([data_fetching.md](./data_fetching.md)).
|
|
319
350
|
|
|
320
351
|
### Counts
|
|
321
352
|
|
|
@@ -328,8 +359,8 @@ row is on screen — no request is sent and `counts` is `{}`.
|
|
|
328
359
|
|
|
329
360
|
## Pairing with `@lotics/ui`
|
|
330
361
|
|
|
331
|
-
The SDK ships zero components; these `@lotics/ui` components
|
|
332
|
-
|
|
362
|
+
The SDK ships zero components; these `@lotics/ui` components (a sandbox session's kit, see
|
|
363
|
+
[AGENTS.md](../AGENTS.md)) are shaped to accept SDK values directly:
|
|
333
364
|
|
|
334
365
|
| Value | Component | Feed it |
|
|
335
366
|
| --- | --- | --- |
|
|
@@ -338,8 +369,6 @@ directly (full props: `node_modules/@lotics/ui/AGENTS.md` and its `docs/`):
|
|
|
338
369
|
| A member picker | `MemberSelect` | `members={useMembers().members}` — renders each option as a `MemberChip`; `MEMBER_UNASSIGNED` marks its optional "unassigned" option |
|
|
339
370
|
| A comment thread | `Timeline` + `Composer` (`@lotics/ui/composer`) | `useComments` state; `resolveMember` reads each comment's own `author` (`{ name, image }`) — no roster needed |
|
|
340
371
|
|
|
341
|
-
The SDK never imports `@lotics/ui` — the app owns the (thin) data→UI adapter in each row above.
|
|
342
|
-
|
|
343
372
|
## Where each hook works
|
|
344
373
|
|
|
345
374
|
| Surface | Embedded (signed-in member) | Standalone / public (anonymous) |
|
|
@@ -349,4 +378,5 @@ The SDK never imports `@lotics/ui` — the app owns the (thin) data→UI adapter
|
|
|
349
378
|
| `useFieldOptions` | ✓ | ✓ |
|
|
350
379
|
| `useMembers` | ✓ (same-org + declaration gates) | ✗ errors |
|
|
351
380
|
| `useViewer` | member id (view-as target) | `null` |
|
|
381
|
+
| `useWorkspaceTimezone` / `useWorkspaceCurrency` | ✓ | ✓ |
|
|
352
382
|
| `useComments` / `useCommentCounts` | ✓ when capability declared | ✗ `available: false` |
|