@lotics/app-sdk 0.111.4 → 0.112.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/dist/mock.d.ts CHANGED
@@ -7,8 +7,9 @@
7
7
  * `useFileUpload` is not mocked.
8
8
  */
9
9
  import type { QueryAggregate } from "./shared_types.js";
10
- import type { UploadedFile, WorkflowResult } from "./hooks.js";
10
+ import type { WorkflowResult } from "./hooks.js";
11
11
  import type { MockAgent } from "./mock_agent_run.js";
12
+ import type { ExportedFile } from "./shared_types.js";
12
13
  import type { ExportCall, QuerySortKey } from "./queries.js";
13
14
  import type { RecordingState } from "./recording_state.js";
14
15
  /** A result, or a function of the inputs — return a slow promise to review the pending state. */
@@ -27,7 +28,7 @@ export type MockQuery = Array<Record<string, unknown>> | ((call: MockQueryCall)
27
28
  /** The file an export of a query makes, as a function of the call — return a slow promise to review the pending state. */
28
29
  export type MockExport = (call: ExportCall & {
29
30
  alias: string;
30
- }) => UploadedFile | Promise<UploadedFile>;
31
+ }) => ExportedFile | Promise<ExportedFile>;
31
32
  export interface AppFixture {
32
33
  queries?: Record<string, MockQuery>;
33
34
  workflows?: Record<string, MockWorkflow>;
package/dist/queries.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { QueryAggregate } from "./shared_types.js";
2
2
  import type { AppQueries, AppQueryColumns } from "./types.js";
3
3
  import type { ResolvedOption } from "./select.js";
4
- import type { UploadedFile } from "./hooks.js";
5
4
  import type { ReportLanguage } from "./shared_types.js";
5
+ import type { ExportedFile } from "./shared_types.js";
6
6
  import type { ReportColumn, ReportReading } from "./shared_types.js";
7
7
  /**
8
8
  * One row of a query whose columns codegen could not read (a dynamic alias, a
@@ -234,9 +234,9 @@ export interface ExportCall {
234
234
  /**
235
235
  * Every row a declared query matches — narrowed by `filter` and ordered by `sort` as a page of it is — made into one
236
236
  * file by the server, so no row reaches the browser. Past the most rows one export holds it is refused, never cut.
237
- * Resolves the stored file, its URL signed for the caller.
237
+ * Resolves a link to download the file, signed for at most an hour.
238
238
  */
239
- export declare function exportQuery(alias: string, call: ExportCall): Promise<UploadedFile>;
239
+ export declare function exportQuery(alias: string, call: ExportCall): Promise<ExportedFile>;
240
240
  /** One select column's full option set. */
241
241
  export interface FieldOptions {
242
242
  /** The source field's display name. */
@@ -3,17 +3,81 @@
3
3
  * `scripts/build_sdk.mjs`, because that package is private and ships in no
4
4
  * tarball. Written at every build, never edited.
5
5
  */
6
- export type QueryAggregate = { by?: readonly string[]; day?: string; sum?: string; };
7
- export type ReportColumn = { key: string; header: string; type: "number" | "text" | "date"; labels?: { readonly [x: string]: string; }; };
6
+ /** The file an app export made, as a link to download it: no workspace file, so no id, and kept a day. */
7
+ export type ExportedFile = {
8
+ filename: string;
9
+ mime_type: string;
10
+ /** Signed for at most an hour, for the caller who exported. */
11
+ url: string;
12
+ /** Bytes. */
13
+ size: number;
14
+ /** When the server made it, ISO 8601. */
15
+ created_at: string;
16
+ };
17
+ /** What an aggregate asks: output columns to group by, an output date grouped by its day, an output number summed. */
18
+ export type QueryAggregate = {
19
+ by?: readonly string[];
20
+ day?: string;
21
+ sum?: string;
22
+ };
23
+ export type ReportColumn = {
24
+ /** Each row's cell by it. */
25
+ key: string;
26
+ header: string;
27
+ /** A `date` cell is an ISO day or moment. */
28
+ type: "number" | "text" | "date";
29
+ /** A select whose cells hold bare option keys, no field of the query naming them: the words each key prints as. */
30
+ labels?: { readonly [x: string]: string; };
31
+ };
8
32
  export type ReportLanguage = "vi" | "en";
9
- export type ReportPart = { label: string; value: number; share: number | null; };
10
- export type ReportPivotColumn = { label: string; total: number | null; };
11
- export type ReportPivotRow = { label: string; values: number[]; total: number | null; };
12
- export type ReportPoint = { label: string; value: number; partial: boolean; };
33
+ export type ReportPart = {
34
+ label: string;
35
+ value: number;
36
+ /** Its share of the whole, 0–1; none where the whole is unknown or nothing. */
37
+ share: number | null;
38
+ };
39
+ export type ReportPivotColumn = {
40
+ label: string;
41
+ total: number | null;
42
+ };
43
+ export type ReportPivotRow = {
44
+ label: string;
45
+ /** Its figure under every column, in their order. */
46
+ values: number[];
47
+ total: number | null;
48
+ };
49
+ export type ReportPoint = {
50
+ /** The period as its axis names it. */
51
+ label: string;
52
+ value: number;
53
+ /** Still running, or cut by the window's edge. */
54
+ partial: boolean;
55
+ };
13
56
  export type ReportReading = (ReportReadingHead & { kind: "metric"; value: number | null; }) | (ReportReadingHead & { kind: "breakdown"; byLabel: string; parts: ReportPart[]; total: number | null; }) | (ReportReadingHead & { kind: "trend"; points: ReportPoint[]; }) | (ReportReadingHead & { kind: "pivot"; rowsLabel: string; columnsLabel: string; columns: ReportPivotColumn[]; rows: ReportPivotRow[]; total: number | null; }) | { kind: "failed"; label: string; };
14
- export type ReportReadingHead = { label: string; valueLabel: string; unit: string | null; truncated: boolean; };
57
+ /** What every reading of numbers states beside its figures. */
58
+ export type ReportReadingHead = {
59
+ label: string;
60
+ /** What its figures are: the summed field's name, or a count's word. */
61
+ valueLabel: string;
62
+ /** The unit or currency its figures read in; none for a count or a plain number. */
63
+ unit: string | null;
64
+ /** The read behind it was cut: its figures stand, every total over them is left out (`null`). */
65
+ truncated: boolean;
66
+ };
15
67
  export type TableField = { key: string; name: string; description: string; type: "number"; format: "number" | "currency" | "percentage"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; currency?: string | null | undefined; unit?: string | null | undefined; unit_field?: string | null | undefined; currency_field?: string | null | undefined; default_value?: number | null | undefined; } | { key: string; name: string; description: string; type: "text"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; unique?: boolean | undefined; format?: "text" | "link" | "markdown" | undefined; default_value?: string | null | undefined; } | { key: string; name: string; description: string; type: "date"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; format?: "date" | "datetime" | "date_range" | "datetime_range" | undefined; timezone?: string | undefined; derive_from?: "created_at" | "updated_at" | undefined; default_value?: string | null | undefined; } | { key: string; name: string; description: string; type: "boolean"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; default_value?: boolean | null | undefined; } | { key: string; name: string; description: string; type: "select"; options: { key: string; name: string; color: "red" | "orange" | "amber" | "yellow" | "lime" | "green" | "emerald" | "teal" | "cyan" | "sky" | "blue" | "indigo" | "violet" | "purple" | "fuchsia" | "pink" | "rose" | "slate" | "gray" | "zinc" | "neutral" | "stone"; mark?: { kind: string; name: string; } | undefined; }[]; confirm_before_update?: boolean | undefined; required?: boolean | undefined; multi?: boolean | undefined; default_value?: string[] | null | undefined; } | { key: string; name: string; description: string; type: "select_member"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; multi?: boolean | undefined; default_value?: string[] | null | undefined; } | { key: string; name: string; description: string; type: "select_record_link"; table_id: string; display_field_keys: string[]; confirm_before_update?: boolean | undefined; required?: boolean | undefined; display_field_widths?: Record<string, number> | undefined; paired_field_key?: string | undefined; cardinality?: "one" | "many" | undefined; } | { key: string; name: string; description: string; type: "rollup"; source_field_key: string; aggregate_option: { operation: "count"; field_key?: string | undefined; } | { operation: "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique"; field_key: string; } | { operation: "min" | "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "sum" | "avg" | "median" | "max" | "range"; field_key: string; } | { operation: "unique" | "date_range" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "earliest" | "latest"; field_key: string; } | { operation: "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "selected"; field_key: string; } | { operation: "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "selected"; field_key: string; } | { operation: "empty" | "filled" | "percent_empty" | "percent_filled" | "selected"; field_key: string; } | { operation: "checked" | "unchecked" | "percent_checked" | "percent_unchecked"; field_key: string; } | { operation: "empty" | "filled" | "percent_empty" | "percent_filled" | "selected"; field_key: string; } | { operation: "min" | "unique" | "date_range" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "sum" | "avg" | "median" | "max" | "range" | "earliest" | "latest" | "checked" | "unchecked" | "percent_checked" | "percent_unchecked"; field_key: string; } | { operation: "min" | "unique" | "date_range" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "sum" | "avg" | "median" | "max" | "range" | "earliest" | "latest"; field_key: string; }; confirm_before_update?: boolean | undefined; required?: boolean | undefined; filter?: TableRecordFiltersGroupNode | undefined; aggregate_field_type?: "number" | "boolean" | "text" | "date" | "select" | "select_member" | "select_record_link" | "rollup" | "files" | "formula" | "lookup" | "button" | "autonumber" | undefined; aggregate_field_format?: string | undefined; aggregate_field_currency?: string | undefined; aggregate_field_unit?: string | undefined; aggregate_field_unit_field?: string | undefined; aggregate_field_currency_field?: string | undefined; } | { key: string; name: string; description: string; type: "lookup"; source_field_key: string; lookup_field_key: string; confirm_before_update?: boolean | undefined; required?: boolean | undefined; lookup_field_type?: "number" | "boolean" | "text" | "date" | "select" | "select_member" | "select_record_link" | "rollup" | "files" | "formula" | "lookup" | "button" | "autonumber" | undefined; lookup_field_format?: string | undefined; lookup_field_currency?: string | undefined; lookup_field_unit?: string | undefined; lookup_field_options?: { key: string; name: string; color?: string | undefined; mark?: { kind: string; name: string; } | undefined; }[] | undefined; order_by?: { field_key: string; direction: "asc" | "desc"; } | undefined; } | { key: string; name: string; description: string; type: "files"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; } | { key: string; name: string; description: string; type: "formula"; formula: { expression: string; format?: "number" | "currency" | "percentage" | "link" | undefined; currency?: string | undefined; unit?: string | undefined; unit_field?: string | undefined; currency_field?: string | undefined; options?: { key: string; name: string; color: "red" | "orange" | "amber" | "yellow" | "lime" | "green" | "emerald" | "teal" | "cyan" | "sky" | "blue" | "indigo" | "violet" | "purple" | "fuchsia" | "pink" | "rose" | "slate" | "gray" | "zinc" | "neutral" | "stone"; mark?: { kind: string; name: string; } | undefined; }[] | undefined; output_type?: "number" | "boolean" | "text" | "date" | "datetime" | "select" | undefined; volatile?: boolean | undefined; }; confirm_before_update?: boolean | undefined; required?: boolean | undefined; } | { key: string; name: string; description: string; type: "button"; text: string; confirm_before_update?: boolean | undefined; required?: boolean | undefined; workflow_id?: string | null | undefined; } | { key: string; name: string; description: string; type: "autonumber"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; prefix?: string | undefined; padding?: number | undefined; template?: string | undefined; };
16
68
  export type TableRecordFilters = TableRecordFiltersConditionNode | TableRecordFiltersTraversalNode | TableRecordFiltersGroupNode;
17
69
  export type TableRecordFiltersConditionNode = { node_type: "condition"; field_key?: string | undefined; type?: string | undefined; operator: string; value?: unknown; unit_option?: string | undefined; };
18
70
  export type TableRecordFiltersGroupNode = { node_type: "group"; logic: "and" | "or"; children: (TableRecordFilters)[]; };
71
+ /**
72
+ * Traversal node — filter the current table by a field on a record reachable
73
+ * through one or more `select_record_link` hops. ANY semantics: a row matches
74
+ * if at least one reachable record satisfies the inner condition.
75
+ *
76
+ * - `path`: the link field keys to walk, in order. Last entry must be a
77
+ * `select_record_link` field; all entries must be link fields.
78
+ * - `condition`: the standard field-condition shape, evaluated on the FINAL
79
+ * table reached by the path.
80
+ *
81
+ * Max path depth and inner-condition syntax are validated by `validateFilters`.
82
+ */
19
83
  export type TableRecordFiltersTraversalNode = { node_type: "traversal"; path: string[]; condition: TableRecordFiltersConditionNode; };
@@ -24,7 +24,7 @@ untyped. It is written from the app's live bindings by `lotics app create --cust
24
24
  | `useQuery(alias, params?, { rows: false, total: true })` | `total` only, no rows (`total: { by: column }` or `total: { where: { name: filter } }` adds `counts`) | a facet chip, a queue badge, an "N awaiting approval" tile — the size of a set you are not listing |
25
25
  | `useQueries(calls, opts?)` | one state per `{ alias, params?, filter?, sort?, aggregate?, total?, rows? }`, in order — a `sort` orders that call's rows before the cap cuts them | reads that are DATA — a list known only at render, one read per item, or one count per stage (`rows: false, total: true`, each state's `.total`); the deploy's alias scan reads a list as dynamic, so every alias in it is still declared. With `aggregate` a call answers the groups of its filtered rows instead of the rows ([queries](./queries.md)) |
26
26
  | `queryAll(alias, params?, { filter, sort })` | a promise of every row | outside React, for a job that must hold the whole narrowed set in the browser (the ids an act runs on): it asks page after page until the server says the set ended, never stopping at the 10,000-row cap. The server's rows as stored — no write of this app is drawn over them |
27
- | `exportQuery(alias, { params, filter, sort, tabs?, report, file })` | a promise of the file | every row the narrowed set holds, made into one file by the server — no row reaches the browser. `tabs` (2 to 5, each `{ tab, label, filter?, sort? }`) are each read as the export's own rows are — same query and params, under the tab's own filter and sort (absent, the declared order) — and a template reads each one's rows under its `tab`, a name none of the report's keys nor the template's `rows` key holds. `report` is `{ title, lines, readings, dates?, period?, per?, filename? }` — `dates` each date filter's `{ from?, to? }` by field alias, each bound a day (`yyyy-MM-dd`) or a minute of one (`yyyy-MM-ddTHH:mm`) with no zone; `period` the tabs' `{ from, to }`, bounds of the same form; `per` the value the report was picked for, in the reader's words; `filename` the file's name in the template grammar over one value each — `title`, `at` (the moment the server made the file), `dates.<field>.from` or `.to`, `period.from` or `.to`, `per`, `lines | lookup:<n>` — at most 200 bytes once filled (absent, the template's name, or `title`); `file` is `{ kind: "workbook", language, sheet, columns }` (the default report workbook, its head closing with the moment the server made it, each column `{ key, header, type, labels? }` an output column of the query — a `number` one on a number column, a `date` one on a date or datetime; `labels` the words each bare option key of a `select` column no field names prints as, refused on any other column and for a key it lacks; with `tabs`, one sheet per tab named by its `label` in place of `sheet`) or `{ kind: "template", template }`, one the query declares under `templates` by name ([queries](./queries.md)). Past 20,000 rows, in any tab, it rejects, never cuts; each caller makes at most 60 exports a minute. The file's `url` is signed for the caller: open it with `openExternal` |
27
+ | `exportQuery(alias, { params, filter, sort, tabs?, report, file })` | a promise of an `ExportedFile`: `{ filename, mime_type, url, size, created_at }` | every row the narrowed set holds, made into one file by the server — no row reaches the browser. `tabs` (2 to 5, each `{ tab, label, filter?, sort? }`) are each read as the export's own rows are — same query and params, under the tab's own filter and sort (absent, the declared order) — and a template reads each one's rows under its `tab`, a name none of the report's keys nor the template's `rows` key holds. `report` is `{ title, lines, readings, dates?, period?, per?, filename? }` — `dates` each date filter's `{ from?, to? }` by field alias, each bound a day (`yyyy-MM-dd`) or a minute of one (`yyyy-MM-ddTHH:mm`) with no zone; `period` the tabs' `{ from, to }`, bounds of the same form; `per` the value the report was picked for, in the reader's words; `filename` the file's name in the template grammar over one value each — `title`, `at` (the moment the server made the file), `dates.<field>.from` or `.to`, `period.from` or `.to`, `per`, `lines | lookup:<n>` — at most 200 bytes once filled (absent, the template's name, or `title`); `file` is `{ kind: "workbook", language, sheet, columns }` (the default report workbook, its head closing with the moment the server made it, each column `{ key, header, type, labels? }` an output column of the query — a `number` one on a number column, a `date` one on a date or datetime; `labels` the words each bare option key of a `select` column no field names prints as, refused on any other column and for a key it lacks; with `tabs`, one sheet per tab named by its `label` in place of `sheet`) or `{ kind: "template", template }`, one the query declares under `templates` by name ([queries](./queries.md)). Past 20,000 rows, in any tab, it rejects, never cuts; each caller makes at most 60 exports a minute. The file has no `id` — it is a download, not a workspace file a workflow or a field takes: its `url` is signed for the caller for at most an hour, so open it with `openExternal` when the promise resolves; the server deletes the file a day after it is made |
28
28
 
29
29
  Every hook answers the same `QueryState`: `rows`, `truncated`, `total`, `counts`, `loading`,
30
30
  `isValidating`, `error`, `pending`, `refetch`, `page`, `setPage`, `pageCount`, `hasMore`,
@@ -151,7 +151,8 @@ write it as `{ node_type: "condition", type: "record_id", operator, value }`.
151
151
  `refetch()` covers what none of those can know about.
152
152
  - **This app's own writes show from the press.** Every read draws each write this app has made
153
153
  over its rows before the server answers — cells, membership, new rows, `total`, `counts` and
154
- groups — and says **`pending`** while what one changes is not all drawn. Nothing to wire: [./mutations.md](./mutations.md)
154
+ groups — and says **`pending`** while what one changes is not all drawn. A visitor from outside
155
+ your organization sees each write when the server answers instead. Nothing to wire: [./mutations.md](./mutations.md)
155
156
  § A write shows from the press.
156
157
  - **Realtime push keeps an already-open screen current.** When a table one of your queries reads
157
158
  changes — another member, a workflow, the chat agent, or an external agent writing over the CLI or
package/docs/files.md CHANGED
@@ -32,7 +32,9 @@ storage, size and upload day), which is inert like an upload until a save puts i
32
32
  place. A save carrying `_added` and `_removed` of one field puts the added file where the removed
33
33
  one stood, so a rename or a replace keeps its position. The name keeps the file's extension
34
34
  exactly — `"scan.pdf"` → `"Invoice 9.pdf"`; `.html` is refused — and holds no `/`, `\` or control
35
- character. Offer it only where the app writes that field: the save is what may be refused.
35
+ character. Offer it only where the app writes that field: the save is what may be refused. A
36
+ visitor from outside your organization may rename only a file uploaded through this app, within
37
+ twenty minutes of that upload starting; any other file answers as a missing one.
36
38
 
37
39
  ## Uploading — `useFileUpload`
38
40
 
@@ -96,9 +98,10 @@ to object storage → finalize. The API server never proxies the bytes.
96
98
  - **Transport resilience.** The storage `PUT` has a 5-minute per-request timeout and retries
97
99
  network errors / timeouts / 5xx up to 3 attempts with 1 s → 2 s backoff between attempts. 4xx
98
100
  responses are terminal (no retry). The presigned upload URL itself is valid for 10 minutes.
99
- - **Server-side verification.** The finalize step verifies the actually-stored object: an object
100
- larger than the limit (or of unverifiable size) is rejected **and its bytes deleted** — the
101
- file record is never created.
101
+ - **Server-side verification.** The finalize step takes only the upload this app minted the URL
102
+ for, and verifies the actually-stored object: an object larger than the size declared when its
103
+ URL was minted (or of unverifiable size) is rejected **and its bytes deleted** — the file record
104
+ is never created.
102
105
 
103
106
  ### Upload limits
104
107
 
package/docs/mutations.md CHANGED
@@ -188,8 +188,9 @@ envelope's grammar — `message` is required, `field_errors` never reaches the a
188
188
  redeploy); use inline `options` for a fixed enum the app owns.
189
189
  - **Validated at run.** On a success return, the returned `data` is validated against the
190
190
  schema at the app boundary — a mismatch resolves as `status: "error"` with a field-level
191
- message, so a declared output is a real contract. An *error* return's `data` passes through
192
- unvalidated.
191
+ message, so a declared output is a real contract. A visitor from outside your organization reads
192
+ the generic sentence a failed run gives them instead, since the message names the keys the body
193
+ returned. An *error* return's `data` passes through unvalidated.
193
194
  - **An output field marked `required: false` types as `key?: T | null`.** The derivation
194
195
  collapses "may be absent" and "may be null" into that one flag, so the generated type admits
195
196
  both — narrow with `!= null`, never with a bare truthiness check on a number or a string.
@@ -327,8 +328,9 @@ const onClose = async (recordId: string) => {
327
328
  ```
328
329
 
329
330
  Only on **success**: a refused write changed nothing. A body that names a table only at run time,
330
- a run whose writes could not be predicted, a run before the app's write model has loaded, and a
331
- `useAgentRun` leg that lands, re-read every mounted query. A comment written or deleted re-reads
331
+ a run whose writes could not be predicted, a run before the app's write model has loaded, a run
332
+ by a visitor from outside your organization, and a `useAgentRun` leg that lands, re-read every
333
+ mounted query. A comment written or deleted re-reads
332
334
  every `useCommentCounts`. Under a design-time
333
335
  fixture every mounted read re-reads, since a fixture has no body to read tables from. The host's
334
336
  realtime push is separate: it carries changes **other** people make, and is far too slow for your
@@ -554,8 +556,11 @@ What the steps cannot tell before they run is never guessed:
554
556
  delete: every read over the tables it may touch (every read, where it names none) is `pending`
555
557
  until the answer lands.
556
558
  - A guard (`validate`, a `return` with `status: "error"`) it can decide over held rows: a refused
557
- write draws nothing. One it cannot decide is assumed to pass — the server is the authority, and
558
- a refusal takes the drawing back.
559
+ write draws nothing, and neither does one whose `validate` check throws on those rows, since the
560
+ server refuses that write too. One over a value the app does not hold is assumed to pass — the
561
+ server is the authority, and a refusal takes the drawing back.
562
+ - A prediction that takes more than a second: the run is sent without it, and its writes wait for
563
+ the answer — predicting never holds a press back longer than that.
559
564
  - A guard or branch comparing an input with a constant (`i.code == "VIP"`) never reaches the app:
560
565
  the constant would be shown to everyone who can open it. The guard is assumed to pass, and the
561
566
  branch's writes wait for the answer. A comparison with a held row's cell reaches it.
@@ -564,6 +569,11 @@ Every read hook returns **`pending`**: a write this app made is in flight over i
564
569
  landed with part of what it changes not yet read back. The rows already show what is known; use it
565
570
  for a quiet "saving" cue, never to hide the rows.
566
571
 
572
+ **A visitor from outside your organization sees a write when the server answers.** For anyone who
573
+ opens the app through its public link rather than access of their own, nothing is drawn ahead and
574
+ no read's `pending` turns on: each write shows once its run answers, so a press that needs a cue
575
+ before then takes it from the awaited result.
576
+
567
577
  **A create gets its id before the server answers.** Every row a `create_records` makes without
568
578
  `ids` is drawn under a `rec_*` the SDK mints, and the run carries those ids to the server, which
569
579
  stores each row under the one drawn for it — so the row drawn from the press is the row the server
package/docs/queries.md CHANGED
@@ -477,7 +477,7 @@ derived surfaces share one implementation.
477
477
  | select_member | `select_member` | select ops + `is_current_member`, `is_not_current_member` (field-scoped, no value; `is_not_current_member` matches every cell not holding the requester, an empty one included) | same — `is_current_member` **works at the runtime layer** too | `unnest` fans member ids. Anonymous public request: current-member binds the app owner (§1). |
478
478
  | select_record_link | `select_record_link` | membership by linked-record **id**: `has_any_of`, `has_none_of`, `has_all_of`; text over the cached **display**: `contains`, `not_contains`, `starts_with`, `ends_with`; `is_empty`, `is_not_empty` | same — id-membership works at the runtime layer (converged `[{id}]` containment) | Sorting orders by raw JSON, not display text — project the display (link extraction / `display_output`) and sort that. `unnest` fans link ids (+ display). |
479
479
  | files | `files` | `has_filename`, `has_mime_type` (substring), `has_file_count` (exact count), `is_empty`, `is_not_empty` | same | Presign-enriched at delivery (§1); `unnest` fans file ids. Only presence-counting aggregates. |
480
- | formula | its declared output type (`number`/`text`/`boolean`/`date`/`datetime`, or `select` for one stating its own options, its cell a single select's; `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). |
480
+ | formula | its declared output type (`number`/`text`/`boolean`/`date`/`datetime`, or `select` for one stating its own options, its cell a single select's; `json` until inferred) | filtered by the output type's operators; a cell holding an error (`#ERROR: …`) holds no value — it projects as `null` whatever the output type, and matches what an empty cell matches (`is_empty`, a negation such as `not_equals`) and nothing that needs a value | 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). |
481
481
  | rollup | decided by the OPERATION, never by the aggregated field: `earliest`/`latest` → `date`/`datetime` per that field's format; **every** other op → `number` | number or date operators per output type; text operators route as text | same | Datetime-format rollups match day-level date filters. `date_range` counts DAYS between the extremes, so it is a number despite its date input. A presence count (`filled`/`empty`/`percent_*`) is a number whatever it counts — including over `files`. |
482
482
  | lookup | the type of the VALUE the looked-up field yields — a formula's output type, a rollup's operation result, an autonumber's display string (`text`); never the word `formula`/`rollup` | operators of that 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). |
483
483
  | 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. |
@@ -569,8 +569,10 @@ axes from ONE named query — don't shard into a query-per-combination. Mark eac
569
569
  `required: false`; the server **prunes every filter condition (or traversal) whose
570
570
  `{{params.x}}` the caller didn't pass** — then collapses emptied groups — so an unset axis
571
571
  stops constraining instead of erroring. A param passed empty (`""`, `[]`, `null`) counts as not
572
- passed. A REQUIRED param passed empty is refused (400, naming the condition it feeds) when a
573
- filter reads it.
572
+ passed, and so does one that leaves a search with no word once trimmed and accent-folded (`" "`,
573
+ a lone accent): the condition or `from_table.search` it fills prunes. A REQUIRED param passed
574
+ either way is refused (400, naming the condition or the search it feeds — to a caller outside the
575
+ app's organization, the param alone).
574
576
 
575
577
  ```jsonc
576
578
  "search": {
@@ -633,8 +635,9 @@ index that is:
633
635
  - multi-term: the query splits on whitespace (max 10 terms), all terms must match (AND).
634
636
 
635
637
  It AND-s with `filter` (search *within* a scope) and is templatable
636
- (`"search": "{{params.q}}"`). An empty or whitespace-only term matches everything; an
637
- unresolved optional token prunes to match-all (§6).
638
+ (`"search": "{{params.q}}"`). A term with no word once trimmed and accent-folded matches
639
+ everything: an optional param that leaves one, or is not passed, prunes the search to match-all;
640
+ a required one is refused (§6).
638
641
 
639
642
  **Search-as-you-type over a LARGE table uses `search`, never a `contains` OR-group alone.** Per-field
640
643
  `contains` is unindexed — a zero-match keystroke forces a full-table scan that hangs the
package/docs/runtime.md CHANGED
@@ -78,7 +78,7 @@ returns a promise: the read stays loading until it settles, to review a pending
78
78
  the fixture nothing, as it asks the server nothing. **Partial mocking is
79
79
  supported**: an alias absent from the fixture still flows through the real
80
80
  transport. `fixture.exports[alias]` answers `exportQuery(alias, call)` — a
81
- function of the call returning the file, or a promise of it to review the
81
+ function of the call returning an `ExportedFile`, or a promise of one to review the
82
82
  pending state. Calling `mount` again (HMR) replaces the registration last-write-wins.
83
83
 
84
84
  - **Fixture rows stand in for wire rows.** They pass through the same code path
package/docs/workflows.md CHANGED
@@ -245,7 +245,9 @@ validate({ checks: [{
245
245
 
246
246
  `fail_when` rejects when truthy. A failing check ends the run as `status: "error"` with the
247
247
  failing messages joined — cleanly, not as a crash: it is control flow, so **`try`/`catch` does
248
- not catch it** (nor `return`).
248
+ not catch it** (nor `return`). A check that throws instead of answering — its `fail_when` or its
249
+ `message` — refuses the write too: the run fails with a message naming the step and the check's
250
+ index and nothing the check read, and `try`/`catch` does not catch that either.
249
251
 
250
252
  `field_key` is optional, a string literal, and carries the same key `return({ field_errors })`
251
253
  does: **the control the message belongs to, on the surface that renders it**. Each failing check
@@ -341,10 +343,11 @@ try {
341
343
  ```
342
344
 
343
345
  `e` is shaped `{ message, type, step_id?, detail? }`. Caught: tool errors and expression errors
344
- thrown inside the body. **Not caught:** `validate` failures and `return` (control flow), and
345
- errors from steps that run on *resume* after a `wait` inside the body — the wrapper spans one
346
- execution pass. There is no `finally`: put always-run steps after the `try`, always-on-failure
347
- steps in the `catch`.
346
+ thrown inside the body. **Not caught:** `validate` failures — a check that throws included — and
347
+ `return` (control flow), the run's own bounds (its time, its step count, and the node and 512 MB
348
+ bounds in *Traps*), and errors from steps that run on *resume* after a `wait` inside the body —
349
+ the wrapper spans one execution pass. There is no `finally`: put always-run steps after the `try`,
350
+ always-on-failure steps in the `catch`.
348
351
 
349
352
  ### Loops
350
353
 
@@ -819,6 +822,16 @@ The rules that are easy to get wrong because the failing code looks correct.
819
822
  `includes(["member", "chat_agent", "api_client"], trigger.change_origin.type)`. Not
820
823
  `runtime.change_origin`: that is the execution's own origin, always `table_workflow` here, so
821
824
  it discriminates nothing.
825
+ - **Expressions are bounded.** A helper that builds an array — `range`, `list`, `flatten`,
826
+ `concat` of arrays, `split`'s parts — makes at most 100,000 elements, and a callback helper
827
+ takes an array of at most 100,000. `join`, `replaceAll`, `padStart`, `padEnd`, `toString`,
828
+ `toJson` and a template string write at most 10,000,000 characters; `randomNumber` /
829
+ `randomAlphaNumeric` take a length of at most 1,000. Past one of those the call throws an
830
+ expression error, which `try`/`catch` catches, and so does a step whose result would take what
831
+ the run keeps past 67,108,864 characters of JSON. One evaluation runs at most 20,000,000 nodes —
832
+ a callback nested in another runs once per pair of elements — and the run's expressions build at
833
+ most 512 MB of arrays, objects and text in all, every temporary counted: past either, the run
834
+ fails whatever catches it. Build long text with `join`, never a fold of `+`.
822
835
 
823
836
  ## The bright line
824
837
 
@@ -886,7 +899,10 @@ check's message joined with `"; "`, and one `field_errors` entry per failing che
886
899
  **A rehearsal can stop early, and it says so in `evaluation_errors`.** Four bounds apply — 1 000
887
900
  iterations of a `while` / `do_while` / `c_for`, 10 000 steps, 50 live reads, and 20 seconds — and
888
901
  tripping any of them appends an entry naming the bound and ending "the plan below is incomplete",
889
- then stops. So read `evaluation_errors` before reading `planned_calls`: an entry there may be a
902
+ then stops. It also stops, with an entry saying why, where the live run would fail: at a
903
+ `validate` check that throws, an evaluation past the run's node or 512 MB bound (*Traps*), or a
904
+ return too long to write.
905
+ So read `evaluation_errors` before reading `planned_calls`: an entry there may be a
890
906
  truncated plan rather than a bug in your body. A `foreach` is deliberately NOT capped at 1 000 —
891
907
  its length is known before the first iteration, so the rehearsal walks every item and instead
892
908
  refuses, up front, exactly the lists a live run refuses (over 10 000 items). The 50-live-read bound
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.111.4",
3
+ "version": "0.112.0",
4
4
  "description": "The SDK a Lotics custom-code app reads and writes through \u2014 typed hooks over the host bridge, cell readers, mount() and AppRouter",
5
5
  "type": "module",
6
6
  "exports": {