@lotics/app-sdk 0.91.1 → 0.94.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
  /**
@@ -311,11 +334,16 @@ export interface WorkflowResult<TData = unknown> {
311
334
  files?: UploadedFile[];
312
335
  data?: TData;
313
336
  /**
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.
337
+ * Per-input refusals, keyed by the INPUT name the alias declares — what a
338
+ * form wires straight onto the control that is wrong
339
+ * (`<FormField error={result.field_errors?.ly_do}>`), where `message` can
340
+ * only say it at the dialog's scope.
341
+ *
342
+ * Either half of the round trip fills it: the SERVER, when the payload does
343
+ * not match what the alias declares, and the workflow's own
344
+ * `return({ field_errors })`. One key, so a screen wires the control once
345
+ * rather than branching on which half refused. Absent when neither named a
346
+ * field — and an older server names none.
319
347
  */
320
348
  field_errors?: Record<string, string>;
321
349
  }
@@ -344,9 +372,10 @@ type QueryArgs<K extends string, O> = K extends keyof AppQueries ? AppQueries[K]
344
372
  *
345
373
  * Per-app CLI codegen writes `.lotics/app_queries.d.ts` augmenting `AppQueries`
346
374
  * with the declared alias → param-type map, so an undeclared alias is a
347
- * compile-time error and params are typed per the manifest.
375
+ * compile-time error and params are typed per the manifest. The same file names
376
+ * the alias's projected columns, which is what bounds a ROW read — see `RowOf`.
348
377
  */
349
- export declare function useQuery<K extends string>(alias: K, ...args: QueryArgs<K, QueryOptions<ColumnKeyOf<K>>>): QueryState<QueryRow>;
378
+ export declare function useQuery<K extends string>(alias: K, ...args: QueryArgs<K, QueryOptions<ColumnKeyOf<K>>>): QueryState<RowOf<K>>;
350
379
  /** The resolved option set of one select column, plus an index for value
351
380
  * rendering. The companion to a query row, for select fields. */
352
381
  export interface FieldOptions {
@@ -366,14 +395,20 @@ export interface FieldOptions {
366
395
  */
367
396
  byKey: (key: string) => ResolvedOption | undefined;
368
397
  }
369
- /** Return value of `useFieldOptions`. */
370
- export interface FieldOptionsState {
398
+ /** Return value of `useFieldOptions`. `C` is the column key's type — the alias's
399
+ * projected outputs, see `ColumnKeyOf`. */
400
+ export interface FieldOptionsState<C extends string = string> {
371
401
  /**
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?`).
402
+ * Resolved option sets keyed by the query's OUTPUT column name — the alias's
403
+ * own columns, so a key belonging to another query is a `tsc` error rather
404
+ * than a `[]` picker nobody can open.
405
+ *
406
+ * EVERY key is optional, and that is the shape rather than a hedge: a select
407
+ * column the server could not resolve to a source field (a UNION output whose
408
+ * arms disagree, a computed column) is genuinely absent, and a non-select
409
+ * column never had options. Read through it (`fields.status?.options ?? []`).
375
410
  */
376
- fields: Record<string, FieldOptions>;
411
+ fields: Partial<Record<C, FieldOptions>>;
377
412
  loading: boolean;
378
413
  isValidating: boolean;
379
414
  error: string | null;
@@ -395,8 +430,11 @@ export interface FieldOptionsOptions {
395
430
  * added/removed option flows through with no app change.
396
431
  *
397
432
  * 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.
433
+ * query's output columns, scoped exactly like running it, and `fields` is keyed
434
+ * by that same union — the alias that CARRIES the column is the one to ask, and
435
+ * asking another is a compile error instead of an empty picker. A column the
436
+ * server can't map to a source select field (UNION output, computed column) is
437
+ * absent, so every key is optional.
400
438
  *
401
439
  * ```tsx
402
440
  * const { fields } = useFieldOptions("records");
@@ -407,7 +445,7 @@ export interface FieldOptionsOptions {
407
445
  * <Status option={fields.status?.byKey(readSelect(r.status)[0]?.key ?? "")} />
408
446
  * ```
409
447
  */
410
- export declare function useFieldOptions<K extends keyof AppQueries & string>(alias: K, opts?: FieldOptionsOptions): FieldOptionsState;
448
+ export declare function useFieldOptions<K extends keyof AppQueries & string>(alias: K, opts?: FieldOptionsOptions): FieldOptionsState<ColumnKeyOf<K>>;
411
449
  export declare function useFieldOptions(alias: string, opts?: FieldOptionsOptions): FieldOptionsState;
412
450
  /**
413
451
  * Like `useQuery` but append/load-more: the first render loads one page and
@@ -418,7 +456,7 @@ export declare function useFieldOptions(alias: string, opts?: FieldOptionsOption
418
456
  * const { rows, loadMore, hasMore } = useInfiniteQuery("feed", {}, { pageSize: 30 });
419
457
  * ```
420
458
  */
421
- export declare function useInfiniteQuery<K extends string>(alias: K, ...args: QueryArgs<K, InfiniteQueryOptions<ColumnKeyOf<K>>>): InfiniteQueryState<QueryRow>;
459
+ export declare function useInfiniteQuery<K extends string>(alias: K, ...args: QueryArgs<K, InfiniteQueryOptions<ColumnKeyOf<K>>>): InfiniteQueryState<RowOf<K>>;
422
460
  /**
423
461
  * Page-model query with a total — the data hook behind a numbered, jumpable
424
462
  * table (pairs with `@lotics/ui/pagination`). It owns the page
@@ -433,7 +471,7 @@ export declare function useInfiniteQuery<K extends string>(alias: K, ...args: Qu
433
471
  * usePaginatedQuery("orders", { q }, { pageSize: 25, sort, filter });
434
472
  * ```
435
473
  */
436
- export declare function usePaginatedQuery<K extends string>(alias: K, ...args: QueryArgs<K, PaginatedQueryOptions<ColumnKeyOf<K>>>): PaginatedQueryState<QueryRow>;
474
+ export declare function usePaginatedQuery<K extends string>(alias: K, ...args: QueryArgs<K, PaginatedQueryOptions<ColumnKeyOf<K>>>): PaginatedQueryState<RowOf<K>>;
437
475
  /**
438
476
  * HOW MANY rows a query matches — one number, no rows fetched.
439
477
  *
@@ -617,9 +655,11 @@ interface MembersState {
617
655
  /** Options for `useMembers`. */
618
656
  export interface MembersOptions {
619
657
  /**
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.
658
+ * Restrict to one member group, as `GRP.<group>` from `.lotics/app_fields.ts`
659
+ * (a pasted `grp_…` id is refused by `lotics app check` — it resolves to
660
+ * nothing in a copy of the app). The group must be declared on a `member`
661
+ * workflow input's `group` — listing an undeclared group errors. Omit to list
662
+ * the whole org roster.
623
663
  */
624
664
  group?: string;
625
665
  }
@@ -636,7 +676,7 @@ export interface MembersOptions {
636
676
  * `group` — so an app can only list (and assign into) groups it declares.
637
677
  *
638
678
  * ```tsx
639
- * const { members } = useMembers({ group: "grp_..." });
679
+ * const { members } = useMembers({ group: GRP.sale });
640
680
  * // <Select variant="native" options={members.map((m) => ({
641
681
  * // value: m.id, label: m.name || m.email || m.id, image: m.image,
642
682
  * // }))} />
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";
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/dist/src/rpc.js CHANGED
@@ -568,6 +568,34 @@ export function transportErrorMessage(status, parsed) {
568
568
  parsed.code.length > 0;
569
569
  return status < 500 || authored ? jsonMessage : gatewayErrorMessage(status);
570
570
  }
571
+ /**
572
+ * A failed request, carrying the per-input refusals when the API named any.
573
+ *
574
+ * A body that does not match what an alias declares is answered 400 with
575
+ * top-level `field_errors` — one sentence per INPUT name, which is what a form
576
+ * puts on the control that is wrong, where the message can only say it at the
577
+ * screen's scope. It rides the error because the transport's own callers are
578
+ * what turn a failure into the result an app reads. Optional throughout: a
579
+ * server that predates them sends none.
580
+ */
581
+ class ApiRefusal extends Error {
582
+ field_errors;
583
+ constructor(message, field_errors) {
584
+ super(message);
585
+ this.field_errors = field_errors;
586
+ }
587
+ }
588
+ /** The `field_errors` of an error body, when it carries a well-formed one. */
589
+ function readFieldErrors(parsed) {
590
+ if (parsed === null || typeof parsed !== "object" || !("field_errors" in parsed)) {
591
+ return undefined;
592
+ }
593
+ const raw = parsed.field_errors;
594
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
595
+ return undefined;
596
+ const named = Object.entries(raw).filter((entry) => typeof entry[1] === "string");
597
+ return named.length > 0 ? Object.fromEntries(named) : undefined;
598
+ }
571
599
  /**
572
600
  * The error a stream that never started should throw.
573
601
  *
@@ -663,8 +691,9 @@ async function apiCall(method, path, body, opts) {
663
691
  return new Promise(() => { });
664
692
  }
665
693
  // Never surface a non-JSON body (a gateway HTML error page) or a 5xx body as
666
- // the message — emit a body-free, status-derived message instead.
667
- throw new Error(transportErrorMessage(res.status, parsed));
694
+ // the message — emit a body-free, status-derived message instead. The
695
+ // per-input refusals ride along, for the caller that can place them.
696
+ throw new ApiRefusal(transportErrorMessage(res.status, parsed), readFieldErrors(parsed));
668
697
  }
669
698
  return parsed ?? (text ? text : {});
670
699
  }
@@ -793,7 +822,16 @@ async function standaloneWorkflow(p) {
793
822
  // rejection carrying a raw body) so an app reads `result.status === "error"`
794
823
  // uniformly with a handled workflow error. `apiCall` already sanitized the
795
824
  // message, so it never contains an HTML body.
796
- return { status: "error", message: err instanceof Error ? err.message : "The workflow failed to run." };
825
+ //
826
+ // A payload the alias refuses is that same shape plus the inputs it named,
827
+ // which is the half a form can act on — the same key a workflow's own
828
+ // `return({ field_errors })` arrives under, so a screen wires one control
829
+ // once whichever half answered.
830
+ return {
831
+ status: "error",
832
+ message: err instanceof Error ? err.message : "The workflow failed to run.",
833
+ ...(err instanceof ApiRefusal && err.field_errors ? { field_errors: err.field_errors } : {}),
834
+ };
797
835
  }
798
836
  }
799
837
  async function standaloneAgentRuns(p) {
@@ -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
@@ -65,7 +65,7 @@ Every call resolves to a `WorkflowResult<TData>` (type exported from the package
65
65
  | `message` | `string?` | The workflow's `return({ message })` text, a validation/binding error, or a body-free transport message |
66
66
  | `files` | `UploadedFile[]?` | Files generated during the run (auto-collected — below). Absent when the run generated none |
67
67
  | `data` | `TData?` | Structured data from `return({ data })`. Absent when no return step ran |
68
- | `field_errors` | `Record<string, string>?` | Per-input refusals from `return({ field_errors })`, keyed by the INPUT name the alias declares. Absent when the workflow returned none |
68
+ | `field_errors` | `Record<string, string>?` | Per-input refusals, keyed by the INPUT name the alias declares — from the server when the payload does not match what the alias declares, and from the workflow's own `return({ field_errors })`. Absent when neither named a field |
69
69
 
70
70
  ### The failure model: check `status`, never just try/catch
71
71
 
@@ -81,6 +81,11 @@ convert every failure into a resolved `{ status: "error", message }`:
81
81
  | Alias not bound / workflow deleted | `status: "error"` with the explanatory message |
82
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 |
83
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
+
84
89
  So the correct handling is:
85
90
 
86
91
  ```tsx
@@ -106,8 +111,10 @@ passes through unvalidated).
106
111
 
107
112
  ### Locating a refusal: `field_errors`
108
113
 
109
- A workflow that validates its own inputs refuses with both halves — a message for the dialog and a
110
- map for the controls:
114
+ Two things refuse a call, and both address the input they refused. A payload that does not match
115
+ what the alias declares — a missing required input, a value of the wrong type — is refused before
116
+ the workflow runs, with one sentence per input name. A workflow that validates its own inputs
117
+ refuses the same way, with both halves — a message for the dialog and a map for the controls:
111
118
 
112
119
  ```ts
113
120
  return({ status: "error", message: "Thiếu lý do.",
@@ -125,7 +132,8 @@ setErrs(result.field_errors ?? {});
125
132
  ```
126
133
 
127
134
  Keep `message` too — it is what a refusal with no field to blame says, and the two are shown
128
- together (a `Callout` at the dialog's scope plus the per-field text).
135
+ together (a `Callout` at the dialog's scope plus the per-field text). One reading covers both
136
+ refusals: the key is the same whichever half answered, so a form never branches on that.
129
137
 
130
138
  ### Generated files come back in `files[]`
131
139
 
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.1",
3
+ "version": "0.94.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": {