@lotics/app-sdk 0.91.1 → 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";
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).
@@ -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
@@ -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
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.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": {