@lotics/app-sdk 0.91.0 → 0.93.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,11 +16,11 @@ signature; open the file.**
16
16
  |---|---|
17
17
  | [docs/recipes.md](./docs/recipes.md) | Task-shaped how-tos for the actions whose mechanism is not guessable from the hooks — returning a generated file, returning structured data, parameterized lookups, composable optional filters, cell decoding, testing an AI action without spending credits. |
18
18
  | [docs/queries.md](./docs/queries.md) | **The query engine authoring reference** — AST node kinds, per-field-type operator support, filters/params/pruning, free-text search, combining tables (join/union/link/`unnest`/`record_id`), shaping (aggregates, date buckets, windows), runtime refinement bounds, limits & the efficiency playbook. |
19
- | [docs/data_fetching.md](./docs/data_fetching.md) | The four read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`/`useCount` — the last for a number with no rows, sharing the `(alias, params, filter)` count key the paginated hook uses, so a list and a badge over one set buy one count; a count is a full scan and stays its OWN request so rows paint without waiting for it, and `rows.length` is never a count since rows truncate at 10,000 — `useQuery` says so with `truncated`), the `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. |
19
+ | [docs/data_fetching.md](./docs/data_fetching.md) | The four read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`/`useCount` — the last for a number with no rows, sharing the `(alias, params, filter)` count key the paginated hook uses, so a list and a badge over one set buy one count; a count is a full scan and stays its OWN request so rows paint without waiting for it, and `rows.length` is never a count since rows truncate at 10,000 — `useQuery` says so with `truncated`), the ROW type (`RowOf` — the alias's projected columns and nothing else, values `unknown`; `__source_record_id`/`__source_table_id` typed but optional), runtime `sort`/`filter` keys AND the `useFieldOptions` map keyed against that same projection (`AppQueryColumns`, `ColumnKeyOf`), cell readers (`row.*` — `row.num` 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), caching — **arrival revalidates** (a re-mount renders cache *and* refreshes it in the background, `loading` never flips) — **realtime push** (a table one of your queries reads changes and that query refetches within about a second, alias-precise, records-only, host-embedded apps only) — and the fourth source, **this app's own successful write**, which re-reads every mounted query immediately rather than waiting on that push (→ [mutations](./docs/mutations.md)), data discipline, the pagination count as a second full execution (and `total` to suppress it), the search-as-you-type + record-picker patterns. |
20
20
  | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract (`field_errors` locates a refusal on the control it belongs to, where `message` can only say it at the dialog's scope), typed inputs, the automatic re-read a SUCCESSFUL write triggers over every mounted query (so a screen never waits on the host push to see its own write), diff-before-update, locked records, `useOptimistic`, `useNewRecord` (client-minted `rec_*` id so a new-record surface never remounts on its first save), read-after-write ordering (a re-read must not overtake an in-flight write). |
21
21
  | [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY authoring reference** — the JS subset a `src/workflows/<alias>.ts` body may use: the parse-at-save/never-execute model, opaque `fld_*`/`opt_*` keys, expression sources + explicit `linked()` descent, every step form (tool call, `agent`, waits, `validate`, `return`), the accepted sugar and its canonical lowering, helpers + callback rules, record-write surfaces, the traps, the bright line, and the verify loop — `check` (the only local gate: the app's own `npm run typecheck` never sees a body) → `dry_run_workflow` (static green is not a run) → `set`. |
22
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` (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. |
23
+ | [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions` (keyed by the alias's OWN columns, every key optional), `useViewer`, `useComments` (each comment carries its own resolved `author`, so a thread crossing a role boundary is legible without declaring member access), and the `@lotics/ui` components they feed. |
24
24
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
25
25
  | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `AgentRun`, the agent's ask-back — `pendingChoice`/`answerChoice` over the parked `awaiting_input` state — and the `AgentRunLanding` every leg resolves), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics; a chat mutation refetches your queries through the realtime channel, not a separate poke). **A `file` input carries its own content** — no reader tool to declare. **An agent reaches record DATA only through its declared `query_aliases` / `workflow_aliases`.** Also **what the member's own chat agent can do with your app while it is open** — the alias catalog it reads and how to shape a mutating alias for it. |
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). |
@@ -44,6 +44,9 @@ signature; open the file.**
44
44
  column, including files. → [queries](./docs/queries.md)
45
45
  - **Never hand-roll the serialization contract** — decode cells with the typed readers.
46
46
  → [data_fetching](./docs/data_fetching.md)
47
+ - **Read only the columns the alias projects** — its option sets too, from the alias that CARRIES
48
+ the column. Anything else is `undefined`, which every reader draws as a blank cell, so the row
49
+ type and the option map are bounded to the projection. → [data_fetching](./docs/data_fetching.md)
47
50
  - **Narrow `__source_record_id` / `__source_table_id` before use** — a grouped query emits neither,
48
51
  so an unchecked read hands a workflow (or an AI record ref) `undefined`.
49
52
  → [data_fetching](./docs/data_fetching.md)
@@ -7,12 +7,14 @@ import type { ResolvedOption } from "./select.js";
7
7
  export type { AgentRunState, AgentUIPart, PendingChoice, ChoiceQuestion, ChoiceOption, AskUserChoiceOutput, AgentRunLanding } from "./agent_stream.js";
8
8
  export { buildChoiceOutput } from "./agent_stream.js";
9
9
  /**
10
- * One row of a query result: the projected columns, plus the platform's
11
- * ADDRESSING columns.
10
+ * One row of a query result whose COLUMNS are not known: a dynamic alias, or a
11
+ * query whose AST names no projection (a bare `from_table`). Where codegen could
12
+ * read them, `RowOf` states them instead and there is no index signature — see
13
+ * that type, which is where the reason lives.
12
14
  *
13
- * The projected values stay `unknown` — which columns a row carries is fixed by
14
- * the query's manifest declaration, not by this type, so the cell readers
15
- * (`row.text`, `readLinks`, `readFiles`, …) are what narrow them.
15
+ * The projected values stay `unknown` either way: a column's TYPE is the field's
16
+ * and the manifest does not carry it, so the cell readers (`row.text`,
17
+ * `readLinks`, `readFiles`, …) are what narrow them.
16
18
  *
17
19
  * The `__source_*` columns are different in kind: the compiler injects them at
18
20
  * every layer, and they are the only way to address the RECORD a row came from
@@ -41,6 +43,27 @@ export interface QueryRow {
41
43
  /** The table that record lives in. Absent on a grouped/aggregated row. */
42
44
  __source_table_id?: string;
43
45
  }
46
+ /**
47
+ * The row type of alias `K`: the columns that alias PROJECTS, plus the two
48
+ * addressing columns, and nothing else.
49
+ *
50
+ * `.lotics/app_queries.d.ts` already names every projected column
51
+ * (`AppQueryColumns`), read off the query's AST by the same rule the server
52
+ * names them by. A read is bounded to that union for the same reason a
53
+ * `filter`/`sort` key is — except the failure is quieter. A key the query does
54
+ * not carry is refused by the server; a COLUMN it does not carry comes back
55
+ * `undefined`, which every cell reader answers with its empty value, so a
56
+ * misspelt column, a renamed field and a projection moved to another alias all
57
+ * render as a blank cell in a member's browser with no error anywhere. Removing
58
+ * the index signature is what turns that silence into a `tsc` error, which is
59
+ * what `lotics app check` refuses to ship.
60
+ *
61
+ * An alias with no `AppQueryColumns` entry keeps `QueryRow` — a union that could
62
+ * be wrong is worse than none, the rule `ColumnKeyOf` already follows.
63
+ */
64
+ export type RowOf<K extends string> = K extends keyof AppQueryColumns ? {
65
+ [C in AppQueryColumns[K] & string]: unknown;
66
+ } & Pick<QueryRow, "__source_record_id" | "__source_table_id"> : QueryRow;
44
67
  /** Fields shared by every query hook's return value. */
45
68
  interface QueryStateBase {
46
69
  /**
@@ -344,9 +367,10 @@ type QueryArgs<K extends string, O> = K extends keyof AppQueries ? AppQueries[K]
344
367
  *
345
368
  * Per-app CLI codegen writes `.lotics/app_queries.d.ts` augmenting `AppQueries`
346
369
  * with the declared alias → param-type map, so an undeclared alias is a
347
- * compile-time error and params are typed per the manifest.
370
+ * compile-time error and params are typed per the manifest. The same file names
371
+ * the alias's projected columns, which is what bounds a ROW read — see `RowOf`.
348
372
  */
349
- export declare function useQuery<K extends string>(alias: K, ...args: QueryArgs<K, QueryOptions<ColumnKeyOf<K>>>): QueryState<QueryRow>;
373
+ export declare function useQuery<K extends string>(alias: K, ...args: QueryArgs<K, QueryOptions<ColumnKeyOf<K>>>): QueryState<RowOf<K>>;
350
374
  /** The resolved option set of one select column, plus an index for value
351
375
  * rendering. The companion to a query row, for select fields. */
352
376
  export interface FieldOptions {
@@ -366,14 +390,20 @@ export interface FieldOptions {
366
390
  */
367
391
  byKey: (key: string) => ResolvedOption | undefined;
368
392
  }
369
- /** Return value of `useFieldOptions`. */
370
- export interface FieldOptionsState {
393
+ /** Return value of `useFieldOptions`. `C` is the column key's type — the alias's
394
+ * projected outputs, see `ColumnKeyOf`. */
395
+ export interface FieldOptionsState<C extends string = string> {
371
396
  /**
372
- * Resolved option sets keyed by the query's OUTPUT column name. A select
373
- * column the server couldn't resolve to a source field (a UNION output, a
374
- * computed column) is simply absent — read defensively (`fields.status?`).
397
+ * Resolved option sets keyed by the query's OUTPUT column name — the alias's
398
+ * own columns, so a key belonging to another query is a `tsc` error rather
399
+ * than a `[]` picker nobody can open.
400
+ *
401
+ * EVERY key is optional, and that is the shape rather than a hedge: a select
402
+ * column the server could not resolve to a source field (a UNION output whose
403
+ * arms disagree, a computed column) is genuinely absent, and a non-select
404
+ * column never had options. Read through it (`fields.status?.options ?? []`).
375
405
  */
376
- fields: Record<string, FieldOptions>;
406
+ fields: Partial<Record<C, FieldOptions>>;
377
407
  loading: boolean;
378
408
  isValidating: boolean;
379
409
  error: string | null;
@@ -395,8 +425,11 @@ export interface FieldOptionsOptions {
395
425
  * added/removed option flows through with no app change.
396
426
  *
397
427
  * Addressed by the same alias you query: the option sets resolve from the named
398
- * query's output columns, scoped exactly like running it. A column the server
399
- * can't map to a source select field (UNION output, computed column) is absent.
428
+ * query's output columns, scoped exactly like running it, and `fields` is keyed
429
+ * by that same union — the alias that CARRIES the column is the one to ask, and
430
+ * asking another is a compile error instead of an empty picker. A column the
431
+ * server can't map to a source select field (UNION output, computed column) is
432
+ * absent, so every key is optional.
400
433
  *
401
434
  * ```tsx
402
435
  * const { fields } = useFieldOptions("records");
@@ -407,7 +440,7 @@ export interface FieldOptionsOptions {
407
440
  * <Status option={fields.status?.byKey(readSelect(r.status)[0]?.key ?? "")} />
408
441
  * ```
409
442
  */
410
- export declare function useFieldOptions<K extends keyof AppQueries & string>(alias: K, opts?: FieldOptionsOptions): FieldOptionsState;
443
+ export declare function useFieldOptions<K extends keyof AppQueries & string>(alias: K, opts?: FieldOptionsOptions): FieldOptionsState<ColumnKeyOf<K>>;
411
444
  export declare function useFieldOptions(alias: string, opts?: FieldOptionsOptions): FieldOptionsState;
412
445
  /**
413
446
  * Like `useQuery` but append/load-more: the first render loads one page and
@@ -418,7 +451,7 @@ export declare function useFieldOptions(alias: string, opts?: FieldOptionsOption
418
451
  * const { rows, loadMore, hasMore } = useInfiniteQuery("feed", {}, { pageSize: 30 });
419
452
  * ```
420
453
  */
421
- export declare function useInfiniteQuery<K extends string>(alias: K, ...args: QueryArgs<K, InfiniteQueryOptions<ColumnKeyOf<K>>>): InfiniteQueryState<QueryRow>;
454
+ export declare function useInfiniteQuery<K extends string>(alias: K, ...args: QueryArgs<K, InfiniteQueryOptions<ColumnKeyOf<K>>>): InfiniteQueryState<RowOf<K>>;
422
455
  /**
423
456
  * Page-model query with a total — the data hook behind a numbered, jumpable
424
457
  * table (pairs with `@lotics/ui/pagination`). It owns the page
@@ -433,7 +466,7 @@ export declare function useInfiniteQuery<K extends string>(alias: K, ...args: Qu
433
466
  * usePaginatedQuery("orders", { q }, { pageSize: 25, sort, filter });
434
467
  * ```
435
468
  */
436
- export declare function usePaginatedQuery<K extends string>(alias: K, ...args: QueryArgs<K, PaginatedQueryOptions<ColumnKeyOf<K>>>): PaginatedQueryState<QueryRow>;
469
+ export declare function usePaginatedQuery<K extends string>(alias: K, ...args: QueryArgs<K, PaginatedQueryOptions<ColumnKeyOf<K>>>): PaginatedQueryState<RowOf<K>>;
437
470
  /**
438
471
  * HOW MANY rows a query matches — one number, no rows fetched.
439
472
  *
@@ -617,9 +650,11 @@ interface MembersState {
617
650
  /** Options for `useMembers`. */
618
651
  export interface MembersOptions {
619
652
  /**
620
- * Restrict to one member group (a group ID). The group must be declared on a
621
- * `member` workflow input's `group` — listing an undeclared group errors. Omit
622
- * to list the whole org roster.
653
+ * Restrict to one member group, as `GRP.<group>` from `.lotics/app_fields.ts`
654
+ * (a pasted `grp_…` id is refused by `lotics app check` — it resolves to
655
+ * nothing in a copy of the app). The group must be declared on a `member`
656
+ * workflow input's `group` — listing an undeclared group errors. Omit to list
657
+ * the whole org roster.
623
658
  */
624
659
  group?: string;
625
660
  }
@@ -636,7 +671,7 @@ export interface MembersOptions {
636
671
  * `group` — so an app can only list (and assign into) groups it declares.
637
672
  *
638
673
  * ```tsx
639
- * const { members } = useMembers({ group: "grp_..." });
674
+ * const { members } = useMembers({ group: GRP.sale });
640
675
  * // <Select variant="native" options={members.map((m) => ({
641
676
  * // value: m.id, label: m.name || m.email || m.id, image: m.image,
642
677
  * // }))} />
package/dist/src/hooks.js CHANGED
@@ -557,7 +557,7 @@ export function useAiContext(slot, context) {
557
557
  * `group` — so an app can only list (and assign into) groups it declares.
558
558
  *
559
559
  * ```tsx
560
- * const { members } = useMembers({ group: "grp_..." });
560
+ * const { members } = useMembers({ group: GRP.sale });
561
561
  * // <Select variant="native" options={members.map((m) => ({
562
562
  * // value: m.id, label: m.name || m.email || m.id, image: m.image,
563
563
  * // }))} />
@@ -16,7 +16,7 @@
16
16
  export { mount } from "./mount.js";
17
17
  export type { MountOptions } from "./mount.js";
18
18
  export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useCount, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, buildChoiceOutput, } from "./hooks.js";
19
- export type { QueryRow, 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";
19
+ export type { QueryRow, RowOf, 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
21
  export type { AppComment, AppCommentAuthor, AppCommentFile, CommentsState, UseCommentsArgs, CommentCountsState, UseCommentCountsArgs, } from "./comments.js";
22
22
  export { useViewer } from "./viewer.js";
@@ -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.js CHANGED
@@ -87,7 +87,15 @@ function datetime(v) {
87
87
  return null;
88
88
  return new Date(Number(m[1]), (m[2] ? Number(m[2]) : 1) - 1, m[3] ? Number(m[3]) : 1, m[4] ? Number(m[4]) : 0, m[5] ? Number(m[5]) : 0);
89
89
  }
90
- /** One `{ id, display }` object → ResolvedLink, or null if absent/malformed. */
90
+ /**
91
+ * One `{ id, display }` object → ResolvedLink, or null if absent/malformed.
92
+ *
93
+ * A MISSING `display` IS NOT A LINK. Other cells carry `{ id }` without one — a
94
+ * member is the everyday case — and answering `display: ""` for them made every
95
+ * assignee read as a blank-named link wherever a reader tried links first. A
96
+ * link the server resolved always states its display, even as the empty string,
97
+ * so requiring the KEY costs a real link nothing.
98
+ */
91
99
  function asLink(v) {
92
100
  if (!v || typeof v !== "object")
93
101
  return null;
@@ -95,7 +103,7 @@ function asLink(v) {
95
103
  if (typeof id !== "string" || !id)
96
104
  return null;
97
105
  const display = v.display;
98
- return { id, display: typeof display === "string" ? display : "" };
106
+ return typeof display === "string" ? { id, display } : null;
99
107
  }
100
108
  /**
101
109
  * select_record_link → the FIRST linked record as `{ id, display }` (or null).
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
 
@@ -116,6 +116,11 @@ sort for a typed alias names its keys `ColumnKeyOf<"alias">` — `QueryFilter<Co
116
116
  `QuerySortKey<…>`; `QueryFilterCondition` and `QueryFilterGroup` take the same parameter — so the
117
117
  check reaches the helper that derives the key, not only the hook call that sends it.
118
118
 
119
+ **The option map is keyed the same way.** `useFieldOptions(alias).fields` is keyed by that alias's
120
+ projected columns too — ask the alias that CARRIES the column, because a picker fed from a sibling
121
+ query's map is otherwise a control that renders empty with nothing reporting it. Every key is
122
+ optional: [./members_and_options.md](./members_and_options.md).
123
+
119
124
  `@lotics/ui`'s `columnFilterToConditions` carries the same parameter (`FilterableColumn<C>` →
120
125
  `FilterConditionNode<C>`), so a per-column filter UI built over a typed alias composes without a
121
126
  cast.
@@ -411,10 +416,17 @@ workflows), `__created_at` / `__updated_at` (record timestamps), and per-project
411
416
  metadata. A grouped query collapses rows and emits none of these. Details:
412
417
  [./queries.md](./queries.md).
413
418
 
414
- A row is typed `QueryRow`: projected columns are `unknown` (the manifest declares them, so the
415
- readers below are what narrow them), while `__source_record_id` / `__source_table_id` are typed
416
- `string | undefined` — reachable without a cast, but only after narrowing, because "a grouped query
417
- emits none of these" is a fact the type states rather than one you have to remember.
419
+ A row is typed from the alias's own projection. Codegen names the columns each query carries
420
+ (`AppQueryColumns` in `.lotics/app_queries.d.ts`) and a row read is bounded to them, so a misspelt
421
+ column, a renamed field and a projection moved to a sibling alias are one `tsc` error rather than a
422
+ blank cell — the server answers an unprojected column with `undefined`, and every reader below
423
+ answers `undefined` with its empty value, so nothing else reports it. The values stay `unknown` (a
424
+ column's TYPE is the field's, which the manifest does not carry, so the readers are the narrowing),
425
+ while `__source_record_id` / `__source_table_id` are typed `string | undefined` — reachable without
426
+ a cast, but only after narrowing, because "a grouped query emits none of these" is a fact the type
427
+ states rather than one you have to remember. Write the row type down as `RowOf<"alias">`; an alias
428
+ whose columns cannot be read off its AST keeps the open `QueryRow`, where any column compiles and
429
+ the server's answer is the only check — the rule the filter keys follow too.
418
430
 
419
431
  ### The readers
420
432
 
@@ -473,7 +485,10 @@ never carry avatar images — the avatar lives on the `useMembers` roster
473
485
 
474
486
  **`readLinks` / `row.link`.** `display` is the linked record's primary-field text — render it;
475
487
  use `id` to correlate, filter (link `has_any_of`), or fetch detail. `row.link` reads the first
476
- entry only — on a multi-link field use `readLinks`.
488
+ entry only — on a multi-link field use `readLinks`. An entry carrying an `id` and **no
489
+ `display` key** is not a link and is skipped: a member cell has that shape, so reading one as a
490
+ link would otherwise render every assignee under a blank name. Read a member cell with
491
+ `readMembers`.
477
492
 
478
493
  **`readFiles`.** Each entry's `url` (and `thumbnail_url` for images) is **presigned with a 24-hour
479
494
  TTL** — it renders directly in an `<Image>`/preview and works from the sandboxed iframe and for
@@ -68,8 +68,11 @@ const { fields } = useFieldOptions("orders"); // same alias you query
68
68
 
69
69
  ### What comes back
70
70
 
71
- `fields` is a `Record<outputColumnName, FieldOptions>` — keyed by the query's **output column
72
- name**, not the field key. Each `FieldOptions`:
71
+ `fields` is keyed by the query's **output column name**, not the field key — and by *that alias's*
72
+ columns: `Partial<Record<ColumnKeyOf<"alias">, FieldOptions>>`. A key the alias does not project is
73
+ a compile error, so a picker fed from a sibling query's option map fails at your desk instead of
74
+ rendering with no options; and every key is optional, so the read is through `?.` and never a bare
75
+ `.options`. Each `FieldOptions`:
73
76
 
74
77
  | Property | Meaning |
75
78
  | --- | --- |
@@ -80,8 +83,8 @@ name**, not the field key. Each `FieldOptions`:
80
83
  - `color` is a named palette token (e.g. `"blue"`, `"emerald"`). Pass the option straight to
81
84
  `@lotics/ui`'s `Status`; a missing/unrecognized token degrades to a neutral badge.
82
85
  - **A column the server can't map to a single source select field is simply absent** from
83
- `fields` — a UNION output whose arms disagree on the source field, or a computed column. Read
84
- defensively: `fields.status?.options ?? []`.
86
+ `fields` — a UNION output whose arms disagree on the source field, or a computed column. That is
87
+ why the type makes every key optional: `fields.status?.options ?? []`.
85
88
  - Addressed by the same alias you query, and scoped exactly like running that query — it exposes
86
89
  nothing the query itself doesn't. Params are irrelevant (they only fill filter values, never
87
90
  change the projection), so no params argument exists.
@@ -147,7 +150,7 @@ contract as `readSelect`: `[]` for null/empty/unexpected cells, malformed entrie
147
150
  The candidate set for an "assign to a member" picker (`dist/src/hooks.d.ts`):
148
151
 
149
152
  ```tsx
150
- const { members, loading, error } = useMembers({ group: "grp_fulfillment" });
153
+ const { members, loading, error } = useMembers({ group: GRP.fulfillment });
151
154
  <MemberSelect members={members} value={assignee} onValueChange={setAssignee} />
152
155
  ```
153
156
 
@@ -175,11 +178,17 @@ three gates are observable as an `error` on the hook:
175
178
  member declaration gets: *"This app has not declared member access — listing members requires a
176
179
  declared workflow `member` input or query `member` param."* Member access is a declared,
177
180
  auditable surface, like queries and workflows.
178
- 3. **Declared groups only.** `{ group: "grp_…" }` restricts the roster to one member group — but
181
+ 3. **Declared groups only.** `{ group }` restricts the roster to one member group — but
179
182
  only a group declared on some member input's `group` property. An undeclared group errors; an
180
183
  app can only enumerate groups it actually assigns into. A declared group with no members
181
184
  resolves to `{ members: [] }`.
182
185
 
186
+ Name the group `GRP.<group>`, from the generated `.lotics/app_fields.ts` — `GRP` is that workspace's
187
+ group directory keyed by slugified display name, so the same source resolves in every copy of the
188
+ app. A pasted `grp_…` id is an id one workspace minted: it resolves to nothing anywhere else, and
189
+ `lotics app check` refuses it. (The manifest DECLARATION below is the exception — it carries the
190
+ concrete id, and publish inverts it.)
191
+
183
192
  ```jsonc
184
193
  // package.json → "lotics" — the declaration that unlocks useMembers
185
194
  "workflows": {
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
@@ -82,6 +81,11 @@ convert every failure into a resolved `{ status: "error", message }`:
82
81
  | Alias not bound / workflow deleted | `status: "error"` with the explanatory message |
83
82
  | Gateway timeout (a 524 on a long run), any 5xx, a non-JSON error page | `status: "error"` with a friendly, **body-free** message — never raw gateway HTML |
84
83
 
84
+ A workflow call is **synchronous end to end** — about 100 s at the edge, 30 s through the bridged
85
+ host — and a timed-out call may still have committed. So model work that can run for minutes belongs
86
+ in an agent run, which is asynchronous by construction and polls to completion, never in a workflow
87
+ step.
88
+
85
89
  So the correct handling is:
86
90
 
87
91
  ```tsx
@@ -204,11 +208,13 @@ if (r.status === "success" && r.data) {
204
208
 
205
209
  ## Declaring workflow inputs
206
210
 
207
- An alias's declaration is `{ workflow_id, inputs?, outputs? }`. `inputs` maps each input name
208
- to a typed declaration; it is authored when the workflow is bound (`set_app_workflow` /
209
- `lotics app workflow set` reads it from `package.json#lotics.workflows.<alias>`), and the
210
- schema the body was verified against is canonical — the manifest reflection cannot silently
211
- weaken it. It drives three things at once: compile-time typing of the `useWorkflow` payload,
211
+ An alias's declaration is `{ inputs?, outputs?, workflow_id? }` — `workflow_id` is the server's
212
+ half, stamped by the first bind, so an alias an author has only just written carries none.
213
+ `inputs` maps each input name to a typed declaration; it is authored beside the body and
214
+ travels with it (`set_app_workflow` / `lotics app workflow set` reads it from
215
+ `package.json#lotics.workflows.<alias>`, and a deploy pushes a declaration that has moved), and
216
+ the schema the body was verified against is canonical — a manifest edit that reaches no push
217
+ weakens nothing. It drives three things at once: compile-time typing of the `useWorkflow` payload,
212
218
  compile-time typing of `trigger.app_workflow.inputs.*` inside the body, and runtime payload
213
219
  validation at the execute boundary.
214
220
 
@@ -278,7 +284,7 @@ When the alias declares `inputs`, the server validates the payload before the wo
278
284
  added after authoring is accepted, a removed one rejected
279
285
  (`select input "<path>" value "<v>" is not one of field "<key>"'s current options`) with no
280
286
  redeploy. A `field` that doesn't exist or names a non-select field is rejected at bind time
281
- (`lotics app workflow set` / `set_app_workflow`; `lotics app deploy` never binds workflows) with
287
+ (`lotics app workflow set` / `set_app_workflow`, which a deploy calls for a declaration it holds) with
282
288
  `select input references field "<key>", which does not exist in this workspace` /
283
289
  `… which is a <type> field, not a select`. Prefer `field` for any select backed by a real
284
290
  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
@@ -114,7 +114,9 @@ A result is `{ rows, columns, source_fields_by_table_id }` (`+ total` for a coun
114
114
  `columns` is the statically computed output schema — `{ name, type, nullable?,
115
115
  source_table_id?, source_field_key? }` per column. Rows are plain objects keyed by output
116
116
  column name; decode cells with the SDK readers (`row.*`, `readSelect`, `readMembers`,
117
- `readLinks`, `readFiles` — see [data_fetching.md](./data_fetching.md)).
117
+ `readLinks`, `readFiles` — see [data_fetching.md](./data_fetching.md)). Those output names are also
118
+ the row's TYPE: codegen writes them into `AppQueryColumns`, so reading a column this query does not
119
+ project is a `tsc` error.
118
120
 
119
121
  **`source_fields_by_table_id` is always `{}`** — the key is part of the shape, the map is not
120
122
  filled. The server reads the source tables' field definitions to resolve option and member
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.91.0",
3
+ "version": "0.93.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": {