@lotics/app-sdk 0.72.0 → 0.74.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 CHANGED
@@ -15,7 +15,7 @@ signature; open the file.**
15
15
  | Doc | Read it for |
16
16
  |---|---|
17
17
  | [docs/queries.md](./docs/queries.md) | **The query engine authoring reference** — AST node kinds, per-field-type operator support, filters/params/pruning, free-text search, combining tables (join/union/link/`unnest`/`record_id`), shaping (aggregates, date buckets, windows), runtime refinement bounds, limits & the efficiency playbook. |
18
- | [docs/data_fetching.md](./docs/data_fetching.md) | The three read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`), the `QueryRow` shape (projected columns `unknown`; `__source_record_id`/`__source_table_id` typed but optional), cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `useFieldOptions`, caching — **arrival revalidates** (a re-mount renders cache *and* refreshes it in the background, `loading` never flips) — data discipline, the search-as-you-type + record-picker patterns. |
18
+ | [docs/data_fetching.md](./docs/data_fetching.md) | The three read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`), the `QueryRow` shape (projected columns `unknown`; `__source_record_id`/`__source_table_id` typed but optional), cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `useFieldOptions`, caching — **arrival revalidates** (a re-mount renders cache *and* refreshes it in the background, `loading` never flips) — data discipline, the pagination count as a second full execution (and `total` to suppress it), the search-as-you-type + record-picker patterns. |
19
19
  | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract, typed inputs, diff-before-update, locked records, `useOptimistic`, `useNewRecord` (client-minted `rec_*` id so a new-record surface never remounts on its first save), read-after-write ordering (a re-read must not overtake an in-flight write). |
20
20
  | [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY authoring reference** — the JS subset a `src/workflows/<alias>.ts` body may use: the parse-at-save/never-execute model, opaque `fld_*`/`opt_*` keys, expression sources + link descent, every step form (tool call, `agent`, waits, `validate`, `return`), the accepted sugar and its canonical lowering, helpers + callback rules, record-write surfaces, the traps, the bright line, and the verify loop — `check` (the only local gate: the app's own `npm run typecheck` never sees a body) → `dry_run_workflow` (static green is not a run) → `set`. |
21
21
  | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs (**a bearer credential for the bytes** — never logged, reported, or persisted), workflow-generated files, **naming a zip's entries** (`{ id, name }` per file — a file name, never a path), preview pairing, filter operators, the server-side delivery bounds. **Uploads declare a `fidelity`** (`standard` / `high` / `original`) — the app picks how much of the image survives storage; use `high` whenever text must stay legible. |
@@ -181,6 +181,41 @@ export interface InfiniteQueryOptions extends BaseQueryOptions {
181
181
  export interface PaginatedQueryOptions extends BaseQueryOptions {
182
182
  /** Rows per page. Default 25. */
183
183
  pageSize?: number;
184
+ /**
185
+ * The total, when the SCREEN already knows it — which suppresses the hook's
186
+ * own count request entirely.
187
+ *
188
+ * Three states, all of them in the type: **omitted** → the hook counts;
189
+ * **`null`** → yours, not resolved yet; **a number** → yours, use it. `null`
190
+ * is what makes the option usable at all, because the natural source is an
191
+ * aggregate that is still loading on the first render — with only
192
+ * "number-or-nothing" the hook would fire the count it exists to avoid and
193
+ * throw the result away the moment the real number landed. So read it as
194
+ * `?? null`:
195
+ *
196
+ * ```tsx
197
+ * const summary = useQuery("orderStats", params); // one ungrouped aggregate
198
+ * const rows = usePaginatedQuery("orders", params, {
199
+ * pageSize: 100,
200
+ * total: (summary.rows[0]?.row_count as number | undefined) ?? null,
201
+ * });
202
+ * ```
203
+ *
204
+ * Until the number arrives the hook reports `total: undefined` and `hasMore`
205
+ * falls back to "the page came back full" — exactly its behaviour while a
206
+ * count is in flight.
207
+ *
208
+ * Worth reaching for when a screen already renders the same figure: a `count`
209
+ * request re-executes the whole named query server-side, so a summary reading
210
+ * "N items" beside a table of those N rows is otherwise paying for that number
211
+ * twice, and both executions grow with the filtered set.
212
+ *
213
+ * The number must count the SAME set the query returns — the hook derives
214
+ * `totalPages` and `hasMore` from it and cannot tell that it doesn't.
215
+ *
216
+ * `refetch()` does not refresh it; it is yours, so refresh its source.
217
+ */
218
+ total?: number | null;
184
219
  }
185
220
  /**
186
221
  * Trigger a workflow by alias from the app's manifest.
package/dist/src/hooks.js CHANGED
@@ -207,6 +207,10 @@ export function usePaginatedQuery(alias, params, opts) {
207
207
  const sort = opts?.sort && opts.sort.length > 0 ? opts.sort : undefined;
208
208
  const filter = opts?.filter;
209
209
  const mockRows = getMockRows(alias);
210
+ // `null` = the caller owns the total and it hasn't resolved; `undefined`
211
+ // (or omitted) = the hook counts. See `PaginatedQueryOptions.total`.
212
+ const suppliedTotal = opts?.total;
213
+ const callerOwnsTotal = suppliedTotal !== undefined;
210
214
  // The result-set identity. When it changes, `page` derives back to 0 (not via
211
215
  // an effect, so the stale page never fires a wasted fetch) and the count key
212
216
  // changes (recount). `setPage` re-stamps the current identity.
@@ -228,13 +232,20 @@ export function usePaginatedQuery(alias, params, opts) {
228
232
  // Keep the previous page's rows on screen while the next page loads.
229
233
  { keepPreviousData: true, ...swrConfig(revalidateOnFocus) });
230
234
  // Count key omits page AND sort — one count per result-set identity, reused
231
- // across page clicks and re-sorts.
232
- const countKey = mockRows || !enabled
235
+ // across page clicks and re-sorts. Null when the caller supplies the total:
236
+ // SWR issues nothing for a null key, so the second scan never happens.
237
+ const countKey = mockRows || !enabled || callerOwnsTotal
233
238
  ? null
234
239
  : ["app-query-count", alias, params ?? {}, filter ?? null];
235
240
  const countSwr = useSWR(countKey, () => rpc("query", { alias, params: params ?? {}, filter, count: true }), swrConfig(revalidateOnFocus));
236
241
  const rows = mockRows ?? rowsSwr.data?.rows ?? [];
237
- const total = mockRows ? mockRows.length : countSwr.data?.total;
242
+ // A supplied `null` reads out as `undefined` — "not known yet" is one state
243
+ // to the consumer whether the hook is counting or the caller is.
244
+ const total = mockRows
245
+ ? mockRows.length
246
+ : callerOwnsTotal
247
+ ? (suppliedTotal ?? undefined)
248
+ : countSwr.data?.total;
238
249
  const totalPages = total != null ? Math.max(1, Math.ceil(total / pageSize)) : undefined;
239
250
  const hasMore = total != null ? (page + 1) * pageSize < total : rows.length === pageSize;
240
251
  const refetch = useCallback(() => {
@@ -91,7 +91,8 @@ server validates system conditions by `type` and never reads `field_key` on them
91
91
  rendered. The next focus revalidation or an explicit `refetch()` re-runs it.
92
92
  - **`refetch()`** re-runs the query. Call it after a known mutation point — a successful
93
93
  `useWorkflow` call — to pull the latest state (see [./mutations.md](./mutations.md)).
94
- `usePaginatedQuery.refetch()` refreshes both the page and the count.
94
+ `usePaginatedQuery.refetch()` refreshes the page, and the count when the hook owns it
95
+ (a caller-supplied `total` is the caller's to refresh).
95
96
  - **Ambient-chat mutations refetch automatically.** When the member's ambient chat agent (see
96
97
  [./ai.md](./ai.md#useaicontextslot-context--tell-the-ambient-chat-what-the-member-is-looking-at))
97
98
  finishes a turn that mutated records, the host pushes **every mounted query hook** to re-read —
@@ -147,6 +148,10 @@ Offset-only (the `usePaginatedQuery` numbered pages and the `useQuery` / manual
147
148
  - **`count: true` counts the filtered set**, ignoring sort/limit/offset — `usePaginatedQuery`
148
149
  issues it automatically. The count and the page are separate requests, so under concurrent
149
150
  writes `total` can briefly disagree with what paging finds.
151
+ - **A count is a full re-execution of the named query, not a cheap lookup.** It costs what the
152
+ query costs and grows with the filtered set — on a union/unpivot pipeline that is a whole extra
153
+ scan. When the screen already renders that same number from an aggregate, hand it to
154
+ `usePaginatedQuery` as `total` rather than paying for it twice (below).
150
155
 
151
156
  ### `useQuery`
152
157
 
@@ -189,7 +194,8 @@ const { rows, total, totalPages, page, setPage, hasMore } =
189
194
  ```
190
195
 
191
196
  The page-model hook behind a numbered table (pairs with `@lotics/ui` `Pagination`). It owns the
192
- page cursor and fetches two things: the current page, and a `count` over the filtered set.
197
+ page cursor and fetches two things: the current page, and a `count` over the filtered set — unless
198
+ you supply the total yourself.
193
199
 
194
200
  - **Result-set identity is `(params, filter)`.** Changing either resets to page 0 and recounts.
195
201
  Changing only `sort` does **neither** — the count is sort-independent and you stay on the same
@@ -203,6 +209,29 @@ page cursor and fetches two things: the current page, and a `count` over the fil
203
209
  - A page change **keeps the previous rows on screen** while the next page loads (built-in
204
210
  `keepPreviousData`) — gate any skeleton on `loading && rows.length === 0`, never on `loading`
205
211
  alone, or every page click collapses the table.
212
+ - **`total` in the options suppresses the count request entirely.** Reach for it when the screen
213
+ already has that figure — a summary reading "N items" beside a table of those N rows is
214
+ otherwise executing the same query twice, and both executions grow with the set.
215
+
216
+ ```tsx
217
+ const summary = useQuery("orderStats", params); // one ungrouped aggregate
218
+ const rows = usePaginatedQuery("orders", params, {
219
+ pageSize: 100,
220
+ total: (summary.rows[0]?.row_count as number | undefined) ?? null,
221
+ });
222
+ ```
223
+
224
+ **Three states, all of them in the type**: omitted → the hook counts; `null` → yours, not
225
+ resolved yet; a number → yours, use it. The `?? null` is what makes it work — the natural source
226
+ is an aggregate still loading on the first render, and without a way to say "mine, pending" the
227
+ hook would fire the very count it exists to avoid and discard it a moment later. Until the number
228
+ arrives the hook reports `total: undefined` and `hasMore` falls back to "the page came back
229
+ full", exactly as while a count is in flight.
230
+
231
+ `refetch()` does not refresh a supplied total — it is yours, so refresh its source.
232
+
233
+ The number must count the **same set** the query returns — `totalPages` and `hasMore` derive
234
+ from it and the hook cannot tell that it doesn't.
206
235
 
207
236
  ## Standalone (public) transport
208
237
 
@@ -214,9 +243,11 @@ keyset `cursor`. Design a public app around this:
214
243
  - **Runtime `sort` / `filter` are ignored** — every hook's `opts.sort` / `opts.filter` is dropped,
215
244
  so a standalone app can't order or narrow a query at the call site. Bake ordering and scoping
216
245
  into the query **template** (or drive them through declared `params`), not the refinement options.
217
- - **`usePaginatedQuery.total` stays `undefined`** — the `count` request never resolves, so
218
- `totalPages` never lands and `hasMore` falls back to "the current page came back full". A
219
- numbered "Page 1 of N" control has no N; render it defensively, or prefer `useInfiniteQuery`.
246
+ - **`usePaginatedQuery` gets no count** — the `count` request is dropped in transit, so a numbered
247
+ "Page 1 of N" control has no N unless you supply one. Pass `total` from a figure the app already
248
+ reads (an aggregate query survives the thin transport fine); with neither, `totalPages` never
249
+ lands, `hasMore` falls back to "the current page came back full", and `useInfiniteQuery` is the
250
+ better shape.
220
251
  - **`useInfiniteQuery.loadMore()` never advances past the first page** — with the cursor dropped,
221
252
  no `next_cursor` comes back, so `hasMore` is `false` after page one. For a standalone browse,
222
253
  size the template `limit` (or a `params`-driven page) to return the whole set in one fetch.
package/docs/mutations.md CHANGED
@@ -328,7 +328,9 @@ const onClose = async (recordId: string) => {
328
328
 
329
329
  `refetch` re-runs the query in the background while the current rows stay on screen (no
330
330
  flash to a spinner — `loading` stays false during revalidation). `usePaginatedQuery`'s
331
- `refetch` re-runs both the current page and the total count. Focus revalidation
331
+ `refetch` re-runs the current page, and the count when the hook owns it — a total you supplied
332
+ yourself is yours to refresh, so a write that changes the row COUNT must also refresh whatever
333
+ you read it from, or the page moves while "of N" does not. Focus revalidation
332
334
  (`revalidateOnFocus`, default on) eventually self-corrects stale data, but never rely on it
333
335
  in place of an explicit refetch after a write the user is watching for.
334
336
 
package/docs/queries.md CHANGED
@@ -121,7 +121,8 @@ Delivery-layer enrichment (applied to the response, per request):
121
121
 
122
122
  - **`files` cells** — each entry gains a presigned `url` + `thumbnail_url` (24 h TTL) and
123
123
  `size` (bytes) + `created_at`, resolved from the file object at read. Presigning is
124
- server-bounded (§10). Public apps hand out direct presigned URLs anonymous-fetchable,
124
+ server-bounded (§10) and it is per ENTRY, which is why a list that renders one thumbnail
125
+ should project the column with `limit` (§3) rather than the whole array. Public apps hand out direct presigned URLs — anonymous-fetchable,
125
126
  time-boxed. (App workflow-execute responses presign returned files the same way, so their
126
127
  URLs also work for anonymous public-app viewers — see [files.md](./files.md).)
127
128
  - **`select_member` cells** — bare member-id arrays become
@@ -199,6 +200,20 @@ indexes live (§10). `sort` entries are `{ field_key, order: "asc"|"desc", blank
199
200
  (§4), never a relabel. An uncastable combination is rejected at deploy.
200
201
  - **Project only what you render.** A bare `from_table` ships every column — including `files`
201
202
  cells with storage keys — to the client (over-exposure + the presign ceiling at scale).
203
+ - **A files column takes `limit` — bound it when the surface renders a THUMBNAIL, not the
204
+ collection.** `{ "type": "files", "output": "photo", "source": "fld_…", "limit": 1 }` returns
205
+ the first entry per cell, in stored order, cut in SQL so the rest is never read or signed.
206
+ Without it the cell yields the whole array and the response pays to resolve every entry it
207
+ will never show: a register over containers averaging 33 gate photos each resolved ~832 files
208
+ to display 25 thumbnails, within 2.4× of the response-wide presign ceiling (§10) that would
209
+ reject the query outright. The bound is per CELL and caps at `FILES_PROJECTION_MAX_LIMIT`
210
+ (10) — a row wanting more than a handful is asking for the collection, which belongs to the
211
+ record surface that opens it. It is a different axis from the query's own `limit`, which
212
+ counts ROWS; `limit` on any non-`files` column — or on a computed source, which has no cell
213
+ to bound — is rejected at deploy rather than ignored. **On a UNION, set it on every arm.**
214
+ Arms align by column name and type, and `limit` is neither, so an arm that omits it still
215
+ yields whole cells — the query stays correct and silently costs what the bound was there to
216
+ avoid.
202
217
 
203
218
  ### `filter` — predicate over derived columns
204
219
 
@@ -783,7 +798,9 @@ template as derived nodes, in this order: `filter` (narrow) → `sort` (order)
783
798
  `next_cursor` (null on the last page). Seek stays O(page) and never skips/duplicates a row as the
784
799
  set shifts, falling back to offset for a multi-key sort it can't seek.
785
800
  - **`count: true`** — returns `{ total }` only: a single-row COUNT over the *filtered* set,
786
- ignoring sort/limit/offset. Drives "Page 1 of N".
801
+ ignoring sort/limit/offset. Drives "Page 1 of N". It is a **second full execution** of the
802
+ template, not a cheap lookup off the page request — it costs what the query costs and grows with
803
+ the filtered set, which on a union/unpivot pipeline is a whole extra scan.
787
804
 
788
805
  **A terminal aggregate cannot be refined — plan the total accordingly.** Because refinement wraps
789
806
  *around* the template over its **output** columns, a query that has already collapsed (a `group`
@@ -803,7 +820,10 @@ sends `limit: pageSize, offset: 0` (a cap, not pagination) plus `opts.sort`/`opt
803
820
  `useInfiniteQuery` pages by **keyset** (`keyset: true` + the prior `next_cursor`), so its scroll
804
821
  never skips or duplicates a row; `usePaginatedQuery` owns the page cursor, pages by `offset`, and
805
822
  issues a `count` keyed on `(alias, params, filter)` — independent of page and sort, so paging and
806
- re-sorting never recount, while changing `(params, filter)` resets to page 0 and recounts.
823
+ re-sorting never recount, while changing `(params, filter)` resets to page 0 and recounts. Given
824
+ its `total` option it issues none at all: a screen that already renders that figure from an
825
+ aggregate hands it over rather than buying it twice
826
+ ([data_fetching](./data_fetching.md) → `usePaginatedQuery`).
807
827
 
808
828
  Build `filter` from UI column-filters with `columnFilterToConditions` (`@lotics/ui`); prefer
809
829
  `useFieldOptions` for a select filter's option set.
@@ -811,9 +831,10 @@ Build `filter` from UI column-filters with `columnFilterToConditions` (`@lotics/
811
831
  **Warning (transport gaps):** the embedded product host and the `lotics app dev` forwarder pass
812
832
  `sort`/`filter`/`count` through. The **standalone public transport** (`<slug>.lotics.app`)
813
833
  currently forwards only `alias`/`params`/`limit`/`offset` — runtime `sort`/`filter` are
814
- silently ignored there and `count` never resolves (`usePaginatedQuery.total` stays
815
- `undefined`). A standalone app must bake ordering/scoping into the template (or params) rather
816
- than rely on runtime refinement.
834
+ silently ignored there and a `count` never resolves, so a standalone pager has no "of N" unless the
835
+ app supplies one through `usePaginatedQuery`'s `total` (a plain aggregate query survives the thin
836
+ transport). A standalone app must bake ordering/scoping into the template (or params) rather than
837
+ rely on runtime refinement.
817
838
 
818
839
  **Pagination semantics.** `useQuery` / `usePaginatedQuery` and manual `rpc("query", { limit,
819
840
  offset })` are **offset** — a deep page costs the server the full skipped prefix (page 400 of a
@@ -919,8 +940,21 @@ and is not — and a playbook rule applied to the wrong query costs effort while
919
940
  the server's concurrent-query gate (503 busy) on every load. Group by ALL the dimensions
920
941
  at once (`by: [a, b, c, …]`) and fold each facet client-side by summing over the others:
921
942
  `count` and `sum` fold exactly. `unique` does NOT fold across groups (the same value can
922
- appear in many groups) keep a separate query for each distinct-count you render.
923
- 11. **Re-derive a query when its tables change shape.** A `join` or `union` that exists to bridge
943
+ appear in many groups), so a rendered distinct-count needs its own query — **unless there
944
+ are no group keys**, where `by: []` carries sums, counts and distinct counts together in one
945
+ scan. (It counts distinct *present* values, §8 — so a pre-filter that excludes the rows whose
946
+ value is blank buys nothing but its own scan.) Which makes the rule's own corollary the thing
947
+ to check first: **group keys are not free scaffolding.** They are the expensive half of a
948
+ group — a `select`/jsonb key especially — so keys kept for a facet the screen no longer
949
+ renders cost a real multiple of the ungrouped aggregate, and a query that outlived its cards
950
+ is invisible because it still returns the right answer.
951
+ 11. **A paginated table's count is a second full execution.** `usePaginatedQuery` issues
952
+ `count: true` alongside the page, and a count re-runs the whole query — it costs what the
953
+ query costs and grows with the filtered set, so on a union/unpivot pipeline it is a whole
954
+ extra scan, not a cheap lookup. When the screen already renders that figure from an
955
+ aggregate, hand it over as the hook's `total` instead of buying it twice
956
+ ([data_fetching](./data_fetching.md) → `usePaginatedQuery`).
957
+ 12. **Re-derive a query when its tables change shape.** A `join` or `union` that exists to bridge
924
958
  two tables becomes pure cost the moment those tables become one — and nothing fails, because
925
959
  it keeps returning the right answer at the old price. Migrations that merge, move or back-fill
926
960
  a table are exactly when this happens, and exactly when nobody re-reads the queries. After
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.72.0",
3
+ "version": "0.74.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {