@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/data_fetching.md
CHANGED
|
@@ -1,104 +1,104 @@
|
|
|
1
1
|
# Data fetching
|
|
2
2
|
|
|
3
|
-
How an app reads data: the
|
|
4
|
-
|
|
5
|
-
(`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`, `readCreatedAt`/`readUpdatedAt`), `useFieldOptions`
|
|
6
|
-
for complete select option sets, the data-discipline rules, and the two load-bearing read patterns
|
|
7
|
-
(search-as-you-type, browse/record-picker). Authoring the named queries these hooks invoke — the
|
|
3
|
+
How an app reads data: the read hooks, their caching and pagination contracts, counts, the cell
|
|
4
|
+
readers, the data-discipline rules, and the search and record-picker patterns. Authoring the named queries these hooks invoke — the
|
|
8
5
|
AST, params, filter operators, aggregation, performance contract — is [./queries.md](./queries.md);
|
|
9
6
|
every write goes through a workflow — [./mutations.md](./mutations.md). Exact signatures:
|
|
10
|
-
`dist/
|
|
7
|
+
`dist/queries.d.ts`, `dist/row.d.ts`, `dist/select.d.ts`, `dist/members.d.ts`.
|
|
11
8
|
|
|
12
9
|
The read model in one paragraph: an app never sends a raw query. It invokes a **named query by
|
|
13
|
-
alias** (
|
|
10
|
+
alias** (bound with `set_app_query`) and fills the template's declared `{{params.x}}`
|
|
14
11
|
value holes; the server holds the canonical AST and runs it under the **app owner's** authority
|
|
15
|
-
(
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
([./security.md](./security.md)). The generated `.lotics/app_queries.d.ts` augments `AppQueries`,
|
|
13
|
+
so an unbound alias is a compile-time error and params are typed per the binding. It is written
|
|
14
|
+
from the app's live bindings whenever a sandbox session opens on the app; a project on your own
|
|
15
|
+
machine has none, so every alias is accepted there as a plain string, untyped. Every hook reads through the SDK's own cache, over the host RPC bridge.
|
|
18
16
|
|
|
19
|
-
## Choosing a
|
|
17
|
+
## Choosing a read
|
|
20
18
|
|
|
21
|
-
|
|
|
19
|
+
| Call | Answers | Reach for it when |
|
|
22
20
|
|---|---|---|
|
|
23
|
-
| `useQuery(alias, params?, opts?)` |
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
`
|
|
34
|
-
|
|
35
|
-
|
|
21
|
+
| `useQuery(alias, params?, opts?)` | `rows` up to the server's cap, or the first `limit` | a detail read, a dashboard block, a combobox's top-N — anything that is not a long list |
|
|
22
|
+
| `useQuery(alias, params?, { page: n })` | one numbered page of `n` rows, `total`, `pageCount`, `setPage` | numbered, jumpable pages behind `@lotics/ui` `Pagination` |
|
|
23
|
+
| `useQuery(alias, params?, { more: n })` | accumulated `rows` + `loadMore` | infinite scroll / "load more" feeds |
|
|
24
|
+
| `useQuery(alias, params?, { rows: false, total: true })` | `total` only, no rows (`total: { by: column }` adds `counts`) | a facet chip, a queue badge, an "N awaiting approval" tile — the size of a set you are not listing |
|
|
25
|
+
| `useQueries(calls, opts?)` | one state per `{ alias, params?, filter?, sort?, aggregate?, total?, rows? }`, in order — a `sort` orders that call's rows before the cap cuts them | reads that are DATA — a list known only at render, one read per item, or one count per stage (`rows: false, total: true`, each state's `.total`); the deploy's alias scan reads a list as dynamic, so every alias in it is still declared. With `aggregate` a call answers the groups of its filtered rows instead of the rows ([queries](./queries.md)) |
|
|
26
|
+
| `queryAll(alias, params?, { filter, sort })` | a promise of every row | outside React, for a job that must hold the whole narrowed set (an export): it asks page after page until the server says the set ended, never stopping at the 10,000-row cap. The server's rows as stored — no write of this app is drawn over them |
|
|
27
|
+
|
|
28
|
+
Every hook answers the same `QueryState`: `rows`, `truncated`, `total`, `counts`, `loading`,
|
|
29
|
+
`isValidating`, `error`, `pending`, `refetch`, `page`, `setPage`, `pageCount`, `hasMore`,
|
|
30
|
+
`loadMore`, `loadingMore`. A field the call did not ask for rests at its empty value (`page` 0,
|
|
31
|
+
`total` and `pageCount` undefined, `hasMore` false; `setPage` and `loadMore` do nothing).
|
|
32
|
+
|
|
33
|
+
`limit`, `page` and `more` are mutually exclusive — a call given two throws. A big `limit` is not
|
|
34
|
+
pagination; a `page` read whose pages you concatenate yourself is `more` done by hand; and a
|
|
35
|
+
`page: 1` read whose row you drop is `rows: false, total: true` buying a page nobody renders.
|
|
36
36
|
|
|
37
37
|
### One count per filtered set
|
|
38
38
|
|
|
39
|
-
`
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
`total` is a COUNT over the filtered set — `sort`, `limit`, `page` and `more` do not change it
|
|
40
|
+
(per value of `by`, when given). Two reads counting the same `(alias, params, filter, by)` share
|
|
41
|
+
**one** request, and page clicks and re-sorts reuse it. `page` counts by default:
|
|
42
42
|
|
|
43
43
|
```tsx
|
|
44
44
|
// One count total, not two — same alias, same params, same filter.
|
|
45
|
-
const
|
|
46
|
-
const { total: sameNumber } =
|
|
45
|
+
const orders = useQuery("orders", { q }, { page: 25 }); // orders.total, orders.pageCount
|
|
46
|
+
const { total: sameNumber } = useQuery("orders", { q }, { rows: false, total: true });
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
**A count is not free, and it is not fast.** It re-executes the whole `from` tree and scans the
|
|
50
|
-
entire filtered set, where a page stops at
|
|
51
|
-
|
|
52
|
-
folded into the page response: rows paint the moment they arrive, and the number fills in
|
|
53
|
-
behind them. Fold the two together and every table waits for its own count before showing a
|
|
54
|
-
single row.
|
|
50
|
+
entire filtered set, where a page stops at its size — so it is a request of its own, sent only when
|
|
51
|
+
`total` is asked, and rows paint without waiting for it.
|
|
55
52
|
|
|
56
53
|
Do not reach for it N times over one source. Each declared query re-executes its whole `from`
|
|
57
|
-
tree, so four facets mounted as four
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
54
|
+
tree, so four facets mounted as four counts are four full scans landing in one burst against the
|
|
55
|
+
server's concurrency gate. When the counts differ only by the value of one column the query
|
|
56
|
+
projects — a strip of stages, a facet per option — pass it as `by`: **one scan returns every
|
|
57
|
+
value's count as `counts`, beside `total`**. A row holding several values is counted under each; a
|
|
58
|
+
row holding none is in `total` only.
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
const { total, counts } = useQuery("orders", { q }, { rows: false, total: { by: "stage" } });
|
|
62
|
+
// counts?.["opt_shipped"] — the rows in that stage over the whole set, however few are loaded
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A `useQueries` call's `total` is `true` or absent — it takes no `by`.
|
|
62
66
|
|
|
63
67
|
### Why not hand-roll it
|
|
64
68
|
|
|
65
|
-
A direct `rpc("query", { …, count: true })`
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
moved while everything around it updates — and a count that is quietly wrong costs more than a
|
|
69
|
-
count that costs a request.
|
|
69
|
+
A direct `rpc("query", { …, count: true })` leaves the cache: it does not dedupe with the page
|
|
70
|
+
beside it, does not revalidate on focus, and never hears the post-write re-read, so the number
|
|
71
|
+
goes stale while everything around it updates.
|
|
70
72
|
|
|
71
73
|
And never count client-side from `useQuery(...).rows.length`: rows are capped at 10,000 per
|
|
72
74
|
response, so the number is right in development and wrong in production ([queries](./queries.md)
|
|
73
|
-
§10). `
|
|
74
|
-
|
|
75
|
+
§10). `truncated` says when the cap cut the result — read it wherever a figure is folded from the
|
|
76
|
+
rows (below) — but a count over the whole set is `total`, not a length.
|
|
75
77
|
|
|
76
|
-
##
|
|
78
|
+
## Options
|
|
77
79
|
|
|
78
|
-
|
|
80
|
+
`useQuery` takes these (`QueryOptions`); `useQueries` takes `enabled` and `revalidateOnFocus` for
|
|
81
|
+
all its calls, and each call states its own `filter`, `sort`, `total` and `rows`:
|
|
79
82
|
|
|
80
83
|
| Option | Type | Default | Effect |
|
|
81
84
|
|---|---|---|---|
|
|
82
|
-
| `enabled` | `boolean` | `true` | `false` = no request is sent, `rows` is `[]`, `loading` is `false`. Flip to `true` to fetch. The gate for search-as-you-type and on-demand detail.
|
|
83
|
-
| `revalidateOnFocus` | `boolean` | `true` | `false` = no
|
|
84
|
-
| `sort` | `QuerySortKey[]` | — | Runtime sort, applied server-side **after** the named query, over its output columns: `[{ field_key, order: "asc" \| "desc" }]`. An empty/omitted array leaves the query's own order intact. Part of the
|
|
85
|
-
| `filter` | `QueryFilter` | — | Runtime filter, applied server-side after the named query, over its output columns. A single condition or a recursive `{ node_type: "group", logic: "and" \| "or", children }`. Part of the
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
|
90
|
-
|
|
91
|
-
| `
|
|
92
|
-
| `pageSize: number` | `useInfiniteQuery` | rows per appended page — the options type requires it, though omitting `opts` entirely type-checks and defaults to 30 |
|
|
93
|
-
| `pageSize?: number` | `usePaginatedQuery` | rows per page, default 25 |
|
|
85
|
+
| `enabled` | `boolean` | `true` | `false` = no request is sent, `rows` is `[]`, `loading` is `false`. Flip to `true` to fetch. The gate for search-as-you-type and on-demand detail. Disabling also **hides** previously loaded rows (the cached answer survives; it re-renders instantly when re-enabled). |
|
|
86
|
+
| `revalidateOnFocus` | `boolean` | `true` | `false` = no re-read on window focus / tab return / network reconnect (`refetch()` still works). Keep the default for dashboards; turn off for transient queries (a search bound to an ephemeral term) where a refocus re-run is wasted work and a visible reload. |
|
|
87
|
+
| `sort` | `QuerySortKey[]` | — | Runtime sort, applied server-side **after** the named query, over its output columns: `[{ field_key, order: "asc" \| "desc", blank_position? }]`. An empty/omitted array leaves the query's own order intact. Part of the read's key — changing it re-queries. |
|
|
88
|
+
| `filter` | `QueryFilter` | — | Runtime filter, applied server-side after the named query, over its output columns. A single condition or a recursive `{ node_type: "group", logic: "and" \| "or", children }`. Part of the read's key. Build from per-column UI state with `columnFilterToConditions` (`@lotics/ui/column_filter`). |
|
|
89
|
+
| `limit` | `number` | — | The first `limit` rows only — a **cap**, not pages; omit to read up to the server's row cap. |
|
|
90
|
+
| `page` | `number` | — | Numbered pages of `page` rows: `page` (from 0), `setPage`, `pageCount`, `hasMore`, and `total`, counted by default. |
|
|
91
|
+
| `pageAt` | `{ at, onChange }` | — | With `page`, the page the screen keeps (its address, so a way back or a reload opens on it): `at` is read, `setPage` calls `onChange`, and nothing resets it — send a changed read back to 0 yourself. |
|
|
92
|
+
| `more` | `number` | — | A keyset feed read `more` rows at a time: `loadMore`, `hasMore`, `loadingMore`. |
|
|
93
|
+
| `total` | `boolean \| { by: column }` | `true` with `page`, else `false` | The whole set's count, its own request (`total`); with `by`, a count per value of that column from the same scan (`counts`). |
|
|
94
|
+
| `rows` | `boolean` | `true` | `false` reads no rows — a total alone. |
|
|
94
95
|
|
|
95
96
|
Runtime `sort`/`filter` are **bounded to the named query's output columns**, twice over. At
|
|
96
97
|
compile time, `field_key` on a typed alias is the literal union of the columns the query projects —
|
|
97
|
-
|
|
98
|
+
the generated `.lotics/app_queries.d.ts` carries it as `AppQueryColumns[alias]`, read off the query's
|
|
98
99
|
AST by the same naming rule the server applies — so a key the query does not carry fails
|
|
99
|
-
`npm run typecheck` (and therefore
|
|
100
|
-
|
|
101
|
-
sortable/filterable UI can never widen the app's data exposure even from an untyped call. The full
|
|
100
|
+
`npm run typecheck` (and therefore the build a deploy runs). At request time the server
|
|
101
|
+
rejects an un-projected key again, so even an untyped call cannot widen the app's data exposure. The full
|
|
102
102
|
runtime-refinement contract (which operators are valid per column type, record-link membership
|
|
103
103
|
filtering, and the field-less system conditions — `record_id` works in a runtime filter; `locked` /
|
|
104
104
|
`current_member` are template-only and rejected at the runtime layer) lives in
|
|
@@ -107,9 +107,7 @@ filtering, and the field-less system conditions — `record_id` works in a runti
|
|
|
107
107
|
**A key you compute must be narrowed, not widened.** A register that derives its filter columns
|
|
108
108
|
from a ladder — `` `hs_${step.leaves}` `` — must type `leaves` as the literal union of the stamps
|
|
109
109
|
it can name, so the template literal resolves to members of `AppQueryColumns[alias]`; a `string`
|
|
110
|
-
there is a compile error on a typed alias, and that error is the point.
|
|
111
|
-
type exists to catch: a rung added to the ladder without its column added to the query, which no
|
|
112
|
-
`app dev` session notices until a member scopes to the one project on that ladder. An alias whose
|
|
110
|
+
there is a compile error on a typed alias, and that error is the point. An alias whose
|
|
113
111
|
columns cannot be known from its AST (a bare `from_table`) has no `AppQueryColumns` entry, and its
|
|
114
112
|
key stays `string` — the server's check is then the only one. A helper that builds a filter or
|
|
115
113
|
sort for a typed alias names its keys `ColumnKeyOf<"alias">` — `QueryFilter<ColumnKeyOf<"register">>`,
|
|
@@ -117,16 +115,15 @@ sort for a typed alias names its keys `ColumnKeyOf<"alias">` — `QueryFilter<Co
|
|
|
117
115
|
check reaches the helper that derives the key, not only the hook call that sends it.
|
|
118
116
|
|
|
119
117
|
**The option map is keyed the same way.** `useFieldOptions(alias).fields` is keyed by that alias's
|
|
120
|
-
projected columns
|
|
121
|
-
|
|
122
|
-
optional: [./members_and_options.md](./members_and_options.md).
|
|
118
|
+
projected columns — ask the alias that CARRIES the column
|
|
119
|
+
([./members_and_options.md](./members_and_options.md)).
|
|
123
120
|
|
|
124
121
|
`@lotics/ui`'s `columnFilterToConditions` carries the same parameter (`FilterableColumn<C>` →
|
|
125
122
|
`FilterConditionNode<C>`), so a per-column filter UI built over a typed alias composes without a
|
|
126
123
|
cast.
|
|
127
124
|
|
|
128
125
|
Refinement order is fixed: the runtime **filter narrows** the named query's result, then **sort
|
|
129
|
-
orders** it, then
|
|
126
|
+
orders** it, then **`limit` / `page` / `more` cut** it. The template's own filters/sort/limit run first,
|
|
130
127
|
inside the named query.
|
|
131
128
|
|
|
132
129
|
The field-less `record_id` system condition documented in [./queries.md](./queries.md) (the one
|
|
@@ -135,62 +132,56 @@ write it as `{ node_type: "condition", type: "record_id", operator, value }`.
|
|
|
135
132
|
|
|
136
133
|
## Caching, loading states, and errors
|
|
137
134
|
|
|
138
|
-
- **
|
|
139
|
-
|
|
140
|
-
`
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
135
|
+
- **A read's key** is what it asks: rows by `(alias, params, filter, sort, limit)` (+ the page
|
|
136
|
+
size and index with `page`), a feed by `(alias, params, filter, sort, more)`, a count by
|
|
137
|
+
`(alias, params, filter, by)`. Object *contents* make the key, not references — passing a fresh
|
|
138
|
+
inline `{ status: "open" }` each render is the same key; you never need to memoize params.
|
|
139
|
+
- **The cache survives unmount/remount**, and **arrival is a refresh event**: returning to a screen
|
|
140
|
+
renders the cached rows instantly *and* re-reads them in the background, so a list reflects what
|
|
141
|
+
another screen changed while you were away. Identical concurrent reads share one request.
|
|
144
142
|
Freshness comes from four places — arrival, window focus / tab return / network reconnect
|
|
145
|
-
(`revalidateOnFocus`, default on
|
|
146
|
-
which re-reads
|
|
147
|
-
[./mutations.md](./mutations.md)).
|
|
143
|
+
(`revalidateOnFocus`, default on; a read asked within the last few seconds is not asked again),
|
|
144
|
+
a **realtime push**, and **this app's own successful write**, which re-reads the mounted queries
|
|
145
|
+
over the tables its body names without being asked (see [./mutations.md](./mutations.md)).
|
|
146
|
+
`refetch()` covers what none of those can know about.
|
|
147
|
+
- **This app's own writes show from the press.** Every read draws each write this app has made
|
|
148
|
+
over its rows before the server answers — cells, membership, new rows, `total`, `counts` and
|
|
149
|
+
groups — and says **`pending`** while what one changes is not all drawn. Nothing to wire: [./mutations.md](./mutations.md)
|
|
150
|
+
§ A write shows from the press.
|
|
148
151
|
- **Realtime push keeps an already-open screen current.** When a table one of your queries reads
|
|
149
152
|
changes — another member, a workflow, the chat agent, or an external agent writing over the CLI or
|
|
150
153
|
MCP — that query refetches within about a second. Nothing to wire: it follows from the query's own
|
|
151
|
-
declaration
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
alone: a filter derived from what is on screen re-keys on every keystroke, and gating on
|
|
168
|
-
`loading` swaps the list for a spinner each time — which unmounts everything the rows contained,
|
|
169
|
-
images included. **`isValidating`** is `true`
|
|
170
|
-
whenever any request is in flight — use it for a subtle refresh indicator.
|
|
154
|
+
declaration, and only the aliases whose tables actually moved refetch. It carries RECORD
|
|
155
|
+
changes only (a schema edit does not push), and a **standalone/public app has no host**, so
|
|
156
|
+
those apps keep focus/reconnect freshness alone. Push is absent whenever the channel cannot
|
|
157
|
+
connect, so never treat it as a replacement for pull.
|
|
158
|
+
- **A `more` feed does not re-read on arrival** — the one exception. A warm feed re-mounted
|
|
159
|
+
reads nothing, because re-reading it would reshuffle the feed under a reader scrolling it; a cold
|
|
160
|
+
key still reads, and focus / reconnect / a realtime push / this app's own write still refresh.
|
|
161
|
+
**A feed that must be fresh on arrival calls `refetch()`.**
|
|
162
|
+
- **`loading`** is `true` only while a key's *first* answer is awaited. It stays `false` during a
|
|
163
|
+
re-read of a key that already has an answer, so consumers never blank loaded data to a spinner
|
|
164
|
+
on refetch. A key *change* (new params, sort, filter, or page) awaits a first answer: `loading`
|
|
165
|
+
goes `true` again unless that key is already cached — but the previous key's rows STAY on screen
|
|
166
|
+
while it resolves, for every read, so `rows` never empties mid-flight. So skeletons gate on
|
|
167
|
+
`loading && rows.length === 0`, never `loading` alone, or every re-key swaps the list for a
|
|
168
|
+
spinner. **`isValidating`** is `true` whenever any request of the read is in flight — use it for
|
|
169
|
+
a subtle refresh indicator.
|
|
171
170
|
- **`error`** is a `string | null`. A failed query surfaces immediately — there is **no automatic
|
|
172
171
|
retry** (no retry loop that masks the error). The last successful rows for the same key stay
|
|
173
|
-
rendered. The next focus
|
|
174
|
-
- **Never derive a FIGURE from `rows` without gating on `error`.**
|
|
175
|
-
and `[]` is indistinguishable from a genuinely empty result — so `rows.reduce(…)`
|
|
176
|
-
`rows.length` returns `0`, and a screen states a confident number that no query
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
meter, a badge — anything a reader would act on — renders a dash and the failure, never a
|
|
182
|
-
number, while `error !== null`. Note a key CHANGE resets this: a new `params`/`filter`/`sort`
|
|
183
|
-
is a fresh key with no prior rows, so the "last successful rows stay rendered" behaviour above
|
|
184
|
-
does not save you.
|
|
172
|
+
rendered. The next focus re-read or an explicit `refetch()` re-runs it.
|
|
173
|
+
- **Never derive a FIGURE from `rows` without gating on `error`.** A first read that fails leaves
|
|
174
|
+
`rows` at `[]`, and `[]` is indistinguishable from a genuinely empty result — so `rows.reduce(…)`
|
|
175
|
+
returns `0`, `rows.length` returns `0`, and a screen states a confident number that no query
|
|
176
|
+
answered — a list that renders nothing looks wrong, a total that reads `0` looks *fine*. A new
|
|
177
|
+
key that fails keeps the previous key's rows instead — a number for a question nobody is asking
|
|
178
|
+
any more. A count, a sum, a "N of M", a progress meter, a badge — anything a reader would act
|
|
179
|
+
on — renders a dash and the failure, never a number, while `error !== null`.
|
|
185
180
|
- **Never state an ABSENCE from `rows` without gating on `loading`.** The sibling of the rule
|
|
186
181
|
above, and it bites earlier: while the first request is in flight `rows` is `[]`, so
|
|
187
182
|
`rows.find(…)` returns nothing and any code shaped `if (!found) → "there is no X"` prints a
|
|
188
|
-
confident denial of something that is merely not here yet
|
|
189
|
-
|
|
190
|
-
reader opening the surface cold sees the half-second where every requirement reads as missing.
|
|
191
|
-
A record drawer did this to five document rows at once — each rendering a red "missing" badge
|
|
192
|
-
and a blocking callout — so the loudest thing on the screen was, briefly, entirely false.
|
|
193
|
-
Gate the derivation, not the display: compute nothing while `loading`, and reserve the space with
|
|
183
|
+
confident denial of something that is merely not here yet — and only a reader opening the
|
|
184
|
+
surface cold sees it. Gate the derivation, not the display: compute nothing while `loading`, and reserve the space with
|
|
194
185
|
a `Skeleton` so the layout does not jump when the answer arrives.
|
|
195
186
|
|
|
196
187
|
Watch for the second source of a legitimate empty: `enabled: false` never sends a request, so
|
|
@@ -199,21 +190,16 @@ write it as `{ node_type: "condition", type: "record_id", operator, value }`.
|
|
|
199
190
|
fact is that nothing was asked. Read the gate itself — `loading || vehicleId == null` — and say
|
|
200
191
|
what is actually missing, which is the parent, not the children.
|
|
201
192
|
|
|
202
|
-
- **`refetch()`** re-runs the
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
- **Ambient-chat mutations refetch automatically
|
|
208
|
-
[./ai.md](./ai.md#
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
the app its rendered rows may be stale. Inert standalone and in mock mode.
|
|
213
|
-
- A design-time fixture registered via `mount(<App />, { fixture })` plus the `?__mock=1` URL flag
|
|
214
|
-
short-circuits all three hooks (rows come from the fixture, no request, `loading` stays `false`).
|
|
215
|
-
The same fixture mocks `useWorkflow`, so a screen's in-flight / done / error states are
|
|
216
|
-
reviewable without running anything — see [./runtime.md](./runtime.md).
|
|
193
|
+
- **`refetch()`** re-runs every request of the read — its rows, every page a feed holds, its
|
|
194
|
+
count — for what no write of this app can have told you
|
|
195
|
+
([./mutations.md](./mutations.md#refetch-after-a-mutation)).
|
|
196
|
+
- **`useQueries` is one read.** Its calls share one key, one `loading` and one `refetch`: changing
|
|
197
|
+
any call re-reads them all. A call that fails carries its own `error`; the others keep their rows.
|
|
198
|
+
- **Ambient-chat mutations refetch automatically** — every mounted query re-reads when the
|
|
199
|
+
member's chat agent finishes a turn that mutated records ([./ai.md](./ai.md#query-freshness--the-mutation-companion)).
|
|
200
|
+
- A design-time fixture (`mount(…, { fixture })` + `?__mock=1`) answers every read through the same
|
|
201
|
+
cache; a `useQuery` read shows the fixture's rows from the first render —
|
|
202
|
+
[./runtime.md](./runtime.md#the-mock-harness-optionsfixture--__mock1).
|
|
217
203
|
|
|
218
204
|
### Error messages you will actually see
|
|
219
205
|
|
|
@@ -224,20 +210,17 @@ write it as `{ node_type: "condition", type: "record_id", operator, value }`.
|
|
|
224
210
|
| `query execution failed` | The query failed at the database. Deliberately generic — database internals are never sent to the client. | The app author diagnoses from the platform's server logs; the app surfaces the message. |
|
|
225
211
|
| A specific validation message | e.g. an un-projected `field_key` in runtime `sort`/`filter`, invalid params, an unknown alias. An unknown column names every column the query DOES project, so the valid set is in the message. | Fix the call site — these are contract violations, not transient. |
|
|
226
212
|
|
|
227
|
-
**Which message you get is decided by the ENVELOPE, not the status
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
a fixed sentence derived from the status instead, so a raw HTML body can never become your error
|
|
233
|
-
copy. Practically: for a 5xx you are now showing a sentence the PLATFORM wrote, so treat `error` as
|
|
234
|
-
copy to display, never as a string to branch on.
|
|
213
|
+
**Which message you get is decided by the ENVELOPE, not the status.** A failure whose JSON body
|
|
214
|
+
carries a `code` is one the API authored, so its `message` is what `error` holds — a 5xx included,
|
|
215
|
+
which is how the shed sentence above arrives. Anything else — a gateway's HTML page, a proxy's JSON
|
|
216
|
+
with no `code`, a body with no `message` — becomes a fixed sentence derived from the status. Treat
|
|
217
|
+
`error` as copy to display, never as a string to branch on.
|
|
235
218
|
|
|
236
219
|
## Pagination — two models
|
|
237
220
|
|
|
238
221
|
Reads paginate two different ways, and the difference is load-bearing:
|
|
239
222
|
|
|
240
|
-
- **`
|
|
223
|
+
- **`more` uses keyset (seek) pagination.** Each page seeks past the previous page's
|
|
241
224
|
opaque cursor instead of counting an offset, so a deep scroll stays O(page) and — the reason it
|
|
242
225
|
matters — **never skips or duplicates a row as the set shifts** under concurrent inserts/deletes.
|
|
243
226
|
The cursor is internal: the hook sends it and reads the next one back; you never see it. The
|
|
@@ -245,22 +228,21 @@ Reads paginate two different ways, and the difference is load-bearing:
|
|
|
245
228
|
column it projects, else the record id) and falls back to offset transparently for a multi-key
|
|
246
229
|
sort it can't seek. So an infinite feed needs **no
|
|
247
230
|
dedupe** — key rows by `__source_record_id` for stable React keys, not to guard against repeats.
|
|
248
|
-
- **`
|
|
231
|
+
- **`page`, `limit`, `queryAll` and manual `rpc("query", { limit, offset })` use offset
|
|
249
232
|
pagination** — count `offset` rows, skip them, return the next page. Two consequences a keyset
|
|
250
233
|
scroll doesn't have: deep pages cost more, and pages shift under concurrent writes.
|
|
251
234
|
|
|
252
235
|
Shared by both:
|
|
253
236
|
|
|
254
237
|
- **Row cap: 10,000.** No single query response returns more than 10,000 rows; a larger `limit`
|
|
255
|
-
(or
|
|
256
|
-
|
|
257
|
-
say so instead of quietly under-reporting.
|
|
238
|
+
(or none) is clamped to it. To read a bigger result set, page through it. `truncated: true`
|
|
239
|
+
says the cap cut the result, so a screen can say so instead of quietly under-reporting.
|
|
258
240
|
- **Always give a paginated query a deterministic order.** With no `ORDER BY` (neither in the
|
|
259
241
|
template nor runtime `sort`), row order is unspecified — offset pages may overlap or skip rows
|
|
260
242
|
even without concurrent writes, and keyset falls back to the record-id order. Sort by a stable
|
|
261
243
|
column (unique where possible).
|
|
262
244
|
|
|
263
|
-
Offset-only (
|
|
245
|
+
Offset-only (`page`, `limit`, and manual `rpc`):
|
|
264
246
|
|
|
265
247
|
- **Deep pages cost more.** `offset: n` makes the server produce and discard `n` rows before the
|
|
266
248
|
page — page 200 is materially slower than page 2. Prefer narrowing filters over deep paging.
|
|
@@ -268,27 +250,21 @@ Offset-only (the `usePaginatedQuery` numbered pages and the `useQuery` / manual
|
|
|
268
250
|
shifts every later offset — a row can appear on two consecutive pages or fall between them. If
|
|
269
251
|
you concatenate offset pages yourself, key rows by `__source_record_id` (never by array index)
|
|
270
252
|
and dedupe if the list must be exact.
|
|
271
|
-
-
|
|
272
|
-
|
|
273
|
-
writes `total` can briefly disagree with what paging finds.
|
|
274
|
-
- **A count is a full re-execution of the named query, not a cheap lookup.** It costs what the
|
|
275
|
-
query costs and grows with the filtered set — on a union/unpivot pipeline that is a whole extra
|
|
276
|
-
scan. When the screen already renders that same number from an aggregate, hand it to
|
|
277
|
-
`usePaginatedQuery` as `total` rather than paying for it twice (below).
|
|
253
|
+
- **The count and the page are separate requests**, so under concurrent writes `total` can briefly
|
|
254
|
+
disagree with what paging finds.
|
|
278
255
|
|
|
279
|
-
###
|
|
256
|
+
### Rows
|
|
280
257
|
|
|
281
258
|
```tsx
|
|
282
259
|
const { rows, loading, error, refetch } = useQuery("openOrders", { status: "open" });
|
|
283
260
|
```
|
|
284
261
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
`Array<Record<string, unknown>>` — decode cells with the readers below.
|
|
262
|
+
`params` is required when the alias declares params, optional otherwise. `rows` is typed from the
|
|
263
|
+
alias's projection ([below](#decoding-query-cells)) — decode cells with the readers.
|
|
288
264
|
|
|
289
265
|
**`truncated` — the one thing a short result cannot tell you itself.** It is `true` when more rows
|
|
290
266
|
matched than arrived, so `rows` is short and anything folded off it (a grouped period × site table,
|
|
291
|
-
a per-carrier ratio, a KPI strip) is under-reported. The
|
|
267
|
+
a per-carrier ratio, a KPI strip) is under-reported. The cap is your `limit` when you set one
|
|
292
268
|
and the server's 10,000-row cap otherwise. Nothing else says so — there is no error, no empty state
|
|
293
269
|
and no visual tell — so read it wherever the screen computes a figure from the whole result, and
|
|
294
270
|
say the report is short rather than printing a number that is quietly wrong:
|
|
@@ -300,74 +276,50 @@ return <>{truncated && <Callout tone="warning">Số liệu chưa đủ — thu h
|
|
|
300
276
|
<Stat value={total} /></>;
|
|
301
277
|
```
|
|
302
278
|
|
|
303
|
-
### `
|
|
279
|
+
### A feed — `more`
|
|
304
280
|
|
|
305
281
|
```tsx
|
|
306
|
-
const { rows, loadMore, hasMore, loadingMore } =
|
|
282
|
+
const { rows, loadMore, hasMore, loadingMore } = useQuery("feed", {}, { more: 30 });
|
|
307
283
|
```
|
|
308
284
|
|
|
309
|
-
The first render
|
|
285
|
+
The first render reads one page; `loadMore()` appends the next, accumulating into `rows`. Paging is
|
|
310
286
|
**keyset (seek)** (the pagination models above).
|
|
311
287
|
|
|
312
|
-
- `hasMore` is `true` while the last page came back
|
|
313
|
-
|
|
314
|
-
`false`.
|
|
288
|
+
- `hasMore` is `true` while the last page came back with a next cursor — so a set that is an exact
|
|
289
|
+
multiple of `more` costs one final short read before `hasMore` turns `false`.
|
|
315
290
|
- `loadMore()` is a no-op while `loadingMore` is `true` or when `hasMore` is `false` — safe to wire
|
|
316
291
|
directly to a scroll sentinel.
|
|
317
|
-
- Changing any part of the result-set identity (`params`, `sort`, `filter`, `
|
|
318
|
-
|
|
319
|
-
-
|
|
320
|
-
|
|
321
|
-
page loads shows old and new side by side) — but keyset guarantees no row is skipped or repeated.
|
|
292
|
+
- Changing any part of the result-set identity (`params`, `sort`, `filter`, `more`) is a new feed.
|
|
293
|
+
- `refetch()` re-reads every loaded page. Appending a page does **not** re-read earlier pages,
|
|
294
|
+
so a long-lived list still mixes page snapshots taken at different times — but keyset guarantees
|
|
295
|
+
no row is skipped or repeated, and this app's own writes are drawn over every page.
|
|
322
296
|
- **Standalone (public) apps:** `loadMore()` can't advance past page one — the cursor is dropped in
|
|
323
297
|
transit (see [Standalone transport](#standalone-public-transport)).
|
|
324
298
|
|
|
325
|
-
### `
|
|
299
|
+
### Numbered pages — `page`
|
|
326
300
|
|
|
327
301
|
```tsx
|
|
328
|
-
const { rows, total,
|
|
329
|
-
|
|
302
|
+
const { rows, total, pageCount, page, setPage, hasMore } =
|
|
303
|
+
useQuery("orders", { q }, { page: 25, sort, filter });
|
|
330
304
|
```
|
|
331
305
|
|
|
332
|
-
The page
|
|
333
|
-
|
|
334
|
-
you supply the total yourself.
|
|
306
|
+
The page model behind a numbered table (pairs with `@lotics/ui` `Pagination`). It owns the page
|
|
307
|
+
index and reads two things: the current page, and a count over the filtered set.
|
|
335
308
|
|
|
336
|
-
- **Result-set identity is `(params, filter)`.** Changing
|
|
337
|
-
Changing
|
|
338
|
-
page number of the new order (re-sorting never recounts, and never jumps you back to page 0).
|
|
339
|
-
- The count is keyed on `(alias, params, filter)` — page clicks and re-sorts reuse it.
|
|
309
|
+
- **Result-set identity is `(params, filter, sort)`.** Changing any of them resets to page 0.
|
|
310
|
+
Changing `sort` does not recount: the count does not depend on the order.
|
|
340
311
|
- `page` is 0-indexed. `setPage` clamps at 0 but has **no upper clamp** — a page past the end
|
|
341
312
|
returns empty rows.
|
|
342
|
-
- `total` and `
|
|
343
|
-
defensively. `
|
|
313
|
+
- `total` and `pageCount` are `undefined` until the count answers — render pagination controls
|
|
314
|
+
defensively. `pageCount` is `max(1, ceil(total / page size))`. Until the count lands, `hasMore`
|
|
344
315
|
falls back to "the current page came back full".
|
|
345
|
-
- A page change **keeps the previous rows on screen** while the next page loads
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
- **`total`
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
```tsx
|
|
353
|
-
const summary = useQuery("orderStats", params); // one ungrouped aggregate
|
|
354
|
-
const rows = usePaginatedQuery("orders", params, {
|
|
355
|
-
pageSize: 100,
|
|
356
|
-
total: (summary.rows[0]?.row_count as number | undefined) ?? null,
|
|
357
|
-
});
|
|
358
|
-
```
|
|
359
|
-
|
|
360
|
-
**Three states, all of them in the type**: omitted → the hook counts; `null` → yours, not
|
|
361
|
-
resolved yet; a number → yours, use it. The `?? null` is what makes it work — the natural source
|
|
362
|
-
is an aggregate still loading on the first render, and without a way to say "mine, pending" the
|
|
363
|
-
hook would fire the very count it exists to avoid and discard it a moment later. Until the number
|
|
364
|
-
arrives the hook reports `total: undefined` and `hasMore` falls back to "the page came back
|
|
365
|
-
full", exactly as while a count is in flight.
|
|
366
|
-
|
|
367
|
-
`refetch()` does not refresh a supplied total — it is yours, so refresh its source.
|
|
368
|
-
|
|
369
|
-
The number must count the **same set** the query returns — `totalPages` and `hasMore` derive
|
|
370
|
-
from it and the hook cannot tell that it doesn't.
|
|
316
|
+
- A page change **keeps the previous rows on screen** while the next page loads — gate any skeleton
|
|
317
|
+
on `loading && rows.length === 0`, never on `loading` alone, or every page click collapses the
|
|
318
|
+
table.
|
|
319
|
+
- **`total: false` sends no count.** Reach for it when "of N" earns less than a second full
|
|
320
|
+
execution of the query; `pageCount` then stays `undefined` and `hasMore` reads "the page came
|
|
321
|
+
back full". A summary showing "N items" beside the table reads this read's `total` rather than
|
|
322
|
+
counting again.
|
|
371
323
|
|
|
372
324
|
## Standalone (public) transport
|
|
373
325
|
|
|
@@ -376,20 +328,18 @@ An app served standalone on its own origin (a public share with no Lotics host
|
|
|
376
328
|
`alias`, `params`, `limit`, and `offset`. It **silently drops** `sort`, `filter`, `count`, and the
|
|
377
329
|
keyset `cursor`. Design a public app around this:
|
|
378
330
|
|
|
379
|
-
- **Runtime `sort` / `filter` are ignored** — every
|
|
331
|
+
- **Runtime `sort` / `filter` are ignored** — every read's `sort` / `filter` is dropped,
|
|
380
332
|
so a standalone app can't order or narrow a query at the call site. Bake ordering and scoping
|
|
381
333
|
into the query **template** (or drive them through declared `params`), not the refinement options.
|
|
382
|
-
- **`
|
|
383
|
-
"Page 1 of N" control has no N
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
The embedded product host and the `lotics app dev` forwarder pass all of these through — this
|
|
392
|
-
caveat is standalone-only.
|
|
334
|
+
- **`total` gets no count** — the `count` request is dropped in transit, so a numbered
|
|
335
|
+
"Page 1 of N" control has no N: `pageCount` never lands and `hasMore` falls back to "the current
|
|
336
|
+
page came back full". A figure the app needs comes from an aggregate query, which survives the
|
|
337
|
+
thin transport fine.
|
|
338
|
+
- **A `more` feed never advances past the first page** — with the cursor dropped, no
|
|
339
|
+
`next_cursor` comes back, so `hasMore` is `false` after page one. For a standalone browse, size
|
|
340
|
+
the template `limit` (or a `params`-driven page) to return the whole set in one read.
|
|
341
|
+
|
|
342
|
+
The embedded product host passes all of these through — this caveat is standalone-only.
|
|
393
343
|
|
|
394
344
|
## Decoding query cells
|
|
395
345
|
|
|
@@ -409,7 +359,7 @@ Wire shapes per output column type:
|
|
|
409
359
|
| `select` | `Array<{ key, label }>` — one entry per selected option |
|
|
410
360
|
| `select_member` | `Array<{ id, name, email?, image?, groups? }>` — the last three only for authenticated viewers of the app's own org; `image` presigned |
|
|
411
361
|
| `select_record_link` | `Array<{ id, display }>` — target record id + its display text |
|
|
412
|
-
| `files` | `Array<{ id, filename, mime_type, url, thumbnail_url?, size?, created_at? }>` — presigned |
|
|
362
|
+
| `files` | `Array<{ id, filename, mime_type, url, thumbnail_url?, size?, created_at?, document_template_id? }>` — presigned |
|
|
413
363
|
|
|
414
364
|
Row-level (non-grouped) queries additionally carry system columns: `__source_record_id` /
|
|
415
365
|
`__source_table_id` / `__source_locked` (source addressing — pass `__source_record_id` to
|
|
@@ -417,15 +367,11 @@ workflows), `__created_at` / `__updated_at` (record timestamps), and per-project
|
|
|
417
367
|
metadata. A grouped query collapses rows and emits none of these. Details:
|
|
418
368
|
[./queries.md](./queries.md).
|
|
419
369
|
|
|
420
|
-
A row is typed from the alias's own projection
|
|
421
|
-
|
|
422
|
-
column
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
column's TYPE is the field's, which the manifest does not carry, so the readers are the narrowing),
|
|
426
|
-
while `__source_record_id` / `__source_table_id` are typed `string | undefined` — reachable without
|
|
427
|
-
a cast, but only after narrowing, because "a grouped query emits none of these" is a fact the type
|
|
428
|
-
states rather than one you have to remember. Write the row type down as `RowOf<"alias">`; an alias
|
|
370
|
+
A row is typed from the alias's own projection (`AppQueryColumns` in `.lotics/app_queries.d.ts`),
|
|
371
|
+
so a misspelt column is a `tsc` error rather than a blank cell — the server answers an unprojected
|
|
372
|
+
column with `undefined`, which every reader draws as its empty value. The values stay `unknown`
|
|
373
|
+
(the readers are the narrowing), and `__source_record_id` / `__source_table_id` are
|
|
374
|
+
`string | undefined`, narrowed before use. Write the row type down as `RowOf<"alias">`; an alias
|
|
429
375
|
whose columns cannot be read off its AST keeps the open `QueryRow`, where any column compiles and
|
|
430
376
|
the server's answer is the only check — the rule the filter keys follow too.
|
|
431
377
|
|
|
@@ -478,73 +424,47 @@ a partial as its period start.
|
|
|
478
424
|
`label === key` (the stale state is explicit, never hidden). Render with `@lotics/ui` `Status`
|
|
479
425
|
— see [./members_and_options.md](./members_and_options.md).
|
|
480
426
|
|
|
481
|
-
**`readMembers`.**
|
|
482
|
-
member)
|
|
483
|
-
own org; anonymous public visitors and cross-org viewers get name only (no PII exposure). Cells
|
|
484
|
-
never carry avatar images — the avatar lives on the `useMembers` roster
|
|
485
|
-
([./members_and_options.md](./members_and_options.md)).
|
|
427
|
+
**`readMembers`.** The member shape, its gates and its `null` name:
|
|
428
|
+
[./members_and_options.md](./members_and_options.md#member-cells-readmembers).
|
|
486
429
|
|
|
487
430
|
**`readLinks` / `row.link`.** `display` is the linked record's primary-field text — render it;
|
|
488
431
|
use `id` to correlate, filter (link `has_any_of`), or fetch detail. `row.link` is the first of
|
|
489
432
|
`readLinks` — on a multi-link field use `readLinks`. An entry carrying an `id` and **no
|
|
490
|
-
`display` key** is not a link and is skipped
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
anonymous public-app visitors; re-querying refreshes the TTL naturally. Entries the server didn't
|
|
499
|
-
presign (no `url`) are skipped, so you never render an unservable file. `size` (bytes) and
|
|
500
|
-
`created_at` (ISO upload timestamp) are resolved at serving time; `size` is absent for older files
|
|
501
|
-
not yet backfilled — render it only when present, and surface both as dedicated sortable columns
|
|
502
|
-
over the raw values (a formatted "8.4 MB" string sorts wrong). **A LOOKUP of a files field arrives
|
|
503
|
-
one level deeper**, exactly as a looked-up link does — one entry per linked record, each entry that
|
|
504
|
-
record's whole files cell — and `readFiles` opens it, in link order and with a file two linked rows
|
|
505
|
-
both carry named once, so a looked-up picture needs no unwrapping of its own. Previewing and
|
|
506
|
-
uploading files: [./files.md](./files.md).
|
|
507
|
-
|
|
508
|
-
**Warning — the presign ceiling:** signing file URLs is per-entry server work, so a response is
|
|
509
|
-
capped at **2,000 file entries** (server default). A query over the cap **fails** with an error
|
|
510
|
-
naming the count and the remedies — it never silently returns unsigned cells. Project `files`
|
|
511
|
-
columns **only in the query that renders them**, narrow with a filter, or paginate. A bare
|
|
512
|
-
`from_table` with no projection ships every column — including `files` — and is how you hit this.
|
|
433
|
+
`display` key** is not a link and is skipped (a member cell has that shape — read it with
|
|
434
|
+
`readMembers`). **A LOOKUP of a link field arrives one level deeper** — one entry per linked
|
|
435
|
+
record, each that record's whole link cell — and both readers open it, in order, naming a record
|
|
436
|
+
two linked rows point at once.
|
|
437
|
+
|
|
438
|
+
**`readFiles`.** Presigned 24-hour URLs, lookups opened like `readLinks`, and the 2,000-entry
|
|
439
|
+
presign ceiling that fails a file-heavy query whole:
|
|
440
|
+
[./files.md](./files.md#file-cells-in-query-results--readfiles-and-appfile).
|
|
513
441
|
|
|
514
442
|
**`readLocked`.** Takes the whole row object, not a cell. A locked record rejects direct writes —
|
|
515
443
|
show the locked state and route edits through the locked-change request flow
|
|
516
444
|
([./mutations.md](./mutations.md)).
|
|
517
445
|
|
|
518
|
-
**`readCreatedAt` / `readUpdatedAt`.** Take the whole row object, not a cell.
|
|
519
|
-
no date field of its own a chronological anchor — a kardex/statement date, an "as of" caption —
|
|
520
|
-
without adding a field or a stamping workflow. Unlike a `date`/`datetime` CELL (a timezone-less
|
|
446
|
+
**`readCreatedAt` / `readUpdatedAt`.** Take the whole row object, not a cell. Unlike a `date`/`datetime` CELL (a timezone-less
|
|
521
447
|
workspace wall-clock at minute precision), these are true **instants**: ISO-8601 with an offset on
|
|
522
|
-
the wire, parsed as instants,
|
|
448
|
+
the wire, parsed as instants, dated in the workspace's zone — `useWorkspaceTimezone()`, which
|
|
449
|
+
the app's root hands the kit's locale ([members & options](./members_and_options.md)). `null` on a grouped row, which has no
|
|
523
450
|
originating record.
|
|
524
451
|
|
|
525
452
|
## Complete select option sets
|
|
526
453
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
(row-derived sets are incomplete until every page loads and carry no colors). Its full contract
|
|
531
|
-
(return shape, `byKey`, `opts.enabled`, freshness), rendering the values (`Status`,
|
|
532
|
-
`MemberChip`, `MemberSelect`), and the member roster (`useMembers`) live in
|
|
533
|
-
[./members_and_options.md](./members_and_options.md).
|
|
454
|
+
For the **complete** option list of a select column, with colors, use **`useFieldOptions`** —
|
|
455
|
+
never options derived from loaded rows, which are incomplete until every page loads
|
|
456
|
+
([./members_and_options.md](./members_and_options.md)).
|
|
534
457
|
|
|
535
458
|
## Data discipline
|
|
536
459
|
|
|
537
|
-
These rules prevent whole bug classes; every app follows them.
|
|
538
|
-
|
|
539
460
|
- **Server data is never copied into `useState`.** `useQuery` / `useWorkflow` results are the
|
|
540
|
-
source of truth — derive everything else with `useMemo`.
|
|
541
|
-
values.
|
|
461
|
+
source of truth — derive everything else with `useMemo`.
|
|
542
462
|
- **Prefer derivation + callbacks over `useEffect`.** A derived value is `useMemo`; "state A
|
|
543
463
|
changed → set state B" is both set in the one triggering callback. `useEffect` is for genuine
|
|
544
464
|
external subscriptions (timers, DOM listeners, storage) — fetching is `useQuery`, not an effect.
|
|
545
465
|
- **No layout shift on load or paging — the UX bar, not a nicety.** (1) First load renders
|
|
546
466
|
`Skeleton` placeholders that mirror the final layout, not a bare spinner; (2) a page change keeps
|
|
547
|
-
the previous rows (
|
|
467
|
+
the previous rows (every read does this for you) — gate the skeleton on
|
|
548
468
|
`loading && rows.length === 0`, never `loading` alone; (3) a view↔edit toggle reserves the
|
|
549
469
|
input's height so pressing Edit never reflows.
|
|
550
470
|
- **Diff before update.** An edit form snapshots the record at load and sends only the CHANGED
|
|
@@ -565,23 +485,20 @@ pieces. Compose these — don't hand-roll search:
|
|
|
565
485
|
- **A parameterized `search` query** — a `from_table` with `search: "{{params.q}}"` over the
|
|
566
486
|
maintained search document: **diacritics- and case-insensitive**, trigram-indexed, and AND-ed
|
|
567
487
|
with the template's `filter` (search within a scope). The full `search` contract is in
|
|
568
|
-
[./queries.md](./queries.md).
|
|
569
|
-
|
|
570
|
-
forces a full
|
|
571
|
-
OR-group.**
|
|
488
|
+
[./queries.md](./queries.md). To bound exactly which fields match, AND an OR-group of per-field
|
|
489
|
+
`contains` with the same `search`: alone, `contains` is **unindexed**, so a zero-match keystroke
|
|
490
|
+
forces a full-table scan. **Search-as-you-type uses `search`, never a `contains`
|
|
491
|
+
OR-group alone.**
|
|
572
492
|
- **`useQuery(alias, { q }, { enabled })`** — gate on a non-empty term: an empty term applies no
|
|
573
493
|
search constraint and would dump the table on first paint. `enabled` makes "nothing loads until
|
|
574
494
|
you type" true. Add `revalidateOnFocus: false` — re-running an ephemeral search on refocus is
|
|
575
495
|
wasted work.
|
|
576
496
|
- **Debounce the term — `enabled` is not a substitute.** `enabled` decides *whether* to ask, not
|
|
577
497
|
*how often*: wire an input's own state into `params` and every keystroke past the first is a
|
|
578
|
-
fresh cache key and a fresh request
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
two values are genuinely different — what is being typed, and what the rows on screen answer —
|
|
583
|
-
and anything reporting on the results (an empty state, a count, a "showing N for X" line) reads
|
|
584
|
-
the committed one, or it describes a set the server was never asked for. `Combobox` already
|
|
498
|
+
fresh cache key and a fresh request (and a re-count, where `total` is asked). Keep the input's
|
|
499
|
+
value in one state and debounce the COMMIT into a second (`useDebouncedCallback` from
|
|
500
|
+
`@lotics/ui/use_debounced_callback`, ~250 ms); anything reporting on the results (an empty state,
|
|
501
|
+
a count) reads the committed one. `Combobox` already
|
|
585
502
|
debounces its own `onSearchChange`; this is for a search box you built yourself.
|
|
586
503
|
- **`useRecents(key, { max })`** — persist the picked option locally; pass its list as
|
|
587
504
|
`recentOptions` ([./navigation_and_state.md](./navigation_and_state.md)).
|
|
@@ -615,14 +532,16 @@ they can browse (numbered pages), search, sort, and filter:
|
|
|
615
532
|
pills (`FilterChip column=`) + `Table` + `Pagination`. It needs both `@lotics/ui` and the SDK (which is
|
|
616
533
|
UI-free), so it lives in the app (e.g. a `record_picker.tsx`) — reuse it for any table by passing
|
|
617
534
|
a different `alias` + column config.
|
|
618
|
-
- **`
|
|
619
|
-
`
|
|
535
|
+
- **`useQuery(alias, params, { page: n, sort, filter })`** drives it: the page of rows, the
|
|
536
|
+
`total` and `pageCount` for "Page 1 of N" (counted by default with `page`), and `setPage`.
|
|
620
537
|
- **Filter pills → runtime `filter`** via `columnFilterToConditions` (`@lotics/ui/column_filter`);
|
|
621
538
|
**column-header sort → runtime `sort`** by mapping the table's `{ key, order }` to
|
|
622
539
|
`[{ field_key: key, order }]`. Both are server-bounded to the query's output columns — the picker
|
|
623
540
|
can't widen exposure ([./queries.md](./queries.md)).
|
|
624
541
|
- **Browse needs an unbounded query.** A `limit` baked into the query template caps the *total*
|
|
625
|
-
browsable set — paging then pages within that cap. Drop the template limit and let `
|
|
542
|
+
browsable set — paging then pages within that cap. Drop the template limit and let `page`
|
|
626
543
|
drive (the server row cap still bounds any single page).
|
|
627
544
|
- **Select filter options come from `useFieldOptions`** — the complete set, not options derived
|
|
628
|
-
from loaded rows
|
|
545
|
+
from loaded rows — and so does a number column's `units`, where each row reads it in its own:
|
|
546
|
+
a range on it names one, or the server refuses it
|
|
547
|
+
([./members_and_options.md](./members_and_options.md)).
|