@lotics/app-sdk 0.86.0 → 0.87.1

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.
@@ -56,7 +56,7 @@ export interface NewRecordApi<P> {
56
56
  * const { id, save } = useNewRecord({
57
57
  * create: (id, patch) => createCustomer({ record_id: id, ...patch }),
58
58
  * update: (id, patch) => updateCustomer({ record_id: id, ...patch }),
59
- * onCreated: refetch,
59
+ * onCreated: (id) => select(id), // it exists now — the list re-reads itself
60
60
  * });
61
61
  * <InlineText onBlur={(name) => save({ name })} />
62
62
  * ```
@@ -64,6 +64,11 @@ export interface NewRecordApi<P> {
64
64
  export declare function useNewRecord<P>(opts: {
65
65
  create: (id: string, patch: P) => Promise<unknown>;
66
66
  update: (id: string, patch: P) => Promise<unknown>;
67
- /** Runs once, after the record first exists. Pass a list `refetch` to reveal it. */
67
+ /**
68
+ * Runs once, after the record first exists, with its id. NOT for refetching a
69
+ * list — the `create` workflow's own success already re-read it. This is for
70
+ * what only the id can drive: routing to the record, selecting it, dropping the
71
+ * surface's "new" state.
72
+ */
68
73
  onCreated?: (id: string) => void;
69
74
  }): NewRecordApi<P>;
@@ -56,7 +56,7 @@ export function newRecordId() {
56
56
  * const { id, save } = useNewRecord({
57
57
  * create: (id, patch) => createCustomer({ record_id: id, ...patch }),
58
58
  * update: (id, patch) => updateCustomer({ record_id: id, ...patch }),
59
- * onCreated: refetch,
59
+ * onCreated: (id) => select(id), // it exists now — the list re-reads itself
60
60
  * });
61
61
  * <InlineText onBlur={(name) => save({ name })} />
62
62
  * ```
package/dist/src/rpc.d.ts CHANGED
@@ -176,11 +176,13 @@ export declare const APP_PUBLIC_SESSION_HEADER = "x-lotics-app-session";
176
176
  /** The run token header — mirrored server-side by `APP_AGENT_RUN_TOKEN_HEADER`. */
177
177
  export declare const APP_AGENT_RUN_TOKEN_HEADER = "x-app-agent-run-token";
178
178
  /**
179
- * The error message for a non-ok response. A genuine JSON error (a 4xx carrying
180
- * a `message`) surfaces verbatim; a non-JSON body (a gateway HTML page), any
181
- * 5xx, or a JSON body without a `message` falls back to a body-free,
182
- * status-derived message so a raw HTML body never becomes the message.
183
- * `parsed` is the JSON.parse of the body, or `null` if it wasn't JSON.
179
+ * The error message for a non-ok response. A JSON error the API AUTHORED surfaces
180
+ * verbatim — a 4xx carrying a `message`, or a 5xx that also carries `code`, the
181
+ * discriminator every error the API emits is built with. A non-JSON body (a
182
+ * gateway HTML page), a 5xx from something that is not us, or a body without a
183
+ * `message` falls back to a body-free, status-derived message so a raw HTML
184
+ * body never becomes the message. `parsed` is the JSON.parse of the body, or
185
+ * `null` if it wasn't JSON.
184
186
  */
185
187
  export declare function transportErrorMessage(status: number, parsed: unknown): string;
186
188
  /**
package/dist/src/rpc.js CHANGED
@@ -519,19 +519,30 @@ function gatewayErrorMessage(status) {
519
519
  return "The service returned an unexpected response. Please try again.";
520
520
  }
521
521
  /**
522
- * The error message for a non-ok response. A genuine JSON error (a 4xx carrying
523
- * a `message`) surfaces verbatim; a non-JSON body (a gateway HTML page), any
524
- * 5xx, or a JSON body without a `message` falls back to a body-free,
525
- * status-derived message so a raw HTML body never becomes the message.
526
- * `parsed` is the JSON.parse of the body, or `null` if it wasn't JSON.
522
+ * The error message for a non-ok response. A JSON error the API AUTHORED surfaces
523
+ * verbatim — a 4xx carrying a `message`, or a 5xx that also carries `code`, the
524
+ * discriminator every error the API emits is built with. A non-JSON body (a
525
+ * gateway HTML page), a 5xx from something that is not us, or a body without a
526
+ * `message` falls back to a body-free, status-derived message so a raw HTML
527
+ * body never becomes the message. `parsed` is the JSON.parse of the body, or
528
+ * `null` if it wasn't JSON.
527
529
  */
528
530
  export function transportErrorMessage(status, parsed) {
529
531
  const jsonMessage = parsed && typeof parsed.message === "string"
530
532
  ? parsed.message
531
533
  : null;
532
- return parsed === null || status >= 500 || jsonMessage === null
533
- ? gatewayErrorMessage(status)
534
- : jsonMessage;
534
+ if (jsonMessage === null)
535
+ return gatewayErrorMessage(status);
536
+ // Below 500 the body is ours. At 500 and above it is ours only if it carries
537
+ // `code` — the discriminator every error the API emits is built with. Status
538
+ // alone was the wrong test: it threw away the 503 our OWN backpressure gate
539
+ // authors and replaced it with a different English sentence, so no wording we
540
+ // choose for a 5xx could ever reach an app member (GAP-300). A gateway's HTML
541
+ // does not parse at all, and a proxy's JSON does not carry `code`, so both
542
+ // still get the body-free message.
543
+ const authored = typeof parsed.code === "string" &&
544
+ parsed.code.length > 0;
545
+ return status < 500 || authored ? jsonMessage : gatewayErrorMessage(status);
535
546
  }
536
547
  /**
537
548
  * The error a stream that never started should throw.
@@ -3,9 +3,14 @@ export interface OptimisticApi<T> {
3
3
  items: T[];
4
4
  /**
5
5
  * Optimistically merge `next` into the item keyed `id`, then run `persist`.
6
- * On resolve → `onSettled?.()` (pass your query's `refetch`); the patch is
7
- * kept (it already matches the refetched value, so no flicker). On reject
8
- * the patch is reverted.
6
+ * On resolve → `onSettled?.()`; the patch is kept (it should already match
7
+ * what the write persisted, so the re-read lands underneath it without a
8
+ * flicker). On reject → the patch is reverted.
9
+ *
10
+ * `onSettled` is NOT for refetching the query this patch came from — a
11
+ * successful `useWorkflow` inside `persist` re-reads every mounted query on
12
+ * its own. Use it for what the write cannot know about: a total the app
13
+ * computed itself, an indicator to clear.
9
14
  */
10
15
  patch: (id: string, next: Partial<T>, persist: () => Promise<unknown>, opts?: {
11
16
  onSettled?: () => void;
package/docs/ai.md CHANGED
@@ -28,7 +28,7 @@ A declaration carries:
28
28
  | `effort_level` | Optional reasoning depth for adaptive-thinking tiers. Requires an explicit `model_tier` pin — effort is tuned per tier |
29
29
  | `prefix_cache_ttl` | Optional prompt-cache window for the agent's stable prefix (tools + system). **Omit it** — the default (`"5m"`, Anthropic's own) is right for essentially every agent. `"1h"` is a leveraged bet: it doubles the write price (2x the input rate against 1.25x, both reading back at 0.1x) to buy only the five-minute-to-one-hour band. Declining it is never "uncached" — the same prefix stays cached at the default window. Set `"1h"` only with measured cadence showing runs reliably land in that band, such as a scheduled sweep |
30
30
  | `inputs` | Optional typed input schema for one run — the same vocabulary as workflow inputs (`text`, `number`, `file`, `member`, `record_link`, `select`, …). The server validates every run payload against it. **Every field defaults to `required: true`**, exactly as `outputs` does — the two extend the same base — so an input the caller may legitimately omit needs `"required": false`, or the run is rejected before the agent sees it |
31
- | `outputs` | Optional typed output schema. Declared → **structured agent** (the run must emit a matching result); omitted → **free-text agent** (the answer is the final prose). **Every field defaults to `required: true`** (the same base schema as `inputs` above) — mark `"required": false` on anything the source may legitimately not carry. It matters most for `number`, which has no blank: text can answer `""`, but a required number leaves only a wrong value or a rejected submission |
31
+ | `outputs` | Optional typed output schema — the declaration vocabulary, including the two ways a `select` names its option set, is [mutations](./mutations.md#structured-results-return-data). Declared → **structured agent** (the run must emit a matching result); omitted → **free-text agent** (the answer is the final prose). **Every field defaults to `required: true`** (the same base schema as `inputs` above) — mark `"required": false` on anything the source may legitimately not carry. It matters most for `number`, which has no blank: text can answer `""`, but a required number leaves only a wrong value or a rejected submission |
32
32
 
33
33
  **Structured vs free-text is the load-bearing split.** A structured agent's result arrives in `run.output` (the server strictly validates the submitted result against the declared schema — see the `output` typing section for the exact guarantee); a free-text agent's answer is the transcript's prose (`run.text`) and its `output` stays `undefined` — never a stray string, so a consumer reading `output.<field>` can't crash on a free-text answer.
34
34
 
@@ -145,7 +145,9 @@ The server validates the agent's submitted result strictly (unknown keys rejecte
145
145
 
146
146
  - a field declared `required: false` may be absent;
147
147
  - a `json`-typed output field is passthrough — anything goes inside it;
148
- - `date` / `datetime` / `email` outputs are validated only as strings, not as well-formed values;
148
+ - a `date` / `datetime` / `email` output is checked for well-formedness (`yyyy-MM-dd`, an ISO
149
+ timestamp, an address shape) but not for being the *right* value — a parseable date can still
150
+ be the wrong date;
149
151
  - every **value** was authored by the model — a schema-valid string can still be semantically wrong;
150
152
  - one live-stream corner: the hook adopts `output` from the submit call's **arguments as they stream**, before the server-side validation runs. Normally the loud rejection makes the agent retry and the last (valid) submit overwrites it — but a run whose *final* submit was rejected ends the live stream with that invalid attempt still in `output` (the persisted run settles as an error). The review surface below is the backstop.
151
153
 
@@ -178,6 +178,15 @@ server validates system conditions by `type` and never reads `field_key` on them
178
178
  | `query execution failed` | The query failed at the database. Deliberately generic — database internals are never sent to the client. | The app author diagnoses from the platform's server logs; the app surfaces the message. |
179
179
  | A specific validation message | e.g. an un-projected `field_key` in runtime `sort`/`filter`, invalid params, an unknown alias. An unknown column names every column the query DOES project, so the valid set is in the message. | Fix the call site — these are contract violations, not transient. |
180
180
 
181
+ **Which message you get is decided by the ENVELOPE, not the status** (SDK 0.85.0 — before it, the
182
+ status alone decided). A failure whose JSON body carries a `code` is one the API authored, so its
183
+ `message` is what `error` holds — including a 5xx, which is why the shed sentence in the table above
184
+ reaches the app at all rather than being replaced by a generic one. Everything else — a gateway's
185
+ HTML page, a proxy's JSON with no `code`, a body with no `message` — is plumbing, and `error` holds
186
+ a fixed sentence derived from the status instead, so a raw HTML body can never become your error
187
+ copy. Practically: for a 5xx you are now showing a sentence the PLATFORM wrote, so treat `error` as
188
+ copy to display, never as a string to branch on.
189
+
181
190
  ## Pagination — two models
182
191
 
183
192
  Reads paginate two different ways, and the difference is load-bearing:
package/docs/mutations.md CHANGED
@@ -138,9 +138,23 @@ envelope's grammar — `message` is required, `field_errors` never reaches the a
138
138
  refreshes that alias's generated types in the same command, so `result.data` is typed
139
139
  immediately — no hand-copy of the echoed schema, no second `lotics app codegen`. An
140
140
  **explicitly declared** `outputs` is authoritative and never overwritten. Declare an explicit
141
- `outputs` (same recursive vocabulary as inputs: scalars plus nested `object`/`array`, no
142
- member/file/date_range) only to narrow beyond what's inferred; a shape the checker can't pin
143
- down degrades to untyped `json`, never to a wrong schema.
141
+ `outputs` only to narrow beyond what's inferred; a shape the checker can't pin down degrades
142
+ to untyped `json`, never to a wrong schema.
143
+ - **The output vocabulary is the input one minus the caller-only types, and `select` behaves
144
+ differently.** Scalars (`text`/`number`/`boolean`/`date`/`datetime`/`email`), `record_link`
145
+ (`table_id`, `multi?`), `select`, `json`, and nested `object` (`fields`) / `array` (`items`).
146
+ No `member`, `file`, or `date_range` — those describe what a caller *sends*. `select` names
147
+ its option set the same two ways as an input (**exactly one** of inline
148
+ `options: [{label, value}]` or `field: "fld_…"`, and a `field` that doesn't exist or names a
149
+ non-select field is rejected at bind time by the same gate, worded `select output references
150
+ field …`), but enforcement is **stricter than on the input side**: an output's option set is
151
+ enforced as *membership*, not merely key format. Inline `options` are enforced against the
152
+ frozen set; a `field` form is resolved to the field's **current** options at run and enforced
153
+ against those. So a producer — a workflow's `return({ data })`, an app agent's
154
+ `submit_result` — is handed the legal keys and cannot settle a label where a key belongs.
155
+ Prefer `field` for any select backed by a real field (the generated type tracks it, no
156
+ redeploy); use inline `options` for a fixed enum the app owns or a select you plan to
157
+ **package** (a `field` form carries a concrete field id and can't ride a package contract).
144
158
  - **Validated at run.** On a success return, the returned `data` is validated against the
145
159
  schema at the app boundary — a mismatch resolves as `status: "error"` with a field-level
146
160
  message, so a declared output is a real contract. An *error* return's `data` passes through
@@ -347,7 +361,8 @@ flash to a spinner — `loading` stays false during revalidation). `usePaginated
347
361
  yourself is yours to refresh, so a write that changes the row COUNT must also refresh whatever
348
362
  you read it from, or the page moves while "of N" does not. Focus revalidation
349
363
  (`revalidateOnFocus`, default on) eventually self-corrects stale data, but never rely on it
350
- in place of an explicit refetch after a write the user is watching for.
364
+ where the staleness is one of those three the user is looking at the number now, not the
365
+ next time they come back to the tab.
351
366
 
352
367
  ### A read must not overtake an in-flight write
353
368
 
@@ -506,9 +521,10 @@ Contract points:
506
521
  a whole. Include every changed field in the same call; don't submit per-field requests.
507
522
  - **Nothing changes immediately.** The tool returns
508
523
  `{ approval_request_id, status: "pending" }`; the record's values update only when an
509
- approver (the table's configured approvers, else org admins) accepts. A `refetch()` right
510
- after submitting shows the *old* values — render a "pending approval" state from the
511
- result, don't poll for the new values.
524
+ approver (the table's configured approvers, else org admins) accepts. The submission
525
+ succeeds, so the automatic re-read fires and returns the *old* values — that is correct,
526
+ and it is why the pending state must come from the RESULT rather than from the row. Render
527
+ it from `data.request_id`; don't poll the record for values no one has approved yet.
512
528
  - **Requires a member actor.** The request is attributed to the triggering member; an
513
529
  anonymous caller through a public app is rejected. Gate the affordance on a signed-in
514
530
  viewer.
@@ -523,7 +539,8 @@ const save = async () => {
523
539
  if (res.status === "success") showPending();
524
540
  } else {
525
541
  const res = await updateOrder({ record_id, ...changes });
526
- if (res.status === "success") orders.refetch();
542
+ if (res.status === "error") { showError(res.message); return; }
543
+ // orders re-reads itself — a successful write already said so.
527
544
  }
528
545
  };
529
546
  ```
@@ -543,9 +560,18 @@ patch(id, next, persist, opts?);
543
560
  item's stable key. `items` is `base` with pending patches merged per key (`{ ...item,
544
561
  ...patch }`); repeated patches on the same key merge.
545
562
  - `patch(id, next, persist, { onSettled })` applies `next` immediately, then runs the
546
- `persist` thunk. On **resolve**, the patch is *kept* (it should match the refetched value —
547
- no flicker) and `onSettled` runs — pass the owning query's `refetch`. On **reject**, the
548
- patch is *reverted*.
563
+ `persist` thunk. On **resolve**, the patch is *kept* and `onSettled` runs. On **reject**,
564
+ the patch is *reverted*.
565
+ - **`onSettled` is not where you refetch the query the patch came from.** The `persist` thunk
566
+ calls `useWorkflow`, and a successful write re-reads every mounted query by itself — so the
567
+ re-read is already in flight before `onSettled` fires, and passing `refetch` here buys a
568
+ second execution of the same query. Keep it for what the write cannot know about: a total
569
+ you computed yourself, a "saving…" indicator to clear, an analytics call.
570
+ - **A kept patch stays merged over `base` for the life of the view.** It is never cleared on
571
+ success — the design assumes it equals what the server stored, so the re-read lands
572
+ underneath it with no flicker. Patch the value you are SENDING, not a display form of it: if
573
+ the server normalizes (rounds a time, trims a string, resolves a link's label), the
574
+ optimistic value masks the stored one permanently and nothing reports it.
549
575
 
550
576
  **Warning — the persist thunk must throw on `status: "error"`.** `useWorkflow` resolves on
551
577
  failure (the failure model above), and a resolved promise means "kept" to `useOptimistic` —
@@ -555,6 +581,7 @@ workflow failed. Convert the status into a rejection:
555
581
  ```tsx
556
582
  const q = useQuery("events");
557
583
  const reschedule = useWorkflow("rescheduleEvent");
584
+ const mapped = useMemo(() => q.rows.map(toCalendarEvent), [q.rows]);
558
585
  const { items, patch } = useOptimistic(mapped, (e) => e.id);
559
586
 
560
587
  const onEventDrop = (ev: CalendarEvent, newStart: Date) =>
@@ -566,14 +593,13 @@ const onEventDrop = (ev: CalendarEvent, newStart: Date) =>
566
593
  const r = await reschedule({ record_id: ev.recordId, new_date: toISODate(newStart) });
567
594
  if (r.status === "error") throw new Error(r.message ?? "Reschedule failed");
568
595
  },
569
- { onSettled: q.refetch },
570
596
  );
571
597
  ```
572
598
 
573
599
  This is the full read → mutate → reconcile loop: `useQuery` reads, `row.*` coerces,
574
- `useWorkflow` mutates, `useOptimistic` bridges the gap until `refetch` converges. Declare
575
- one workflow per (table, field) you mutate — typed and narrow, never a generic
576
- `setField(any_field)` that hands the client write access to every field
600
+ `useWorkflow` mutates, `useOptimistic` covers the round-trip until the write's own re-read
601
+ converges. Declare one workflow per (table, field) you mutate — typed and narrow, never a
602
+ generic `setField(any_field)` that hands the client write access to every field
577
603
  ([security](./security.md)).
578
604
 
579
605
  ## Editing a record that does not exist yet: `useNewRecord`
@@ -590,7 +616,7 @@ id is known from the first render and the create stops being an event the UI has
590
616
  const { id, save } = useNewRecord({
591
617
  create: (id, patch) => createCustomer({ record_id: id, ...patch }),
592
618
  update: (id, patch) => updateCustomer({ record_id: id, ...patch }),
593
- onCreated: list.refetch,
619
+ onCreated: (id) => select(id),
594
620
  });
595
621
 
596
622
  <InlineText onBlur={(name) => save({ name })} />
@@ -605,6 +631,9 @@ const { id, save } = useNewRecord({
605
631
  - **A failed create does not latch.** The next `save` retries the create, rather than updating a
606
632
  row that was never written while the UI looks like it saved.
607
633
  - `id` and `save` are stable across renders, so `save` can be bound directly to an `onBlur`.
634
+ - **`onCreated` is not for refetching the list.** The `create` workflow succeeded, so every
635
+ mounted query re-read itself already. It runs once, with the id, for what only the id can
636
+ drive — routing to the record, selecting it, dropping the surface's "new" state.
608
637
 
609
638
  Your `create` workflow must pass the id through to `create_records` as `ids: [record_id]`.
610
639
  Creation is creation: an id that already exists is a conflict, never an overwrite — so the
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 |
@@ -425,6 +425,15 @@ full menu by category, so a miss is one informed retry.
425
425
  | **Date** | `now`, `formatDate`, `parseDate`, `addDays`, `subDays`, `addHours`, `subHours`, `addMinutes`, `subMinutes`, `startOfDay`, `endOfDay`, `differenceInCalendarDays`, `differenceInHours`, `differenceInMinutes`, `isBefore`, `isAfter`, `isSameDay`, `isToday`, `isWithinRange` |
426
426
  | **Other** | `formatCurrency(amount, locale, currency)`, `randomNumber(len)`, `randomAlphaNumeric(len)`, `sample(items)`, `current_member_in_any_group(["grp_…"])` |
427
427
 
428
+ **Emptiness on a record cell.** `isNull` is the strict check — true only for `null`/`undefined`.
429
+ A cleared cell does not always reach a workflow that way: the platform stores a cleared date or
430
+ text as `""` and a cleared select, link or files cell as `[]`, so `isNull(record.data.ngay_doi_soat)`
431
+ is **false** on a date the user emptied. Reach for `isEmpty` for the "has the user filled this in?"
432
+ question — it covers `null`, `undefined`, `""` and `[]`. (`isNull` stays narrow on purpose: it is
433
+ declared as a type predicate and every `?.` in a body lowers to it, so widening it would narrow
434
+ `""` out of a branch that still receives it.) A FORMULA field is the one surface where the two
435
+ agree: its evaluation context normalizes every unset cell to `null` before the expression runs.
436
+
428
437
  A few signatures worth knowing: `requireFirst(arr, message?)` asserts non-empty and returns `T`
429
438
  rather than `T | undefined` — pair it with a `validate` on `size(x) == 0` instead of wrapping
430
439
  every read in `if (x)`. `at(arr, -1)` counts from the end. `range(end)` / `range(start, end)`.
@@ -707,7 +716,8 @@ rejects, or read a path that is null on real data. The rehearsal for that is `dr
707
716
  `app_workflow_inputs`. It walks the real step tree with the production expression evaluator and
708
717
  hands back `planned_calls` (every tool call in order, with its fully-resolved input),
709
718
  `return_value`, `validation_failures`, `evaluation_errors`, and `tool_input_errors` — inputs the
710
- target tool would reject, which can only surface once the values are computed. **Write tools are
719
+ target tool would reject, which can only surface once the values are computed. Loops run for real,
720
+ every iteration, so a fold or a per-row fan-out is rehearsed at its true size. **Write tools are
711
721
  recorded, never dispatched**, and nothing is persisted.
712
722
 
713
723
  **Add `live_reads: true` whenever the body READS.** By default the read-only tools
@@ -721,6 +731,22 @@ which supply the real record as the payload AND turn live reads on; an app workf
721
731
  record — its payload is its inputs — so it asks for the reads directly.) Do this before the first
722
732
  live run of anything that writes.
723
733
 
734
+ **`return_value` is what the CALLER receives**, not a summary of it: `{ status, message }` always,
735
+ plus `data` when the body returns one and `field_errors` when it returns those — the same map a
736
+ form reads. A `validate` step that fails produces the same shape the live run does: every failing
737
+ check's message joined with `"; "`, and one `field_errors` entry per failing check that names a
738
+ `field_key`.
739
+
740
+ **A rehearsal can stop early, and it says so in `evaluation_errors`.** Four bounds apply — 1 000
741
+ iterations of a `while` / `do_while` / `c_for`, 10 000 steps, 50 live reads, and 20 seconds — and
742
+ tripping any of them appends an entry naming the bound and ending "the plan below is incomplete",
743
+ then stops. So read `evaluation_errors` before reading `planned_calls`: an entry there may be a
744
+ truncated plan rather than a bug in your body. A `foreach` is deliberately NOT capped at 1 000 —
745
+ its length is known before the first iteration, so the rehearsal walks every item and instead
746
+ refuses, up front, exactly the lists a live run refuses (over 10 000 items). The 50-live-read bound
747
+ is the one `live_reads: true` makes reachable: a loop body that reads issues one real query per
748
+ iteration.
749
+
724
750
  ## A worked body
725
751
 
726
752
  An app action that creates an order after checking for a duplicate, then returns the new id.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.86.0",
3
+ "version": "0.87.1",
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": {