@lotics/app-sdk 0.109.0 → 0.110.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/index.js +30 -11
- package/dist/queries.d.ts +6 -2
- package/docs/data_fetching.md +10 -2
- package/docs/mutations.md +1 -1
- package/docs/queries.md +4 -1
- package/docs/security.md +1 -1
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -14,7 +14,7 @@ This file is the index. The **exact type** of anything is its shipped declaratio
|
|
|
14
14
|
|
|
15
15
|
| Doc | Read it for |
|
|
16
16
|
|---|---|
|
|
17
|
-
| [docs/data_fetching.md](./docs/data_fetching.md) | The reads: `useQuery(alias, params, opts)` — its rows (`limit`), numbered pages (`page`), a keyset feed (`more`), its `total` or the total alone, with `total: { by }` a count per value of one column — `useQueries` for reads known only at render, `queryAll` outside React, `exportQuery` for a file of them the server makes; every hook answers one `QueryState`. The ROW type (`RowOf` — the alias's projected columns and nothing else), runtime `sort`/`filter` keys, cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`, `readCreatedAt`/`readUpdatedAt`), the SDK's own cache — **arrival revalidates** — **realtime push**, a write drawn on every read from the press, a count as its own full scan, and the search-as-you-type and record-picker patterns. |
|
|
17
|
+
| [docs/data_fetching.md](./docs/data_fetching.md) | The reads: `useQuery(alias, params, opts)` — its rows (`limit`), numbered pages (`page`), a keyset feed (`more`), its `total` or the total alone, with `total: { by }` a count per value of one column and `total: { where }` a count per named filter, from one scan — `useQueries` for reads known only at render, `queryAll` outside React, `exportQuery` for a file of them the server makes; every hook answers one `QueryState`. The ROW type (`RowOf` — the alias's projected columns and nothing else), runtime `sort`/`filter` keys, cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`, `readCreatedAt`/`readUpdatedAt`), the SDK's own cache — **arrival revalidates** — **realtime push**, a write drawn on every read from the press, a count as its own full scan, and the search-as-you-type and record-picker patterns. |
|
|
18
18
|
| [docs/queries.md](./docs/queries.md) | **The query engine reference** — AST node kinds, per-field-type operators, filters/params/pruning, free-text search, combining tables, shaping (aggregates, date buckets, windows), runtime refinement bounds, limits and the efficiency playbook. |
|
|
19
19
|
| [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path; `useWorkflows` for writes that are data), the `WorkflowResult` resolve-never-throw contract (`field_errors`), typed inputs, every write drawn on every read from the press — predicted from the workflow's own steps, taken back on a refusal, `pending` meanwhile — the re-read a successful write triggers over the tables its body names, diff-before-update, locked records, `useNewRecord`, read-after-write ordering (`writesSettled()`), `useRecording`, `useRecordings` over several aliases. |
|
|
20
20
|
| [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY reference** — the JS subset a body may use, opaque `fld_*`/`opt_*` keys, every step form, the accepted sugar, helpers, record-write surfaces, the traps and the verify loop. |
|
package/dist/index.js
CHANGED
|
@@ -30975,13 +30975,25 @@ function pageOf(mocked, call) {
|
|
|
30975
30975
|
function countOf(mocked, by) {
|
|
30976
30976
|
return { total: mocked.length, ...by === void 0 ? {} : { counts: countsOf(mocked, by) } };
|
|
30977
30977
|
}
|
|
30978
|
+
function within(filter2, named) {
|
|
30979
|
+
return filter2 === void 0 ? named : { node_type: "group", logic: "and", children: [filter2, named] };
|
|
30980
|
+
}
|
|
30981
|
+
function mockCount(alias, call) {
|
|
30982
|
+
const mocked = getMockRows(alias, { params: call.params, filter: call.filter });
|
|
30983
|
+
if (mocked === null) return null;
|
|
30984
|
+
const where = Object.entries(call.where ?? {});
|
|
30985
|
+
const named = where.map(([, one]) => getMockRows(alias, { params: call.params, filter: within(call.filter, one) }) ?? []);
|
|
30986
|
+
const answer = (rows, each) => call.where === void 0 ? countOf(rows, call.by) : { total: rows.length, counts: Object.fromEntries(where.map(([name], at2) => [name, each[at2]?.length ?? 0])) };
|
|
30987
|
+
if (Array.isArray(mocked) && named.every((one) => Array.isArray(one))) return answer(mocked, named.filter((one) => Array.isArray(one)));
|
|
30988
|
+
return Promise.all([mocked, ...named]).then(([rows = [], ...each]) => answer(rows, each));
|
|
30989
|
+
}
|
|
30978
30990
|
function knownRows(alias, call) {
|
|
30979
30991
|
const mocked = getMockRows(alias, call);
|
|
30980
30992
|
return Array.isArray(mocked) ? pageOf(mocked, call) : void 0;
|
|
30981
30993
|
}
|
|
30982
30994
|
function knownCount(alias, call) {
|
|
30983
|
-
const
|
|
30984
|
-
return
|
|
30995
|
+
const counted = mockCount(alias, call);
|
|
30996
|
+
return counted === null || counted instanceof Promise ? void 0 : counted;
|
|
30985
30997
|
}
|
|
30986
30998
|
async function askRows(key, alias, askedAt, call, since = askedAt) {
|
|
30987
30999
|
const mocked = getMockRows(alias, call);
|
|
@@ -30998,18 +31010,22 @@ async function askRows(key, alias, askedAt, call, since = askedAt) {
|
|
|
30998
31010
|
return answer;
|
|
30999
31011
|
}
|
|
31000
31012
|
async function askCount(key, alias, askedAt, call) {
|
|
31001
|
-
const mocked =
|
|
31002
|
-
if (mocked !== null) return
|
|
31013
|
+
const mocked = mockCount(alias, call);
|
|
31014
|
+
if (mocked !== null) return mocked;
|
|
31003
31015
|
const answer = await rpc("query", {
|
|
31004
31016
|
alias,
|
|
31005
31017
|
params: call.params,
|
|
31006
31018
|
count: true,
|
|
31007
31019
|
...call.filter === void 0 ? {} : { filter: call.filter },
|
|
31008
|
-
...call.by === void 0 ? {} : { count_by: call.by }
|
|
31020
|
+
...call.by === void 0 ? {} : { count_by: call.by },
|
|
31021
|
+
...call.where === void 0 ? {} : { count_where: call.where }
|
|
31009
31022
|
});
|
|
31010
31023
|
noteAnswer(key, alias, askedAt, []);
|
|
31011
31024
|
return answer;
|
|
31012
31025
|
}
|
|
31026
|
+
function namedCounts(named) {
|
|
31027
|
+
return { rows: Object.fromEntries(named.map(([name, one]) => [name, one.rows])), pending: named.some(([, one]) => one.pending) };
|
|
31028
|
+
}
|
|
31013
31029
|
var feedWants = /* @__PURE__ */ new Map();
|
|
31014
31030
|
function ignore() {
|
|
31015
31031
|
}
|
|
@@ -31022,7 +31038,8 @@ function useQuery(alias, params = {}, opts = {}) {
|
|
|
31022
31038
|
const filter2 = opts.filter;
|
|
31023
31039
|
const wantsRows = opts.rows ?? true;
|
|
31024
31040
|
const totalAsked = opts.total ?? opts.page !== void 0;
|
|
31025
|
-
const by = typeof totalAsked === "object" ? totalAsked.by : void 0;
|
|
31041
|
+
const by = typeof totalAsked === "object" && "by" in totalAsked ? totalAsked.by : void 0;
|
|
31042
|
+
const where = typeof totalAsked === "object" && "where" in totalAsked ? totalAsked.where : void 0;
|
|
31026
31043
|
const call = JSON.stringify([params, filter2 ?? null, sort ?? null]);
|
|
31027
31044
|
const mocking = hasMockFlag();
|
|
31028
31045
|
const [paging, setPaging] = useState3({ call, page: 0 });
|
|
@@ -31077,11 +31094,12 @@ function useQuery(alias, params = {}, opts = {}) {
|
|
|
31077
31094
|
feedWants.set(feedKey, heldPages + 1);
|
|
31078
31095
|
feed.refetch();
|
|
31079
31096
|
}, [feedKey, feedHasMore, heldPages, feed]);
|
|
31080
|
-
const countKey = !enabled || totalAsked === false ? null : JSON.stringify(["count", alias, JSON.stringify([params, filter2 ?? null]), by ?? null]);
|
|
31081
|
-
const
|
|
31097
|
+
const countKey = !enabled || totalAsked === false ? null : JSON.stringify(["count", alias, JSON.stringify([params, filter2 ?? null]), by ?? null, where ?? null]);
|
|
31098
|
+
const countCall = { params, ...filter2 === void 0 ? {} : { filter: filter2 }, ...by === void 0 ? {} : { by }, ...where === void 0 ? {} : { where } };
|
|
31099
|
+
const count = useKey(countKey, (askedAt) => askCount(countKey ?? "", alias, askedAt, countCall), {
|
|
31082
31100
|
focus,
|
|
31083
31101
|
aliases: [alias],
|
|
31084
|
-
known: mocking ? () => knownCount(alias,
|
|
31102
|
+
known: mocking ? () => knownCount(alias, countCall) : void 0
|
|
31085
31103
|
});
|
|
31086
31104
|
const version3 = useWrittenVersion();
|
|
31087
31105
|
const drawn2 = useMemo3(() => {
|
|
@@ -31097,9 +31115,10 @@ function useQuery(alias, params = {}, opts = {}) {
|
|
|
31097
31115
|
const counted = useMemo3(() => {
|
|
31098
31116
|
if (count.data === void 0) return void 0;
|
|
31099
31117
|
const total2 = overlayCount(alias, params, filter2, count.askedAt, count.data.total);
|
|
31100
|
-
const
|
|
31118
|
+
const answered2 = count.data.counts;
|
|
31119
|
+
const counts = answered2 === void 0 ? void 0 : where !== void 0 ? namedCounts(Object.entries(where).map(([name, one]) => [name, overlayCount(alias, params, within(filter2, one), count.askedAt, answered2[name] ?? 0)])) : by === void 0 ? void 0 : overlayCounts(alias, params, filter2, by, count.askedAt, answered2);
|
|
31101
31120
|
return { total: total2.rows, counts: counts?.rows, pending: total2.pending || (counts?.pending ?? false) };
|
|
31102
|
-
}, [alias, call,
|
|
31121
|
+
}, [alias, call, countKey, count.data, count.askedAt, version3]);
|
|
31103
31122
|
const total = counted?.total;
|
|
31104
31123
|
const pageCount = opts.page === void 0 || total === void 0 ? void 0 : Math.max(1, Math.ceil(total / opts.page));
|
|
31105
31124
|
const pageHasMore = opts.page === void 0 ? false : total !== void 0 ? (page + 1) * opts.page < total : drawn2.rows.length === opts.page;
|
package/dist/queries.d.ts
CHANGED
|
@@ -89,10 +89,13 @@ export interface QueryOptions<C extends string = string> {
|
|
|
89
89
|
more?: number;
|
|
90
90
|
/**
|
|
91
91
|
* The total the rows come to — a count of the whole set, its own read, so the rows never wait for it —
|
|
92
|
-
*
|
|
92
|
+
* with `by` a count per value of that column from the same scan, or with `where` a count per named filter
|
|
93
|
+
* of the rows it keeps within the set, every one from the same scan. On by default with `page`.
|
|
93
94
|
*/
|
|
94
95
|
total?: boolean | {
|
|
95
96
|
by: C;
|
|
97
|
+
} | {
|
|
98
|
+
where: Readonly<Record<string, QueryFilter<C>>>;
|
|
96
99
|
};
|
|
97
100
|
/** `false` reads no rows: a total alone. Default `true`. */
|
|
98
101
|
rows?: boolean;
|
|
@@ -103,7 +106,8 @@ export interface QueryState<R> {
|
|
|
103
106
|
truncated: boolean;
|
|
104
107
|
/** The whole set's count, where `total` is asked; `undefined` until it answers. */
|
|
105
108
|
total: number | undefined;
|
|
106
|
-
/** Rows per value of `total.by`, keyed as a row carries it (an option's `opt_…`)
|
|
109
|
+
/** Rows per value of `total.by`, keyed as a row carries it (an option's `opt_…`), a value no row holds absent;
|
|
110
|
+
* or per name of `total.where`. */
|
|
107
111
|
counts: Readonly<Record<string, number>> | undefined;
|
|
108
112
|
/** Only the first read of a key with nothing to show; a re-read and a new key keep the rows on screen. */
|
|
109
113
|
loading: boolean;
|
package/docs/data_fetching.md
CHANGED
|
@@ -21,7 +21,7 @@ untyped. It is written from the app's live bindings by `lotics app create --cust
|
|
|
21
21
|
| `useQuery(alias, params?, opts?)` | `rows` up to the server's cap, or the first `limit` | a detail read, a dashboard block, a combobox's top-N — anything that is not a long list |
|
|
22
22
|
| `useQuery(alias, params?, { page: n })` | one numbered page of `n` rows, `total`, `pageCount`, `setPage` | numbered, jumpable pages |
|
|
23
23
|
| `useQuery(alias, params?, { more: n })` | accumulated `rows` + `loadMore` | infinite scroll / "load more" feeds |
|
|
24
|
-
| `useQuery(alias, params?, { rows: false, total: true })` | `total` only, no rows (`total: { by: column }` adds `counts`) | a facet chip, a queue badge, an "N awaiting approval" tile — the size of a set you are not listing |
|
|
24
|
+
| `useQuery(alias, params?, { rows: false, total: true })` | `total` only, no rows (`total: { by: column }` or `total: { where: { name: filter } }` adds `counts`) | a facet chip, a queue badge, an "N awaiting approval" tile — the size of a set you are not listing |
|
|
25
25
|
| `useQueries(calls, opts?)` | one state per `{ alias, params?, filter?, sort?, aggregate?, total?, rows? }`, in order — a `sort` orders that call's rows before the cap cuts them | reads that are DATA — a list known only at render, one read per item, or one count per stage (`rows: false, total: true`, each state's `.total`); the deploy's alias scan reads a list as dynamic, so every alias in it is still declared. With `aggregate` a call answers the groups of its filtered rows instead of the rows ([queries](./queries.md)) |
|
|
26
26
|
| `queryAll(alias, params?, { filter, sort })` | a promise of every row | outside React, for a job that must hold the whole narrowed set in the browser (the ids an act runs on): it asks page after page until the server says the set ended, never stopping at the 10,000-row cap. The server's rows as stored — no write of this app is drawn over them |
|
|
27
27
|
| `exportQuery(alias, { params, filter, sort, tabs?, report, file })` | a promise of the file | every row the narrowed set holds, made into one file by the server — no row reaches the browser. `tabs` (2 to 5, each `{ tab, label, filter?, sort? }`) are each read as the export's own rows are — same query and params, under the tab's own filter and sort (absent, the declared order) — and a template reads each one's rows under its `tab`, a name none of the report's keys nor the template's `rows` key holds. `report` is `{ title, lines, readings, dates?, period?, per?, filename? }` — `dates` each date filter's `{ from?, to? }` by field alias, each bound a day (`yyyy-MM-dd`) or a minute of one (`yyyy-MM-ddTHH:mm`) with no zone; `period` the tabs' `{ from, to }`, bounds of the same form; `per` the value the report was picked for, in the reader's words; `filename` the file's name in the template grammar over one value each — `title`, `at` (the moment the server made the file), `dates.<field>.from` or `.to`, `period.from` or `.to`, `per`, `lines | lookup:<n>` — at most 200 bytes once filled (absent, the template's name, or `title`); `file` is `{ kind: "workbook", language, sheet, columns }` (the default report workbook, its head closing with the moment the server made it, each column `{ key, header, type }` an output column of the query — a `number` one on a number column, a `date` one on a date or datetime; with `tabs`, one sheet per tab named by its `label` in place of `sheet`) or `{ kind: "template", template }`, one the query declares under `templates` by name ([queries](./queries.md)). Past 20,000 rows, in any tab, it rejects, never cuts; each caller makes at most 60 exports a minute. The file's `url` is signed for the caller: open it with `openExternal` |
|
|
@@ -63,7 +63,15 @@ const { total, counts } = useQuery("orders", { q }, { rows: false, total: { by:
|
|
|
63
63
|
// counts?.["opt_shipped"] — the rows in that stage over the whole set, however few are loaded
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
Several counts of one set — a tab each, a share each — are **one read with `total: { where }`**: each
|
|
67
|
+
named filter's rows within the read's `filter`, as `counts` by name, from the same scan.
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
const { total, counts } = useQuery("orders", {}, { rows: false, total: { where: { late: lateFilter, mine: mineFilter } } });
|
|
71
|
+
// counts?.late, counts?.mine — one scan, however many names
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
A `useQueries` call's `total` is `true` or absent — it takes no `by` or `where`.
|
|
67
75
|
|
|
68
76
|
### Why not hand-roll it
|
|
69
77
|
|
package/docs/mutations.md
CHANGED
|
@@ -526,7 +526,7 @@ every read before the request answers:
|
|
|
526
526
|
leaves it; a created row it admits joins it, standing by the read's order (the call's `sort`,
|
|
527
527
|
then the query's own) where its sort cells are numbers or dates, else first. A row the read does
|
|
528
528
|
not hold that a write may move into it waits for the server, and `pending` says so.
|
|
529
|
-
- **Totals.** A `total`, a count per value (`total: { by }`), a group asked with `aggregate` or
|
|
529
|
+
- **Totals.** A `total`, a count per value (`total: { by }`) or per named filter (`total: { where }`), a group asked with `aggregate` or
|
|
530
530
|
declared by the query, and a parent's count or sum rollup move by exactly what the written row adds
|
|
531
531
|
or takes away; a group the last row leaves is gone, and a count per value gains the value a row
|
|
532
532
|
first holds. A mean, an extreme, a distinct count, a group by day, an aggregate with its own
|
package/docs/queries.md
CHANGED
|
@@ -844,7 +844,10 @@ template as derived nodes, in this order: `filter` (narrow) → `sort` (order)
|
|
|
844
844
|
- **`count: true`** — returns `{ total }` only: a COUNT over the *filtered* set, ignoring
|
|
845
845
|
sort/limit/offset. Drives "Page 1 of N". With **`count_by: "<column>"`** the same scan also
|
|
846
846
|
returns `counts` — rows per value of that projected column (a multi-value row under each value,
|
|
847
|
-
a row holding none in `total` only); a column whose values are not plain (a link) → 400.
|
|
847
|
+
a row holding none in `total` only); a column whose values are not plain (a link) → 400. With
|
|
848
|
+
**`count_where: { <name>: <filter> }`** (not beside `count_by`) the same scan returns `counts` by
|
|
849
|
+
name — the rows each filter keeps within `filter`, each filter bounded and resolved as `filter` is,
|
|
850
|
+
at most 32. It is a
|
|
848
851
|
**second full execution** of the template — sent only where a read asks for its `total`, and
|
|
849
852
|
one count serves every read of the same set ([data_fetching](./data_fetching.md)).
|
|
850
853
|
- **`aggregate: { by?, day?, sum? }`** — the groups of the *filtered* set in place of its rows: one
|
package/docs/security.md
CHANGED
|
@@ -111,7 +111,7 @@ Listing members (`useMembers`) takes an authenticated member of the app's own or
|
|
|
111
111
|
|
|
112
112
|
## What runtime refinement cannot widen
|
|
113
113
|
|
|
114
|
-
The query RPC accepts runtime `filter` and `sort` (for search boxes, sortable tables, pickers) — but these are **bounded to the named query's output columns**. A `field_key` naming a column the query does not project is rejected, so a caller can never filter or sort by — and thereby probe — a field the author didn't expose. The caller's `limit` is clamped to the server row cap, params fill the template's *value holes* only — filter values and the search term; tables, joins, and projections are author-fixed — and `count` mode returns a total — with `count_by`, one per value of a projected column — over the same bounded filter. Full mechanics in [queries](./queries.md).
|
|
114
|
+
The query RPC accepts runtime `filter` and `sort` (for search boxes, sortable tables, pickers) — but these are **bounded to the named query's output columns**. A `field_key` naming a column the query does not project is rejected, so a caller can never filter or sort by — and thereby probe — a field the author didn't expose. The caller's `limit` is clamped to the server row cap, params fill the template's *value holes* only — filter values and the search term; tables, joins, and projections are author-fixed — and `count` mode returns a total — with `count_by`, one per value of a projected column; with `count_where`, one per named filter, each bounded as `filter` is — over the same bounded filter. Full mechanics in [queries](./queries.md).
|
|
115
115
|
|
|
116
116
|
**Warning — templated free-text search is not output-bounded.** A `search` term in the query template (typically a `{{params.q}}` hole) matches against the record's **whole search document**: every searchable field of the table — text, numbers, dates (in three formats), select option names, member names, linked-record display text, formula/rollup/lookup values, and autonumbers (only booleans, buttons, and file fields are excluded). It is *not* restricted to the columns the query projects. A caller who controls the search term can therefore probe the *contents* of unprojected fields by watching which rows match — a row-membership oracle. Put a `search` hole only in queries over tables where every searchable field is acceptable to probe for that audience; for a search box over a table with sensitive unprojected fields, use runtime `filter` with `contains` on the projected columns instead, or AND the `search` with a template `contains` OR-group over the fields that may be probed — every row that group matches also matches the search, so the pair answers only for those fields.
|
|
117
117
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/app-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.110.0",
|
|
4
4
|
"description": "The SDK a Lotics custom-code app reads and writes through \u2014 typed hooks over the host bridge, cell readers, mount() and AppRouter",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|