@lotics/app-sdk 0.46.1 → 0.47.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.
@@ -0,0 +1,438 @@
1
+ # Mutations — writing data through workflows
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
+ refetching after a mutation, the diff-before-update discipline, locked records
7
+ (`readLocked` + `request_locked_record_change`), and optimistic reconciliation
8
+ (`useOptimistic`). Read this before building any screen that creates, updates, or deletes
9
+ records. Reading data is [queries](./queries.md) + [data_fetching](./data_fetching.md);
10
+ uploads are [files](./files.md); who a write runs as is [security](./security.md).
11
+
12
+ ## The write model
13
+
14
+ An app holds no credentials and never writes records directly — there is no
15
+ `createRecord`/`updateRecord` API in the SDK. Every write is a **workflow** bound to an alias
16
+ on the server and invoked by that alias:
17
+
18
+ ```tsx
19
+ import { useWorkflow } from "@lotics/app-sdk";
20
+
21
+ const createOrder = useWorkflow("createOrder");
22
+ const result = await createOrder({ customer_id, quantity: 3 });
23
+ ```
24
+
25
+ - The server resolves the alias against the app's bound workflows, validates the payload
26
+ against the alias's declared input schema, and executes the workflow under the **app
27
+ owner's** authority (never the viewer's — see [security](./security.md) for attribution,
28
+ privilege gates, and public-app semantics).
29
+ - Binding is server-side (`set_app_workflow`, or `lotics app workflow set <alias>` from the
30
+ app project). `lotics app deploy` ships code, queries, and capabilities — it never binds
31
+ workflows.
32
+ The `package.json#lotics.workflows` map is a *pulled reflection* of the live bindings, used
33
+ purely to type `useWorkflow` (below); hand-editing it changes nothing on the server.
34
+ - Invoking an alias that is not bound resolves with `status: "error"` and a message naming
35
+ the missing binding.
36
+ - Anonymous visitors to a publicly shared app can invoke workflows too; the triggering
37
+ member is then `null` inside the body. See [security](./security.md).
38
+
39
+ ## `useWorkflow(alias)` — signature and typing
40
+
41
+ Exact signature: `dist/src/hooks.d.ts`. `useWorkflow(alias)` returns a stable async callable:
42
+ `(inputs) => Promise<WorkflowResult<TData>>`.
43
+
44
+ Typing comes from per-app codegen: `lotics app pull` / `app dev` / `app deploy` /
45
+ `app codegen` write `.lotics/app_workflows.d.ts`, augmenting the SDK's `AppWorkflows` and
46
+ `AppWorkflowResults` interfaces from the manifest's alias declarations. The result:
47
+
48
+ | Declaration | Call-site type |
49
+ |---|---|
50
+ | Alias not declared | Compile-time error at `useWorkflow("...")` |
51
+ | Declared with `inputs` | `(inputs: <declared shape>) => Promise<WorkflowResult<TData>>` — inputs required and shaped |
52
+ | Declared with an empty `inputs: {}` | `(inputs?: Record<string, never>)` — call with `{}` or nothing |
53
+ | Declared without `inputs` | Untyped `Record<string, unknown>` payload (and unvalidated server-side — declare inputs for any real workflow) |
54
+ | Declared with `outputs` | `result.data` typed as the declared shape (`TData`); otherwise `unknown` |
55
+
56
+ ## `WorkflowResult` — the result contract
57
+
58
+ Every call resolves to a `WorkflowResult<TData>` (type exported from the package root;
59
+ `dist/src/hooks.d.ts`):
60
+
61
+ | Field | Type | Meaning |
62
+ |---|---|---|
63
+ | `status` | `"success" \| "error"` | The one field to branch on — see the failure model below |
64
+ | `message` | `string?` | The workflow's `return({ message })` text, a validation/binding error, or a body-free transport message |
65
+ | `files` | `UploadedFile[]?` | Files generated during the run (auto-collected — below). Absent when the run generated none |
66
+ | `data` | `TData?` | Structured data from `return({ data })`. Absent when no return step ran |
67
+
68
+ ### The failure model: check `status`, never just try/catch
69
+
70
+ **Every failure resolves — the promise (almost) never rejects.** All three transports
71
+ (embedded product host, standalone `<slug>.lotics.app`, and the `lotics app dev` harness)
72
+ convert every failure into a resolved `{ status: "error", message }`:
73
+
74
+ | Failure | What resolves |
75
+ |---|---|
76
+ | Workflow ends with `return({ status: "error", message })` | That status + message |
77
+ | Workflow crashes mid-run (unhandled tool/expression error) | `status: "error"` with a failure message |
78
+ | Input validation rejected (missing required, extra key, type mismatch) | `status: "error"` with the server's structured field-level message |
79
+ | Alias not bound / workflow deleted | `status: "error"` with the explanatory message |
80
+ | Gateway timeout (a 524 on a long run), any 5xx, a non-JSON error page | `status: "error"` with a friendly, **body-free** message — never raw gateway HTML |
81
+
82
+ So the correct handling is:
83
+
84
+ ```tsx
85
+ const result = await createOrder({ customer_id, quantity });
86
+ if (result.status === "error") {
87
+ showError(result.message ?? "Something went wrong.");
88
+ return;
89
+ }
90
+ orders.refetch();
91
+ ```
92
+
93
+ **Warning:** a `try/catch` with no `status` check silently treats every workflow failure as
94
+ success — the promise resolves either way. Conversely, `status: "success"` on a
95
+ transport-timeout retry is not guaranteed to mean "ran once": a 524 means the run *may still
96
+ be executing* server-side; design retried workflows to be idempotent or re-check state via a
97
+ query before retrying.
98
+
99
+ **Warning:** a gateway timeout's message is deliberately body-free and generic. Don't parse
100
+ `message` to distinguish failure kinds — it is display text, not a machine-readable code. If
101
+ the app must branch on failure kinds, make the workflow return them:
102
+ `return({ status: "error", data: { code: "OUT_OF_STOCK" } })` (an *error* return's `data`
103
+ passes through unvalidated).
104
+
105
+ ### Generated files come back in `files[]`
106
+
107
+ Any step in the run whose tool output carries a `file_id` (most commonly the
108
+ `generate_*_from_template` document tools) is collected automatically into
109
+ `result.files[]` — each an `UploadedFile`
110
+ `{ id, filename, mime_type, url?, thumbnail_url? }` with a presigned `url` (24-hour TTL) the
111
+ app can open directly:
112
+
113
+ ```tsx
114
+ const gen = useWorkflow("generateInvoice");
115
+ const { status, files } = await gen({ order_id });
116
+ const url = files?.[0]?.url;
117
+ if (status === "success" && url) await openExternal(url);
118
+ ```
119
+
120
+ Files travel **only** via `files[]` — never hand a `file_id`/`url` back through
121
+ `return({ data })`, and never `update_records` a file onto a record solely to make it
122
+ downloadable. A download-only workflow generates and returns; attaching to a record is a
123
+ separate, optional step. Previewing/downloading files: [files](./files.md).
124
+
125
+ ### Structured results: `return({ data })`
126
+
127
+ A workflow body ends with `return({ status, message?, data? })`. `data` is arbitrary
128
+ structured data (computed totals, row lists, status objects) the app reads back as
129
+ `result.data`:
130
+
131
+ - **Typed for free.** The alias's `outputs` schema is derived at save time from the inferred
132
+ TypeScript type of the body's `return({ data })` — the return *is* the declaration. Codegen
133
+ then types `result.data` per alias. Declare an explicit `outputs` on the alias (same
134
+ recursive vocabulary as inputs: scalars plus nested `object`/`array`, no member/file/
135
+ date_range) only to narrow beyond what's inferred; a shape the checker can't pin down
136
+ degrades to untyped `json`, never to a wrong schema.
137
+ - **Validated at run.** On a success return, the returned `data` is validated against the
138
+ schema at the app boundary — a mismatch resolves as `status: "error"` with a field-level
139
+ message, so a declared output is a real contract. An *error* return's `data` passes through
140
+ unvalidated.
141
+ - **`data` is optional even when typed.** A workflow that completes without hitting a
142
+ `return` step resolves `status: "success"` with no `data` (and no validation). If the app
143
+ depends on `data`, make every success path in the body end in `return({ data })` — and
144
+ still guard `result.data` at the call site.
145
+
146
+ ```tsx
147
+ const quote = useWorkflow("computeQuote"); // outputs: { total: number, lines: [...] }
148
+ const r = await quote({ order_id });
149
+ if (r.status === "success" && r.data) {
150
+ render(r.data.total, r.data.lines);
151
+ }
152
+ ```
153
+
154
+ ## Declaring workflow inputs
155
+
156
+ An alias's declaration is `{ workflow_id, inputs?, outputs? }`. `inputs` maps each input name
157
+ to a typed declaration; it is authored when the workflow is bound (`set_app_workflow` /
158
+ `lotics app workflow set` reads it from `package.json#lotics.workflows.<alias>`), and the
159
+ schema the body was verified against is canonical — the manifest reflection cannot silently
160
+ weaken it. It drives three things at once: compile-time typing of the `useWorkflow` payload,
161
+ compile-time typing of `trigger.app_workflow.inputs.*` inside the body, and runtime payload
162
+ validation at the execute boundary.
163
+
164
+ ### Input vocabulary
165
+
166
+ Every declaration takes optional `description` and `required` (default **true**).
167
+
168
+ | `type` | Extra declaration fields | Payload value | Codegen TS type |
169
+ |---|---|---|---|
170
+ | `text` | — | any string | `string` |
171
+ | `number` | — | a number | `number` |
172
+ | `boolean` | — | a boolean | `boolean` |
173
+ | `date` | — | `"YYYY-MM-DD"`, or a TZ-bearing ISO datetime (projected to the workspace day) | `string` |
174
+ | `datetime` | — | `"YYYY-MM-DDTHH:mm"` naive workspace wall-clock (what the platform DatePicker emits), or TZ-bearing ISO | `string` |
175
+ | `email` | — | a valid email | `string` |
176
+ | `select` | `options: [{label, value}]` (min 1), `multi?` | an option key (array when `multi`) | union of declared `value` literals; `ReadonlyArray<…>` when `multi` |
177
+ | `record_link` | `table_id` (required), `multi?` | a record id — must exist **in the declared table** | `string` / `ReadonlyArray<string>` |
178
+ | `member` | `group?`, `multi?` | a member id — with `group`, must belong to that group | `string` / `ReadonlyArray<string>` |
179
+ | `file` | `multi?` | a file id from an upload — must live in the app's workspace | `string` / `ReadonlyArray<string>` |
180
+ | `date_range` | `include_time?` | `{ start, end }` strings | `{ start: string; end: string }` |
181
+ | `object` | `fields: { name: declaration }` | a nested object (strict — undeclared keys rejected) | nested object type |
182
+ | `array` | `items: declaration` | an array of the item shape | `ReadonlyArray<…>` |
183
+ | `json` | — | anything (unvalidated escape hatch) | `unknown` |
184
+
185
+ `object`/`array` nest the full vocabulary (max depth 8), so a structured payload — an array
186
+ of line-item objects — arrives in the body as a typed `ReadonlyArray<{…}>` instead of being
187
+ smuggled through `json`. The per-leaf security bindings (record_link → table, member → group,
188
+ file → workspace) are enforced at **every nesting depth**.
189
+
190
+ ### Validation at the execute boundary
191
+
192
+ When the alias declares `inputs`, the server validates the payload before the workflow runs
193
+ (a rejection resolves as `status: "error"` with per-field messages):
194
+
195
+ - **Strict object** — a payload key not in the schema is rejected. Want forward-compatible
196
+ payloads? Declare a `json` input.
197
+ - **Missing required input** — rejected. `required` defaults to true.
198
+ - **Optional inputs and `""`** — a top-level optional input whose value is the empty string
199
+ is treated as *omitted* (HTML form controls emit `""` for "left blank"). Nested optional
200
+ object fields are plain-optional: omit the key. A required `text` input does accept `""` —
201
+ emptiness is not a type error; validate non-emptiness in the workflow body if it matters.
202
+ - **Server-side reference bindings** — every `record_link` id must reference an existing
203
+ record in its declared `table_id`; every group-scoped `member` id must belong to the
204
+ declared group; every `file` id must live in the app's workspace. These are real write-time
205
+ constraints, not picker cosmetics — a hand-crafted request can't redirect the workflow.
206
+ Rationale and the full caller-boundary model: [security](./security.md).
207
+ - **`select` accepts any well-formed option key at runtime**, not only the deploy-declared
208
+ `options`. **Limitation:** the declared options are frozen into the codegen literal union,
209
+ so an option added to the live field after deploy is valid at runtime but fails the
210
+ compile-time type. Populate pickers from `useFieldOptions` (the live option set — see
211
+ [members_and_options](./members_and_options.md)) and widen or redeploy the declaration when
212
+ the type gets in the way.
213
+
214
+ If the alias declares **no** `inputs`, the payload passes through opaquely — no validation,
215
+ no typing, no reference binding. Fine for a zero-input action; declare inputs for anything
216
+ that carries data.
217
+
218
+ ### File inputs
219
+
220
+ A `file` input is how an upload becomes data: the app uploads bytes first (`useFileUpload` /
221
+ `useAttachments` — [files](./files.md)), then passes the returned id(s) to the workflow. An
222
+ uploaded file is **inert** until a workflow attaches it to a record's `files` field.
223
+
224
+ - Single `file` input → one file id; wrap in an array (`[id]`) when the body writes it to a
225
+ `files` field (files fields are always arrays).
226
+ - `multi: true` → the body receives `ReadonlyArray<FileId>`, directly assignable to a `files`
227
+ field — the way to persist several attachments onto one record in one write.
228
+
229
+ ### Member inputs
230
+
231
+ A `member` input is for genuine member *selection* (assign an order to a teammate) — never
232
+ for actor identity, which the client can't be trusted to supply
233
+ ([security](./security.md) → `runtime.triggered_by_member_id`). Declaring one also unlocks
234
+ the roster: `useMembers()` (and `useMembers({ group })` for a declared `group`) only works
235
+ when the app declares a member-typed input somewhere — a workflow input or a query param —
236
+ see [members_and_options](./members_and_options.md).
237
+
238
+ ### What the body sees
239
+
240
+ Inside the workflow body, the payload is `trigger.app_workflow.inputs.<name>`, typed per the
241
+ declaration (optional inputs may be absent). The triggering member is
242
+ `runtime.triggered_by_member_id` (`null` for an anonymous public caller). An omitted optional
243
+ input should mean "don't write that field" — guard each write:
244
+
245
+ ```js
246
+ const i = trigger.app_workflow.inputs;
247
+ if (i.title != null) {
248
+ await update_records({ table_id: "tbl_items", record_ids: [i.record_id], set: { fld_title: i.title } });
249
+ }
250
+ ```
251
+
252
+ ## Refetch after a mutation
253
+
254
+ Query hooks cache through SWR and know nothing about your workflows — a successful mutation
255
+ does **not** invalidate any query. After a known mutation point, call the owning query hook's
256
+ `refetch()`:
257
+
258
+ ```tsx
259
+ const items = useQuery("items");
260
+ const closeItem = useWorkflow("closeItem");
261
+
262
+ const onClose = async (recordId: string) => {
263
+ const r = await closeItem({ record_id: recordId });
264
+ if (r.status === "error") { showError(r.message); return; }
265
+ items.refetch();
266
+ };
267
+ ```
268
+
269
+ `refetch` re-runs the query in the background while the current rows stay on screen (no
270
+ flash to a spinner — `loading` stays false during revalidation). `usePaginatedQuery`'s
271
+ `refetch` re-runs both the current page and the total count. Focus revalidation
272
+ (`revalidateOnFocus`, default on) eventually self-corrects stale data, but never rely on it
273
+ in place of an explicit refetch after a write the user is watching for.
274
+
275
+ ## Diff before update — send only what changed
276
+
277
+ An edit form snapshots the record's values when it loads, and on save sends **only the fields
278
+ the user actually changed** to its update workflow. Declare each updatable input
279
+ `required: false`; an omitted input means "not written" (the body guards each write as shown
280
+ above). "Changed" is decided at the edit surface that loaded the before-state — compare
281
+ against the load-time snapshot, not against a re-fetch.
282
+
283
+ Why a full-form snapshot save is a bug, not a style choice — three independent mechanisms:
284
+
285
+ 1. **Locks block touched fields regardless of value.** A locked record rejects a write the
286
+ moment *any* field is touched — there is no value-equality pass (deep equality over stored
287
+ shapes would be a silent lock bypass). A snapshot touches every field, so it turns every
288
+ save against a locked record into a total rejection even when nothing changed.
289
+ 2. **`before_update` table workflows fire on the write.** Tables can carry `before_update`
290
+ workflows (autofills, validations, side effects). They run on every write that touches the
291
+ record — a snapshot save fires them for fields the user never edited.
292
+ 3. **Snapshots clobber concurrent edits.** An update writes exactly the fields present in the
293
+ payload; unsent fields keep their current value. A diff therefore coexists with a
294
+ colleague's concurrent edit to a *different* field — a snapshot overwrites it with the
295
+ stale load-time value.
296
+
297
+ ```tsx
298
+ // load: snapshot the editable fields from the row
299
+ const initial = useMemo(() => ({
300
+ title: row.text(r.title),
301
+ status: readSelect(r.status)[0]?.key ?? "",
302
+ }), [r]);
303
+
304
+ // save: send only the diff
305
+ const changes: Record<string, unknown> = {};
306
+ if (draft.title !== initial.title) changes.title = draft.title;
307
+ if (draft.status !== initial.status) changes.status = draft.status;
308
+ if (Object.keys(changes).length === 0) return; // nothing to save
309
+ const result = await updateItem({ record_id, ...changes });
310
+ ```
311
+
312
+ ## Locked records
313
+
314
+ A record can be locked (frozen) on the platform; direct writes to it are rejected and edits
315
+ route through an approval flow.
316
+
317
+ ### Detecting the lock: `readLocked`
318
+
319
+ Row-level query results carry a `__source_locked` addressing column automatically (alongside
320
+ `__source_record_id` — no projection needed; like all source addressing it survives
321
+ project/filter/sort/join but not grouping, where rows stop being records).
322
+ `readLocked(row)` — pass the **whole row**, not a cell — returns `true` for a locked record,
323
+ `false` when the flag is absent (`dist/src/row.d.ts`). Use it to render a locked state and to
324
+ switch the save path.
325
+
326
+ ### What a write against a locked record does
327
+
328
+ **Warning — the skip is silent at the workflow level.** When a workflow's `update_records`
329
+ hits a locked record, the record's write is *skipped*, not failed: the tool returns the
330
+ skipped records in its `blocked` output and the run otherwise proceeds — so the workflow
331
+ resolves `status: "success"` while nothing was written, unless the body checks:
332
+
333
+ ```js
334
+ const res = await update_records({ table_id: "tbl_orders", record_ids: [i.record_id], set: { fld_status: [i.status] } });
335
+ if (res.blocked.length > 0) {
336
+ return({ status: "error", message: "This record is locked — submit a change request instead." });
337
+ }
338
+ return({ status: "success", message: "Updated." });
339
+ ```
340
+
341
+ `update_records` has no bypass flag — a locked record's values change only through the
342
+ approval flow below (or an admin explicitly unlocking the record first; `unlock_records` is
343
+ admin-gated, so don't build an unlock→edit→relock workflow around the approval flow — the
344
+ lock exists to route changes through review).
345
+
346
+ ### The change-request path: `request_locked_record_change`
347
+
348
+ For a locked record, the save becomes one approval request carrying the **diffed** change set
349
+ plus a reason. The workflow body calls the `request_locked_record_change` tool:
350
+
351
+ ```js
352
+ // alias e.g. "requestOrderChange" — inputs: record_id (record_link), changes (json), reason (text)
353
+ const i = trigger.app_workflow.inputs;
354
+ const req = await request_locked_record_change({
355
+ table_id: "tbl_orders",
356
+ record_id: i.record_id,
357
+ changes: i.changes, // { fld_key: new value } — the app-side diff
358
+ reason: i.reason,
359
+ });
360
+ return({ status: "success", message: "Change request submitted for approval.", data: { request_id: req.approval_request_id } });
361
+ ```
362
+
363
+ Contract points:
364
+
365
+ - **One request per record, covering the whole diff.** The `changes` map (`field_key` → new
366
+ value) is reviewed and applied **atomically** — the approver accepts or rejects the set as
367
+ a whole. Include every changed field in the same call; don't submit per-field requests.
368
+ - **Nothing changes immediately.** The tool returns
369
+ `{ approval_request_id, status: "pending" }`; the record's values update only when an
370
+ approver (the table's configured approvers, else org admins) accepts. A `refetch()` right
371
+ after submitting shows the *old* values — render a "pending approval" state from the
372
+ result, don't poll for the new values.
373
+ - **Requires a member actor.** The request is attributed to the triggering member; an
374
+ anonymous caller through a public app is rejected. Gate the affordance on a signed-in
375
+ viewer.
376
+
377
+ App-side, the save handler branches on the lock:
378
+
379
+ ```tsx
380
+ const save = async () => {
381
+ const changes = diff(initial, draft); // field_key → new value, changed only
382
+ if (readLocked(r)) {
383
+ const res = await requestOrderChange({ record_id, changes, reason });
384
+ if (res.status === "success") showPending();
385
+ } else {
386
+ const res = await updateOrder({ record_id, ...changes });
387
+ if (res.status === "success") orders.refetch();
388
+ }
389
+ };
390
+ ```
391
+
392
+ ## Optimistic reconciliation: `useOptimistic`
393
+
394
+ For interactive data-bound views (calendar drag, gantt resize, kanban move) where waiting for
395
+ the round-trip feels broken, `useOptimistic` overlays pending patches on the query result
396
+ (`dist/src/use_optimistic.d.ts`):
397
+
398
+ ```ts
399
+ const { items, patch } = useOptimistic(base, keyOf);
400
+ patch(id, next, persist, opts?);
401
+ ```
402
+
403
+ - `base` is the already-mapped item list (derived from `useQuery` rows); `keyOf` extracts each
404
+ item's stable key. `items` is `base` with pending patches merged per key (`{ ...item,
405
+ ...patch }`); repeated patches on the same key merge.
406
+ - `patch(id, next, persist, { onSettled })` applies `next` immediately, then runs the
407
+ `persist` thunk. On **resolve**, the patch is *kept* (it should match the refetched value —
408
+ no flicker) and `onSettled` runs — pass the owning query's `refetch`. On **reject**, the
409
+ patch is *reverted*.
410
+
411
+ **Warning — the persist thunk must throw on `status: "error"`.** `useWorkflow` resolves on
412
+ failure (the failure model above), and a resolved promise means "kept" to `useOptimistic` —
413
+ so a bare `() => reschedule({...})` thunk keeps the optimistic value on screen even when the
414
+ workflow failed. Convert the status into a rejection:
415
+
416
+ ```tsx
417
+ const q = useQuery("events");
418
+ const reschedule = useWorkflow("rescheduleEvent");
419
+ const { items, patch } = useOptimistic(mapped, (e) => e.id);
420
+
421
+ const onEventDrop = (ev: CalendarEvent, newStart: Date) =>
422
+ patch(
423
+ ev.id,
424
+ { start: newStart },
425
+ async () => {
426
+ // toISODate: the `@lotics/ui` local-date formatter → "YYYY-MM-DD"
427
+ const r = await reschedule({ record_id: ev.recordId, new_date: toISODate(newStart) });
428
+ if (r.status === "error") throw new Error(r.message ?? "Reschedule failed");
429
+ },
430
+ { onSettled: q.refetch },
431
+ );
432
+ ```
433
+
434
+ This is the full read → mutate → reconcile loop: `useQuery` reads, `row.*` coerces,
435
+ `useWorkflow` mutates, `useOptimistic` bridges the gap until `refetch` converges. Declare
436
+ one workflow per (table, field) you mutate — typed and narrow, never a generic
437
+ `setField(any_field)` that hands the client write access to every field
438
+ ([security](./security.md)).