@lotics/app-sdk 0.59.0 → 0.59.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.
package/AGENTS.md CHANGED
@@ -16,7 +16,7 @@ signature; open the file.**
16
16
  |---|---|
17
17
  | [docs/queries.md](./docs/queries.md) | **The query engine authoring reference** — AST node kinds, per-field-type operator support, filters/params/pruning, free-text search, combining tables (join/union/link/`unnest`/`record_id`), shaping (aggregates, date buckets, windows), runtime refinement bounds, limits & the efficiency playbook. |
18
18
  | [docs/data_fetching.md](./docs/data_fetching.md) | The three read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`), cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `useFieldOptions`, data discipline, the search-as-you-type + record-picker patterns. |
19
- | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract, typed inputs, diff-before-update, locked records, `useOptimistic`. |
19
+ | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract, typed inputs, diff-before-update, locked records, `useOptimistic`, read-after-write ordering (a re-read must not overtake an in-flight write). |
20
20
  | [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY authoring reference** — the JS subset a `src/workflows/<alias>.ts` body may use: the parse-at-save/never-execute model, opaque `fld_*`/`opt_*` keys, expression sources + link descent, every step form (tool call, `agent`, waits, `validate`, `return`), the accepted sugar and its canonical lowering, helpers + callback rules, record-write surfaces, the traps, the bright line, and the `lotics app workflow check` loop. |
21
21
  | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs, workflow-generated files, preview pairing, filter operators, the server-side delivery bounds. **Uploads declare a `fidelity`** (`standard` / `high` / `original`) — the app picks how much of the image survives storage; use `high` whenever text must stay legible. |
22
22
  | [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions`, `useViewer`, `useComments`, and the `@lotics/ui` components they feed. |
package/dist/src/rpc.d.ts CHANGED
@@ -178,6 +178,16 @@ export declare const APP_PUBLIC_SESSION_HEADER = "x-lotics-app-session";
178
178
  * `parsed` is the JSON.parse of the body, or `null` if it wasn't JSON.
179
179
  */
180
180
  export declare function transportErrorMessage(status: number, parsed: unknown): string;
181
+ /**
182
+ * The error a stream that never started should throw.
183
+ *
184
+ * A streaming endpoint fails BEFORE the first byte like any other request — a 402 when the
185
+ * workspace is out of credits, a 403, a 404. Those bodies are structured errors, and throwing
186
+ * the body TEXT put the whole JSON on screen: `{"message":"Credit quota exceeded…","error":
187
+ * "quota_exceeded","plan_id":"free",…}`. Same rule as every other call: the `message` is the
188
+ * message, and a non-JSON or 5xx body never becomes one.
189
+ */
190
+ export declare function streamStartError(res: Response): Promise<Error>;
181
191
  /**
182
192
  * A package installation's alias→concrete-id maps — what the generated
183
193
  * `.lotics/app_fields.ts` of a package project resolves `F`/`OPT`/`ROLE`
package/dist/src/rpc.js CHANGED
@@ -257,8 +257,7 @@ export function rpcAgentRunContinue(payload, onText) {
257
257
  signal: controller.signal,
258
258
  });
259
259
  if (!res.ok || !res.body) {
260
- const text = await res.text().catch(() => "");
261
- throw new Error(text || `HTTP ${res.status}`);
260
+ throw await streamStartError(res);
262
261
  }
263
262
  const reader = res.body.getReader();
264
263
  const decoder = new TextDecoder();
@@ -295,8 +294,7 @@ function agentRunStandalone(payload, onText, onRunId) {
295
294
  signal: controller.signal,
296
295
  });
297
296
  if (!res.ok || !res.body) {
298
- const text = await res.text().catch(() => "");
299
- throw new Error(text || `HTTP ${res.status}`);
297
+ throw await streamStartError(res);
300
298
  }
301
299
  const runId = res.headers.get("x-app-agent-run-id");
302
300
  if (runId)
@@ -410,8 +408,11 @@ async function boot() {
410
408
  * A user-facing message for a transport/gateway failure — derived from the HTTP
411
409
  * status, never from the response body. A 524 (Cloudflare edge timeout on a long
412
410
  * run), any 5xx, or a non-JSON body (an HTML error page) must NOT surface its raw
413
- * body as the error message. Kept in parity (by value, no shared dep) with the
414
- * dev-loop transport in `packages/sdk/src/client.ts`.
411
+ * body as the error message.
412
+ *
413
+ * A by-value MIRROR of `@lotics/shared/transport_error`, which is canonical. This
414
+ * package ships to npm with zero internal dependencies, so it cannot import it;
415
+ * every other transport does. Change one, change both.
415
416
  */
416
417
  function gatewayErrorMessage(status) {
417
418
  if (status === 524) {
@@ -437,6 +438,28 @@ export function transportErrorMessage(status, parsed) {
437
438
  ? gatewayErrorMessage(status)
438
439
  : jsonMessage;
439
440
  }
441
+ /**
442
+ * The error a stream that never started should throw.
443
+ *
444
+ * A streaming endpoint fails BEFORE the first byte like any other request — a 402 when the
445
+ * workspace is out of credits, a 403, a 404. Those bodies are structured errors, and throwing
446
+ * the body TEXT put the whole JSON on screen: `{"message":"Credit quota exceeded…","error":
447
+ * "quota_exceeded","plan_id":"free",…}`. Same rule as every other call: the `message` is the
448
+ * message, and a non-JSON or 5xx body never becomes one.
449
+ */
450
+ export async function streamStartError(res) {
451
+ const text = await res.text().catch(() => "");
452
+ let parsed = null;
453
+ if (text) {
454
+ try {
455
+ parsed = JSON.parse(text);
456
+ }
457
+ catch {
458
+ // not JSON — a gateway HTML page; transportErrorMessage will not surface it
459
+ }
460
+ }
461
+ return new Error(transportErrorMessage(res.status, parsed));
462
+ }
440
463
  // Standalone requests carried no timeout: a hung or slow backend pinned the
441
464
  // fetch — and one of the browser's few per-origin connections — indefinitely,
442
465
  // surfacing as an app that "loads forever". Bound every standalone call the way
package/docs/ai.md CHANGED
@@ -90,6 +90,16 @@ The terminal `submit_result` call is captured into `output`, **not** rendered as
90
90
 
91
91
  `AgentRun` renders thinking collapsed, groups consecutive tool calls, and expands each tool's input/output in place on press — all for free. Running it in a bounded container (a dialog, a panel)? Wrap it in `@lotics/ui`'s `FollowScroll` so the container follows the stream instead of letting new content grow below the fold. **Errors surface two ways:** a per-tool failure is an `output-error` part (amber dot, reason in the expanded Error panel — the feed flows on, exactly like a run that retried and recovered); a **breaking** error that killed the run lives in `run.error` (not in `parts`) — pass it as `error` and it renders as a terminal danger row. Never rebuild this feed by hand.
92
92
 
93
+ `run.error` is always a **sentence to show a person**, never a payload — on both the ways a run can fail.
94
+
95
+ *Before it starts*: the request is refused (most often a 402 when the workspace is out of AI credits — "Credit quota exceeded. Upgrade your plan for continued AI access." — or a 403/404). You get that response's `message` alone; the rest of the body (`error`, `plan_id`, `credits_used`, …) never reaches the string, and a non-JSON body or any 5xx is replaced by a status-derived message.
96
+
97
+ *After it starts*: the run dies mid-flight, and the message is written **in the triggering member's language** (the workspace default when an anonymous visitor triggered it). An error the platform wrote surfaces as-is, so running out of credits **during** a run still says to upgrade rather than to retry. A provider or tool failure does NOT — its wording is a debugging string that names our request shape, so it becomes one of two sentences: the request itself was rejected (an attachment that is corrupt or too large — retrying unchanged cannot help), or it broke transiently and is worth another run. The raw error is kept in error tracking with the run id.
98
+
99
+ Two caveats on that language guarantee. A message written at its throw site rather than fixed on its error class stays English — those strings are also what the chat agent reads to correct its own tool calls, so they are deliberately one language. And the *before it starts* case above is the API response's own `message`, which is not localized either. So an app that must be wholly in one language should render its own copy on failure rather than the platform's.
100
+
101
+ So `error={run.error}` is safe to render verbatim. What it is NOT is machine-readable: `status` tells you a run failed, not why, and the difference between "worth retrying" and "retrying cannot help" lives in the sentence rather than in a code. **Do not parse it** — the wording is copy and will change. If your app needs to act on the distinction (a retry button that disables itself when a retry is futile, an upgrade prompt on a quota hit), that discriminator can be exposed; it is classified server-side already and simply is not on the wire yet.
102
+
93
103
  ### The agent asks back — `pendingChoice` / `answerChoice`
94
104
 
95
105
  Every app agent carries the platform's MANDATORY interactive tool `ask_user_choice` —
package/docs/mutations.md CHANGED
@@ -319,6 +319,62 @@ flash to a spinner — `loading` stays false during revalidation). `usePaginated
319
319
  (`revalidateOnFocus`, default on) eventually self-corrects stale data, but never rely on it
320
320
  in place of an explicit refetch after a write the user is watching for.
321
321
 
322
+ ### A read must not overtake an in-flight write
323
+
324
+ `refetch()` and a direct re-read know nothing about a write that is still running. On a record
325
+ surface with inline (blur-committing) fields, a single gesture starts both: the field's write on
326
+ mousedown, the button's handler on mouseup — so a handler that re-reads the record to build a
327
+ document, mint something, or copy the row can read the row as it was BEFORE the edit. It fails
328
+ silently: the screen shows the new value, the output carries the old one.
329
+
330
+ Serialize them with one barrier per record surface — every write chains on, and the ONE place
331
+ that re-reads stored state awaits it, so no handler can forget:
332
+
333
+ ```tsx
334
+ // Per surface, not module-level: useMemo(createWriteBarrier, []) inside the hook.
335
+ export function createWriteBarrier() {
336
+ let chain: Promise<unknown> = Promise.resolve();
337
+ return {
338
+ // Returns the write UNCHANGED — the caller keeps its own result + error handling;
339
+ // the chain never rejects, it only tracks when writes SETTLE.
340
+ track: <T,>(write: Promise<T>): Promise<T> => {
341
+ chain = chain.then(() => write).catch(() => undefined);
342
+ return write;
343
+ },
344
+ settled: () => chain,
345
+ };
346
+ }
347
+
348
+ const saveField = async (key: string, value: unknown) => {
349
+ const r = await writes.track(saveRecord({ record_id, [key]: value }));
350
+ if (r.status === "error") throw new Error(r.message); // the inline editor keeps the edit
351
+ };
352
+
353
+ /** The ONLY re-read of stored state. Every handler goes through it — never the cached row. */
354
+ const reload = async () => {
355
+ await writes.settled();
356
+ const res = await rpc<{ rows?: Rec[] }>("query", { alias: "record", params: { id: record_id } });
357
+ return res.rows?.[0] ?? null;
358
+ };
359
+
360
+ const printDoc = async () => {
361
+ const fresh = (await reload()) ?? current; // reflects the edit the press just committed
362
+ await issueDoc(buildDoc(fresh));
363
+ };
364
+ ```
365
+
366
+ A handler that reads the row held in state instead has the same defect with no in-flight write
367
+ needed — any edit since the row was loaded is missing. Build documents and mints from the
368
+ re-read, not from the cached row.
369
+
370
+ The same trap one level up: **an action takes an ID, never a captured object.**
371
+ `onPress={() => issueInvoice(invoice)}` closes over the invoice as it was when the press
372
+ happened, so no amount of waiting refreshes it — the confirm quotes, and the mint bills, the
373
+ pre-edit total. Pass the key and resolve at use (`onPress={() => setConfirmKey(invoice.key)}`,
374
+ then re-read or `find` where it is consumed). `@lotics/ui` gates the press itself
375
+ (`docs/data_entry.md` § Inline edit), which fixes the ordering; the captured value is the app's
376
+ to get right.
377
+
322
378
  ## Diff before update — send only what changed
323
379
 
324
380
  An edit form snapshots the record's values when it loads, and on save sends **only the fields
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.59.0",
4
- "description": "Runtime SDK for Lotics custom-code apps typed hooks, postMessage bridge, mount entry point",
3
+ "version": "0.59.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": {
7
7
  ".": {