@lotics/app-sdk 0.79.1 → 0.80.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/AGENTS.md CHANGED
@@ -16,13 +16,13 @@ signature; open the file.**
16
16
  |---|---|
17
17
  | [docs/recipes.md](./docs/recipes.md) | Task-shaped how-tos for the actions whose mechanism is not guessable from the hooks — returning a generated file, returning structured data, parameterized lookups, composable optional filters, cell decoding, testing an AI action without spending credits. |
18
18
  | [docs/queries.md](./docs/queries.md) | **The query engine authoring reference** — AST node kinds, per-field-type operator support, filters/params/pruning, free-text search, combining tables (join/union/link/`unnest`/`record_id`), shaping (aggregates, date buckets, windows), runtime refinement bounds, limits & the efficiency playbook. |
19
- | [docs/data_fetching.md](./docs/data_fetching.md) | The three read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`), the `QueryRow` shape (projected columns `unknown`; `__source_record_id`/`__source_table_id` typed but optional), cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `useFieldOptions`, caching — **arrival revalidates** (a re-mount renders cache *and* refreshes it in the background, `loading` never flips) — data discipline, the pagination count as a second full execution (and `total` to suppress it), the search-as-you-type + record-picker patterns. |
19
+ | [docs/data_fetching.md](./docs/data_fetching.md) | The three read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`), the `QueryRow` shape (projected columns `unknown`; `__source_record_id`/`__source_table_id` typed but optional), cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `useFieldOptions`, caching — **arrival revalidates** (a re-mount renders cache *and* refreshes it in the background, `loading` never flips) — **realtime push** (a table one of your queries reads changes and that query refetches within about a second, alias-precise, records-only, host-embedded apps only), data discipline, the pagination count as a second full execution (and `total` to suppress it), the search-as-you-type + record-picker patterns. |
20
20
  | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract, typed inputs, diff-before-update, locked records, `useOptimistic`, `useNewRecord` (client-minted `rec_*` id so a new-record surface never remounts on its first save), read-after-write ordering (a re-read must not overtake an in-flight write). |
21
21
  | [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY authoring reference** — the JS subset a `src/workflows/<alias>.ts` body may use: the parse-at-save/never-execute model, opaque `fld_*`/`opt_*` keys, expression sources + link descent, every step form (tool call, `agent`, waits, `validate`, `return`), the accepted sugar and its canonical lowering, helpers + callback rules, record-write surfaces, the traps, the bright line, and the verify loop — `check` (the only local gate: the app's own `npm run typecheck` never sees a body) → `dry_run_workflow` (static green is not a run) → `set`. |
22
22
  | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs (**a bearer credential for the bytes** — never logged, reported, or persisted), workflow-generated files, **naming a zip's entries** (`{ id, name }` per file — a file name, never a path), preview pairing, filter operators, the server-side delivery bounds. **Uploads declare a `fidelity`** (`standard` / `high` / `original`) — the app picks how much of the image survives storage; use `high` whenever text must stay legible. |
23
23
  | [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions`, `useViewer`, `useComments`, and the `@lotics/ui` components they feed. |
24
24
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
25
- | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `AgentRun`, the agent's ask-back — `pendingChoice`/`answerChoice` over the parked `awaiting_input` state — and the `AgentRunLanding` every leg resolves), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics, auto query-refetch on chat mutation). **A `file` input carries its own content** — no reader tool to declare. **An agent reaches record DATA only through its declared `query_aliases` / `workflow_aliases`.** Also **what the member's own chat agent can do with your app while it is open** — the alias catalog it reads and how to shape a mutating alias for it. |
25
+ | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `AgentRun`, the agent's ask-back — `pendingChoice`/`answerChoice` over the parked `awaiting_input` state — and the `AgentRunLanding` every leg resolves), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics; a chat mutation refetches your queries through the realtime channel, not a separate poke). **A `file` input carries its own content** — no reader tool to declare. **An agent reaches record DATA only through its declared `query_aliases` / `workflow_aliases`.** Also **what the member's own chat agent can do with your app while it is open** — the alias catalog it reads and how to shape a mutating alias for it. |
26
26
  | [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, what runtime refinement cannot widen, and why a per-input bound is a tenancy floor rather than an authorization check (a caller-supplied id must be intersected with the record server-side). |
27
27
  | [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, `rpc()`, `openExternal`/`downloadFile`, geofencing, analytics, `useConfig` (App-Packages installation config), `getAppBinding` (package apps' runtime `F`/`OPT`/`ROLE` resolution via the generated `.lotics/app_fields.ts`), and the publish chain for package contributors. |
28
28
 
package/dist/src/hooks.js CHANGED
@@ -19,7 +19,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
19
19
  import { DEFAULT_IMAGE_FIDELITY } from "./upload/optimize.js";
20
20
  import useSWR from "swr";
21
21
  import useSWRInfinite from "swr/infinite";
22
- import { rpc, rpcAgentRun, rpcAgentRunContinue, postHostNotification, subscribeHostRefetch, } from "./rpc.js";
22
+ import { rpc, rpcAgentRun, rpcAgentRunContinue, postHostNotification, subscribeQueriesChanged, } from "./rpc.js";
23
23
  import { initialAgentRunState, reduceAgentChunk, parseSseChunks, adoptSettledRun, pendingInteractiveCall, buildChoiceOutput, applyInteractiveAnswer, landingOf, proseOf, ABORTED, } from "./agent_stream.js";
24
24
  import { getMockRows, hasMockFlag } from "./mock.js";
25
25
  export { buildChoiceOutput } from "./agent_stream.js";
@@ -52,17 +52,27 @@ function swrConfig(revalidateOnFocus) {
52
52
  };
53
53
  }
54
54
  /**
55
- * Re-run `refetch` when the host pushes `refetchQueries` after an ambient
56
- * app-chat agent turn mutated records, every mounted query hook refreshes the
57
- * data it rendered. Inert in mock mode (no host, no listener) and for a mocked
58
- * alias (its rows come from the fixture). Shared by all three query hooks.
55
+ * Refetch this query when the host reports that ITS tables changed — whoever
56
+ * wrote them: another member, a workflow, the chat agent, or an external agent
57
+ * over the CLI or MCP.
58
+ *
59
+ * The host names the affected aliases rather than telling every hook to
60
+ * re-read: an app's queries usually span several tables, and a blanket refetch
61
+ * would re-run expensive aggregates on a change to a table they never touch.
62
+ *
63
+ * Inert in mock mode (no host, no listener), for a mocked alias (its rows come
64
+ * from the fixture), and for a standalone/public app (no host to push).
65
+ * Shared by all three query hooks.
59
66
  */
60
- function useHostRefetch(refetch, mockRows) {
67
+ function useHostRefetch(alias, refetch, mockRows) {
61
68
  useEffect(() => {
62
69
  if (hasMockFlag() || mockRows)
63
70
  return;
64
- return subscribeHostRefetch(refetch);
65
- }, [refetch, mockRows]);
71
+ return subscribeQueriesChanged((aliases) => {
72
+ if (aliases.includes(alias))
73
+ refetch();
74
+ });
75
+ }, [alias, refetch, mockRows]);
66
76
  }
67
77
  export function useQuery(alias, params, opts) {
68
78
  const pageSize = opts?.pageSize;
@@ -88,7 +98,7 @@ export function useQuery(alias, params, opts) {
88
98
  const refetch = useCallback(() => {
89
99
  void swr.mutate();
90
100
  }, [swr]);
91
- useHostRefetch(refetch, mockRows);
101
+ useHostRefetch(alias, refetch, mockRows);
92
102
  return {
93
103
  rows: mockRows ?? swr.data?.rows ?? [],
94
104
  loading: mockRows ? false : swr.isLoading,
@@ -182,7 +192,7 @@ export function useInfiniteQuery(alias, params, opts) {
182
192
  const refetch = useCallback(() => {
183
193
  void swr.mutate();
184
194
  }, [swr]);
185
- useHostRefetch(refetch, mockRows);
195
+ useHostRefetch(alias, refetch, mockRows);
186
196
  const loadMore = useCallback(() => {
187
197
  if (!hasMore || loadingMore)
188
198
  return;
@@ -251,7 +261,7 @@ export function usePaginatedQuery(alias, params, opts) {
251
261
  void rowsSwr.mutate();
252
262
  void countSwr.mutate();
253
263
  }, [rowsSwr, countSwr]);
254
- useHostRefetch(refetch, mockRows);
264
+ useHostRefetch(alias, refetch, mockRows);
255
265
  return {
256
266
  rows,
257
267
  total,
package/dist/src/rpc.d.ts CHANGED
@@ -135,12 +135,20 @@ export declare function subscribeUrlParams(cb: (params: UrlParams) => void): ()
135
135
  */
136
136
  export declare function postHostNotification(message: HostNotification): void;
137
137
  /**
138
- * Subscribe to the host's `refetchQueries` push. Returns an unsubscribe fn.
139
- * Wired by every mounted query hook so an ambient-chat record mutation refreshes
140
- * exactly the data currently on screen. No host ever posts it standalone, so the
141
- * subscription is inert there.
138
+ * Subscribe to the host's `queriesChanged` push, which names the aliases whose
139
+ * underlying tables moved. Returns an unsubscribe fn.
140
+ *
141
+ * The host owns the realtime connection — one per tab, shared by every surface
142
+ * in it — because the app frame deliberately holds no platform credentials, and
143
+ * a second socket per app would only duplicate a subscription the host already
144
+ * has. The host resolves changed tables to aliases (it holds the query ASTs)
145
+ * and names them here, so a mounted hook refetches only when ITS query is
146
+ * affected rather than on every change anywhere in the workspace.
147
+ *
148
+ * A standalone (public) app has no host, so this is inert there — those apps
149
+ * stay on pull-based freshness.
142
150
  */
143
- export declare function subscribeHostRefetch(cb: () => void): () => void;
151
+ export declare function subscribeQueriesChanged(cb: (aliases: string[]) => void): () => void;
144
152
  /**
145
153
  * Start a streaming agent run. Each raw SSE text chunk is handed to `onText`
146
154
  * (the caller parses it via `agent_stream`); `done` settles when the stream
package/dist/src/rpc.js CHANGED
@@ -91,12 +91,20 @@ export function postHostNotification(message) {
91
91
  window.parent.postMessage(message, hostOrigin);
92
92
  }
93
93
  /**
94
- * Subscribe to the host's `refetchQueries` push. Returns an unsubscribe fn.
95
- * Wired by every mounted query hook so an ambient-chat record mutation refreshes
96
- * exactly the data currently on screen. No host ever posts it standalone, so the
97
- * subscription is inert there.
94
+ * Subscribe to the host's `queriesChanged` push, which names the aliases whose
95
+ * underlying tables moved. Returns an unsubscribe fn.
96
+ *
97
+ * The host owns the realtime connection — one per tab, shared by every surface
98
+ * in it — because the app frame deliberately holds no platform credentials, and
99
+ * a second socket per app would only duplicate a subscription the host already
100
+ * has. The host resolves changed tables to aliases (it holds the query ASTs)
101
+ * and names them here, so a mounted hook refetches only when ITS query is
102
+ * affected rather than on every change anywhere in the workspace.
103
+ *
104
+ * A standalone (public) app has no host, so this is inert there — those apps
105
+ * stay on pull-based freshness.
98
106
  */
99
- export function subscribeHostRefetch(cb) {
107
+ export function subscribeQueriesChanged(cb) {
100
108
  ensureListener();
101
109
  refetchSubscribers.add(cb);
102
110
  return () => {
@@ -108,7 +116,7 @@ const streaming = new Map();
108
116
  /** `useUrlState` subscribers — notified when the host broadcasts new params
109
117
  * after browser back/forward. */
110
118
  const urlStateSubscribers = new Set();
111
- /** Query hooks subscribed to the host's `refetchQueries` push — the host sends
119
+ /** Query hooks subscribed to the host's `queriesChanged` push — the host sends
112
120
  * it after an ambient app-chat agent turn mutated records. */
113
121
  const refetchSubscribers = new Set();
114
122
  let nextRpcId = 0;
@@ -131,13 +139,13 @@ function ensureListener() {
131
139
  cb(msg.params);
132
140
  return;
133
141
  }
134
- // Broadcast (no id): the host tells the app its rendered data may be stale
135
- // after an ambient app-chat agent turn mutated records every mounted query
136
- // hook re-reads through its own declared queries. Push-only freshness; the
142
+ // Broadcast (no id): the host names the aliases whose tables changed, from
143
+ // the realtime channel it holds for the whole tab. Push-only freshness; the
137
144
  // host never reads app data through this path.
138
- if (msg.type === "refetchQueries") {
145
+ if (msg.type === "queriesChanged" && Array.isArray(msg.aliases)) {
146
+ const aliases = msg.aliases.filter((a) => typeof a === "string");
139
147
  for (const cb of refetchSubscribers)
140
- cb();
148
+ cb(aliases);
141
149
  return;
142
150
  }
143
151
  if (typeof msg.id !== "number")
@@ -71,11 +71,19 @@ server validates system conditions by `type` and never reads `field_key` on them
71
71
  renders the cached rows instantly *and* revalidates them in the background, so a list reflects what
72
72
  another screen changed while you were away. Identical concurrent reads dedupe to one request.
73
73
  Freshness comes from four places — arrival, window focus / tab return / network reconnect
74
- (`revalidateOnFocus`, default on), the host's post-chat-turn poke, and explicit `refetch()`.
74
+ (`revalidateOnFocus`, default on), a **realtime push**, and explicit `refetch()`.
75
75
  **Still call `refetch()` after a write the user is watching for**: arrival covers navigation, not a
76
76
  mutation made on the screen you are already standing on.
77
+ - **Realtime push keeps an already-open screen current.** When a table one of your queries reads
78
+ changes — another member, a workflow, the chat agent, or an external agent writing over the CLI or
79
+ MCP — that query refetches within about a second. Nothing to wire: it follows from the query's own
80
+ declaration. It refetches only the aliases whose tables actually moved, so an expensive aggregate
81
+ is not re-run by a change to a table it never reads. Two limits worth knowing: it carries RECORD
82
+ changes only (a schema edit does not push), and a **standalone/public app has no host**, so those
83
+ apps keep focus/reconnect freshness alone. Treat push as an improvement on pull, never a
84
+ replacement — it is absent whenever the channel cannot connect.
77
85
  - **`useInfiniteQuery` does not arrival-revalidate** — the one exception. A warm feed re-mounted
78
- refetches nothing; a cold key still fetches, and focus / reconnect / the chat poke still refresh.
86
+ refetches nothing; a cold key still fetches, and focus / reconnect / a realtime push still refresh.
79
87
  Re-fetching page 1 on arrival would cost a request on every `loadMore()` and reshuffle the top of
80
88
  the feed under a reader scrolling further down it, and re-fetching every loaded page grows without
81
89
  bound. **A feed that must be fresh on arrival calls `refetch()`.**
@@ -89,6 +97,17 @@ server validates system conditions by `type` and never reads `field_key` on them
89
97
  - **`error`** is a `string | null`. A failed query surfaces immediately — there is **no automatic
90
98
  retry** (no retry loop that masks the error). The last successful rows for the same key stay
91
99
  rendered. The next focus revalidation or an explicit `refetch()` re-runs it.
100
+ - **Never derive a FIGURE from `rows` without gating on `error`.** On a failure `rows` is `[]`,
101
+ and `[]` is indistinguishable from a genuinely empty result — so `rows.reduce(…)` returns `0`,
102
+ `rows.length` returns `0`, and a screen states a confident number that no query answered. This
103
+ is the one place the empty-vs-broken distinction is load-bearing: a list that renders nothing
104
+ looks obviously wrong, whereas a total that reads `0` looks *fine*. It cost a sales register
105
+ nine days of showing every rep `0 ₫` of commission, because one query's runtime filter named a
106
+ column it did not project and nothing read `error`. A count, a sum, a "N of M", a progress
107
+ meter, a badge — anything a reader would act on — renders a dash and the failure, never a
108
+ number, while `error !== null`. Note a key CHANGE resets this: a new `params`/`filter`/`sort`
109
+ is a fresh key with no prior rows, so the "last successful rows stay rendered" behaviour above
110
+ does not save you.
92
111
  - **`refetch()`** re-runs the query. Call it after a known mutation point — a successful
93
112
  `useWorkflow` call — to pull the latest state (see [./mutations.md](./mutations.md)).
94
113
  `usePaginatedQuery.refetch()` refreshes the page, and the count when the hook owns it
@@ -110,7 +129,7 @@ server validates system conditions by `type` and never reads `field_key` on them
110
129
  | `query timed out after 15s — narrow the filter or simplify the query` | The query hit the per-query statement timeout (15 s). Usually an unindexed predicate scanning a large table — filter *shape* drives latency; see the performance contract in [./queries.md](./queries.md). | Narrow with an indexed filter or `search`, reduce the work per request. |
111
130
  | `The app is handling too many requests right now. Please retry in a moment.` | The server's bounded-concurrency gate shed the query under load (a 503). Distinct from a query error — nothing is wrong with the query itself. | Retry (e.g. surface a retry button wired to `refetch()`); the hook does not auto-retry. |
112
131
  | `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. |
113
- | 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. |
132
+ | A specific validation message | e.g. an un-projected `field_key` in runtime `sort`/`filter`, invalid params, an unknown alias. An unknown column names every column the query DOES project, so the valid set is in the message. | Fix the call site — these are contract violations, not transient. |
114
133
 
115
134
  ## Pagination — two models
116
135
 
package/docs/queries.md CHANGED
@@ -116,6 +116,14 @@ source_table_id?, source_field_key? }` per column. Rows are plain objects keyed
116
116
  column name; decode cells with the SDK readers (`row.*`, `readSelect`, `readMembers`,
117
117
  `readLinks`, `readFiles` — see [data_fetching.md](./data_fetching.md)).
118
118
 
119
+ **`source_fields_by_table_id` is always `{}`** — the key is part of the shape, the map is not
120
+ filled. The server reads the source tables' field definitions to resolve option and member
121
+ cells and then keeps them: sending them beside every page was 94% of a real register
122
+ response's compressed bytes, for a map no reader wanted there. **The complete option sets —
123
+ every option including those no loaded row holds, with colors — are `useFieldOptions`**
124
+ (`/field-options`, which derives them from the same definitions); a cell's own `{ key, label }`
125
+ comes down on the cell. See [members_and_options.md](./members_and_options.md).
126
+
119
127
  Delivery-layer enrichment (applied to the response, per request):
120
128
 
121
129
  - **`files` cells** — each entry gains a presigned `url` + `thumbnail_url` (24 h TTL) and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.79.1",
3
+ "version": "0.80.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": {