@lotics/app-sdk 0.83.1 → 0.84.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 +5 -2
- package/dist/src/hooks.js +22 -3
- package/dist/src/rpc.d.ts +19 -0
- package/dist/src/rpc.js +44 -0
- package/docs/data_fetching.md +8 -7
- package/docs/mutations.md +27 -7
- package/docs/workflows.md +8 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -16,8 +16,8 @@ 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 four read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`/`useCount` — the last for a number with no rows, sharing the `(alias, params, filter)` count key the paginated hook uses, so a list and a badge over one set buy one count; a count is a full scan and stays its OWN request so rows paint without waiting for it, and `rows.length` is never a count since rows truncate silently at 10,000), 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
|
-
| [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). |
|
|
19
|
+
| [docs/data_fetching.md](./docs/data_fetching.md) | The four read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`/`useCount` — the last for a number with no rows, sharing the `(alias, params, filter)` count key the paginated hook uses, so a list and a badge over one set buy one count; a count is a full scan and stays its OWN request so rows paint without waiting for it, and `rows.length` is never a count since rows truncate silently at 10,000), 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) — and the fourth source, **this app's own successful write**, which re-reads every mounted query immediately rather than waiting on that push (→ [mutations](./docs/mutations.md)), data discipline, the pagination count as a second full execution (and `total` to suppress it), the search-as-you-type + record-picker patterns. |
|
|
20
|
+
| [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract, typed inputs, the automatic re-read a SUCCESSFUL write triggers over every mounted query (so a screen never waits on the host push to see its own write), 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. |
|
|
@@ -34,6 +34,9 @@ signature; open the file.**
|
|
|
34
34
|
A client-supplied member id is an IDOR. → [security](./docs/security.md)
|
|
35
35
|
- **Server data is never copied into `useState`** — hooks are the source of truth; derive with
|
|
36
36
|
`useMemo`. → [data_fetching](./docs/data_fetching.md)
|
|
37
|
+
- **A successful write re-reads the screen for you** — do not chase a `useWorkflow` call with
|
|
38
|
+
`refetch()`. Reach for it only where a write cannot have told you: a poll, a value nothing on
|
|
39
|
+
this screen wrote, a total you supplied. → [mutations](./docs/mutations.md)
|
|
37
40
|
- **Update writes are diffs** — send only changed fields, and *cleared* is a change: an optional
|
|
38
41
|
input clears with `null` (never `""`), which `set_skip_null` drops and `set` performs.
|
|
39
42
|
→ [mutations](./docs/mutations.md)
|
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, subscribeQueriesChanged, } from "./rpc.js";
|
|
22
|
+
import { rpc, rpcAgentRun, rpcAgentRunContinue, postHostNotification, subscribeQueriesChanged, registerQueryAlias, notifyLocalWrite, } from "./rpc.js";
|
|
23
23
|
import { initialAgentRunState, reduceAgentChunk, parseSseChunks, adoptSettledRun, pendingInteractiveCall, buildChoiceOutput, applyInteractiveAnswer, landingOf, proseOf, ABORTED, } from "./agent_stream.js";
|
|
24
24
|
import { getMockRows, getMockWorkflow, hasMockFlag } from "./mock.js";
|
|
25
25
|
export { buildChoiceOutput } from "./agent_stream.js";
|
|
@@ -34,7 +34,18 @@ export function useWorkflow(alias) {
|
|
|
34
34
|
if (mock) {
|
|
35
35
|
return typeof mock === "function" ? await mock(inputs ?? {}) : mock;
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
const result = await rpc("workflow", { alias, inputs: inputs ?? {} });
|
|
38
|
+
// A workflow is the app's only write path, so a successful one means the
|
|
39
|
+
// rows on screen are stale. Re-read now rather than waiting for the host's
|
|
40
|
+
// realtime push to say what the caller already knows — that wait is what
|
|
41
|
+
// makes a screen feel like it lagged its own button.
|
|
42
|
+
//
|
|
43
|
+
// Only on success: a refused write changed nothing, and refetching after
|
|
44
|
+
// one would replace the values the user is still looking at (and about to
|
|
45
|
+
// correct) with an identical set, for a round trip nobody asked for.
|
|
46
|
+
if (result.status !== "error")
|
|
47
|
+
notifyLocalWrite();
|
|
48
|
+
return result;
|
|
38
49
|
}, [alias]);
|
|
39
50
|
}
|
|
40
51
|
// Shared SWR config: surface a failed query immediately, keep the last good
|
|
@@ -79,10 +90,18 @@ function useHostRefetch(alias, refetch, mockRows) {
|
|
|
79
90
|
useEffect(() => {
|
|
80
91
|
if (hasMockFlag() || mockRows)
|
|
81
92
|
return;
|
|
82
|
-
|
|
93
|
+
// Registered separately from the subscription because the two answer
|
|
94
|
+
// different questions: the subscription is "tell me when MY alias moved",
|
|
95
|
+
// the registration is "I am on screen, so a local write should re-read me".
|
|
96
|
+
const unregister = registerQueryAlias(alias);
|
|
97
|
+
const unsubscribe = subscribeQueriesChanged((aliases) => {
|
|
83
98
|
if (aliases.includes(alias))
|
|
84
99
|
refetch();
|
|
85
100
|
});
|
|
101
|
+
return () => {
|
|
102
|
+
unregister();
|
|
103
|
+
unsubscribe();
|
|
104
|
+
};
|
|
86
105
|
}, [alias, refetch, mockRows]);
|
|
87
106
|
}
|
|
88
107
|
/**
|
package/dist/src/rpc.d.ts
CHANGED
|
@@ -149,6 +149,25 @@ export declare function postHostNotification(message: HostNotification): void;
|
|
|
149
149
|
* stay on pull-based freshness.
|
|
150
150
|
*/
|
|
151
151
|
export declare function subscribeQueriesChanged(cb: (aliases: string[]) => void): () => void;
|
|
152
|
+
/** Register a mounted query's alias. Returns the matching unregister. */
|
|
153
|
+
export declare function registerQueryAlias(alias: string): () => void;
|
|
154
|
+
/**
|
|
155
|
+
* Re-read the mounted queries because THIS app just wrote.
|
|
156
|
+
*
|
|
157
|
+
* The host's `queriesChanged` push is for changes the app did not make — it
|
|
158
|
+
* travels the realtime path (a version counter, a socket the host owns, and
|
|
159
|
+
* coalescing that is deliberately lazy under load), which is right for another
|
|
160
|
+
* member's edit and far too slow for your own. A writer already knows, so it
|
|
161
|
+
* rings the same bell locally instead of waiting to be told.
|
|
162
|
+
*
|
|
163
|
+
* It names every mounted alias rather than only the ones the write touched: the
|
|
164
|
+
* app cannot know which tables a workflow wrote, and the alternative — asking
|
|
165
|
+
* every call site to declare what it invalidates — is a list that goes stale
|
|
166
|
+
* silently the first time a workflow body grows a second write. The cost is
|
|
167
|
+
* bounded by what is on screen, and it is the work the app was going to do a
|
|
168
|
+
* moment later anyway.
|
|
169
|
+
*/
|
|
170
|
+
export declare function notifyLocalWrite(): void;
|
|
152
171
|
/**
|
|
153
172
|
* Start a streaming agent run. Each raw SSE text chunk is handed to `onText`
|
|
154
173
|
* (the caller parses it via `agent_stream`); `done` settles when the stream
|
package/dist/src/rpc.js
CHANGED
|
@@ -111,6 +111,50 @@ export function subscribeQueriesChanged(cb) {
|
|
|
111
111
|
refetchSubscribers.delete(cb);
|
|
112
112
|
};
|
|
113
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Every alias with a mounted query hook, so a local write can name them.
|
|
116
|
+
*
|
|
117
|
+
* Registered by the hooks themselves rather than derived from the manifest: what
|
|
118
|
+
* matters is what is ON SCREEN, and a screen mounts a handful of an app's
|
|
119
|
+
* queries. Keyed by alias with a count, because two hooks can read one alias at
|
|
120
|
+
* once (a register and the drawer over it) and the first to unmount must not
|
|
121
|
+
* retract the second's registration.
|
|
122
|
+
*/
|
|
123
|
+
const mountedAliases = new Map();
|
|
124
|
+
/** Register a mounted query's alias. Returns the matching unregister. */
|
|
125
|
+
export function registerQueryAlias(alias) {
|
|
126
|
+
mountedAliases.set(alias, (mountedAliases.get(alias) ?? 0) + 1);
|
|
127
|
+
return () => {
|
|
128
|
+
const n = (mountedAliases.get(alias) ?? 1) - 1;
|
|
129
|
+
if (n > 0)
|
|
130
|
+
mountedAliases.set(alias, n);
|
|
131
|
+
else
|
|
132
|
+
mountedAliases.delete(alias);
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Re-read the mounted queries because THIS app just wrote.
|
|
137
|
+
*
|
|
138
|
+
* The host's `queriesChanged` push is for changes the app did not make — it
|
|
139
|
+
* travels the realtime path (a version counter, a socket the host owns, and
|
|
140
|
+
* coalescing that is deliberately lazy under load), which is right for another
|
|
141
|
+
* member's edit and far too slow for your own. A writer already knows, so it
|
|
142
|
+
* rings the same bell locally instead of waiting to be told.
|
|
143
|
+
*
|
|
144
|
+
* It names every mounted alias rather than only the ones the write touched: the
|
|
145
|
+
* app cannot know which tables a workflow wrote, and the alternative — asking
|
|
146
|
+
* every call site to declare what it invalidates — is a list that goes stale
|
|
147
|
+
* silently the first time a workflow body grows a second write. The cost is
|
|
148
|
+
* bounded by what is on screen, and it is the work the app was going to do a
|
|
149
|
+
* moment later anyway.
|
|
150
|
+
*/
|
|
151
|
+
export function notifyLocalWrite() {
|
|
152
|
+
if (mountedAliases.size === 0)
|
|
153
|
+
return;
|
|
154
|
+
const aliases = [...mountedAliases.keys()];
|
|
155
|
+
for (const cb of refetchSubscribers)
|
|
156
|
+
cb(aliases);
|
|
157
|
+
}
|
|
114
158
|
const pending = new Map();
|
|
115
159
|
const streaming = new Map();
|
|
116
160
|
/** `useUrlState` subscribers — notified when the host broadcasts new params
|
package/docs/data_fetching.md
CHANGED
|
@@ -116,9 +116,9 @@ server validates system conditions by `type` and never reads `field_key` on them
|
|
|
116
116
|
renders the cached rows instantly *and* revalidates them in the background, so a list reflects what
|
|
117
117
|
another screen changed while you were away. Identical concurrent reads dedupe to one request.
|
|
118
118
|
Freshness comes from four places — arrival, window focus / tab return / network reconnect
|
|
119
|
-
(`revalidateOnFocus`, default on), a **realtime push**, and
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
(`revalidateOnFocus`, default on), a **realtime push**, and **this app's own successful write**,
|
|
120
|
+
which re-reads every mounted query without being asked (see
|
|
121
|
+
[./mutations.md](./mutations.md)). `refetch()` covers what none of those can know about.
|
|
122
122
|
- **Realtime push keeps an already-open screen current.** When a table one of your queries reads
|
|
123
123
|
changes — another member, a workflow, the chat agent, or an external agent writing over the CLI or
|
|
124
124
|
MCP — that query refetches within about a second. Nothing to wire: it follows from the query's own
|
|
@@ -153,10 +153,11 @@ server validates system conditions by `type` and never reads `field_key` on them
|
|
|
153
153
|
number, while `error !== null`. Note a key CHANGE resets this: a new `params`/`filter`/`sort`
|
|
154
154
|
is a fresh key with no prior rows, so the "last successful rows stay rendered" behaviour above
|
|
155
155
|
does not save you.
|
|
156
|
-
- **`refetch()`** re-runs the query.
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
156
|
+
- **`refetch()`** re-runs the query. A successful `useWorkflow` call already re-reads the mounted
|
|
157
|
+
queries on its own (see [./mutations.md](./mutations.md)), so reach for this only where a write
|
|
158
|
+
cannot have told you: a poll, a value that changes without anything on this screen writing, or a
|
|
159
|
+
total you supplied yourself. `usePaginatedQuery.refetch()` refreshes the page, and the count when
|
|
160
|
+
the hook owns it (a caller-supplied `total` is the caller's to refresh).
|
|
160
161
|
- **Ambient-chat mutations refetch automatically.** When the member's ambient chat agent (see
|
|
161
162
|
[./ai.md](./ai.md#useaicontextslot-context--tell-the-ambient-chat-what-the-member-is-looking-at))
|
|
162
163
|
finishes a turn that mutated records, the host pushes **every mounted query hook** to re-read —
|
package/docs/mutations.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Everything about writing data from an app: `useWorkflow` (the only write path), the
|
|
4
4
|
`WorkflowResult` contract and its resolve-never-throw failure model, declaring typed workflow
|
|
5
5
|
inputs (file/member/optional inputs), returning structured data with `return({ data })`,
|
|
6
|
-
|
|
6
|
+
the automatic re-read a successful write triggers, the diff-before-update discipline, locked records
|
|
7
7
|
(`readLocked` + `request_locked_record_change`), and optimistic reconciliation
|
|
8
8
|
(`useOptimistic`). Read this before building any screen that creates, updates, or deletes
|
|
9
9
|
records. What may be written **inside** the workflow body — the JS subset, steps, helpers,
|
|
@@ -89,7 +89,7 @@ if (result.status === "error") {
|
|
|
89
89
|
showError(result.message ?? "Something went wrong.");
|
|
90
90
|
return;
|
|
91
91
|
}
|
|
92
|
-
|
|
92
|
+
// Success. Mounted queries re-read themselves — see "Refetch after a mutation".
|
|
93
93
|
```
|
|
94
94
|
|
|
95
95
|
**Warning:** a `try/catch` with no `status` check silently treats every workflow failure as
|
|
@@ -312,9 +312,9 @@ is [workflows](./workflows.md#writing-records).
|
|
|
312
312
|
|
|
313
313
|
## Refetch after a mutation
|
|
314
314
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
315
|
+
**A successful workflow re-reads the queries that are mounted, on its own.** A workflow is the
|
|
316
|
+
app's only write path, so the rows on screen are stale the moment one succeeds — the SDK
|
|
317
|
+
re-reads them rather than making every call site remember to. Nothing to call:
|
|
318
318
|
|
|
319
319
|
```tsx
|
|
320
320
|
const items = useQuery("items");
|
|
@@ -323,10 +323,24 @@ const closeItem = useWorkflow("closeItem");
|
|
|
323
323
|
const onClose = async (recordId: string) => {
|
|
324
324
|
const r = await closeItem({ record_id: recordId });
|
|
325
325
|
if (r.status === "error") { showError(r.message); return; }
|
|
326
|
-
items.
|
|
326
|
+
// items re-reads itself — a successful write already said so.
|
|
327
327
|
};
|
|
328
328
|
```
|
|
329
329
|
|
|
330
|
+
Only on **success**: a refused write changed nothing, and re-reading after one would swap the
|
|
331
|
+
values the user is still looking at, and about to correct, for an identical set.
|
|
332
|
+
|
|
333
|
+
It names every MOUNTED alias, not the ones the write touched — an app cannot know which tables
|
|
334
|
+
a workflow body wrote, and a per-call-site list of what to invalidate goes stale silently the
|
|
335
|
+
first time that body grows a second write. The cost is bounded by what is on screen.
|
|
336
|
+
|
|
337
|
+
This is separate from the host's realtime push, which carries changes **other** people make and
|
|
338
|
+
travels a socket the host owns. That path is right for someone else's edit and far too slow for
|
|
339
|
+
your own — waiting on it is what makes a screen feel like it lagged its own button.
|
|
340
|
+
|
|
341
|
+
`refetch()` remains for the reads a write cannot know about: a poll, a value the user expects to
|
|
342
|
+
change without writing anything, or a total you supplied yourself (below).
|
|
343
|
+
|
|
330
344
|
`refetch` re-runs the query in the background while the current rows stay on screen (no
|
|
331
345
|
flash to a spinner — `loading` stays false during revalidation). `usePaginatedQuery`'s
|
|
332
346
|
`refetch` re-runs the current page, and the count when the hook owns it — a total you supplied
|
|
@@ -411,7 +425,13 @@ Why a full-form snapshot save is a bug, not a style choice — three independent
|
|
|
411
425
|
3. **Snapshots clobber concurrent edits.** An update writes exactly the fields present in the
|
|
412
426
|
payload; unsent fields keep their current value. A diff therefore coexists with a
|
|
413
427
|
colleague's concurrent edit to a *different* field — a snapshot overwrites it with the
|
|
414
|
-
stale load-time value.
|
|
428
|
+
stale load-time value. The coexistence stops at field granularity: sending the full array
|
|
429
|
+
of a multi-value field is a snapshot *inside* that field, so two writers appending to it
|
|
430
|
+
each keep the array they read and the later one wins, both reporting success. When the
|
|
431
|
+
append is the system's rather than a person's — an upload settling, a workflow attaching a
|
|
432
|
+
generated document — name the ITEMS with `add_to` / `remove_from`
|
|
433
|
+
([workflows](./workflows.md#writing-records)) and the write path resolves them against the
|
|
434
|
+
record as it stands.
|
|
415
435
|
|
|
416
436
|
```tsx
|
|
417
437
|
// load: snapshot the editable fields from the row
|
package/docs/workflows.md
CHANGED
|
@@ -482,6 +482,14 @@ differ from `<` / `>` (code-unit order) and from the database's collation.
|
|
|
482
482
|
| `add_to` / `remove_from` / `replace` | `{ fld_x: [items] }` | surgical edits on multi-value fields (files, multi select, multi member, record links) |
|
|
483
483
|
| `field_edits` | `[{ field, op, value }]` | the same surgical ops with the field named by a **string expression** — the only way to target a field chosen at run time |
|
|
484
484
|
|
|
485
|
+
`add_to` / `remove_from` are RELATIVE: the server resolves them against the record as it stands
|
|
486
|
+
when the write lands, inside the lock it already takes. Two runs appending to one field at the
|
|
487
|
+
same time therefore each keep their item — which reading the field and writing the whole array
|
|
488
|
+
back through `set` does not, since both fold onto the array they read and the later write drops
|
|
489
|
+
the earlier, both reporting success. Parallel runs are ordinary (a trigger firing twice, an agent
|
|
490
|
+
emitting two calls in one step), so reach for the ops rather than a read-modify-write whenever
|
|
491
|
+
the workflow is ADDING to a field rather than stating it.
|
|
492
|
+
|
|
485
493
|
Inside `set` and `create_records.records`: `null` clears (persisted), `undefined` or an omitted
|
|
486
494
|
key preserves. So passing a possibly-null read straight through is safe. Use
|
|
487
495
|
`coalesce(x, fallback)` only when you want a real fallback, never to "strip" null.
|