@lotics/app-sdk 0.94.0 → 0.96.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,11 +16,11 @@ 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. |
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. |
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
+ | [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + stage dates + comments — `useMembers`, `useFieldOptions` (keyed by the alias's OWN columns, every key optional), `useViewer`, `useLifecycleHistory` (option id → the instant the row most recently entered it, folded server-side from the record's audit trail; an option never entered is absent), `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. |
26
26
  | [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, what runtime refinement cannot widen, and why a per-input bound is a tenancy floor rather than an authorization check (a caller-supplied id must be intersected with the record server-side). |
@@ -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";
@@ -550,44 +551,16 @@ interface FileUploadState {
550
551
  * ```
551
552
  */
552
553
  export declare function useFileUpload(): FileUploadState;
553
- /** A file attached to a composer — its local preview is available immediately,
554
- * its stored `file_id` once the background upload completes. */
555
- export interface AttachedFile {
556
- /** Stable local id — the React key and the `remove(id)` handle. */
557
- id: string;
558
- filename: string;
559
- mime_type: string;
560
- /** Local object-URL preview, available the INSTANT the file is added (before upload). */
561
- preview_url: string;
562
- status: "uploading" | "ready" | "error";
563
- /** The stored file id, set once `status` is `"ready"` — pass to a workflow/agent. */
564
- file_id?: string;
565
- }
566
- interface AttachmentsState {
567
- /** The current attachments, in the order added. */
568
- files: AttachedFile[];
569
- /** Add picked/pasted/dropped files — each shows its local preview at once and
570
- * uploads in the background. Picking is the app's choice: wire a button to
571
- * `@lotics/ui` `pickFiles`, or a paste/drop handler, then call this. */
572
- add: (files: File[], options?: {
573
- fidelity?: ImageFidelity;
574
- }) => void;
575
- /** Remove one attachment and revoke its preview URL. */
576
- remove: (id: string) => void;
577
- /** Remove all attachments and revoke their preview URLs. */
578
- clear: () => void;
579
- /** True while any attachment is still uploading — gate Send on it. */
580
- uploading: boolean;
581
- /** Stored file ids of the completed uploads — the workflow/agent payload. */
582
- fileIds: string[];
583
- }
584
554
  /**
585
- * Composer attachments with the chat's optimistic-preview UX: a local object-URL
586
- * preview shows the INSTANT a file is added, the upload runs in the background,
587
- * and the stored `file_id` lands in `files` when it completes. Previews are
588
- * revoked on remove/clear. Picking is the app's (button / paste / drop) — pass
589
- * the resulting `File[]` to `add`; the hook owns the preview → upload → id →
590
- * 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.
591
564
  *
592
565
  * ```tsx
593
566
  * const { files, add, remove, clear, uploading, fileIds } = useAttachments();
@@ -602,8 +575,10 @@ interface AttachmentsState {
602
575
  * // ))
603
576
  * // send: design({ photo: fileIds[0] }); clear();
604
577
  * ```
578
+ *
579
+ * The queue is `useAttachmentQueue`, bound here to the app's own upload.
605
580
  */
606
- export declare function useAttachments(): AttachmentsState;
581
+ export declare function useAttachments(options?: AttachmentsOptions): AttachmentsState;
607
582
  /**
608
583
  * Publish a slice of the CURRENT SCREEN's view state to the app's ambient chat
609
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,9 +16,12 @@
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";
23
+ export { useLifecycleHistory } from "./lifecycle_history.js";
24
+ export type { LifecycleHistory, LifecycleHistoryArgs } from "./lifecycle_history.js";
22
25
  export { useViewer } from "./viewer.js";
23
26
  export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
24
27
  export type { GeofenceZone, GeoCoords, GeofenceOutcome, GeofenceOptions } from "./geolocation.js";
package/dist/src/index.js CHANGED
@@ -16,6 +16,7 @@
16
16
  export { mount } from "./mount.js";
17
17
  export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useCount, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, buildChoiceOutput, } from "./hooks.js";
18
18
  export { useComments, useCommentCounts } from "./comments.js";
19
+ export { useLifecycleHistory } from "./lifecycle_history.js";
19
20
  export { useViewer } from "./viewer.js";
20
21
  export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
21
22
  export { rpc, isEmbedded } from "./rpc.js";
@@ -0,0 +1,26 @@
1
+ export interface LifecycleHistoryArgs {
2
+ /** The table the record lives in. */
3
+ table_id: string;
4
+ /** The record whose walk to date. */
5
+ record_id: string;
6
+ /** The `fld_` id of the lifecycle select. */
7
+ field_id: string;
8
+ }
9
+ export interface LifecycleHistory {
10
+ /**
11
+ * Option id → the ISO 8601 instant the row MOST RECENTLY entered it. The
12
+ * stage the row is on now is in here too; an option it never entered is
13
+ * absent rather than present with an empty value, so a caller reads "no date"
14
+ * as "never been there".
15
+ */
16
+ entered: ReadonlyMap<string, string>;
17
+ /** True on the first load only — false during a background revalidation. */
18
+ loading: boolean;
19
+ /**
20
+ * Why the read failed, or `null`. An undated strip and a strip whose dates
21
+ * could not be read look identical, so the caller is told which it is holding
22
+ * rather than drawing "never entered" over a failure.
23
+ */
24
+ error: string | null;
25
+ }
26
+ export declare function useLifecycleHistory(args: LifecycleHistoryArgs): LifecycleHistory;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * `useLifecycleHistory` — when the row entered each stage.
3
+ *
4
+ * A pipeline drawn as steps has to say when each step happened, and the record
5
+ * carries only where it IS. The platform is the one that knows: every write to
6
+ * a record lands an audit row carrying the field diff, so the instants are
7
+ * derived server-side and arrive already folded — one entry per option the row
8
+ * entered, dated by the MOST RECENT entry into it.
9
+ *
10
+ * The field is a `select`, which is what a lifecycle is, and the keys of
11
+ * `entered` are its `opt_` ids — never a rendered label, so a renamed option
12
+ * keeps its date.
13
+ *
14
+ * SWR-cached like every other read hook: keyed by (table, record, field), so
15
+ * two steps of the same lifecycle share one fetch and the cache survives a
16
+ * remount. There is no realtime channel for an audit row, so a stage moved in
17
+ * another tab appears on the next focus or after the write that moved it
18
+ * invalidates this app's queries.
19
+ */
20
+ import { useMemo } from "react";
21
+ import useSWR from "swr";
22
+ import { rpc } from "./rpc.js";
23
+ const NOTHING = new Map();
24
+ export function useLifecycleHistory(args) {
25
+ const { table_id, record_id, field_id } = args;
26
+ // Every part of the address has to be real: a hook cannot be called
27
+ // conditionally, and a blank id would fetch the history of nothing and cache
28
+ // the empty answer under a key a real id later reads.
29
+ const addressed = table_id !== "" && record_id !== "" && field_id !== "";
30
+ const swr = useSWR(addressed ? ["app-field-history", table_id, record_id, field_id] : null, () => rpc("field_history", { table_id, record_id, field_id }), { shouldRetryOnError: false });
31
+ const entered = useMemo(() => {
32
+ if (!swr.data)
33
+ return NOTHING;
34
+ return new Map(swr.data.entered.map((entry) => [entry.option_id, entry.entered_at]));
35
+ }, [swr.data]);
36
+ return {
37
+ entered,
38
+ loading: addressed && swr.data === undefined && swr.error === undefined,
39
+ error: swr.error ? swr.error.message : null,
40
+ };
41
+ }
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.d.ts CHANGED
@@ -21,7 +21,7 @@ import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
21
21
  * app → host: { id, op, payload }
22
22
  * host → app: { id, type: "result", data } | { id, type: "error", message }
23
23
  */
24
- export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "openApp" | "askAi" | "urlState.get" | "urlState.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
24
+ export type RpcOp = "query" | "field_options" | "field_history" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "openApp" | "askAi" | "urlState.get" | "urlState.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
25
25
  /** Payload for starting a streaming agent run. */
26
26
  export interface AgentRunPayload {
27
27
  alias: string;
package/dist/src/rpc.js CHANGED
@@ -703,6 +703,8 @@ function rpcStandalone(op, payload) {
703
703
  return standaloneQuery(payload);
704
704
  case "field_options":
705
705
  return standaloneFieldOptions(payload);
706
+ case "field_history":
707
+ return standaloneFieldHistory(payload);
706
708
  case "workflow":
707
709
  return standaloneWorkflow(payload);
708
710
  case "agentRuns":
@@ -751,6 +753,17 @@ function rpcStandalone(op, payload) {
751
753
  function rejectCommentsStandalone() {
752
754
  return Promise.reject(new Error("Comments are available only in embedded apps — a signed-in member is required."));
753
755
  }
756
+ /**
757
+ * When the row entered each stage — the public-app half of `field_history`.
758
+ * The endpoint is app-authority and `publicAppAccess`, so an anonymous visitor
759
+ * of a shared app reads exactly the dates its own queries already earn it.
760
+ */
761
+ async function standaloneFieldHistory(p) {
762
+ const { app_id } = await boot();
763
+ const qs = new URLSearchParams({ table_id: p.table_id, field_id: p.field_id });
764
+ const r = (await apiCall("GET", `/v1/apps/${app_id}/records/${encodeURIComponent(p.record_id)}/field-history?${qs.toString()}`, undefined, { appId: app_id }));
765
+ return { entered: r.entered ?? [] };
766
+ }
754
767
  async function standaloneMembers(p) {
755
768
  const { app_id } = await boot();
756
769
  const qs = p.group ? `?group_id=${encodeURIComponent(p.group)}` : "";
@@ -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
 
@@ -2,10 +2,10 @@
2
2
 
3
3
  How an app renders and picks **people** and **select-field options**, plus the **record comments**
4
4
  surface. Covers the two cell readers (`readSelect`, `readMembers`), the two catalog hooks
5
- (`useFieldOptions`, `useMembers`), the viewer identity hook (`useViewer`), comments
6
- (`useComments`, `useCommentCounts`), and the `@lotics/ui` components they feed. Read this before
7
- building an assign picker, a colored `Status` mark, a per-viewer ("my records") screen, or a
8
- comment thread. Query mechanics live in [queries](./queries.md); the authority model in
5
+ (`useFieldOptions`, `useMembers`), the viewer identity hook (`useViewer`), when a row entered each
6
+ stage (`useLifecycleHistory`), comments (`useComments`, `useCommentCounts`), and the `@lotics/ui`
7
+ components they feed. Read this before building an assign picker, a colored `Status` mark, a
8
+ pipeline with dates against its steps, a per-viewer ("my records") screen, or a comment thread. Query mechanics live in [queries](./queries.md); the authority model in
9
9
  [security](./security.md).
10
10
 
11
11
  ## Cells vs. catalogs — the model
@@ -18,6 +18,7 @@ Every select and member value reaches the app in one of two shapes, and most scr
18
18
  | Populate a select picker, or color a stored value | `useFieldOptions(alias)` | Each select column's **complete** option list — `{ key, label, color }` — plus a `byKey` index |
19
19
  | Render a stored member value | `readMembers(cell)` | The members the record actually holds |
20
20
  | Populate a member picker (assign UIs) | `useMembers(opts?)` | The org roster — every member, not only the referenced ones |
21
+ | Date each step of a pipeline | `useLifecycleHistory(args)` | When the row entered each option — `Map<option id, ISO instant>` |
21
22
 
22
23
  Cells are **self-describing**: the server rewrites raw storage shapes into resolved objects before
23
24
  rows reach the app, so an app never maintains a hardcoded key→label or id→name map. Catalogs are
@@ -237,6 +238,45 @@ template via the `is_current_member` filter operator — the server binds the sa
237
238
  view-as) with nothing client-supplied to spoof. Write attribution belongs server-side in the
238
239
  workflow body (`runtime.triggered_by_member_id`). Full model: [security](./security.md).
239
240
 
241
+ ## When the row entered each stage: `useLifecycleHistory`
242
+
243
+ A pipeline drawn as steps has to say **when** each step happened, and the record carries only where
244
+ it is. The platform is the one that knows: every write to a record lands an audit entry carrying the
245
+ field diff, so the instants are folded server-side and arrive ready to render.
246
+
247
+ ```tsx
248
+ const { entered, loading, error } = useLifecycleHistory({
249
+ table_id: row.__source_table_id, // the record's table
250
+ record_id: row.__source_record_id, // the record itself
251
+ field_id: "fld_stage", // the lifecycle select
252
+ });
253
+
254
+ <Step label={option.label} at={entered.get(option.key)} />
255
+ ```
256
+
257
+ - `entered` is `ReadonlyMap<option id, ISO 8601 instant>`. The stage the row is on **now** is in
258
+ there too. An option the row never entered is **absent** — read "no date" as "never been there",
259
+ and never as "not loaded yet" (that is `loading`).
260
+ - The instant is the **most recent** entry into that option, so a row that left a stage and came
261
+ back is dated by the return. A write that re-states the stage the row is already on changes
262
+ nothing and dates nothing; on a multi-value select, adding an option does not re-date the ones the
263
+ cell kept.
264
+ - Keyed by `opt_` ids, so a renamed option keeps its date.
265
+ - `field_id` names a **select** — the only field whose values are option ids. Anything else is
266
+ refused.
267
+ - Every part of the address must be a **real** id; an empty string fetches nothing and answers an
268
+ empty map. Narrow `__source_record_id` / `__source_table_id` first — a grouped query emits
269
+ neither.
270
+ - The authority IS a declared query: before it answers, the server runs one of the app's own
271
+ declarations over that table, narrowed to this record, and refuses unless it comes back. A table
272
+ none of them reads is refused; so is a row their filters or the table's row rule exclude. No
273
+ member table grant is needed, and none is a way in.
274
+ - `error` carries why a read failed, as the SDK's other hooks do. An empty `entered` alone cannot
275
+ say it — draw the failure rather than an undated strip.
276
+ - **Freshness:** SWR-cached, keyed by (table, record, field) — two steps of one lifecycle buy one
277
+ read. An audit entry emits no record event, so a stage moved elsewhere appears after this app's
278
+ own write (which re-reads every mounted query) or on the next focus.
279
+
240
280
  ## Comments: `useComments` / `useCommentCounts`
241
281
 
242
282
  Record comments — member-to-member discussion attached to any record the app reaches
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, keyed by the INPUT name the alias declares — from the server when the payload does not match what the alias declares, and from the workflow's own `return({ field_errors })`. Absent when neither named a field |
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
 
@@ -135,6 +135,11 @@ Keep `message` too — it is what a refusal with no field to blame says, and the
135
135
  together (a `Callout` at the dialog's scope plus the per-field text). One reading covers both
136
136
  refusals: the key is the same whichever half answered, so a form never branches on that.
137
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.
142
+
138
143
  ### Generated files come back in `files[]`
139
144
 
140
145
  Any step in the run whose tool output carries a `file_id` (most commonly the
@@ -455,7 +460,7 @@ The same trap one level up: **an action takes an ID, never a captured object.**
455
460
  happened, so no amount of waiting refreshes it — the confirm quotes, and the mint bills, the
456
461
  pre-edit total. Pass the key and resolve at use (`onPress={() => setConfirmKey(invoice.key)}`,
457
462
  then re-read or `find` where it is consumed). `@lotics/ui` gates the press itself
458
- (`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
459
464
  to get right.
460
465
 
461
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/security.md CHANGED
@@ -9,6 +9,7 @@ Every data operation an app performs — queries, workflows, agent runs — exec
9
9
  | Named queries (`useQuery`, the query RPC) | App owner | Yes — bound server-side into `is_current_member` / `current_member` filter predicates |
10
10
  | Workflows (`useWorkflow`) | App owner | Yes — `runtime.triggered_by_member_id` in the workflow body (`null` for anonymous) |
11
11
  | Agent runs (`useAgentRun`) | App owner | Yes — requires an authenticated member; runs are private to that member |
12
+ | Stage history (`useLifecycleHistory`) | App owner — proven by running a declared query narrowed to that record, so reach is the query surface's | Only as a declared query's own `is_current_member` resolves it |
12
13
  | Comments (`useComments`) | App authority for **access**; the **author** is always the real member | Always — members-only, anonymous callers are rejected |
13
14
 
14
15
  Consequences of owner authority:
@@ -98,6 +99,7 @@ A publicly-shared app (its own origin, or its public link) is reachable by **any
98
99
  | File upload (workflow `file` inputs) | Yes — bounded to the app's workspace |
99
100
  | Query/workflow file outputs | Yes — file cells and workflow-produced files return direct presigned URLs (24-hour TTL) that anonymous viewers can fetch; see [files](./files.md) |
100
101
  | Agent runs (`useAgentRun`) | **No** — rejected: agent runs require an authenticated member, and each member's run history is private to them (a guessed session id cannot read another member's thread) |
102
+ | Stage history (`useLifecycleHistory`) | Yes — for a row a declared query hands back, which is the same IDOR surface as the query itself |
101
103
  | Comments (`useComments`) | **No** — members-only |
102
104
  | Member roster (`useMembers`) | **No** — same-org members only (below) |
103
105
 
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.94.0",
3
+ "version": "0.96.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": {