@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.
- package/AGENTS.md +49 -397
- package/docs/ai.md +201 -0
- package/docs/data_fetching.md +349 -0
- package/docs/files.md +312 -0
- package/docs/members_and_options.md +307 -0
- package/docs/mutations.md +438 -0
- package/docs/navigation_and_state.md +289 -0
- package/docs/queries.md +909 -0
- package/docs/runtime.md +432 -0
- package/docs/security.md +116 -0
- package/package.json +3 -2
package/docs/queries.md
ADDED
|
@@ -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.
|