@lotics/app-sdk 0.82.2 → 0.83.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
@@ -16,7 +16,7 @@ signature; open the file.**
16
16
  |---|---|
17
17
  | [docs/recipes.md](./docs/recipes.md) | Task-shaped how-tos for the actions whose mechanism is not guessable from the hooks — returning a generated file, returning structured data, parameterized lookups, composable optional filters, cell decoding, testing an AI action without spending credits. |
18
18
  | [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. |
19
- | [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) — **realtime push** (a table one of your queries reads changes and that query refetches within about a second, alias-precise, records-only, host-embedded apps only), data discipline, the pagination count as a second full execution (and `total` to suppress it), the search-as-you-type + record-picker patterns. |
19
+ | [docs/data_fetching.md](./docs/data_fetching.md) | The four read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`/`useCount` — the last for a number with no rows, sharing the `(alias, params, filter)` count key the paginated hook uses, so a list and a badge over one set buy one count; a count is a full scan and stays its OWN request so rows paint without waiting for it, and `rows.length` is never a count since rows truncate silently at 10,000), 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) — **realtime push** (a table one of your queries reads changes and that query refetches within about a second, alias-precise, records-only, host-embedded apps only), data discipline, the pagination count as a second full execution (and `total` to suppress it), the search-as-you-type + record-picker patterns. |
20
20
  | [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). |
21
21
  | [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`. |
22
22
  | [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. |
@@ -189,6 +189,19 @@ export interface InfiniteQueryOptions extends BaseQueryOptions {
189
189
  /** Rows per page. `loadMore()` appends the next page. */
190
190
  pageSize: number;
191
191
  }
192
+ /**
193
+ * Options for `useCount` — one number, no rows.
194
+ *
195
+ * `sort` and `pageSize` are absent rather than ignored. A count is a single-row
196
+ * COUNT over the filtered set; ordering it and paginating it are meaningless,
197
+ * and an option a hook silently drops is worse than one that will not compile.
198
+ */
199
+ export type CountOptions = Omit<BaseQueryOptions, "sort">;
200
+ /** Return value of `useCount` — the size of a filtered set, and nothing else. */
201
+ interface CountState extends QueryStateBase {
202
+ /** Rows in the filtered set. `undefined` until the count resolves. */
203
+ total: number | undefined;
204
+ }
192
205
  /** Options for `usePaginatedQuery` — page-model with a total. */
193
206
  export interface PaginatedQueryOptions extends BaseQueryOptions {
194
207
  /** Rows per page. Default 25. */
@@ -386,6 +399,43 @@ export declare function useInfiniteQuery(alias: string, params?: Record<string,
386
399
  */
387
400
  export declare function usePaginatedQuery<K extends keyof AppQueries & string>(alias: K, ...args: QueryArgs<K, PaginatedQueryOptions>): PaginatedQueryState<QueryRow>;
388
401
  export declare function usePaginatedQuery(alias: string, params?: Record<string, unknown>, opts?: PaginatedQueryOptions): PaginatedQueryState<QueryRow>;
402
+ /**
403
+ * HOW MANY rows a query matches — one number, no rows fetched.
404
+ *
405
+ * The shape behind a facet chip, a queue badge, a "N awaiting approval" tile:
406
+ * the screen wants the size of a set it is not listing. Reach for it instead of
407
+ * the two things that used to stand in for it, both of which are worse:
408
+ *
409
+ * - `usePaginatedQuery(alias, params, { pageSize: 1 })` buys a row nobody
410
+ * renders — two requests for one integer, against a server that bounds how
411
+ * many app queries run at once.
412
+ * - A hand-rolled `rpc("query", { count: true })` is one request and leaves the
413
+ * cache: it does not dedupe with the page beside it, does not revalidate on
414
+ * focus, and never hears the host's post-write refetch. The number then goes
415
+ * stale over a set that has moved while everything around it updates, which
416
+ * is the failure worth avoiding — a count that is quietly wrong costs more
417
+ * than a count that costs a request.
418
+ *
419
+ * ONE COUNT PER FILTERED SET. The cache key is `(alias, params, filter)` — the
420
+ * very key `usePaginatedQuery` counts under — so a table and a badge over the
421
+ * same set issue ONE count between them, and a page click or a re-sort reuses
422
+ * it (a count is sort- and page-independent).
423
+ *
424
+ * **N counts over one source should not be N hooks.** Each declared query
425
+ * re-executes its whole `from` tree, so four facets mounted as four `useCount`s
426
+ * are four full scans that land in the same burst. When the counts differ only
427
+ * by a bucket the rows can be grouped on, one `group` query returns them all in
428
+ * a single scan and folds client-side (queries.md §10) — and its figure can be
429
+ * handed to `usePaginatedQuery`'s `total` so the list stops counting too. This
430
+ * hook is for the count that has no sibling to group with.
431
+ *
432
+ * ```tsx
433
+ * const { total } = useCount("orders", { q }, { filter: unpaidFilter });
434
+ * return <Badge label={total == null ? "…" : `${total}`} />;
435
+ * ```
436
+ */
437
+ export declare function useCount<K extends keyof AppQueries & string>(alias: K, ...args: QueryArgs<K, CountOptions>): CountState;
438
+ export declare function useCount(alias: string, params?: Record<string, unknown>, opts?: CountOptions): CountState;
389
439
  /** A file the host has stored and resolved serving URLs for. */
390
440
  export interface UploadedFile {
391
441
  id: string;
package/dist/src/hooks.js CHANGED
@@ -85,6 +85,28 @@ function useHostRefetch(alias, refetch, mockRows) {
85
85
  });
86
86
  }, [alias, refetch, mockRows]);
87
87
  }
88
+ /**
89
+ * THE COUNT READ, defined once — used by `usePaginatedQuery` for its page total
90
+ * and by `useCount` for a bare number.
91
+ *
92
+ * Both must agree on the SWR key and on the request body, byte for byte: that
93
+ * identity is what makes a table and a badge over the same set buy ONE count
94
+ * between them. Two hand-copied literals would hold today and diverge the first
95
+ * time one is edited, and the failure is silent — nothing breaks, the screen
96
+ * just quietly issues two full scans where it used to issue one. So there is one
97
+ * definition and no opportunity to disagree.
98
+ *
99
+ * The key omits PAGE and SORT deliberately: a count is independent of both, so
100
+ * page clicks and re-sorts reuse it. `active: false` yields a null key, and SWR
101
+ * issues nothing for a null key — which is how a caller-supplied total, a
102
+ * disabled hook and a fixture all suppress the request.
103
+ */
104
+ function useCountRead(alias, params, filter, active, revalidateOnFocus) {
105
+ const key = active
106
+ ? ["app-query-count", alias, params ?? {}, filter ?? null]
107
+ : null;
108
+ return useSWR(key, () => rpc("query", { alias, params: params ?? {}, filter, count: true }), swrConfig(revalidateOnFocus));
109
+ }
88
110
  export function useQuery(alias, params, opts) {
89
111
  const pageSize = opts?.pageSize;
90
112
  const enabled = opts?.enabled ?? true;
@@ -251,13 +273,9 @@ export function usePaginatedQuery(alias, params, opts) {
251
273
  }),
252
274
  // Keep the previous page's rows on screen while the next page loads.
253
275
  { keepPreviousData: true, ...swrConfig(revalidateOnFocus) });
254
- // Count key omits page AND sort one count per result-set identity, reused
255
- // across page clicks and re-sorts. Null when the caller supplies the total:
256
- // SWR issues nothing for a null key, so the second scan never happens.
257
- const countKey = mockRows || !enabled || callerOwnsTotal
258
- ? null
259
- : ["app-query-count", alias, params ?? {}, filter ?? null];
260
- const countSwr = useSWR(countKey, () => rpc("query", { alias, params: params ?? {}, filter, count: true }), swrConfig(revalidateOnFocus));
276
+ // Not counted when the caller supplies the total, when the hook is disabled,
277
+ // or under a fixture see `useCountRead` for why the key drops page and sort.
278
+ const countSwr = useCountRead(alias, params, filter, !mockRows && enabled && !callerOwnsTotal, revalidateOnFocus);
261
279
  const rows = mockRows ?? rowsSwr.data?.rows ?? [];
262
280
  // A supplied `null` reads out as `undefined` — "not known yet" is one state
263
281
  // to the consumer whether the hook is counting or the caller is.
@@ -287,6 +305,28 @@ export function usePaginatedQuery(alias, params, opts) {
287
305
  refetch,
288
306
  };
289
307
  }
308
+ export function useCount(alias, params, opts) {
309
+ const enabled = opts?.enabled ?? true;
310
+ const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
311
+ const filter = opts?.filter;
312
+ const mockRows = getMockRows(alias);
313
+ // The SAME read `usePaginatedQuery` uses for its total — one definition, so a
314
+ // list and a badge over one set can never drift into two scans.
315
+ const countSwr = useCountRead(alias, params, filter, !mockRows && enabled, revalidateOnFocus);
316
+ const refetch = useCallback(() => {
317
+ void countSwr.mutate();
318
+ }, [countSwr]);
319
+ useHostRefetch(alias, refetch, mockRows);
320
+ return {
321
+ // Under a fixture the whole set is the fixture, so its length IS the count —
322
+ // the same substitution every other hook makes for `rows`.
323
+ total: mockRows ? mockRows.length : countSwr.data?.total,
324
+ loading: mockRows ? false : countSwr.isLoading,
325
+ isValidating: mockRows ? false : countSwr.isValidating,
326
+ error: countSwr.error?.message ?? null,
327
+ refetch,
328
+ };
329
+ }
290
330
  /**
291
331
  * Upload files from an app. The bytes are stored via a presigned
292
332
  * direct-to-storage upload the host mediates; the API server never proxies
@@ -16,8 +16,8 @@
16
16
  */
17
17
  export { mount } from "./mount.js";
18
18
  export type { MountOptions } from "./mount.js";
19
- export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, buildChoiceOutput, } from "./hooks.js";
20
- export type { QueryRow, UploadedFile, AttachedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterFieldCondition, QueryFilterRecordIdCondition, QueryFilterGroup, WorkflowResult, MembersOptions, AgentRunOptions, UseAgentRun, AgentRunLanding, AgentRunRecord, AgentRunState, AgentUIPart, PendingChoice, ChoiceQuestion, ChoiceOption, AskUserChoiceOutput, FieldOptions, FieldOptionsState, FieldOptionsOptions, } from "./hooks.js";
19
+ export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useCount, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, buildChoiceOutput, } from "./hooks.js";
20
+ export type { QueryRow, UploadedFile, AttachedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, CountOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterFieldCondition, QueryFilterRecordIdCondition, QueryFilterGroup, WorkflowResult, MembersOptions, AgentRunOptions, UseAgentRun, AgentRunLanding, AgentRunRecord, AgentRunState, AgentUIPart, PendingChoice, ChoiceQuestion, ChoiceOption, AskUserChoiceOutput, FieldOptions, FieldOptionsState, FieldOptionsOptions, } from "./hooks.js";
21
21
  export { useComments, useCommentCounts } from "./comments.js";
22
22
  export type { AppComment, AppCommentFile, CommentsState, UseCommentsArgs, CommentCountsState, UseCommentCountsArgs, } from "./comments.js";
23
23
  export { useViewer } from "./viewer.js";
package/dist/src/index.js CHANGED
@@ -15,7 +15,7 @@
15
15
  * not raw HTML/CSS. See `docs/apps.md` → "Styling & components".
16
16
  */
17
17
  export { mount } from "./mount.js";
18
- export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, buildChoiceOutput, } from "./hooks.js";
18
+ export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useCount, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, buildChoiceOutput, } from "./hooks.js";
19
19
  export { useComments, useCommentCounts } from "./comments.js";
20
20
  export { useViewer } from "./viewer.js";
21
21
  export { useConfig } from "./config.js";
@@ -23,9 +23,54 @@ per the manifest. Each hook is a thin wrapper over the host RPC bridge with an S
23
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
24
  | `useInfiniteQuery(alias, params?, opts?)` | accumulated `rows` + `loadMore` | infinite scroll / "load more" feeds |
25
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 |
26
27
 
27
28
  One job each — don't overload one. `useQuery` with a big `pageSize` is not pagination; a
28
- `usePaginatedQuery` whose pages you concatenate yourself is `useInfiniteQuery` done by hand.
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.
36
+
37
+ ### One count per filtered set
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):
42
+
43
+ ```tsx
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 });
47
+ ```
48
+
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.
55
+
56
+ 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.
62
+
63
+ ### Why not hand-roll it
64
+
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.
70
+
71
+ And never count client-side from `useQuery(...).rows.length`: rows are capped at 10,000 per
72
+ response and truncation is **silent**, so the number is right in development and wrong in
73
+ production with no error ([queries](./queries.md) §10).
29
74
 
30
75
  ## Shared option surface
31
76
 
package/docs/workflows.md CHANGED
@@ -632,9 +632,14 @@ The rules that are easy to get wrong because the failing code looks correct.
632
632
  spelling. Start that binding from the first array instead of seeding it `[]`. Appending single
633
633
  **items** (`xs.push(item)`) works on a bare `[]` seed.
634
634
  - **A mutation on the trigger table of an `after_*` workflow can re-trigger itself** — a lint
635
- warning, not an error. Gate it on `trigger.change_origin.type == "member"` (the originating
636
- write's origin). Not `runtime.change_origin`: that is the execution's own origin, always
637
- `table_workflow` here, so it discriminates nothing.
635
+ warning, not an error. Gate it on `trigger.change_origin` the originating write's origin —
636
+ and name every origin you mean to let through, rather than testing for one. `member` is a
637
+ person in the browser and **only** that: the same edit made over MCP, the CLI, or a direct API
638
+ call arrives as `api_client`, and one made by the chat agent as `chat_agent`. A gate meaning
639
+ "someone did this by hand" is
640
+ `includes(["member", "chat_agent", "api_client"], trigger.change_origin.type)`. Not
641
+ `runtime.change_origin`: that is the execution's own origin, always `table_workflow` here, so
642
+ it discriminates nothing.
638
643
 
639
644
  ## The bright line
640
645
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.82.2",
3
+ "version": "0.83.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps \u2014 typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {