@lotics/app-sdk 0.51.2 → 0.51.5

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/docs/ai.md CHANGED
@@ -121,16 +121,17 @@ Both settle the in-flight `run()` promise cleanly with `undefined` — a stop is
121
121
 
122
122
  ### Runs survive dropped connections
123
123
 
124
- The run's lifetime is decoupled from the stream: the server drives it to completion and persists the result even if the connection drops. The hook reads the run id from the stream's start; if the connection then fails, it **polls the persisted run to completion** (every 2.5 s, bounded at 11 minutes) and resolves with the settled result instead of surfacing a network error — a long extraction is never lost to a flaky connection. Only when no run id was ever received (the run never started) does the failure reject.
124
+ The run's lifetime is decoupled from the stream: the server drives it to completion and persists the result even if the connection drops. The hook reads the run id from the stream's start; if the connection then fails, it **polls the persisted run to completion** (every 2.5 s, bounded at 11 minutes) and resolves with the settled result instead of surfacing a network error — so a run that settles within that 11-minute poll window survives a flaky connection. A run still going when the poll deadline passes (the hard cap is 20 min) is **not** recovered on the client: the poll gives up and the drop surfaces as an error, though the result is still persisted server-side. Only when no run id was ever received (the run never started) does the failure reject before any polling.
125
125
 
126
126
  **Warning:** on this recovery path the hook adopts the *persisted* run's output — and a **free-text** run is persisted with its final answer text as the output. So after a connection-drop recovery, a free-text agent's `output` can carry the answer **string** (the one place the "never a stray string" rule doesn't hold), while `text` keeps only what streamed before the drop. If you consume free-text agents, guard with `typeof output === "string"` on the resolved value.
127
127
 
128
128
  ### Sessions
129
129
 
130
- `run()` requires `{ sessionId }` — an **app-minted opaque key** grouping runs into a working session. Each run replays the session's prior **completed** runs (their inputs and outputs, including re-materialized image/PDF inputs) as conversation context, so a follow-up like "make it a bit less" resolves against the previous result. Two rules keep this sane:
130
+ `run()` requires `{ sessionId }` — an **app-minted opaque key** grouping runs into a working session. Each run replays the session's prior **completed** runs (their inputs and outputs, including re-materialized image/PDF inputs) as conversation context, so a follow-up like "make it a bit less" resolves against the previous result. Three rules keep this sane:
131
131
 
132
132
  - **The app owns the state.** Always pass the authoritative current state in `input` — the session context is memory, not the source of truth.
133
133
  - **Mint a new `sessionId` to clear context.** There is no reset call; a fresh key is a fresh session.
134
+ - **One-shot agents get a fresh `sessionId` per run.** Session context only pays for conversational follow-ups. If every run is self-contained (the app passes the complete input each time — e.g. a document-extraction agent), reusing a key replays dead context into every request — every replayed run still bills its input tokens, so a one-shot agent on a shared key pays for history it never uses. Append a per-run nonce to the key instead. (Media itself replays as a provider file reference — uploaded once, referenced by id — so a long session no longer grows toward the provider's request-size cap; the cost of dead context is tokens, not payload.)
134
135
 
135
136
  Sessions are scoped to the authenticated member who ran them: two members using the same `sessionId` string share nothing, and a member can never read or extend another member's thread.
136
137
 
@@ -96,24 +96,39 @@ server validates system conditions by `type` and never reads `field_key` on them
96
96
  | `query execution failed` | The query failed at the database. Deliberately generic — database internals are never sent to the client. | The app author diagnoses from the platform's server logs; the app surfaces the message. |
97
97
  | A specific validation message | e.g. an un-projected `field_key` in runtime `sort`/`filter`, invalid params, an unknown alias. | Fix the call site — these are contract violations, not transient. |
98
98
 
99
- ## Pagination realities (offset-based)
99
+ ## Pagination two models
100
100
 
101
- All paging `useInfiniteQuery` pages, `usePaginatedQuery` pages, and manual `rpc("query", { limit,
102
- offset })` — is **offset pagination**. There is no cursor API. The consequences are part of the
103
- contract:
101
+ Reads paginate two different ways, and the difference is load-bearing:
102
+
103
+ - **`useInfiniteQuery` uses keyset (seek) pagination.** Each page seeks past the previous page's
104
+ opaque cursor instead of counting an offset, so a deep scroll stays O(page) and — the reason it
105
+ matters — **never skips or duplicates a row as the set shifts** under concurrent inserts/deletes.
106
+ The cursor is internal: the hook sends it and reads the next one back; you never see it. The
107
+ server keysets a single sortable key (the runtime `sort`, or the record-id default) and falls
108
+ back to offset transparently for a multi-key sort it can't seek. So an infinite feed needs **no
109
+ dedupe** — key rows by `__source_record_id` for stable React keys, not to guard against repeats.
110
+ - **`useQuery`, `usePaginatedQuery`, and manual `rpc("query", { limit, offset })` use offset
111
+ pagination** — count `offset` rows, skip them, return the next page. Two consequences a keyset
112
+ scroll doesn't have: deep pages cost more, and pages shift under concurrent writes.
113
+
114
+ Shared by both:
104
115
 
105
116
  - **Row cap: 10,000.** No single query response returns more than 10,000 rows; a larger `limit`
106
117
  (or an omitted `pageSize` on `useQuery`) is clamped to it. To read a bigger result set, page
107
118
  through it.
119
+ - **Always give a paginated query a deterministic order.** With no `ORDER BY` (neither in the
120
+ template nor runtime `sort`), row order is unspecified — offset pages may overlap or skip rows
121
+ even without concurrent writes, and keyset falls back to the record-id order. Sort by a stable
122
+ column (unique where possible).
123
+
124
+ Offset-only (the `usePaginatedQuery` numbered pages and the `useQuery` / manual `rpc` cap paths):
125
+
108
126
  - **Deep pages cost more.** `offset: n` makes the server produce and discard `n` rows before the
109
127
  page — page 200 is materially slower than page 2. Prefer narrowing filters over deep paging.
110
128
  - **Pages can shift under concurrent writes.** A row inserted/deleted between two page fetches
111
- shifts every later offset — a row can appear on two consecutive pages or fall between them. An
112
- infinite list accumulates pages fetched at different times, so treat duplicates as expected:
113
- key rows by `__source_record_id` (never by array index) and dedupe if the list must be exact.
114
- - **Always give a paginated query a deterministic order.** With no `ORDER BY` (neither in the
115
- template nor runtime `sort`), row order is unspecified and pages may overlap or skip rows even
116
- without concurrent writes. Sort by a stable column (unique where possible).
129
+ shifts every later offset — a row can appear on two consecutive pages or fall between them. If
130
+ you concatenate offset pages yourself, key rows by `__source_record_id` (never by array index)
131
+ and dedupe if the list must be exact.
117
132
  - **`count: true` counts the filtered set**, ignoring sort/limit/offset — `usePaginatedQuery`
118
133
  issues it automatically. The count and the page are separate requests, so under concurrent
119
134
  writes `total` can briefly disagree with what paging finds.
@@ -134,16 +149,22 @@ decode cells with the readers below.
134
149
  const { rows, loadMore, hasMore, loadingMore } = useInfiniteQuery("feed", {}, { pageSize: 30 });
135
150
  ```
136
151
 
137
- The first render loads one page; `loadMore()` appends the next, accumulating into `rows`.
152
+ The first render loads one page; `loadMore()` appends the next, accumulating into `rows`. Paging is
153
+ **keyset (seek)** — appended pages never skip or duplicate a row as the set shifts (the pagination
154
+ models above).
138
155
 
139
- - `hasMore` is `true` when the last page came back **full** so a total that is an exact multiple
140
- of `pageSize` costs one final empty fetch before `hasMore` turns `false`.
156
+ - `hasMore` is `true` while the last page came back **full** (a non-null cursor came back) so a
157
+ total that is an exact multiple of `pageSize` costs one final short fetch before `hasMore` turns
158
+ `false`.
141
159
  - `loadMore()` is a no-op while `loadingMore` is `true` or when `hasMore` is `false` — safe to wire
142
160
  directly to a scroll sentinel.
143
161
  - Changing any part of the result-set identity (`params`, `sort`, `filter`, `pageSize`) starts a
144
162
  fresh accumulation from the first page.
145
163
  - `refetch()` revalidates every loaded page. Appending a page does **not** re-fetch earlier pages,
146
- so a long-lived list mixes page snapshots taken at different times (see the offset notes above).
164
+ so a long-lived list still mixes page snapshots taken at different times (an edit between two
165
+ page loads shows old and new side by side) — but keyset guarantees no row is skipped or repeated.
166
+ - **Standalone (public) apps:** `loadMore()` can't advance past page one — the cursor is dropped in
167
+ transit (see [Standalone transport](#standalone-public-transport)).
147
168
 
148
169
  ### `usePaginatedQuery`
149
170
 
@@ -168,6 +189,26 @@ page cursor and fetches two things: the current page, and a `count` over the fil
168
189
  `keepPreviousData`) — gate any skeleton on `loading && rows.length === 0`, never on `loading`
169
190
  alone, or every page click collapses the table.
170
191
 
192
+ ## Standalone (public) transport
193
+
194
+ An app served standalone at `<slug>.lotics.app` (a public share with no Lotics host — see
195
+ [runtime](./runtime.md)) reaches the query endpoint through a thinner transport that forwards only
196
+ `alias`, `params`, `limit`, and `offset`. It **silently drops** `sort`, `filter`, `count`, and the
197
+ keyset `cursor`. Design a public app around this:
198
+
199
+ - **Runtime `sort` / `filter` are ignored** — every hook's `opts.sort` / `opts.filter` is dropped,
200
+ so a standalone app can't order or narrow a query at the call site. Bake ordering and scoping
201
+ into the query **template** (or drive them through declared `params`), not the refinement options.
202
+ - **`usePaginatedQuery.total` stays `undefined`** — the `count` request never resolves, so
203
+ `totalPages` never lands and `hasMore` falls back to "the current page came back full". A
204
+ numbered "Page 1 of N" control has no N; render it defensively, or prefer `useInfiniteQuery`.
205
+ - **`useInfiniteQuery.loadMore()` never advances past the first page** — with the cursor dropped,
206
+ no `next_cursor` comes back, so `hasMore` is `false` after page one. For a standalone browse,
207
+ size the template `limit` (or a `params`-driven page) to return the whole set in one fetch.
208
+
209
+ The embedded product host and the `lotics app dev` forwarder pass all of these through — this
210
+ caveat is standalone-only.
211
+
171
212
  ## Decoding query cells
172
213
 
173
214
  `useQuery` rows are `Record<string, unknown>`. The server serializes each output column by type;
package/docs/queries.md CHANGED
@@ -708,17 +708,24 @@ template as derived nodes, in this order: `filter` (narrow) → `sort` (order)
708
708
  it. Record-link membership (`has_any_of` by id) **works** here; so does `is_current_member`
709
709
  on a member column. Traversals / `locked` / `current_member` do not — bake those into the
710
710
  template.
711
- - **`sort`** — `[{ field_key, order, blank_position? }]` over output columns.
712
- - **`limit` / `offset`** offset pagination. `limit` is clamped to the 10,000-row cap (§10).
711
+ - **`sort`** — `[{ field_key, order }]` over output columns — the typed `QuerySortKey` the hooks
712
+ accept. The wire sort node also honors `blank_position?: "top" | "bottom"` (blanks default to
713
+ bottom), but that field is **not** on the typed hook option; reach it only through a raw
714
+ `rpc("query", { sort })` call.
715
+ - **`limit` / `offset` / keyset `cursor`** — pagination. `useQuery` / `usePaginatedQuery` page by
716
+ `offset` (`limit` clamped to the 10,000-row cap, §10); `useInfiniteQuery` opts into **keyset
717
+ (seek)** by sending `keyset: true` + the prior page's `cursor`, and the server returns the next
718
+ `next_cursor` (null on the last page). Seek stays O(page) and never skips/duplicates a row as the
719
+ set shifts, falling back to offset for a multi-key sort it can't seek.
713
720
  - **`count: true`** — returns `{ total }` only: a single-row COUNT over the *filtered* set,
714
721
  ignoring sort/limit/offset. Drives "Page 1 of N".
715
722
 
716
723
  The SDK hooks map onto this directly (`dist/src/hooks.d.ts` for exact signatures): `useQuery`
717
724
  sends `limit: pageSize, offset: 0` (a cap, not pagination) plus `opts.sort`/`opts.filter`;
718
- `useInfiniteQuery` pages by `offset = page × pageSize`; `usePaginatedQuery` owns the page
719
- cursor and issues the page query plus a `count` keyed on `(alias, params, filter)`
720
- independent of page and sort, so paging and re-sorting never recount, while changing
721
- `(params, filter)` resets to page 0 and recounts.
725
+ `useInfiniteQuery` pages by **keyset** (`keyset: true` + the prior `next_cursor`), so its scroll
726
+ never skips or duplicates a row; `usePaginatedQuery` owns the page cursor, pages by `offset`, and
727
+ issues a `count` keyed on `(alias, params, filter)` — independent of page and sort, so paging and
728
+ re-sorting never recount, while changing `(params, filter)` resets to page 0 and recounts.
722
729
 
723
730
  Build `filter` from UI column-filters with `columnFilterToConditions` (`@lotics/ui`); prefer
724
731
  `useFieldOptions` for a select filter's option set.
@@ -730,11 +737,12 @@ silently ignored there and `count` never resolves (`usePaginatedQuery.total` sta
730
737
  `undefined`). A standalone app must bake ordering/scoping into the template (or params) rather
731
738
  than rely on runtime refinement.
732
739
 
733
- **Pagination semantics:** offset-only a deep page costs the server the full skipped prefix
734
- (page 400 of a 25-row pager scans ~10,000 rows before returning 25), and pages can shift under
735
- concurrent writes (a row inserted before your offset repeats or skips a row across pages).
736
- There is no cursor/keyset pagination. Keep paginated browses filtered and sorted by a stable
737
- key, and don't build UX that walks thousands of pages.
740
+ **Pagination semantics.** `useQuery` / `usePaginatedQuery` and manual `rpc("query", { limit,
741
+ offset })` are **offset** — a deep page costs the server the full skipped prefix (page 400 of a
742
+ 25-row pager scans ~10,000 rows before returning 25), and pages can shift under concurrent writes
743
+ (a row inserted before your offset repeats or skips a row across pages). `useInfiniteQuery` is
744
+ **keyset (seek)** deep scrolls stay O(page) and never skip/duplicate rows. Keep offset browses
745
+ filtered and sorted by a stable key, and don't build numbered UX that walks thousands of pages.
738
746
 
739
747
  ---
740
748
 
@@ -924,8 +932,9 @@ Consolidated from the sections above — these describe present engine behavior:
924
932
  - **No `first_value`/`last_value`/`nth_value` navigation** — the `functions` set is `row_number`,
925
933
  `rank`, `dense_rank`, `percent_rank`, `cume_dist`, `ntile`, `lag`, `lead` (§8). Frame aggregates
926
934
  remain the 10-item OVER-legal subset.
927
- - **Offset-only pagination.** Deep pages cost the full skipped prefix; pages can shift under
928
- concurrent writes (§9).
935
+ - **`useQuery` / numbered pagination is offset.** Deep pages cost the full skipped prefix and can
936
+ shift under concurrent writes; `useInfiniteQuery` uses keyset (seek), which stays O(page) and
937
+ never skips or duplicates rows (§9).
929
938
  - **No collation control.** Text ORDER BY uses the database default collation —
930
939
  locale-specific alphabetical order (e.g. accented-letter ordering) is not configurable.
931
940
  - **Select / member / link columns sort by raw JSON** in queries — not by configured option
package/docs/runtime.md CHANGED
@@ -169,12 +169,16 @@ surfaces:
169
169
  | `query`, `field_options`, `workflow`, `members`, `context`, `binding`, `upload`, `urlState.get/set`, `openExternal` | yes | yes | yes |
170
170
  | `comments.*` | yes | yes | rejects — `"Comments are available only in embedded apps — a signed-in member is required."` |
171
171
  | `agentRun` (streaming, internal to `useAgentRun`) | yes | yes | yes |
172
- | `agentRuns`, `agentRun.get`, `agentRun.cancel` | yes | **no** — the dev forwarder doesn't implement them (`"Unknown RPC op: …"`) | yes |
172
+ | `agentRun.get`, `agentRun.cancel` | yes | **no** — the dev forwarder doesn't implement them (`"Unknown RPC op: …"`) | yes |
173
+ | `agentRuns` (session history) | **no** — the product host doesn't implement it either (`"Unknown RPC op: agentRuns"`) | **no** | yes |
173
174
  | `askAi` | yes | **no** — `"Unknown RPC op: askAi"` | rejects — `"askAi is only available when the app runs inside Lotics"` |
174
175
 
175
- **Limitation:** in the dev loop, run *history* and server-side *cancel* for
176
- agent runs error (the local abort of a live stream still works); verify those
177
- paths on a deployed app. The standalone `query` transport forwards only
176
+ **Limitation:** agent-run **history** (`agentRuns`) is not implemented by the
177
+ embedded product host *or* the dev forwarder both error `"Unknown RPC op:
178
+ agentRuns"`. Don't build a session-log UI on `useAgentRuns`; keep the log in app
179
+ state from live `useAgentRun` results (see [ai](./ai.md)). Server-side *cancel*
180
+ also errors in the dev loop (the local abort of a live stream still works) —
181
+ verify cancel on a deployed app. The standalone `query` transport forwards only
178
182
  `alias`/`params`/`limit`/`offset` — runtime `sort`/`filter`/`count` refinement
179
183
  is embedded-only (see [data fetching](./data_fetching.md)).
180
184
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.51.2",
3
+ "version": "0.51.5",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {