@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 +5 -2
- package/dist/src/hooks.d.ts +67 -27
- package/dist/src/hooks.js +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/row.js +10 -2
- package/dist/src/rpc.js +41 -3
- package/docs/data_fetching.md +20 -5
- package/docs/members_and_options.md +15 -6
- package/docs/mutations.md +12 -4
- package/docs/queries.md +3 -1
- package/package.json +1 -1
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 `
|
|
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
|
|
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)
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -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:
|
|
11
|
-
*
|
|
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`
|
|
14
|
-
* the
|
|
15
|
-
*
|
|
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
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
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<
|
|
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
|
-
|
|
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
|
|
373
|
-
*
|
|
374
|
-
*
|
|
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<
|
|
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
|
|
399
|
-
*
|
|
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<
|
|
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<
|
|
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
|
|
621
|
-
* `
|
|
622
|
-
*
|
|
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:
|
|
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:
|
|
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
|
* // }))} />
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/docs/data_fetching.md
CHANGED
|
@@ -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
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
72
|
-
|
|
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.
|
|
84
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
110
|
-
|
|
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
|