@lotics/app-sdk 0.64.0 → 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 +3 -1
- package/docs/mutations.md +23 -10
- package/docs/queries.md +18 -7
- package/docs/workflows.md +38 -5
- package/package.json +2 -2
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
|
|
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
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
265
|
-
|
|
266
|
-
`runtime.triggered_by_member_id` (`null` for an anonymous public caller).
|
|
267
|
-
|
|
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
|
|
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/queries.md
CHANGED
|
@@ -431,7 +431,7 @@ derived surfaces share one implementation.
|
|
|
431
431
|
|
|
432
432
|
| Field type | Column type | Source-layer operators | Derived-layer differences | Sort / group / aggregate notes |
|
|
433
433
|
| --- | --- | --- | --- | --- |
|
|
434
|
-
| text | `text` | `equals`, `not_equals`, `contains`, `does_not_contain`, `starts_with`, `ends_with`, `is_empty`, `is_not_empty`, `is_any_of`, `is_none_of` | same set | Sort is lexicographic in the database's default collation — no locale control. Text comparisons normalize both sides (trim + case-insensitive). |
|
|
434
|
+
| text | `text` | `equals`, `not_equals`, `contains`, `does_not_contain`, `starts_with`, `ends_with`, `is_empty`, `is_not_empty`, `is_any_of`, `is_none_of` | same set | Sort is lexicographic in the database's default collation — no locale control. Text comparisons normalize both sides (trim + case-insensitive). The LIKE family (`contains`, `does_not_contain`, `starts_with`, `ends_with`) additionally folds **diacritics**, so `contains "ha noi"` matches `"Hà Nội"`; the identity operators (`equals`, `not_equals`, `is_any_of`, `is_none_of`) do **not** — they compare a stored value, and `mã` is not `ma`. |
|
|
435
435
|
| number | `number` | `equals`, `not_equals`, `greater_than`, `less_than`, `greater_than_or_equal_to`, `less_than_or_equal_to`, `is_empty`, `is_not_empty` | same set | Full numeric aggregate set (§8). |
|
|
436
436
|
| date / datetime | `date` / `datetime` (datetime when the field's format includes time) | `on`, `before`, `after`, `on_or_before`, `on_or_after`, `between`, `time_of_day`, `is_empty`, `is_not_empty` — values are `DateTimePoint`s (below) | same set | Day-level filters on datetime values expand to the full-day window. Sortable; bucketable in `group.by` (§8); `earliest`/`latest`/`min`/`max`/`date_range` aggregate. |
|
|
437
437
|
| boolean | `boolean` | `equals` (value `true`/`false`; `false` matches NULL/missing) | same | `checked`/`unchecked`/`percent_*` aggregate (`unchecked` counts false **or** empty). |
|
|
@@ -596,11 +596,20 @@ It AND-s with `filter` (search *within* a scope) and is templatable
|
|
|
596
596
|
(`"search": "{{params.q}}"`). An empty or whitespace-only term matches everything; an
|
|
597
597
|
unresolved optional token prunes to match-all (§6).
|
|
598
598
|
|
|
599
|
-
**Search-as-you-type uses `search`, never a `contains` OR-group.** Per-field
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
599
|
+
**Search-as-you-type over a LARGE table uses `search`, never a `contains` OR-group.** Per-field
|
|
600
|
+
`contains` is unindexed — a zero-match keystroke forces a full-partition scan that hangs the
|
|
601
|
+
picker. (It is not accent-sensitive; the LIKE family folds diacritics, §5.) Gate the fetch on a
|
|
602
|
+
non-empty term client-side (`enabled`), or first paint dumps the table.
|
|
603
|
+
|
|
604
|
+
**But `search` matches the WHOLE record, and on an identity box that is usually wrong.** The
|
|
605
|
+
search document indexes select option labels, member names, dates in three formats, and each
|
|
606
|
+
linked record's cached display text — including the `Field=True/False` pill a boolean renders
|
|
607
|
+
into. A box captioned "find a person" then matches rows that merely share a stage label or an
|
|
608
|
+
owner, and folding makes near-homographs collide (`tiến` ≡ `tiền`). Measured on a real 88-row
|
|
609
|
+
customer book, one such term returned **every row**. When the box means *find this person*, bound
|
|
610
|
+
it: an OR-group of `contains` over the two or three identity fields (name, national id, phone),
|
|
611
|
+
with the term param `required: false` so an empty box prunes the group to match-all. Use `search`
|
|
612
|
+
when the box genuinely means *find this anywhere in the record*.
|
|
604
613
|
|
|
605
614
|
---
|
|
606
615
|
|
|
@@ -872,7 +881,9 @@ and is not — and a playbook rule applied to the wrong query costs effort while
|
|
|
872
881
|
### The authoring rules
|
|
873
882
|
|
|
874
883
|
1. **Filter at the source.** Push every static predicate into `from_table.filter`.
|
|
875
|
-
2. **
|
|
884
|
+
2. **Match the operator to what the box MEANS.** *Find this anywhere in the record* → `search`.
|
|
885
|
+
*Find this person/order* → a `contains` OR-group over the identity fields (§7) — the
|
|
886
|
+
whole-record index will surface rows that only share an owner or a status.
|
|
876
887
|
3. **Aggregate and bucket server-side.** A dashboard reads grouped rows, never raw rows it
|
|
877
888
|
reduces in JS — raw-row shipping burns the 10k cap, the timeout, and bandwidth at once.
|
|
878
889
|
4. **Project narrow.** Every un-rendered column is wasted bytes; every un-rendered `files`
|
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.
|
|
645
|
-
|
|
646
|
-
`
|
|
647
|
-
|
|
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, //
|
|
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.
|
|
4
|
-
"description": "Runtime SDK for Lotics custom-code apps
|
|
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
|
".": {
|