@lotics/app-sdk 0.62.3 → 0.63.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
@@ -15,7 +15,7 @@ signature; open the file.**
15
15
  | Doc | Read it for |
16
16
  |---|---|
17
17
  | [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. |
18
- | [docs/data_fetching.md](./docs/data_fetching.md) | The three read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`), cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `useFieldOptions`, data discipline, the search-as-you-type + record-picker patterns. |
18
+ | [docs/data_fetching.md](./docs/data_fetching.md) | The three read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`), the `QueryRow` shape (projected columns `unknown`; `__source_record_id`/`__source_table_id` typed but optional), cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `useFieldOptions`, data discipline, the search-as-you-type + record-picker patterns. |
19
19
  | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract, typed inputs, diff-before-update, locked records, `useOptimistic`, read-after-write ordering (a re-read must not overtake an in-flight write). |
20
20
  | [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 + link 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`. |
21
21
  | [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. |
@@ -38,6 +38,9 @@ signature; open the file.**
38
38
  column, including files. → [queries](./docs/queries.md)
39
39
  - **Never hand-roll the serialization contract** — decode cells with the typed readers.
40
40
  → [data_fetching](./docs/data_fetching.md)
41
+ - **Narrow `__source_record_id` / `__source_table_id` before use** — a grouped query emits neither,
42
+ so an unchecked read hands a workflow (or an AI record ref) `undefined`.
43
+ → [data_fetching](./docs/data_fetching.md)
41
44
  - **Errors fail loud** — no swallowed catches, no silent fallbacks.
42
45
 
43
46
  ## Keeping this reference current
@@ -62,10 +62,18 @@ export interface UseCommentsArgs {
62
62
  record_id: string;
63
63
  }
64
64
  /**
65
+ * `record_id` must be a REAL id. The fetch keys on `available` alone, so an
66
+ * empty string is not skipped — it fetches comments for nothing. When the id
67
+ * comes off a row's `__source_record_id` addressing column, narrow it first: a
68
+ * grouped query carries none, and a hook cannot be called conditionally, so the
69
+ * guard belongs at the component that renders the panel.
70
+ *
65
71
  * ```tsx
66
- * const { comments, available, createComment } = useComments({
67
- * record_id: row.__source_record_id,
68
- * });
72
+ * // In a record screen, where an id definitionally exists:
73
+ * const { comments, available, createComment } = useComments({ record_id: recordId });
74
+ *
75
+ * // From a row, gate the whole panel rather than passing a placeholder:
76
+ * {row.__source_record_id && <CommentsPanel recordId={row.__source_record_id} />}
69
77
  * ```
70
78
  */
71
79
  export declare function useComments(args: UseCommentsArgs): CommentsState;
@@ -38,10 +38,18 @@ function toStorageFiles(files) {
38
38
  }
39
39
  let optimisticCounter = 0;
40
40
  /**
41
+ * `record_id` must be a REAL id. The fetch keys on `available` alone, so an
42
+ * empty string is not skipped — it fetches comments for nothing. When the id
43
+ * comes off a row's `__source_record_id` addressing column, narrow it first: a
44
+ * grouped query carries none, and a hook cannot be called conditionally, so the
45
+ * guard belongs at the component that renders the panel.
46
+ *
41
47
  * ```tsx
42
- * const { comments, available, createComment } = useComments({
43
- * record_id: row.__source_record_id,
44
- * });
48
+ * // In a record screen, where an id definitionally exists:
49
+ * const { comments, available, createComment } = useComments({ record_id: recordId });
50
+ *
51
+ * // From a row, gate the whole panel rather than passing a placeholder:
52
+ * {row.__source_record_id && <CommentsPanel recordId={row.__source_record_id} />}
45
53
  * ```
46
54
  */
47
55
  export function useComments(args) {
@@ -6,6 +6,41 @@ import type { ResolvedMember } from "./members.js";
6
6
  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
+ /**
10
+ * One row of a query result: the projected columns, plus the platform's
11
+ * ADDRESSING columns.
12
+ *
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.
16
+ *
17
+ * The `__source_*` columns are different in kind: the compiler injects them at
18
+ * every layer, and they are the only way to address the RECORD a row came from
19
+ * — what `useComments`, a workflow's `record_id` input, and `useAiContext`'s
20
+ * record refs all need. Typed as `unknown` they were unusable without a cast,
21
+ * which is how this SDK's own examples came to show code that does not compile.
22
+ *
23
+ * They are OPTIONAL, and that is the honest shape rather than a hedge: a
24
+ * grouped query collapses rows, so its output has no originating record and the
25
+ * compiler emits no addressing columns for it (the platform's own `record_id`
26
+ * filter refuses such a query for exactly this reason). Narrow before use —
27
+ * on an aggregate row these are genuinely absent, not merely unproven.
28
+ *
29
+ * The line for what belongs here is "consumed RAW", not "starts with
30
+ * `__source_`". A row also carries `__source_locked`, `__created_at` /
31
+ * `__updated_at` and per-projection `__src_field_*`, and each of those has a
32
+ * reader that owns its decoding (`readLocked`, the `row.*` helpers) — a reader
33
+ * IS the narrowing, so a type here would duplicate it. Only these two are
34
+ * handed straight to a `record_id` / `table_id` parameter with nothing in
35
+ * between, which is why only these two needed a type.
36
+ */
37
+ export interface QueryRow {
38
+ [column: string]: unknown;
39
+ /** The record this row came from. Absent on a grouped/aggregated row. */
40
+ __source_record_id?: string;
41
+ /** The table that record lives in. Absent on a grouped/aggregated row. */
42
+ __source_table_id?: string;
43
+ }
9
44
  /** Fields shared by every query hook's return value. */
10
45
  interface QueryStateBase {
11
46
  /**
@@ -152,7 +187,9 @@ export interface PaginatedQueryOptions extends BaseQueryOptions {
152
187
  *
153
188
  * ```tsx
154
189
  * const issue = useWorkflow("issueInvoiceStorageDrop");
155
- * await issue({ record_id: row.__source_record_id });
190
+ * // Guard the addressing column: a grouped query's rows carry none, and the
191
+ * // workflow would be handed `undefined` where it declares a record.
192
+ * if (row.__source_record_id) await issue({ record_id: row.__source_record_id });
156
193
  * ```
157
194
  */
158
195
  export declare function useWorkflow<K extends keyof AppWorkflows & string>(alias: K): UseWorkflowFn<K>;
@@ -199,8 +236,8 @@ type QueryArgs<K extends keyof AppQueries & string, O> = AppQueries[K] extends R
199
236
  * with the declared alias → param-type map, so an undeclared alias is a
200
237
  * compile-time error and params are typed per the manifest.
201
238
  */
202
- export declare function useQuery<K extends keyof AppQueries & string>(alias: K, ...args: QueryArgs<K, QueryOptions>): QueryState<Record<string, unknown>>;
203
- export declare function useQuery(alias: string, params?: Record<string, unknown>, opts?: QueryOptions): QueryState<Record<string, unknown>>;
239
+ export declare function useQuery<K extends keyof AppQueries & string>(alias: K, ...args: QueryArgs<K, QueryOptions>): QueryState<QueryRow>;
240
+ export declare function useQuery(alias: string, params?: Record<string, unknown>, opts?: QueryOptions): QueryState<QueryRow>;
204
241
  /** The resolved option set of one select column, plus an index for value
205
242
  * rendering. The companion to a query row, for select fields. */
206
243
  export interface FieldOptions {
@@ -272,8 +309,8 @@ export declare function useFieldOptions(alias: string, opts?: FieldOptionsOption
272
309
  * const { rows, loadMore, hasMore } = useInfiniteQuery("feed", {}, { pageSize: 30 });
273
310
  * ```
274
311
  */
275
- export declare function useInfiniteQuery<K extends keyof AppQueries & string>(alias: K, ...args: QueryArgs<K, InfiniteQueryOptions>): InfiniteQueryState<Record<string, unknown>>;
276
- export declare function useInfiniteQuery(alias: string, params?: Record<string, unknown>, opts?: InfiniteQueryOptions): InfiniteQueryState<Record<string, unknown>>;
312
+ export declare function useInfiniteQuery<K extends keyof AppQueries & string>(alias: K, ...args: QueryArgs<K, InfiniteQueryOptions>): InfiniteQueryState<QueryRow>;
313
+ export declare function useInfiniteQuery(alias: string, params?: Record<string, unknown>, opts?: InfiniteQueryOptions): InfiniteQueryState<QueryRow>;
277
314
  /**
278
315
  * Page-model query with a total — the data hook behind a numbered, jumpable
279
316
  * table (pairs with `@lotics/ui/pagination`). It owns the page
@@ -288,8 +325,8 @@ export declare function useInfiniteQuery(alias: string, params?: Record<string,
288
325
  * usePaginatedQuery("orders", { q }, { pageSize: 25, sort, filter });
289
326
  * ```
290
327
  */
291
- export declare function usePaginatedQuery<K extends keyof AppQueries & string>(alias: K, ...args: QueryArgs<K, PaginatedQueryOptions>): PaginatedQueryState<Record<string, unknown>>;
292
- export declare function usePaginatedQuery(alias: string, params?: Record<string, unknown>, opts?: PaginatedQueryOptions): PaginatedQueryState<Record<string, unknown>>;
328
+ export declare function usePaginatedQuery<K extends keyof AppQueries & string>(alias: K, ...args: QueryArgs<K, PaginatedQueryOptions>): PaginatedQueryState<QueryRow>;
329
+ export declare function usePaginatedQuery(alias: string, params?: Record<string, unknown>, opts?: PaginatedQueryOptions): PaginatedQueryState<QueryRow>;
293
330
  /** A file the host has stored and resolved serving URLs for. */
294
331
  export interface UploadedFile {
295
332
  id: string;
@@ -412,7 +449,14 @@ export declare function useAttachments(): AttachmentsState;
412
449
  * ```tsx
413
450
  * useAiContext("orders_list", {
414
451
  * description: `Viewing ${rows.length} orders filtered to status=open, sorted by due date.`,
415
- * records: rows.map((r) => ({ table_id: r.__source_table_id, record_id: r.__source_record_id })),
452
+ * // `flatMap` + the guard, not `map`: the addressing columns are absent on a
453
+ * // GROUPED query's rows, so a ref built without checking carries `undefined`
454
+ * // and points the agent at nothing.
455
+ * records: rows.flatMap((r) =>
456
+ * r.__source_table_id && r.__source_record_id
457
+ * ? [{ table_id: r.__source_table_id, record_id: r.__source_record_id }]
458
+ * : [],
459
+ * ),
416
460
  * data: { filter: "status=open", sort: "due_date desc" },
417
461
  * });
418
462
  * ```
package/dist/src/hooks.js CHANGED
@@ -373,7 +373,14 @@ function serializeAiContext(context) {
373
373
  * ```tsx
374
374
  * useAiContext("orders_list", {
375
375
  * description: `Viewing ${rows.length} orders filtered to status=open, sorted by due date.`,
376
- * records: rows.map((r) => ({ table_id: r.__source_table_id, record_id: r.__source_record_id })),
376
+ * // `flatMap` + the guard, not `map`: the addressing columns are absent on a
377
+ * // GROUPED query's rows, so a ref built without checking carries `undefined`
378
+ * // and points the agent at nothing.
379
+ * records: rows.flatMap((r) =>
380
+ * r.__source_table_id && r.__source_record_id
381
+ * ? [{ table_id: r.__source_table_id, record_id: r.__source_record_id }]
382
+ * : [],
383
+ * ),
377
384
  * data: { filter: "status=open", sort: "due_date desc" },
378
385
  * });
379
386
  * ```
@@ -17,7 +17,7 @@
17
17
  export { mount } from "./mount.js";
18
18
  export type { MountOptions } from "./mount.js";
19
19
  export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, buildChoiceOutput, } from "./hooks.js";
20
- export type { UploadedFile, AttachedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterGroup, WorkflowResult, MembersOptions, AgentRunOptions, UseAgentRun, AgentRunLanding, AgentRunRecord, AgentRunState, AgentUIPart, PendingChoice, ChoiceQuestion, ChoiceOption, AskUserChoiceOutput, FieldOptions, FieldOptionsState, FieldOptionsOptions, } from "./hooks.js";
20
+ export type { QueryRow, UploadedFile, AttachedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterGroup, WorkflowResult, MembersOptions, AgentRunOptions, UseAgentRun, AgentRunLanding, AgentRunRecord, AgentRunState, AgentUIPart, PendingChoice, ChoiceQuestion, ChoiceOption, AskUserChoiceOutput, FieldOptions, FieldOptionsState, FieldOptionsOptions, } from "./hooks.js";
21
21
  export { useComments, useCommentCounts } from "./comments.js";
22
22
  export type { AppComment, AppCommentFile, CommentsState, UseCommentsArgs, CommentCountsState, UseCommentCountsArgs, } from "./comments.js";
23
23
  export { useViewer } from "./viewer.js";
package/docs/ai.md CHANGED
@@ -277,7 +277,13 @@ import { useAiContext } from "@lotics/app-sdk";
277
277
  // A list screen publishes what it rendered:
278
278
  useAiContext("orders_list", {
279
279
  description: `Viewing ${rows.length} orders filtered to status=open, sorted by due date.`,
280
- records: rows.map((r) => ({ table_id: r.__source_table_id, record_id: r.__source_record_id })),
280
+ // Guarded, not `map`: the addressing columns are absent on a GROUPED query's
281
+ // rows, so an unchecked ref carries `undefined` and points the agent at nothing.
282
+ records: rows.flatMap((r) =>
283
+ r.__source_table_id && r.__source_record_id
284
+ ? [{ table_id: r.__source_table_id, record_id: r.__source_record_id }]
285
+ : [],
286
+ ),
281
287
  data: { filter: "status=open", sort: "due_date desc" },
282
288
  });
283
289
 
@@ -246,6 +246,11 @@ workflows), `__created_at` / `__updated_at` (record timestamps), and per-project
246
246
  metadata. A grouped query collapses rows and emits none of these. Details:
247
247
  [./queries.md](./queries.md).
248
248
 
249
+ A row is typed `QueryRow`: projected columns are `unknown` (the manifest declares them, so the
250
+ readers below are what narrow them), while `__source_record_id` / `__source_table_id` are typed
251
+ `string | undefined` — reachable without a cast, but only after narrowing, because "a grouped query
252
+ emits none of these" is a fact the type states rather than one you have to remember.
253
+
249
254
  ### The readers
250
255
 
251
256
  | Reader | In → out | Semantics |
package/docs/files.md CHANGED
@@ -265,6 +265,9 @@ with `files` absent.
265
265
 
266
266
  ```tsx
267
267
  const generate = useWorkflow("generateInvoice");
268
+ // `__source_record_id` is optional — a grouped query carries none — so narrow it
269
+ // rather than handing the workflow `undefined` where it declares a record.
270
+ if (!row.__source_record_id) return;
268
271
  const result = await generate({ record_id: row.__source_record_id });
269
272
  if (result.status === "success" && result.files?.length) {
270
273
  await openExternal(result.files[0].url);
@@ -241,13 +241,18 @@ client-side up front, and re-checked server-side.
241
241
  ### Reading & writing
242
242
 
243
243
  ```tsx
244
- const { comments, available, createComment } = useComments({
245
- record_id: row.__source_record_id,
246
- });
244
+ // In a record screen, where an id definitionally exists:
245
+ const { comments, available, createComment } = useComments({ record_id: recordId });
246
+
247
+ // From a row, gate the whole panel rather than passing a placeholder:
248
+ {row.__source_record_id && <CommentsPanel recordId={row.__source_record_id} />}
247
249
  ```
248
250
 
249
- `record_id` comes from the row's `__source_record_id` addressing column (see
250
- [queries](./queries.md)). State: `{ comments, loading, error, available, createComment,
251
+ `record_id` must be a **real** id — the fetch keys on `available` alone, so an empty string is not
252
+ skipped, it fetches comments for nothing. When the id comes from a row's `__source_record_id`
253
+ addressing column (see [queries](./queries.md)) narrow it first: a grouped query emits no addressing
254
+ columns, and a hook cannot be called conditionally, so the guard belongs at the component that
255
+ renders the panel. State: `{ comments, loading, error, available, createComment,
251
256
  updateComment, deleteComment, refetch }`.
252
257
 
253
258
  - `comments` — newest first on the wire (server order). Pass the array as-is to `@lotics/ui`'s
package/docs/queries.md CHANGED
@@ -442,7 +442,7 @@ derived surfaces share one implementation.
442
442
  | formula | its declared output type (`number`/`text`/`boolean`/`date`/`datetime`; `json` until inferred) | filtered by the output type's operators; `#ERROR:` cells are guarded — they count as empty and never match value operators | same | Extracted as a real scalar, so numeric formulas feed `sum`/`avg`/sorts. A **datetime-output** formula matches day-level date filters (full-day expansion). |
443
443
  | rollup | number-returning ops → `number`; `earliest`/`latest`/`date_range` → `date`/`datetime` per the aggregated field's format | number or date operators per output type; text operators route as text | same | Datetime-format rollups match day-level date filters. |
444
444
  | lookup | the looked-up field's type | operators of the looked-up type, evaluated with ANY-element semantics over the linked values (a row matches if *any* linked value matches) | array-typed lookups follow their column type's rules | **Projection returns the first element only.** Emptiness on a files-lookup checks the inner file arrays (a linked record with zero files counts empty). For all values across links: unnest the link column + join (§8). |
445
- | autonumber | `text` | text operators — filter by the visible composed string (`"ORD-0042"`) | same | Lexical sort matches numeric order (constant prefix + padding). |
445
+ | autonumber | `text` | text operators — filter by the visible composed string (`"ORD-0042"`) | same | Sorts numerically when the stored value is a bare integer, lexicographically otherwise — so `"ORD-0042"` orders by its padding and an unprefixed `"124"` orders after `"99"`, not before it. |
446
446
  | button | `json` | **not filterable** — buttons are not data | — | Project/ignore; only presence-counting aggregates. |
447
447
 
448
448
  **Field-less conditions** (no `field_key`):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.62.3",
3
+ "version": "0.63.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {
@@ -20,7 +20,7 @@
20
20
  "docs"
21
21
  ],
22
22
  "scripts": {
23
- "build": "tsgo",
23
+ "build": "tsgo -p tsconfig.build.json",
24
24
  "typecheck": "tsgo --noEmit",
25
25
  "test": "vitest run",
26
26
  "prepublishOnly": "npm run build"