@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 +3 -3
- 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 +4 -1
- package/dist/src/index.js +1 -0
- package/dist/src/lifecycle_history.d.ts +26 -0
- package/dist/src/lifecycle_history.js +41 -0
- package/dist/src/row.d.ts +24 -3
- package/dist/src/row.js +45 -11
- package/dist/src/rpc.d.ts +1 -1
- package/dist/src/rpc.js +13 -0
- package/docs/data_fetching.md +7 -5
- package/docs/files.md +48 -13
- package/docs/members_and_options.md +44 -4
- package/docs/mutations.md +7 -2
- package/docs/recipes.md +3 -1
- package/docs/security.md +2 -0
- package/docs/workflows.md +19 -8
- package/package.json +1 -1
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`
|
|
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
|
|
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
|
+
}
|
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,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,
|
|
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
|
-
/**
|
|
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/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)}` : "";
|
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
|
|
|
@@ -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`),
|
|
6
|
-
(`useComments`, `useCommentCounts`), and the `@lotics/ui`
|
|
7
|
-
building an assign picker, a colored `Status` mark, 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,
|
|
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/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`
|
|
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
|
|