@lotics/app-sdk 0.90.2 → 0.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -16,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
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();
@@ -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
@@ -66,6 +66,7 @@ Every call resolves to a `WorkflowResult<TData>` (type exported from the package
66
66
  | `message` | `string?` | The workflow's `return({ message })` text, a validation/binding error, or a body-free transport message |
67
67
  | `files` | `UploadedFile[]?` | Files generated during the run (auto-collected — below). Absent when the run generated none |
68
68
  | `data` | `TData?` | Structured data from `return({ data })`. Absent when no return step ran |
69
+ | `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
70
 
70
71
  ### The failure model: check `status`, never just try/catch
71
72
 
@@ -104,6 +105,29 @@ the app must branch on failure kinds, make the workflow return them:
104
105
  `return({ status: "error", data: { code: "OUT_OF_STOCK" } })` (an *error* return's `data`
105
106
  passes through unvalidated).
106
107
 
108
+ ### Locating a refusal: `field_errors`
109
+
110
+ A workflow that validates its own inputs refuses with both halves — a message for the dialog and a
111
+ map for the controls:
112
+
113
+ ```ts
114
+ return({ status: "error", message: "Thiếu lý do.",
115
+ field_errors: { ly_do: "Nhập vì sao ngoại lệ này chấp nhận được." } });
116
+ ```
117
+
118
+ The map arrives on the result keyed by the INPUT name, so a form wires it straight onto the control
119
+ that is wrong instead of making the reader hunt across a six-field dialog:
120
+
121
+ ```tsx
122
+ const [errs, setErrs] = useState<Record<string, string>>({});
123
+ const result = await submit(values);
124
+ setErrs(result.field_errors ?? {});
125
+ // <FormField error={errs.ly_do}> … </FormField>
126
+ ```
127
+
128
+ Keep `message` too — it is what a refusal with no field to blame says, and the two are shown
129
+ together (a `Callout` at the dialog's scope plus the per-field text).
130
+
107
131
  ### Generated files come back in `files[]`
108
132
 
109
133
  Any step in the run whose tool output carries a `file_id` (most commonly the
@@ -119,6 +143,10 @@ const url = files?.[0]?.url;
119
143
  if (status === "success" && url) await openExternal(url);
120
144
  ```
121
145
 
146
+ The same run reached through the `run_app_workflow` tool — from chat, from MCP, from an app
147
+ agent — lists those files as `file_id`. It is the same value `useWorkflow` gives you as `id`,
148
+ so a file an agent produced and a file the app produced are addressed identically.
149
+
122
150
  Files travel **only** via `files[]` — never hand a `file_id`/`url` back through
123
151
  `return({ data })`, and never `update_records` a file onto a record solely to make it
124
152
  downloadable. A download-only workflow generates and returns; attaching to a record is a
@@ -158,6 +186,9 @@ envelope's grammar — `message` is required, `field_errors` never reaches the a
158
186
  schema at the app boundary — a mismatch resolves as `status: "error"` with a field-level
159
187
  message, so a declared output is a real contract. An *error* return's `data` passes through
160
188
  unvalidated.
189
+ - **An output field marked `required: false` types as `key?: T | null`.** The derivation
190
+ collapses "may be absent" and "may be null" into that one flag, so the generated type admits
191
+ both — narrow with `!= null`, never with a bare truthiness check on a number or a string.
161
192
  - **`data` is optional even when typed.** A workflow that completes without hitting a
162
193
  `return` step resolves `status: "success"` with no `data` (and no validation). If the app
163
194
  depends on `data`, make every success path in the body end in `return({ data })` — and
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
@@ -464,7 +470,7 @@ derived surfaces share one implementation.
464
470
  | number | `number` | `equals`, `not_equals`, `greater_than`, `less_than`, `greater_than_or_equal_to`, `less_than_or_equal_to`, `is_empty`, `is_not_empty` | same set | Full numeric aggregate set (§8). |
465
471
  | date / datetime | `date` / `datetime` (datetime when the field's format includes time) | `on`, `before`, `after`, `on_or_before`, `on_or_after`, `between`, `time_of_day`, `is_empty`, `is_not_empty` — values are `DateTimePoint`s (below) | same set | Day-level filters on datetime values expand to the full-day window. Sortable; bucketable in `group.by` (§8); `earliest`/`latest`/`min`/`max`/`date_range` aggregate. |
466
472
  | boolean | `boolean` | `equals` (value `true`/`false`; `false` matches NULL/missing) | same | `checked`/`unchecked`/`percent_*` aggregate (`unchecked` counts false **or** empty). |
467
- | select (single & multi) | `select` | `has_any_of`, `has_none_of`, `has_all_of`, `is_empty`, `is_not_empty` — values are **option keys** (`opt_*`), never labels | same | Cells are arrays even for single-selects. Sorting a select column in a query orders by raw JSON, **not** configured option order. `unnest` fans option keys. |
473
+ | select (single & multi) | `select` | `has_any_of`, `has_none_of`, `has_all_of`, `is_empty`, `is_not_empty` — values are **option keys** (`opt_*`), never labels | the **runtime** `filter` (§9) also takes option **names**, resolving each to its key — an unknown one is refused rather than matching nothing, and the value stays an array either way; a select column the query DERIVES instead of projecting from a field carries no option set, so addressing one there is refused | Cells are arrays even for single-selects. Sorting a select column in a query orders by raw JSON, **not** configured option order. `unnest` fans option keys. |
468
474
  | select_member | `select_member` | select ops + `is_current_member`, `is_not_current_member` (field-scoped, no value) | same — `is_current_member` **works at the runtime layer** too | `unnest` fans member ids. Anonymous public request: current-member binds the app owner (§1). |
469
475
  | select_record_link | `select_record_link` | membership by linked-record **id**: `has_any_of`, `has_none_of`, `has_all_of`; text over the cached **display**: `contains`, `not_contains`, `starts_with`, `ends_with`; `is_empty`, `is_not_empty` | same — id-membership works at the runtime layer (converged `[{id}]` containment) | Sorting orders by raw JSON, not display text — project the display (link extraction / `display_output`) and sort that. `unnest` fans link ids (+ display). |
470
476
  | files | `files` | `has_filename`, `has_mime_type` (substring), `has_file_count` (exact count), `is_empty`, `is_not_empty` | same | Presign-enriched at delivery (§1); `unnest` fans file ids. Only presence-counting aggregates. |
@@ -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
@@ -16,9 +16,8 @@ engine, no `eval`, no sandbox. Four consequences shape everything below:
16
16
 
17
17
  - **Only the listed forms exist.** Anything outside them fails the save with a precise error
18
18
  naming the rule and, where one exists, the substitute. A save never partially succeeds.
19
- - **A successful save may still return `warnings[]`** — advisory lint that does not block
20
- (a possible infinite loop, a link path more than three hops deep, a button action with no
21
- `validate` guard). Read them; they are the failures that only show up in production.
19
+ - **A successful save may still return `warnings[]`** — advisory lint that does not block.
20
+ Read them; they are the failures that only show up in production.
22
21
  - **One canonical form per concept.** Several JS spellings are accepted and *lowered* to one
23
22
  stored form. The stored tree renders back to source on `lotics app pull`, so a pulled body
24
23
  shows the canonical spelling, not the sugar you typed (see *Round-tripping* below).
@@ -431,7 +430,7 @@ full menu by category, so a miss is one informed retry.
431
430
  | **Type / null** | `isNull`, `isNotNull`, `isEmpty`, `isString`, `isNumber`, `isBoolean`, `isArray`, `isObject`, `coalesce`, `toNumber`, `toString`, `typeOf`, `parseJson`, `toJson` |
432
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` |
433
432
  | **Number** | `sum`, `sumBy`, `mean`, `meanBy`, `min`, `max`, `minBy`, `maxBy`, `round`, `ceil`, `floor`, `abs`, `mod`, `pow`, `sqrt`, `clamp`, `percentage` |
434
- | **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` |
435
434
  | **Object** | `keys`, `values`, `entries`, `get`, `pick`, `omit`, `merge`, `nonNullKeys` |
436
435
  | **Date** | `now`, `formatDate`, `parseDate`, `addDays`, `subDays`, `addHours`, `subHours`, `addMinutes`, `subMinutes`, `startOfDay`, `endOfDay`, `differenceInCalendarDays`, `differenceInHours`, `differenceInMinutes`, `isBefore`, `isAfter`, `isSameDay`, `isToday`, `isWithinRange` |
437
436
  | **Other** | `formatCurrency(amount, locale, currency)`, `randomNumber(len)`, `randomAlphaNumeric(len)`, `sample(items)`, `current_member_in_any_group(["grp_…"])` |
@@ -803,8 +802,8 @@ What only the **server** can decide, so `check` stays green and `set` may still
803
802
  - **`switch` case validation** against a single-select's options, and the multi-select rejection;
804
803
  - the **literal `formatDate` format probe**;
805
804
  - **wait inside a loop**, and the `before_*` restrictions on waits and `agent` steps;
806
- - the **lint** — possible self-retrigger, deep link chains, a button action with no `validate`
807
- guard (all warnings).
805
+ - the **lint** — deep link chains, an input the alias declares that this body never reads, and
806
+ an unguarded `set` in a body that generates a document (all warnings).
808
807
 
809
808
  There is no separate verify endpoint: the loop is `set` → read the returned diagnostics → fix →
810
809
  `set`. Diagnostics arrive **batched** — independent errors across the whole body come back in one
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.90.2",
3
+ "version": "0.91.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps \u2014 typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {