@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 +1 -1
- package/dist/src/hooks.d.ts +35 -0
- package/dist/src/hooks.js +14 -3
- package/docs/data_fetching.md +36 -5
- package/docs/mutations.md +3 -1
- package/docs/queries.md +26 -7
- package/package.json +1 -1
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. |
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(() => {
|
package/docs/data_fetching.md
CHANGED
|
@@ -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
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
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
|
|
830
|
-
|
|
831
|
-
|
|
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)
|
|
938
|
-
|
|
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
|