@lotics/cli 0.276.0 → 0.278.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.
@@ -0,0 +1,181 @@
1
+ # App bindings — the queries, workflows and agents an app calls
2
+
3
+ A custom-code app reaches workspace data through three kinds of binding, each declared on the app
4
+ under an alias — a JS identifier (`/^[a-zA-Z_$][a-zA-Z0-9_$]*$/`):
5
+
6
+ | Binding | Declared with | The app calls it with | An agent calls it with |
7
+ |---|---|---|---|
8
+ | Query | `set_app_query`, `set_app_queries` | `useQuery("<alias>", params?)` | `run_app_query` |
9
+ | Workflow | `set_app_workflow` | `useWorkflow("<alias>")({ ...inputs })` | `run_app_workflow` |
10
+ | Agent | `set_app_agent` | `useAgentRun("<alias>")({ ...inputs })` | — |
11
+
12
+ Each runs under the app's authority (`{ type: "app", app_id }`), the app owner's reach — never the
13
+ calling member's. Declaring one needs the app's owner or an admin. A write is live: the app's next
14
+ call uses it. `get_app_capabilities` lists an app's aliases with their params and inputs.
15
+
16
+ ## Queries
17
+
18
+ A query declaration is `{ ast, params?, description?, templates? }`:
19
+
20
+ - `ast` — a query template (a QueryNode, below). It fixes the tables, filters and columns the alias
21
+ reads, so a caller cannot widen it.
22
+ - `params` — the typed value holes the caller fills, each an input declaration (see **Inputs**),
23
+ named in the ast as `{{params.<name>}}`. A param fills a VALUE position only — a filter value, a
24
+ `search` — never a `table_id` or a field key. Every token the ast names is declared. An optional
25
+ param left unset drops the conditions that read it; a required param that arrives empty is
26
+ refused where a filter reads it.
27
+ - `description` — one line saying what the query returns, for an agent choosing between aliases.
28
+ - `templates` — the document templates an export of its rows may fill, by name:
29
+ `{ <name>: { template_id: "dtl_…", rows: "<key the template lists the rows under>" } }`.
30
+
31
+ A save checks what a deploy checks: every table is in this workspace and within the owner's reach,
32
+ every projected, filtered and sorted field resolves, every param token is declared, and a key a
33
+ node's kind does not take (a stray `sort`, `limit`, `filter`, `search`) is refused by name.
34
+
35
+ `set_app_query` edits ONE alias and merges: send only the fields you change, `null` clears
36
+ `params`, `description` or `templates`, and a new alias needs an `ast`. `set_app_queries` writes
37
+ several at once: an alias it names is replaced whole, an alias it omits is kept.
38
+ `remove_app_query` deletes one; `get_app_query` reads one back.
39
+
40
+ ### The query tree
41
+
42
+ Every node has a `kind`; all but `from_table` read the node under `from` (`join`: `left`/`right`,
43
+ `union`: `sources`).
44
+
45
+ | `kind` | Keys |
46
+ |---|---|
47
+ | `from_table` | `table_id`, `filter?`, `search?`, `sort?`, `limit?` |
48
+ | `project` | `columns` — the output columns |
49
+ | `filter` | `predicate` — a filter tree over the input's columns |
50
+ | `join` | `left`, `right`, `on: { left_column, right_column }`, `type: "inner" \| "left"` |
51
+ | `union` | `sources` (at least two, columns aligned by name and type) |
52
+ | `group` | `by` (columns, or `{ bucket: { source, granularity, output } }`), `aggregates` |
53
+ | `window` | `partition_by`, `order_by`, `frame?`, `aggregates?`, `functions?` |
54
+ | `sort` | `by: [{ field_key, order }]` |
55
+ | `limit` | `n`, `offset?` or `keyset?` |
56
+ | `unpivot` | `passthrough`, `row_columns`, `rows` — one source row into several |
57
+ | `unnest` | `source`, `output`, `display_output?`, `keep_empty?` — one row per element of a multi-value cell |
58
+
59
+ Filters (`from_table.filter`, `filter.predicate`, an aggregate's `filter`) are the filter grammar — the
60
+ `filters` reference — over the input's columns, taking `{{params.x}}` as values. `search` matches
61
+ every text-bearing field of the row, accent- and case-insensitive.
62
+
63
+ A `project` column is a field key as a bare string (output name and type come from the field), or
64
+ `{ output?, type?, source, writable_target?, limit? }` to rename, compute or bound one. A computed
65
+ `source` is a field key, `{ literal }`, `{ eq: [a, b] }`, `{ neq: [a, b] }`, `{ isEmpty }`,
66
+ `{ isNotEmpty }`, `{ coalesce: [...] }`, `{ concat: [...] }`, `{ record_id: true }`,
67
+ `{ link: { source, field } }` (a field of the first linked record),
68
+ `{ link_agg: { source, field, operation } }` (`sum`, `avg`, `min`, `max`, `count`, `string_agg`
69
+ over every linked record), or `{ expression }`. A computed column states its `type`: `text`,
70
+ `number`, `boolean`, `date`, `datetime`, `select`, `select_record_link`, `select_member`, `files`
71
+ or `json`. `limit` (1–10) bounds a `files` column's entries per cell.
72
+
73
+ An aggregate column is `{ output, type, operation, input_column?, filter? }` — `string_agg` also
74
+ takes `separator`, `distinct` and `max_values`. A `window` function is
75
+ `{ output, fn }` for `row_number`, `rank`, `dense_rank`, `percent_rank`, `cume_dist`, plus
76
+ `buckets` for `ntile` and `input_column`, `offset?`, `default?` for `lag` / `lead`; functions need
77
+ a non-empty `order_by`.
78
+
79
+ ## Inputs
80
+
81
+ A query's `params`, a workflow's `inputs` and an agent's `inputs` are one vocabulary: a map of
82
+ name → `{ type, required?, description?, …per type }`. An entry is required unless it states
83
+ `required: false`.
84
+
85
+ | `type` | Takes |
86
+ |---|---|
87
+ | `text`, `number`, `boolean`, `date`, `datetime`, `email` | — |
88
+ | `record_link` | `table_id`; `multi?`; `max?` (with `multi`, the most ids one call carries) |
89
+ | `select` | exactly one of `options: [{ label, value }]` or `field: "fld_…"` (a select field whose CURRENT options back it); `multi?` |
90
+ | `date_range` | `include_time?` |
91
+ | `member` | `multi?`; `group?` (a member group the value must belong to) |
92
+ | `file` | `multi?` — an uploaded `fil_…` id, for a files field |
93
+ | `json` | — any value |
94
+ | `object` | `fields` — a nested map of entries |
95
+ | `array` | `items` — one entry |
96
+
97
+ Nesting stops at depth 8. A `field`-form select tracks the field's options as they change; an
98
+ inline `options` set is a fixed list.
99
+
100
+ ## Outputs
101
+
102
+ A workflow's and an agent's `outputs` declare the structured data handed back: a map of
103
+ name → `{ type, required?, description?, …per type }`, every entry present unless it states
104
+ `required: false`.
105
+
106
+ | `type` | Takes |
107
+ |---|---|
108
+ | `text`, `number`, `boolean`, `date`, `datetime`, `email`, `json` | — |
109
+ | `record_link` | `table_id`, `multi?` |
110
+ | `select` | exactly one of `options: [{ label, value }]` or `field: "fld_…"`; `multi?` |
111
+ | `object` | `fields` |
112
+ | `array` | `items` |
113
+
114
+ The returned value is checked against it, and the app reads it typed. An inline `options` set is
115
+ also enforced on the value returned, so the producer is told the legal keys; a `field`-form select
116
+ keeps the type tracking the live field.
117
+
118
+ ## Workflows
119
+
120
+ `set_app_workflow` binds a workflow body to an alias: `{ app_id, alias, source, inputs?, outputs?,
121
+ name?, description? }`.
122
+
123
+ - `source` is the body with no `on({...})` trigger — the app is the trigger. It reads each declared
124
+ input as `trigger.app_workflow.inputs.<name>`; no record is in scope, so it reads each one by id
125
+ with `get_record`.
126
+ The body's steps are the `workflows` reference.
127
+ - `inputs` is the payload the call site passes (see **Inputs**). Omitted, an existing workflow keeps
128
+ its inputs; on a first bind it takes an untyped payload. `{}` clears them.
129
+ - `outputs` is what `return({ data })` hands back (see **Outputs**). Usually omit it: the type is
130
+ derived from the body's `return({ data })`, and the result echoes the bound `outputs`. Declared,
131
+ the body does not save unless its return matches.
132
+ - A save checks the body — parse, type-check against the declared inputs, names, lint, structure —
133
+ and answers with `[source/code] location: message` lines. `verify_only: true` makes every check
134
+ and writes nothing. A call naming a live alias replaces its body in place, keeping its run history.
135
+ - A call whose payload does not match `inputs` is refused before the body runs.
136
+
137
+ `get_app_workflow` reads the body back; `dry_run_workflow` with `trigger_type: "app_workflow"`
138
+ runs it against sample inputs before binding; `remove_app_workflow` unbinds it.
139
+
140
+ ## Agents
141
+
142
+ `set_app_agent` binds a streaming tool-loop agent to an alias. A run streams its work to the app,
143
+ returns a typed result and keeps a run history per session. The declaration:
144
+
145
+ - `instructions` — the task, every run. A new alias needs it.
146
+ - `tool_names` — the tools it may call, from these and no other: `analyze_pdf_template`,
147
+ `code_edit_file`, `code_exec`, `code_read_file`, `code_write_file`, `excel_create_file`,
148
+ `excel_find_cells`, `excel_format_range`, `excel_get_range`, `excel_update_range`,
149
+ `generate_bank_qr_code`, `generate_excel_from_template`, `generate_image`,
150
+ `generate_pdf_from_template`, `generate_qr_code`, `generate_word_from_template`, `get_template`,
151
+ `grep_knowledge`, `list_banks`, `list_knowledge`, `lookup_business`, `query_templates`,
152
+ `read_knowledge`, `run_app_query`, `run_app_workflow`, `validate_excel_template`,
153
+ `vietcombank_convert_currency`, `view_files`, `word_create_document`, `word_find_text`,
154
+ `word_get_content`, `word_get_table_data`, `word_insert_conditional`, `word_insert_loop`,
155
+ `word_replace_text`. `[]` is a reasoning-only agent. A few fields of a document read well through
156
+ the `excel_*` / `word_*` tools; a cross-check, reconciliation or transform a decision rests on
157
+ belongs in the code tools.
158
+ - `query_aliases` / `workflow_aliases` — the app's own queries and workflows it may call through
159
+ `run_app_query` / `run_app_workflow`. They are its whole reach over records: no agent tool takes a
160
+ `table_id`, so a read is bounded by the query's tables, rows and columns, and a write goes through
161
+ the app's own workflow.
162
+ - `knowledge_doc_ids` — knowledge docs it may read with `grep_knowledge` / `read_knowledge`
163
+ (declare them in `tool_names`). Each must be one the app owner and you can use. Nothing is
164
+ inlined and size does not matter. The list is the agent's whole reach — `list_knowledge` finds no
165
+ doc outside it — so name a doc's id in `instructions` to point it there. `code_exec` computes
166
+ across a corpus (counting, cross-referencing); reading needs only the knowledge tools.
167
+ - `model_tier` — `haiku`, `sonnet` or `opus`. Omitted, the run follows the platform's default tier
168
+ and moves with new models; pin one only as a tested choice, and never `haiku`, a utility tier.
169
+ - `effort_level` — `low`, `medium`, `high`, `xhigh` or `max`, one the pinned tier supports; it
170
+ needs `model_tier`.
171
+ - `prefix_cache_ttl` — `5m` (the default) or `1h`. `1h` doubles the cache write price and pays only
172
+ when runs land 5 to 60 minutes apart, as a scheduled sweep does.
173
+ - `inputs` — the per-run payload (see **Inputs**); omitted, the payload is untyped.
174
+ - `outputs` — the structured result (see **Outputs**), checked before the run is saved and typed
175
+ as `run.output` in the app; omitted, the result is the final message.
176
+ - `writes` — where the outputs land: `{ table_id, row, fields }`, `row` naming a single
177
+ `record_link` input to `table_id` and `fields` mapping each output name to a field key of that
178
+ table. A result that validates is written to that row under the app's authority.
179
+
180
+ Send only what you change: an omitted field keeps its stored value, `null` clears an optional one.
181
+ `get_app_agent` reads one back; `remove_app_agent` deletes it.
@@ -1,15 +1,33 @@
1
- # The data model — how tables relate
1
+ # The data model — tables, their fields, and how they relate
2
2
 
3
3
  The decisions here outlive any one app, and most become expensive the moment a second screen depends
4
- on them. They are separate from `lotics docs building_an_app` on purpose: **every workspace starts
5
- with tables and many never get a custom app**, so schema design is not a chapter of app building.
4
+ on them. They stand apart from building an app on purpose: **every workspace starts with tables
5
+ and many never get an app**, so schema design is not a chapter of app building.
6
6
 
7
- `ONE FACT, ONE COLUMN` — the rules governing a single table's own columns — is stated at the tools
8
- that add a field, `create_table` and `update_table`, where you meet it while deciding. This doc is
9
- the other half: how tables relate to each other.
7
+ The rules below share one signature. **Both sides read correctly on their own**, so nothing reports
8
+ the problem — no error, no empty column, no failing query. Each is found by looking for it. Then,
9
+ under **Fields**, what each field type takes.
10
10
 
11
- Everything below shares one signature. **Both sides read correctly on their own**, so nothing reports
12
- the problem — no error, no empty column, no failing query. Each is found by looking for it.
11
+ ## One fact, one column
12
+
13
+ **Read the table's existing fields first, and add nothing that stores a fact the table already
14
+ stores.** A value written as text and the same value held as a `select_record_link` are one fact in
15
+ two columns — a customer's city typed into a text box beside a link to the city record, a status
16
+ word beside the select that decides it, a total beside the formula that computes it.
17
+
18
+ Two columns for one fact do not stay equal. Some writer sets only one of them, and nothing reports
19
+ the divergence: both rows still look correct on their own. A reader that then matches on the text
20
+ half treats "Acme" and "Acme Ltd" as different records, so an import creates a duplicate every time
21
+ it runs.
22
+
23
+ - **Prefer the link, the select, or the formula.** Text is a RENDERING of a record; compose it when
24
+ you read, rather than storing it a second time.
25
+ - **Renaming, retyping or re-pointing the existing field beats adding another.** A field's key is
26
+ stable, so a rename breaks nothing that addresses it by key.
27
+ - **Superseding a field means DELETING it**, not leaving it beside its replacement with a
28
+ description that says which one is real.
29
+ - **Empty is not the same as redundant.** A field nothing fills may still be the only home for a
30
+ real distinction — read what it MEANS before removing it.
13
31
 
14
32
  ## One entity, one table — and the test is measurable
15
33
 
@@ -69,6 +87,16 @@ it falls back to comparing displayed text — which is how one company arrives t
69
87
  spellings. Name the key: a reference number, a tax id, a link plus a period. Then match on `rec_…`
70
88
  and `opt_…`, never on rendered labels.
71
89
 
90
+ ## A state's HISTORY is rows, not columns
91
+
92
+ A `changed at` column says only how long a row has been where it is now — the next move overwrites
93
+ it — and a date column per state holds until something re-enters a state it already left.
94
+
95
+ Measuring time-in-state or conversion needs one ROW per move: a link to the subject, the state left,
96
+ the state entered, when. Hold those states as the source field's own `opt_` keys so the log carries
97
+ no second vocabulary, and write the rows from that table's own lifecycle workflows, which covers
98
+ every writer rather than one app's.
99
+
72
100
  ## Keep derived chains shallow
73
101
 
74
102
  Formulas and rollups are computed and STORED when a row is written, and one that reads another
@@ -90,3 +118,141 @@ the numbers themselves:
90
118
  And when a formula gains a new field, **test that the value IS the one you want, never that it
91
119
  differs from it** — an empty cell reads as `""`, which differs from every option key, so the inverted
92
120
  spelling silently zeroes every row written before the field existed.
121
+
122
+ ## Fields
123
+
124
+ What `create_table` and `update_table` take in `add_fields`, and `update_table` in `update_fields`.
125
+
126
+ ### Types and formats
127
+
128
+ `type` is one of `text`, `number`, `date`, `boolean`, `select`, `select_member`,
129
+ `select_record_link`, `files`, `formula`, `rollup`, `lookup`, `autonumber`. `button` is retired: an
130
+ existing button field keeps running, but none is created, converted to or edited.
131
+
132
+ A URL, an email, markdown, a checkbox, a datetime, a currency or a percentage is a `format` on
133
+ another type, never a `type`:
134
+
135
+ | Wanted | Field |
136
+ |---|---|
137
+ | URL or external link | `{ type: "text", format: "link" }` |
138
+ | Email or phone | `{ type: "text" }` |
139
+ | Markdown | `{ type: "text", format: "markdown" }` |
140
+ | Checkbox | `{ type: "boolean" }` |
141
+ | Money | `{ type: "number", format: "currency", currency: "USD" }` |
142
+ | Percentage | `{ type: "number", format: "percentage" }` |
143
+ | Datetime | `{ type: "date", format: "datetime" }` |
144
+ | Date range | `{ type: "date", format: "date_range" }` |
145
+
146
+ ### Properties
147
+
148
+ A type's properties sit **directly on the field object** — there is no `config` wrapper. A property
149
+ may itself hold an object (`formula`, `aggregate_option`, `filter`, `order_by`); its inner keys stay
150
+ inside it. Each type takes only its own:
151
+
152
+ - **text** — `format?` (`"text"` | `"link"` | `"markdown"`), `unique?`, `default_value?` (a string).
153
+ - **number** — `format?` (`"number"` | `"currency"` | `"percentage"`), `currency?` (an ISO 4217
154
+ code), `unit?`, `unit_field?`, `currency_field?`, `default_value?` (a number). On `update_fields`,
155
+ null clears `currency`, `unit`, `unit_field` or `currency_field`.
156
+ - `unit`, beside format `"number"`: a measured code — g, kg, t, l, m3, cbm, mm, cm, m, km, m2,
157
+ min, h, day — or a counted noun such as `kiện`. A change between two units of one dimension
158
+ converts every stored figure; any other change relabels.
159
+ - `unit_field`: A single select on the same row, or a lookup of one through a one-link, whose chosen option is that row's unit: every option label is a unit as `unit` takes one. In place of `unit`; only beside format "number".
160
+ - `currency_field`: A single select on the same row, or a lookup of one through a one-link, whose chosen option is that row's currency: every option label is an ISO 4217 code. In place of `currency`; only beside format "currency".
161
+ - **date** — `format?` (`"date"` | `"datetime"` | `"date_range"` | `"datetime_range"`), `timezone?`,
162
+ `derive_from?`, `default_value?` (a date string). `derive_from: "created_at"` stamps the row's
163
+ creation once, `"updated_at"` re-stamps on every update; the field is then read-only, takes no
164
+ `default_value`, and holds only the `date` and `datetime` formats.
165
+ - **boolean** — `default_value?` (`true` | `false`).
166
+ - **select** — `options` `[{ name, color?, mark? }]`, `multi?`, `default_value?`.
167
+ - **select_member** — `multi?`, `default_value?` (member ids).
168
+ - **select_record_link** — `table_id`, `display_field_keys?`, `sync_both_ways?`, `cardinality?`,
169
+ `paired_field_name?`, `paired_field_display_field_keys?`. Without `display_field_keys` a link shows
170
+ the target's autonumber, else its first text field. `sync_both_ways: true` on an existing one-way
171
+ link makes it two-way — never add a second link for that. `cardinality: "one"` marks the child
172
+ side of a parent-child pair (the linked record is this one's parent); two paired sides cannot both
173
+ be `"one"`. The `paired_*` keys name and label the back-reference the target gets.
174
+ - **files** — no properties.
175
+ - **autonumber** — `template` (`"INV-{YEAR}-{N:4}"`; tokens `{N}`, `{N:W}` zero-padded to W,
176
+ `{YEAR}`, `{YEAR:2}`, `{MONTH}`, `{DAY}`), or `prefix` + `padding` (1–20) for `PREFIX-0001`.
177
+ - **any type**, at create — `confirm_before_update?`: a person confirms each later edit.
178
+
179
+ `default_value` fills the field on a NEW record that states no value; existing records are never
180
+ backfilled, and null clears it. A select's or a member field's default is an ARRAY even when the
181
+ field holds one (`["Unread"]`, never `"Unread"`) — option names in `add_fields`, where the keys do
182
+ not exist yet, and option keys (`opt_…`) in `update_fields`.
183
+
184
+ ### Computed fields
185
+
186
+ Read-only in records. `formula` is one key holding an object; a rollup's and a lookup's properties
187
+ are separate keys on the field — `rollup: {…}` and `lookup: {…}` are refused as unrecognized.
188
+
189
+ formula: { expression, format?, currency?, unit?, unit_field?, currency_field? }
190
+
191
+ - `{Field Name}` or `{fld_key}` names a field of the same table, stored as its key, so a rename never
192
+ breaks the formula. A reference to no field, or a call to something that is no helper, is refused.
193
+ - A select reads as an ARRAY of option keys: `{Status}[0]` for a single select,
194
+ `includes({Tags}, "opt_…")` for a multi.
195
+ - The operators, the helpers and how an empty cell reads are the `model` reference, section `formula` — a model
196
+ names fields by alias where a table tool names them by name or key; the language is the same.
197
+ - On `update_fields` a key left out keeps its stored value, and null clears `currency`, `unit`,
198
+ `unit_field` or `currency_field`. `get_table` marks a formula that is null when every field it
199
+ reads is empty.
200
+
201
+ rollup — `source_field_key`, `aggregate_option: { field_key?, operation }`, `filter?`
202
+
203
+ - `source_field_key` is a `select_record_link` of this table; `aggregate_option.field_key` is a
204
+ field of the linked table, required by every operation but `count`, which counts linked records.
205
+ - Operations by the aggregated field's type — none: `count`; any: `empty`, `filled`,
206
+ `percent_empty`, `percent_filled`, `unique`, `percent_unique`; number: `sum`, `avg`, `median`,
207
+ `min`, `max`, `range`; date: `earliest`, `latest`, `date_range`.
208
+ - The cell's type comes from the OPERATION: `earliest` and `latest` hold a date, every other
209
+ operation a number (`date_range` counts days). A "most recent linked date" is `latest` — `min` and
210
+ `max` are numeric and refuse a date. A rollup takes no format, currency or unit of its own: `sum`
211
+ over money carries the aggregated field's currency, over a weight its unit.
212
+ - Over a figure read in each row's own unit or currency (`unit_field` / `currency_field`), `sum`,
213
+ `avg`, `median`, `min`, `max` and `range` hold only where that select is a lookup, through the
214
+ link paired with `source_field_key`, of a single select on this table — the total reads in this
215
+ row's option of it. A lookup of such a field has no unit.
216
+ - `filter` aggregates only the linked records it matches: a full group over the LINKED table's
217
+ fields — `{ node_type: "group", logic, children: [{ node_type: "condition", type, field_key,
218
+ operator, value }] }`, a select condition using `has_any_of` with an ARRAY value.
219
+
220
+ lookup — `source_field_key`, `lookup_field_key`, `order_by?`
221
+
222
+ - `lookup_field_key` is a field of the linked table. Without `order_by` the cell holds every linked
223
+ record's value; with `order_by: { field_key, direction }` (`desc` latest, `asc` earliest) it holds
224
+ the picked field of the single extreme record — a "latest linked X" that maintains itself.
225
+ Ordered lookups sharing one `order_by` resolve to the SAME record.
226
+
227
+ ### Examples
228
+
229
+ ```
230
+ { name: "Giá bán", type: "number", format: "currency", currency: "VND" }
231
+ { name: "Trọng lượng", type: "number", unit: "kg" }
232
+ { name: "Số lượng", type: "number", unit_field: "ĐVT" }
233
+ { name: "Trạng thái", type: "select", options: [{ name: "Mới" }, { name: "Xong" }] }
234
+ { name: "Mã đơn", type: "autonumber", template: "SR-{YEAR}-{N:4}" }
235
+ { name: "Khách hàng", type: "select_record_link", table_id: "tbl_x", sync_both_ways: true }
236
+ { name: "Total", type: "formula", formula: { expression: "{Price} * {Qty}", format: "currency", currency: "VND" } }
237
+ { name: "SL đã giao", type: "rollup", source_field_key: "Giao hàng", aggregate_option: { operation: "sum", field_key: "Số lượng" } }
238
+ { name: "Customer Name", type: "lookup", source_field_key: "Customer", lookup_field_key: "Name" }
239
+ { name: "Latest note", type: "lookup", source_field_key: "Calls", lookup_field_key: "Note", order_by: { field_key: "At", direction: "desc" } }
240
+ ```
241
+
242
+ ### Changing a field
243
+
244
+ A field added by `update_table` shows in a view only when `add_to_views` names it, at the view's far
245
+ right; `update_view` with `move_field` places it beside the columns it belongs with.
246
+
247
+ An `update_fields` entry is `{ field_key, name?, description?, convert_to?, … }` with the same flat
248
+ properties as `add_fields`, plus what only an update does:
249
+
250
+ - A select's options change through `add_options` `[{ name, color?, mark? }]`, `update_options`
251
+ `[{ key, name?, color?, mark? }]`, `remove_option_keys` and `reorder_options` (every existing
252
+ option once, in order; options added in the same call follow). An option is named by its `opt_…`
253
+ key (its name also resolves); a rename keeps every record's value. `mark` is the option's brand
254
+ (`{ kind: "brand", name: "tiktok" }`) or kit glyph (`{ kind: "icon", name: "wrench" }`), drawn in
255
+ place of its colour dot — every option of a field has one or none does, so marking sets them all
256
+ in one call and `mark: null` on each clears them.
257
+ - A link's `sync_both_ways: false` disconnects the pair; a new `table_id` re-points it and clears its
258
+ record data; `cardinality` is `"one"` for the child side of a parent-child pair, `"many"` for peers.
@@ -0,0 +1,114 @@
1
+ # Filters — choosing records by their fields
2
+
3
+ One grammar for every tool that takes a filter: `query_records`, `aggregate_records`, `update_records`
4
+ and `delete_records` by filter, a view's filter, a rollup's filter, and the filters a caller passes
5
+ to an app's query.
6
+
7
+ ## The shape
8
+
9
+ A filter is a single condition, or a group of conditions.
10
+
11
+ ```
12
+ { "node_type": "condition", "field_key": "Status", "operator": "has_any_of", "value": ["Done"] }
13
+ { "node_type": "group", "logic": "and" | "or", "children": [ …conditions or groups ] }
14
+ ```
15
+
16
+ Groups nest up to 4 levels. AND(OR(status = A, status = B), age > 40):
17
+
18
+ ```
19
+ { "node_type": "group", "logic": "and", "children": [
20
+ { "node_type": "group", "logic": "or", "children": [cond1, cond2] },
21
+ cond3
22
+ ] }
23
+ ```
24
+
25
+ Every `field_key` resolves against the table being filtered — read each table's fields (`get_table`)
26
+ and carry no name across tables: sibling tables holding the same concept routinely name it
27
+ differently. A field is named by its `fld_…` key or by its name (`"Status"`).
28
+
29
+ ## Text
30
+
31
+ | Operators | Value |
32
+ |---|---|
33
+ | `equals`, `not_equals`, `contains`, `does_not_contain`, `starts_with`, `ends_with` | a string |
34
+ | `is_any_of`, `is_none_of` | a string array — exact, case-insensitive |
35
+ | `contains_any_of` | a string array — the cell contains any of them, case- and accent-insensitive |
36
+ | `is_empty`, `is_not_empty` | none |
37
+
38
+ ```
39
+ { "node_type": "condition", "field_key": "Title", "operator": "is_any_of", "value": ["hello", "world"] }
40
+ ```
41
+
42
+ A text value is a string, never an array; `is_any_of` matches several. Its array has no length cap —
43
+ pass the whole list in one condition rather than splitting it across calls.
44
+
45
+ ## Number
46
+
47
+ | Operators | Value |
48
+ |---|---|
49
+ | `equals`, `not_equals`, `greater_than`, `less_than`, `greater_than_or_equal_to`, `less_than_or_equal_to` | a number |
50
+ | `is_empty`, `is_not_empty` | none |
51
+
52
+ A number whose field states `unit_field` or `currency_field` is read in each row's own unit or
53
+ currency, so a comparison on it states `unit_option`: the option of that select its value is in.
54
+ Rows holding another option are out; measured units of one dimension convert.
55
+
56
+ ```
57
+ { "node_type": "condition", "field_key": "Amount", "operator": "greater_than", "value": 100, "unit_option": "USD" }
58
+ ```
59
+
60
+ ## Boolean
61
+
62
+ `equals` with `true` or `false`.
63
+
64
+ ## Select
65
+
66
+ | Operators | Value |
67
+ |---|---|
68
+ | `has_any_of` (any of them — "A or B"), `has_none_of`, `has_all_of` (a multi-select only) | an ARRAY of option names, even for one |
69
+ | `is_empty`, `is_not_empty` | none |
70
+
71
+ ```
72
+ { "node_type": "condition", "field_key": "Status", "operator": "has_any_of", "value": ["Done", "In Progress"] }
73
+ ```
74
+
75
+ ## Member
76
+
77
+ | Operators | Value |
78
+ |---|---|
79
+ | `has_any_of`, `has_none_of`, `has_all_of` | an array of member ids |
80
+ | `is_current_member`, `is_not_current_member`, `is_empty`, `is_not_empty` | none |
81
+
82
+ ## Date
83
+
84
+ | Operators | Value |
85
+ |---|---|
86
+ | `before`, `after`, `on_or_before`, `on_or_after`, `on`, `starts_before`, `starts_after`, `ends_before`, `ends_after` | a point |
87
+ | `between`, `overlaps`, `contains`, `within` | `{ start: point \| null, end: point \| null }` |
88
+ | `time_of_day` | `{ start_time: "HH:mm" \| null, end_time: "HH:mm" \| null }` |
89
+ | `duration_equals`, `duration_greater_than`, `duration_less_than` | `{ amount, unit: "minutes" \| "hours" \| "days" }` |
90
+ | `is_empty`, `is_not_empty` | none |
91
+
92
+ A point is one of:
93
+
94
+ ```
95
+ { "type": "exact", "date": "2025-03-14", "time": "09:00" } time optional
96
+ { "type": "relative", "offset": -7, "unit": "minutes" | "hours" | "days" | "weeks" | "months" | "years" }
97
+ { "type": "period", "period": "day" | "week" | "month" | "quarter" | "year", "boundary": "start" | "end", "offset": 0 }
98
+ ```
99
+
100
+ ## Link
101
+
102
+ | Operators | Value |
103
+ |---|---|
104
+ | `has_any_of` (linked to any — filters by record identity), `has_none_of`, `has_all_of` (a multi-link only) | an array of record ids (`rec_…`) |
105
+ | `contains`, `not_contains`, `starts_with`, `ends_with` | a string, matched against the linked record's display text |
106
+ | `is_empty`, `is_not_empty` | none |
107
+
108
+ ```
109
+ { "node_type": "condition", "field_key": "Customer", "operator": "has_any_of", "value": ["rec_abc123"] }
110
+ ```
111
+
112
+ ## Sort
113
+
114
+ A view's sort is a list, applied in order: `[{ "field_key": "Due", "order": "asc" | "desc" }]`.