@lotics/app-sdk 0.100.1 → 0.101.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.
Files changed (108) hide show
  1. package/AGENTS.md +32 -47
  2. package/dist/agent_stream.d.ts +131 -0
  3. package/dist/ask_ai.d.ts +27 -0
  4. package/dist/attachments.d.ts +58 -0
  5. package/dist/chunk-ARV5FAU5.js +1132 -0
  6. package/dist/comments.d.ts +89 -0
  7. package/dist/error_report.d.ts +9 -0
  8. package/dist/folder_pick.d.ts +8 -0
  9. package/dist/geolocation.d.ts +42 -0
  10. package/dist/hooks.d.ts +251 -0
  11. package/dist/{src/index.d.ts → index.d.ts} +13 -22
  12. package/dist/index.js +31309 -0
  13. package/dist/index.js.LEGAL.txt +11 -0
  14. package/dist/members.d.ts +32 -0
  15. package/dist/mock.d.ts +37 -0
  16. package/dist/mount.d.ts +19 -0
  17. package/dist/new_record.d.ts +37 -0
  18. package/dist/open_app.d.ts +12 -0
  19. package/dist/open_external.d.ts +10 -0
  20. package/dist/overlay.d.ts +25 -0
  21. package/dist/queries.d.ts +231 -0
  22. package/dist/recording.d.ts +47 -0
  23. package/dist/recording_state.d.ts +43 -0
  24. package/dist/rename_file.d.ts +13 -0
  25. package/dist/router.d.ts +10 -0
  26. package/dist/router.js +97 -0
  27. package/dist/row.d.ts +87 -0
  28. package/dist/rpc.d.ts +114 -0
  29. package/dist/select.d.ts +24 -0
  30. package/dist/shared_types.d.ts +8 -0
  31. package/dist/store.d.ts +43 -0
  32. package/dist/types.d.ts +36 -0
  33. package/dist/upload/optimize.d.ts +30 -0
  34. package/dist/upload/pipeline.d.ts +36 -0
  35. package/dist/upload/transport.d.ts +19 -0
  36. package/dist/url_params.d.ts +55 -0
  37. package/dist/use_recents.d.ts +15 -0
  38. package/dist/{src/use_url_state.d.ts → use_url_state.d.ts} +0 -2
  39. package/dist/viewer.d.ts +41 -0
  40. package/dist/written.d.ts +77 -0
  41. package/docs/ai.md +74 -133
  42. package/docs/data_fetching.md +209 -290
  43. package/docs/files.md +61 -51
  44. package/docs/members_and_options.md +92 -62
  45. package/docs/mutations.md +135 -205
  46. package/docs/navigation_and_state.md +26 -35
  47. package/docs/queries.md +144 -207
  48. package/docs/recipes.md +21 -45
  49. package/docs/runtime.md +74 -137
  50. package/docs/security.md +8 -11
  51. package/docs/workflows.md +189 -174
  52. package/package.json +27 -28
  53. package/dist/src/agent_stream.d.ts +0 -200
  54. package/dist/src/agent_stream.js +0 -314
  55. package/dist/src/ask_ai.d.ts +0 -40
  56. package/dist/src/ask_ai.js +0 -35
  57. package/dist/src/attachments.d.ts +0 -68
  58. package/dist/src/attachments.js +0 -93
  59. package/dist/src/comments.d.ts +0 -127
  60. package/dist/src/comments.js +0 -192
  61. package/dist/src/download.js +0 -54
  62. package/dist/src/geolocation.d.ts +0 -64
  63. package/dist/src/geolocation.js +0 -96
  64. package/dist/src/hooks.d.ts +0 -781
  65. package/dist/src/hooks.js +0 -860
  66. package/dist/src/index.js +0 -34
  67. package/dist/src/members.d.ts +0 -105
  68. package/dist/src/members.js +0 -62
  69. package/dist/src/mock.d.ts +0 -118
  70. package/dist/src/mock.js +0 -124
  71. package/dist/src/mount.d.ts +0 -47
  72. package/dist/src/mount.js +0 -34
  73. package/dist/src/new_record.d.ts +0 -74
  74. package/dist/src/new_record.js +0 -117
  75. package/dist/src/open_app.d.ts +0 -15
  76. package/dist/src/open_app.js +0 -18
  77. package/dist/src/open_external.d.ts +0 -16
  78. package/dist/src/open_external.js +0 -19
  79. package/dist/src/recording.d.ts +0 -59
  80. package/dist/src/recording.js +0 -30
  81. package/dist/src/recording_state.d.ts +0 -59
  82. package/dist/src/recording_state.js +0 -94
  83. package/dist/src/router.d.ts +0 -17
  84. package/dist/src/router.js +0 -144
  85. package/dist/src/row.d.ts +0 -159
  86. package/dist/src/row.js +0 -254
  87. package/dist/src/rpc.d.ts +0 -207
  88. package/dist/src/rpc.js +0 -904
  89. package/dist/src/select.d.ts +0 -48
  90. package/dist/src/select.js +0 -40
  91. package/dist/src/types.d.ts +0 -115
  92. package/dist/src/types.js +0 -1
  93. package/dist/src/upload/optimize.d.ts +0 -54
  94. package/dist/src/upload/optimize.js +0 -207
  95. package/dist/src/upload/pipeline.d.ts +0 -55
  96. package/dist/src/upload/pipeline.js +0 -52
  97. package/dist/src/upload/transport.d.ts +0 -42
  98. package/dist/src/upload/transport.js +0 -128
  99. package/dist/src/url_params.d.ts +0 -93
  100. package/dist/src/url_params.js +0 -215
  101. package/dist/src/use_optimistic.d.ts +0 -27
  102. package/dist/src/use_optimistic.js +0 -27
  103. package/dist/src/use_recents.d.ts +0 -19
  104. package/dist/src/use_recents.js +0 -71
  105. package/dist/src/use_url_state.js +0 -73
  106. package/dist/src/viewer.d.ts +0 -26
  107. package/dist/src/viewer.js +0 -47
  108. /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
- Everything about writing data from an app: `useWorkflow` (the only write path), the
4
- `WorkflowResult` contract and its resolve-never-throw failure model, declaring typed workflow
5
- inputs (file/member/optional inputs), returning structured data with `return({ data })`,
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 (`set_app_workflow`, or `lotics app workflow set <alias>` from the
32
- app project), and `lotics app deploy` calls it: `package.json#lotics.workflows.<alias>` plus
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/src/hooks.d.ts`. `useWorkflow(alias)` returns a stable async callable:
36
+ Exact signature: `dist/hooks.d.ts`. `useWorkflow(alias)` returns a stable async callable:
43
37
  `(inputs) => Promise<WorkflowResult<TData>>`.
44
38
 
45
- Typing comes from per-app codegen: `lotics app pull` / `app dev` / `app deploy` /
46
- `app codegen` write `.lotics/app_workflows.d.ts`, augmenting the SDK's `AppWorkflows` and
47
- `AppWorkflowResults` interfaces from the manifest's alias declarations. The result:
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/src/hooks.d.ts`):
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, and the `lotics app dev` harness)
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). One reading covers both
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 hand a `file_id`/`url` back through
163
- `return({ data })`, and never `update_records` a file onto a record solely to make it
164
- downloadable. A download-only workflow generates and returns; attaching to a record is a
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 TypeScript type of the body's `return({ data })` — the return *is*
176
- the declaration. When the manifest declared **no** `outputs`, `lotics app workflow set`
177
- writes the server-derived schema into `package.json#lotics.workflows.<alias>.outputs` and
178
- refreshes that alias's generated types in the same command, so `result.data` is typed
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` / `lotics app workflow set` reads it from
223
- `package.json#lotics.workflows.<alias>`, and a deploy pushes a declaration that has moved), and
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. Reaching for `""` to clear is the natural first
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. These are real write-time
282
- constraints, not picker cosmetics — a hand-crafted request can't redirect the workflow.
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 codegen
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
- (`lotics app workflow set` / `set_app_workflow`, which a deploy calls for a declaration it holds) with
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 `member` input is for genuine member *selection* (assign an order to a teammate) — never
319
- for actor identity, which the client can't be trusted to supply
320
- ([security](./security.md) → `runtime.triggered_by_member_id`). Declaring one also unlocks
321
- the roster: `useMembers()` (and `useMembers({ group })` for a declared `group`) only works
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.** The three caller states arrive intact — an omitted input
332
- has no key at all, a cleared one has the key with `null` — so a value test cannot tell them apart:
333
- `!= null` and `isNull(…)` both skip a clear as well as an omission, which is how a Clear button
334
- ends up doing nothing. Ask whether the key was sent:
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 that are mounted, on its own.** A workflow is the
373
- app's only write path, so the rows on screen are stale the moment one succeeds — the SDK
374
- re-reads them rather than making every call site remember to. Nothing to call:
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 — a successful write already said so.
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, and re-reading after one would swap the
388
- values the user is still looking at, and about to correct, for an identical set.
389
-
390
- It names every MOUNTED alias, not the ones the write touched — an app cannot know which tables
391
- a workflow body wrote, and a per-call-site list of what to invalidate goes stale silently the
392
- first time that body grows a second write. The cost is bounded by what is on screen.
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
- This is separate from the host's realtime push, which carries changes **other** people make and
395
- travels a socket the host owns. That path is right for someone else's edit and far too slow for
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). `usePaginatedQuery`'s
403
- `refetch` re-runs the current page, and the count when the hook owns it — a total you supplied
404
- yourself is yours to refresh, so a write that changes the row COUNT must also refresh whatever
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
- Serialize them with one barrier per record surface — every write chains on, and the ONE place
419
- that re-reads stored state awaits it, so no handler can forget:
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
- // Per surface, not module-level: useMemo(createWriteBarrier, []) inside the hook.
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 writes.settled();
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 — or hands
471
- them to `set_skip_null` — as shown above). "Changed" is decided at the edit surface that loaded
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/src/row.d.ts`). Use it to render a locked state and to
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,70 @@ const save = async () => {
590
511
  };
591
512
  ```
592
513
 
593
- ## Optimistic reconciliation: `useOptimistic`
594
-
595
- For interactive data-bound views (calendar drag, gantt resize, kanban move) where waiting for
596
- the round-trip feels broken, `useOptimistic` overlays pending patches on the query result
597
- (`dist/src/use_optimistic.d.ts`):
598
-
599
- ```ts
600
- const { items, patch } = useOptimistic(base, keyOf);
601
- patch(id, next, persist, opts?);
602
- ```
603
-
604
- - `base` is the already-mapped item list (derived from `useQuery` rows); `keyOf` extracts each
605
- item's stable key. `items` is `base` with pending patches merged per key (`{ ...item,
606
- ...patch }`); repeated patches on the same key merge.
607
- - `patch(id, next, persist, { onSettled })` applies `next` immediately, then runs the
608
- `persist` thunk. On **resolve**, the patch is *kept* and `onSettled` runs. On **reject**,
609
- the patch is *reverted*.
610
- - **`onSettled` is not where you refetch the query the patch came from.** The `persist` thunk
611
- calls `useWorkflow`, and a successful write re-reads every mounted query by itself — so the
612
- re-read is already in flight before `onSettled` fires, and passing `refetch` here buys a
613
- second execution of the same query. Keep it for what the write cannot know about: a total
614
- you computed yourself, a "saving…" indicator to clear, an analytics call.
615
- - **A kept patch stays merged over `base` for the life of the view.** It is never cleared on
616
- success — the design assumes it equals what the server stored, so the re-read lands
617
- underneath it with no flicker. Patch the value you are SENDING, not a display form of it: if
618
- the server normalizes (rounds a time, trims a string, resolves a link's label), the
619
- optimistic value masks the stored one permanently and nothing reports it.
620
-
621
- **Warning — the persist thunk must throw on `status: "error"`.** `useWorkflow` resolves on
622
- failure (the failure model above), and a resolved promise means "kept" to `useOptimistic` —
623
- so a bare `() => reschedule({...})` thunk keeps the optimistic value on screen even when the
624
- workflow failed. Convert the status into a rejection:
625
-
626
- ```tsx
627
- const q = useQuery("events");
628
- const reschedule = useWorkflow("rescheduleEvent");
629
- const mapped = useMemo(() => q.rows.map(toCalendarEvent), [q.rows]);
630
- const { items, patch } = useOptimistic(mapped, (e) => e.id);
631
-
632
- const onEventDrop = (ev: CalendarEvent, newStart: Date) =>
633
- patch(
634
- ev.id,
635
- { start: newStart },
636
- async () => {
637
- // toISODate: the `@lotics/ui` local-date formatter → "YYYY-MM-DD"
638
- const r = await reschedule({ record_id: ev.recordId, new_date: toISODate(newStart) });
639
- if (r.status === "error") throw new Error(r.message ?? "Reschedule failed");
640
- },
641
- );
642
- ```
643
-
644
- This is the full read → mutate → reconcile loop: `useQuery` reads, `row.*` coerces,
645
- `useWorkflow` mutates, `useOptimistic` covers the round-trip until the write's own re-read
646
- converges. Declare one workflow per (table, field) you mutate — typed and narrow, never a
647
- generic `setField(any_field)` that hands the client write access to every field
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.
543
+
544
+ What the steps cannot tell before they run is never guessed:
545
+
546
+ - A value from a query step, an agent, an integration, a member's groups, a cell no read carried:
547
+ the write that needs it is not drawn, and the read's `pending` is true until the answer lands.
548
+ - A branch on such a value: every write under it waits for the answer — and where the branch can
549
+ end the run (`return`, `validate`, `break`), so does every write after it: a create that first
550
+ looks for a row to reuse draws nothing until the server says which it did. A `catch` block and a
551
+ `while` or counted `for` loop wait too.
552
+ - A pause (`wait`, `wait_for_event`, `wait_for_approval`): every write after it waits for the answer.
553
+ - A step that changes rows it cannot draw — `lock_records`, `restore_records`, a pressed button, an
554
+ agent whose tools can change rows, a `create_records` matching on a field, a filtered update or
555
+ delete: every read over the tables it may touch (every read, where it names none) is `pending`
556
+ until the answer lands.
557
+ - A guard (`validate`, a `return` with `status: "error"`) it can decide over held rows: a refused
558
+ write draws nothing. One it cannot decide is assumed to pass — the server is the authority, and
559
+ a refusal takes the drawing back.
560
+ - A guard or branch comparing an input with a constant (`i.code == "VIP"`) never reaches the app:
561
+ the constant would be shown to everyone who can open it. The guard is assumed to pass, and the
562
+ branch's writes wait for the answer. A comparison with a held row's cell reaches it.
563
+
564
+ Every read hook returns **`pending`**: a write this app made is in flight over its rows, or has
565
+ landed with part of what it changes not yet read back. The rows already show what is known; use it
566
+ for a quiet "saving" cue, never to hide the rows.
567
+
568
+ **A create gets its id before the server answers.** Every row a `create_records` makes without
569
+ `ids` is drawn under a `rec_*` the SDK mints, and the run carries those ids to the server, which
570
+ stores each row under the one drawn for it — so the row drawn from the press is the row the server
571
+ stores, and every write that names it (a history line, a child filed under it) is drawn too. The
572
+ body states nothing for this. A create that states its own `ids` keeps them; after a create the
573
+ client cannot count, or one that may or may not run, the rows that follow wait for the server.
574
+ `result.data` carries the id as the body returns it.
575
+
576
+ Declare one workflow per (table, field) you mutate — typed and narrow, never a generic
577
+ `setField(any_field)` that hands the client write access to every field
648
578
  ([security](./security.md)).
649
579
 
650
580
  ## Editing a record that does not exist yet: `useNewRecord`
@@ -670,9 +600,8 @@ const { id, save } = useNewRecord({
670
600
  - **The record is still created on the first write, not on mount** — a surface the user opens
671
601
  and abandons leaves nothing behind.
672
602
  - **`save` serialises.** The first call creates, every later one updates, and calls made before
673
- the create resolves queue behind it. Two fields blurred in quick succession therefore produce
674
- one record, not two. This is the whole reason to use the hook rather than a `useRef` latch:
675
- hand-rolled, the racing-blur case creates a duplicate.
603
+ the create resolves queue behind it, so two fields blurred in quick succession produce one
604
+ record, not two.
676
605
  - **A failed create does not latch.** The next `save` retries the create, rather than updating a
677
606
  row that was never written while the UI looks like it saved.
678
607
  - `id` and `save` are stable across renders, so `save` can be bound directly to an `onBlur`.
@@ -685,12 +614,13 @@ Creation is creation: an id that already exists is a conflict, never an overwrit
685
614
  workflow cannot be used to clobber another record by guessing its id.
686
615
 
687
616
  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.
617
+ mounting it, say). It mints the same `rec_*` shape the server validates. A create made in one press
618
+ needs neither: the SDK mints the id itself (above).
689
619
 
690
620
  ## Recording into a workflow: `useRecording`
691
621
 
692
622
  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/src/recording.d.ts`.
623
+ is a workflow whose run waits for the transcript. Exact signature: `dist/recording.d.ts`.
694
624
 
695
625
  ```tsx
696
626
  import { useRecording } from "@lotics/app-sdk";
@@ -727,7 +657,7 @@ await visit.stop();
727
657
  is still bound, and its declaration still receives recordings. A refused or failed filing is
728
658
  never retried by itself and the recording is never lost: the member retries from the
729
659
  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 `lotics app dev`, in a host
660
+ - **`available`** is what the host states — false standalone, in a host
731
661
  that predates recording, on an instance without transcription, and until the context has
732
662
  answered. A standalone app has no member to record as; both calls reject there.
733
663