@lotics/app-sdk 0.87.1 → 0.87.3

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/workflows.md CHANGED
@@ -602,6 +602,84 @@ if (!current_member_in_any_group(["grp_managers"])) {
602
602
 
603
603
  Full model, including what a public app must never expose: [security](./security.md).
604
604
 
605
+ ## Every `await` is a round trip
606
+
607
+ A body runs **strictly sequentially**. There is no `Promise.all`, and independent reads do not
608
+ overlap — three `get_record` calls that need nothing from each other still cost three trips, in
609
+ order. So the thing to count when a workflow feels slow is not how much data moves but **how many
610
+ tool calls it makes**, and the two cheapest wins are always the same: don't fetch what you already
611
+ have, and don't write one row at a time.
612
+
613
+ **A write costs more than a read.** A create or update drags its computed-field cascades, rollups
614
+ and `after_*` hooks behind it before the call returns, so trimming reads is worth less than
615
+ trimming writes. Reach for the write-side reductions first.
616
+
617
+ ### Batch every write — and build the array INLINE
618
+
619
+ `create_records` takes an ARRAY. N rows written one call at a time is N round trips plus N cascade
620
+ passes; the same N rows in one call is one of each.
621
+
622
+ The catch is where you build that array, and it is not obvious. **Write the literal inside the
623
+ call**, so it is typed by the `records` parameter:
624
+
625
+ ```js
626
+ // ONE trip. The array literal is contextually typed by `records`, so "opt_bVPEMB"
627
+ // stays the literal the table's write type wants.
628
+ await create_records({
629
+ records: concat(
630
+ coRaVao ? [{ fld_khoan: "opt_bVPEMB", fld_tien: giaRaVao, fld_chuyen: [i.chuyen_id] }] : [],
631
+ coVeSinh ? [{ fld_khoan: "opt_nx0KAL", fld_tien: giaVeSinh, fld_chuyen: [i.chuyen_id] }] : [],
632
+ ),
633
+ table_id: "tbl_…",
634
+ });
635
+ ```
636
+
637
+ Accumulating into a variable first does **not** work, and the two errors it produces point away
638
+ from the fix:
639
+
640
+ ```js
641
+ let rows = [];
642
+ for (const g of gia.records) {
643
+ rows = concat(rows, [{ fld_khoan: "opt_bVPEMB", … }]); // widens to `string`
644
+ }
645
+ await create_records({ records: rows, … });
646
+ // TS2322: Type '{ fld_khoan: string; … }[]' is not assignable to 'readonly …RecordWrite[]'.
647
+ // Type 'string' is not assignable to '"opt_bVPEMB" | "opt_nx0KAL" | … | null'.
648
+ ```
649
+
650
+ `concat` widens a select's option key to `string` the moment it leaves the literal, and the obvious
651
+ repair — `let rows: SomeRecordWrite[] = []` — is rejected by the next pass, because the stored body
652
+ is parsed as a JS subset and type syntax is not in it. The way through is not an annotation: it is
653
+ to let the call site supply the type. Keep the loop for FINDING values and put the writing after it.
654
+
655
+ ### Don't buy the same row twice
656
+
657
+ - **A record you already read is already in hand.** The authorization preamble usually reads the
658
+ triggering record; a later `get_record` on the same id is a second trip for a value sitting in a
659
+ variable.
660
+ - **Don't read back what you just computed.** A total you derived from the rows you are writing is
661
+ cheaper to sum in the body than to re-read from a computed field afterwards.
662
+ - **`create_records` returns `{ created, record_ids }` — ids only.** When you need a
663
+ server-generated value (an auto-number), read it back with `get_record` on the returned id, never
664
+ a filtered `query_records` that scans for the row you just made: the filtered form is both an
665
+ extra scan and wrong under concurrency, since a row created between your write and your read
666
+ matches the same filter.
667
+
668
+ ### Keep expensive steps off the path the caller waits on
669
+
670
+ `generate_pdf_from_template` and `agent` are the two steps that dominate a body's wall clock. Ask
671
+ whether the person pressing the button needs that artifact **at that instant**. A document that is
672
+ printed later belongs in the workflow that prints it — moving it there also removes the reads that
673
+ existed only to feed it, which is usually where the round trips were hiding.
674
+
675
+ ### Measuring, if you do
676
+
677
+ `query_workflow_executions` records `started_at` / `completed_at` per run, which is the server's own
678
+ duration with your network and CLI startup excluded. **Run-to-run variance is large** — the same
679
+ body measured twice, minutes apart, can differ by a second — so compare medians of several runs,
680
+ and interleave the two versions rather than measuring one after the other. A single before/after
681
+ pair will happily show an improvement that is only drift.
682
+
605
683
  ## Traps
606
684
 
607
685
  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.3",
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": {