@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 +3 -3
- package/dist/src/hooks.js +34 -13
- package/dist/src/mock.d.ts +43 -13
- package/dist/src/mock.js +37 -13
- package/dist/src/rpc.d.ts +13 -5
- package/dist/src/rpc.js +19 -11
- package/docs/data_fetching.md +13 -4
- package/docs/runtime.md +22 -4
- package/package.json +2 -2
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,
|
|
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,
|
|
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) =>
|
|
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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
65
|
-
|
|
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,
|
package/dist/src/mock.d.ts
CHANGED
|
@@ -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[] }
|
|
8
|
-
* app declared in
|
|
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
|
|
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`
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
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[] }
|
|
8
|
-
* app declared in
|
|
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
|
|
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`
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
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 `
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
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
|
|
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 `
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
|
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 `
|
|
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
|
|
135
|
-
//
|
|
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 === "
|
|
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")
|
package/docs/data_fetching.md
CHANGED
|
@@ -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),
|
|
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 /
|
|
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
|
-
|
|
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
|
|
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
|
-
- **
|
|
72
|
-
|
|
73
|
-
|
|
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.
|
|
4
|
-
"description": "Runtime SDK for Lotics custom-code apps
|
|
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
|
".": {
|