@lotics/app-sdk 0.46.0 → 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.
- package/AGENTS.md +49 -397
- package/dist/src/rpc.js +32 -5
- package/docs/ai.md +201 -0
- package/docs/data_fetching.md +349 -0
- package/docs/files.md +312 -0
- package/docs/members_and_options.md +307 -0
- package/docs/mutations.md +438 -0
- package/docs/navigation_and_state.md +289 -0
- package/docs/queries.md +909 -0
- package/docs/runtime.md +432 -0
- package/docs/security.md +116 -0
- package/package.json +3 -2
|
@@ -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)).
|