@lotics/app-sdk 0.114.0 → 0.115.1

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.
@@ -64,7 +64,7 @@ export type ReportReadingHead = {
64
64
  /** The read behind it was cut: its figures stand, every total over them is left out (`null`). */
65
65
  truncated: boolean;
66
66
  };
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; };
67
+ export type TableField = { key: string; name: string; description: string; type: "number"; format: "number" | "currency" | "percentage"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; notation?: { style: "decimal"; } | { style: "currency"; currency: string | { per_row: string; }; } | { style: "unit"; unit: string | { per_row: string; }; } | 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_notation?: { style: "decimal"; } | { style: "currency"; currency: string | { per_row: string; }; } | { style: "unit"; unit: string | { per_row: string; }; } | 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_notation?: { style: "decimal"; } | { style: "currency"; currency: string | { per_row: string; }; } | { style: "unit"; unit: string | { per_row: string; }; } | 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; lookup_field_autonumber?: boolean | 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; notation?: { style: "decimal"; } | { style: "currency"; currency: string | { per_row: string; }; } | { style: "unit"; unit: string | { per_row: string; }; } | undefined; link?: true | undefined; 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; };
68
68
  export type TableRecordFilters = TableRecordFiltersConditionNode | TableRecordFiltersTraversalNode | TableRecordFiltersGroupNode;
69
69
  export type TableRecordFiltersConditionNode = { node_type: "condition"; field_key?: string | undefined; type?: string | undefined; operator: string; value?: unknown; unit_option?: string | undefined; };
70
70
  export type TableRecordFiltersGroupNode = { node_type: "group"; logic: "and" | "or"; children: (TableRecordFilters)[]; };
@@ -86,9 +86,9 @@ a compile error, and every key is optional. Each `FieldOptions`:
86
86
 
87
87
  ### A figure read in each row's own unit: `units`
88
88
 
89
- A number whose field names a `unit_field` or `currency_field` reads each row in the option a select
90
- on that row holds, so a comparison on it states which one as `unit_option` — the server refuses one
91
- that does not, and keeps only the rows in that unit (a measured unit's kin, `kg` beside `t`,
89
+ A number whose field's `notation` reads its unit or currency on each row (`{ "per_row": <select> }`)
90
+ reads each row in the option that select holds, so a comparison on it states which one as
91
+ `unit_option` — the server refuses one that does not, and keeps only the rows in that unit (a measured unit's kin, `kg` beside `t`,
92
92
  converted). `units` is keyed like `fields`: `{ vocabulary: "unit" | "currency", options: { key, label }[] }`
93
93
  per such column, whether or not the query projects the select. A range filter on such a column
94
94
  offers its `options` and puts the picked key on each condition:
package/docs/mutations.md CHANGED
@@ -397,9 +397,8 @@ Why a full-form snapshot save is a bug, not a style choice — three independent
397
397
  moment *any* field is touched — there is no value-equality pass (deep equality over stored
398
398
  shapes would be a silent lock bypass). A snapshot touches every field, so it turns every
399
399
  save against a locked record into a total rejection even when nothing changed.
400
- 2. **`before_update` table workflows fire on the write.** Tables can carry `before_update`
401
- workflows (autofills, validations, side effects). They run on every write that touches the
402
- record — a snapshot save fires them for fields the user never edited.
400
+ 2. **A table's checks and `after_update` workflows run on the write.** They run on every
401
+ write that touches the record — a snapshot save runs them for fields the user never edited.
403
402
  3. **Snapshots clobber concurrent edits.** An update writes exactly the fields present in the
404
403
  payload; unsent fields keep their current value. A diff therefore coexists with a
405
404
  colleague's concurrent edit to a *different* field — a snapshot overwrites it with the
package/docs/queries.md CHANGED
@@ -264,6 +264,13 @@ heterogeneous tables. Source addressing survives per column only when every arm
264
264
  source table/field; per-row `__src_field_*` metadata still tracks which field each row's cell
265
265
  came from.
266
266
 
267
+ A select column whose arms project different selects takes every arm's options: a filter value
268
+ matches the arm whose select holds that key, and an option **name** stands for each arm's key
269
+ called that. A sort reads each row by its own select's order — arms listing the same options
270
+ interleave place by place, a key no arm names after every option. A `{ "literal": "<key>" }`
271
+ source typed `select` is an option of the query's own, held by every row of its arm: a column of
272
+ them (a book per arm) filters by those keys and sorts in the arms' order.
273
+
267
274
  ### `group` — aggregate
268
275
 
269
276
  ```jsonc
@@ -474,17 +481,17 @@ derived surfaces share one implementation.
474
481
 
475
482
  | Field type | Column type | Source-layer operators | Derived-layer differences | Sort / group / aggregate notes |
476
483
  | --- | --- | --- | --- | --- |
477
- | text | `text` | `equals`, `not_equals`, `contains`, `does_not_contain`, `starts_with`, `ends_with`, `is_empty`, `is_not_empty`, `is_any_of`, `is_none_of`, `contains_any_of` | same set | Sort is lexicographic in the database's default collation — no locale control. Text comparisons normalize both sides (trim + case-insensitive). The LIKE family (`contains`, `does_not_contain`, `starts_with`, `ends_with`, and `contains_any_of`, whose value is a list and which holds when ANY listed word is in the cell) additionally folds **diacritics**, so `contains "ha noi"` matches `"Hà Nội"`; the identity operators (`equals`, `not_equals`, `is_any_of`, `is_none_of`) do **not** — they compare a stored value, and `mã` is not `ma`. |
478
- | number | `number` | `equals`, `not_equals`, `greater_than`, `less_than`, `greater_than_or_equal_to`, `less_than_or_equal_to`, `is_empty`, `is_not_empty` | same set — except a figure read in each row's own unit or currency (`unit_field` / `currency_field`, or a rollup totalling in its row's), which a comparison matches only at the source layer: a condition on a column passing it straight through moves there, anywhere else it is refused | Full numeric aggregate set (§8). A comparison on a figure read per row states `unit_option`, the key of the option of that select its value is in — `{ "field_key": "amount", "operator": "greater_than", "value": 100, "unit_option": "opt_usd" }` — and matches only rows holding that option; rows in a measured unit of the same dimension compare converted (`≥ 1` in `t` matches `1500` in `kg`), a counted unit or a currency never. One stating none is refused. |
484
+ | text | `text` | `equals`, `not_equals`, `contains`, `does_not_contain`, `starts_with`, `ends_with`, `is_empty`, `is_not_empty`, `is_any_of`, `is_none_of`, `contains_any_of` | same set | Sort is lexicographic in the database's default collation — no locale control — over the trimmed, case-folded text, a column the query derives included; the value read stays as written. Text comparisons normalize both sides (trim + case-insensitive). The LIKE family (`contains`, `does_not_contain`, `starts_with`, `ends_with`, and `contains_any_of`, whose value is a list and which holds when ANY listed word is in the cell) additionally folds **diacritics**, so `contains "ha noi"` matches `"Hà Nội"`; the identity operators (`equals`, `not_equals`, `is_any_of`, `is_none_of`) do **not** — they compare a stored value, and `mã` is not `ma`. |
485
+ | number | `number` | `equals`, `not_equals`, `greater_than`, `less_than`, `greater_than_or_equal_to`, `less_than_or_equal_to`, `is_empty`, `is_not_empty` | same set — except a figure read in each row's own unit or currency (a `notation` whose currency or unit is `per_row`, or a rollup totalling in its row's), which a comparison matches only at the source layer: a condition on a column passing it straight through moves there, anywhere else it is refused | Full numeric aggregate set (§8). A comparison on a figure read per row states `unit_option`, the key of the option of that select its value is in — `{ "field_key": "amount", "operator": "greater_than", "value": 100, "unit_option": "opt_usd" }` — and matches only rows holding that option; rows in a measured unit of the same dimension compare converted (`≥ 1` in `t` matches `1500` in `kg`), a counted unit or a currency never. One stating none is refused. |
479
486
  | date / datetime | `date` / `datetime` (datetime when the field's format includes time) | `on`, `before`, `after`, `on_or_before`, `on_or_after`, `between`, `time_of_day`, `is_empty`, `is_not_empty` — values are `DateTimePoint`s (below) | same set | Day-level filters on datetime values expand to the full-day window; a `date` column compares on the day alone, so a time in the filter value is ignored. A reduced-precision value (`"2026-05"`) is its period start, and a value with no time is at `00:00` for `time_of_day`. Sortable; bucketable in `group.by` (§8); `earliest`/`latest`/`min`/`max`/`date_range` aggregate. |
480
487
  | boolean | `boolean` | `equals` (value `true`/`false`; `false` matches NULL/missing) | same | `checked`/`unchecked`/`percent_*` aggregate (`unchecked` counts false **or** empty). |
481
- | select (single & multi) | `select` | `has_any_of`, `has_none_of`, `has_all_of`, `is_empty`, `is_not_empty` — values are **option keys** (`opt_*`), never labels | the **runtime** `filter` (§9) also takes option **names**, resolving each to its key — an unknown one is refused rather than matching nothing, and the value stays an array either way; a select column the query DERIVES instead of projecting from a field carries no option set, so addressing one there is refused | Cells are arrays even for single-selects. Sorting a select column orders by its options' order (a multi-select by its first option), an empty cell last; a select the query derives carries no options and orders by raw JSON. `unnest` fans option keys. |
482
- | 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). |
483
- | 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). |
488
+ | select (single & multi) | `select` | `has_any_of`, `has_none_of`, `has_all_of`, `is_empty`, `is_not_empty` — values are **option keys** (`opt_*`), never labels | the **runtime** `filter` (§9) also takes option **names**, resolving each to its key — an unknown one is refused rather than matching nothing, and the value stays an array either way; a select column the query DERIVES instead of projecting from a field or a select literal carries no option set, so addressing one there is refused | Cells are arrays even for single-selects. Sorting a select column orders by its options' order (a multi-select by its first option), an empty cell last, on every page, keyset included; through a `union` whose arms project different selects, each row by its own select's order (§2 `union`); a select the query derives carries no options and orders by raw JSON. `unnest` fans option keys. |
489
+ | 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 | Sorting orders by the first member's name as a reader is shown it (the one the organization gave, else their profile's; a member it does not name reads as empty text), folded like text, then by member id; an empty cell last, on every page, keyset included. `unnest` fans member ids. Anonymous public request: current-member binds the app owner (§1). |
490
+ | 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 the first linked record's display, folded like text, then by its id; an empty cell last, on every page, keyset included. `unnest` fans link ids (+ display). |
484
491
  | 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. |
485
492
  | 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). |
486
493
  | 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`. |
487
- | 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). |
494
+ | 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**, and a sort orders by it as its column type sorts (a link's display, a member's name, a select's option order, text folded). 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). |
488
495
  | 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. |
489
496
  | button | `json` | **not filterable** — buttons are not data | — | Project/ignore; only presence-counting aggregates. |
490
497
 
package/docs/workflows.md CHANGED
@@ -344,8 +344,8 @@ try {
344
344
 
345
345
  `e` is shaped `{ message, type, step_id?, detail? }`. Caught: tool errors and expression errors
346
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 —
347
+ `return` (control flow), the run's own bounds (its time, its step count, and the compute and
348
+ memory bounds in *Traps*), and errors from steps that run on *resume* after a `wait` inside the body —
349
349
  the wrapper spans one execution pass. There is no `finally`: put always-run steps after the `try`,
350
350
  always-on-failure steps in the `catch`.
351
351
 
@@ -611,7 +611,7 @@ key preserves. So passing a possibly-null read straight through is safe. Use
611
611
  `set_skip_null` is what turns a bag of optional workflow inputs into a diff-write without a guard
612
612
  per field; naming the same field in both `set` and `set_skip_null` is an error. A call whose
613
613
  surfaces all resolve to nothing — an all-dropped `set_skip_null`, an op object with no entries —
614
- is a no-op: no records touched, no `before_update` hooks. So a conditionally-built op object needs
614
+ is a no-op: no records touched, no table check asked. So a conditionally-built op object needs
615
615
  no guard around the call. Why diffs and not snapshots, plus locked records and the
616
616
  `request_locked_record_change` path: [mutations](./mutations.md#diff-before-update--send-only-what-changed).
617
617
 
@@ -791,9 +791,6 @@ The rules that are easy to get wrong because the failing code looks correct.
791
791
  init/update included. Resume re-enters *after* the whole loop, so a wait inside would run
792
792
  iteration 0 and silently skip every remaining iteration and its mutations. Move the wait out,
793
793
  or split the loop into separately triggered runs.
794
- - **Waits and `agent` steps are disallowed in `before_*` table workflows.** Those run inside the
795
- mutation pipeline and must complete synchronously; an LLM loop cannot return an accept/reject
796
- verdict in time.
797
794
  - **String contexts coerce to display text.** A read inside a template or a string-typed argument
798
795
  renders the *label*; everywhere else it is the raw key. So
799
796
  `` `Status: ${record["fld_status"]}` `` prints "Done" while
@@ -827,11 +824,14 @@ The rules that are easy to get wrong because the failing code looks correct.
827
824
  takes an array of at most 100,000. `join`, `replaceAll`, `padStart`, `padEnd`, `toString`,
828
825
  `toJson` and a template string write at most 10,000,000 characters; `randomNumber` /
829
826
  `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 `+`.
827
+ expression error, which `try`/`catch` catches, and so does an expression whose value would write
828
+ more than 67,108,864 characters of JSON, or a step whose result would take what the run keeps
829
+ past that. One evaluation computes for at most 6 seconds — waiting on a linked row, a member's
830
+ name or `current_member_in_any_group` does not count — and uses at most 256 MB of memory (512 MB
831
+ on a private instance with 4 GB or more). The values a run's expressions answer come to at most
832
+ 512 MB in all, counted each time one is answered, a text at every place the value holds it.
833
+ Past any of these, the run fails whatever catches it. A callback nested in another runs once
834
+ per pair of elements.
835
835
 
836
836
  ## The bright line
837
837
 
@@ -860,8 +860,7 @@ genuinely recursive.
860
860
  `set_app_workflow` with `verify_only: true` gives the verdict a save would, and writes nothing. The
861
861
  server runs every check a save runs — the subset parse, the type pass against the alias's
862
862
  declared inputs, tool and kwarg resolution, field, option, table and group resolution, `switch`
863
- case validation, the literal `formatDate` probe, waits in loops, the `before_*` restrictions,
864
- table reach, the `field_key` rule and the published-API guard. **Green means the save will take
863
+ case validation, the literal `formatDate` probe, waits in loops, table reach, the `field_key` rule and the published-API guard. **Green means the save will take
865
864
  it, red means real** — do not push through a red check. Lint comes back as warnings, which do not
866
865
  fail it.
867
866
 
@@ -900,7 +899,7 @@ check's message joined with `"; "`, and one `field_errors` entry per failing che
900
899
  iterations of a `while` / `do_while` / `c_for`, 10 000 steps, 50 live reads, and 20 seconds — and
901
900
  tripping any of them appends an entry naming the bound and ending "the plan below is incomplete",
902
901
  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
902
+ `validate` check that throws, an evaluation past the run's compute or memory bounds (*Traps*), or a
904
903
  return too long to write.
905
904
  So read `evaluation_errors` before reading `planned_calls`: an entry there may be a
906
905
  truncated plan rather than a bug in your body. A `foreach` is deliberately NOT capped at 1 000 —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.114.0",
3
+ "version": "0.115.1",
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": {