@lotics/app-sdk 0.48.0 → 0.49.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/dist/src/hooks.js CHANGED
@@ -101,21 +101,28 @@ export function useInfiniteQuery(alias, params, opts) {
101
101
  const sort = opts?.sort && opts.sort.length > 0 ? opts.sort : undefined;
102
102
  const filter = opts?.filter;
103
103
  const mockRows = getMockRows(alias);
104
+ // Keyset (seek) pagination: each page seeks past the previous page's
105
+ // `next_cursor` instead of an increasing OFFSET, so deep scrolls stay O(page)
106
+ // and never skip/duplicate a row as the set shifts. The cursor is opaque; the
107
+ // server keysets a sortable key (or the id default) and falls back to offset
108
+ // transparently. `loadMore`/`rows` are unchanged — the cursor is internal.
104
109
  const getKey = (index, prev) => {
105
110
  if (mockRows || !enabled)
106
111
  return null;
107
- // Stop once a short page returns.
108
- if (index > 0 && (prev == null || prev.rows.length < pageSize))
112
+ // Stop once a page reports no next cursor (the end).
113
+ if (index > 0 && (prev == null || prev.next_cursor == null))
109
114
  return null;
110
- return ["app-query-infinite", alias, params ?? {}, pageSize, sort ?? null, filter ?? null, index];
115
+ const cursor = index === 0 ? null : (prev?.next_cursor ?? null);
116
+ return ["app-query-infinite", alias, params ?? {}, pageSize, sort ?? null, filter ?? null, cursor];
111
117
  };
112
118
  const swr = useSWRInfinite(getKey, (key) => {
113
- const index = Number(key[6]);
119
+ const cursor = key[6];
114
120
  return rpc("query", {
115
121
  alias,
116
122
  params: params ?? {},
117
123
  limit: pageSize,
118
- offset: index * pageSize,
124
+ keyset: true,
125
+ cursor: cursor ?? undefined,
119
126
  sort,
120
127
  filter,
121
128
  });
@@ -126,7 +133,8 @@ export function useInfiniteQuery(alias, params, opts) {
126
133
  const pages = (swr.data ?? []).filter((p) => p != null);
127
134
  const rows = mockRows ?? pages.flatMap((p) => p.rows ?? []);
128
135
  const lastPage = pages.length > 0 ? pages[pages.length - 1] : undefined;
129
- const hasMore = lastPage != null && (lastPage.rows?.length ?? 0) === pageSize;
136
+ // A non-null next_cursor means another page exists; null/absent = the end.
137
+ const hasMore = lastPage != null && lastPage.next_cursor != null;
130
138
  const loadingMore = swr.isValidating && swr.size > pages.length;
131
139
  const refetch = useCallback(() => {
132
140
  void swr.mutate();
package/docs/queries.md CHANGED
@@ -246,17 +246,21 @@ addressing is dropped.
246
246
  { "kind": "window", "from": { … },
247
247
  "partition_by": ["customer_id"],
248
248
  "order_by": [{ "field_key": "created", "order": "asc" }],
249
- "frame": { "type": "rows", "following": 0 }, // optional
249
+ "frame": { "type": "rows", "following": 0 }, // optional; frames `aggregates` only
250
250
  "aggregates": [{ "output": "running_total", "type": "number",
251
- "operation": "sum", "input_column": "total" }] }
251
+ "operation": "sum", "input_column": "total" }],
252
+ "functions": [{ "output": "rnk", "fn": "rank" }] } // ranking / navigation (§8)
252
253
  ```
253
254
 
254
- Appends aggregate columns to every input row (input columns pass through). Only the
255
- **OVER-legal** operation subset is accepted (§8). Frame is `rows`-type only:
256
- `preceding`/`following` omitted = unbounded, `0` = current row, `N` = N rows. Omitting `frame`
257
- uses the SQL default with `order_by` that is a *running* frame (partition start current
258
- row, ties included); without `order_by`, the whole partition. Output names must not collide
259
- with input columns.
255
+ Appends columns to every input row (input columns pass through) from two independent lists:
256
+ **`aggregates`** (frame aggregates — the OVER-legal operation subset, §8) and **`functions`**
257
+ (ranking / navigation: `row_number`, `rank`, `dense_rank`, `percent_rank`, `cume_dist`,
258
+ `ntile`, `lag`, `lead`§8). At least one list must be non-empty; both may be present. `frame`
259
+ is `rows`-type only (`preceding`/`following` omitted = unbounded, `0` = current row, `N` = N
260
+ rows) and applies **only** to `aggregates`; omitting it uses the SQL default (with `order_by`, a
261
+ *running* frame partition start → current row, ties included; without, the whole partition).
262
+ `functions` are never framed and need a non-empty `order_by`. Output names must not collide with
263
+ input columns.
260
264
 
261
265
  ### `sort` / `limit` — order and page a derived set
262
266
 
@@ -633,17 +637,30 @@ Sort by the bucket column for a time series; filter it with date operators for a
633
637
  window. **Always bucket server-side** — shipping raw rows to bucket in JS burns the row cap and
634
638
  the timeout for nothing.
635
639
 
636
- ### Window functionsthe OVER-legal subset
640
+ A `window` node carries two independent column lists **`aggregates`** (frame aggregates) and
641
+ **`functions`** (ranking / navigation). At least one must be non-empty; a node may carry both.
637
642
 
638
- Only operations that compile to a single legal SQL window call are accepted in `window`:
639
- **`count`, `sum`, `avg`, `min`, `max`, `earliest`, `latest`, `filled`, `checked`,
640
- `unchecked`.** The rest cannot take an OVER clause (`median` is an ordered-set aggregate;
641
- `unique`/`percent_unique` need DISTINCT; `range`/`empty`/`date_range`/`percent_*` compose
642
- multiple calls) — rejected at deploy.
643
+ **`aggregates` — the OVER-legal aggregate subset.** Only operations that compile to a single
644
+ legal SQL window call are accepted: **`count`, `sum`, `avg`, `min`, `max`, `earliest`, `latest`,
645
+ `filled`, `checked`, `unchecked`.** The rest cannot take an OVER clause (`median` is an
646
+ ordered-set aggregate; `unique`/`percent_unique` need DISTINCT; `range`/`empty`/`date_range`/
647
+ `percent_*` compose multiple calls) — rejected at deploy. The optional `frame` applies **only**
648
+ to these; a `frame` on a window with no `aggregates` is rejected as dead config.
643
649
 
644
- **There are no ranking or navigation functions** no `row_number`, `rank`, `dense_rank`,
645
- `lag`, `lead`, `first_value`. **Top-N per group** is emulated with a running count over a
646
- frame:
650
+ **`functions` ranking / navigation.** Each is `{ "output", "fn", … }` (its own arg shape, no
651
+ `operation`/`type` the output type is fixed or inherited). They never take a `frame` and
652
+ **require a non-empty `order_by`** (ranking without an order is nondeterministic):
653
+
654
+ | `fn` | args | output |
655
+ | --- | --- | --- |
656
+ | `row_number` | — | `number`, non-null. Total order 1..N within the partition; ties broken arbitrarily. |
657
+ | `rank` | — | `number`, non-null. Ties share a rank; the next rank **skips** (1,2,2,4). |
658
+ | `dense_rank` | — | `number`, non-null. Ties share a rank; the next rank does **not** skip (1,2,2,3). |
659
+ | `percent_rank` / `cume_dist` | — | `number`, non-null. Relative position in [0, 1]. |
660
+ | `ntile` | `buckets` (positive int) | `number`, non-null. The row's bucket (1..buckets) splitting the partition into equal groups. |
661
+ | `lag` / `lead` | `input_column`, `offset?` (int ≥ 1, default 1), `default?` (literal) | the input column's type, **nullable**. The value `offset` rows before / after this one; at the partition edge, `default` if given else NULL. `default`'s type must match the input column (checked at deploy). |
662
+
663
+ **Top-N per group** — rank within each partition, then filter on the derived rank column:
647
664
 
648
665
  ```jsonc
649
666
  { "kind": "filter",
@@ -651,14 +668,15 @@ frame:
651
668
  "from": { "kind": "from_table", "table_id": "tbl_orders" },
652
669
  "partition_by": ["customer_id"],
653
670
  "order_by": [{ "field_key": "total", "order": "desc" }],
654
- "frame": { "type": "rows", "following": 0 }, // partition start → current row
655
- "aggregates": [{ "output": "rank", "type": "number", "operation": "count" }] },
656
- "predicate": { "node_type": "condition", "field_key": "rank",
671
+ "functions": [{ "output": "rnk", "fn": "rank" }] },
672
+ "predicate": { "node_type": "condition", "field_key": "rnk",
657
673
  "operator": "less_than_or_equal_to", "value": 3 } }
658
674
  ```
659
675
 
660
- `count` over a `ROWS CURRENT ROW` frame is a row position (`row_number`-like; ties are
661
- ordered arbitrarily unless the `order_by` is total — add a tiebreaker key for determinism).
676
+ `rank` keeps ties (a 3-way tie for 3rd returns all three). For exactly N rows regardless of
677
+ ties, use `row_number` (with a total `order_by` — add a tiebreaker key for determinism).
678
+ **Delta vs the previous row/period** is `lag` (e.g. a project column `{ "expression": "input.total
679
+ - input.prev_total" }` over a `lag` output).
662
680
 
663
681
  ### unpivot vs unnest
664
682
 
@@ -885,9 +903,9 @@ Consolidated from the sections above — these describe present engine behavior:
885
903
 
886
904
  - **No pivot/crosstab node.** Row-values-to-columns happens client-side over grouped results
887
905
  (§8).
888
- - **No ranking or navigation window functions** (`row_number`, `rank`, `lag`, `lead`, …).
889
- Top-N per group = the count-over-frame emulation (§8). Window ops are the 10-item OVER-legal
890
- subset.
906
+ - **No `first_value`/`last_value`/`nth_value` navigation** the `functions` set is `row_number`,
907
+ `rank`, `dense_rank`, `percent_rank`, `cume_dist`, `ntile`, `lag`, `lead` (§8). Frame aggregates
908
+ remain the 10-item OVER-legal subset.
891
909
  - **Offset-only pagination.** Deep pages cost the full skipped prefix; pages can shift under
892
910
  concurrent writes (§9).
893
911
  - **No collation control.** Text ORDER BY uses the database default collation —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.48.0",
3
+ "version": "0.49.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": {