@lotics/app-sdk 0.93.0 → 0.95.0

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,10 +16,10 @@ signature; open the file.**
16
16
  |---|---|
17
17
  | [docs/recipes.md](./docs/recipes.md) | Task-shaped how-tos for the actions whose mechanism is not guessable from the hooks — returning a generated file, returning structured data, parameterized lookups, composable optional filters, cell decoding, testing an AI action without spending credits. |
18
18
  | [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. |
19
- | [docs/data_fetching.md](./docs/data_fetching.md) | The four read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`/`useCount` — the last for a number with no rows, sharing the `(alias, params, filter)` count key the paginated hook uses, so a list and a badge over one set buy one count; a count is a full scan and stays its OWN request so rows paint without waiting for it, and `rows.length` is never a count since rows truncate at 10,000 — `useQuery` says so with `truncated`), the ROW type (`RowOf` — the alias's projected columns and nothing else, values `unknown`; `__source_record_id`/`__source_table_id` typed but optional), runtime `sort`/`filter` keys AND the `useFieldOptions` map keyed against that same projection (`AppQueryColumns`, `ColumnKeyOf`), cell readers (`row.*` — `row.num` answers `null` for an EMPTY cell, so none is distinguishable from zero — `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`, and `readCreatedAt`/`readUpdatedAt` for the record timestamps every row-level result carries), caching — **arrival revalidates** (a re-mount renders cache *and* refreshes it in the background, `loading` never flips) — **realtime push** (a table one of your queries reads changes and that query refetches within about a second, alias-precise, records-only, host-embedded apps only) — and the fourth source, **this app's own successful write**, which re-reads every mounted query immediately rather than waiting on that push (→ [mutations](./docs/mutations.md)), data discipline, the pagination count as a second full execution (and `total` to suppress it), the search-as-you-type + record-picker patterns. |
19
+ | [docs/data_fetching.md](./docs/data_fetching.md) | The four read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`/`useCount` — the last for a number with no rows, sharing the `(alias, params, filter)` count key the paginated hook uses, so a list and a badge over one set buy one count; a count is a full scan and stays its OWN request so rows paint without waiting for it, and `rows.length` is never a count since rows truncate at 10,000 — `useQuery` says so with `truncated`), the ROW type (`RowOf` — the alias's projected columns and nothing else, values `unknown`; `__source_record_id`/`__source_table_id` typed but optional), runtime `sort`/`filter` keys AND the `useFieldOptions` map keyed against that same projection (`AppQueryColumns`, `ColumnKeyOf`), cell readers (`row.*` — `row.num` and `row.bool` answer `null` for an EMPTY cell, so none is distinguishable from zero and an unanswered checkbox from a "no" — `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`, and `readCreatedAt`/`readUpdatedAt` for the record timestamps every row-level result carries), caching — **arrival revalidates** (a re-mount renders cache *and* refreshes it in the background, `loading` never flips) — **realtime push** (a table one of your queries reads changes and that query refetches within about a second, alias-precise, records-only, host-embedded apps only) — and the fourth source, **this app's own successful write**, which re-reads every mounted query immediately rather than waiting on that push (→ [mutations](./docs/mutations.md)), data discipline, the pagination count as a second full execution (and `total` to suppress it), the search-as-you-type + record-picker patterns. |
20
20
  | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract (`field_errors` locates a refusal on the control it belongs to, where `message` can only say it at the dialog's scope), typed inputs, the automatic re-read a SUCCESSFUL write triggers over every mounted query (so a screen never waits on the host push to see its own write), diff-before-update, locked records, `useOptimistic`, `useNewRecord` (client-minted `rec_*` id so a new-record surface never remounts on its first save), read-after-write ordering (a re-read must not overtake an in-flight write). |
21
21
  | [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 + explicit `linked()` 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 verify loop — `check` (the only local gate: the app's own `npm run typecheck` never sees a body) → `dry_run_workflow` (static green is not a run) → `set`. |
22
- | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs (**a bearer credential for the bytes** — never logged, reported, or persisted), workflow-generated files, **naming a zip's entries** (`{ id, name }` per file — a file name, never a path), 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
+ | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments` (**the add-queue, and which lifecycle it keeps**: a composer clears, a record passes `landed` and each entry leaves as the stored pile takes it over), `readFiles`/presigned URLs (**a bearer credential for the bytes** — never logged, reported, or persisted), workflow-generated files, **naming a zip's entries** (`{ id, name }` per file — a file name, never a path), 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. |
23
23
  | [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions` (keyed by the alias's OWN columns, every key optional), `useViewer`, `useComments` (each comment carries its own resolved `author`, so a thread crossing a role boundary is legible without declaring member access), and the `@lotics/ui` components they feed. |
24
24
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
25
25
  | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `AgentRun`, the agent's ask-back — `pendingChoice`/`answerChoice` over the parked `awaiting_input` state — and the `AgentRunLanding` every leg resolves), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics; a chat mutation refetches your queries through the realtime channel, not a separate poke). **A `file` input carries its own content** — no reader tool to declare. **An agent reaches record DATA only through its declared `query_aliases` / `workflow_aliases`.** Also **what the member's own chat agent can do with your app while it is open** — the alias catalog it reads and how to shape a mutating alias for it. |
@@ -0,0 +1,68 @@
1
+ import type { ImageFidelity } from "./upload/optimize.js";
2
+ /** A file attached to a surface — its local preview is available immediately,
3
+ * its stored `file_id` once the background upload completes. */
4
+ export interface AttachedFile {
5
+ /** Stable local id — the React key and the `remove(id)` handle. */
6
+ id: string;
7
+ filename: string;
8
+ mime_type: string;
9
+ /** Local object-URL preview, available the INSTANT the file is added (before upload). */
10
+ preview_url: string;
11
+ status: "uploading" | "ready" | "error";
12
+ /** The stored file id, set once `status` is `"ready"` — pass to a workflow/agent. */
13
+ file_id?: string;
14
+ }
15
+ export interface AttachmentsOptions {
16
+ /**
17
+ * The stored ids the DESTINATION now holds. Every queued entry among them has
18
+ * arrived, so it leaves the queue and its preview URL is revoked.
19
+ *
20
+ * This is what makes the queue a record's rather than a composer's: pass what
21
+ * the row came back with and the queue hands over to the pile exactly as the
22
+ * pile takes it, with no window where neither is on screen. Pass nothing and
23
+ * the queue accumulates until `clear`.
24
+ */
25
+ landed?: readonly string[];
26
+ }
27
+ export interface AttachmentsState {
28
+ /** The current attachments, in the order added. */
29
+ files: AttachedFile[];
30
+ /**
31
+ * Add picked/pasted/dropped files — each shows its local preview at once and
32
+ * uploads in the background. Picking is the app's choice: wire a button to
33
+ * `@lotics/ui` `pickFiles`, or a paste/drop handler, then call this.
34
+ */
35
+ add: (files: File[], options?: {
36
+ fidelity?: ImageFidelity;
37
+ }) => void;
38
+ /**
39
+ * The same add, ANSWERED WHEN EVERY ONE OF THEM IS STORED — the ids, in the
40
+ * order given. What a write waits on: a record told it holds files whose
41
+ * bytes are still going up names ids the workspace does not have.
42
+ *
43
+ * Rejects with the first refusal instead of answering short, and the queue
44
+ * keeps every entry with its own status, so the reader sees which one failed
45
+ * and the ones that succeeded are not silently written without it.
46
+ */
47
+ attach: (files: File[], options?: {
48
+ fidelity?: ImageFidelity;
49
+ }) => Promise<string[]>;
50
+ /** Remove one attachment and revoke its preview URL. */
51
+ remove: (id: string) => void;
52
+ /** Remove all attachments and revoke their preview URLs. */
53
+ clear: () => void;
54
+ /** True while any attachment is still uploading — gate Send on it. */
55
+ uploading: boolean;
56
+ /** Stored file ids of the completed uploads — the workflow/agent payload. */
57
+ fileIds: string[];
58
+ }
59
+ /**
60
+ * The queue itself, over whichever upload the caller runs through. `upload` is
61
+ * typed by what this needs of it — a stored id — so the transport is the
62
+ * caller's to bring.
63
+ */
64
+ export declare function useAttachmentQueue(upload: (file: File, options?: {
65
+ fidelity?: ImageFidelity;
66
+ }) => Promise<{
67
+ id: string;
68
+ }>, options?: AttachmentsOptions): AttachmentsState;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * THE ADD-QUEUE — one owner of the preview → upload → id → revoke lifecycle,
3
+ * apart from the transport that carries the bytes.
4
+ *
5
+ * A surface that takes files has the same four moments wherever it stands: a
6
+ * local preview the instant one is picked, an upload in the background, the
7
+ * stored id when it lands, and the object URL revoked when the entry is done
8
+ * with. What differs is only WHEN an entry is done with — a composer keeps its
9
+ * pile until it sends, a record's section keeps one until the row comes back
10
+ * holding it — so the lifecycle is stated once and the caller says which.
11
+ *
12
+ * Parameterised by the upload rather than reaching for it, so the queue is the
13
+ * same queue wherever the bytes go.
14
+ */
15
+ import { useCallback, useEffect, useState } from "react";
16
+ let attachSeq = 0;
17
+ /**
18
+ * What the landed ids are joined by to make the effect's one dependency, and
19
+ * split back on. A stored id is an opaque token with no punctuation in it; one
20
+ * that carried this would simply not match, leaving its own tile standing —
21
+ * never taking a different one away.
22
+ */
23
+ const LANDED_SEPARATOR = ",";
24
+ /**
25
+ * The queue itself, over whichever upload the caller runs through. `upload` is
26
+ * typed by what this needs of it — a stored id — so the transport is the
27
+ * caller's to bring.
28
+ */
29
+ export function useAttachmentQueue(upload, options) {
30
+ const [files, setFiles] = useState([]);
31
+ const start = useCallback((file, uploadOptions) => {
32
+ const id = `att_${(attachSeq += 1)}`;
33
+ const previewUrl = URL.createObjectURL(file);
34
+ setFiles((prev) => [
35
+ ...prev,
36
+ { id, filename: file.name, mime_type: file.type, preview_url: previewUrl, status: "uploading" },
37
+ ]);
38
+ // SETTLES, NEVER REJECTS: the queue entry is where a failure is recorded,
39
+ // so a caller that only wanted the preview holds no rejected promise.
40
+ return upload(file, uploadOptions).then((uploaded) => {
41
+ setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, status: "ready", file_id: uploaded.id } : f)));
42
+ return { kind: "stored", id: uploaded.id };
43
+ }, (cause) => {
44
+ setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, status: "error" } : f)));
45
+ return { kind: "failed", reason: cause instanceof Error ? cause : new Error(String(cause)) };
46
+ });
47
+ }, [upload]);
48
+ const add = useCallback((incoming, uploadOptions) => {
49
+ for (const file of incoming)
50
+ void start(file, uploadOptions);
51
+ }, [start]);
52
+ const attach = useCallback(async (incoming, uploadOptions) => {
53
+ const settled = await Promise.all(incoming.map((file) => start(file, uploadOptions)));
54
+ const refused = settled.find((one) => one.kind === "failed");
55
+ if (refused?.kind === "failed")
56
+ throw refused.reason;
57
+ return settled.flatMap((one) => (one.kind === "stored" ? [one.id] : []));
58
+ }, [start]);
59
+ const remove = useCallback((id) => {
60
+ setFiles((prev) => {
61
+ const target = prev.find((f) => f.id === id);
62
+ if (target)
63
+ URL.revokeObjectURL(target.preview_url);
64
+ return prev.filter((f) => f.id !== id);
65
+ });
66
+ }, []);
67
+ const clear = useCallback(() => {
68
+ setFiles((prev) => {
69
+ for (const f of prev)
70
+ URL.revokeObjectURL(f.preview_url);
71
+ return [];
72
+ });
73
+ }, []);
74
+ // The IDS are the dependency, not the array holding them: a caller reading
75
+ // them off a row builds a fresh array every render.
76
+ const landedKey = options?.landed === undefined ? null : options.landed.join(LANDED_SEPARATOR);
77
+ useEffect(() => {
78
+ if (landedKey === null)
79
+ return;
80
+ const taken = new Set(landedKey === "" ? [] : landedKey.split(LANDED_SEPARATOR));
81
+ setFiles((prev) => {
82
+ const arrived = prev.filter((f) => f.file_id !== undefined && taken.has(f.file_id));
83
+ if (arrived.length === 0)
84
+ return prev;
85
+ for (const f of arrived)
86
+ URL.revokeObjectURL(f.preview_url);
87
+ return prev.filter((f) => !arrived.includes(f));
88
+ });
89
+ }, [landedKey]);
90
+ const uploading = files.some((f) => f.status === "uploading");
91
+ const fileIds = files.flatMap((f) => (f.status === "ready" && f.file_id ? [f.file_id] : []));
92
+ return { files, add, attach, remove, clear, uploading, fileIds };
93
+ }
@@ -1,4 +1,5 @@
1
1
  import { type ImageFidelity } from "./upload/optimize.js";
2
+ import { type AttachmentsOptions, type AttachmentsState } from "./attachments.js";
2
3
  import { type AiContextValue } from "./rpc.js";
3
4
  import { type AgentUIPart, type PendingChoice, type AgentRunLanding } from "./agent_stream.js";
4
5
  import type { AppWorkflows, AppWorkflowResults, AppQueries, AppQueryColumns, AppAgents, AppAgentResults } from "./types.js";
@@ -334,11 +335,16 @@ export interface WorkflowResult<TData = unknown> {
334
335
  files?: UploadedFile[];
335
336
  data?: TData;
336
337
  /**
337
- * Per-input refusals the workflow returned via `return({ field_errors })`,
338
- * keyed by the INPUT name it declares — what a form wires straight onto the
339
- * control that is wrong (`<FormField error={result.field_errors?.ly_do}>`),
340
- * where `message` can only say it at the dialog's scope. Absent when the
341
- * workflow returned none.
338
+ * Per-input refusals, keyed by the INPUT name the alias declares — what a
339
+ * form wires straight onto the control that is wrong
340
+ * (`<FormField error={result.field_errors?.ly_do}>`), where `message` can
341
+ * only say it at the dialog's scope.
342
+ *
343
+ * Either half of the round trip fills it: the SERVER, when the payload does
344
+ * not match what the alias declares, and the workflow's own
345
+ * `return({ field_errors })`. One key, so a screen wires the control once
346
+ * rather than branching on which half refused. Absent when neither named a
347
+ * field — and an older server names none.
342
348
  */
343
349
  field_errors?: Record<string, string>;
344
350
  }
@@ -545,44 +551,16 @@ interface FileUploadState {
545
551
  * ```
546
552
  */
547
553
  export declare function useFileUpload(): FileUploadState;
548
- /** A file attached to a composer — its local preview is available immediately,
549
- * its stored `file_id` once the background upload completes. */
550
- export interface AttachedFile {
551
- /** Stable local id — the React key and the `remove(id)` handle. */
552
- id: string;
553
- filename: string;
554
- mime_type: string;
555
- /** Local object-URL preview, available the INSTANT the file is added (before upload). */
556
- preview_url: string;
557
- status: "uploading" | "ready" | "error";
558
- /** The stored file id, set once `status` is `"ready"` — pass to a workflow/agent. */
559
- file_id?: string;
560
- }
561
- interface AttachmentsState {
562
- /** The current attachments, in the order added. */
563
- files: AttachedFile[];
564
- /** Add picked/pasted/dropped files — each shows its local preview at once and
565
- * uploads in the background. Picking is the app's choice: wire a button to
566
- * `@lotics/ui` `pickFiles`, or a paste/drop handler, then call this. */
567
- add: (files: File[], options?: {
568
- fidelity?: ImageFidelity;
569
- }) => void;
570
- /** Remove one attachment and revoke its preview URL. */
571
- remove: (id: string) => void;
572
- /** Remove all attachments and revoke their preview URLs. */
573
- clear: () => void;
574
- /** True while any attachment is still uploading — gate Send on it. */
575
- uploading: boolean;
576
- /** Stored file ids of the completed uploads — the workflow/agent payload. */
577
- fileIds: string[];
578
- }
579
554
  /**
580
- * Composer attachments with the chat's optimistic-preview UX: a local object-URL
581
- * preview shows the INSTANT a file is added, the upload runs in the background,
582
- * and the stored `file_id` lands in `files` when it completes. Previews are
583
- * revoked on remove/clear. Picking is the app's (button / paste / drop) — pass
584
- * the resulting `File[]` to `add`; the hook owns the preview → upload → id →
585
- * revoke lifecycle so apps don't re-implement it.
555
+ * Attachments with the optimistic-preview UX: a local object-URL preview shows
556
+ * the INSTANT a file is added, the upload runs in the background, and the
557
+ * stored `file_id` lands in `files` when it completes. Picking is the app's
558
+ * (button / paste / drop) — pass the resulting `File[]` to `add`, or to
559
+ * `attach` where a write has to wait for the ids.
560
+ *
561
+ * WHICH LIFECYCLE is the caller's one decision. A composer accumulates and
562
+ * clears when it sends; a record's section passes what its row now holds as
563
+ * `landed`, and each entry leaves the queue as the pile takes it over.
586
564
  *
587
565
  * ```tsx
588
566
  * const { files, add, remove, clear, uploading, fileIds } = useAttachments();
@@ -597,8 +575,10 @@ interface AttachmentsState {
597
575
  * // ))
598
576
  * // send: design({ photo: fileIds[0] }); clear();
599
577
  * ```
578
+ *
579
+ * The queue is `useAttachmentQueue`, bound here to the app's own upload.
600
580
  */
601
- export declare function useAttachments(): AttachmentsState;
581
+ export declare function useAttachments(options?: AttachmentsOptions): AttachmentsState;
602
582
  /**
603
583
  * Publish a slice of the CURRENT SCREEN's view state to the app's ambient chat
604
584
  * agent, so a member chatting alongside the app gets an agent that knows what
package/dist/src/hooks.js CHANGED
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
19
19
  import { DEFAULT_IMAGE_FIDELITY } from "./upload/optimize.js";
20
+ import { useAttachmentQueue } from "./attachments.js";
20
21
  import useSWR from "swr";
21
22
  import useSWRInfinite from "swr/infinite";
22
23
  import { rpc, rpcAgentRun, rpcAgentRunContinue, postHostNotification, subscribeQueriesChanged, registerQueryAlias, notifyLocalWrite, } from "./rpc.js";
@@ -408,14 +409,16 @@ export function useFileUpload() {
408
409
  }, []);
409
410
  return { upload, uploading: inFlight > 0, error };
410
411
  }
411
- let attachSeq = 0;
412
412
  /**
413
- * Composer attachments with the chat's optimistic-preview UX: a local object-URL
414
- * preview shows the INSTANT a file is added, the upload runs in the background,
415
- * and the stored `file_id` lands in `files` when it completes. Previews are
416
- * revoked on remove/clear. Picking is the app's (button / paste / drop) — pass
417
- * the resulting `File[]` to `add`; the hook owns the preview → upload → id →
418
- * revoke lifecycle so apps don't re-implement it.
413
+ * Attachments with the optimistic-preview UX: a local object-URL preview shows
414
+ * the INSTANT a file is added, the upload runs in the background, and the
415
+ * stored `file_id` lands in `files` when it completes. Picking is the app's
416
+ * (button / paste / drop) — pass the resulting `File[]` to `add`, or to
417
+ * `attach` where a write has to wait for the ids.
418
+ *
419
+ * WHICH LIFECYCLE is the caller's one decision. A composer accumulates and
420
+ * clears when it sends; a record's section passes what its row now holds as
421
+ * `landed`, and each entry leaves the queue as the pile takes it over.
419
422
  *
420
423
  * ```tsx
421
424
  * const { files, add, remove, clear, uploading, fileIds } = useAttachments();
@@ -430,41 +433,12 @@ let attachSeq = 0;
430
433
  * // ))
431
434
  * // send: design({ photo: fileIds[0] }); clear();
432
435
  * ```
436
+ *
437
+ * The queue is `useAttachmentQueue`, bound here to the app's own upload.
433
438
  */
434
- export function useAttachments() {
439
+ export function useAttachments(options) {
435
440
  const { upload } = useFileUpload();
436
- const [files, setFiles] = useState([]);
437
- const add = useCallback((incoming, options) => {
438
- for (const file of incoming) {
439
- const id = `att_${(attachSeq += 1)}`;
440
- const previewUrl = URL.createObjectURL(file);
441
- setFiles((prev) => [
442
- ...prev,
443
- { id, filename: file.name, mime_type: file.type, preview_url: previewUrl, status: "uploading" },
444
- ]);
445
- upload(file, options)
446
- .then((uploaded) => setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, status: "ready", file_id: uploaded.id } : f))))
447
- .catch(() => setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, status: "error" } : f))));
448
- }
449
- }, [upload]);
450
- const remove = useCallback((id) => {
451
- setFiles((prev) => {
452
- const target = prev.find((f) => f.id === id);
453
- if (target)
454
- URL.revokeObjectURL(target.preview_url);
455
- return prev.filter((f) => f.id !== id);
456
- });
457
- }, []);
458
- const clear = useCallback(() => {
459
- setFiles((prev) => {
460
- for (const f of prev)
461
- URL.revokeObjectURL(f.preview_url);
462
- return [];
463
- });
464
- }, []);
465
- const uploading = files.some((f) => f.status === "uploading");
466
- const fileIds = files.flatMap((f) => (f.status === "ready" && f.file_id ? [f.file_id] : []));
467
- return { files, add, remove, clear, uploading, fileIds };
441
+ return useAttachmentQueue(upload, options);
468
442
  }
469
443
  /**
470
444
  * JSON-serialize the view-state snapshot — used for BOTH change-detection (a
@@ -16,7 +16,8 @@
16
16
  export { mount } from "./mount.js";
17
17
  export type { MountOptions } from "./mount.js";
18
18
  export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useCount, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, buildChoiceOutput, } from "./hooks.js";
19
- export type { QueryRow, RowOf, UploadedFile, AttachedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, CountOptions, ColumnKeyOf, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterFieldCondition, QueryFilterRecordIdCondition, QueryFilterGroup, WorkflowResult, MembersOptions, AgentRunOptions, UseAgentRun, AgentRunLanding, AgentRunRecord, AgentRunState, AgentUIPart, PendingChoice, ChoiceQuestion, ChoiceOption, AskUserChoiceOutput, FieldOptions, FieldOptionsState, FieldOptionsOptions, } from "./hooks.js";
19
+ export type { QueryRow, RowOf, UploadedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, CountOptions, ColumnKeyOf, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterFieldCondition, QueryFilterRecordIdCondition, QueryFilterGroup, WorkflowResult, MembersOptions, AgentRunOptions, UseAgentRun, AgentRunLanding, AgentRunRecord, AgentRunState, AgentUIPart, PendingChoice, ChoiceQuestion, ChoiceOption, AskUserChoiceOutput, FieldOptions, FieldOptionsState, FieldOptionsOptions, } from "./hooks.js";
20
+ export type { AttachedFile, AttachmentsOptions, AttachmentsState } from "./attachments.js";
20
21
  export { useComments, useCommentCounts } from "./comments.js";
21
22
  export type { AppComment, AppCommentAuthor, AppCommentFile, CommentsState, UseCommentsArgs, CommentCountsState, UseCommentCountsArgs, } from "./comments.js";
22
23
  export { useViewer } from "./viewer.js";
package/dist/src/row.d.ts CHANGED
@@ -25,8 +25,19 @@ declare function text(v: unknown): string;
25
25
  * correct reading of an empty cell (a sum, a count).
26
26
  */
27
27
  declare function num(v: unknown): number | null;
28
- /** Checkbox/boolean → boolean (accepts the string "true"). */
29
- declare function bool(v: unknown): boolean;
28
+ /**
29
+ * Checkbox/boolean → `true` / `false`, or `null` when the cell holds neither —
30
+ * an empty cell, a value of another kind. (The strings "true"/"false" decode.)
31
+ *
32
+ * AN UNANSWERED CHECKBOX IS NOT A "NO", exactly as an empty number cell is not a
33
+ * 0: the field's default is what the workspace writes when a row states
34
+ * something, and a row nobody has answered states nothing. Read as `false` it
35
+ * drew "No" for every partner nobody had assessed — the rows a gate exists to
36
+ * catch are precisely the ones that answer nothing — so the absence is preserved
37
+ * the way `num` and `date` preserve theirs. Write `=== true` where only the
38
+ * affirmative acts, `?? false` where the safe side IS the reading.
39
+ */
40
+ declare function bool(v: unknown): boolean | null;
30
41
  /**
31
42
  * Date/datetime field → a LOCAL-midnight Date for the stored calendar day, so
32
43
  * calendar/gantt placement never shifts across timezones. Parses the leading
@@ -62,7 +73,17 @@ export interface ResolvedLink {
62
73
  * `readLinks` for the full list on multi-link fields.
63
74
  */
64
75
  declare function link(v: unknown): ResolvedLink | null;
65
- /** select_record_link → ALL linked records as `{ id, display }[]` (empty if none). */
76
+ /**
77
+ * select_record_link → ALL linked records as `{ id, display }[]` (empty if none).
78
+ *
79
+ * A LOOKUP OF A LINK FIELD ARRIVES ONE LEVEL DEEPER — the cell holds one entry
80
+ * per linked record and each entry is THAT record's whole link cell, an array —
81
+ * so read entry-by-entry it decoded to nothing while the value was plainly
82
+ * there. The nesting is flattened here, at the boundary that owns the
83
+ * serialization, rather than at each caller: one level, in order, and a record
84
+ * two linked rows both point at is one link, so ids repeat at most once.
85
+ * Anything else still reads as empty.
86
+ */
66
87
  export declare function readLinks(v: unknown): ResolvedLink[];
67
88
  /**
68
89
  * A `files`-field cell entry, as the app query serializes it. The server
package/dist/src/row.js CHANGED
@@ -52,9 +52,24 @@ function num(v) {
52
52
  }
53
53
  return null;
54
54
  }
55
- /** Checkbox/boolean → boolean (accepts the string "true"). */
55
+ /**
56
+ * Checkbox/boolean → `true` / `false`, or `null` when the cell holds neither —
57
+ * an empty cell, a value of another kind. (The strings "true"/"false" decode.)
58
+ *
59
+ * AN UNANSWERED CHECKBOX IS NOT A "NO", exactly as an empty number cell is not a
60
+ * 0: the field's default is what the workspace writes when a row states
61
+ * something, and a row nobody has answered states nothing. Read as `false` it
62
+ * drew "No" for every partner nobody had assessed — the rows a gate exists to
63
+ * catch are precisely the ones that answer nothing — so the absence is preserved
64
+ * the way `num` and `date` preserve theirs. Write `=== true` where only the
65
+ * affirmative acts, `?? false` where the safe side IS the reading.
66
+ */
56
67
  function bool(v) {
57
- return v === true || v === "true";
68
+ if (v === true || v === "true")
69
+ return true;
70
+ if (v === false || v === "false")
71
+ return false;
72
+ return null;
58
73
  }
59
74
  /**
60
75
  * Date/datetime field → a LOCAL-midnight Date for the stored calendar day, so
@@ -112,17 +127,34 @@ function asLink(v) {
112
127
  * `readLinks` for the full list on multi-link fields.
113
128
  */
114
129
  function link(v) {
115
- if (Array.isArray(v))
116
- return v.length ? asLink(v[0]) : null;
117
- return asLink(v);
130
+ return readLinks(v)[0] ?? null;
118
131
  }
119
- /** select_record_link → ALL linked records as `{ id, display }[]` (empty if none). */
132
+ /**
133
+ * select_record_link → ALL linked records as `{ id, display }[]` (empty if none).
134
+ *
135
+ * A LOOKUP OF A LINK FIELD ARRIVES ONE LEVEL DEEPER — the cell holds one entry
136
+ * per linked record and each entry is THAT record's whole link cell, an array —
137
+ * so read entry-by-entry it decoded to nothing while the value was plainly
138
+ * there. The nesting is flattened here, at the boundary that owns the
139
+ * serialization, rather than at each caller: one level, in order, and a record
140
+ * two linked rows both point at is one link, so ids repeat at most once.
141
+ * Anything else still reads as empty.
142
+ */
120
143
  export function readLinks(v) {
121
- if (!Array.isArray(v)) {
122
- const one = asLink(v);
123
- return one ? [one] : [];
144
+ const cell = Array.isArray(v) ? v : [v];
145
+ const out = [];
146
+ const seen = new Set();
147
+ for (const entry of cell) {
148
+ const held = Array.isArray(entry) ? entry : [entry];
149
+ for (const one of held) {
150
+ const linked = asLink(one);
151
+ if (linked === null || seen.has(linked.id))
152
+ continue;
153
+ seen.add(linked.id);
154
+ out.push(linked);
155
+ }
124
156
  }
125
- return v.map(asLink).filter((x) => x !== null);
157
+ return out;
126
158
  }
127
159
  /**
128
160
  * files field → the attached files with their presigned `url` (empty if none).
@@ -170,7 +202,9 @@ export function readFiles(v) {
170
202
  export function readLocked(rowValue) {
171
203
  if (!rowValue || typeof rowValue !== "object")
172
204
  return false;
173
- return bool(rowValue["__source_locked"]);
205
+ // A row that says NOTHING about its lock is not locked — this is the flag's
206
+ // absence, not a record's unanswered question, so the safe side is the reading.
207
+ return bool(rowValue["__source_locked"]) === true;
174
208
  }
175
209
  /**
176
210
  * The stored record's timestamp on a `useQuery` row, read off the addressing
package/dist/src/rpc.js CHANGED
@@ -568,6 +568,34 @@ export function transportErrorMessage(status, parsed) {
568
568
  parsed.code.length > 0;
569
569
  return status < 500 || authored ? jsonMessage : gatewayErrorMessage(status);
570
570
  }
571
+ /**
572
+ * A failed request, carrying the per-input refusals when the API named any.
573
+ *
574
+ * A body that does not match what an alias declares is answered 400 with
575
+ * top-level `field_errors` — one sentence per INPUT name, which is what a form
576
+ * puts on the control that is wrong, where the message can only say it at the
577
+ * screen's scope. It rides the error because the transport's own callers are
578
+ * what turn a failure into the result an app reads. Optional throughout: a
579
+ * server that predates them sends none.
580
+ */
581
+ class ApiRefusal extends Error {
582
+ field_errors;
583
+ constructor(message, field_errors) {
584
+ super(message);
585
+ this.field_errors = field_errors;
586
+ }
587
+ }
588
+ /** The `field_errors` of an error body, when it carries a well-formed one. */
589
+ function readFieldErrors(parsed) {
590
+ if (parsed === null || typeof parsed !== "object" || !("field_errors" in parsed)) {
591
+ return undefined;
592
+ }
593
+ const raw = parsed.field_errors;
594
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
595
+ return undefined;
596
+ const named = Object.entries(raw).filter((entry) => typeof entry[1] === "string");
597
+ return named.length > 0 ? Object.fromEntries(named) : undefined;
598
+ }
571
599
  /**
572
600
  * The error a stream that never started should throw.
573
601
  *
@@ -663,8 +691,9 @@ async function apiCall(method, path, body, opts) {
663
691
  return new Promise(() => { });
664
692
  }
665
693
  // Never surface a non-JSON body (a gateway HTML error page) or a 5xx body as
666
- // the message — emit a body-free, status-derived message instead.
667
- throw new Error(transportErrorMessage(res.status, parsed));
694
+ // the message — emit a body-free, status-derived message instead. The
695
+ // per-input refusals ride along, for the caller that can place them.
696
+ throw new ApiRefusal(transportErrorMessage(res.status, parsed), readFieldErrors(parsed));
668
697
  }
669
698
  return parsed ?? (text ? text : {});
670
699
  }
@@ -793,7 +822,16 @@ async function standaloneWorkflow(p) {
793
822
  // rejection carrying a raw body) so an app reads `result.status === "error"`
794
823
  // uniformly with a handled workflow error. `apiCall` already sanitized the
795
824
  // message, so it never contains an HTML body.
796
- return { status: "error", message: err instanceof Error ? err.message : "The workflow failed to run." };
825
+ //
826
+ // A payload the alias refuses is that same shape plus the inputs it named,
827
+ // which is the half a form can act on — the same key a workflow's own
828
+ // `return({ field_errors })` arrives under, so a screen wires one control
829
+ // once whichever half answered.
830
+ return {
831
+ status: "error",
832
+ message: err instanceof Error ? err.message : "The workflow failed to run.",
833
+ ...(err instanceof ApiRefusal && err.field_errors ? { field_errors: err.field_errors } : {}),
834
+ };
797
835
  }
798
836
  }
799
837
  async function standaloneAgentRuns(p) {
@@ -435,11 +435,11 @@ the server's answer is the only check — the rule the filter keys follow too.
435
435
  | `row.opt(cell)` | select cell → `string \| null` | first option **key** (accepts the legacy bare-string / `{ id }` shapes); `null` when empty |
436
436
  | `row.text(cell)` | any → `string` | strings pass through, finite numbers stringify, everything else → `""` |
437
437
  | `row.num(cell)` | any → `number \| null` | finite numbers pass, parseable strings parse, everything else (empty cell, NaN/Infinity, unparseable string) → `null` |
438
- | `row.bool(cell)` | any → `boolean` | `true` or the string `"true"`; everything else `false` |
438
+ | `row.bool(cell)` | any → `boolean \| null` | `true`/`false` (the strings decode too); everything else → `null` — an unanswered checkbox is not a "no" |
439
439
  | `row.date(cell)` | date/datetime cell → `Date \| null` | the stored **calendar day** at LOCAL midnight — time stripped; a reduced-precision value decodes to its **period start** |
440
440
  | `row.datetime(cell)` | date/datetime cell → `Date \| null` | local `Date` **keeping the stored wall-clock** (minute precision; seconds are dropped); missing time = midnight; a reduced-precision value decodes to its **period start** |
441
441
  | `row.link(cell)` | link cell → `{ id, display } \| null` | the FIRST linked record |
442
- | `readLinks(cell)` | link cell → `{ id, display }[]` | ALL linked records (`[]` when empty) |
442
+ | `readLinks(cell)` | link cell → `{ id, display }[]` | ALL linked records (`[]` when empty), a LOOKUP of a link field included |
443
443
  | `readSelect(cell)` | select cell → `ResolvedOption[]` | all selected options as `{ key, label }` (`[]` when empty) |
444
444
  | `readMembers(cell)` | member cell → `ResolvedMember[]` | `{ id, name, email?, image?, groups? }[]` (`[]` when empty) |
445
445
  | `readFiles(cell)` | files cell → `AppFile[]` | attached files with presigned URLs (`[]` when empty) |
@@ -484,11 +484,13 @@ never carry avatar images — the avatar lives on the `useMembers` roster
484
484
  ([./members_and_options.md](./members_and_options.md)).
485
485
 
486
486
  **`readLinks` / `row.link`.** `display` is the linked record's primary-field text — render it;
487
- use `id` to correlate, filter (link `has_any_of`), or fetch detail. `row.link` reads the first
488
- entry only — on a multi-link field use `readLinks`. An entry carrying an `id` and **no
487
+ use `id` to correlate, filter (link `has_any_of`), or fetch detail. `row.link` is the first of
488
+ `readLinks` — on a multi-link field use `readLinks`. An entry carrying an `id` and **no
489
489
  `display` key** is not a link and is skipped: a member cell has that shape, so reading one as a
490
490
  link would otherwise render every assignee under a blank name. Read a member cell with
491
- `readMembers`.
491
+ `readMembers`. **A LOOKUP of a link field arrives one level deeper** — one entry per linked
492
+ record, each entry that record's whole link cell — and both readers open it, in order and with a
493
+ record two linked rows point at named once, so a looked-up link needs no unwrapping of its own.
492
494
 
493
495
  **`readFiles`.** Each entry's `url` (and `thumbnail_url` for images) is **presigned with a 24-hour
494
496
  TTL** — it renders directly in an `<Image>`/preview and works from the sandboxed iframe and for
package/docs/files.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Files
2
2
 
3
- Files end to end in a custom-code app: uploading bytes (`useFileUpload`), composer attachments
4
- with instant previews (`useAttachments`), decoding `files` cells from query results (`readFiles` →
3
+ Files end to end in a custom-code app: uploading bytes (`useFileUpload`), an add-queue with
4
+ instant previews (`useAttachments`), decoding `files` cells from query results (`readFiles` →
5
5
  `AppFile`), documents a workflow generates (`WorkflowResult.files`), previewing with `@lotics/ui`,
6
6
  filtering on files fields, and the server-side delivery bounds that decide whether a file-bearing
7
7
  query succeeds at all. Read this before building any screen that shows, collects, or generates
@@ -15,7 +15,8 @@ discipline in [data fetching](./data_fetching.md).
15
15
  | Collect bytes from the visitor | `useFileUpload().upload(file)` | `UploadedFile` — a stored, **unattached** file id + presigned serving URLs |
16
16
  | Collect several with live previews | `useAttachments()` | `AttachedFile[]` with instant local previews; `fileIds` when done |
17
17
 
18
- `useAttachments().add(files, { fidelity })` takes the same `fidelity` as `upload`.
18
+ `useAttachments().add(files, { fidelity })` and `.attach(files, { fidelity })` take the same
19
+ `fidelity` as `upload`.
19
20
  | Attach to a record | a declared workflow with a `{ type: "file" }` input | the workflow writes the id(s) into a `files` field — the **only** write path |
20
21
  | Read back from records | `useQuery` + `readFiles(cell)` | `AppFile[]` — presigned `url`/`thumbnail_url` (24 h) + `size`/`created_at` |
21
22
  | Receive a generated document | `useWorkflow` → `WorkflowResult.files` | presigned files auto-extracted from the run |
@@ -122,21 +123,27 @@ For **agent** aliases the same `file` inputs are materialized into the model's v
122
123
  become image parts, PDFs file parts; other types stay an id the agent opens with its own tools.
123
124
  Pair `useFileUpload` with `useAgentRun` for photo→extraction flows — see [ai](./ai.md).
124
125
 
125
- ## Composer attachments — `useAttachments`
126
+ ## Attachments — `useAttachments`
126
127
 
127
128
  The optimistic-preview UX in one hook: a local object-URL preview shows the *instant* a file is
128
129
  added, the upload runs in the background (through the same `useFileUpload` pipeline, image
129
130
  optimization included), and the stored `file_id` lands when it completes. Don't hand-roll the
130
- `createObjectURL` → upload → id → revoke lifecycle per app.
131
+ `createObjectURL` -> upload -> id -> revoke lifecycle per app.
132
+
133
+ **Which lifecycle is the one decision.** A composer accumulates and `clear()`s when it sends. A
134
+ record's section hands its write the ids and then passes what the row now holds as `landed` — each
135
+ entry leaves the queue, and its preview URL is revoked, exactly as the stored pile takes it over,
136
+ so nothing blinks out between the two.
131
137
 
132
138
  ```tsx
139
+ // A COMPOSER — accumulate, send, clear.
133
140
  const { files, add, remove, clear, uploading, fileIds } = useAttachments();
134
141
  const design = useWorkflow("design");
135
142
 
136
143
  // attach: picking is the app's choice — button, paste, or drop
137
144
  <Button icon="paperclip" onPress={() => pickFiles({ accept: "image/*" }).then(add)} />
138
145
 
139
- // preview: map each AttachedFile to a @lotics/ui DisplayFile (snake → camel)
146
+ // preview: map each AttachedFile to a @lotics/ui DisplayFile (snake -> camel)
140
147
  {files.map((f) => (
141
148
  <FileThumbnail
142
149
  key={f.id}
@@ -151,23 +158,50 @@ await design({ photos: fileIds }); // multi:true input
151
158
  clear();
152
159
  ```
153
160
 
154
- Contract (`dist/src/hooks.d.ts`):
161
+ ```tsx
162
+ // A RECORD'S SECTION — attach, write, hand over to the row.
163
+ const held = readFiles(r.photo);
164
+ const queue = useAttachments({ landed: held.map((file) => file.id) });
165
+ const save = useWorkflow("update_item");
166
+
167
+ const onAdd = async (picked: File[]) => {
168
+ if (picked.length === 0) return; // a cancelled pick is not a write
169
+ const stored = await queue.attach(picked); // answers when every byte is up
170
+ await save({ item_id: r.__source_record_id, photo_added: stored });
171
+ };
172
+
173
+ <RecordFiles
174
+ files={held.map(toDisplayFile)}
175
+ uploads={queue.files.map((f) => ({
176
+ id: f.id,
177
+ filename: f.filename,
178
+ mimeType: f.mime_type,
179
+ previewUrl: f.preview_url,
180
+ status: f.status === "error" ? "error" : "uploading",
181
+ }))}
182
+ onAdd={(picked) => { void onAdd(picked).catch(report); }}
183
+ />
184
+ ```
185
+
186
+ Contract (`dist/src/attachments.d.ts`):
155
187
 
156
188
  | Member | Behavior |
157
189
  |---|---|
158
190
  | `files: AttachedFile[]` | Current attachments, in the order added |
159
- | `add(files: File[])` | Each file shows its local preview at once and uploads in the background |
191
+ | `add(files: File[])` | Each file shows its local preview at once and uploads in the background. Never rejects — a failure is the entry's `status` |
192
+ | `attach(files: File[])` | The same add, resolved with the stored ids once **every** one is up. Rejects with the first refusal rather than answering short |
160
193
  | `remove(id)` | Removes one attachment and revokes its preview object-URL |
161
194
  | `clear()` | Removes all and revokes every preview URL |
162
195
  | `uploading` | True while any attachment is still uploading — gate Send on it |
163
196
  | `fileIds: string[]` | Stored file ids of the **completed** uploads — the workflow/agent payload |
197
+ | `useAttachments({ landed })` | Stored ids the destination now holds; each matching entry leaves the queue and its preview is revoked |
164
198
 
165
199
  Each `AttachedFile` is `{ id, filename, mime_type, preview_url, status, file_id? }`:
166
200
 
167
201
  - `id` — a stable *local* id (the React key and the `remove(id)` handle), **not** the stored file
168
202
  id.
169
203
  - `preview_url` — a local object-URL, available before the upload finishes. Revoked on
170
- remove/clear — don't hold it past the attachment's lifetime.
204
+ remove/clear/`landed` — don't hold it past the attachment's lifetime.
171
205
  - `status` — `"uploading" | "ready" | "error"`. A failed upload stays in `files` with
172
206
  `status: "error"` (and no `file_id`); there is no auto-retry — offer remove + re-add.
173
207
  - `file_id` — the stored id, set once `status` is `"ready"`. `fileIds` includes only ready
@@ -175,10 +209,11 @@ Each `AttachedFile` is `{ id, filename, mime_type, preview_url, status, file_id?
175
209
 
176
210
  Wiring to `@lotics/ui`: in a `Composer`, trigger picking from `actionsButton` (via `pickFiles`),
177
211
  render the attachment pills with `FileThumbnail` as above, and gate `sendDisabled` on `uploading`.
178
- For a full add-files *screen* (not a composer pill), map each `AttachedFile` to a
179
- `FileThumbnailGrid` `FileUpload` entry — ready → `{ status: "complete", id: file_id, file:
180
- <DisplayFile> }`, else `{ status, id, filename, mimeType: mime_type, previewUrl: preview_url }`
181
- — and the grid renders the uploading/error/retry tiles itself.
212
+ A record's section passes the queue to `RecordFiles` `uploads` (a `PendingUpload` each) so the
213
+ picked file and the stored pile stand in one grid. For a full add-files *screen*, map each
214
+ `AttachedFile` to a `FileThumbnailGrid` `FileUpload` entry — ready -> `{ status: "complete", id:
215
+ file_id, file: <DisplayFile> }`, else `{ status, id, filename, mimeType: mime_type, previewUrl:
216
+ preview_url }` — and the grid renders the uploading/error/retry tiles itself.
182
217
 
183
218
  ## File cells in query results — `readFiles` and `AppFile`
184
219
 
package/docs/mutations.md CHANGED
@@ -65,7 +65,7 @@ Every call resolves to a `WorkflowResult<TData>` (type exported from the package
65
65
  | `message` | `string?` | The workflow's `return({ message })` text, a validation/binding error, or a body-free transport message |
66
66
  | `files` | `UploadedFile[]?` | Files generated during the run (auto-collected — below). Absent when the run generated none |
67
67
  | `data` | `TData?` | Structured data from `return({ data })`. Absent when no return step ran |
68
- | `field_errors` | `Record<string, string>?` | Per-input refusals from `return({ field_errors })`, keyed by the INPUT name the alias declares. Absent when the workflow returned none |
68
+ | `field_errors` | `Record<string, string>?` | Per-input refusals, keyed by the INPUT name the alias declares — from the server when the payload does not match what the alias declares, from the workflow's own `return({ field_errors })`, or from a failing `validate` check's `field_key`. Absent when none named a field |
69
69
 
70
70
  ### The failure model: check `status`, never just try/catch
71
71
 
@@ -111,8 +111,10 @@ passes through unvalidated).
111
111
 
112
112
  ### Locating a refusal: `field_errors`
113
113
 
114
- A workflow that validates its own inputs refuses with both halves — a message for the dialog and a
115
- map for the controls:
114
+ Two things refuse a call, and both address the input they refused. A payload that does not match
115
+ what the alias declares — a missing required input, a value of the wrong type — is refused before
116
+ the workflow runs, with one sentence per input name. A workflow that validates its own inputs
117
+ refuses the same way, with both halves — a message for the dialog and a map for the controls:
116
118
 
117
119
  ```ts
118
120
  return({ status: "error", message: "Thiếu lý do.",
@@ -130,7 +132,13 @@ setErrs(result.field_errors ?? {});
130
132
  ```
131
133
 
132
134
  Keep `message` too — it is what a refusal with no field to blame says, and the two are shown
133
- together (a `Callout` at the dialog's scope plus the per-field text).
135
+ together (a `Callout` at the dialog's scope plus the per-field text). One reading covers both
136
+ refusals: the key is the same whichever half answered, so a form never branches on that.
137
+
138
+ A failing `validate` check fills the same map from its `field_key`, so the key there is the INPUT
139
+ name as well — `field_key: "ly_do"`, never the `fld_` key of whatever column the check happened
140
+ to read ([workflows](./workflows.md) § `validate`). Nothing at run time corrects a key that names
141
+ neither, so the refusal arrives and marks no control.
134
142
 
135
143
  ### Generated files come back in `files[]`
136
144
 
@@ -452,7 +460,7 @@ The same trap one level up: **an action takes an ID, never a captured object.**
452
460
  happened, so no amount of waiting refreshes it — the confirm quotes, and the mint bills, the
453
461
  pre-edit total. Pass the key and resolve at use (`onPress={() => setConfirmKey(invoice.key)}`,
454
462
  then re-read or `find` where it is consumed). `@lotics/ui` gates the press itself
455
- (`docs/data_entry.md` § Inline edit), which fixes the ordering; the captured value is the app's
463
+ (`docs/data_entry.md` § A field that saves itself), which fixes the ordering; the captured value is the app's
456
464
  to get right.
457
465
 
458
466
  ## Diff before update — send only what changed
package/docs/recipes.md CHANGED
@@ -118,7 +118,9 @@ A query cell is `unknown` with a per-type serialized shape:
118
118
 
119
119
  - `row.opt` / `row.text` / `row.num` / `row.bool` / `row.date` — scalars and the first value of a
120
120
  select. `row.date` keeps only the calendar day; use **`row.datetime`** when the time matters.
121
- `row.num` answers `null` for an empty cell — write `?? 0` only where zero is its correct reading.
121
+ `row.num` and `row.bool` answer `null` for an empty cell — an unanswered checkbox is not a "no"
122
+ any more than an empty number cell is a 0. Write `=== true` where only the affirmative acts, and
123
+ `?? 0` / `?? false` only where that side is the cell's correct reading.
122
124
  - `readSelect` — the full `{ key, label }[]` of a multi-select.
123
125
  - `readMembers` — `select_member` cells.
124
126
  - `row.link` / `readLinks` — `select_record_link` → `{ id, display }`. Read `.display` to render,
package/docs/workflows.md CHANGED
@@ -203,9 +203,11 @@ return({ status: "success", message: "Order created.", data: { total: subtotal }
203
203
  expression). Both `field_errors` and `data` are optional.
204
204
  - `data` is what the app reads back as `result.data`; the alias's `outputs` schema is derived at
205
205
  save from its inferred type. Full contract: [mutations](./mutations.md) § "Structured results".
206
- - `field_errors` annotates a *rejected record write* — it is consumed by the table-workflow
207
- surface for cell-level error rendering. An app-invoked workflow surfaces only `status`,
208
- `message`, `data`, and `files`, so put anything the app must read under `data`.
206
+ - `field_errors` maps a key to a message and reaches the app on `result.field_errors`, so a
207
+ form draws each one against the control it names. **The key names the control on the surface
208
+ that renders it**: for an app workflow that is the alias's declared INPUT name, for a
209
+ table-triggered one it is the record's `fld_` field key. The runtime keys the map by whatever
210
+ string you write, so a key naming neither reaches the dialog and lands on nothing.
209
211
  - A body that completes without hitting a `return` resolves `status: "success"` with no `data`.
210
212
  - Generated documents never travel through `data`; they are collected into `result.files[]`
211
213
  automatically ([files](./files.md)).
@@ -215,14 +217,22 @@ return({ status: "success", message: "Order created.", data: { total: subtotal }
215
217
  ```js
216
218
  validate({ checks: [{
217
219
  fail_when: size(dup.records) > 0,
218
- field_key: "fld_order_code",
220
+ field_key: "order_code",
219
221
  message: "This order code already exists.",
220
222
  }]});
221
223
  ```
222
224
 
223
- `fail_when` rejects when truthy. `field_key` is an optional literal `fld_*`. A failing check ends
224
- the run as `status: "error"` with the failing messages joined — cleanly, not as a crash: it is
225
- control flow, so **`try`/`catch` does not catch it** (nor `return`).
225
+ `fail_when` rejects when truthy. A failing check ends the run as `status: "error"` with the
226
+ failing messages joined — cleanly, not as a crash: it is control flow, so **`try`/`catch` does
227
+ not catch it** (nor `return`).
228
+
229
+ `field_key` is optional, a string literal, and carries the same key `return({ field_errors })`
230
+ does: **the control the message belongs to, on the surface that renders it** — an app workflow's
231
+ declared INPUT name (`order_code`, the key under `lotics.workflows.<alias>.inputs`), a
232
+ table-triggered workflow's `fld_` field key. Each failing check that names one contributes an
233
+ entry to `field_errors`, so the app's form marks that control instead of only the dialog.
234
+ A key naming neither is refused when the body is SAVED — by `lotics app workflow set`, by an
235
+ agent binding one, by a table workflow's save — and by `lotics app workflow check` before that.
226
236
 
227
237
  ### `wait_for_approval` — the one wait worth binding
228
238
 
@@ -870,7 +880,8 @@ const dup = await query_records({
870
880
 
871
881
  validate({ checks: [{
872
882
  fail_when: size(dup.records) > 0,
873
- field_key: "fld_order_code",
883
+ // The INPUT name, not the field key: this is what the app's form marks.
884
+ field_key: "order_code",
874
885
  message: `Order code ${i.order_code} already exists.`,
875
886
  }]});
876
887
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.93.0",
3
+ "version": "0.95.0",
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": {