@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 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 silently at 10,000), 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.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `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, 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
 
@@ -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;
@@ -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 = getMockRows(alias);
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 = getMockRows(alias);
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 = getMockRows(alias);
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 = getMockRows(alias);
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);
@@ -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";
@@ -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: Row[] }, workflows: { alias: Result } }` map
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 inputs. The function
30
- * form is what makes the in-flight state reachable: resolve on a timer and the
31
- * app renders the pending branch it otherwise never shows. It also lets one
32
- * alias answer differently per input, which is how an error branch is reviewed
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 should return when mock mode is on. */
47
- queries?: Record<string, Array<Record<string, unknown>>>;
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: Row[] }, workflows: { alias: Result } }` map
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 inputs. The function
30
- * form is what makes the in-flight state reachable: resolve on a timer and the
31
- * app renders the pending branch it otherwise never shows. It also lets one
32
- * alias answer differently per input, which is how an error branch is reviewed
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 rows = registeredFixture?.queries?.[alias];
81
- return rows ?? null;
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
@@ -1,4 +1,17 @@
1
1
  import { type RouteObject } from "react-router";
2
- export declare function AppRouter({ routes }: {
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;
@@ -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
- export function AppRouter({ routes }) {
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
- /** Number → number (NaN/Infinity and unparseable strings → 0). */
21
- declare function num(v: unknown): number;
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
- /** Number → number (NaN/Infinity and unparseable strings → 0). */
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 : 0;
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 : 0;
51
+ return Number.isFinite(n) ? n : null;
46
52
  }
47
- return 0;
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
- async function standaloneQuery(p) {
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
- const r = (await apiCall("POST", `/v1/apps/${app_id}/query`, { alias: p.alias, params: p.params, limit: p.limit, offset: p.offset }, { appId: app_id }));
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`). `lotics app deploy` ships code and queries only — it never binds agents. The `lotics.agents` map in `package.json` is a **read-only reflection** written by `lotics app pull`; hand-editing it does nothing. 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.)
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
 
@@ -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 and truncation is **silent**, so the number is right in development and wrong in
73
- production with no error ([queries](./queries.md) §10).
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 `Array<Record<string, unknown>>` —
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 (NaN/Infinity → 0), parseable strings parse, everything else → `0` |
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` / `""` / `0` / `false` / `[]`) for absent or malformed input — so callers iterate and
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
- - **Limitation:** a comment carries `member_id` only — resolving the author's display name needs a
298
- member source: the `useMembers` roster (requires the member-access declaration above) or member
299
- cells in your own data. An unresolvable id should render a fallback (`CommentThread` has an
300
- `unknownMember` label for exactly this).
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` bridges `member_id` → your member source |
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). `lotics app deploy` ships code, queries, and capabilities — it never binds
33
- workflows.
34
- The `package.json#lotics.workflows` map is a *pulled reflection* of the live bindings, used
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 `{ workflow_id, inputs?, outputs? }`. `inputs` maps each input name
177
- to a typed declaration; it is authored when the workflow is bound (`set_app_workflow` /
178
- `lotics app workflow set` reads it from `package.json#lotics.workflows.<alias>`), and the
179
- schema the body was verified against is canonical — the manifest reflection cannot silently
180
- weaken it. It drives three things at once: compile-time typing of the `useWorkflow` payload,
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`; `lotics app deploy` never binds workflows) with
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) | silent truncation at the cap — paginate |
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`, `numberToWords` |
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_…"])` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.90.3",
3
+ "version": "0.91.1",
4
4
  "description": "Runtime SDK for Lotics custom-code apps \u2014 typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {