@lotics/app-sdk 0.90.3 → 0.91.1
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 +4 -4
- package/dist/src/comments.d.ts +21 -0
- package/dist/src/hooks.d.ts +18 -0
- package/dist/src/hooks.js +27 -5
- package/dist/src/index.d.ts +3 -3
- package/dist/src/index.js +1 -1
- package/dist/src/mock.d.ts +34 -10
- package/dist/src/mock.js +28 -10
- package/dist/src/router.d.ts +14 -1
- package/dist/src/router.js +69 -4
- package/dist/src/row.d.ts +19 -2
- package/dist/src/row.js +41 -4
- package/dist/src/rpc.js +9 -3
- package/docs/ai.md +1 -1
- package/docs/data_fetching.md +38 -8
- package/docs/members_and_options.md +10 -6
- package/docs/mutations.md +42 -10
- package/docs/navigation_and_state.md +22 -0
- package/docs/queries.md +7 -1
- package/docs/recipes.md +1 -0
- package/docs/runtime.md +15 -0
- package/docs/workflows.md +1 -1
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -16,15 +16,15 @@ 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
|
|
20
|
-
| [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract, 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). |
|
|
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 `QueryRow` shape (projected columns `unknown`; `__source_record_id`/`__source_table_id` typed but optional), runtime `sort`/`filter` keys typed against the query's own projection (`AppQueryColumns`, `ColumnKeyOf`), cell readers (`row.*` — `row.num` answers `null` for an EMPTY cell, so none is distinguishable from zero — `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`, and `readCreatedAt`/`readUpdatedAt` for the record timestamps every row-level result carries), `useFieldOptions`, 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
|
+
| [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
22
|
| [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs (**a bearer credential for the bytes** — never logged, reported, or persisted), workflow-generated files, **naming a zip's entries** (`{ id, name }` per file — a file name, never a path), preview pairing, filter operators, the server-side delivery bounds. **Uploads declare a `fidelity`** (`standard` / `high` / `original`) — the app picks how much of the image survives storage; use `high` whenever text must stay legible. |
|
|
23
|
-
| [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions`, `useViewer`, `useComments`, and the `@lotics/ui` components they feed. |
|
|
23
|
+
| [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions`, `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. |
|
|
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). |
|
|
27
|
-
| [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, **the API address a standalone bundle reads out of its own page** (`<meta name="lotics-api-base">`, declared by whatever serves the app — no address is compiled into the SDK, so one bundle runs on any instance and a page without it refuses rather than guessing), `rpc()`, the design-time mock harness (`fixture` + `?__mock=1` — queries AND workflows, so an AI screen's in-flight/done/error states are reviewable without running or paying for anything), `openExternal`/`openApp`/`downloadFile`, geofencing, and the publish chain for SDK contributors. |
|
|
27
|
+
| [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, **the API address a standalone bundle reads out of its own page** (`<meta name="lotics-api-base">`, declared by whatever serves the app — no address is compiled into the SDK, so one bundle runs on any instance and a page without it refuses rather than guessing), `rpc()`, the design-time mock harness (`fixture` + `?__mock=1` — queries AND workflows, so an AI screen's in-flight/done/error states are reviewable without running or paying for anything; a fixture is rows or a FUNCTION of the call, and only the function form reproduces a per-call `filter`), `openExternal`/`openApp`/`downloadFile`, geofencing, and the publish chain for SDK contributors. |
|
|
28
28
|
|
|
29
29
|
## Non-negotiables (each detailed in its doc)
|
|
30
30
|
|
package/dist/src/comments.d.ts
CHANGED
|
@@ -7,6 +7,14 @@ export interface AppCommentFile {
|
|
|
7
7
|
thumbnail_url?: string;
|
|
8
8
|
preview_url?: string;
|
|
9
9
|
}
|
|
10
|
+
/** A comment author's display identity, resolved server-side. */
|
|
11
|
+
export interface AppCommentAuthor {
|
|
12
|
+
/** The member's display name. Null when the id no longer resolves in the org
|
|
13
|
+
* (a removed member) — fall back explicitly, as with `readMembers`. */
|
|
14
|
+
name: string | null;
|
|
15
|
+
/** Presigned avatar URL, when the member has one. */
|
|
16
|
+
image?: string | null;
|
|
17
|
+
}
|
|
10
18
|
/** One record comment, as returned by the list op. */
|
|
11
19
|
export interface AppComment {
|
|
12
20
|
id: string;
|
|
@@ -14,6 +22,19 @@ export interface AppComment {
|
|
|
14
22
|
table_id: string;
|
|
15
23
|
/** The author's member id. */
|
|
16
24
|
member_id: string;
|
|
25
|
+
/**
|
|
26
|
+
* The author's display identity, resolved server-side on EVERY comment the
|
|
27
|
+
* server returns — list, create and update alike — including one written from
|
|
28
|
+
* another app by someone the record references nowhere. A thread crossing a
|
|
29
|
+
* role boundary is legible without the app declaring member access it does not
|
|
30
|
+
* otherwise need.
|
|
31
|
+
*
|
|
32
|
+
* Optional for one reason only: the optimistic row `createComment` renders is
|
|
33
|
+
* built HERE, from a context that carries the viewer's id and not their name,
|
|
34
|
+
* and inventing a display identity for it would render as somebody. It is
|
|
35
|
+
* replaced by the server's row when the refetch lands.
|
|
36
|
+
*/
|
|
37
|
+
author?: AppCommentAuthor;
|
|
17
38
|
content: string;
|
|
18
39
|
files: AppCommentFile[] | null;
|
|
19
40
|
workspace_id: string;
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -62,6 +62,16 @@ interface QueryStateBase {
|
|
|
62
62
|
/** Return value of `useQuery` — a single fetch, no pagination. */
|
|
63
63
|
interface QueryState<R> extends QueryStateBase {
|
|
64
64
|
rows: R[];
|
|
65
|
+
/**
|
|
66
|
+
* True when the limit cut the result — more rows matched than arrived, so
|
|
67
|
+
* `rows` is SHORT and anything folded from it (a total, a ratio, a KPI strip)
|
|
68
|
+
* is under-reported. Without it the cap is invisible: no error, no empty
|
|
69
|
+
* state, just numbers that look right.
|
|
70
|
+
*
|
|
71
|
+
* The limit is your `pageSize` when you set one and the server's row cap
|
|
72
|
+
* otherwise. False while loading and under a fixture.
|
|
73
|
+
*/
|
|
74
|
+
truncated: boolean;
|
|
65
75
|
}
|
|
66
76
|
/** Return value of `useInfiniteQuery` — append/load-more. */
|
|
67
77
|
interface InfiniteQueryState<R> extends QueryStateBase {
|
|
@@ -300,6 +310,14 @@ export interface WorkflowResult<TData = unknown> {
|
|
|
300
310
|
message?: string;
|
|
301
311
|
files?: UploadedFile[];
|
|
302
312
|
data?: TData;
|
|
313
|
+
/**
|
|
314
|
+
* Per-input refusals the workflow returned via `return({ field_errors })`,
|
|
315
|
+
* keyed by the INPUT name it declares — what a form wires straight onto the
|
|
316
|
+
* control that is wrong (`<FormField error={result.field_errors?.ly_do}>`),
|
|
317
|
+
* where `message` can only say it at the dialog's scope. Absent when the
|
|
318
|
+
* workflow returned none.
|
|
319
|
+
*/
|
|
320
|
+
field_errors?: Record<string, string>;
|
|
303
321
|
}
|
|
304
322
|
/**
|
|
305
323
|
* The (params, opts) tail of a query hook for alias `K`: `params` optional when
|
package/dist/src/hooks.js
CHANGED
|
@@ -81,6 +81,25 @@ function swrConfig(revalidateOnFocus) {
|
|
|
81
81
|
keepPreviousData: true,
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* The fixture rows for this alias and this call, held STABLE across renders.
|
|
86
|
+
*
|
|
87
|
+
* A function fixture returns a fresh array each time it is called, and `rows` is
|
|
88
|
+
* handed straight to consumers — so without memoizing, a screen that derives
|
|
89
|
+
* anything from `rows` (a `useMemo`, an effect) re-runs on every render, and one
|
|
90
|
+
* that sets state from them never settles. Keyed exactly the way the real read
|
|
91
|
+
* is keyed, so a changed param/filter/sort re-asks the fixture and nothing else
|
|
92
|
+
* does.
|
|
93
|
+
*/
|
|
94
|
+
function useMockRows(alias, call) {
|
|
95
|
+
const key = JSON.stringify([call.params, call.filter ?? null, call.sort ?? null, call.limit ?? null]);
|
|
96
|
+
const { params, filter, sort, limit } = call;
|
|
97
|
+
return useMemo(() => getMockRows(alias, { params, filter, sort, limit }),
|
|
98
|
+
// The KEY, not the object literal the caller rebuilt this render — a memo on
|
|
99
|
+
// the literal would be no memo at all.
|
|
100
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
101
|
+
[alias, key]);
|
|
102
|
+
}
|
|
84
103
|
/**
|
|
85
104
|
* Refetch this query when the host reports that ITS tables changed — whoever
|
|
86
105
|
* wrote them: another member, a workflow, the chat agent, or an external agent
|
|
@@ -140,7 +159,7 @@ export function useQuery(alias, params, opts) {
|
|
|
140
159
|
const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
|
|
141
160
|
const sort = opts?.sort && opts.sort.length > 0 ? opts.sort : undefined;
|
|
142
161
|
const filter = opts?.filter;
|
|
143
|
-
const mockRows =
|
|
162
|
+
const mockRows = useMockRows(alias, { params: params ?? {}, filter, sort, limit: pageSize });
|
|
144
163
|
// A `null` key disables the fetch (mock / disabled). SWR canonicalizes the
|
|
145
164
|
// params/sort/filter objects via its stable hash, so identical reads dedupe to
|
|
146
165
|
// one request + one cache entry that survives unmount/remount.
|
|
@@ -161,6 +180,9 @@ export function useQuery(alias, params, opts) {
|
|
|
161
180
|
useHostRefetch(alias, refetch, mockRows);
|
|
162
181
|
return {
|
|
163
182
|
rows: mockRows ?? swr.data?.rows ?? [],
|
|
183
|
+
// A fixture IS the whole set, so it is never short; before the first
|
|
184
|
+
// response there is nothing to be short of.
|
|
185
|
+
truncated: mockRows || !swr.data ? false : swr.data.truncated,
|
|
164
186
|
loading: mockRows ? false : swr.isLoading,
|
|
165
187
|
isValidating: mockRows ? false : swr.isValidating,
|
|
166
188
|
error: swr.error ? swr.error.message : null,
|
|
@@ -200,7 +222,7 @@ export function useInfiniteQuery(alias, params, opts) {
|
|
|
200
222
|
const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
|
|
201
223
|
const sort = opts?.sort && opts.sort.length > 0 ? opts.sort : undefined;
|
|
202
224
|
const filter = opts?.filter;
|
|
203
|
-
const mockRows =
|
|
225
|
+
const mockRows = useMockRows(alias, { params: params ?? {}, filter, sort, limit: pageSize });
|
|
204
226
|
// Keyset (seek) pagination: each page seeks past the previous page's
|
|
205
227
|
// `next_cursor` instead of an increasing OFFSET, so deep scrolls stay O(page)
|
|
206
228
|
// and never skip/duplicate a row as the set shifts. The cursor is opaque; the
|
|
@@ -244,7 +266,7 @@ export function useInfiniteQuery(alias, params, opts) {
|
|
|
244
266
|
// operate on resolved pages only so a page being appended never crashes the
|
|
245
267
|
// flatten or skews the counts.
|
|
246
268
|
const pages = (swr.data ?? []).filter((p) => p != null);
|
|
247
|
-
const rows = mockRows ?? pages.flatMap((p) => p.rows
|
|
269
|
+
const rows = mockRows ?? pages.flatMap((p) => p.rows);
|
|
248
270
|
const lastPage = pages.length > 0 ? pages[pages.length - 1] : undefined;
|
|
249
271
|
// A non-null next_cursor means another page exists; null/absent = the end.
|
|
250
272
|
const hasMore = lastPage != null && lastPage.next_cursor != null;
|
|
@@ -275,7 +297,7 @@ export function usePaginatedQuery(alias, params, opts) {
|
|
|
275
297
|
const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
|
|
276
298
|
const sort = opts?.sort && opts.sort.length > 0 ? opts.sort : undefined;
|
|
277
299
|
const filter = opts?.filter;
|
|
278
|
-
const mockRows =
|
|
300
|
+
const mockRows = useMockRows(alias, { params: params ?? {}, filter, sort, limit: pageSize });
|
|
279
301
|
// `null` = the caller owns the total and it hasn't resolved; `undefined`
|
|
280
302
|
// (or omitted) = the hook counts. See `PaginatedQueryOptions.total`.
|
|
281
303
|
const suppliedTotal = opts?.total;
|
|
@@ -334,7 +356,7 @@ export function useCount(alias, params, opts) {
|
|
|
334
356
|
const enabled = opts?.enabled ?? true;
|
|
335
357
|
const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
|
|
336
358
|
const filter = opts?.filter;
|
|
337
|
-
const mockRows =
|
|
359
|
+
const mockRows = useMockRows(alias, { params: params ?? {}, filter });
|
|
338
360
|
// The SAME read `usePaginatedQuery` uses for its total — one definition, so a
|
|
339
361
|
// list and a badge over one set can never drift into two scans.
|
|
340
362
|
const countSwr = useCountRead(alias, params, filter, !mockRows && enabled, revalidateOnFocus);
|
package/dist/src/index.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ 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
19
|
export type { QueryRow, UploadedFile, AttachedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, CountOptions, ColumnKeyOf, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterFieldCondition, QueryFilterRecordIdCondition, QueryFilterGroup, WorkflowResult, MembersOptions, AgentRunOptions, UseAgentRun, AgentRunLanding, AgentRunRecord, AgentRunState, AgentUIPart, PendingChoice, ChoiceQuestion, ChoiceOption, AskUserChoiceOutput, FieldOptions, FieldOptionsState, FieldOptionsOptions, } from "./hooks.js";
|
|
20
20
|
export { useComments, useCommentCounts } from "./comments.js";
|
|
21
|
-
export type { AppComment, AppCommentFile, CommentsState, UseCommentsArgs, CommentCountsState, UseCommentCountsArgs, } from "./comments.js";
|
|
21
|
+
export type { AppComment, AppCommentAuthor, AppCommentFile, CommentsState, UseCommentsArgs, CommentCountsState, UseCommentCountsArgs, } from "./comments.js";
|
|
22
22
|
export { useViewer } from "./viewer.js";
|
|
23
23
|
export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
|
|
24
24
|
export type { GeofenceZone, GeoCoords, GeofenceOutcome, GeofenceOptions } from "./geolocation.js";
|
|
@@ -32,9 +32,9 @@ export { readMembers } from "./members.js";
|
|
|
32
32
|
export type { ResolvedMember } from "./members.js";
|
|
33
33
|
export { readSelect } from "./select.js";
|
|
34
34
|
export type { ResolvedOption } from "./select.js";
|
|
35
|
-
export type { AppFixture } from "./mock.js";
|
|
35
|
+
export type { AppFixture, MockQuery, MockQueryCall, MockWorkflow } from "./mock.js";
|
|
36
36
|
export type { AppWorkflows, AppWorkflowResults, AppQueries, AppQueryColumns, AppAgents, AppAgentResults } from "./types.js";
|
|
37
|
-
export { row, readLinks, readFiles, readLocked } from "./row.js";
|
|
37
|
+
export { row, readLinks, readFiles, readLocked, readCreatedAt, readUpdatedAt } from "./row.js";
|
|
38
38
|
export type { ResolvedLink, AppFile } from "./row.js";
|
|
39
39
|
export { useOptimistic } from "./use_optimistic.js";
|
|
40
40
|
export type { OptimisticApi } from "./use_optimistic.js";
|
package/dist/src/index.js
CHANGED
|
@@ -25,7 +25,7 @@ export { askAi } from "./ask_ai.js";
|
|
|
25
25
|
export { downloadFile } from "./download.js";
|
|
26
26
|
export { readMembers } from "./members.js";
|
|
27
27
|
export { readSelect } from "./select.js";
|
|
28
|
-
export { row, readLinks, readFiles, readLocked } from "./row.js";
|
|
28
|
+
export { row, readLinks, readFiles, readLocked, readCreatedAt, readUpdatedAt } from "./row.js";
|
|
29
29
|
export { useOptimistic } from "./use_optimistic.js";
|
|
30
30
|
export { useNewRecord, newRecordId } from "./new_record.js";
|
|
31
31
|
export { useRecents } from "./use_recents.js";
|
package/dist/src/mock.d.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Activation contract:
|
|
5
5
|
*
|
|
6
6
|
* 1. App passes `{ fixture }` to `mount(<App />, { fixture })`. The fixture
|
|
7
|
-
* is a `{ queries: { alias:
|
|
8
|
-
* keyed by the same aliases the app declared in
|
|
7
|
+
* is a `{ queries: { alias: MockQuery }, workflows: { alias: MockWorkflow } }`
|
|
8
|
+
* map keyed by the same aliases the app declared in
|
|
9
9
|
* `package.json#lotics.queries` / `#lotics.workflows`.
|
|
10
10
|
* 2. At runtime, the user (or a screenshot script) loads the app with the
|
|
11
11
|
* `?__mock=1` URL search param. Without that param the SDK ignores the
|
|
@@ -26,11 +26,14 @@
|
|
|
26
26
|
* non-billing path to its own in-flight / done / error states at all, so those
|
|
27
27
|
* three screens could not be reviewed without spending on a live workspace.
|
|
28
28
|
*
|
|
29
|
-
* A fixture entry may be the RESULT, or a FUNCTION of the
|
|
30
|
-
* form is what makes the in-flight state reachable: resolve on a
|
|
31
|
-
* app renders the pending branch it otherwise never shows. It also
|
|
32
|
-
* alias answer differently per input, which is how an error branch is
|
|
33
|
-
* beside a success one.
|
|
29
|
+
* A fixture entry may be the RESULT, or a FUNCTION of the call. For a workflow
|
|
30
|
+
* the function form is what makes the in-flight state reachable: resolve on a
|
|
31
|
+
* timer and the app renders the pending branch it otherwise never shows. It also
|
|
32
|
+
* lets one alias answer differently per input, which is how an error branch is
|
|
33
|
+
* reviewed beside a success one. For a query it is what reproduces a NARROWED
|
|
34
|
+
* surface: the server applies `filter`/`sort`/`limit` after the named query, and
|
|
35
|
+
* a static row array cannot, so a master/detail drawer under `?__mock=1` would
|
|
36
|
+
* otherwise show every parent's children under every parent.
|
|
34
37
|
*
|
|
35
38
|
* What's deliberately *not* mocked: `useFileUpload`. Bytes and progress are a
|
|
36
39
|
* different shape from a request/response pair, and nothing has needed it.
|
|
@@ -42,9 +45,30 @@ import type { WorkflowResult } from "./hooks.js";
|
|
|
42
45
|
* caller in its pending state for as long as the review needs.
|
|
43
46
|
*/
|
|
44
47
|
export type MockWorkflow = WorkflowResult | ((inputs: Record<string, unknown>) => WorkflowResult | Promise<WorkflowResult>);
|
|
48
|
+
/** The call a mocked query is answering: the alias's params plus the per-call
|
|
49
|
+
* refinement the screen passed. A master/detail surface narrows by `filter`,
|
|
50
|
+
* so a fixture that ignores it renders every parent's children under every
|
|
51
|
+
* parent — right-looking and wrong. */
|
|
52
|
+
export interface MockQueryCall {
|
|
53
|
+
params: Record<string, unknown>;
|
|
54
|
+
filter?: unknown;
|
|
55
|
+
sort?: unknown;
|
|
56
|
+
limit?: number;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* What a mocked query answers with: fixed rows, or a function of the call.
|
|
60
|
+
*
|
|
61
|
+
* The function form is the one that reproduces a FILTERED surface. The server
|
|
62
|
+
* applies `filter`/`sort`/`limit` after the named query; the fixture path has no
|
|
63
|
+
* query engine, and re-implementing the filter grammar here would ship a second,
|
|
64
|
+
* divergent copy of it — so the fixture decides, from the call it was handed,
|
|
65
|
+
* which rows that call returns.
|
|
66
|
+
*/
|
|
67
|
+
export type MockQuery = Array<Record<string, unknown>> | ((call: MockQueryCall) => Array<Record<string, unknown>>);
|
|
45
68
|
export interface AppFixture {
|
|
46
|
-
/** Map of query alias → rows the hook
|
|
47
|
-
|
|
69
|
+
/** Map of query alias → the rows the hook returns when mock mode is on, or a
|
|
70
|
+
* function of the call for a surface that narrows per call. */
|
|
71
|
+
queries?: Record<string, MockQuery>;
|
|
48
72
|
/** Map of workflow alias → the result it resolves with when mock mode is on.
|
|
49
73
|
* The workflow never executes, so nothing it would have written is written. */
|
|
50
74
|
workflows?: Record<string, MockWorkflow>;
|
|
@@ -70,7 +94,7 @@ export declare function hasMockFlag(): boolean;
|
|
|
70
94
|
* through to the real RPC path. The distinction matters: an app may mock
|
|
71
95
|
* only some queries and let the rest flow through to real data.
|
|
72
96
|
*/
|
|
73
|
-
export declare function getMockRows(alias: string): Array<Record<string, unknown>> | null;
|
|
97
|
+
export declare function getMockRows(alias: string, call: MockQueryCall): Array<Record<string, unknown>> | null;
|
|
74
98
|
/**
|
|
75
99
|
* The fixture entry for a workflow alias when mock mode is active *and* the
|
|
76
100
|
* fixture has an entry for it. Otherwise null — the hook falls through to the
|
package/dist/src/mock.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Activation contract:
|
|
5
5
|
*
|
|
6
6
|
* 1. App passes `{ fixture }` to `mount(<App />, { fixture })`. The fixture
|
|
7
|
-
* is a `{ queries: { alias:
|
|
8
|
-
* keyed by the same aliases the app declared in
|
|
7
|
+
* is a `{ queries: { alias: MockQuery }, workflows: { alias: MockWorkflow } }`
|
|
8
|
+
* map keyed by the same aliases the app declared in
|
|
9
9
|
* `package.json#lotics.queries` / `#lotics.workflows`.
|
|
10
10
|
* 2. At runtime, the user (or a screenshot script) loads the app with the
|
|
11
11
|
* `?__mock=1` URL search param. Without that param the SDK ignores the
|
|
@@ -26,16 +26,21 @@
|
|
|
26
26
|
* non-billing path to its own in-flight / done / error states at all, so those
|
|
27
27
|
* three screens could not be reviewed without spending on a live workspace.
|
|
28
28
|
*
|
|
29
|
-
* A fixture entry may be the RESULT, or a FUNCTION of the
|
|
30
|
-
* form is what makes the in-flight state reachable: resolve on a
|
|
31
|
-
* app renders the pending branch it otherwise never shows. It also
|
|
32
|
-
* alias answer differently per input, which is how an error branch is
|
|
33
|
-
* beside a success one.
|
|
29
|
+
* A fixture entry may be the RESULT, or a FUNCTION of the call. For a workflow
|
|
30
|
+
* the function form is what makes the in-flight state reachable: resolve on a
|
|
31
|
+
* timer and the app renders the pending branch it otherwise never shows. It also
|
|
32
|
+
* lets one alias answer differently per input, which is how an error branch is
|
|
33
|
+
* reviewed beside a success one. For a query it is what reproduces a NARROWED
|
|
34
|
+
* surface: the server applies `filter`/`sort`/`limit` after the named query, and
|
|
35
|
+
* a static row array cannot, so a master/detail drawer under `?__mock=1` would
|
|
36
|
+
* otherwise show every parent's children under every parent.
|
|
34
37
|
*
|
|
35
38
|
* What's deliberately *not* mocked: `useFileUpload`. Bytes and progress are a
|
|
36
39
|
* different shape from a request/response pair, and nothing has needed it.
|
|
37
40
|
*/
|
|
38
41
|
let registeredFixture;
|
|
42
|
+
/** Aliases already warned about a static fixture under a filtered call. */
|
|
43
|
+
const warnedStaticFilter = new Set();
|
|
39
44
|
/**
|
|
40
45
|
* Called by `mount({ fixture })`. Module-level state because the SDK has no
|
|
41
46
|
* React context boundary around the iframe — every hook resolves against the
|
|
@@ -74,11 +79,24 @@ function isMockMode() {
|
|
|
74
79
|
* through to the real RPC path. The distinction matters: an app may mock
|
|
75
80
|
* only some queries and let the rest flow through to real data.
|
|
76
81
|
*/
|
|
77
|
-
export function getMockRows(alias) {
|
|
82
|
+
export function getMockRows(alias, call) {
|
|
78
83
|
if (!isMockMode())
|
|
79
84
|
return null;
|
|
80
|
-
const
|
|
81
|
-
|
|
85
|
+
const fixture = registeredFixture?.queries?.[alias];
|
|
86
|
+
if (fixture === undefined)
|
|
87
|
+
return null;
|
|
88
|
+
if (typeof fixture === "function")
|
|
89
|
+
return fixture(call);
|
|
90
|
+
// A static fixture cannot honour the call's narrowing, and the divergence is
|
|
91
|
+
// otherwise invisible — a drawer showing four rows where production shows two
|
|
92
|
+
// reads as a correct render. Said once per alias, at the first call that asks.
|
|
93
|
+
if (call.filter !== undefined && !warnedStaticFilter.has(alias)) {
|
|
94
|
+
warnedStaticFilter.add(alias);
|
|
95
|
+
console.warn(`Mock fixture for query "${alias}" is a static row array, so the filter passed to this call ` +
|
|
96
|
+
`is not applied — the mocked screen shows rows production would exclude. Make ` +
|
|
97
|
+
`fixture.queries.${alias} a function of the call to narrow it.`);
|
|
98
|
+
}
|
|
99
|
+
return fixture;
|
|
82
100
|
}
|
|
83
101
|
/**
|
|
84
102
|
* The fixture entry for a workflow alias when mock mode is active *and* the
|
package/dist/src/router.d.ts
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import { type RouteObject } from "react-router";
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* The not-found screen's words. The SDK ships no locale, so an app whose reader
|
|
4
|
+
* does not read English passes its own — from `@lotics/ui`'s locale where the app
|
|
5
|
+
* uses the kit, from its own strings otherwise.
|
|
6
|
+
*/
|
|
7
|
+
export interface NotFoundWords {
|
|
8
|
+
/** Names the address that has no screen. Takes the path so the words may put
|
|
9
|
+
* it anywhere the language needs it. */
|
|
10
|
+
message: (path: string) => string;
|
|
11
|
+
/** The label of the link back to the first screen. */
|
|
12
|
+
firstScreen: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function AppRouter({ routes, notFound }: {
|
|
3
15
|
routes: RouteObject[];
|
|
16
|
+
notFound?: NotFoundWords;
|
|
4
17
|
}): import("react").JSX.Element;
|
package/dist/src/router.js
CHANGED
|
@@ -35,9 +35,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
35
35
|
* { path: "/item/:id", element: <Detail /> },
|
|
36
36
|
* ]} />;
|
|
37
37
|
* }
|
|
38
|
+
*
|
|
39
|
+
* An address none of the routes claim renders the SDK's not-found screen rather
|
|
40
|
+
* than nothing — see {@link NotFoundScreen} and {@link withNotFound}. Its words
|
|
41
|
+
* are the `notFound` prop ({@link NotFoundWords}), because an app's reader reads
|
|
42
|
+
* the app's language and the SDK ships no locale.
|
|
38
43
|
*/
|
|
39
44
|
import { useEffect } from "react";
|
|
40
|
-
import { BrowserRouter, useLocation, useRoutes, } from "react-router";
|
|
45
|
+
import { BrowserRouter, Link, useLocation, useRoutes, } from "react-router";
|
|
41
46
|
import { isEmbedded, setUrlParams } from "./rpc.js";
|
|
42
47
|
/** Host query key carrying the app's current screen, so it's shareable and the
|
|
43
48
|
* host can restore it on refresh. */
|
|
@@ -70,10 +75,70 @@ function HostScreenMirror() {
|
|
|
70
75
|
function RoutedRoutes({ routes }) {
|
|
71
76
|
return useRoutes(routes);
|
|
72
77
|
}
|
|
73
|
-
|
|
78
|
+
/*
|
|
79
|
+
* Shaped like the kit's `RegionState` — a centred message and one destination —
|
|
80
|
+
* and written in its tokens with literal fallbacks, so it takes the app's theme
|
|
81
|
+
* where `@lotics/ui/styles.css` is loaded and stays legible where it is not. It
|
|
82
|
+
* cannot BE `RegionState`: the SDK ships no kit component.
|
|
83
|
+
*/
|
|
84
|
+
const NOT_FOUND_ROOT = {
|
|
85
|
+
display: "flex",
|
|
86
|
+
flexDirection: "column",
|
|
87
|
+
alignItems: "center",
|
|
88
|
+
justifyContent: "center",
|
|
89
|
+
gap: "var(--lotics-space-8, 8px)",
|
|
90
|
+
paddingBlock: "var(--lotics-space-48, 48px)",
|
|
91
|
+
paddingInline: "var(--lotics-space-16, 16px)",
|
|
92
|
+
textAlign: "center",
|
|
93
|
+
fontFamily: "var(--font-sans, system-ui, sans-serif)",
|
|
94
|
+
};
|
|
95
|
+
const NOT_FOUND_MESSAGE = {
|
|
96
|
+
margin: 0,
|
|
97
|
+
fontSize: "var(--lotics-text-sm, 14px)",
|
|
98
|
+
color: "var(--lotics-ink-muted, #71717a)",
|
|
99
|
+
};
|
|
100
|
+
const NOT_FOUND_LINK = {
|
|
101
|
+
fontSize: "var(--lotics-text-sm, 14px)",
|
|
102
|
+
color: "var(--lotics-accent, #2563eb)",
|
|
103
|
+
};
|
|
104
|
+
const NOT_FOUND_ENGLISH = {
|
|
105
|
+
message: (path) => `No screen at ${path}`,
|
|
106
|
+
firstScreen: "Go to the first screen",
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* What an app shows at an address none of its routes claim. Without it
|
|
110
|
+
* `useRoutes` matches nothing and the page renders EMPTY — no message and no
|
|
111
|
+
* console error — so a stale link or a typo reads as a crash.
|
|
112
|
+
*/
|
|
113
|
+
function NotFoundScreen({ home, words }) {
|
|
114
|
+
const { pathname } = useLocation();
|
|
115
|
+
return (_jsxs("div", { style: NOT_FOUND_ROOT, children: [
|
|
116
|
+
_jsx("p", { style: NOT_FOUND_MESSAGE, children: words.message(pathname) }), _jsx(Link, { to: home, style: NOT_FOUND_LINK, children: words.firstScreen })
|
|
117
|
+
] }));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The catch-all, appended at EVERY level of the tree: a route with `children`
|
|
121
|
+
* is a layout, and a catch-all among those children is what keeps that layout's
|
|
122
|
+
* shell on screen instead of swapping the whole page for the message. An app
|
|
123
|
+
* that declares its own `*` still wins — react-router ranks equal matches by
|
|
124
|
+
* declaration order and ours is appended last.
|
|
125
|
+
*/
|
|
126
|
+
function withNotFound(routes, element) {
|
|
127
|
+
return [
|
|
128
|
+
...routes.map((route) => route.children === undefined
|
|
129
|
+
? route
|
|
130
|
+
: { ...route, children: withNotFound(route.children, element) }),
|
|
131
|
+
{ path: "*", element },
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
/** The screen the not-found sends the reader back to — the first one declared. */
|
|
135
|
+
function firstScreenPath(routes) {
|
|
136
|
+
const first = routes.find((route) => route.path !== undefined && route.path !== "*");
|
|
137
|
+
return first?.path ?? "/";
|
|
138
|
+
}
|
|
139
|
+
export function AppRouter({ routes, notFound = NOT_FOUND_ENGLISH, }) {
|
|
74
140
|
// `isEmbedded()` reads the `?lotics_host=` the host puts on the iframe src, so
|
|
75
141
|
// it's known synchronously at first render.
|
|
76
142
|
const embedded = isEmbedded();
|
|
77
|
-
return (_jsxs(BrowserRouter, { children: [embedded ? _jsx(HostScreenMirror, {}) : null, _jsx(RoutedRoutes, { routes: routes })
|
|
78
|
-
] }));
|
|
143
|
+
return (_jsxs(BrowserRouter, { children: [embedded ? _jsx(HostScreenMirror, {}) : null, _jsx(RoutedRoutes, { routes: withNotFound(routes, _jsx(NotFoundScreen, { home: firstScreenPath(routes), words: notFound })) })] }));
|
|
79
144
|
}
|
package/dist/src/row.d.ts
CHANGED
|
@@ -17,8 +17,14 @@
|
|
|
17
17
|
declare function opt(v: unknown): string | null;
|
|
18
18
|
/** Text/markdown/autonumber → string (numbers stringified; everything else ""). */
|
|
19
19
|
declare function text(v: unknown): string;
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Number → number, or `null` when the cell holds no number — an empty cell, a
|
|
22
|
+
* NaN/Infinity, an unparseable string. Empty and zero usually mean opposite
|
|
23
|
+
* things ("not priced yet" vs "free", "no reading" vs "a reading of 0"), so the
|
|
24
|
+
* absence is preserved the way `date` preserves it. Write `?? 0` where 0 IS the
|
|
25
|
+
* correct reading of an empty cell (a sum, a count).
|
|
26
|
+
*/
|
|
27
|
+
declare function num(v: unknown): number | null;
|
|
22
28
|
/** Checkbox/boolean → boolean (accepts the string "true"). */
|
|
23
29
|
declare function bool(v: unknown): boolean;
|
|
24
30
|
/**
|
|
@@ -100,6 +106,17 @@ export declare function readFiles(v: unknown): AppFile[];
|
|
|
100
106
|
* non-object / absent flag.
|
|
101
107
|
*/
|
|
102
108
|
export declare function readLocked(rowValue: unknown): boolean;
|
|
109
|
+
/**
|
|
110
|
+
* When the record behind this row was created — the row-level `__created_at`
|
|
111
|
+
* column the compiler emits on every row-level query result. It is a DISPLAY
|
|
112
|
+
* value, so a table with no date field of its own still has a chronological
|
|
113
|
+
* anchor (a kardex date, an "as of" caption) with no schema change. Absent on a
|
|
114
|
+
* grouped/aggregated row, which has no originating record.
|
|
115
|
+
*/
|
|
116
|
+
export declare function readCreatedAt(rowValue: unknown): Date | null;
|
|
117
|
+
/** When the record behind this row was last updated — the `__updated_at`
|
|
118
|
+
* sibling of {@link readCreatedAt}. */
|
|
119
|
+
export declare function readUpdatedAt(rowValue: unknown): Date | null;
|
|
103
120
|
export declare const row: {
|
|
104
121
|
opt: typeof opt;
|
|
105
122
|
text: typeof text;
|
package/dist/src/row.js
CHANGED
|
@@ -36,15 +36,21 @@ function text(v) {
|
|
|
36
36
|
return String(v);
|
|
37
37
|
return "";
|
|
38
38
|
}
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Number → number, or `null` when the cell holds no number — an empty cell, a
|
|
41
|
+
* NaN/Infinity, an unparseable string. Empty and zero usually mean opposite
|
|
42
|
+
* things ("not priced yet" vs "free", "no reading" vs "a reading of 0"), so the
|
|
43
|
+
* absence is preserved the way `date` preserves it. Write `?? 0` where 0 IS the
|
|
44
|
+
* correct reading of an empty cell (a sum, a count).
|
|
45
|
+
*/
|
|
40
46
|
function num(v) {
|
|
41
47
|
if (typeof v === "number")
|
|
42
|
-
return Number.isFinite(v) ? v :
|
|
48
|
+
return Number.isFinite(v) ? v : null;
|
|
43
49
|
if (typeof v === "string" && v.trim()) {
|
|
44
50
|
const n = Number(v);
|
|
45
|
-
return Number.isFinite(n) ? n :
|
|
51
|
+
return Number.isFinite(n) ? n : null;
|
|
46
52
|
}
|
|
47
|
-
return
|
|
53
|
+
return null;
|
|
48
54
|
}
|
|
49
55
|
/** Checkbox/boolean → boolean (accepts the string "true"). */
|
|
50
56
|
function bool(v) {
|
|
@@ -158,4 +164,35 @@ export function readLocked(rowValue) {
|
|
|
158
164
|
return false;
|
|
159
165
|
return bool(rowValue["__source_locked"]);
|
|
160
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* The stored record's timestamp on a `useQuery` row, read off the addressing
|
|
169
|
+
* column named. Unlike a `date`/`datetime` CELL — a timezone-less workspace
|
|
170
|
+
* wall-clock at minute precision — these are true INSTANTS (ISO-8601 with an
|
|
171
|
+
* offset), so they are parsed as such and render in the viewer's zone. Pass the
|
|
172
|
+
* whole row (not a cell). Null for any non-object / absent / unparseable value.
|
|
173
|
+
*/
|
|
174
|
+
function rowInstant(rowValue, column) {
|
|
175
|
+
if (!rowValue || typeof rowValue !== "object")
|
|
176
|
+
return null;
|
|
177
|
+
const raw = rowValue[column];
|
|
178
|
+
if (typeof raw !== "string" || !raw)
|
|
179
|
+
return null;
|
|
180
|
+
const d = new Date(raw);
|
|
181
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* When the record behind this row was created — the row-level `__created_at`
|
|
185
|
+
* column the compiler emits on every row-level query result. It is a DISPLAY
|
|
186
|
+
* value, so a table with no date field of its own still has a chronological
|
|
187
|
+
* anchor (a kardex date, an "as of" caption) with no schema change. Absent on a
|
|
188
|
+
* grouped/aggregated row, which has no originating record.
|
|
189
|
+
*/
|
|
190
|
+
export function readCreatedAt(rowValue) {
|
|
191
|
+
return rowInstant(rowValue, "__created_at");
|
|
192
|
+
}
|
|
193
|
+
/** When the record behind this row was last updated — the `__updated_at`
|
|
194
|
+
* sibling of {@link readCreatedAt}. */
|
|
195
|
+
export function readUpdatedAt(rowValue) {
|
|
196
|
+
return rowInstant(rowValue, "__updated_at");
|
|
197
|
+
}
|
|
161
198
|
export const row = { opt, text, num, bool, date, datetime, link };
|
package/dist/src/rpc.js
CHANGED
|
@@ -767,10 +767,16 @@ async function standaloneContext() {
|
|
|
767
767
|
comments_enabled: info.comments_enabled,
|
|
768
768
|
};
|
|
769
769
|
}
|
|
770
|
-
|
|
770
|
+
/**
|
|
771
|
+
* The op payload IS the endpoint's body, and the endpoint's answer IS the op's
|
|
772
|
+
* result: both pass through untouched, so `sort`/`filter`/`count`/`keyset` on
|
|
773
|
+
* the way out and `total`/`truncated`/`next_cursor` on the way back reach a
|
|
774
|
+
* standalone app exactly as they reach a bridged one. Naming fields here is
|
|
775
|
+
* what once made `truncated` a host-only field.
|
|
776
|
+
*/
|
|
777
|
+
async function standaloneQuery(payload) {
|
|
771
778
|
const { app_id } = await boot();
|
|
772
|
-
|
|
773
|
-
return { rows: r.rows ?? [] };
|
|
779
|
+
return apiCall("POST", `/v1/apps/${app_id}/query`, payload, { appId: app_id });
|
|
774
780
|
}
|
|
775
781
|
async function standaloneFieldOptions(p) {
|
|
776
782
|
const { app_id } = await boot();
|
package/docs/ai.md
CHANGED
|
@@ -13,7 +13,7 @@ Don't run a structured extraction through `askAi` (the result is stranded in a c
|
|
|
13
13
|
|
|
14
14
|
## Declared agents — what `useAgentRun` runs
|
|
15
15
|
|
|
16
|
-
An agent is **declared on the app server-side, by alias**, with the `set_app_agent` tool (removed with `remove_app_agent`)
|
|
16
|
+
An agent is **declared on the app server-side, by alias**, with the `set_app_agent` tool (removed with `remove_app_agent`) — the only verb that creates a binding, so no deploy binds an alias the app does not already have. A deploy does push an alias it has: the prose in `src/agents/<alias>.md` and the authored `inputs`/`outputs` in `package.json#lotics.agents.<alias>` go through `set_app_agent` whenever either differs from the live row, before the bundle ships. Every other key of that map is a reflection `lotics app pull` refreshes and no verb sends, so replaying a stale snapshot cannot revert a grant bound elsewhere. Invoking an alias that isn't bound fails with a "no agent alias" error naming `set_app_agent`. (After a deploy, the CLI warns about any manifest alias not bound on the server.)
|
|
17
17
|
|
|
18
18
|
A declaration carries:
|
|
19
19
|
|
package/docs/data_fetching.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
How an app reads data: the three read hooks (`useQuery`, `useInfiniteQuery`, `usePaginatedQuery`),
|
|
4
4
|
their caching/revalidation and pagination contracts, the typed cell readers that decode query rows
|
|
5
|
-
(`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `useFieldOptions`
|
|
5
|
+
(`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`, `readCreatedAt`/`readUpdatedAt`), `useFieldOptions`
|
|
6
6
|
for complete select option sets, the data-discipline rules, and the two load-bearing read patterns
|
|
7
7
|
(search-as-you-type, browse/record-picker). Authoring the named queries these hooks invoke — the
|
|
8
8
|
AST, params, filter operators, aggregation, performance contract — is [./queries.md](./queries.md);
|
|
@@ -69,8 +69,9 @@ moved while everything around it updates — and a count that is quietly wrong c
|
|
|
69
69
|
count that costs a request.
|
|
70
70
|
|
|
71
71
|
And never count client-side from `useQuery(...).rows.length`: rows are capped at 10,000 per
|
|
72
|
-
response
|
|
73
|
-
|
|
72
|
+
response, so the number is right in development and wrong in production ([queries](./queries.md)
|
|
73
|
+
§10). `useQuery` reports `truncated` when the cap cut the result — read it wherever a figure is
|
|
74
|
+
folded from the rows (below) — but a count over the whole set is `useCount`'s answer, not a length.
|
|
74
75
|
|
|
75
76
|
## Shared option surface
|
|
76
77
|
|
|
@@ -246,7 +247,8 @@ Shared by both:
|
|
|
246
247
|
|
|
247
248
|
- **Row cap: 10,000.** No single query response returns more than 10,000 rows; a larger `limit`
|
|
248
249
|
(or an omitted `pageSize` on `useQuery`) is clamped to it. To read a bigger result set, page
|
|
249
|
-
through it.
|
|
250
|
+
through it. `useQuery` reports `truncated: true` when the limit cut the result, so a screen can
|
|
251
|
+
say so instead of quietly under-reporting.
|
|
250
252
|
- **Always give a paginated query a deterministic order.** With no `ORDER BY` (neither in the
|
|
251
253
|
template nor runtime `sort`), row order is unspecified — offset pages may overlap or skip rows
|
|
252
254
|
even without concurrent writes, and keyset falls back to the record-id order. Sort by a stable
|
|
@@ -275,8 +277,22 @@ const { rows, loading, error, refetch } = useQuery("openOrders", { status: "open
|
|
|
275
277
|
```
|
|
276
278
|
|
|
277
279
|
One fetch. `params` is required when the alias declares params, optional otherwise. Returns
|
|
278
|
-
`{ rows, loading, isValidating, error, refetch }`. `rows` is
|
|
279
|
-
decode cells with the readers below.
|
|
280
|
+
`{ rows, truncated, loading, isValidating, error, refetch }`. `rows` is
|
|
281
|
+
`Array<Record<string, unknown>>` — decode cells with the readers below.
|
|
282
|
+
|
|
283
|
+
**`truncated` — the one thing a short result cannot tell you itself.** It is `true` when more rows
|
|
284
|
+
matched than arrived, so `rows` is short and anything folded off it (a grouped period × site table,
|
|
285
|
+
a per-carrier ratio, a KPI strip) is under-reported. The limit is your `pageSize` when you set one
|
|
286
|
+
and the server's 10,000-row cap otherwise. Nothing else says so — there is no error, no empty state
|
|
287
|
+
and no visual tell — so read it wherever the screen computes a figure from the whole result, and
|
|
288
|
+
say the report is short rather than printing a number that is quietly wrong:
|
|
289
|
+
|
|
290
|
+
```tsx
|
|
291
|
+
const { rows, truncated } = useQuery("movements", { from, to });
|
|
292
|
+
const total = rows.reduce((n, r) => n + (row.num(r.amount) ?? 0), 0);
|
|
293
|
+
return <>{truncated && <Callout tone="warning">Số liệu chưa đủ — thu hẹp khoảng thời gian.</Callout>}
|
|
294
|
+
<Stat value={total} /></>;
|
|
295
|
+
```
|
|
280
296
|
|
|
281
297
|
### `useInfiniteQuery`
|
|
282
298
|
|
|
@@ -406,7 +422,7 @@ emits none of these" is a fact the type states rather than one you have to remem
|
|
|
406
422
|
|---|---|---|
|
|
407
423
|
| `row.opt(cell)` | select cell → `string \| null` | first option **key** (accepts the legacy bare-string / `{ id }` shapes); `null` when empty |
|
|
408
424
|
| `row.text(cell)` | any → `string` | strings pass through, finite numbers stringify, everything else → `""` |
|
|
409
|
-
| `row.num(cell)` | any → `number` | numbers pass
|
|
425
|
+
| `row.num(cell)` | any → `number \| null` | finite numbers pass, parseable strings parse, everything else (empty cell, NaN/Infinity, unparseable string) → `null` |
|
|
410
426
|
| `row.bool(cell)` | any → `boolean` | `true` or the string `"true"`; everything else `false` |
|
|
411
427
|
| `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** |
|
|
412
428
|
| `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** |
|
|
@@ -416,11 +432,18 @@ emits none of these" is a fact the type states rather than one you have to remem
|
|
|
416
432
|
| `readMembers(cell)` | member cell → `ResolvedMember[]` | `{ id, name, email?, image?, groups? }[]` (`[]` when empty) |
|
|
417
433
|
| `readFiles(cell)` | files cell → `AppFile[]` | attached files with presigned URLs (`[]` when empty) |
|
|
418
434
|
| `readLocked(rowObj)` | the whole **row** → `boolean` | the record's lock state from `__source_locked` |
|
|
435
|
+
| `readCreatedAt(rowObj)` | the whole **row** → `Date \| null` | when the record was created, from `__created_at` |
|
|
436
|
+
| `readUpdatedAt(rowObj)` | the whole **row** → `Date \| null` | when the record was last updated, from `__updated_at` |
|
|
419
437
|
|
|
420
438
|
All readers are pure (`unknown` in, value out), never throw, and return their empty value
|
|
421
|
-
(`null` / `""` / `
|
|
439
|
+
(`null` / `""` / `false` / `[]`) for absent or malformed input — so callers iterate and
|
|
422
440
|
render without null-check pyramids.
|
|
423
441
|
|
|
442
|
+
**`row.num` and the empty cell.** `null` means the cell holds no number; `0` means it holds zero.
|
|
443
|
+
The two usually mean opposite things ("not priced yet" vs "free", "no reading" vs "a reading of 0"),
|
|
444
|
+
so the reader keeps them apart instead of collapsing both to `0`. Write `?? 0` where zero IS the
|
|
445
|
+
correct reading of an empty cell (a sum, a count, a running total).
|
|
446
|
+
|
|
424
447
|
**`row.date` vs `row.datetime`.** `row.date` parses the leading `YYYY[-MM[-DD]]` and builds a
|
|
425
448
|
LOCAL-midnight `Date` — calendar/gantt placement never shifts across viewer timezones. `row.datetime`
|
|
426
449
|
keeps the stored wall-clock verbatim (no UTC conversion — the stored value is a timezone-less
|
|
@@ -471,6 +494,13 @@ columns **only in the query that renders them**, narrow with a filter, or pagina
|
|
|
471
494
|
show the locked state and route edits through the locked-change request flow
|
|
472
495
|
([./mutations.md](./mutations.md)).
|
|
473
496
|
|
|
497
|
+
**`readCreatedAt` / `readUpdatedAt`.** Take the whole row object, not a cell. They give a table with
|
|
498
|
+
no date field of its own a chronological anchor — a kardex/statement date, an "as of" caption —
|
|
499
|
+
without adding a field or a stamping workflow. Unlike a `date`/`datetime` CELL (a timezone-less
|
|
500
|
+
workspace wall-clock at minute precision), these are true **instants**: ISO-8601 with an offset on
|
|
501
|
+
the wire, parsed as instants, rendered in the viewer's zone. `null` on a grouped row, which has no
|
|
502
|
+
originating record.
|
|
503
|
+
|
|
474
504
|
## Complete select option sets
|
|
475
505
|
|
|
476
506
|
A query cell carries only the options a record actually holds (key + label, no color). For the
|
|
@@ -278,7 +278,7 @@ updateComment, deleteComment, refetch }`.
|
|
|
278
278
|
|
|
279
279
|
- `comments` — newest first on the wire (server order). Pass the array as-is to `@lotics/ui`'s
|
|
280
280
|
`CommentThread`, which re-sorts oldest-first for display. Each `AppComment`: `{ id, record_id,
|
|
281
|
-
table_id, member_id, content, files, workspace_id, created_at, updated_at }`. Attachments
|
|
281
|
+
table_id, member_id, author, content, files, workspace_id, created_at, updated_at }`. Attachments
|
|
282
282
|
(`AppCommentFile`) carry `id` / `filename` / `mime_type` — a file's identity is its `id`, and
|
|
283
283
|
the server re-reads every attachment from storage by that id, so nothing else you hold about a
|
|
284
284
|
file can affect what is stored. The `url` / `thumbnail_url` / `preview_url` fields exist on the type
|
|
@@ -294,10 +294,14 @@ updateComment, deleteComment, refetch }`.
|
|
|
294
294
|
server. **Limitation:** an optimistic create renders a placeholder row (temporary id, empty
|
|
295
295
|
`table_id`/`workspace_id`, `files: null`) until the refetch lands — don't persist anything keyed
|
|
296
296
|
on it.
|
|
297
|
-
- **
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
297
|
+
- **The author names itself.** `author` carries `{ name, image? }`, resolved server-side on every
|
|
298
|
+
comment the server returns — list, create and update alike — including one written from another
|
|
299
|
+
app by someone the record references nowhere. So a thread that crosses a role boundary is legible without the app declaring member
|
|
300
|
+
access it does not otherwise need, and a per-viewer app never widens its reach for a display
|
|
301
|
+
string. `name` is `null` when the id no longer resolves in the org (a removed member) — render a
|
|
302
|
+
fallback (`CommentThread` has an `unknownMember` label for exactly this). The one comment with no
|
|
303
|
+
`author` is the optimistic row `createComment` renders locally: the SDK knows the viewer's id and
|
|
304
|
+
not their name, and it will not invent one. The server's row replaces it when the refetch lands.
|
|
301
305
|
- **Freshness:** SWR-cached, revalidates on focus/reconnect, so another viewer's comment appears on
|
|
302
306
|
the next focus or explicit `refetch()` — not on its own. Comments are the one read that does not
|
|
303
307
|
keep itself current, and not because apps lack realtime: the channel carries **record** changes,
|
|
@@ -323,7 +327,7 @@ directly (full props: `node_modules/@lotics/ui/AGENTS.md` and its `docs/`):
|
|
|
323
327
|
| A select value (stored or picker option) | `Status` | A `useFieldOptions` option, or `byKey(readSelect(cell)[0]?.key)`; accepts a single option, an array (multi → one badge each), or null (renders nothing). Missing/unknown color → neutral. |
|
|
324
328
|
| A person, inline | `MemberChip` | `name` / `image` from a roster or a cell — both carry it; no image → initials |
|
|
325
329
|
| A member picker | `MemberSelect` | `members={useMembers().members}` — renders each option as a `MemberChip`; `MEMBER_UNASSIGNED` marks its optional "unassigned" option |
|
|
326
|
-
| A comment thread | `CommentThread` + `CommentComposer` | `useComments` state; `resolveMember`
|
|
330
|
+
| A comment thread | `CommentThread` + `CommentComposer` | `useComments` state; `resolveMember` reads each comment's own `author` (`{ name, image }`) — no roster needed |
|
|
327
331
|
|
|
328
332
|
The SDK never imports `@lotics/ui` — the app owns the (thin) data→UI adapter in each row above.
|
|
329
333
|
|
package/docs/mutations.md
CHANGED
|
@@ -29,10 +29,9 @@ const result = await createOrder({ customer_id, quantity: 3 });
|
|
|
29
29
|
owner's** authority (never the viewer's — see [security](./security.md) for attribution,
|
|
30
30
|
privilege gates, and public-app semantics).
|
|
31
31
|
- Binding is server-side (`set_app_workflow`, or `lotics app workflow set <alias>` from the
|
|
32
|
-
app project)
|
|
33
|
-
workflows
|
|
34
|
-
|
|
35
|
-
purely to type `useWorkflow` (below); hand-editing it changes nothing on the server.
|
|
32
|
+
app project), and `lotics app deploy` calls it: `package.json#lotics.workflows.<alias>` plus
|
|
33
|
+
`src/workflows/<alias>.ts` ARE the source of a binding, so an alias committed to the repo is
|
|
34
|
+
one the next clone ships. The declaration also types `useWorkflow` (below).
|
|
36
35
|
- Invoking an alias that is not bound resolves with `status: "error"` and a message naming
|
|
37
36
|
the missing binding.
|
|
38
37
|
- Anonymous visitors to a publicly shared app can invoke workflows too; the triggering
|
|
@@ -66,6 +65,7 @@ Every call resolves to a `WorkflowResult<TData>` (type exported from the package
|
|
|
66
65
|
| `message` | `string?` | The workflow's `return({ message })` text, a validation/binding error, or a body-free transport message |
|
|
67
66
|
| `files` | `UploadedFile[]?` | Files generated during the run (auto-collected — below). Absent when the run generated none |
|
|
68
67
|
| `data` | `TData?` | Structured data from `return({ data })`. Absent when no return step ran |
|
|
68
|
+
| `field_errors` | `Record<string, string>?` | Per-input refusals from `return({ field_errors })`, keyed by the INPUT name the alias declares. Absent when the workflow returned none |
|
|
69
69
|
|
|
70
70
|
### The failure model: check `status`, never just try/catch
|
|
71
71
|
|
|
@@ -104,6 +104,29 @@ the app must branch on failure kinds, make the workflow return them:
|
|
|
104
104
|
`return({ status: "error", data: { code: "OUT_OF_STOCK" } })` (an *error* return's `data`
|
|
105
105
|
passes through unvalidated).
|
|
106
106
|
|
|
107
|
+
### Locating a refusal: `field_errors`
|
|
108
|
+
|
|
109
|
+
A workflow that validates its own inputs refuses with both halves — a message for the dialog and a
|
|
110
|
+
map for the controls:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
return({ status: "error", message: "Thiếu lý do.",
|
|
114
|
+
field_errors: { ly_do: "Nhập vì sao ngoại lệ này chấp nhận được." } });
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The map arrives on the result keyed by the INPUT name, so a form wires it straight onto the control
|
|
118
|
+
that is wrong instead of making the reader hunt across a six-field dialog:
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
const [errs, setErrs] = useState<Record<string, string>>({});
|
|
122
|
+
const result = await submit(values);
|
|
123
|
+
setErrs(result.field_errors ?? {});
|
|
124
|
+
// <FormField error={errs.ly_do}> … </FormField>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Keep `message` too — it is what a refusal with no field to blame says, and the two are shown
|
|
128
|
+
together (a `Callout` at the dialog's scope plus the per-field text).
|
|
129
|
+
|
|
107
130
|
### Generated files come back in `files[]`
|
|
108
131
|
|
|
109
132
|
Any step in the run whose tool output carries a `file_id` (most commonly the
|
|
@@ -119,6 +142,10 @@ const url = files?.[0]?.url;
|
|
|
119
142
|
if (status === "success" && url) await openExternal(url);
|
|
120
143
|
```
|
|
121
144
|
|
|
145
|
+
The same run reached through the `run_app_workflow` tool — from chat, from MCP, from an app
|
|
146
|
+
agent — lists those files as `file_id`. It is the same value `useWorkflow` gives you as `id`,
|
|
147
|
+
so a file an agent produced and a file the app produced are addressed identically.
|
|
148
|
+
|
|
122
149
|
Files travel **only** via `files[]` — never hand a `file_id`/`url` back through
|
|
123
150
|
`return({ data })`, and never `update_records` a file onto a record solely to make it
|
|
124
151
|
downloadable. A download-only workflow generates and returns; attaching to a record is a
|
|
@@ -158,6 +185,9 @@ envelope's grammar — `message` is required, `field_errors` never reaches the a
|
|
|
158
185
|
schema at the app boundary — a mismatch resolves as `status: "error"` with a field-level
|
|
159
186
|
message, so a declared output is a real contract. An *error* return's `data` passes through
|
|
160
187
|
unvalidated.
|
|
188
|
+
- **An output field marked `required: false` types as `key?: T | null`.** The derivation
|
|
189
|
+
collapses "may be absent" and "may be null" into that one flag, so the generated type admits
|
|
190
|
+
both — narrow with `!= null`, never with a bare truthiness check on a number or a string.
|
|
161
191
|
- **`data` is optional even when typed.** A workflow that completes without hitting a
|
|
162
192
|
`return` step resolves `status: "success"` with no `data` (and no validation). If the app
|
|
163
193
|
depends on `data`, make every success path in the body end in `return({ data })` — and
|
|
@@ -173,11 +203,13 @@ if (r.status === "success" && r.data) {
|
|
|
173
203
|
|
|
174
204
|
## Declaring workflow inputs
|
|
175
205
|
|
|
176
|
-
An alias's declaration is `{
|
|
177
|
-
|
|
178
|
-
`
|
|
179
|
-
|
|
180
|
-
|
|
206
|
+
An alias's declaration is `{ inputs?, outputs?, workflow_id? }` — `workflow_id` is the server's
|
|
207
|
+
half, stamped by the first bind, so an alias an author has only just written carries none.
|
|
208
|
+
`inputs` maps each input name to a typed declaration; it is authored beside the body and
|
|
209
|
+
travels with it (`set_app_workflow` / `lotics app workflow set` reads it from
|
|
210
|
+
`package.json#lotics.workflows.<alias>`, and a deploy pushes a declaration that has moved), and
|
|
211
|
+
the schema the body was verified against is canonical — a manifest edit that reaches no push
|
|
212
|
+
weakens nothing. It drives three things at once: compile-time typing of the `useWorkflow` payload,
|
|
181
213
|
compile-time typing of `trigger.app_workflow.inputs.*` inside the body, and runtime payload
|
|
182
214
|
validation at the execute boundary.
|
|
183
215
|
|
|
@@ -247,7 +279,7 @@ When the alias declares `inputs`, the server validates the payload before the wo
|
|
|
247
279
|
added after authoring is accepted, a removed one rejected
|
|
248
280
|
(`select input "<path>" value "<v>" is not one of field "<key>"'s current options`) with no
|
|
249
281
|
redeploy. A `field` that doesn't exist or names a non-select field is rejected at bind time
|
|
250
|
-
(`lotics app workflow set` / `set_app_workflow
|
|
282
|
+
(`lotics app workflow set` / `set_app_workflow`, which a deploy calls for a declaration it holds) with
|
|
251
283
|
`select input references field "<key>", which does not exist in this workspace` /
|
|
252
284
|
`… which is a <type> field, not a select`. Prefer `field` for any select backed by a real
|
|
253
285
|
field; keep `options` for a fixed enum the app owns. Populate pickers from `useFieldOptions`
|
|
@@ -63,6 +63,28 @@ routes, and splats all work. Inside the tree, use react-router normally:
|
|
|
63
63
|
(`^7 || ^8` — the canonical package; the `react-router-dom` shim's tree also
|
|
64
64
|
satisfies it) is an **optional peer dependency** — an app that imports the
|
|
65
65
|
router entry must install it itself; nothing else in the SDK needs it.
|
|
66
|
+
- **An address no route claims says so.** `AppRouter` appends a catch-all at
|
|
67
|
+
every level of the tree, so such a path renders a not-found screen — a
|
|
68
|
+
message naming the path and a link to the first route — instead of an empty
|
|
69
|
+
page, and a layout route keeps its shell around that message. Declaring your
|
|
70
|
+
own `{ path: "*" }` replaces it.
|
|
71
|
+
- **The not-found screen speaks the app's language.** Its two strings default
|
|
72
|
+
to English; an app whose reader reads another language passes them as
|
|
73
|
+
`notFound`, next to `routes`:
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
<AppRouter
|
|
77
|
+
routes={routes}
|
|
78
|
+
notFound={{
|
|
79
|
+
message: (path) => `Không có màn hình ở ${path}`,
|
|
80
|
+
firstScreen: "Về màn hình đầu tiên",
|
|
81
|
+
}}
|
|
82
|
+
/>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`message` takes the path so the words may place it where the language needs
|
|
86
|
+
it. Where the app uses `@lotics/ui`, read both from the kit's locale — the
|
|
87
|
+
SDK ships none of its own.
|
|
66
88
|
- **Limitation: element routing only.** `AppRouter` mounts a plain browser
|
|
67
89
|
router, not a react-router *data* router — route `loader`/`action` fields
|
|
68
90
|
are ignored, and `useLoaderData` **throws** ("must be used within a data
|
package/docs/queries.md
CHANGED
|
@@ -161,6 +161,12 @@ declare these; output names starting with `__source_` / `__src_field_`, or equal
|
|
|
161
161
|
drops addressing (grouped results can't be written through or matched by `record_id`); a `join`
|
|
162
162
|
keeps the **left** side's addressing.
|
|
163
163
|
|
|
164
|
+
`__created_at` / `__updated_at` are DISPLAY values, not just reserved names: a table with no date
|
|
165
|
+
field of its own still has a chronological anchor. They arrive as ISO-8601 **instants** with an
|
|
166
|
+
offset (`"2026-07-05T09:00:00.000Z"`) — not the timezone-less wall-clock a `date`/`datetime` cell
|
|
167
|
+
carries — so read them with `readCreatedAt(row)` / `readUpdatedAt(row)`, which parse them as
|
|
168
|
+
instants ([./data_fetching.md](./data_fetching.md)).
|
|
169
|
+
|
|
164
170
|
---
|
|
165
171
|
|
|
166
172
|
## 2. The AST node reference
|
|
@@ -850,7 +856,7 @@ runtime refinement.
|
|
|
850
856
|
|
|
851
857
|
| Limit | Value | On violation |
|
|
852
858
|
| --- | --- | --- |
|
|
853
|
-
| Rows per response | **10,000** (caller `limit` is clamped; the cap is the default) |
|
|
859
|
+
| Rows per response | **10,000** (caller `limit` is clamped; the cap is the default) | truncation at the cap — `useQuery` reports `truncated`; paginate, or say the report is short |
|
|
854
860
|
| Statement timeout | **15 s** per query execution | 400: `query timed out after 15s — narrow the filter or simplify the query` |
|
|
855
861
|
| Concurrent query executions | server-configured bulkhead (bounded slots + bounded queue wait) | **503**: `The app is handling too many requests right now. Please retry in a moment.` — a distinct busy-retry outcome; back off and retry |
|
|
856
862
|
| Signed file URLs per response | server-configured, default **2,000 file entries** | 400 naming the alias, the count, and the ceiling — project files columns only where rendered, narrow, or paginate |
|
package/docs/recipes.md
CHANGED
|
@@ -118,6 +118,7 @@ A query cell is `unknown` with a per-type serialized shape:
|
|
|
118
118
|
|
|
119
119
|
- `row.opt` / `row.text` / `row.num` / `row.bool` / `row.date` — scalars and the first value of a
|
|
120
120
|
select. `row.date` keeps only the calendar day; use **`row.datetime`** when the time matters.
|
|
121
|
+
`row.num` answers `null` for an empty cell — write `?? 0` only where zero is its correct reading.
|
|
121
122
|
- `readSelect` — the full `{ key, label }[]` of a multi-select.
|
|
122
123
|
- `readMembers` — `select_member` cells.
|
|
123
124
|
- `row.link` / `readLinks` — `select_record_link` → `{ id, display }`. Read `.display` to render,
|
package/docs/runtime.md
CHANGED
|
@@ -43,6 +43,10 @@ mount(<App />, {
|
|
|
43
43
|
queries: {
|
|
44
44
|
orders: MOCK_ORDERS, // alias → rows, same aliases as package.json#lotics.queries
|
|
45
45
|
customers: MOCK_CUSTOMERS,
|
|
46
|
+
// Or a FUNCTION of the call — how a surface that narrows per call
|
|
47
|
+
// (a master/detail drawer) renders what production would.
|
|
48
|
+
orderLines: ({ filter }) =>
|
|
49
|
+
MOCK_LINES.filter((l) => matchesOpenOrder(l, filter)),
|
|
46
50
|
},
|
|
47
51
|
workflows: {
|
|
48
52
|
// A fixed result, for the settled state.
|
|
@@ -75,6 +79,17 @@ transport. Calling `mount` again (HMR) replaces the registration last-write-wins
|
|
|
75
79
|
as fetched rows, so shape them exactly like the query's real output — the same
|
|
76
80
|
serialized cells your `row.*` / `readSelect` / `readFiles` readers decode —
|
|
77
81
|
or the readers will decode nothing.
|
|
82
|
+
- **A ROW ARRAY answers every call to its alias identically.** The server applies
|
|
83
|
+
the per-call `filter` / `sort` / `pageSize` *after* the named query; a static
|
|
84
|
+
array cannot, so a drawer narrowed with
|
|
85
|
+
`useQuery("orderLines", {}, { filter: byOpenOrder })` shows every parent's
|
|
86
|
+
children under every parent — a screen that looks right and is wrong, which
|
|
87
|
+
defeats the review the mock path exists for. **Make such a fixture a function
|
|
88
|
+
of the call** (`({ params, filter, sort, limit }) => rows`) and narrow it
|
|
89
|
+
there; a filtered call against a row array warns once per alias in the
|
|
90
|
+
console. Do NOT compensate with a client-side re-filter in the app — that
|
|
91
|
+
ships a second, divergent copy of the filter to production to make a mock
|
|
92
|
+
look right.
|
|
78
93
|
- **A mocked workflow does not RUN.** `useWorkflow(alias)` resolves the fixture
|
|
79
94
|
entry and sends nothing, so there is no notification, no audit trail and no
|
|
80
95
|
spend — the side-effect argument is the reason to mock it, not a reason not
|
package/docs/workflows.md
CHANGED
|
@@ -430,7 +430,7 @@ full menu by category, so a miss is one informed retry.
|
|
|
430
430
|
| **Type / null** | `isNull`, `isNotNull`, `isEmpty`, `isString`, `isNumber`, `isBoolean`, `isArray`, `isObject`, `coalesce`, `toNumber`, `toString`, `typeOf`, `parseJson`, `toJson` |
|
|
431
431
|
| **Array** | `size`, `first`, `requireFirst`, `last`, `nth`, `at`, `slice`, `includes`, `filter`, `find`, `some`, `every`, `pluck`, `sortBy`, `groupBy`, `countBy`, `unique`, `uniqueBy`, `compact`, `flatten`, `reverse`, `concat`, `difference`, `differenceBy`, `intersection`, `intersectionBy`, `list`, `range`, `reduce` |
|
|
432
432
|
| **Number** | `sum`, `sumBy`, `mean`, `meanBy`, `min`, `max`, `minBy`, `maxBy`, `round`, `ceil`, `floor`, `abs`, `mod`, `pow`, `sqrt`, `clamp`, `percentage` |
|
|
433
|
-
| **String** | `upper`, `lower`, `capitalize`, `trim`, `contains`, `startsWith`, `endsWith`, `replace`, `replaceAll`, `substring`, `length`, `split`, `join`, `padStart`, `padEnd`, `formatNumber
|
|
433
|
+
| **String** | `upper`, `lower`, `capitalize`, `trim`, `contains`, `startsWith`, `endsWith`, `replace`, `replaceAll`, `substring`, `length`, `split`, `join`, `padStart`, `padEnd`, `formatNumber(value, decimals)` (fixed-decimal, ungrouped), `formatDecimal(value, decimals, locale)` (grouped for a reader — `formatDecimal(151000, 0, "vi-VN")` → `151.000`), `numberToWords` |
|
|
434
434
|
| **Object** | `keys`, `values`, `entries`, `get`, `pick`, `omit`, `merge`, `nonNullKeys` |
|
|
435
435
|
| **Date** | `now`, `formatDate`, `parseDate`, `addDays`, `subDays`, `addHours`, `subHours`, `addMinutes`, `subMinutes`, `startOfDay`, `endOfDay`, `differenceInCalendarDays`, `differenceInHours`, `differenceInMinutes`, `isBefore`, `isAfter`, `isSameDay`, `isToday`, `isWithinRange` |
|
|
436
436
|
| **Other** | `formatCurrency(amount, locale, currency)`, `randomNumber(len)`, `randomAlphaNumeric(len)`, `sample(items)`, `current_member_in_any_group(["grp_…"])` |
|