@lotics/app-sdk 0.79.3 → 0.81.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,15 +16,15 @@ 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
- | [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. |
27
+ | [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, `rpc()`, the design-time mock harness (`fixture` + `?__mock=1` — queries AND workflows, so an AI screen's in-flight/done/error states are reviewable without running or paying for anything), `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
 
29
29
  ## Non-negotiables (each detailed in its doc)
30
30
 
package/dist/src/hooks.js CHANGED
@@ -19,12 +19,23 @@ 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
- import { getMockRows, hasMockFlag } from "./mock.js";
24
+ import { getMockRows, getMockWorkflow, hasMockFlag } from "./mock.js";
25
25
  export { buildChoiceOutput } from "./agent_stream.js";
26
26
  export function useWorkflow(alias) {
27
- return useCallback((inputs) => rpc("workflow", { alias, inputs: inputs ?? {} }), [alias]);
27
+ return useCallback(async (inputs) => {
28
+ // Checked per CALL, not per render: a fixture registered after mount or
29
+ // swapped by HMR is picked up, and an app that never runs this workflow
30
+ // does no work here. Mock mode needs BOTH the `?__mock=1` flag and a
31
+ // registered fixture, so a bundle carrying demo data cannot answer real
32
+ // traffic with it.
33
+ const mock = getMockWorkflow(alias);
34
+ if (mock) {
35
+ return typeof mock === "function" ? await mock(inputs ?? {}) : mock;
36
+ }
37
+ return rpc("workflow", { alias, inputs: inputs ?? {} });
38
+ }, [alias]);
28
39
  }
29
40
  // Shared SWR config: surface a failed query immediately, keep the last good
30
41
  // rows (no retry loop that masks the error), and honor the focus/reconnect
@@ -52,17 +63,27 @@ function swrConfig(revalidateOnFocus) {
52
63
  };
53
64
  }
54
65
  /**
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.
66
+ * Refetch this query when the host reports that ITS tables changed — whoever
67
+ * wrote them: another member, a workflow, the chat agent, or an external agent
68
+ * over the CLI or MCP.
69
+ *
70
+ * The host names the affected aliases rather than telling every hook to
71
+ * re-read: an app's queries usually span several tables, and a blanket refetch
72
+ * would re-run expensive aggregates on a change to a table they never touch.
73
+ *
74
+ * Inert in mock mode (no host, no listener), for a mocked alias (its rows come
75
+ * from the fixture), and for a standalone/public app (no host to push).
76
+ * Shared by all three query hooks.
59
77
  */
60
- function useHostRefetch(refetch, mockRows) {
78
+ function useHostRefetch(alias, refetch, mockRows) {
61
79
  useEffect(() => {
62
80
  if (hasMockFlag() || mockRows)
63
81
  return;
64
- return subscribeHostRefetch(refetch);
65
- }, [refetch, mockRows]);
82
+ return subscribeQueriesChanged((aliases) => {
83
+ if (aliases.includes(alias))
84
+ refetch();
85
+ });
86
+ }, [alias, refetch, mockRows]);
66
87
  }
67
88
  export function useQuery(alias, params, opts) {
68
89
  const pageSize = opts?.pageSize;
@@ -88,7 +109,7 @@ export function useQuery(alias, params, opts) {
88
109
  const refetch = useCallback(() => {
89
110
  void swr.mutate();
90
111
  }, [swr]);
91
- useHostRefetch(refetch, mockRows);
112
+ useHostRefetch(alias, refetch, mockRows);
92
113
  return {
93
114
  rows: mockRows ?? swr.data?.rows ?? [],
94
115
  loading: mockRows ? false : swr.isLoading,
@@ -182,7 +203,7 @@ export function useInfiniteQuery(alias, params, opts) {
182
203
  const refetch = useCallback(() => {
183
204
  void swr.mutate();
184
205
  }, [swr]);
185
- useHostRefetch(refetch, mockRows);
206
+ useHostRefetch(alias, refetch, mockRows);
186
207
  const loadMore = useCallback(() => {
187
208
  if (!hasMore || loadingMore)
188
209
  return;
@@ -251,7 +272,7 @@ export function usePaginatedQuery(alias, params, opts) {
251
272
  void rowsSwr.mutate();
252
273
  void countSwr.mutate();
253
274
  }, [rowsSwr, countSwr]);
254
- useHostRefetch(refetch, mockRows);
275
+ useHostRefetch(alias, refetch, mockRows);
255
276
  return {
256
277
  rows,
257
278
  total,
@@ -1,33 +1,53 @@
1
1
  /**
2
- * Demo / design-time fixture support for `useQuery`.
2
+ * Demo / design-time fixture support for `useQuery` and `useWorkflow`.
3
3
  *
4
4
  * Activation contract:
5
5
  *
6
6
  * 1. App passes `{ fixture }` to `mount(<App />, { fixture })`. The fixture
7
- * is a `{ queries: { alias: Row[] } }` map keyed by the same aliases the
8
- * app declared in `package.json#lotics.queries`.
7
+ * is a `{ queries: { alias: Row[] }, workflows: { alias: Result } }` map
8
+ * keyed by the same aliases the app declared in
9
+ * `package.json#lotics.queries` / `#lotics.workflows`.
9
10
  * 2. At runtime, the user (or a screenshot script) loads the app with the
10
11
  * `?__mock=1` URL search param. Without that param the SDK ignores the
11
- * fixture entirely and `useQuery` flows through the RPC bridge as usual.
12
+ * fixture entirely and both hooks flow through the RPC bridge as usual.
12
13
  *
13
14
  * The two-step gate keeps demo data shipping in the bundle from leaking into
14
15
  * normal traffic — the param namespace (`__mock` prefix) is reserved and
15
16
  * unlikely to collide with app-side query state. Apps that don't pass a
16
- * fixture pay nothing: `getMockRows` returns `null` for every alias and the
17
- * hook path is unchanged.
17
+ * fixture pay nothing: `getMockRows` / `getMockWorkflow` return `null` for
18
+ * every alias and the hook path is unchanged.
18
19
  *
19
- * What's deliberately *not* mocked yet:
20
- * - `useWorkflow` mutations have side effects (notifications, audit
21
- * trail). A workflow mock would have to also produce realistic followup
22
- * state, which is more design than this iteration warrants.
23
- * - `useFileUpload` same reason.
20
+ * `workflows` exists because the side-effect argument runs the other way: a
21
+ * mocked workflow does not RUN, so it produces no notification and no audit
22
+ * trail it PREVENTS them. What it cannot produce is the followup state a
23
+ * subsequent `useQuery` would read, which is the author's call and is already
24
+ * true of a mocked query. Without it, an app whose only AI surface is a
25
+ * workflow that reads and calls `agent(...)` — the standard shape — had no
26
+ * non-billing path to its own in-flight / done / error states at all, so those
27
+ * three screens could not be reviewed without spending on a live workspace.
24
28
  *
25
- * If those become needed, extend `AppFixture` with `workflows`/`uploads` and
26
- * route from each hook.
29
+ * A fixture entry may be the RESULT, or a FUNCTION of the inputs. The function
30
+ * form is what makes the in-flight state reachable: resolve on a timer and the
31
+ * app renders the pending branch it otherwise never shows. It also lets one
32
+ * alias answer differently per input, which is how an error branch is reviewed
33
+ * beside a success one.
34
+ *
35
+ * What's deliberately *not* mocked: `useFileUpload`. Bytes and progress are a
36
+ * different shape from a request/response pair, and nothing has needed it.
37
+ */
38
+ import type { WorkflowResult } from "./hooks.js";
39
+ /**
40
+ * What a mocked workflow answers with: a fixed result, or a function of the
41
+ * inputs it was called with. Return a promise from the function to hold the
42
+ * caller in its pending state for as long as the review needs.
27
43
  */
44
+ export type MockWorkflow = WorkflowResult | ((inputs: Record<string, unknown>) => WorkflowResult | Promise<WorkflowResult>);
28
45
  export interface AppFixture {
29
46
  /** Map of query alias → rows the hook should return when mock mode is on. */
30
47
  queries?: Record<string, Array<Record<string, unknown>>>;
48
+ /** Map of workflow alias → the result it resolves with when mock mode is on.
49
+ * The workflow never executes, so nothing it would have written is written. */
50
+ workflows?: Record<string, MockWorkflow>;
31
51
  }
32
52
  /**
33
53
  * Called by `mount({ fixture })`. Module-level state because the SDK has no
@@ -51,3 +71,13 @@ export declare function hasMockFlag(): boolean;
51
71
  * only some queries and let the rest flow through to real data.
52
72
  */
53
73
  export declare function getMockRows(alias: string): Array<Record<string, unknown>> | null;
74
+ /**
75
+ * The fixture entry for a workflow alias when mock mode is active *and* the
76
+ * fixture has an entry for it. Otherwise null — the hook falls through to the
77
+ * real RPC path, so an app may mock one workflow and let the rest execute.
78
+ *
79
+ * Resolved when the workflow is CALLED rather than when the hook is created, so
80
+ * a fixture registered after mount (or replaced by HMR) is picked up, and an
81
+ * app that never calls the workflow pays nothing.
82
+ */
83
+ export declare function getMockWorkflow(alias: string): MockWorkflow | null;
package/dist/src/mock.js CHANGED
@@ -1,29 +1,39 @@
1
1
  /**
2
- * Demo / design-time fixture support for `useQuery`.
2
+ * Demo / design-time fixture support for `useQuery` and `useWorkflow`.
3
3
  *
4
4
  * Activation contract:
5
5
  *
6
6
  * 1. App passes `{ fixture }` to `mount(<App />, { fixture })`. The fixture
7
- * is a `{ queries: { alias: Row[] } }` map keyed by the same aliases the
8
- * app declared in `package.json#lotics.queries`.
7
+ * is a `{ queries: { alias: Row[] }, workflows: { alias: Result } }` map
8
+ * keyed by the same aliases the app declared in
9
+ * `package.json#lotics.queries` / `#lotics.workflows`.
9
10
  * 2. At runtime, the user (or a screenshot script) loads the app with the
10
11
  * `?__mock=1` URL search param. Without that param the SDK ignores the
11
- * fixture entirely and `useQuery` flows through the RPC bridge as usual.
12
+ * fixture entirely and both hooks flow through the RPC bridge as usual.
12
13
  *
13
14
  * The two-step gate keeps demo data shipping in the bundle from leaking into
14
15
  * normal traffic — the param namespace (`__mock` prefix) is reserved and
15
16
  * unlikely to collide with app-side query state. Apps that don't pass a
16
- * fixture pay nothing: `getMockRows` returns `null` for every alias and the
17
- * hook path is unchanged.
17
+ * fixture pay nothing: `getMockRows` / `getMockWorkflow` return `null` for
18
+ * every alias and the hook path is unchanged.
18
19
  *
19
- * What's deliberately *not* mocked yet:
20
- * - `useWorkflow` mutations have side effects (notifications, audit
21
- * trail). A workflow mock would have to also produce realistic followup
22
- * state, which is more design than this iteration warrants.
23
- * - `useFileUpload` same reason.
20
+ * `workflows` exists because the side-effect argument runs the other way: a
21
+ * mocked workflow does not RUN, so it produces no notification and no audit
22
+ * trail it PREVENTS them. What it cannot produce is the followup state a
23
+ * subsequent `useQuery` would read, which is the author's call and is already
24
+ * true of a mocked query. Without it, an app whose only AI surface is a
25
+ * workflow that reads and calls `agent(...)` — the standard shape — had no
26
+ * non-billing path to its own in-flight / done / error states at all, so those
27
+ * three screens could not be reviewed without spending on a live workspace.
24
28
  *
25
- * If those become needed, extend `AppFixture` with `workflows`/`uploads` and
26
- * route from each hook.
29
+ * A fixture entry may be the RESULT, or a FUNCTION of the inputs. The function
30
+ * form is what makes the in-flight state reachable: resolve on a timer and the
31
+ * app renders the pending branch it otherwise never shows. It also lets one
32
+ * alias answer differently per input, which is how an error branch is reviewed
33
+ * beside a success one.
34
+ *
35
+ * What's deliberately *not* mocked: `useFileUpload`. Bytes and progress are a
36
+ * different shape from a request/response pair, and nothing has needed it.
27
37
  */
28
38
  let registeredFixture;
29
39
  /**
@@ -70,3 +80,17 @@ export function getMockRows(alias) {
70
80
  const rows = registeredFixture?.queries?.[alias];
71
81
  return rows ?? null;
72
82
  }
83
+ /**
84
+ * The fixture entry for a workflow alias when mock mode is active *and* the
85
+ * fixture has an entry for it. Otherwise null — the hook falls through to the
86
+ * real RPC path, so an app may mock one workflow and let the rest execute.
87
+ *
88
+ * Resolved when the workflow is CALLED rather than when the hook is created, so
89
+ * a fixture registered after mount (or replaced by HMR) is picked up, and an
90
+ * app that never calls the workflow pays nothing.
91
+ */
92
+ export function getMockWorkflow(alias) {
93
+ if (!isMockMode())
94
+ return null;
95
+ return registeredFixture?.workflows?.[alias] ?? null;
96
+ }
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()`.**
@@ -111,8 +119,9 @@ server validates system conditions by `type` and never reads `field_key` on them
111
119
  queries currently on screen (mounted hooks only); it never reaches into app data, it only tells
112
120
  the app its rendered rows may be stale. Inert standalone and in mock mode.
113
121
  - A design-time fixture registered via `mount(<App />, { fixture })` plus the `?__mock=1` URL flag
114
- short-circuits all three hooks (rows come from the fixture, no request, `loading` stays `false`)
115
- see [./runtime.md](./runtime.md).
122
+ short-circuits all three hooks (rows come from the fixture, no request, `loading` stays `false`).
123
+ The same fixture mocks `useWorkflow`, so a screen's in-flight / done / error states are
124
+ reviewable without running anything — see [./runtime.md](./runtime.md).
116
125
 
117
126
  ### Error messages you will actually see
118
127
 
package/docs/runtime.md CHANGED
@@ -47,6 +47,16 @@ mount(<App />, {
47
47
  orders: MOCK_ORDERS, // alias → rows, same aliases as package.json#lotics.queries
48
48
  customers: MOCK_CUSTOMERS,
49
49
  },
50
+ workflows: {
51
+ // A fixed result, for the settled state.
52
+ archive: { status: "success", message: "Archived 3 orders." },
53
+ // Or a FUNCTION of the inputs — the only way to reach the IN-FLIGHT state,
54
+ // and the way to put an error branch beside a success one.
55
+ publish: (inputs) =>
56
+ inputs.dryRun
57
+ ? { status: "error", message: "Row 2 has no customer." }
58
+ : new Promise((r) => setTimeout(() => r({ status: "success" }), 1200)),
59
+ },
50
60
  },
51
61
  });
52
62
  ```
@@ -55,7 +65,7 @@ Activation is a **two-step gate** — both must hold, so demo data shipping in t
55
65
  bundle never leaks into normal traffic:
56
66
 
57
67
  1. A fixture is registered via `mount({ fixture })` (`AppFixture` type:
58
- `dist/src/mock.d.ts` — `{ queries?: Record<string, Array<Record<string, unknown>>> }`).
68
+ `dist/src/mock.d.ts` — `{ queries?, workflows? }`).
59
69
  2. The page URL carries `?__mock=1` (exactly `1`). Without the flag the fixture
60
70
  is completely inert.
61
71
 
@@ -68,9 +78,17 @@ transport. Calling `mount` again (HMR) replaces the registration last-write-wins
68
78
  as fetched rows, so shape them exactly like the query's real output — the same
69
79
  serialized cells your `row.*` / `readSelect` / `readFiles` readers decode —
70
80
  or the readers will decode nothing.
71
- - **Not mocked:** workflows (`useWorkflow`), uploads, `useFieldOptions`, members,
72
- comments, agent runs. In mock mode those still hit the real transport;
73
- mutations have side effects, so there is deliberately no workflow mock.
81
+ - **A mocked workflow does not RUN.** `useWorkflow(alias)` resolves the fixture
82
+ entry and sends nothing, so there is no notification, no audit trail and no
83
+ spend the side-effect argument is the reason to mock it, not a reason not
84
+ to. What a mock cannot produce is the followup state a later query would read;
85
+ that is yours to fixture too, exactly as it already is for queries.
86
+ **Reach the in-flight state with a function** that resolves on a timer: an app
87
+ whose only AI surface is a workflow calling `agent(...)` otherwise has no
88
+ non-billing path to its own thinking / done / error screens, which is how
89
+ those three ship unreviewed.
90
+ - **Not mocked:** uploads, `useFieldOptions`, members, comments, agent runs. In
91
+ mock mode those still hit the real transport.
74
92
  - **Analytics is disabled** whenever `?__mock=1` is present, fixture or not — a
75
93
  screenshot/design-time load emits no events.
76
94
  - The `__mock` param-name prefix is reserved by the SDK; don't use it for
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.79.3",
4
- "description": "Runtime SDK for Lotics custom-code apps typed hooks, postMessage bridge, mount entry point",
3
+ "version": "0.81.0",
4
+ "description": "Runtime SDK for Lotics custom-code apps \u2014 typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {