@lotics/app-sdk 0.94.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 +2 -2
- package/dist/src/attachments.d.ts +68 -0
- package/dist/src/attachments.js +93 -0
- package/dist/src/hooks.d.ts +13 -38
- package/dist/src/hooks.js +14 -40
- package/dist/src/index.d.ts +2 -1
- package/dist/src/row.d.ts +24 -3
- package/dist/src/row.js +45 -11
- package/docs/data_fetching.md +7 -5
- package/docs/files.md +48 -13
- package/docs/mutations.md +7 -2
- package/docs/recipes.md +3 -1
- package/docs/workflows.md +19 -8
- package/package.json +1 -1
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`
|
|
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
|
|
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
|
+
}
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
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
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
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
|
-
|
|
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
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
29
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
return v.length ? asLink(v[0]) : null;
|
|
117
|
-
return asLink(v);
|
|
130
|
+
return readLinks(v)[0] ?? null;
|
|
118
131
|
}
|
|
119
|
-
/**
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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
|
-
|
|
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/docs/data_fetching.md
CHANGED
|
@@ -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`
|
|
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`
|
|
488
|
-
|
|
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`),
|
|
4
|
-
|
|
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 })`
|
|
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
|
-
##
|
|
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`
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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, keyed by the INPUT name the alias declares — from the server when the payload does not match what the alias declares,
|
|
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` §
|
|
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`
|
|
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`
|
|
207
|
-
|
|
208
|
-
|
|
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: "
|
|
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.
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
|