@lotics/app-sdk 0.47.1 → 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.
@@ -8,10 +8,11 @@
8
8
  * with autocapture OFF (the product captures explicit events, not DOM
9
9
  * autocapture), and that project setting overrides any client `autocapture`
10
10
  * flag — so apps can't rely on autocapture. The SDK instead emits explicit
11
- * events for genuine user actions: `app_opened`, plus `app_workflow_run` /
12
- * `app_file_uploaded` from the hooks. Data-read mechanics (a `useQuery`
13
- * refetch) are a system signal, not a user action, and are deliberately not
14
- * events.
11
+ * events for genuine user actions only: `app_opened`, `app_file_uploaded`, and
12
+ * `app_comment_*`. System signals data-read mechanics (a `useQuery` refetch)
13
+ * and workflow/agent run outcomes are not events: the backend already logs
14
+ * and persists every run (`workflow_executions`, `app_agent_runs`), so a client
15
+ * event would be redundant system telemetry, not a gesture.
15
16
  *
16
17
  * Every event is tagged with app identity and rolls up under the existing
17
18
  * `organization` group; embedded apps `identify` the member the host passes
@@ -93,6 +94,10 @@ export async function bootstrapAnalytics() {
93
94
  autocapture: false,
94
95
  capture_pageview: false,
95
96
  capture_pageleave: false,
97
+ // Dead-click autocapture (enabled by the defaults preset) is a UX-research
98
+ // signal nobody consumes; explicit user actions + exception capture cover
99
+ // the app's monitoring needs.
100
+ capture_dead_clicks: false,
96
101
  // Error tracking (the project opts in). mount() only renders a local
97
102
  // banner, so this is the app's one exception channel.
98
103
  capture_exceptions: true,
package/dist/src/hooks.js CHANGED
@@ -23,17 +23,7 @@ import { initialAgentRunState, reduceAgentChunk, parseSseChunks, } from "./agent
23
23
  import { getMockRows } from "./mock.js";
24
24
  import { captureAppEvent } from "./analytics.js";
25
25
  export function useWorkflow(alias) {
26
- return useCallback(async (inputs) => {
27
- try {
28
- const result = await rpc("workflow", { alias, inputs: inputs ?? {} });
29
- captureAppEvent("app_workflow_run", { alias, ok: result.status !== "error" });
30
- return result;
31
- }
32
- catch (err) {
33
- captureAppEvent("app_workflow_run", { alias, ok: false });
34
- throw err;
35
- }
36
- }, [alias]);
26
+ return useCallback((inputs) => rpc("workflow", { alias, inputs: inputs ?? {} }), [alias]);
37
27
  }
38
28
  // Shared SWR config: surface a failed query immediately, keep the last good
39
29
  // rows (no retry loop that masks the error), and honor the focus/reconnect
@@ -111,21 +101,28 @@ export function useInfiniteQuery(alias, params, opts) {
111
101
  const sort = opts?.sort && opts.sort.length > 0 ? opts.sort : undefined;
112
102
  const filter = opts?.filter;
113
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.
114
109
  const getKey = (index, prev) => {
115
110
  if (mockRows || !enabled)
116
111
  return null;
117
- // Stop once a short page returns.
118
- 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))
119
114
  return null;
120
- 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];
121
117
  };
122
118
  const swr = useSWRInfinite(getKey, (key) => {
123
- const index = Number(key[6]);
119
+ const cursor = key[6];
124
120
  return rpc("query", {
125
121
  alias,
126
122
  params: params ?? {},
127
123
  limit: pageSize,
128
- offset: index * pageSize,
124
+ keyset: true,
125
+ cursor: cursor ?? undefined,
129
126
  sort,
130
127
  filter,
131
128
  });
@@ -136,7 +133,8 @@ export function useInfiniteQuery(alias, params, opts) {
136
133
  const pages = (swr.data ?? []).filter((p) => p != null);
137
134
  const rows = mockRows ?? pages.flatMap((p) => p.rows ?? []);
138
135
  const lastPage = pages.length > 0 ? pages[pages.length - 1] : undefined;
139
- 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;
140
138
  const loadingMore = swr.isValidating && swr.size > pages.length;
141
139
  const refetch = useCallback(() => {
142
140
  void swr.mutate();
@@ -413,7 +411,6 @@ export function useAgentRun(alias) {
413
411
  acc = { ...acc, status: "completed" };
414
412
  safeSetState(acc);
415
413
  }
416
- captureAppEvent("app_agent_run", { alias, ok: acc.status !== "error" });
417
414
  return acc.output;
418
415
  })
419
416
  .catch(async (err) => {
@@ -431,7 +428,6 @@ export function useAgentRun(alias) {
431
428
  ? { ...acc, status: "completed", output: settled.output ?? acc.output }
432
429
  : { ...acc, status: "error", error: settled.error_message ?? "The run was stopped." };
433
430
  safeSetState(acc);
434
- captureAppEvent("app_agent_run", { alias, ok: settled.status === "completed" });
435
431
  return acc.output;
436
432
  }
437
433
  if (aborted)
@@ -439,7 +435,6 @@ export function useAgentRun(alias) {
439
435
  }
440
436
  acc = { ...acc, status: "error", error: err.message };
441
437
  safeSetState(acc);
442
- captureAppEvent("app_agent_run", { alias, ok: false });
443
438
  throw err;
444
439
  });
445
440
  }, [alias, safeSetState]);
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/docs/runtime.md CHANGED
@@ -333,17 +333,18 @@ await checkIn({ latitude: r.coords.latitude, longitude: r.coords.longitude });
333
333
  bundle, invisible to the product's own analytics. **No per-app wiring**: don't
334
334
  install `posthog-js` or call any analytics API from app code.
335
335
 
336
- - **Explicit events only.** Autocapture, pageviews, and session replay are off
337
- (autocapture is disabled at the project level — a client flag could not
338
- re-enable it). Data reads (`useQuery` fetches) are deliberately not events.
336
+ - **Explicit events only — user gestures.** Autocapture, pageviews, and session
337
+ replay are off (autocapture is disabled at the project level — a client flag
338
+ could not re-enable it). System signals are deliberately not events: data
339
+ reads (`useQuery` fetches) and workflow/agent run outcomes. The backend
340
+ already logs and persists every run (`workflow_executions`, `app_agent_runs`),
341
+ so a client run event would be redundant system telemetry, not a gesture.
339
342
  - Events the SDK emits automatically:
340
343
 
341
344
  | Event | Fired when | Properties |
342
345
  |---|---|---|
343
346
  | `app_opened` | analytics finished initializing after `mount()` | — |
344
- | `app_workflow_run` | each `useWorkflow` call settles | `alias`, `ok` |
345
347
  | `app_file_uploaded` | a `useFileUpload` upload succeeds | `mime_type` |
346
- | `app_agent_run` | a `useAgentRun` run finishes | `alias`, `ok` |
347
348
  | `app_comment_created` | a comment is created via `useComments` | `has_files` |
348
349
  | `app_comment_updated` / `app_comment_deleted` | comment edit/delete succeeds | — |
349
350
 
@@ -359,9 +360,8 @@ install `posthog-js` or call any analytics API from app code.
359
360
  and never breaks the app. Events fired before init completes are buffered
360
361
  (bounded) and drained on init.
361
362
  - **Limitation:** there is no public API for custom app events — the capture
362
- function is internal to the SDK. If a bespoke funnel matters, model it as a
363
- workflow (which produces `app_workflow_run`) or request the surface as a
364
- platform change.
363
+ function is internal to the SDK. If a bespoke funnel matters, request the
364
+ surface as a platform change.
365
365
  - **Limitation:** PostHog's default bot/user-agent filter applies — headless
366
366
  browsers (e.g. Playwright) are never tracked, so analytics cannot be verified
367
367
  through headless automation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.47.1",
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": {