@lotics/app-sdk 0.46.1 → 0.47.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,909 @@
1
+ # Queries — the query engine authoring reference
2
+
3
+ Every read a custom-code app performs is a **named query**: a fixed AST template declared in
4
+ `package.json#lotics.queries`, validated and compiled at deploy, and invoked by alias via
5
+ `useQuery` / `useInfiniteQuery` / `usePaginatedQuery` (see [data_fetching.md](./data_fetching.md)
6
+ for the hook-side contract). This document is the authoring reference for the query engine
7
+ itself: the node kinds, the source expressions, the per-field-type operator support, filters and
8
+ params, search, cross-table composition, server-side shaping, runtime refinement, and the
9
+ engine's hard limits. Read it before writing any non-trivial query — the engine is deliberately
10
+ a small closed surface, and knowing what composes (and what doesn't) up front saves hours.
11
+
12
+ ---
13
+
14
+ ## 1. The model
15
+
16
+ ### Declaration
17
+
18
+ Queries live in the app's `package.json` under `lotics.queries` — an alias → declaration map:
19
+
20
+ ```jsonc
21
+ {
22
+ "lotics": {
23
+ "queries": {
24
+ "openOrders": {
25
+ "ast": {
26
+ "kind": "project",
27
+ "from": {
28
+ "kind": "from_table",
29
+ "table_id": "tbl_orders",
30
+ "filter": { "node_type": "condition", "field_key": "status",
31
+ "operator": "has_any_of", "value": ["opt_open"] }
32
+ },
33
+ "columns": ["order_code", "customer", "total", "created"]
34
+ }
35
+ },
36
+ "orderByCode": {
37
+ "ast": { /* … a filter with "{{params.code}}" … */ },
38
+ "params": { "code": { "type": "text" } }
39
+ }
40
+ }
41
+ }
42
+ }
43
+ ```
44
+
45
+ - **`ast`** — a `QueryNode` tree (the 11 node kinds below). It may embed `{{params.<name>}}`
46
+ tokens in **value positions only**.
47
+ - **`params`** — a typed param schema. Param types reuse the workflow-input vocabulary
48
+ (`text`, `number`, `boolean`, `date`, `datetime`, `email`, `select`, `member`, `record_link`,
49
+ `date_range`, `file`, `json`, `object`, `array`); each param may set `required: false`
50
+ (default is required) and `description`. Nesting is capped at depth 8.
51
+ - Aliases must be valid JS identifiers (`useQuery("openOrders")` and codegen depend on it).
52
+
53
+ `lotics app deploy` syncs this map to the server. The **server holds the canonical template**;
54
+ the app never sends a raw AST over the wire. This is the exposure model: a public app can read
55
+ exactly what its author's queries project — params fill declared value holes and can never
56
+ widen the query's reach (a token in a `table_id` or field-key position fails deploy validation).
57
+
58
+ ### What deploy validates
59
+
60
+ Deploy fails — with the compiler's own message, never raw SQL/Postgres text — when any alias:
61
+
62
+ 1. isn't a valid identifier, or the declaration isn't `{ ast, params? }`;
63
+ 2. references `{{params.x}}` without declaring param `x` (typos can't silently widen);
64
+ 3. doesn't parse as a `QueryNode` **as the raw template** (the runtime parses the stored
65
+ template before substitution, so a template that only parses after substitution would fail
66
+ every run);
67
+ 4. doesn't parse after typed placeholder substitution (this is what catches a param token in a
68
+ structural position);
69
+ 5. references a table outside the app's workspace (including link-extraction targets and
70
+ filter-traversal targets);
71
+ 6. fails the output-schema pass — unknown field/column references, duplicate or reserved output
72
+ names, union misalignment, join collisions or type-incompatible ON columns, invalid
73
+ aggregate-operation × input-type combinations, window-illegal operations, uncastable type
74
+ overrides, `writable_target` on a computed column;
75
+ 7. fails to **compile to SQL** — several rejections exist only in the compiler (the expression
76
+ allowlist, link extraction over a composed parent, derived-filter operators with no
77
+ translation). Deploy compiles every query so these fail at deploy, not at first run;
78
+ 8. has optional params and the **pruned** shape (all optional params omitted) fails any of the
79
+ above — each omitted optional param executes a pruned variant of the template, and the
80
+ all-params shape compiling doesn't prove the pruned one does. Deploy validates both.
81
+
82
+ What deploy **cannot** catch: data-dependent failures (a statement timeout on a huge unindexed
83
+ scan, the file-presign ceiling) — those surface at run time as clean, safe errors (§10).
84
+
85
+ ### Execution
86
+
87
+ `useQuery(alias, params)` → the server: validates the caller's params against the declared
88
+ schema (missing required param → 400), **prunes** filter conditions gated on omitted optional
89
+ params (§6), interpolates the rest, wraps the result in the caller's runtime
90
+ filter → sort → limit nodes (§9), resolves IAM scoping, compiles to one SQL statement, and
91
+ executes it inside a bounded transaction.
92
+
93
+ - **Authority**: the query runs under the **app owner's** principal, not the viewer's. Data
94
+ scoping is the author's job — see [security.md](./security.md). `is_current_member`
95
+ conditions bind the signed-in viewer (the *viewed* member under "View as").
96
+ **Warning:** on an anonymous public-app request there is no viewer — `is_current_member`
97
+ resolves to the **app owner's** member id. Per-user data must not ship in a public app.
98
+ - Archived and draft records are always excluded. Rows hidden by a table's row scope
99
+ (`private_filters`) are excluded on every reach — base scans, link extraction, and
100
+ non-self-scoped traversals alike. (Row scopes don't apply when the app owner is an admin.)
101
+ - **Dev loop**: `lotics app dev` forwards query RPCs to the **deployed** manifest. Editing
102
+ `lotics.queries` locally does nothing until you `lotics app deploy`.
103
+
104
+ ### Results
105
+
106
+ A result is `{ rows, columns, source_fields_by_table_id }` (`+ total` for a count request).
107
+ `columns` is the statically computed output schema — `{ name, type, nullable?,
108
+ source_table_id?, source_field_key? }` per column. Rows are plain objects keyed by output
109
+ column name; decode cells with the SDK readers (`row.*`, `readSelect`, `readMembers`,
110
+ `readLinks`, `readFiles` — see [data_fetching.md](./data_fetching.md)).
111
+
112
+ Delivery-layer enrichment (applied to the response, per request):
113
+
114
+ - **`files` cells** — each entry gains a presigned `url` + `thumbnail_url` (24 h TTL) and
115
+ `size` (bytes) + `created_at`, resolved from the file object at read. Presigning is
116
+ server-bounded (§10). Public apps hand out direct presigned URLs — anonymous-fetchable,
117
+ time-boxed. (App workflow-execute responses presign returned files the same way, so their
118
+ URLs also work for anonymous public-app viewers — see [files.md](./files.md).)
119
+ - **`select_member` cells** — bare member-id arrays become `{ id, name, email? }[]` (`email`
120
+ only for authenticated members of the app's own org).
121
+ - **`select` cells** — bare option-key arrays become `{ key, label }[]` (colors ride in
122
+ `useFieldOptions`, not the cell — see [members_and_options.md](./members_and_options.md)).
123
+ - **`number` columns** arrive as JS numbers (Postgres `numeric` strings are coerced; a
124
+ high-precision decimal that would lose digits stays a string). **`date`/`datetime`** columns
125
+ arrive as canonical wall-clock strings (`YYYY-MM-DD` / `YYYY-MM-DDTHH:mm`).
126
+
127
+ ### System columns
128
+
129
+ Every row-level query carries auto-injected addressing columns alongside your output columns:
130
+ `__source_record_id`, `__source_table_id`, `__source_locked` (decode with `readLocked`),
131
+ `__created_at`, `__updated_at`, and per-projection `__src_field_<output>` metadata. You never
132
+ declare these; output names starting with `__source_` / `__src_field_`, or equal to
133
+ `__created_at` / `__updated_at`, are **reserved** and rejected. A `group` collapses rows and
134
+ drops addressing (grouped results can't be written through or matched by `record_id`); a `join`
135
+ keeps the **left** side's addressing.
136
+
137
+ ---
138
+
139
+ ## 2. The AST node reference
140
+
141
+ Eleven node kinds. Every node except `from_table` wraps one or more child nodes; each subtree
142
+ compiles to a derived `SELECT`. All structural errors below fail at **deploy** (the output
143
+ schema + compile passes run there); only data-dependent errors fail at run time.
144
+
145
+ ### `from_table` — the only leaf
146
+
147
+ ```jsonc
148
+ { "kind": "from_table", "table_id": "tbl_orders",
149
+ "filter": { /* TableRecordFilters, §6 */ },
150
+ "search": "{{params.q}}", // optional free-text search, §7
151
+ "sort": [{ "field_key": "created", "order": "desc" }],
152
+ "limit": 50 }
153
+ ```
154
+
155
+ Scans one table. Output = one column per field on the table (named by field key, typed per §5)
156
+ plus the system columns. `filter`, `search` (AND-ed with `filter`), `sort`, and `limit` apply
157
+ inside the scan — this is the **source layer**, where the richest operator support and all
158
+ indexes live (§10). `sort` entries are `{ field_key, order: "asc"|"desc", blank_position?:
159
+ "top"|"bottom" }` (blanks default to bottom). Unknown filter/sort field keys fail deploy.
160
+
161
+ ### `project` — choose and compute columns
162
+
163
+ ```jsonc
164
+ { "kind": "project", "from": { … },
165
+ "columns": [
166
+ "status", // passthrough shorthand
167
+ { "output": "code", "source": "order_code" }, // rename
168
+ { "output": "is_paid", "type": "boolean",
169
+ "source": { "isNotEmpty": "paid_at" } }, // computed (needs output + type)
170
+ { "output": "id", "source": { "record_id": true } } // the row's own id
171
+ ] }
172
+ ```
173
+
174
+ - A bare string is a passthrough: output name = the field key, type = the field's type,
175
+ source addressing preserved (write-through eligible via `writable_target`).
176
+ - A computed source (any typed object except `record_id`) must declare `output` **and**
177
+ `type`, is always nullable, and is never writable.
178
+ - Output names must be unique and non-reserved.
179
+ - A passthrough may declare a `type` **override** — realized as a *real, guarded SQL cast*
180
+ (§4), never a relabel. An uncastable combination is rejected at deploy.
181
+ - **Project only what you render.** A bare `from_table` ships every column — including `files`
182
+ cells with storage keys — to the client (over-exposure + the presign ceiling at scale).
183
+
184
+ ### `filter` — predicate over derived columns
185
+
186
+ ```jsonc
187
+ { "kind": "filter", "from": { …any node… }, "predicate": { /* TableRecordFilters */ } }
188
+ ```
189
+
190
+ Filters the child's **output columns** (field_keys are output column names here). Column types
191
+ come from the child's output schema, so type-shaped operators (record-link containment, array
192
+ emptiness, timezone-aware date points) behave exactly like the source layer. The derived layer
193
+ supports the full §5 operator matrix **except**: `traversal` nodes, `locked`, and
194
+ `current_member` conditions are rejected — push those down into `from_table.filter`.
195
+ `record_id` works on any row-level derived query (rejected over a `group`, which has no row
196
+ identity). When a `filter` directly wraps a `union` of `project(from_table)` arms and every
197
+ condition targets bare passthrough columns, the engine pushes the predicate into each arm's
198
+ source filter automatically (making it index-servable); otherwise it evaluates post-union.
199
+
200
+ ### `join` — combine two row sets
201
+
202
+ ```jsonc
203
+ { "kind": "join", "type": "inner", // "inner" | "left"
204
+ "left": { … }, "right": { … },
205
+ "on": { "left_column": "customer_id", "right_column": "id" } }
206
+ ```
207
+
208
+ Single **equality** predicate on one output column per side — no multi-column ON, no
209
+ inequality/range joins. The two ON columns must share a SQL value category (text/number/…);
210
+ mismatches are rejected at deploy (cast one side with a projection type override). Column-name
211
+ collisions between the sides are rejected — `project`-rename first. A `left` join makes every
212
+ right-side column nullable. Addressing (write-through, `record_id` filtering) follows the
213
+ **left** side only.
214
+
215
+ ### `union` — stack row sets
216
+
217
+ ```jsonc
218
+ { "kind": "union", "sources": [ { … }, { … } ] } // 2+ sources
219
+ ```
220
+
221
+ `UNION ALL` — duplicates are preserved, never deduped. Sources must align **positionally** by
222
+ column name *and* type (column j of every source must have the same name and type). Use
223
+ `project` with `{ "literal": null }` fills (typed by the column's declared `type`) to align
224
+ heterogeneous tables. Source addressing survives per column only when every arm agrees on the
225
+ source table/field; per-row `__src_field_*` metadata still tracks which field each row's cell
226
+ came from.
227
+
228
+ ### `group` — aggregate
229
+
230
+ ```jsonc
231
+ { "kind": "group", "from": { … },
232
+ "by": [ "status",
233
+ { "bucket": { "source": "created", "granularity": "month", "output": "period" } } ],
234
+ "aggregates": [
235
+ { "output": "orders", "type": "number", "operation": "count" },
236
+ { "output": "revenue", "type": "number", "operation": "sum", "input_column": "total" } ] }
237
+ ```
238
+
239
+ Full semantics in §8. `by` may be empty (a single-row aggregate); `aggregates` needs ≥ 1 entry.
240
+ Aggregate operation × input-column type is validated at deploy. Grouping collapses rows —
241
+ addressing is dropped.
242
+
243
+ ### `window` — aggregate without collapsing
244
+
245
+ ```jsonc
246
+ { "kind": "window", "from": { … },
247
+ "partition_by": ["customer_id"],
248
+ "order_by": [{ "field_key": "created", "order": "asc" }],
249
+ "frame": { "type": "rows", "following": 0 }, // optional
250
+ "aggregates": [{ "output": "running_total", "type": "number",
251
+ "operation": "sum", "input_column": "total" }] }
252
+ ```
253
+
254
+ Appends aggregate columns to every input row (input columns pass through). Only the
255
+ **OVER-legal** operation subset is accepted (§8). Frame is `rows`-type only:
256
+ `preceding`/`following` omitted = unbounded, `0` = current row, `N` = N rows. Omitting `frame`
257
+ uses the SQL default — with `order_by` that is a *running* frame (partition start → current
258
+ row, ties included); without `order_by`, the whole partition. Output names must not collide
259
+ with input columns.
260
+
261
+ ### `sort` / `limit` — order and page a derived set
262
+
263
+ ```jsonc
264
+ { "kind": "sort", "from": { … }, "by": [{ "field_key": "revenue", "order": "desc" }] }
265
+ { "kind": "limit", "from": { … }, "n": 20, "offset": 40 }
266
+ ```
267
+
268
+ `sort.by` references output columns. `limit.n` is a positive int; `offset` optional. These are
269
+ the same nodes the runtime refinement wraps around your query (§9) — bake a `limit` into the
270
+ AST **only** for a fixed top-N; a baked-in limit caps the *total* a paginated browse can reach.
271
+
272
+ ### `unpivot` — static fan-out (columns → rows)
273
+
274
+ ```jsonc
275
+ { "kind": "unpivot", "from": { "kind": "from_table", "table_id": "tbl_shipments" },
276
+ "passthrough": [{ "output": "shipment_id", "type": "text", "source": { "record_id": true } }],
277
+ "row_columns": [
278
+ { "output": "charge_type", "type": "text" },
279
+ { "output": "charge_amount", "type": "number" } ],
280
+ "rows": [
281
+ { "charge_type": { "literal": "freight" }, "charge_amount": "freight_fee" },
282
+ { "charge_type": { "literal": "handling" }, "charge_amount": "handling_fee" },
283
+ { "charge_type": { "literal": "customs" }, "charge_amount": "customs_fee" } ] }
284
+ ```
285
+
286
+ Fans each source row into N output rows — one per `rows[]` entry — replicating `passthrough`
287
+ columns across the fan. The shape for "one record holds K parallel fields that are really K
288
+ child rows" (then `group` by `charge_type` for a per-type breakdown). Each `rows[i]` must
289
+ provide a source for exactly the `row_columns` outputs; types are declared once at the top.
290
+ The fan is **static** — declared in the AST. For fanning over an *array-valued cell's
291
+ contents*, use `unnest`.
292
+
293
+ ### `unnest` — dynamic fan-out (one row per array element)
294
+
295
+ ```jsonc
296
+ { "kind": "unnest", "from": { "kind": "from_table", "table_id": "tbl_orders" },
297
+ "source": "tags", // an array-valued column: select / select_member /
298
+ "output": "tag_key", // select_record_link / files (incl. such lookups)
299
+ "display_output": "tag_name",// links ONLY: the element's cached display text
300
+ "keep_empty": false } // true → element-less rows kept with NULL element
301
+ ```
302
+
303
+ One output row per **element** of an array-valued cell. Input columns (and addressing) pass
304
+ through unchanged; `output` is a new **text** column holding each element's identity by the
305
+ source column's type — select → option key, member → member id, link → **link record id**,
306
+ files → file id. `display_output` is valid only over a `select_record_link` source. Rows whose
307
+ cell is empty produce no rows unless `keep_empty: true` (then one row with NULL element
308
+ columns). Malformed / non-array cells are treated as empty, never abort the query. Element
309
+ columns are read-only. This is the per-element view of the record-link graph — the composition
310
+ primitive of §8's link-identity patterns.
311
+
312
+ **Limitation:** unnesting a **lookup** column only fans the *first linked record's* value —
313
+ lookup extraction unwraps the first element before the fan (§5). To reach all values across a
314
+ multi-link, unnest the *link column itself* and join to the target table (§8).
315
+
316
+ ---
317
+
318
+ ## 3. QuerySource — typed value expressions
319
+
320
+ A `QuerySource` appears in `project.columns[].source`, `unpivot.passthrough[].source`, and
321
+ `unpivot.rows[][key]`:
322
+
323
+ | Variant | Meaning | Output type |
324
+ | --- | --- | --- |
325
+ | `"field_key"` (bare string) | Passthrough of an input column | the column's type |
326
+ | `{ "literal": v }` | Constant (`string \| number \| boolean \| null`) | inferred (`null` takes the column's declared type) |
327
+ | `{ "eq": [a, b] }` / `{ "neq": [a, b] }` | Null-safe equality (`IS [NOT] DISTINCT FROM` — `null eq null` is `true`) | boolean |
328
+ | `{ "isEmpty": s }` / `{ "isNotEmpty": s }` | Emptiness test — over an array-valued column: empty at NULL / JSON null / `[]`; otherwise NULL-or-blank-text | boolean |
329
+ | `{ "coalesce": [s, …] }` | First non-null | declared |
330
+ | `{ "concat": [s, …] }` | Text concatenation (NULLs render empty) | text |
331
+ | `{ "record_id": true }` | The row's **own** record id — requires a direct `from_table` parent; always text, non-null, never writable | text |
332
+ | `{ "link": { "source": "customer", "field": "Company name" } }` | A field on the **first** linked record of a `select_record_link` field (correlated per-row subquery) | the target field's type |
333
+ | `{ "expression": "…" }` | jexpr escape hatch (below) | declared |
334
+
335
+ ### Link extraction — the fine print
336
+
337
+ `{ link: { source, field } }` requires a direct `from_table` parent, and `source` must be a
338
+ `select_record_link` field on it. `field` names a field on the **link target table** —
339
+ resolved **by display name first, then by key**. The target row is pinned to the declared
340
+ table, the workspace, live (non-archived, non-draft) rows, and the target table's row scope —
341
+ a scoped-out or stale link extracts as NULL.
342
+
343
+ **Warning:** only the **first** link in the cell is followed. Multi-link analytics go through
344
+ `unnest` + join (§8). And because `field` resolves by name first, renaming a field on the
345
+ target table can silently re-point (or break) the extraction — prefer field **keys** when the
346
+ target's names are volatile. Each extraction is a correlated subquery evaluated per output
347
+ row — cheap on a filtered detail read, expensive over thousands of rows.
348
+
349
+ ### The expression escape hatch
350
+
351
+ `{ "expression": "…" }` compiles a small jexpr subset to SQL. Column references are
352
+ `input.<column>` (user output columns only — system columns are not reachable; use
353
+ `{ record_id: true }` for identity). The **entire** supported surface:
354
+
355
+ - **Functions**: `concat`, `coalesce`, `round`, `floor`, `ceil`/`ceiling`, `abs`, `lower`/
356
+ `toLowerCase`, `upper`/`toUpperCase`, `length`, `trim`, `isNull`/`isNil`, `isNotNull`.
357
+ (`round`/`floor`/`ceil`/`abs` are 1-arg; `round` rounds to an integer.)
358
+ - **Operators**: `+ - * / %`, `== === != !==`, `< > <= >=`, `&& ||`, `??` (→ `COALESCE`),
359
+ unary `- + !`, and the ternary `cond ? a : b`.
360
+ - **Not supported** (rejected at deploy): indexing `[…]`, nested access (`input.a.b`), list/map
361
+ literals, arrow functions, method calls, any other function. Project the raw columns and
362
+ compute client-side instead.
363
+
364
+ ```jsonc
365
+ { "output": "unit_price", "type": "number",
366
+ "source": { "expression": "input.qty > 0 ? round(input.total / input.qty) : 0" } }
367
+ ```
368
+
369
+ Prefer the typed variants where one exists — expressions are strings and get less structural
370
+ validation (though they still compile-check at deploy).
371
+
372
+ ---
373
+
374
+ ## 4. Column types & type overrides
375
+
376
+ Query columns carry one of ten static types: `text`, `number`, `boolean`, `date`, `datetime`,
377
+ `select`, `select_member`, `select_record_link`, `files`, `json`. Four are **array-valued**
378
+ (`select`, `select_member`, `select_record_link`, `files`) — their cells are JSON arrays and a
379
+ cleared cell persists as `[]`; this drives the one emptiness contract (§8) and `unnest`
380
+ eligibility.
381
+
382
+ A passthrough projection may declare a different `type` than the source column. The override is
383
+ a **real SQL cast**, guarded so malformed values become NULL instead of aborting the query:
384
+
385
+ | From → To | Behavior |
386
+ | --- | --- |
387
+ | same SQL category (`date` ↔ `datetime`, `select` ↔ `json`, …) | reinterpretation, no cast |
388
+ | `text` → `number` / `boolean` / `date` / `datetime` | guarded cast; a non-conforming value → NULL |
389
+ | `json` → any scalar | via the JSON scalar text, guarded the same way |
390
+ | scalar → `text` / `json` | plain cast |
391
+ | array-valued (`select`/`member`/`link`/`files`) ↔ scalar | **impossible — rejected at deploy** |
392
+
393
+ The guarded-NULL rule means a query never aborts because one row holds `"n/a"` in a text column
394
+ you cast to number — that row's cell is NULL. Use overrides to align UNION arms or to make a
395
+ text-stored code join a numeric column; don't use them to paper over dirty data you could fix.
396
+
397
+ ---
398
+
399
+ ## 5. Per-field-type support matrix
400
+
401
+ How each **table field type** behaves in a query. "Source layer" = `from_table.filter`;
402
+ "derived layer" = `filter` nodes above projections **and** the runtime `filter` (§9) — the two
403
+ derived surfaces share one implementation.
404
+
405
+ | Field type | Column type | Source-layer operators | Derived-layer differences | Sort / group / aggregate notes |
406
+ | --- | --- | --- | --- | --- |
407
+ | text | `text` | `equals`, `not_equals`, `contains`, `does_not_contain`, `starts_with`, `ends_with`, `is_empty`, `is_not_empty`, `is_any_of`, `is_none_of` | same set | Sort is lexicographic in the database's default collation — no locale control. Text comparisons normalize both sides (trim + case-insensitive). |
408
+ | 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 | Full numeric aggregate set (§8). |
409
+ | 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. Sortable; bucketable in `group.by` (§8); `earliest`/`latest`/`min`/`max`/`date_range` aggregate. |
410
+ | boolean | `boolean` | `equals` (value `true`/`false`; `false` matches NULL/missing) | same | `checked`/`unchecked`/`percent_*` aggregate (`unchecked` counts false **or** empty). |
411
+ | 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 | same | Cells are arrays even for single-selects. Sorting a select column in a query orders by raw JSON, **not** configured option order. `unnest` fans option keys. |
412
+ | select_member | `select_member` | select ops + `is_current_member`, `is_not_current_member` (field-scoped, no value) | same — `is_current_member` **works at the runtime layer** too | `unnest` fans member ids. Anonymous public request: current-member binds the app owner (§1). |
413
+ | 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). |
414
+ | 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. |
415
+ | formula | its declared output type (`number`/`text`/`boolean`/`date`/`datetime`; `json` until inferred) | filtered by the output type's operators; `#ERROR:` cells are guarded — they count as empty and never match value operators | same | Extracted as a real scalar, so numeric formulas feed `sum`/`avg`/sorts. A **datetime-output** formula matches day-level date filters (full-day expansion). |
416
+ | rollup | number-returning ops → `number`; `earliest`/`latest`/`date_range` → `date`/`datetime` per the aggregated field's format | number or date operators per output type; text operators route as text | same | Datetime-format rollups match day-level date filters. |
417
+ | lookup | the looked-up field's type | operators of the looked-up type, evaluated with ANY-element semantics over the linked values (a row matches if *any* linked value matches) | array-typed lookups follow their column type's rules | **Projection returns the first element only.** Emptiness on a files-lookup checks the inner file arrays (a linked record with zero files counts empty). For all values across links: unnest the link column + join (§8). |
418
+ | autonumber | `text` | text operators — filter by the visible composed string (`"ORD-0042"`) | same | Lexical sort matches numeric order (constant prefix + padding). |
419
+ | button | `json` | **not filterable** — buttons are not data | — | Project/ignore; only presence-counting aggregates. |
420
+
421
+ **Field-less conditions** (no `field_key`):
422
+
423
+ | `type` | Operators | Where |
424
+ | --- | --- | --- |
425
+ | `locked` | `is_locked`, `is_not_locked` | source layer only |
426
+ | `current_member` | `in_any_group` (value: group ids — gates by the *viewer's* group membership) | source layer only |
427
+ | `record_id` | `is_any_of`, `is_none_of` (value: record ids) | source, derived, **and** runtime layers; rejected over a `group` |
428
+
429
+ **Warning (silent no-op):** a condition whose operator has no translation for the field's type
430
+ at the source layer — e.g. the date-*range* operators (`overlaps`, `within`, `starts_before`,
431
+ `duration_*`) — contributes **no constraint** rather than erroring. The same applies to a
432
+ condition whose value is `null`/absent. At the *derived* layer, unsupported operators fail
433
+ loudly instead. Positive list operators with an **empty list** (`is_any_of: []`,
434
+ `has_any_of: []`) match **nothing** at the derived layer, but are a silent no-op (no
435
+ constraint — like a null value) at the source layer; negative ones (`is_none_of: []`,
436
+ `has_none_of: []`) constrain nothing at either layer.
437
+
438
+ ### DateTimePoint
439
+
440
+ Date operator values are `DateTimePoint`s, resolved server-side in the **field's timezone**
441
+ (so "today" on a +14 h-zone field uses that zone's calendar day):
442
+
443
+ ```jsonc
444
+ { "type": "exact", "date": "2026-03-01", "time": null } // or "time": "14:30"
445
+ { "type": "relative", "offset": -7, "unit": "days" } // units: minutes…years
446
+ { "type": "period", "period": "month", "boundary": "start", "offset": 0 }
447
+ ```
448
+
449
+ `between` takes `{ start, end }` of points (either side may be null → open-ended);
450
+ `time_of_day` takes `{ start_time, end_time }` (`"HH:mm"`). A date-only `between` end bound is
451
+ inclusive of the whole day.
452
+
453
+ ---
454
+
455
+ ## 6. Filters in depth
456
+
457
+ `from_table.filter`, the `filter` node's `predicate`, and the runtime `filter` all share one
458
+ tree shape — `TableRecordFilters`:
459
+
460
+ ```jsonc
461
+ // condition — one predicate
462
+ { "node_type": "condition", "field_key": "status", "operator": "has_any_of", "value": ["opt_open"] }
463
+ // group — AND/OR of children (conditions, traversals, nested groups)
464
+ { "node_type": "group", "logic": "and", "children": [ … ] }
465
+ // traversal — a predicate on a record reachable through link hops
466
+ { "node_type": "traversal", "path": ["customer"],
467
+ "condition": { "node_type": "condition", "field_key": "owner", "operator": "is_current_member" } }
468
+ ```
469
+
470
+ A condition's `type` (`"text"`, `"number"`, `"date"`, …) is derived from the live field —
471
+ include it for date/formula conditions (it routes value semantics); the operator must belong to
472
+ the field's set (§5). An **empty group is a no-op** (match-all) — the foundation of param
473
+ pruning below.
474
+
475
+ ### Traversals
476
+
477
+ A traversal filters the current table by a condition on a **linked** record: `path` is a chain
478
+ of `select_record_link` field keys (max **3 hops**), `condition` evaluates on the final table.
479
+ **ANY semantics** — a row matches if at least one reachable record satisfies the condition.
480
+ Negation-shaped inner operators (`has_none_of`, `not_equals`, `is_none_of`,
481
+ `is_not_current_member`, …) evaluate as *no reachable record matches the positive form* — not
482
+ "any record matches the negation".
483
+
484
+ Traversals are **source-layer only** (rejected on derived columns — push them into
485
+ `from_table.filter`). Two access classes:
486
+
487
+ - **Self-scoped** — inner operator `is_current_member` / `is_not_current_member`: tests only
488
+ the viewer's own membership on the linked row, leaks nothing, and is exempt from the linked
489
+ table's row scope. This is the primitive for "my child rows" self-service queries — scope to
490
+ a parent the viewer can't otherwise list.
491
+ - **Everything else** — the traversal reads linked-row data through row selection, so the
492
+ linked tables are access-gated like any projected reach, and each hop honors the linked
493
+ table's row scope.
494
+
495
+ ### Params, `{{params.x}}` tokens, and optional-param pruning
496
+
497
+ Tokens interpolate in **value positions**. A string that *is* exactly one token is replaced by
498
+ the param's typed value (an array param yields an array); a token embedded in a larger string
499
+ interpolates as text. Declared-but-unreferenced params are fine; referenced-but-undeclared
500
+ tokens fail deploy.
501
+
502
+ **Composable optional filters (one query, many scopes).** Expose several independent filter
503
+ axes from ONE named query — don't shard into a query-per-combination. Mark each scoping param
504
+ `required: false`; the server **prunes every filter condition (or traversal) whose
505
+ `{{params.x}}` the caller didn't pass** — then collapses emptied groups — so an unset axis
506
+ stops constraining instead of erroring. No-op for all-required queries.
507
+
508
+ ```jsonc
509
+ "search": {
510
+ "ast": { "kind": "project", "from": { "kind": "from_table", "table_id": "tbl_items", "filter": {
511
+ "node_type": "group", "logic": "and", "children": [
512
+ { "node_type": "condition", "field_key": "status", "operator": "has_any_of", "value": ["{{params.status}}"] },
513
+ // keyword over two fields — the whole OR-group prunes when `keyword` is absent
514
+ { "node_type": "group", "logic": "or", "children": [
515
+ { "node_type": "condition", "field_key": "title", "operator": "contains", "value": "{{params.keyword}}" },
516
+ { "node_type": "condition", "field_key": "notes", "operator": "contains", "value": "{{params.keyword}}" } ] },
517
+ // date range — each bound is its OWN single-param condition, so an open-ended range prunes one side
518
+ { "node_type": "condition", "field_key": "created", "operator": "on_or_after",
519
+ "value": { "type": "exact", "date": "{{params.from}}", "time": null } },
520
+ { "node_type": "condition", "field_key": "created", "operator": "on_or_before",
521
+ "value": { "type": "exact", "date": "{{params.to}}", "time": null } } ] }, "columns": [ /* … */ ] },
522
+ "params": {
523
+ "status": { "type": "select", "options": [ /* … */ ], "required": false },
524
+ "keyword": { "type": "text", "required": false },
525
+ "from": { "type": "date", "required": false },
526
+ "to": { "type": "date", "required": false } }
527
+ }
528
+ ```
529
+
530
+ `useQuery("search", { keyword })` filters by keyword only; `{}` returns everything. Pruning is
531
+ per-*condition*: a group whose children all prune collapses away; a condition mixing a provided
532
+ and an un-provided param prunes whole (all its tokens must be provided to survive). A
533
+ `from_table.search` whose tokens aren't all provided also prunes to match-all — so a
534
+ "show all until you type" search box works with a `required: false` search param. **Dates:**
535
+ there is no `date_range` interpolation shortcut — embed a `date` param in a hand-built
536
+ exact `DateTimePoint` as above, one condition per bound so each prunes independently. Keep a
537
+ scope that must *always* apply `required` (a missing required param 400s).
538
+
539
+ ### Fetching one record by id
540
+
541
+ Use the field-less `record_id` condition — typically as a **runtime** filter so one detail
542
+ query serves any id:
543
+
544
+ ```ts
545
+ useQuery("orderDetail", {}, { filter: {
546
+ node_type: "condition", type: "record_id", operator: "is_any_of", value: [id] } });
547
+ ```
548
+
549
+ A link `has_any_of [id]` matches a *related* record; `record_id` matches the row's **own** id —
550
+ the only way, since a record has no field holding its own id. Prefer a link/join when an actual
551
+ relationship exists; reach for `record_id` when your starting point is a bare id (a drill row).
552
+ Rejected over a grouped query (no row identity).
553
+
554
+ ---
555
+
556
+ ## 7. Free-text search
557
+
558
+ `from_table.search` runs over the record's maintained **search document** — a per-record text
559
+ index that is:
560
+
561
+ - **diacritics- and case-insensitive** (`"da nang"` matches `"Đà Nẵng"`),
562
+ - **trigram-indexed** (scales to large tables — unlike `contains`, which scans),
563
+ - **near-total field coverage** — indexes text, number, date (ISO plus `MM/dd/yyyy` and
564
+ `dd/MM/yyyy` forms), select option labels, member names, link display text, and
565
+ formula/rollup/lookup values; boolean and files cells are not indexed,
566
+ - multi-term: the query splits on whitespace (max 10 terms), all terms must match (AND).
567
+
568
+ It AND-s with `filter` (search *within* a scope) and is templatable
569
+ (`"search": "{{params.q}}"`). An empty or whitespace-only term matches everything; an
570
+ unresolved optional token prunes to match-all (§6).
571
+
572
+ **Search-as-you-type uses `search`, never a `contains` OR-group.** Per-field `contains` is
573
+ accent-*sensitive* and unindexed — a zero-match keystroke forces a full-partition scan that
574
+ hangs the picker. Reserve an OR-group of `contains` for when you must bound exactly *which*
575
+ fields match. Gate the fetch on a non-empty term client-side (`enabled`), or first paint dumps
576
+ the table.
577
+
578
+ ---
579
+
580
+ ## 8. Shaping: group, buckets, window, fan-out
581
+
582
+ ### The 20 aggregate operations
583
+
584
+ `group` and `window` share one operation vocabulary. `count` is `COUNT(*)` (no `input_column`);
585
+ everything else requires an `input_column` whose type must be compatible — checked at deploy:
586
+
587
+ | Operation | Valid input column types | Result | Notes |
588
+ | --- | --- | --- | --- |
589
+ | `count` | — (counts rows) | number | non-null; 0 for empty groups |
590
+ | `sum`, `avg`, `median`, `range` | number | number | `range` = max − min; `median` = continuous percentile |
591
+ | `min`, `max` | number, text, date, datetime | input type (number/text) or date | text is lexicographic; on dates ≡ earliest/latest |
592
+ | `earliest`, `latest` | date, datetime | date/datetime | |
593
+ | `date_range` | date, datetime | number | span in **days** (fractional) |
594
+ | `filled`, `empty` | any except boolean | number | presence counts — see the emptiness contract |
595
+ | `percent_filled`, `percent_empty` | any except boolean | number | **fraction 0–1**, not 0–100; NULL for an empty group |
596
+ | `unique`, `percent_unique` | text, number, date/datetime, select, select_member, select_record_link, files, json¹ | number | distinct **present** values; array cells compare as whole arrays |
597
+ | `checked`, `unchecked`, `percent_checked`, `percent_unchecked` | boolean | number | `unchecked` counts false **or** empty |
598
+
599
+ ¹ opaque `json` columns support only the presence-counting six (`empty`/`filled`/`unique` and
600
+ their `percent_*` forms).
601
+
602
+ **The one emptiness contract.** `filled`/`empty`/`unique`/`percent_*` use the same definition
603
+ of "present" as the filter layer's `is_empty` and the `isEmpty` source: array-valued cells are
604
+ empty at NULL / JSON `null` / `[]`; text at NULL or blank (whitespace-only); opaque json at
605
+ NULL / JSON `null`; other scalars at SQL NULL. A cleared multi-select or a whitespace-only text
606
+ cell counts **empty** everywhere — filters and aggregates never disagree.
607
+
608
+ ### Date-bucket group keys
609
+
610
+ `group.by` accepts, alongside plain column names:
611
+
612
+ ```jsonc
613
+ { "bucket": { "source": "created", "granularity": "month", "output": "period" } }
614
+ ```
615
+
616
+ - `source` must be a `date` or `datetime` **column** of the input (deploy-checked).
617
+ - `granularity`: `day` | `week` | `month` | `quarter` | `year`. **Weeks are ISO — Monday
618
+ start.**
619
+ - Buckets are computed on the stored **wall-clock** value in the field's timezone (a 23:30
620
+ record buckets into its local month), matching how date filters compare.
621
+ - The output is a new **`date`** column holding the **bucket start** — sortable, filterable,
622
+ and re-aggregatable downstream like any date column. A NULL source date yields a NULL bucket
623
+ (its rows group together).
624
+
625
+ ```jsonc
626
+ { "kind": "group",
627
+ "from": { "kind": "from_table", "table_id": "tbl_orders" },
628
+ "by": [ { "bucket": { "source": "created", "granularity": "week", "output": "week" } }, "status" ],
629
+ "aggregates": [ { "output": "n", "type": "number", "operation": "count" } ] }
630
+ ```
631
+
632
+ Sort by the bucket column for a time series; filter it with date operators for a rolling
633
+ window. **Always bucket server-side** — shipping raw rows to bucket in JS burns the row cap and
634
+ the timeout for nothing.
635
+
636
+ ### Window functions — the OVER-legal subset
637
+
638
+ Only operations that compile to a single legal SQL window call are accepted in `window`:
639
+ **`count`, `sum`, `avg`, `min`, `max`, `earliest`, `latest`, `filled`, `checked`,
640
+ `unchecked`.** The rest cannot take an OVER clause (`median` is an ordered-set aggregate;
641
+ `unique`/`percent_unique` need DISTINCT; `range`/`empty`/`date_range`/`percent_*` compose
642
+ multiple calls) — rejected at deploy.
643
+
644
+ **There are no ranking or navigation functions** — no `row_number`, `rank`, `dense_rank`,
645
+ `lag`, `lead`, `first_value`. **Top-N per group** is emulated with a running count over a
646
+ frame:
647
+
648
+ ```jsonc
649
+ { "kind": "filter",
650
+ "from": { "kind": "window",
651
+ "from": { "kind": "from_table", "table_id": "tbl_orders" },
652
+ "partition_by": ["customer_id"],
653
+ "order_by": [{ "field_key": "total", "order": "desc" }],
654
+ "frame": { "type": "rows", "following": 0 }, // partition start → current row
655
+ "aggregates": [{ "output": "rank", "type": "number", "operation": "count" }] },
656
+ "predicate": { "node_type": "condition", "field_key": "rank",
657
+ "operator": "less_than_or_equal_to", "value": 3 } }
658
+ ```
659
+
660
+ `count` over a `ROWS … CURRENT ROW` frame is a row position (`row_number`-like; ties are
661
+ ordered arbitrarily unless the `order_by` is total — add a tiebreaker key for determinism).
662
+
663
+ ### unpivot vs unnest
664
+
665
+ - **`unpivot`** — *static* fan: N parallel **fields** on one record become N rows. The fan is
666
+ written in the AST.
667
+ - **`unnest`** — *dynamic* fan: the **elements of one array cell** become rows. The fan is the
668
+ data.
669
+
670
+ They compose: unpivot a record's three link fields into rows, then unnest each.
671
+
672
+ ### No pivot
673
+
674
+ There is **no pivot/crosstab node** — you cannot turn row values into columns server-side.
675
+ Group by the two dimensions (`by: [bucket, "status"]`) and pivot client-side over the compact
676
+ grouped result (cheap — it's already aggregated), or `unpivot` a wide record into long form
677
+ first. For a fixed, small set of columns, N filtered aggregate queries also work.
678
+
679
+ ---
680
+
681
+ ## 9. Runtime refinement — filter / sort / limit / offset / count
682
+
683
+ Each query request carries optional refinement the server wraps **around** the deployed
684
+ template as derived nodes, in this order: `filter` (narrow) → `sort` (order) → `limit`+`offset`
685
+ (page):
686
+
687
+ - **`filter`** — a `TableRecordFilters` tree over the query's **output columns** (§5 derived
688
+ layer; `record_id` allowed). A `field_key` that isn't a projected output column → 400. This
689
+ is the exposure invariant: a caller can refine what the query already exposes, never widen
690
+ it. Record-link membership (`has_any_of` by id) **works** here; so does `is_current_member`
691
+ on a member column. Traversals / `locked` / `current_member` do not — bake those into the
692
+ template.
693
+ - **`sort`** — `[{ field_key, order, blank_position? }]` over output columns.
694
+ - **`limit` / `offset`** — offset pagination. `limit` is clamped to the 10,000-row cap (§10).
695
+ - **`count: true`** — returns `{ total }` only: a single-row COUNT over the *filtered* set,
696
+ ignoring sort/limit/offset. Drives "Page 1 of N".
697
+
698
+ The SDK hooks map onto this directly (`dist/src/hooks.d.ts` for exact signatures): `useQuery`
699
+ sends `limit: pageSize, offset: 0` (a cap, not pagination) plus `opts.sort`/`opts.filter`;
700
+ `useInfiniteQuery` pages by `offset = page × pageSize`; `usePaginatedQuery` owns the page
701
+ cursor and issues the page query plus a `count` keyed on `(alias, params, filter)` —
702
+ independent of page and sort, so paging and re-sorting never recount, while changing
703
+ `(params, filter)` resets to page 0 and recounts.
704
+
705
+ Build `filter` from UI column-filters with `columnFilterToConditions` (`@lotics/ui`); prefer
706
+ `useFieldOptions` for a select filter's option set.
707
+
708
+ **Warning (transport gaps):** the embedded product host and the `lotics app dev` forwarder pass
709
+ `sort`/`filter`/`count` through. The **standalone public transport** (`<slug>.lotics.app`)
710
+ currently forwards only `alias`/`params`/`limit`/`offset` — runtime `sort`/`filter` are
711
+ silently ignored there and `count` never resolves (`usePaginatedQuery.total` stays
712
+ `undefined`). A standalone app must bake ordering/scoping into the template (or params) rather
713
+ than rely on runtime refinement.
714
+
715
+ **Pagination semantics:** offset-only — a deep page costs the server the full skipped prefix
716
+ (page 400 of a 25-row pager scans ~10,000 rows before returning 25), and pages can shift under
717
+ concurrent writes (a row inserted before your offset repeats or skips a row across pages).
718
+ There is no cursor/keyset pagination. Keep paginated browses filtered and sorted by a stable
719
+ key, and don't build UX that walks thousands of pages.
720
+
721
+ ---
722
+
723
+ ## 10. Limits & the efficiency playbook
724
+
725
+ ### Hard limits
726
+
727
+ | Limit | Value | On violation |
728
+ | --- | --- | --- |
729
+ | Rows per response | **10,000** (caller `limit` is clamped; the cap is the default) | silent truncation at the cap — paginate |
730
+ | Statement timeout | **15 s** per query execution | 400: `query timed out after 15s — narrow the filter or simplify the query` |
731
+ | Concurrent query executions | server-configured bulkhead (bounded slots + bounded queue wait) | **503**: `The app is handling too many requests right now. Please retry in a moment.` — a distinct busy-retry outcome; back off and retry |
732
+ | Signed file URLs per response | server-configured, default **2,000 file entries** | 400 naming the alias, the count, and the ceiling — project files columns only where rendered, narrow, or paginate |
733
+ | Traversal depth | 3 hops | **silent match-nothing** — a longer path compiles to `FALSE` (deploys green, returns zero rows) |
734
+ | Param schema depth | 8 | deploy reject |
735
+
736
+ Any other execution failure returns a generic `query execution failed` (the real error — which
737
+ may embed SQL — is server-logged only). Deploy-time and validation errors are always specific.
738
+
739
+ ### Index reality, in plain terms
740
+
741
+ Records live in one big partitioned store; a `from_table` scan narrows to the table's partition
742
+ slice, and within it:
743
+
744
+ - **GIN-served (fast at any size):** *positive* exact-membership filters at the **source
745
+ layer** — `select`/`select_member`/`select_record_link` `has_any_of` / `has_all_of`, and
746
+ `is_current_member`. These compile to containment the JSONB GIN index serves.
747
+ - **Trigram-served:** `from_table.search` (§7).
748
+ - **Partition scan (linear in table size):** everything else — text `contains`/`equals`,
749
+ number and date range predicates, negations (`has_none_of`, `is_none_of`, `not_*`),
750
+ emptiness, files predicates. Fine on thousands of rows; on very large tables these dominate
751
+ latency and are the usual timeout cause.
752
+
753
+ **Filter shape drives latency.** Lead with a GIN-served membership filter or `search` where you
754
+ can; let the scan-shaped predicates refine the already-narrowed set. Derived-layer filters run
755
+ over the subquery result (no index), so **filter at the source layer whenever the field exists
756
+ there** — the runtime filter is for caller-driven refinement, not for the main cut. (The engine
757
+ pushes eligible filter-over-union predicates down automatically, but don't rely on that for
758
+ other shapes.)
759
+
760
+ ### The authoring rules
761
+
762
+ 1. **Filter at the source.** Push every static predicate into `from_table.filter`.
763
+ 2. **Prefer `search`** for any free-text box; `contains` OR-groups only to bound the fields.
764
+ 3. **Aggregate and bucket server-side.** A dashboard reads grouped rows, never raw rows it
765
+ reduces in JS — raw-row shipping burns the 10k cap, the timeout, and bandwidth at once.
766
+ 4. **Project narrow.** Every un-rendered column is wasted bytes; every un-rendered `files`
767
+ column risks the presign ceiling and over-exposes storage metadata.
768
+ 5. **Files columns only where rendered.** A list view projects no files; the detail query does.
769
+ 6. **Avoid deep offsets.** Filter first so the browsable set is small; sort by a stable key.
770
+ 7. **Don't bake a `limit` into a browsable query** — it caps the total; let `pageSize` drive.
771
+ 8. **Parameterize lookups.** A code/id lookup is a `{{params.x}}` filter (or a `record_id`
772
+ runtime filter) returning one row — never load-all-then-find in JS.
773
+ 9. **Expect and handle the three runtime outcomes**: a 400 with a message (surface it), the
774
+ timeout 400 (narrow/simplify), and the 503 busy (retry after a moment).
775
+
776
+ ---
777
+
778
+ ## 11. Combining tables — the patterns
779
+
780
+ Four tools combine tables: **join** (row sets on a shared value), **union** (stack), **link
781
+ extraction** (one field off the first link), and **unnest + join** (the link graph,
782
+ per-element). Choosing:
783
+
784
+ - One display field from a single-link relation → **link extraction** (or better, a
785
+ materialized **lookup field** on the table, which is filterable/sortable at the source).
786
+ - Rows relate through a shared business key (an order code column both tables carry) →
787
+ **join** on the key columns directly.
788
+ - Anything per-element over a multi-link (aggregates across links, per-tag counts, join through
789
+ link identity) → **unnest + `{ record_id: true }` + join**.
790
+ - A per-parent aggregate you need *often* and *filterable at the source* → consider a
791
+ materialized **rollup field** on the parent table instead of a live query — the platform
792
+ keeps it updated, and it filters/sorts with source-layer support. The query-side aggregate
793
+ is for shapes a rollup can't express or ad-hoc analytics.
794
+
795
+ ### Link-identity join (decorate rows with linked-record data)
796
+
797
+ Orders link customers; you want each order with the customer's region — all links resolvable,
798
+ not just the first field:
799
+
800
+ ```jsonc
801
+ { "kind": "join", "type": "left",
802
+ "left": { "kind": "unnest",
803
+ "from": { "kind": "project", "from": { "kind": "from_table", "table_id": "tbl_orders" },
804
+ "columns": ["order_code", "total", "customer"] },
805
+ "source": "customer", "output": "customer_id", "keep_empty": true },
806
+ "right": { "kind": "project", "from": { "kind": "from_table", "table_id": "tbl_customers" },
807
+ "columns": [ { "output": "cust_rid", "source": { "record_id": true } },
808
+ { "output": "region", "source": "region" } ] },
809
+ "on": { "left_column": "customer_id", "right_column": "cust_rid" } }
810
+ ```
811
+
812
+ `unnest` turns the link cell into a text `customer_id` per link; the right side projects the
813
+ customer's own id via `{ record_id: true }`; the scalar-equality join matches them. Both
814
+ columns are text — the ON type check passes. `keep_empty: true` keeps customer-less orders
815
+ (LEFT-join semantics end to end).
816
+
817
+ ### Child-aggregate-by-parent (including many-to-many)
818
+
819
+ Sum of order totals per customer, when *orders* hold the (possibly multi) link:
820
+
821
+ ```jsonc
822
+ { "kind": "group",
823
+ "from": { "kind": "unnest",
824
+ "from": { "kind": "project", "from": { "kind": "from_table", "table_id": "tbl_orders" },
825
+ "columns": ["total", "customer"] },
826
+ "source": "customer", "output": "customer_id", "display_output": "customer_name" },
827
+ "by": ["customer_id", "customer_name"],
828
+ "aggregates": [
829
+ { "output": "orders", "type": "number", "operation": "count" },
830
+ { "output": "revenue", "type": "number", "operation": "sum", "input_column": "total" } ] }
831
+ ```
832
+
833
+ Works unchanged for many-to-many: an order linked to two customers contributes a row to each
834
+ group (by design — each parent sees its own child). Group by the display column too so the
835
+ result is directly renderable.
836
+
837
+ ### Per-tag counts
838
+
839
+ ```jsonc
840
+ { "kind": "group",
841
+ "from": { "kind": "unnest",
842
+ "from": { "kind": "project", "from": { "kind": "from_table", "table_id": "tbl_items" },
843
+ "columns": ["tags"] },
844
+ "source": "tags", "output": "tag" },
845
+ "by": ["tag"],
846
+ "aggregates": [{ "output": "n", "type": "number", "operation": "count" }] }
847
+ ```
848
+
849
+ Multi-select cells fan one row per option key; grouping counts each tag's frequency. (Resolve
850
+ keys to labels/colors with `useFieldOptions`.)
851
+
852
+ ### One row per file (a document register)
853
+
854
+ ```jsonc
855
+ { "kind": "unnest",
856
+ "from": { "kind": "project", "from": { "kind": "from_table", "table_id": "tbl_contracts" },
857
+ "columns": ["contract_code", "attachments"] },
858
+ "source": "attachments", "output": "file_id" }
859
+ ```
860
+
861
+ Each file becomes a row carrying its parent's columns — but note the fanned `file_id` is a bare
862
+ text id; the presign enrichment rides the original `files` **cell**, which this query still
863
+ projects (so each fanned row repeats the full cell). For a lean register, keep the `files`
864
+ column projected only in the query that renders the previews, and mind the presign ceiling.
865
+
866
+ ### All values of a multi-link lookup
867
+
868
+ A lookup projection (and unnesting the lookup column) yields only the **first** linked record's
869
+ value (§5). To read the looked-up field across *all* links: unnest the **link column**, join to
870
+ the target on `record_id`, and project the field from the target side — the link-identity join
871
+ above, with `region` replaced by whatever the lookup pointed at.
872
+
873
+ ---
874
+
875
+ ## 12. Current limitations
876
+
877
+ Consolidated from the sections above — these describe present engine behavior:
878
+
879
+ - **No pivot/crosstab node.** Row-values-to-columns happens client-side over grouped results
880
+ (§8).
881
+ - **No ranking or navigation window functions** (`row_number`, `rank`, `lag`, `lead`, …).
882
+ Top-N per group = the count-over-frame emulation (§8). Window ops are the 10-item OVER-legal
883
+ subset.
884
+ - **Offset-only pagination.** Deep pages cost the full skipped prefix; pages can shift under
885
+ concurrent writes (§9).
886
+ - **No collation control.** Text ORDER BY uses the database default collation —
887
+ locale-specific alphabetical order (e.g. accented-letter ordering) is not configurable.
888
+ - **Select / member / link columns sort by raw JSON** in queries — not by configured option
889
+ order or display text. Sort a projected text form instead.
890
+ - **Lookup projection returns the first element**; unnesting a lookup column fans only the
891
+ first linked record's value. All-values access goes through unnest-the-link + join (§11).
892
+ - **Link extraction follows the first link only**, resolves the target field by display name
893
+ before key, and costs a correlated subquery per row (§3).
894
+ - **Join is single-column equality** with SQL-category-matched types; addressing follows the
895
+ left side (§2).
896
+ - **Text / number / date range predicates are unindexed within the partition** — the usual
897
+ cause of slow queries and timeouts on large tables; positive membership filters and `search`
898
+ are the indexed paths (§10).
899
+ - **Traversals, `locked`, and `current_member` conditions are source-layer only** — not
900
+ available in derived or runtime filters (§6).
901
+ - **The standalone public transport drops runtime `sort`/`filter`/`count`** — refinement is
902
+ silently ignored and paginated totals never resolve there; bake ordering/scoping into the
903
+ template for standalone apps (§9).
904
+ - **Unsupported operators at the source layer are silent no-ops** (notably the date-range
905
+ operator family) — the condition contributes no constraint rather than erroring (§5).
906
+ - **The expression allowlist is small and flat** — no indexing, nested access, or functions
907
+ beyond the §3 list; compute anything richer client-side from projected columns.
908
+ - **`from_table` is the only leaf** — a query reads tables; there is no way to query a view,
909
+ another query, or an external source.