@lotics/app-sdk 0.82.3 → 0.83.1
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 +50 -0
- package/dist/src/hooks.js +47 -7
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +1 -1
- package/docs/data_fetching.md +46 -1
- package/docs/queries.md +6 -0
- package/package.json +1 -1
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
|
|
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. |
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -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
|
-
//
|
|
255
|
-
//
|
|
256
|
-
|
|
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
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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";
|
package/docs/data_fetching.md
CHANGED
|
@@ -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/queries.md
CHANGED
|
@@ -376,6 +376,12 @@ A `QuerySource` appears in `project.columns[].source`, `unpivot.passthrough[].so
|
|
|
376
376
|
| `{ "link": { "source": "customer", "field": "Company name" } }` | A field on the **first** linked record of a `select_record_link` field (correlated per-row subquery) | the target field's type |
|
|
377
377
|
| `{ "expression": "…" }` | jexpr escape hatch (below) | declared |
|
|
378
378
|
|
|
379
|
+
**Every variant in that table except the bare field key and `record_id` needs an explicit
|
|
380
|
+
`"type"` beside `"source"`, whatever the Output type column says it yields.** That column
|
|
381
|
+
describes the VALUE; the compiler infers a type only for a passthrough and for identity, and
|
|
382
|
+
rejects the column otherwise — so `{ "isNotEmpty": … }` is a boolean you still have to declare
|
|
383
|
+
as one. `record_id` is the sole exception, and declaring anything but `text` on it is rejected.
|
|
384
|
+
|
|
379
385
|
### Link extraction — the fine print
|
|
380
386
|
|
|
381
387
|
`{ link: { source, field } }` requires a direct `from_table` parent, and `source` must be a
|