@lotics/app-sdk 0.87.0 → 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.
- package/docs/data_fetching.md +17 -0
- package/docs/workflows.md +80 -2
- package/package.json +1 -1
package/docs/data_fetching.md
CHANGED
|
@@ -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
|
@@ -73,8 +73,8 @@ compile-time "cannot find name", not a runtime `undefined`.
|
|
|
73
73
|
| `record` | button + table-lifecycle workflows | the record the trigger fired on (merged data on update) |
|
|
74
74
|
| `prev_record` | table `*_update` / `*_delete` | the prior record state — same shape as `record`, link descent included |
|
|
75
75
|
| `changes` | table `*_update` | per-field diff — a *partial* map, so read it `changes["fld_x"]?.next_value` / `?.prev_value` |
|
|
76
|
-
| `index` | inside a `for-of` body | the current 0-based iteration index |
|
|
77
|
-
| `<bind_name>` | inside a `for-of` body | the current item |
|
|
76
|
+
| `index` | inside a `for-of` body | the current 0-based iteration index — always the **innermost** loop's. To use an outer loop's index in a nested body, bind it in the outer one (`const outerIdx = index;`) and read that |
|
|
77
|
+
| `<bind_name>` | inside a `for-of` body | the current item. Each loop keeps its own, so a nested body reads the outer loop's item by its own bind name |
|
|
78
78
|
| `<step_id>` | after the step | that step's output (`rows.records`, `approval.status`, …) |
|
|
79
79
|
| `<let_name>` | its block | a mutable `let` binding |
|
|
80
80
|
| `<param>` | inside a helper callback | that lambda's parameter |
|
|
@@ -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.
|