@lotics/app-sdk 0.48.0 → 0.49.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/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
 
@@ -745,17 +763,26 @@ slice, and within it:
745
763
  layer** — `select`/`select_member`/`select_record_link` `has_any_of` / `has_all_of`, and
746
764
  `is_current_member`. These compile to containment the JSONB GIN index serves.
747
765
  - **Trigram-served:** `from_table.search` (§7).
748
- - **Partition scan (linear in table size):** everything else — text `contains`/`equals`,
749
- number and date range predicates, negations (`has_none_of`, `is_none_of`, `not_*`),
750
- emptiness, files predicates. Fine on thousands of rows; on very large tables these dominate
751
- latency and are the usual timeout cause.
752
-
753
- **Filter shape drives latency.** Lead with a GIN-served membership filter or `search` where you
754
- can; let the scan-shaped predicates refine the already-narrowed set. Derived-layer filters run
755
- over the subquery result (no index), so **filter at the source layer whenever the field exists
756
- there** the runtime filter is for caller-driven refinement, not for the main cut. (The engine
757
- pushes eligible filter-over-union predicates down automatically, but don't rely on that for
758
- other shapes.)
766
+ - **B-tree-served (automatic for deployed queries):** text `equals`, number and date
767
+ comparisons (exact and range), and `sort` fields — for fields referenced in a **deployed
768
+ named query's template**. The platform provisions a partial expression index per referenced
769
+ field automatically: built online on `app deploy` and `app query set`, re-synced daily,
770
+ capped at 8 per table (fields past the cap fall back to the scan tier, with a server WARN).
771
+ A `{{params.…}}` value hole doesn't change this the field key is static in the template,
772
+ so it still gets its index. Index-seek speed at any table size once provisioned.
773
+ - **Partition scan (linear in table size):** everything else text `contains`, negations
774
+ (`has_none_of`, `is_none_of`, `not_*`), emptiness, files predicates, and predicates/sorts on
775
+ fields that appear **only** in the runtime `filter`/`sort` options rather than the deployed
776
+ template. Fine on thousands of rows; on very large tables these dominate latency and are the
777
+ usual timeout cause.
778
+
779
+ **Filter shape drives latency.** Equality/range/sort predicates in the deployed template are
780
+ index-served; lead with those or a GIN-served membership filter / `search`, and let scan-shaped
781
+ predicates refine the already-narrowed set. Derived-layer filters run over the subquery result
782
+ (no index), so **filter at the source layer whenever the field exists there** — the runtime
783
+ filter is for caller-driven refinement, not for the main cut (a runtime-only field gets no
784
+ managed index). (The engine pushes eligible filter-over-union predicates down automatically,
785
+ but don't rely on that for other shapes.)
759
786
 
760
787
  ### The authoring rules
761
788
 
@@ -885,9 +912,9 @@ Consolidated from the sections above — these describe present engine behavior:
885
912
 
886
913
  - **No pivot/crosstab node.** Row-values-to-columns happens client-side over grouped results
887
914
  (§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.
915
+ - **No `first_value`/`last_value`/`nth_value` navigation** the `functions` set is `row_number`,
916
+ `rank`, `dense_rank`, `percent_rank`, `cume_dist`, `ntile`, `lag`, `lead` (§8). Frame aggregates
917
+ remain the 10-item OVER-legal subset.
891
918
  - **Offset-only pagination.** Deep pages cost the full skipped prefix; pages can shift under
892
919
  concurrent writes (§9).
893
920
  - **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.1",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {