@lotics/app-sdk 0.79.3 → 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 +2 -2
- package/dist/src/hooks.js +21 -11
- package/dist/src/rpc.d.ts +13 -5
- package/dist/src/rpc.js +19 -11
- package/docs/data_fetching.md +10 -2
- package/package.json +1 -1
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,
|
|
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,
|
|
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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
65
|
-
|
|
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 `
|
|
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()`.**
|