@lotics/app-sdk 0.100.1 → 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.
Files changed (108) hide show
  1. package/AGENTS.md +32 -47
  2. package/dist/agent_stream.d.ts +131 -0
  3. package/dist/ask_ai.d.ts +27 -0
  4. package/dist/attachments.d.ts +58 -0
  5. package/dist/chunk-ARV5FAU5.js +1132 -0
  6. package/dist/comments.d.ts +89 -0
  7. package/dist/error_report.d.ts +9 -0
  8. package/dist/folder_pick.d.ts +8 -0
  9. package/dist/geolocation.d.ts +42 -0
  10. package/dist/hooks.d.ts +251 -0
  11. package/dist/{src/index.d.ts → index.d.ts} +13 -22
  12. package/dist/index.js +31309 -0
  13. package/dist/index.js.LEGAL.txt +11 -0
  14. package/dist/members.d.ts +32 -0
  15. package/dist/mock.d.ts +37 -0
  16. package/dist/mount.d.ts +19 -0
  17. package/dist/new_record.d.ts +37 -0
  18. package/dist/open_app.d.ts +12 -0
  19. package/dist/open_external.d.ts +10 -0
  20. package/dist/overlay.d.ts +25 -0
  21. package/dist/queries.d.ts +231 -0
  22. package/dist/recording.d.ts +47 -0
  23. package/dist/recording_state.d.ts +43 -0
  24. package/dist/rename_file.d.ts +13 -0
  25. package/dist/router.d.ts +10 -0
  26. package/dist/router.js +97 -0
  27. package/dist/row.d.ts +87 -0
  28. package/dist/rpc.d.ts +114 -0
  29. package/dist/select.d.ts +24 -0
  30. package/dist/shared_types.d.ts +8 -0
  31. package/dist/store.d.ts +43 -0
  32. package/dist/types.d.ts +36 -0
  33. package/dist/upload/optimize.d.ts +30 -0
  34. package/dist/upload/pipeline.d.ts +36 -0
  35. package/dist/upload/transport.d.ts +19 -0
  36. package/dist/url_params.d.ts +55 -0
  37. package/dist/use_recents.d.ts +15 -0
  38. package/dist/{src/use_url_state.d.ts → use_url_state.d.ts} +0 -2
  39. package/dist/viewer.d.ts +41 -0
  40. package/dist/written.d.ts +77 -0
  41. package/docs/ai.md +74 -133
  42. package/docs/data_fetching.md +209 -290
  43. package/docs/files.md +61 -51
  44. package/docs/members_and_options.md +92 -62
  45. package/docs/mutations.md +135 -205
  46. package/docs/navigation_and_state.md +26 -35
  47. package/docs/queries.md +144 -207
  48. package/docs/recipes.md +21 -45
  49. package/docs/runtime.md +74 -137
  50. package/docs/security.md +8 -11
  51. package/docs/workflows.md +189 -174
  52. package/package.json +27 -28
  53. package/dist/src/agent_stream.d.ts +0 -200
  54. package/dist/src/agent_stream.js +0 -314
  55. package/dist/src/ask_ai.d.ts +0 -40
  56. package/dist/src/ask_ai.js +0 -35
  57. package/dist/src/attachments.d.ts +0 -68
  58. package/dist/src/attachments.js +0 -93
  59. package/dist/src/comments.d.ts +0 -127
  60. package/dist/src/comments.js +0 -192
  61. package/dist/src/download.js +0 -54
  62. package/dist/src/geolocation.d.ts +0 -64
  63. package/dist/src/geolocation.js +0 -96
  64. package/dist/src/hooks.d.ts +0 -781
  65. package/dist/src/hooks.js +0 -860
  66. package/dist/src/index.js +0 -34
  67. package/dist/src/members.d.ts +0 -105
  68. package/dist/src/members.js +0 -62
  69. package/dist/src/mock.d.ts +0 -118
  70. package/dist/src/mock.js +0 -124
  71. package/dist/src/mount.d.ts +0 -47
  72. package/dist/src/mount.js +0 -34
  73. package/dist/src/new_record.d.ts +0 -74
  74. package/dist/src/new_record.js +0 -117
  75. package/dist/src/open_app.d.ts +0 -15
  76. package/dist/src/open_app.js +0 -18
  77. package/dist/src/open_external.d.ts +0 -16
  78. package/dist/src/open_external.js +0 -19
  79. package/dist/src/recording.d.ts +0 -59
  80. package/dist/src/recording.js +0 -30
  81. package/dist/src/recording_state.d.ts +0 -59
  82. package/dist/src/recording_state.js +0 -94
  83. package/dist/src/router.d.ts +0 -17
  84. package/dist/src/router.js +0 -144
  85. package/dist/src/row.d.ts +0 -159
  86. package/dist/src/row.js +0 -254
  87. package/dist/src/rpc.d.ts +0 -207
  88. package/dist/src/rpc.js +0 -904
  89. package/dist/src/select.d.ts +0 -48
  90. package/dist/src/select.js +0 -40
  91. package/dist/src/types.d.ts +0 -115
  92. package/dist/src/types.js +0 -1
  93. package/dist/src/upload/optimize.d.ts +0 -54
  94. package/dist/src/upload/optimize.js +0 -207
  95. package/dist/src/upload/pipeline.d.ts +0 -55
  96. package/dist/src/upload/pipeline.js +0 -52
  97. package/dist/src/upload/transport.d.ts +0 -42
  98. package/dist/src/upload/transport.js +0 -128
  99. package/dist/src/url_params.d.ts +0 -93
  100. package/dist/src/url_params.js +0 -215
  101. package/dist/src/use_optimistic.d.ts +0 -27
  102. package/dist/src/use_optimistic.js +0 -27
  103. package/dist/src/use_recents.d.ts +0 -19
  104. package/dist/src/use_recents.js +0 -71
  105. package/dist/src/use_url_state.js +0 -73
  106. package/dist/src/viewer.d.ts +0 -26
  107. package/dist/src/viewer.js +0 -47
  108. /package/dist/{src/download.d.ts → download.d.ts} +0 -0
@@ -1,104 +1,104 @@
1
1
  # Data fetching
2
2
 
3
- How an app reads data: the three read hooks (`useQuery`, `useInfiniteQuery`, `usePaginatedQuery`),
4
- their caching/revalidation and pagination contracts, the typed cell readers that decode query rows
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/src/hooks.d.ts`, `dist/src/row.d.ts`, `dist/src/select.d.ts`, `dist/src/members.d.ts`.
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** (declared in `package.json#lotics.queries`) and fills the template's declared `{{params.x}}`
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
- (per-viewer scoping is a template concern — see [./security.md](./security.md)). Per-app CLI
16
- codegen augments `AppQueries`, so an undeclared alias is a compile-time error and params are typed
17
- per the manifest. Each hook is a thin wrapper over the host RPC bridge with an SWR cache in front.
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 hook
17
+ ## Choosing a read
20
18
 
21
- | Hook | Result shape | Reach for it when |
19
+ | Call | Answers | Reach for it when |
22
20
  |---|---|---|
23
- | `useQuery(alias, params?, opts?)` | one fetch, `rows` | a detail read, a dashboard block, a combobox's top-N — anything that is not a long list |
24
- | `useInfiniteQuery(alias, params?, opts?)` | accumulated `rows` + `loadMore` | infinite scroll / "load more" feeds |
25
- | `usePaginatedQuery(alias, params?, opts?)` | one page of `rows` + `total` | numbered, jumpable pages behind `@lotics/ui` `Pagination` |
26
- | `useCount(alias, params?, opts?)` | `total` only, no rows | a facet chip, a queue badge, an "N awaiting approval" tile — the size of a set you are not listing |
27
-
28
- One job each — don't overload one. `useQuery` with a big `pageSize` is not pagination; a
29
- `usePaginatedQuery` whose pages you concatenate yourself is `useInfiniteQuery` done by hand;
30
- and a `usePaginatedQuery` with `pageSize: 1` whose rows you drop is `useCount` buying a page
31
- nobody renders — **two** requests for one integer, since that hook fetches a page AND a count.
32
-
33
- `useCount` takes no `sort` and no `pageSize`: a count is a single-row COUNT over the filtered
34
- set, so ordering and paginating it are meaningless. They are absent from `CountOptions` rather
35
- than ignored — an option a hook silently drops is worse than one that will not compile.
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
- `useCount` counts under the SAME cache key `usePaginatedQuery` counts under —
40
- `(alias, params, filter)`. A table and a badge over the same set therefore issue **one** count
41
- between them, and page clicks and re-sorts reuse it (a count is page- and sort-independent):
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 { rows, total, setPage } = usePaginatedQuery("orders", { q }, { pageSize: 25 });
46
- const { total: sameNumber } = useCount("orders", { q });
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 `pageSize` — measured on one register, the COUNT ran
51
- *longer* than the page beside it. That is why it is a request of its own rather than something
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 `useCount`s are four full scans landing in one burst
58
- against the server's concurrency gate. When the counts differ only by a bucket the rows can be
59
- grouped on, **one `group` query returns them all in a single scan** and folds client-side
60
- ([queries](./queries.md) §10), and its figure can be handed to `usePaginatedQuery`'s `total` so
61
- the list stops counting too. `useCount` is for the count with no sibling to group with.
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 })` is one request and looks equivalent. It is not: it
66
- leaves the cache, so it does not dedupe with the page beside it, does not revalidate on focus,
67
- and never hears the host's post-write refetch. The number then goes stale over a set that has
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). `useQuery` reports `truncated` when the cap cut the result — read it wherever a figure is
74
- folded from the rows (below) — but a count over the whole set is `useCount`'s answer, not a length.
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
- ## Shared option surface
78
+ ## Options
77
79
 
78
- All three hooks accept these (`BaseQueryOptions`):
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. Note: disabling also **hides** previously loaded rows (the cache entry survives; it re-renders instantly when re-enabled). |
83
- | `revalidateOnFocus` | `boolean` | `true` | `false` = no auto-refetch 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. |
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 cache key — changing it re-queries. |
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 cache key. Build from per-column UI state with `columnFilterToConditions` (`@lotics/ui/column_filter`). |
86
-
87
- Per-hook additions:
88
-
89
- | Option | Hook | Meaning |
90
- |---|---|---|
91
- | `pageSize?: number` | `useQuery` | a **cap** on the one request, not pagination; omit to fetch up to the server row cap |
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
- codegen writes it into `.lotics/app_queries.d.ts` as `AppQueryColumns[alias]`, read off the query's
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 `lotics app check` and deploy) at your desk. At request time the
100
- server checks the same rule again and rejects an un-projected key with an error, so a
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. It is exactly the bug the
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 too — ask the alias that CARRIES the column, because a picker fed from a sibling
121
- query's map is otherwise a control that renders empty with nothing reporting it. Every key is
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 **limit/offset paginate** it. The template's own filters/sort/limit run first,
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
- - **Cache identity** is the tuple `(alias, params, pageSize, sort, filter)` (+ the page index for
139
- the paged hooks). Object *contents* are hashed, not references — passing a fresh inline
140
- `{ status: "open" }` each render is the same key; you never need to memoize params.
141
- - The cache **survives unmount/remount**, and **arrival is a refresh event**: returning to a screen
142
- renders the cached rows instantly *and* revalidates them in the background, so a list reflects what
143
- another screen changed while you were away. Identical concurrent reads dedupe to one request.
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), a **realtime push**, and **this app's own successful write**,
146
- which re-reads every mounted query without being asked (see
147
- [./mutations.md](./mutations.md)). `refetch()` covers what none of those can know about.
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. It refetches only the aliases whose tables actually moved, so an expensive aggregate
152
- is not re-run by a change to a table it never reads. Two limits worth knowing: it carries RECORD
153
- changes only (a schema edit does not push), and a **standalone/public app has no host**, so those
154
- apps keep focus/reconnect freshness alone. Treat push as an improvement on pull, never a
155
- replacement — it is absent whenever the channel cannot connect.
156
- - **`useInfiniteQuery` does not arrival-revalidate** — the one exception. A warm feed re-mounted
157
- refetches nothing; a cold key still fetches, and focus / reconnect / a realtime push still refresh.
158
- Re-fetching page 1 on arrival would cost a request on every `loadMore()` and reshuffle the top of
159
- the feed under a reader scrolling further down it, and re-fetching every loaded page grows without
160
- bound. **A feed that must be fresh on arrival calls `refetch()`.**
161
- - **`loading`** is `true` only on the *initial* load of a key — a request is in flight and there
162
- are no rows yet. It stays `false` during background revalidation of a key that already has rows,
163
- so consumers never blank loaded data to a spinner on refetch. A key *change* (new params, sort,
164
- filter, or page) is a fresh load: `loading` goes `true` again unless that key is already cached —
165
- but the previous key's rows STAY on screen while it resolves, for every query, so `rows` never
166
- empties mid-flight. That is why skeletons gate on `loading && rows.length === 0`, never `loading`
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 revalidation or an explicit `refetch()` re-runs it.
174
- - **Never derive a FIGURE from `rows` without gating on `error`.** On a failure `rows` is `[]`,
175
- and `[]` is indistinguishable from a genuinely empty result — so `rows.reduce(…)` returns `0`,
176
- `rows.length` returns `0`, and a screen states a confident number that no query answered. This
177
- is the one place the empty-vs-broken distinction is load-bearing: a list that renders nothing
178
- looks obviously wrong, whereas a total that reads `0` looks *fine*. It cost a sales register
179
- nine days of showing every rep `0 ₫` of commission, because one query's runtime filter named a
180
- column it did not project and nothing read `error`. A count, a sum, a "N of M", a progress
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. It corrects itself when the data
189
- lands, which is exactly what makes it ship: the author sees the settled screen, and only a
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 query. A successful `useWorkflow` call already re-reads the mounted
203
- queries on its own (see [./mutations.md](./mutations.md)), so reach for this only where a write
204
- cannot have told you: a poll, a value that changes without anything on this screen writing, or a
205
- total you supplied yourself. `usePaginatedQuery.refetch()` refreshes the page, and the count when
206
- the hook owns it (a caller-supplied `total` is the caller's to refresh).
207
- - **Ambient-chat mutations refetch automatically.** When the member's ambient chat agent (see
208
- [./ai.md](./ai.md#useaicontextslot-context--tell-the-ambient-chat-what-the-member-is-looking-at))
209
- finishes a turn that mutated records, the host pushes **every mounted query hook** to re-read —
210
- so the screen reflects the change with no `refetch()` call and no reload. It refreshes exactly the
211
- queries currently on screen (mounted hooks only); it never reaches into app data, it only tells
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** (SDK 0.85.0 — before it, the
228
- status alone decided). A failure whose JSON body carries a `code` is one the API authored, so its
229
- `message` is what `error` holds — including a 5xx, which is why the shed sentence in the table above
230
- reaches the app at all rather than being replaced by a generic one. Everything else — a gateway's
231
- HTML page, a proxy's JSON with no `code`, a body with no `message` — is plumbing, and `error` holds
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
- - **`useInfiniteQuery` uses keyset (seek) pagination.** Each page seeks past the previous page's
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
- - **`useQuery`, `usePaginatedQuery`, and manual `rpc("query", { limit, offset })` use offset
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 an omitted `pageSize` on `useQuery`) is clamped to it. To read a bigger result set, page
256
- through it. `useQuery` reports `truncated: true` when the limit cut the result, so a screen can
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 (the `usePaginatedQuery` numbered pages and the `useQuery` / manual `rpc` cap paths):
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
- - **`count: true` counts the filtered set**, ignoring sort/limit/offset — `usePaginatedQuery`
272
- issues it automatically. The count and the page are separate requests, so under concurrent
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
- ### `useQuery`
256
+ ### Rows
280
257
 
281
258
  ```tsx
282
259
  const { rows, loading, error, refetch } = useQuery("openOrders", { status: "open" });
283
260
  ```
284
261
 
285
- One fetch. `params` is required when the alias declares params, optional otherwise. Returns
286
- `{ rows, truncated, loading, isValidating, error, refetch }`. `rows` is
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 limit is your `pageSize` when you set one
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
- ### `useInfiniteQuery`
279
+ ### A feed — `more`
304
280
 
305
281
  ```tsx
306
- const { rows, loadMore, hasMore, loadingMore } = useInfiniteQuery("feed", {}, { pageSize: 30 });
282
+ const { rows, loadMore, hasMore, loadingMore } = useQuery("feed", {}, { more: 30 });
307
283
  ```
308
284
 
309
- The first render loads one page; `loadMore()` appends the next, accumulating into `rows`. Paging is
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 **full** (a non-null cursor came back) — so a
313
- total that is an exact multiple of `pageSize` costs one final short fetch before `hasMore` turns
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`, `pageSize`) starts a
318
- fresh accumulation from the first page.
319
- - `refetch()` revalidates every loaded page. Appending a page does **not** re-fetch earlier pages,
320
- so a long-lived list still mixes page snapshots taken at different times (an edit between two
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
- ### `usePaginatedQuery`
299
+ ### Numbered pages — `page`
326
300
 
327
301
  ```tsx
328
- const { rows, total, totalPages, page, setPage, hasMore } =
329
- usePaginatedQuery("orders", { q }, { pageSize: 25, sort, filter });
302
+ const { rows, total, pageCount, page, setPage, hasMore } =
303
+ useQuery("orders", { q }, { page: 25, sort, filter });
330
304
  ```
331
305
 
332
- The page-model hook behind a numbered table (pairs with `@lotics/ui` `Pagination`). It owns the
333
- page cursor and fetches two things: the current page, and a `count` over the filtered set — unless
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 either resets to page 0 and recounts.
337
- Changing only `sort` does **neither** — the count is sort-independent and you stay on the same
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 `totalPages` are `undefined` until the count resolves — render pagination controls
343
- defensively. `totalPages` is `max(1, ceil(total / pageSize))`. Until the count lands, `hasMore`
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 (built-in
346
- `keepPreviousData`) — gate any skeleton on `loading && rows.length === 0`, never on `loading`
347
- alone, or every page click collapses the table.
348
- - **`total` in the options suppresses the count request entirely.** Reach for it when the screen
349
- already has that figure — a summary reading "N items" beside a table of those N rows is
350
- otherwise executing the same query twice, and both executions grow with the set.
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 hook's `opts.sort` / `opts.filter` is dropped,
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
- - **`usePaginatedQuery` gets no count** — the `count` request is dropped in transit, so a numbered
383
- "Page 1 of N" control has no N unless you supply one. Pass `total` from a figure the app already
384
- reads (an aggregate query survives the thin transport fine); with neither, `totalPages` never
385
- lands, `hasMore` falls back to "the current page came back full", and `useInfiniteQuery` is the
386
- better shape.
387
- - **`useInfiniteQuery.loadMore()` never advances past the first page** — with the cursor dropped,
388
- no `next_cursor` comes back, so `hasMore` is `false` after page one. For a standalone browse,
389
- size the template `limit` (or a `params`-driven page) to return the whole set in one fetch.
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. Codegen names the columns each query carries
421
- (`AppQueryColumns` in `.lotics/app_queries.d.ts`) and a row read is bounded to them, so a misspelt
422
- column, a renamed field and a projection moved to a sibling alias are one `tsc` error rather than a
423
- blank cell — the server answers an unprojected column with `undefined`, and every reader below
424
- answers `undefined` with its empty value, so nothing else reports it. The values stay `unknown` (a
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`.** `name` is `null` when the id doesn't resolve in the app's org (e.g. a removed
482
- member) — fall back explicitly. `email` is present only on authenticated responses from the app's
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: a member cell has that shape, so reading one as a
491
- link would otherwise render every assignee under a blank name. Read a member cell with
492
- `readMembers`. **A LOOKUP of a link field arrives one level deeper** — one entry per linked
493
- record, each entry that record's whole link cell — and both readers open it, in order and with a
494
- record two linked rows point at named once, so a looked-up link needs no unwrapping of its own.
495
-
496
- **`readFiles`.** Each entry's `url` (and `thumbnail_url` for images) is **presigned with a 24-hour
497
- TTL** — it renders directly in an `<Image>`/preview and works from the sandboxed iframe and for
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. They give a table with
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, rendered in the viewer's zone. `null` on a grouped row, which has no
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
- A query cell carries only the options a record actually holds (key + label, no color). For the
528
- **complete** option list of a select column — every option including those in no current row, with
529
- colors — use **`useFieldOptions`**, and prefer it over deriving options from loaded rows
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`. A second copy drifts and serves stale
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 (`usePaginatedQuery` does this for you) — gate the skeleton on
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). Reserve an OR-group of per-field `contains` only when you must
569
- bound exactly which fields match — `contains` is **unindexed**, so a zero-match keystroke
570
- forces a full table-partition scan. **Search-as-you-type uses `search`, never a `contains`
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. A six-letter name costs six, and on a list screen each one
579
- is several — `usePaginatedQuery` re-counts whenever `params` change, and any sibling query
580
- taking the same term goes with it. Keep the input's value in one state and debounce the COMMIT
581
- into a second (`useDebouncedCallback` from `@lotics/ui/use_debounced_callback`, ~250 ms). The
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
- - **`usePaginatedQuery`** drives it: the page of rows, the `total` for "Page 1 of N" (the built-in
619
- `count` request), and the page cursor.
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 `pageSize`
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)).