@lotics/app-sdk 0.64.1 → 0.66.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 CHANGED
@@ -33,7 +33,9 @@ signature; open the file.**
33
33
  A client-supplied member id is an IDOR. → [security](./docs/security.md)
34
34
  - **Server data is never copied into `useState`** — hooks are the source of truth; derive with
35
35
  `useMemo`. → [data_fetching](./docs/data_fetching.md)
36
- - **Update writes are diffs** — send only changed fields. [mutations](./docs/mutations.md)
36
+ - **Update writes are diffs** — send only changed fields, and *cleared* is a change: an optional
37
+ input clears with `null` (never `""`), which `set_skip_null` drops and `set` performs.
38
+ → [mutations](./docs/mutations.md)
37
39
  - **`project` only what you render; filter server-side.** A bare `from_table` over-ships every
38
40
  column, including files. → [queries](./docs/queries.md)
39
41
  - **Never hand-roll the serialization contract** — decode cells with the typed readers.
package/docs/mutations.md CHANGED
@@ -202,10 +202,17 @@ When the alias declares `inputs`, the server validates the payload before the wo
202
202
  - **Strict object** — a payload key not in the schema is rejected. Want forward-compatible
203
203
  payloads? Declare a `json` input.
204
204
  - **Missing required input** — rejected. `required` defaults to true.
205
- - **Optional inputs and `""`**a top-level optional input whose value is the empty string
206
- is treated as *omitted* (HTML form controls emit `""` for "left blank"). Nested optional
207
- object fields are plain-optional: omit the key. A required `text` input does accept `""`
208
- emptiness is not a type error; validate non-emptiness in the workflow body if it matters.
205
+ - **Optional inputs carry THREE caller states** omitted means *leave unchanged*, a value means
206
+ *write it*, and **`null` means CLEAR**. The generated type says so: a top-level optional input
207
+ is `T | null`. Sending `null` is how an inline editor blanks a phone number or unassigns an
208
+ owner; the body forwards it to `update_records.set`, where `null` clears.
209
+ - **`""` is NOT a clear** — a top-level optional input whose value is the empty string is treated
210
+ as *omitted* (HTML form controls emit `""` for "left blank", never for "blank this out"), so the
211
+ write reports success and the old value stays. Reaching for `""` to clear is the natural first
212
+ guess and it fails silently; send `null`. Nested optional object fields are plain-optional and
213
+ **reject `null`** — only a top-level input clears; omit the key. A required `text` input does
214
+ accept `""` — emptiness is not a type error; validate non-emptiness in the workflow body if it
215
+ matters.
209
216
  - **Required reference inputs reject empty** — a required `record_link` / `member` / `file`
210
217
  must resolve to at least one real id: the single form rejects `""`, and the multi form
211
218
  rejects `[]` (an empty array is the multi-analog of an empty value — it would otherwise slip
@@ -261,14 +268,18 @@ see [members_and_options](./members_and_options.md).
261
268
 
262
269
  ### What the body sees
263
270
 
264
- Inside the workflow body, the payload is `trigger.app_workflow.inputs.<name>`, typed per the
265
- declaration (optional inputs may be absent). The triggering member is
266
- `runtime.triggered_by_member_id` (`null` for an anonymous public caller). An omitted optional
267
- input should mean "don't write that field" — guard each write:
271
+ Inside the workflow body, the payload is `trigger.app_workflow.inputs.<name>`, typed
272
+ `T | null | undefined` for an optional input. The triggering member is
273
+ `runtime.triggered_by_member_id` (`null` for an anonymous public caller).
274
+
275
+ **Guard on `undefined`, not on nullish.** The three caller states arrive intact, and the loose
276
+ `!= null` collapses two of them — it skips a clear as well as an omission, which is how a Clear
277
+ button ends up doing nothing:
268
278
 
269
279
  ```js
270
280
  const i = trigger.app_workflow.inputs;
271
- if (i.title != null) {
281
+ if (i.title !== undefined) {
282
+ // i.title may be null here — that is the CLEAR, and `set` writes it as one.
272
283
  await update_records({ table_id: "tbl_items", record_ids: [i.record_id], set: { fld_title: i.title } });
273
284
  }
274
285
  ```
@@ -277,7 +288,9 @@ When several optional inputs each map to a field, per-field guards become noise.
277
288
  bag to `update_records`' **`set_skip_null`** instead — same object shape as `set`, but entries
278
289
  whose value is `null`/`undefined` are dropped, so only the fields actually provided get written
279
290
  (untouched fields keep their lock / `before_update` / concurrent-edit safety — the diff-write
280
- discipline below, done in the body):
291
+ discipline below, done in the body). **It drops `null` as well as `undefined`, so it cannot
292
+ CLEAR** — a field the screen must be able to blank belongs in `set` with the guard above, whatever
293
+ the rest of the bag does:
281
294
 
282
295
  ```js
283
296
  const i = trigger.app_workflow.inputs;
package/docs/workflows.md CHANGED
@@ -492,6 +492,31 @@ all-dropped `set_skip_null` with no other surface is a no-op (no records touched
492
492
  `before_update` hooks). Why diffs and not snapshots, plus locked records and the
493
493
  `request_locked_record_change` path: [mutations](./mutations.md#diff-before-update--send-only-what-changed).
494
494
 
495
+ **A `null` optional input is a CLEAR, and `set_skip_null` drops it.** The caller has three states
496
+ — omitted (leave unchanged), a value (write it), `null` (clear) — and they arrive in the body
497
+ intact as `T | null | undefined`; the caller side is
498
+ [mutations](./mutations.md#validation-at-the-execute-boundary). Which of them actually reaches the
499
+ record is the body's decision, and the two write surfaces answer differently:
500
+
501
+ | Body writes | a `null` input |
502
+ |---|---|
503
+ | `set_skip_null: { fld_x: i.x }` | **dropped — the clear does not happen** |
504
+ | `set: { fld_x: i.x }` | **clears the field** |
505
+
506
+ So `set_skip_null` is the right default for a bag of optional inputs, and the wrong choice for any
507
+ field the screen must be able to blank — put those in `set`, and guard on `undefined` so an input
508
+ the caller never sent is not written:
509
+
510
+ ```js
511
+ if (i.phone !== undefined) { // NOT `!= null` — that swallows the clear
512
+ await update_records({ table_id: "tbl_x", record_ids: [i.id], set: { fld_phone: i.phone } });
513
+ }
514
+ ```
515
+
516
+ `""` is never a clear on any surface: an HTML input emits it for "the user skipped this", so the
517
+ validator coerces it to omitted before the body runs. Reaching for `""` to blank a field fails
518
+ silently — the write reports success and the old value stays. Send `null`.
519
+
495
520
  Value shapes, which the generated types enforce exactly:
496
521
 
497
522
  | Field | Read | Write |
@@ -641,10 +666,18 @@ rejects, or read a path that is null on real data. The rehearsal for that is `dr
641
666
  hands back `planned_calls` (every tool call in order, with its fully-resolved input),
642
667
  `return_value`, `validation_failures`, `evaluation_errors`, and `tool_input_errors` — inputs the
643
668
  target tool would reject, which can only surface once the values are computed. **Write tools are
644
- recorded, never dispatched**, and nothing is persisted. For a table-triggered body, pass
645
- `record_id` + `table_id` instead of `trigger_payload` and the read-only tools (`query_records`,
646
- `get_record`, `aggregate_records`) dispatch for real, so data-dependent gates are exercised
647
- against actual rows. Do this before the first live run of anything that writes.
669
+ recorded, never dispatched**, and nothing is persisted.
670
+
671
+ **Add `live_reads: true` whenever the body READS.** By default the read-only tools
672
+ (`query_records`, `get_record`, `aggregate_records`) return stubs an empty result set, a blank
673
+ record — so any branch gated on stored data takes the empty path and the rehearsal quietly proves
674
+ nothing about the branch that matters. A duplicate check finds no duplicate; a lookup that should
675
+ skip because a field is already set doesn't skip. With `live_reads: true` those three dispatch
676
+ against the real workspace under YOUR authority, so the gates are exercised against actual rows
677
+ while writes stay recorded-only. (A table-triggered body gets this from `record_id` + `table_id`,
678
+ which supply the real record as the payload AND turn live reads on; an app workflow has no trigger
679
+ record — its payload is its inputs — so it asks for the reads directly.) Do this before the first
680
+ live run of anything that writes.
648
681
 
649
682
  ## A worked body
650
683
 
@@ -676,7 +709,7 @@ await create_records({
676
709
  fld_customer: [i.customer_id],
677
710
  fld_status: "opt_open",
678
711
  fld_opened_on: formatDate(now(), "yyyy-MM-dd"),
679
- fld_note: i.note, // omitted optional input → undefinedkey dropped
712
+ fld_note: i.note, // optional input: omitted dropped; null cleared
680
713
  }],
681
714
  });
682
715
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.64.1",
4
- "description": "Runtime SDK for Lotics custom-code apps typed hooks, postMessage bridge, mount entry point",
3
+ "version": "0.66.0",
4
+ "description": "Runtime SDK for Lotics custom-code apps \u2014 typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {