@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 +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 +22 -3
- package/docs/queries.md +8 -0
- 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()`.**
|
|
@@ -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
|