@lotics/app-sdk 0.87.1 → 0.87.4

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.
@@ -153,6 +153,23 @@ server validates system conditions by `type` and never reads `field_key` on them
153
153
  number, while `error !== null`. Note a key CHANGE resets this: a new `params`/`filter`/`sort`
154
154
  is a fresh key with no prior rows, so the "last successful rows stay rendered" behaviour above
155
155
  does not save you.
156
+ - **Never state an ABSENCE from `rows` without gating on `loading`.** The sibling of the rule
157
+ above, and it bites earlier: while the first request is in flight `rows` is `[]`, so
158
+ `rows.find(…)` returns nothing and any code shaped `if (!found) → "there is no X"` prints a
159
+ confident denial of something that is merely not here yet. It corrects itself when the data
160
+ lands, which is exactly what makes it ship: the author sees the settled screen, and only a
161
+ reader opening the surface cold sees the half-second where every requirement reads as missing.
162
+ A record drawer did this to five document rows at once — each rendering a red "missing" badge
163
+ and a blocking callout — so the loudest thing on the screen was, briefly, entirely false.
164
+ Gate the derivation, not the display: compute nothing while `loading`, and reserve the space with
165
+ a `Skeleton` so the layout does not jump when the answer arrives.
166
+
167
+ Watch for the second source of a legitimate empty: `enabled: false` never sends a request, so
168
+ `rows` is `[]` and `loading` is `false` **forever**. A detail query gated on a parent selection
169
+ (`{ enabled: vehicleId != null }`) therefore satisfies "not loading, no rows" while the real
170
+ fact is that nothing was asked. Read the gate itself — `loading || vehicleId == null` — and say
171
+ what is actually missing, which is the parent, not the children.
172
+
156
173
  - **`refetch()`** re-runs the query. A successful `useWorkflow` call already re-reads the mounted
157
174
  queries on its own (see [./mutations.md](./mutations.md)), so reach for this only where a write
158
175
  cannot have told you: a poll, a value that changes without anything on this screen writing, or a
package/docs/mutations.md CHANGED
@@ -321,7 +321,8 @@ await update_records({
321
321
 
322
322
  `set` still writes every key it carries — `null` in `set` **clears** the field; `null`/absent in
323
323
  `set_skip_null` **skips** it. The full write surface (`set`, `set_skip_null`, the surgical
324
- `add_to`/`remove_from`/`replace` ops, `field_edits`, and the exact value shape per field type)
324
+ `add_to`/`remove_from`/`replace` ops, `increment`, `field_edits`, and the exact value shape per
325
+ field type)
325
326
  is [workflows](./workflows.md#writing-records).
326
327
 
327
328
  ## Refetch after a mutation
@@ -446,7 +447,9 @@ Why a full-form snapshot save is a bug, not a style choice — three independent
446
447
  append is the system's rather than a person's — an upload settling, a workflow attaching a
447
448
  generated document — name the ITEMS with `add_to` / `remove_from`
448
449
  ([workflows](./workflows.md#writing-records)) and the write path resolves them against the
449
- record as it stands.
450
+ record as it stands. A running total is the scalar form of the same defect: two writers both
451
+ read 100, both subtract 10, both write 90, and one sale is gone with no error anywhere. Name
452
+ the DELTA with `increment`.
450
453
 
451
454
  ```tsx
452
455
  // load: snapshot the editable fields from the row
package/docs/workflows.md CHANGED
@@ -482,31 +482,38 @@ differ from `<` / `>` (code-unit order) and from the database's collation.
482
482
 
483
483
  ## Writing records
484
484
 
485
- `update_records` carries four composable surfaces. A field may appear in **at most one** of them.
485
+ `update_records` carries five composable surfaces. A field may appear in **at most one** of them.
486
486
 
487
487
  | Surface | Shape | Effect |
488
488
  |---|---|---|
489
489
  | `set` | `{ fld_x: value }` | write the whole value. `null` **clears** the field |
490
490
  | `set_skip_null` | `{ fld_x: value }` | same shape, but `null`/`undefined` entries are **dropped** — absent means "leave unchanged" |
491
491
  | `add_to` / `remove_from` / `replace` | `{ fld_x: [items] }` | surgical edits on multi-value fields (files, multi select, multi member, record links) |
492
- | `field_edits` | `[{ field, op, value }]` | the same surgical ops with the field named by a **string expression** the only way to target a field chosen at run time |
493
-
494
- `add_to` / `remove_from` are RELATIVE: the server resolves them against the record as it stands
495
- when the write lands, inside the lock it already takes. Two runs appending to one field at the
496
- same time therefore each keep their item which reading the field and writing the whole array
497
- back through `set` does not, since both fold onto the array they read and the later write drops
498
- the earlier, both reporting success. Parallel runs are ordinary (a trigger firing twice, an agent
499
- emitting two calls in one step), so reach for the ops rather than a read-modify-write whenever
500
- the workflow is ADDING to a field rather than stating it.
492
+ | `increment` | `{ fld_x: delta }` | move a **number** field by a signed amount. Negative decrements; there is no separate decrement op |
493
+ | `field_edits` | `[{ field, op, value }]` | the multi-value ops with the field named by a **string expression** — the only way to target a field chosen at run time. `increment` has no value-form: its payload is a scalar, not an item list |
494
+
495
+ `add_to`, `remove_from` and `increment` are RELATIVE: the server resolves them against the record
496
+ as it stands when the write lands, inside the lock it already takes. Two runs changing one field at
497
+ the same time therefore each keep their effect which reading the field and writing the result back
498
+ through `set` does not, since both fold onto the value they read and the later write drops the
499
+ earlier, both reporting success. On a stock count that is a sale that vanishes. Parallel runs are
500
+ ordinary (a trigger firing twice, an agent emitting two calls in one step), so reach for the ops
501
+ rather than a read-modify-write whenever the workflow is CHANGING a field by an amount rather than
502
+ stating its value.
503
+
504
+ `increment` counts an empty or unset field as 0, so a first decrement leaves a negative — oversold,
505
+ and visibly so. It is rejected on formula, rollup, lookup and autonumber fields: those come from
506
+ their own definition, so change what they aggregate instead.
501
507
 
502
508
  Inside `set` and `create_records.records`: `null` clears (persisted), `undefined` or an omitted
503
509
  key preserves. So passing a possibly-null read straight through is safe. Use
504
510
  `coalesce(x, fallback)` only when you want a real fallback, never to "strip" null.
505
511
 
506
512
  `set_skip_null` is what turns a bag of optional workflow inputs into a diff-write without a guard
507
- per field; naming the same field in both `set` and `set_skip_null` is an error, and an
508
- all-dropped `set_skip_null` with no other surface is a no-op (no records touched, no
509
- `before_update` hooks). Why diffs and not snapshots, plus locked records and the
513
+ per field; naming the same field in both `set` and `set_skip_null` is an error. A call whose
514
+ surfaces all resolve to nothing — an all-dropped `set_skip_null`, an op object with no entries
515
+ is a no-op: no records touched, no `before_update` hooks. So a conditionally-built op object needs
516
+ no guard around the call. Why diffs and not snapshots, plus locked records and the
510
517
  `request_locked_record_change` path: [mutations](./mutations.md#diff-before-update--send-only-what-changed).
511
518
 
512
519
  **A `null` optional input is a CLEAR, and `set_skip_null` drops it.** The caller has three states
@@ -602,6 +609,84 @@ if (!current_member_in_any_group(["grp_managers"])) {
602
609
 
603
610
  Full model, including what a public app must never expose: [security](./security.md).
604
611
 
612
+ ## Every `await` is a round trip
613
+
614
+ A body runs **strictly sequentially**. There is no `Promise.all`, and independent reads do not
615
+ overlap — three `get_record` calls that need nothing from each other still cost three trips, in
616
+ order. So the thing to count when a workflow feels slow is not how much data moves but **how many
617
+ tool calls it makes**, and the two cheapest wins are always the same: don't fetch what you already
618
+ have, and don't write one row at a time.
619
+
620
+ **A write costs more than a read.** A create or update drags its computed-field cascades, rollups
621
+ and `after_*` hooks behind it before the call returns, so trimming reads is worth less than
622
+ trimming writes. Reach for the write-side reductions first.
623
+
624
+ ### Batch every write — and build the array INLINE
625
+
626
+ `create_records` takes an ARRAY. N rows written one call at a time is N round trips plus N cascade
627
+ passes; the same N rows in one call is one of each.
628
+
629
+ The catch is where you build that array, and it is not obvious. **Write the literal inside the
630
+ call**, so it is typed by the `records` parameter:
631
+
632
+ ```js
633
+ // ONE trip. The array literal is contextually typed by `records`, so "opt_bVPEMB"
634
+ // stays the literal the table's write type wants.
635
+ await create_records({
636
+ records: concat(
637
+ coRaVao ? [{ fld_khoan: "opt_bVPEMB", fld_tien: giaRaVao, fld_chuyen: [i.chuyen_id] }] : [],
638
+ coVeSinh ? [{ fld_khoan: "opt_nx0KAL", fld_tien: giaVeSinh, fld_chuyen: [i.chuyen_id] }] : [],
639
+ ),
640
+ table_id: "tbl_…",
641
+ });
642
+ ```
643
+
644
+ Accumulating into a variable first does **not** work, and the two errors it produces point away
645
+ from the fix:
646
+
647
+ ```js
648
+ let rows = [];
649
+ for (const g of gia.records) {
650
+ rows = concat(rows, [{ fld_khoan: "opt_bVPEMB", … }]); // widens to `string`
651
+ }
652
+ await create_records({ records: rows, … });
653
+ // TS2322: Type '{ fld_khoan: string; … }[]' is not assignable to 'readonly …RecordWrite[]'.
654
+ // Type 'string' is not assignable to '"opt_bVPEMB" | "opt_nx0KAL" | … | null'.
655
+ ```
656
+
657
+ `concat` widens a select's option key to `string` the moment it leaves the literal, and the obvious
658
+ repair — `let rows: SomeRecordWrite[] = []` — is rejected by the next pass, because the stored body
659
+ is parsed as a JS subset and type syntax is not in it. The way through is not an annotation: it is
660
+ to let the call site supply the type. Keep the loop for FINDING values and put the writing after it.
661
+
662
+ ### Don't buy the same row twice
663
+
664
+ - **A record you already read is already in hand.** The authorization preamble usually reads the
665
+ triggering record; a later `get_record` on the same id is a second trip for a value sitting in a
666
+ variable.
667
+ - **Don't read back what you just computed.** A total you derived from the rows you are writing is
668
+ cheaper to sum in the body than to re-read from a computed field afterwards.
669
+ - **`create_records` returns `{ created, record_ids }` — ids only.** When you need a
670
+ server-generated value (an auto-number), read it back with `get_record` on the returned id, never
671
+ a filtered `query_records` that scans for the row you just made: the filtered form is both an
672
+ extra scan and wrong under concurrency, since a row created between your write and your read
673
+ matches the same filter.
674
+
675
+ ### Keep expensive steps off the path the caller waits on
676
+
677
+ `generate_pdf_from_template` and `agent` are the two steps that dominate a body's wall clock. Ask
678
+ whether the person pressing the button needs that artifact **at that instant**. A document that is
679
+ printed later belongs in the workflow that prints it — moving it there also removes the reads that
680
+ existed only to feed it, which is usually where the round trips were hiding.
681
+
682
+ ### Measuring, if you do
683
+
684
+ `query_workflow_executions` records `started_at` / `completed_at` per run, which is the server's own
685
+ duration with your network and CLI startup excluded. **Run-to-run variance is large** — the same
686
+ body measured twice, minutes apart, can differ by a second — so compare medians of several runs,
687
+ and interleave the two versions rather than measuring one after the other. A single before/after
688
+ pair will happily show an improvement that is only drift.
689
+
605
690
  ## Traps
606
691
 
607
692
  The rules that are easy to get wrong because the failing code looks correct.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.87.1",
3
+ "version": "0.87.4",
4
4
  "description": "Runtime SDK for Lotics custom-code apps \u2014 typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {