@lotics/app-sdk 0.100.1 → 0.101.1
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 +32 -47
- package/dist/agent_stream.d.ts +131 -0
- package/dist/ask_ai.d.ts +27 -0
- package/dist/attachments.d.ts +58 -0
- package/dist/chunk-ARV5FAU5.js +1132 -0
- package/dist/comments.d.ts +89 -0
- package/dist/error_report.d.ts +9 -0
- package/dist/folder_pick.d.ts +8 -0
- package/dist/geolocation.d.ts +42 -0
- package/dist/hooks.d.ts +251 -0
- package/dist/{src/index.d.ts → index.d.ts} +13 -22
- package/dist/index.js +31331 -0
- package/dist/index.js.LEGAL.txt +11 -0
- package/dist/members.d.ts +32 -0
- package/dist/mock.d.ts +37 -0
- package/dist/mount.d.ts +19 -0
- package/dist/new_record.d.ts +37 -0
- package/dist/open_app.d.ts +12 -0
- package/dist/open_external.d.ts +10 -0
- package/dist/overlay.d.ts +25 -0
- package/dist/queries.d.ts +231 -0
- package/dist/recording.d.ts +47 -0
- package/dist/recording_state.d.ts +43 -0
- package/dist/rename_file.d.ts +13 -0
- package/dist/router.d.ts +10 -0
- package/dist/router.js +97 -0
- package/dist/row.d.ts +87 -0
- package/dist/rpc.d.ts +114 -0
- package/dist/select.d.ts +24 -0
- package/dist/shared_types.d.ts +8 -0
- package/dist/store.d.ts +43 -0
- package/dist/types.d.ts +36 -0
- package/dist/upload/optimize.d.ts +30 -0
- package/dist/upload/pipeline.d.ts +36 -0
- package/dist/upload/transport.d.ts +19 -0
- package/dist/url_params.d.ts +55 -0
- package/dist/use_recents.d.ts +15 -0
- package/dist/{src/use_url_state.d.ts → use_url_state.d.ts} +0 -2
- package/dist/viewer.d.ts +41 -0
- package/dist/written.d.ts +79 -0
- package/docs/ai.md +74 -133
- package/docs/data_fetching.md +209 -290
- package/docs/files.md +61 -51
- package/docs/members_and_options.md +92 -62
- package/docs/mutations.md +136 -205
- package/docs/navigation_and_state.md +26 -35
- package/docs/queries.md +144 -207
- package/docs/recipes.md +21 -45
- package/docs/runtime.md +74 -137
- package/docs/security.md +8 -11
- package/docs/workflows.md +189 -174
- package/package.json +27 -28
- package/dist/src/agent_stream.d.ts +0 -200
- package/dist/src/agent_stream.js +0 -314
- package/dist/src/ask_ai.d.ts +0 -40
- package/dist/src/ask_ai.js +0 -35
- package/dist/src/attachments.d.ts +0 -68
- package/dist/src/attachments.js +0 -93
- package/dist/src/comments.d.ts +0 -127
- package/dist/src/comments.js +0 -192
- package/dist/src/download.js +0 -54
- package/dist/src/geolocation.d.ts +0 -64
- package/dist/src/geolocation.js +0 -96
- package/dist/src/hooks.d.ts +0 -781
- package/dist/src/hooks.js +0 -860
- package/dist/src/index.js +0 -34
- package/dist/src/members.d.ts +0 -105
- package/dist/src/members.js +0 -62
- package/dist/src/mock.d.ts +0 -118
- package/dist/src/mock.js +0 -124
- package/dist/src/mount.d.ts +0 -47
- package/dist/src/mount.js +0 -34
- package/dist/src/new_record.d.ts +0 -74
- package/dist/src/new_record.js +0 -117
- package/dist/src/open_app.d.ts +0 -15
- package/dist/src/open_app.js +0 -18
- package/dist/src/open_external.d.ts +0 -16
- package/dist/src/open_external.js +0 -19
- package/dist/src/recording.d.ts +0 -59
- package/dist/src/recording.js +0 -30
- package/dist/src/recording_state.d.ts +0 -59
- package/dist/src/recording_state.js +0 -94
- package/dist/src/router.d.ts +0 -17
- package/dist/src/router.js +0 -144
- package/dist/src/row.d.ts +0 -159
- package/dist/src/row.js +0 -254
- package/dist/src/rpc.d.ts +0 -207
- package/dist/src/rpc.js +0 -904
- package/dist/src/select.d.ts +0 -48
- package/dist/src/select.js +0 -40
- package/dist/src/types.d.ts +0 -115
- package/dist/src/types.js +0 -1
- package/dist/src/upload/optimize.d.ts +0 -54
- package/dist/src/upload/optimize.js +0 -207
- package/dist/src/upload/pipeline.d.ts +0 -55
- package/dist/src/upload/pipeline.js +0 -52
- package/dist/src/upload/transport.d.ts +0 -42
- package/dist/src/upload/transport.js +0 -128
- package/dist/src/url_params.d.ts +0 -93
- package/dist/src/url_params.js +0 -215
- package/dist/src/use_optimistic.d.ts +0 -27
- package/dist/src/use_optimistic.js +0 -27
- package/dist/src/use_recents.d.ts +0 -19
- package/dist/src/use_recents.js +0 -71
- package/dist/src/use_url_state.js +0 -73
- package/dist/src/viewer.d.ts +0 -26
- package/dist/src/viewer.js +0 -47
- /package/dist/{src/download.d.ts → download.d.ts} +0 -0
package/docs/mutations.md
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
# Mutations — writing data through workflows
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
the automatic re-read a successful write triggers, the diff-before-update discipline, locked records
|
|
7
|
-
(`readLocked` + `request_locked_record_change`), optimistic reconciliation
|
|
8
|
-
(`useOptimistic`), and `useRecording` — a recording the host files through a workflow. Read this before building any screen that creates, updates, or deletes
|
|
9
|
-
records. What may be written **inside** the workflow body — the JS subset, steps, helpers,
|
|
3
|
+
Writing data from an app: `useWorkflow` and its result contract, typed inputs and outputs, how a
|
|
4
|
+
write shows on every read from the press, the re-read a successful write triggers,
|
|
5
|
+
diff-before-update, locked records, `useNewRecord` and `useRecording`. What may be written **inside** the workflow body — the JS subset, steps, helpers,
|
|
10
6
|
traps — is [workflows](./workflows.md). Reading data is [queries](./queries.md) +
|
|
11
7
|
[data_fetching](./data_fetching.md); uploads are [files](./files.md); who a write runs as is
|
|
12
8
|
[security](./security.md).
|
|
@@ -28,10 +24,8 @@ const result = await createOrder({ customer_id, quantity: 3 });
|
|
|
28
24
|
against the alias's declared input schema, and executes the workflow under the **app
|
|
29
25
|
owner's** authority (never the viewer's — see [security](./security.md) for attribution,
|
|
30
26
|
privilege gates, and public-app semantics).
|
|
31
|
-
- Binding is server-side
|
|
32
|
-
|
|
33
|
-
`src/workflows/<alias>.ts` ARE the source of a binding, so an alias committed to the repo is
|
|
34
|
-
one the next clone ships. The declaration also types `useWorkflow` (below).
|
|
27
|
+
- Binding is server-side and live: `set_app_workflow` binds an alias as a new version of the app,
|
|
28
|
+
and a deploy never touches it. The declaration also types `useWorkflow` (below).
|
|
35
29
|
- Invoking an alias that is not bound resolves with `status: "error"` and a message naming
|
|
36
30
|
the missing binding.
|
|
37
31
|
- Anonymous visitors to a publicly shared app can invoke workflows too; the triggering
|
|
@@ -39,12 +33,16 @@ const result = await createOrder({ customer_id, quantity: 3 });
|
|
|
39
33
|
|
|
40
34
|
## `useWorkflow(alias)` — signature and typing
|
|
41
35
|
|
|
42
|
-
Exact signature: `dist/
|
|
36
|
+
Exact signature: `dist/hooks.d.ts`. `useWorkflow(alias)` returns a stable async callable:
|
|
43
37
|
`(inputs) => Promise<WorkflowResult<TData>>`.
|
|
44
38
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
39
|
+
`useWorkflows(aliases)` is the same runner for writes that are DATA — a list of aliases known only at
|
|
40
|
+
render: one callable per alias, each run exactly as `useWorkflow`'s.
|
|
41
|
+
|
|
42
|
+
Typing comes from `.lotics/app_workflows.d.ts`, which augments the SDK's `AppWorkflows` and
|
|
43
|
+
`AppWorkflowResults` interfaces from the app's bound declarations. The declarations are generated from the app's live bindings into `.lotics/` whenever a sandbox
|
|
44
|
+
session opens on the app. A project on your own machine has none, so every alias is accepted as a
|
|
45
|
+
plain string there, untyped. The result:
|
|
48
46
|
|
|
49
47
|
| Declaration | Call-site type |
|
|
50
48
|
|---|---|
|
|
@@ -57,7 +55,7 @@ Typing comes from per-app codegen: `lotics app pull` / `app dev` / `app deploy`
|
|
|
57
55
|
## `WorkflowResult` — the result contract
|
|
58
56
|
|
|
59
57
|
Every call resolves to a `WorkflowResult<TData>` (type exported from the package root;
|
|
60
|
-
`dist/
|
|
58
|
+
`dist/hooks.d.ts`):
|
|
61
59
|
|
|
62
60
|
| Field | Type | Meaning |
|
|
63
61
|
|---|---|---|
|
|
@@ -70,7 +68,7 @@ Every call resolves to a `WorkflowResult<TData>` (type exported from the package
|
|
|
70
68
|
### The failure model: check `status`, never just try/catch
|
|
71
69
|
|
|
72
70
|
**Every failure resolves — the promise (almost) never rejects.** All three transports
|
|
73
|
-
(embedded product host, standalone on the app's own origin
|
|
71
|
+
(embedded product host, and standalone on the app's own origin)
|
|
74
72
|
convert every failure into a resolved `{ status: "error", message }`:
|
|
75
73
|
|
|
76
74
|
| Failure | What resolves |
|
|
@@ -132,8 +130,7 @@ setErrs(result.field_errors ?? {});
|
|
|
132
130
|
```
|
|
133
131
|
|
|
134
132
|
Keep `message` too — it is what a refusal with no field to blame says, and the two are shown
|
|
135
|
-
together (a `Callout` at the dialog's scope plus the per-field text).
|
|
136
|
-
refusals: the key is the same whichever half answered, so a form never branches on that.
|
|
133
|
+
together (a `Callout` at the dialog's scope plus the per-field text).
|
|
137
134
|
|
|
138
135
|
A failing `validate` check fills the same map from its `field_key`, so the key there is the INPUT
|
|
139
136
|
name as well — `field_key: "ly_do"`, never the `fld_` key of whatever column the check happened
|
|
@@ -159,10 +156,9 @@ The same run reached through the `run_app_workflow` tool — from chat, from MCP
|
|
|
159
156
|
agent — lists those files as `file_id`. It is the same value `useWorkflow` gives you as `id`,
|
|
160
157
|
so a file an agent produced and a file the app produced are addressed identically.
|
|
161
158
|
|
|
162
|
-
Files travel **only** via `files[]` — never
|
|
163
|
-
`
|
|
164
|
-
|
|
165
|
-
separate, optional step. Previewing/downloading files: [files](./files.md).
|
|
159
|
+
Files travel **only** via `files[]` — never through `return({ data })`, and never
|
|
160
|
+
`update_records` a file onto a record solely to make it downloadable
|
|
161
|
+
([files](./files.md)).
|
|
166
162
|
|
|
167
163
|
### Structured results: `return({ data })`
|
|
168
164
|
|
|
@@ -172,13 +168,10 @@ envelope's grammar — `message` is required, `field_errors` never reaches the a
|
|
|
172
168
|
(computed totals, row lists, status objects) the app reads back as `result.data`:
|
|
173
169
|
|
|
174
170
|
- **Typed for free — persisted at `set`.** The alias's `outputs` schema is derived at save
|
|
175
|
-
time from the inferred
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
immediately — no hand-copy of the echoed schema, no second `lotics app codegen`. An
|
|
180
|
-
**explicitly declared** `outputs` is authoritative and never overwritten. Declare an explicit
|
|
181
|
-
`outputs` only to narrow beyond what's inferred; a shape the checker can't pin down degrades
|
|
171
|
+
time from the inferred type of the body's `return({ data })`. When the binding declares
|
|
172
|
+
**no** `outputs`, `set_app_workflow` stores the derived schema as the alias's `outputs`. An
|
|
173
|
+
**explicitly declared** `outputs` is authoritative and never overwritten;
|
|
174
|
+
declare one only to narrow beyond what's inferred. A shape the checker can't pin down degrades
|
|
182
175
|
to untyped `json`, never to a wrong schema.
|
|
183
176
|
- **The output vocabulary is the input one minus the caller-only types, and `select` behaves
|
|
184
177
|
differently.** Scalars (`text`/`number`/`boolean`/`date`/`datetime`/`email`), `record_link`
|
|
@@ -219,10 +212,8 @@ if (r.status === "success" && r.data) {
|
|
|
219
212
|
An alias's declaration is `{ inputs?, outputs?, workflow_id? }` — `workflow_id` is the server's
|
|
220
213
|
half, stamped by the first bind, so an alias an author has only just written carries none.
|
|
221
214
|
`inputs` maps each input name to a typed declaration; it is authored beside the body and
|
|
222
|
-
travels with it (`set_app_workflow`
|
|
223
|
-
|
|
224
|
-
the schema the body was verified against is canonical — a manifest edit that reaches no push
|
|
225
|
-
weakens nothing. It drives three things at once: compile-time typing of the `useWorkflow` payload,
|
|
215
|
+
travels with it (`set_app_workflow` takes both), and the schema the body was verified against is
|
|
216
|
+
canonical. It drives three things at once: compile-time typing of the `useWorkflow` payload,
|
|
226
217
|
compile-time typing of `trigger.app_workflow.inputs.*` inside the body, and runtime payload
|
|
227
218
|
validation at the execute boundary.
|
|
228
219
|
|
|
@@ -266,8 +257,7 @@ When the alias declares `inputs`, the server validates the payload before the wo
|
|
|
266
257
|
owner; the body forwards it to `update_records.set`, where `null` clears.
|
|
267
258
|
- **`""` is NOT a clear** — a top-level optional input whose value is the empty string is treated
|
|
268
259
|
as *omitted* (HTML form controls emit `""` for "left blank", never for "blank this out"), so the
|
|
269
|
-
write reports success and the old value stays
|
|
270
|
-
guess and it fails silently; send `null`. Nested optional object fields are plain-optional and
|
|
260
|
+
write reports success and the old value stays; send `null`. Nested optional object fields are plain-optional and
|
|
271
261
|
**reject `null`** — only a top-level input clears; omit the key. A required `text` input does
|
|
272
262
|
accept `""` — emptiness is not a type error; validate non-emptiness in the workflow body if it
|
|
273
263
|
matters.
|
|
@@ -278,11 +268,10 @@ When the alias declares `inputs`, the server validates the payload before the wo
|
|
|
278
268
|
the input `required: false` if "attach nothing" is valid; then `""` omits and `[]` clears.
|
|
279
269
|
- **Server-side reference bindings** — every `record_link` id must reference an existing
|
|
280
270
|
record in its declared `table_id`; every group-scoped `member` id must belong to the
|
|
281
|
-
declared group; every `file` id must live in the app's workspace
|
|
282
|
-
|
|
283
|
-
Rationale and the full caller-boundary model: [security](./security.md).
|
|
271
|
+
declared group; every `file` id must live in the app's workspace — a tenancy floor, not an
|
|
272
|
+
authorization check ([security](./security.md#the-caller-boundary)).
|
|
284
273
|
- **`select` names its option set one of two ways — declare exactly one (both or neither is a
|
|
285
|
-
set-time error).** *Inline* `options: [{label, value}]` freezes the set into the
|
|
274
|
+
set-time error).** *Inline* `options: [{label, value}]` freezes the set into the generated
|
|
286
275
|
literal union and is **format-only at run time**: any well-formed key is accepted, and an
|
|
287
276
|
option added to the live field after deploy is valid at run time but fails the compile-time
|
|
288
277
|
type (widen or redeploy the declaration when the frozen union gets in the way).
|
|
@@ -292,7 +281,7 @@ When the alias declares `inputs`, the server validates the payload before the wo
|
|
|
292
281
|
added after authoring is accepted, a removed one rejected
|
|
293
282
|
(`select input "<path>" value "<v>" is not one of field "<key>"'s current options`) with no
|
|
294
283
|
redeploy. A `field` that doesn't exist or names a non-select field is rejected at bind time
|
|
295
|
-
(`
|
|
284
|
+
(`set_app_workflow`) with
|
|
296
285
|
`select input references field "<key>", which does not exist in this workspace` /
|
|
297
286
|
`… which is a <type> field, not a select`. Prefer `field` for any select backed by a real
|
|
298
287
|
field; keep `options` for a fixed enum the app owns. Populate pickers from `useFieldOptions`
|
|
@@ -302,25 +291,12 @@ If the alias declares **no** `inputs`, the payload passes through opaquely — n
|
|
|
302
291
|
no typing, no reference binding. Fine for a zero-input action; declare inputs for anything
|
|
303
292
|
that carries data.
|
|
304
293
|
|
|
305
|
-
### File inputs
|
|
306
|
-
|
|
307
|
-
A `file` input is how an upload becomes data: the app uploads bytes first (`useFileUpload` /
|
|
308
|
-
`useAttachments` — [files](./files.md)), then passes the returned id(s) to the workflow. An
|
|
309
|
-
uploaded file is **inert** until a workflow attaches it to a record's `files` field.
|
|
310
|
-
|
|
311
|
-
- Single `file` input → one file id; wrap in an array (`[id]`) when the body writes it to a
|
|
312
|
-
`files` field (files fields are always arrays).
|
|
313
|
-
- `multi: true` → the body receives `ReadonlyArray<FileId>`, directly assignable to a `files`
|
|
314
|
-
field — the way to persist several attachments onto one record in one write.
|
|
315
|
-
|
|
316
|
-
### Member inputs
|
|
294
|
+
### File and member inputs
|
|
317
295
|
|
|
318
|
-
A `
|
|
319
|
-
|
|
320
|
-
([security](./security.md)
|
|
321
|
-
|
|
322
|
-
when the app declares a member-typed input somewhere — a workflow input or a query param —
|
|
323
|
-
see [members_and_options](./members_and_options.md).
|
|
296
|
+
A `file` input carries an upload's id ([files](./files.md#file-workflow-inputs)). A `member`
|
|
297
|
+
input is member *selection*, never actor identity
|
|
298
|
+
([security](./security.md#attributing-writes-runtimetriggered_by_member_id)); declaring one also
|
|
299
|
+
unlocks `useMembers` ([members_and_options](./members_and_options.md#access-gates-when-it-errors)).
|
|
324
300
|
|
|
325
301
|
### What the body sees
|
|
326
302
|
|
|
@@ -328,50 +304,17 @@ Inside the workflow body, the payload is `trigger.app_workflow.inputs.<name>`, t
|
|
|
328
304
|
`T | null | undefined` for an optional input. The triggering member is
|
|
329
305
|
`runtime.triggered_by_member_id` (`null` for an anonymous public caller).
|
|
330
306
|
|
|
331
|
-
**Guard on the KEY, not on the value
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
```js
|
|
337
|
-
const i = trigger.app_workflow.inputs;
|
|
338
|
-
if (includes(keys(i), "title")) {
|
|
339
|
-
// i.title may be null here — that is the CLEAR, and `set` writes it as one.
|
|
340
|
-
await update_records({ table_id: "tbl_items", record_ids: [i.record_id], set: { fld_title: i.title } });
|
|
341
|
-
}
|
|
342
|
-
```
|
|
343
|
-
|
|
344
|
-
`undefined` is not spellable in a workflow body — the expression subset has no such identifier, and
|
|
345
|
-
a body naming it fails to save with `Unknown identifier "undefined"`. `keys` is the presence test.
|
|
346
|
-
|
|
347
|
-
When several optional inputs each map to a field, per-field guards become noise. Hand the whole
|
|
348
|
-
bag to `update_records`' **`set_skip_null`** instead — same object shape as `set`, but entries
|
|
349
|
-
whose value is `null`/`undefined` are dropped, so only the fields actually provided get written
|
|
350
|
-
(untouched fields keep their lock / `before_update` / concurrent-edit safety — the diff-write
|
|
351
|
-
discipline below, done in the body). **It drops `null` as well as `undefined`, so it cannot
|
|
352
|
-
CLEAR** — a field the screen must be able to blank belongs in `set` with the guard above, whatever
|
|
353
|
-
the rest of the bag does:
|
|
354
|
-
|
|
355
|
-
```js
|
|
356
|
-
const i = trigger.app_workflow.inputs;
|
|
357
|
-
await update_records({
|
|
358
|
-
table_id: "tbl_items",
|
|
359
|
-
record_ids: [i.record_id],
|
|
360
|
-
set_skip_null: { fld_title: i.title, fld_status: i.status, fld_due: i.due }, // absent inputs drop out
|
|
361
|
-
});
|
|
362
|
-
```
|
|
363
|
-
|
|
364
|
-
`set` still writes every key it carries — `null` in `set` **clears** the field; `null`/absent in
|
|
365
|
-
`set_skip_null` **skips** it. The full write surface (`set`, `set_skip_null`, the surgical
|
|
366
|
-
`add_to`/`remove_from`/`replace` ops, `increment`, `field_edits`, and the exact value shape per
|
|
367
|
-
field type)
|
|
368
|
-
is [workflows](./workflows.md#writing-records).
|
|
307
|
+
**Guard on the KEY, not on the value** — an omitted input has no key, a cleared one has the key
|
|
308
|
+
with `null`, so `!= null` skips a clear as well as an omission. `set_skip_null` writes a bag of
|
|
309
|
+
optional inputs as a diff but drops `null`, so it cannot CLEAR. Both, with the rest of the write
|
|
310
|
+
surface: [workflows](./workflows.md#writing-records).
|
|
369
311
|
|
|
370
312
|
## Refetch after a mutation
|
|
371
313
|
|
|
372
|
-
**A successful workflow re-reads the queries
|
|
373
|
-
|
|
374
|
-
|
|
314
|
+
**A successful workflow re-reads the queries its body can have changed, on its own.** The server
|
|
315
|
+
reads every table a bound workflow's body names; a success re-reads each query over one of them,
|
|
316
|
+
mounted or not, so the rows on screen never wait for the host's push. Nothing to call, nothing to
|
|
317
|
+
name:
|
|
375
318
|
|
|
376
319
|
```tsx
|
|
377
320
|
const items = useQuery("items");
|
|
@@ -380,32 +323,25 @@ const closeItem = useWorkflow("closeItem");
|
|
|
380
323
|
const onClose = async (recordId: string) => {
|
|
381
324
|
const r = await closeItem({ record_id: recordId });
|
|
382
325
|
if (r.status === "error") { showError(r.message); return; }
|
|
383
|
-
// items re-reads itself —
|
|
326
|
+
// items re-reads itself — its table is one the body names.
|
|
384
327
|
};
|
|
385
328
|
```
|
|
386
329
|
|
|
387
|
-
Only on **success**: a refused write changed nothing
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
330
|
+
Only on **success**: a refused write changed nothing. A body that names a table only at run time,
|
|
331
|
+
a run whose writes could not be predicted, a run before the app's write model has loaded, and a
|
|
332
|
+
`useAgentRun` leg that lands, re-read every mounted query. A comment written or deleted re-reads
|
|
333
|
+
every `useCommentCounts`. Under a design-time
|
|
334
|
+
fixture every mounted read re-reads, since a fixture has no body to read tables from. The host's
|
|
335
|
+
realtime push is separate: it carries changes **other** people make, and is far too slow for your
|
|
336
|
+
own.
|
|
393
337
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
your own — waiting on it is what makes a screen feel like it lagged its own button.
|
|
397
|
-
|
|
398
|
-
`refetch()` remains for the reads a write cannot know about: a poll, a value the user expects to
|
|
399
|
-
change without writing anything, or a total you supplied yourself (below).
|
|
338
|
+
`refetch()` remains for the reads a write cannot know about: a poll, or a value the user expects
|
|
339
|
+
to change without writing anything.
|
|
400
340
|
|
|
401
341
|
`refetch` re-runs the query in the background while the current rows stay on screen (no
|
|
402
|
-
flash to a spinner — `loading` stays false during revalidation). `
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
you read it from, or the page moves while "of N" does not. Focus revalidation
|
|
406
|
-
(`revalidateOnFocus`, default on) eventually self-corrects stale data, but never rely on it
|
|
407
|
-
where the staleness is one of those three — the user is looking at the number now, not the
|
|
408
|
-
next time they come back to the tab.
|
|
342
|
+
flash to a spinner — `loading` stays false during revalidation). A `page` read's `refetch`
|
|
343
|
+
re-runs the current page and its count. Focus revalidation
|
|
344
|
+
(`revalidateOnFocus`, default on) eventually self-corrects, but never rely on it for those.
|
|
409
345
|
|
|
410
346
|
### A read must not overtake an in-flight write
|
|
411
347
|
|
|
@@ -415,32 +351,17 @@ mousedown, the button's handler on mouseup — so a handler that re-reads the re
|
|
|
415
351
|
document, mint something, or copy the row can read the row as it was BEFORE the edit. It fails
|
|
416
352
|
silently: the screen shows the new value, the output carries the old one.
|
|
417
353
|
|
|
418
|
-
|
|
419
|
-
|
|
354
|
+
Await `writesSettled()` in the handler that re-reads stored state: it resolves once every record
|
|
355
|
+
write this app has already sent — a workflow run or an agent run — has answered, whatever the
|
|
356
|
+
answer; a write sent after the call, and a workflow that writes no record (a document, an export),
|
|
357
|
+
is not waited for.
|
|
420
358
|
|
|
421
359
|
```tsx
|
|
422
|
-
|
|
423
|
-
export function createWriteBarrier() {
|
|
424
|
-
let chain: Promise<unknown> = Promise.resolve();
|
|
425
|
-
return {
|
|
426
|
-
// Returns the write UNCHANGED — the caller keeps its own result + error handling;
|
|
427
|
-
// the chain never rejects, it only tracks when writes SETTLE.
|
|
428
|
-
track: <T,>(write: Promise<T>): Promise<T> => {
|
|
429
|
-
chain = chain.then(() => write).catch(() => undefined);
|
|
430
|
-
return write;
|
|
431
|
-
},
|
|
432
|
-
settled: () => chain,
|
|
433
|
-
};
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
const saveField = async (key: string, value: unknown) => {
|
|
437
|
-
const r = await writes.track(saveRecord({ record_id, [key]: value }));
|
|
438
|
-
if (r.status === "error") throw new Error(r.message); // the inline editor keeps the edit
|
|
439
|
-
};
|
|
360
|
+
import { rpc, writesSettled } from "@lotics/app-sdk";
|
|
440
361
|
|
|
441
362
|
/** The ONLY re-read of stored state. Every handler goes through it — never the cached row. */
|
|
442
363
|
const reload = async () => {
|
|
443
|
-
await
|
|
364
|
+
await writesSettled();
|
|
444
365
|
const res = await rpc<{ rows?: Rec[] }>("query", { alias: "record", params: { id: record_id } });
|
|
445
366
|
return res.rows?.[0] ?? null;
|
|
446
367
|
};
|
|
@@ -467,8 +388,8 @@ to get right.
|
|
|
467
388
|
|
|
468
389
|
An edit form snapshots the record's values when it loads, and on save sends **only the fields
|
|
469
390
|
the user actually changed** to its update workflow. Declare each updatable input
|
|
470
|
-
`required: false`; an omitted input means "not written" (the body guards each write
|
|
471
|
-
them to `set_skip_null`
|
|
391
|
+
`required: false`; an omitted input means "not written" (the body guards each write, or hands
|
|
392
|
+
them to `set_skip_null`). "Changed" is decided at the edit surface that loaded
|
|
472
393
|
the before-state — compare against the load-time snapshot, not against a re-fetch.
|
|
473
394
|
|
|
474
395
|
Why a full-form snapshot save is a bug, not a style choice — three independent mechanisms:
|
|
@@ -519,7 +440,7 @@ Row-level query results carry a `__source_locked` addressing column automaticall
|
|
|
519
440
|
`__source_record_id` — no projection needed; like all source addressing it survives
|
|
520
441
|
project/filter/sort/join but not grouping, where rows stop being records).
|
|
521
442
|
`readLocked(row)` — pass the **whole row**, not a cell — returns `true` for a locked record,
|
|
522
|
-
`false` when the flag is absent (`dist/
|
|
443
|
+
`false` when the flag is absent (`dist/row.d.ts`). Use it to render a locked state and to
|
|
523
444
|
switch the save path.
|
|
524
445
|
|
|
525
446
|
### What a write against a locked record does
|
|
@@ -590,61 +511,71 @@ const save = async () => {
|
|
|
590
511
|
};
|
|
591
512
|
```
|
|
592
513
|
|
|
593
|
-
##
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
the
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
-
|
|
608
|
-
|
|
609
|
-
the
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
the
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
514
|
+
## A write shows from the press
|
|
515
|
+
|
|
516
|
+
**Every write is drawn on every read the moment it is made — with nothing to wire.** The server
|
|
517
|
+
hands the app its **write model** (`GET /v1/apps/{app_id}/write_model`, read once at mount): each
|
|
518
|
+
bound workflow's record writes and the steps they depend on, where each query's columns and rows
|
|
519
|
+
come from, and the fields they name. `useWorkflow` walks the workflow's own steps against the rows
|
|
520
|
+
the app has already read, with the evaluator the engine runs, and stages what it will write over
|
|
521
|
+
every read before the request answers:
|
|
522
|
+
|
|
523
|
+
- **Cells.** A written cell redraws in every column that reads it: an option as its label, a member
|
|
524
|
+
as the face a read or `useMembers` drew, a link as its label — and a column reading the linked row beside it
|
|
525
|
+
(a face) as that row's field, where a read has drawn that row.
|
|
526
|
+
- **Formulas** of the written row recompute, where every field they read is held; one reading a
|
|
527
|
+
field no read carried waits for the server, and `pending` says so.
|
|
528
|
+
- **Membership.** A row a read's filter — and the call's runtime `filter` — no longer admits
|
|
529
|
+
leaves it; a created row it admits joins it, standing by the read's order (the call's `sort`,
|
|
530
|
+
then the query's own) where its sort cells are numbers or dates, else first. A row the read does
|
|
531
|
+
not hold that a write may move into it waits for the server, and `pending` says so.
|
|
532
|
+
- **Totals.** A `total`, a count per value (`total: { by }`), a group asked with `aggregate` or
|
|
533
|
+
declared by the query, and a parent's count or sum rollup move by exactly what the written row adds
|
|
534
|
+
or takes away; a group the last row leaves is gone, and a count per value gains the value a row
|
|
535
|
+
first holds. A mean, an extreme, a distinct count, a group by day, a group none of the read's rows
|
|
536
|
+
counted in yet, a read whose rows a search or a cap decides, or a row the app never read is left
|
|
537
|
+
as the server said it until a read asked after the write answers.
|
|
538
|
+
- **A refusal takes it back**, and `result` says why, as always. A success keeps it until a read
|
|
539
|
+
asked after the write settled answers — the stored value then replaces the prediction, whatever
|
|
540
|
+
it said, so a server that rounds or trims wins with no flicker back to the old value. Where the
|
|
541
|
+
stored value differs from the one drawn, the SDK tells the server where — the workflow, table,
|
|
542
|
+
field and kind of miss, never the value. A write model that cannot be read and a prediction that
|
|
543
|
+
throws are reported too, by the error's class name alone.
|
|
544
|
+
|
|
545
|
+
What the steps cannot tell before they run is never guessed:
|
|
546
|
+
|
|
547
|
+
- A value from a query step, an agent, an integration, a member's groups, a cell no read carried:
|
|
548
|
+
the write that needs it is not drawn, and the read's `pending` is true until the answer lands.
|
|
549
|
+
- A branch on such a value: every write under it waits for the answer — and where the branch can
|
|
550
|
+
end the run (`return`, `validate`, `break`), so does every write after it: a create that first
|
|
551
|
+
looks for a row to reuse draws nothing until the server says which it did. A `catch` block and a
|
|
552
|
+
`while` or counted `for` loop wait too.
|
|
553
|
+
- A pause (`wait`, `wait_for_event`, `wait_for_approval`): every write after it waits for the answer.
|
|
554
|
+
- A step that changes rows it cannot draw — `lock_records`, `restore_records`, a pressed button, an
|
|
555
|
+
agent whose tools can change rows, a `create_records` matching on a field, a filtered update or
|
|
556
|
+
delete: every read over the tables it may touch (every read, where it names none) is `pending`
|
|
557
|
+
until the answer lands.
|
|
558
|
+
- A guard (`validate`, a `return` with `status: "error"`) it can decide over held rows: a refused
|
|
559
|
+
write draws nothing. One it cannot decide is assumed to pass — the server is the authority, and
|
|
560
|
+
a refusal takes the drawing back.
|
|
561
|
+
- A guard or branch comparing an input with a constant (`i.code == "VIP"`) never reaches the app:
|
|
562
|
+
the constant would be shown to everyone who can open it. The guard is assumed to pass, and the
|
|
563
|
+
branch's writes wait for the answer. A comparison with a held row's cell reaches it.
|
|
564
|
+
|
|
565
|
+
Every read hook returns **`pending`**: a write this app made is in flight over its rows, or has
|
|
566
|
+
landed with part of what it changes not yet read back. The rows already show what is known; use it
|
|
567
|
+
for a quiet "saving" cue, never to hide the rows.
|
|
568
|
+
|
|
569
|
+
**A create gets its id before the server answers.** Every row a `create_records` makes without
|
|
570
|
+
`ids` is drawn under a `rec_*` the SDK mints, and the run carries those ids to the server, which
|
|
571
|
+
stores each row under the one drawn for it — so the row drawn from the press is the row the server
|
|
572
|
+
stores, and every write that names it (a history line, a child filed under it) is drawn too. The
|
|
573
|
+
body states nothing for this. A create that states its own `ids` keeps them; after a create the
|
|
574
|
+
client cannot count, or one that may or may not run, the rows that follow wait for the server.
|
|
575
|
+
`result.data` carries the id as the body returns it.
|
|
576
|
+
|
|
577
|
+
Declare one workflow per (table, field) you mutate — typed and narrow, never a generic
|
|
578
|
+
`setField(any_field)` that hands the client write access to every field
|
|
648
579
|
([security](./security.md)).
|
|
649
580
|
|
|
650
581
|
## Editing a record that does not exist yet: `useNewRecord`
|
|
@@ -670,9 +601,8 @@ const { id, save } = useNewRecord({
|
|
|
670
601
|
- **The record is still created on the first write, not on mount** — a surface the user opens
|
|
671
602
|
and abandons leaves nothing behind.
|
|
672
603
|
- **`save` serialises.** The first call creates, every later one updates, and calls made before
|
|
673
|
-
the create resolves queue behind it
|
|
674
|
-
|
|
675
|
-
hand-rolled, the racing-blur case creates a duplicate.
|
|
604
|
+
the create resolves queue behind it, so two fields blurred in quick succession produce one
|
|
605
|
+
record, not two.
|
|
676
606
|
- **A failed create does not latch.** The next `save` retries the create, rather than updating a
|
|
677
607
|
row that was never written while the UI looks like it saved.
|
|
678
608
|
- `id` and `save` are stable across renders, so `save` can be bound directly to an `onBlur`.
|
|
@@ -685,12 +615,13 @@ Creation is creation: an id that already exists is a conflict, never an overwrit
|
|
|
685
615
|
workflow cannot be used to clobber another record by guessing its id.
|
|
686
616
|
|
|
687
617
|
Use `newRecordId()` directly if you need the id outside a hook (routing to the surface before
|
|
688
|
-
mounting it, say). It mints the same `rec_*` shape the server validates.
|
|
618
|
+
mounting it, say). It mints the same `rec_*` shape the server validates. A create made in one press
|
|
619
|
+
needs neither: the SDK mints the id itself (above).
|
|
689
620
|
|
|
690
621
|
## Recording into a workflow: `useRecording`
|
|
691
622
|
|
|
692
623
|
A recording act — record a site visit, a call, a meeting, and file it on the row it is about —
|
|
693
|
-
is a workflow whose run waits for the transcript. Exact signature: `dist/
|
|
624
|
+
is a workflow whose run waits for the transcript. Exact signature: `dist/recording.d.ts`.
|
|
694
625
|
|
|
695
626
|
```tsx
|
|
696
627
|
import { useRecording } from "@lotics/app-sdk";
|
|
@@ -727,7 +658,7 @@ await visit.stop();
|
|
|
727
658
|
is still bound, and its declaration still receives recordings. A refused or failed filing is
|
|
728
659
|
never retried by itself and the recording is never lost: the member retries from the
|
|
729
660
|
product's bar, and a failed run also sends the app's owner the failed-run notice.
|
|
730
|
-
- **`available`** is what the host states — false standalone, in
|
|
661
|
+
- **`available`** is what the host states — false standalone, in a host
|
|
731
662
|
that predates recording, on an instance without transcription, and until the context has
|
|
732
663
|
answered. A standalone app has no member to record as; both calls reject there.
|
|
733
664
|
|