@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.
- package/dist/src/analytics.js +9 -4
- package/dist/src/hooks.js +15 -20
- package/docs/queries.md +43 -25
- package/docs/runtime.md +8 -8
- package/package.json +1 -1
package/dist/src/analytics.js
CHANGED
|
@@ -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`,
|
|
12
|
-
* `
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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(
|
|
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
|
|
118
|
-
if (index > 0 && (prev == null || prev.
|
|
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
|
-
|
|
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
|
|
119
|
+
const cursor = key[6];
|
|
124
120
|
return rpc("query", {
|
|
125
121
|
alias,
|
|
126
122
|
params: params ?? {},
|
|
127
123
|
limit: pageSize,
|
|
128
|
-
|
|
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
|
-
|
|
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
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
with
|
|
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
|
-
|
|
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
|
|
639
|
-
**`count`, `sum`, `avg`, `min`, `max`, `earliest`, `latest`,
|
|
640
|
-
`unchecked`.** The rest cannot take an OVER clause (`median` is an
|
|
641
|
-
`unique`/`percent_unique` need DISTINCT; `range`/`empty`/`date_range
|
|
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
|
-
|
|
645
|
-
`
|
|
646
|
-
|
|
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
|
-
"
|
|
655
|
-
|
|
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
|
-
`
|
|
661
|
-
|
|
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
|
|
889
|
-
|
|
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
|
|
337
|
-
(autocapture is disabled at the project level — a client flag
|
|
338
|
-
re-enable it).
|
|
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,
|
|
363
|
-
|
|
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.
|