@lotics/app-sdk 0.73.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
@@ -798,7 +798,9 @@ template as derived nodes, in this order: `filter` (narrow) → `sort` (order)
798
798
  `next_cursor` (null on the last page). Seek stays O(page) and never skips/duplicates a row as the
799
799
  set shifts, falling back to offset for a multi-key sort it can't seek.
800
800
  - **`count: true`** — returns `{ total }` only: a single-row COUNT over the *filtered* set,
801
- 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.
802
804
 
803
805
  **A terminal aggregate cannot be refined — plan the total accordingly.** Because refinement wraps
804
806
  *around* the template over its **output** columns, a query that has already collapsed (a `group`
@@ -818,7 +820,10 @@ sends `limit: pageSize, offset: 0` (a cap, not pagination) plus `opts.sort`/`opt
818
820
  `useInfiniteQuery` pages by **keyset** (`keyset: true` + the prior `next_cursor`), so its scroll
819
821
  never skips or duplicates a row; `usePaginatedQuery` owns the page cursor, pages by `offset`, and
820
822
  issues a `count` keyed on `(alias, params, filter)` — independent of page and sort, so paging and
821
- 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`).
822
827
 
823
828
  Build `filter` from UI column-filters with `columnFilterToConditions` (`@lotics/ui`); prefer
824
829
  `useFieldOptions` for a select filter's option set.
@@ -826,9 +831,10 @@ Build `filter` from UI column-filters with `columnFilterToConditions` (`@lotics/
826
831
  **Warning (transport gaps):** the embedded product host and the `lotics app dev` forwarder pass
827
832
  `sort`/`filter`/`count` through. The **standalone public transport** (`<slug>.lotics.app`)
828
833
  currently forwards only `alias`/`params`/`limit`/`offset` — runtime `sort`/`filter` are
829
- silently ignored there and `count` never resolves (`usePaginatedQuery.total` stays
830
- `undefined`). A standalone app must bake ordering/scoping into the template (or params) rather
831
- 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.
832
838
 
833
839
  **Pagination semantics.** `useQuery` / `usePaginatedQuery` and manual `rpc("query", { limit,
834
840
  offset })` are **offset** — a deep page costs the server the full skipped prefix (page 400 of a
@@ -934,8 +940,21 @@ and is not — and a playbook rule applied to the wrong query costs effort while
934
940
  the server's concurrent-query gate (503 busy) on every load. Group by ALL the dimensions
935
941
  at once (`by: [a, b, c, …]`) and fold each facet client-side by summing over the others:
936
942
  `count` and `sum` fold exactly. `unique` does NOT fold across groups (the same value can
937
- appear in many groups) keep a separate query for each distinct-count you render.
938
- 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
939
958
  two tables becomes pure cost the moment those tables become one — and nothing fails, because
940
959
  it keeps returning the right answer at the old price. Migrations that merge, move or back-fill
941
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.73.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": {