@lotics/app-sdk 0.100.1 → 0.101.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +32 -47
- package/dist/agent_stream.d.ts +131 -0
- package/dist/ask_ai.d.ts +27 -0
- package/dist/attachments.d.ts +58 -0
- package/dist/chunk-ARV5FAU5.js +1132 -0
- package/dist/comments.d.ts +89 -0
- package/dist/error_report.d.ts +9 -0
- package/dist/folder_pick.d.ts +8 -0
- package/dist/geolocation.d.ts +42 -0
- package/dist/hooks.d.ts +251 -0
- package/dist/{src/index.d.ts → index.d.ts} +13 -22
- package/dist/index.js +31331 -0
- package/dist/index.js.LEGAL.txt +11 -0
- package/dist/members.d.ts +32 -0
- package/dist/mock.d.ts +37 -0
- package/dist/mount.d.ts +19 -0
- package/dist/new_record.d.ts +37 -0
- package/dist/open_app.d.ts +12 -0
- package/dist/open_external.d.ts +10 -0
- package/dist/overlay.d.ts +25 -0
- package/dist/queries.d.ts +231 -0
- package/dist/recording.d.ts +47 -0
- package/dist/recording_state.d.ts +43 -0
- package/dist/rename_file.d.ts +13 -0
- package/dist/router.d.ts +10 -0
- package/dist/router.js +97 -0
- package/dist/row.d.ts +87 -0
- package/dist/rpc.d.ts +114 -0
- package/dist/select.d.ts +24 -0
- package/dist/shared_types.d.ts +8 -0
- package/dist/store.d.ts +43 -0
- package/dist/types.d.ts +36 -0
- package/dist/upload/optimize.d.ts +30 -0
- package/dist/upload/pipeline.d.ts +36 -0
- package/dist/upload/transport.d.ts +19 -0
- package/dist/url_params.d.ts +55 -0
- package/dist/use_recents.d.ts +15 -0
- package/dist/{src/use_url_state.d.ts → use_url_state.d.ts} +0 -2
- package/dist/viewer.d.ts +41 -0
- package/dist/written.d.ts +79 -0
- package/docs/ai.md +74 -133
- package/docs/data_fetching.md +209 -290
- package/docs/files.md +61 -51
- package/docs/members_and_options.md +92 -62
- package/docs/mutations.md +136 -205
- package/docs/navigation_and_state.md +26 -35
- package/docs/queries.md +144 -207
- package/docs/recipes.md +21 -45
- package/docs/runtime.md +74 -137
- package/docs/security.md +8 -11
- package/docs/workflows.md +189 -174
- package/package.json +27 -28
- package/dist/src/agent_stream.d.ts +0 -200
- package/dist/src/agent_stream.js +0 -314
- package/dist/src/ask_ai.d.ts +0 -40
- package/dist/src/ask_ai.js +0 -35
- package/dist/src/attachments.d.ts +0 -68
- package/dist/src/attachments.js +0 -93
- package/dist/src/comments.d.ts +0 -127
- package/dist/src/comments.js +0 -192
- package/dist/src/download.js +0 -54
- package/dist/src/geolocation.d.ts +0 -64
- package/dist/src/geolocation.js +0 -96
- package/dist/src/hooks.d.ts +0 -781
- package/dist/src/hooks.js +0 -860
- package/dist/src/index.js +0 -34
- package/dist/src/members.d.ts +0 -105
- package/dist/src/members.js +0 -62
- package/dist/src/mock.d.ts +0 -118
- package/dist/src/mock.js +0 -124
- package/dist/src/mount.d.ts +0 -47
- package/dist/src/mount.js +0 -34
- package/dist/src/new_record.d.ts +0 -74
- package/dist/src/new_record.js +0 -117
- package/dist/src/open_app.d.ts +0 -15
- package/dist/src/open_app.js +0 -18
- package/dist/src/open_external.d.ts +0 -16
- package/dist/src/open_external.js +0 -19
- package/dist/src/recording.d.ts +0 -59
- package/dist/src/recording.js +0 -30
- package/dist/src/recording_state.d.ts +0 -59
- package/dist/src/recording_state.js +0 -94
- package/dist/src/router.d.ts +0 -17
- package/dist/src/router.js +0 -144
- package/dist/src/row.d.ts +0 -159
- package/dist/src/row.js +0 -254
- package/dist/src/rpc.d.ts +0 -207
- package/dist/src/rpc.js +0 -904
- package/dist/src/select.d.ts +0 -48
- package/dist/src/select.js +0 -40
- package/dist/src/types.d.ts +0 -115
- package/dist/src/types.js +0 -1
- package/dist/src/upload/optimize.d.ts +0 -54
- package/dist/src/upload/optimize.js +0 -207
- package/dist/src/upload/pipeline.d.ts +0 -55
- package/dist/src/upload/pipeline.js +0 -52
- package/dist/src/upload/transport.d.ts +0 -42
- package/dist/src/upload/transport.js +0 -128
- package/dist/src/url_params.d.ts +0 -93
- package/dist/src/url_params.js +0 -215
- package/dist/src/use_optimistic.d.ts +0 -27
- package/dist/src/use_optimistic.js +0 -27
- package/dist/src/use_recents.d.ts +0 -19
- package/dist/src/use_recents.js +0 -71
- package/dist/src/use_url_state.js +0 -73
- package/dist/src/viewer.d.ts +0 -26
- package/dist/src/viewer.js +0 -47
- /package/dist/{src/download.d.ts → download.d.ts} +0 -0
package/docs/queries.md
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
# Queries — the query engine authoring reference
|
|
2
2
|
|
|
3
|
-
Every read
|
|
4
|
-
`
|
|
5
|
-
|
|
6
|
-
for the
|
|
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.
|
|
3
|
+
Every read an app performs is a **named query**: a fixed AST template bound to the app by alias
|
|
4
|
+
(`set_app_query`), validated and compiled when it is written, and invoked by alias
|
|
5
|
+
through the read hooks ([data_fetching.md](./data_fetching.md)). This is the authoring reference
|
|
6
|
+
for the query engine itself.
|
|
10
7
|
|
|
11
8
|
---
|
|
12
9
|
|
|
@@ -14,32 +11,29 @@ engine's hard limits.
|
|
|
14
11
|
|
|
15
12
|
### Declaration
|
|
16
13
|
|
|
17
|
-
Queries live
|
|
14
|
+
Queries live on the app, as an alias → declaration map. `set_app_query` binds one alias at a time
|
|
15
|
+
(`set_app_queries` several), each as a new version of the app:
|
|
18
16
|
|
|
19
17
|
```jsonc
|
|
18
|
+
// set_app_query { "app_id": "app_…", "alias": "openOrders", "declaration": … }
|
|
20
19
|
{
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"operator": "has_any_of", "value": ["opt_open"] }
|
|
31
|
-
},
|
|
32
|
-
"columns": ["order_code", "customer", "total", "created"]
|
|
33
|
-
}
|
|
34
|
-
},
|
|
35
|
-
"orderByCode": {
|
|
36
|
-
"ast": { /* … a filter with "{{params.code}}" … */ },
|
|
37
|
-
"params": { "code": { "type": "text" } },
|
|
38
|
-
"description": "One order by its code, with its customer and line total."
|
|
39
|
-
}
|
|
40
|
-
}
|
|
20
|
+
"ast": {
|
|
21
|
+
"kind": "project",
|
|
22
|
+
"from": {
|
|
23
|
+
"kind": "from_table",
|
|
24
|
+
"table_id": "tbl_orders",
|
|
25
|
+
"filter": { "node_type": "condition", "field_key": "status",
|
|
26
|
+
"operator": "has_any_of", "value": ["opt_open"] }
|
|
27
|
+
},
|
|
28
|
+
"columns": ["order_code", "customer", "total", "created"]
|
|
41
29
|
}
|
|
42
30
|
}
|
|
31
|
+
// set_app_query { "app_id": "app_…", "alias": "orderByCode", "declaration": … }
|
|
32
|
+
{
|
|
33
|
+
"ast": { /* … a filter with "{{params.code}}" … */ },
|
|
34
|
+
"params": { "code": { "type": "text" } },
|
|
35
|
+
"description": "One order by its code, with its customer and line total."
|
|
36
|
+
}
|
|
43
37
|
```
|
|
44
38
|
|
|
45
39
|
- **`ast`** — a `QueryNode` tree (the 11 node kinds below). It may embed `{{params.<name>}}`
|
|
@@ -49,17 +43,14 @@ Queries live in the app's `package.json` under `lotics.queries` — an alias →
|
|
|
49
43
|
`date_range`, `file`, `json`, `object`, `array`); each param may set `required: false`
|
|
50
44
|
(default is required) and `description`. Nesting is capped at depth 8.
|
|
51
45
|
- **`description`** — one line saying what the query returns, capped at 300 characters. No app
|
|
52
|
-
code reads it
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
the app never sends a raw AST over the wire. This is the exposure model: a public app can read
|
|
61
|
-
exactly what its author's queries project — params fill declared value holes and can never
|
|
62
|
-
widen the query's reach (a token in a `table_id` or field-key position fails deploy validation).
|
|
46
|
+
code reads it: it is what the **agents** that reach this app's data (its declared agents, a
|
|
47
|
+
member's chat while the app is open) read to choose between your aliases.
|
|
48
|
+
- Aliases must be valid JS identifiers (`useQuery("openOrders")` and the generated types depend on it).
|
|
49
|
+
|
|
50
|
+
A deploy never touches the bindings; `get_app_query` reads one back. The **server holds the
|
|
51
|
+
canonical template**; the app never sends a raw AST over the wire, so a public app reads exactly
|
|
52
|
+
what its author's queries project — params fill declared value holes and can never widen the
|
|
53
|
+
query's reach (a token in a `table_id` or field-key position fails validation).
|
|
63
54
|
|
|
64
55
|
### What deploy validates
|
|
65
56
|
|
|
@@ -105,8 +96,6 @@ executes it inside a bounded transaction.
|
|
|
105
96
|
- Archived and draft records are always excluded. Rows hidden by a table's row scope
|
|
106
97
|
(`private_filters`) are excluded on every reach — base scans, link extraction, and
|
|
107
98
|
non-self-scoped traversals alike. (Row scopes don't apply when the app owner is an admin.)
|
|
108
|
-
- **Dev loop**: `lotics app dev` forwards query RPCs to the **deployed** manifest. Editing
|
|
109
|
-
`lotics.queries` locally does nothing until you `lotics app query set`.
|
|
110
99
|
|
|
111
100
|
### Results
|
|
112
101
|
|
|
@@ -115,16 +104,12 @@ A result is `{ rows, columns, source_fields_by_table_id }` (`+ total` for a coun
|
|
|
115
104
|
source_table_id?, source_field_key? }` per column. Rows are plain objects keyed by output
|
|
116
105
|
column name; decode cells with the SDK readers (`row.*`, `readSelect`, `readMembers`,
|
|
117
106
|
`readLinks`, `readFiles` — see [data_fetching.md](./data_fetching.md)). Those output names are also
|
|
118
|
-
the row's TYPE:
|
|
119
|
-
project is a `tsc` error.
|
|
107
|
+
the row's TYPE: the generated `.lotics/app_queries.d.ts` carries them in `AppQueryColumns`, so
|
|
108
|
+
reading a column this query does not project is a `tsc` error.
|
|
120
109
|
|
|
121
110
|
**`source_fields_by_table_id` is always `{}`** — the key is part of the shape, the map is not
|
|
122
|
-
filled. The
|
|
123
|
-
|
|
124
|
-
response's compressed bytes, for a map no reader wanted there. **The complete option sets —
|
|
125
|
-
every option including those no loaded row holds, with colors — are `useFieldOptions`**
|
|
126
|
-
(`/field-options`, which derives them from the same definitions); a cell's own `{ key, label }`
|
|
127
|
-
comes down on the cell. See [members_and_options.md](./members_and_options.md).
|
|
111
|
+
filled. **The complete option sets, with colors, are `useFieldOptions`**; a cell's own
|
|
112
|
+
`{ key, label }` comes down on the cell ([members_and_options.md](./members_and_options.md)).
|
|
128
113
|
|
|
129
114
|
Delivery-layer enrichment (applied to the response, per request):
|
|
130
115
|
|
|
@@ -145,10 +130,9 @@ Delivery-layer enrichment (applied to the response, per request):
|
|
|
145
130
|
extraction** carries as of the target field it reads. The one shape that has none is a select
|
|
146
131
|
reached through a **lookup FIELD**: the column addresses the lookup, whose own type is `lookup`,
|
|
147
132
|
not `select` — so the cell stays bare `opt_*` keys AND `useFieldOptions` omits the column
|
|
148
|
-
entirely, and reading `.label` off it renders the option KEY.
|
|
149
|
-
(`readSelect(cell).map((o) => o.key)`)
|
|
150
|
-
|
|
151
|
-
contains it works.
|
|
133
|
+
entirely, and reading `.label` off it renders the option KEY. Resolve the keys
|
|
134
|
+
(`readSelect(cell).map((o) => o.key)`) against any alias that projects the underlying field
|
|
135
|
+
DIRECTLY.
|
|
152
136
|
- **`number` columns** arrive as JS numbers (Postgres `numeric` strings are coerced; a
|
|
153
137
|
high-precision decimal that would lose digits stays a string). **`date`/`datetime`** columns
|
|
154
138
|
arrive as canonical wall-clock strings (`YYYY-MM-DD` / `YYYY-MM-DDTHH:mm`).
|
|
@@ -163,11 +147,15 @@ declare these; output names starting with `__source_` / `__src_field_`, or equal
|
|
|
163
147
|
drops addressing (grouped results can't be written through or matched by `record_id`); a `join`
|
|
164
148
|
keeps the **left** side's addressing.
|
|
165
149
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
150
|
+
A caller outside the app's organization — a public-app visitor, or a member of another
|
|
151
|
+
organization — receives your output columns and the five addressing columns above, and nothing
|
|
152
|
+
else: no `__src_field_*`, and `columns` carries each column's `name`, `type` and `nullable`
|
|
153
|
+
only. Read what a row holds through your projection and the `read*` helpers, never through
|
|
154
|
+
`__src_field_*`.
|
|
155
|
+
|
|
156
|
+
`__created_at` / `__updated_at` arrive as ISO-8601 **instants** with an offset
|
|
157
|
+
(`"2026-07-05T09:00:00.000Z"`), not a wall-clock — read them with `readCreatedAt(row)` /
|
|
158
|
+
`readUpdatedAt(row)` ([./data_fetching.md](./data_fetching.md)).
|
|
171
159
|
|
|
172
160
|
---
|
|
173
161
|
|
|
@@ -217,17 +205,12 @@ indexes live (§10). `sort` entries are `{ field_key, order: "asc"|"desc", blank
|
|
|
217
205
|
cells with storage keys — to the client (over-exposure + the presign ceiling at scale).
|
|
218
206
|
- **A files column takes `limit` — bound it when the surface renders a THUMBNAIL, not the
|
|
219
207
|
collection.** `{ "type": "files", "output": "photo", "source": "fld_…", "limit": 1 }` returns
|
|
220
|
-
the first entry per cell, in stored order, cut in SQL so the rest is never read or signed
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
column — or on a computed source, which has no cell to bound — is rejected at deploy rather
|
|
227
|
-
than ignored. **On a UNION, set it on every arm.**
|
|
228
|
-
Arms align by column name and type, and `limit` is neither, so an arm that omits it still
|
|
229
|
-
yields whole cells — the query stays correct and silently costs what the bound was there to
|
|
230
|
-
avoid.
|
|
208
|
+
the first entry per cell, in stored order, cut in SQL so the rest is never read or signed —
|
|
209
|
+
without it the response pays to resolve every entry toward the presign ceiling (§10). The bound
|
|
210
|
+
is per CELL, caps at `FILES_PROJECTION_MAX_LIMIT` (10), and is a different axis from the
|
|
211
|
+
query's own `limit`, which counts ROWS; `limit` on any non-`files` column or computed source is
|
|
212
|
+
rejected at deploy. **On a UNION, set it on every arm** — arms align by name and type, not by
|
|
213
|
+
`limit`, so an arm that omits it still yields whole cells.
|
|
231
214
|
|
|
232
215
|
### `filter` — predicate over derived columns
|
|
233
216
|
|
|
@@ -377,11 +360,12 @@ A `QuerySource` appears in `project.columns[].source`, `unpivot.passthrough[].so
|
|
|
377
360
|
| `"field_key"` (bare string) | Passthrough of an input column | the column's type |
|
|
378
361
|
| `{ "literal": v }` | Constant (`string \| number \| boolean \| null`) | inferred (`null` takes the column's declared type) |
|
|
379
362
|
| `{ "eq": [a, b] }` / `{ "neq": [a, b] }` | Null-safe equality (`IS [NOT] DISTINCT FROM` — `null eq null` is `true`) | boolean |
|
|
380
|
-
| `{ "isEmpty": s }` / `{ "isNotEmpty": s }` | Emptiness test — over an array-valued column: empty at NULL / JSON null / `[]`;
|
|
363
|
+
| `{ "isEmpty": s }` / `{ "isNotEmpty": s }` | Emptiness test — over an array-valued source (a column or a `link` read of type `select`, `select_member`, `select_record_link` or `files`): empty at NULL / JSON null / `[]`; over anything else: empty at NULL or blank text | boolean |
|
|
381
364
|
| `{ "coalesce": [s, …] }` | First non-null | declared |
|
|
382
365
|
| `{ "concat": [s, …] }` | Text concatenation (NULLs render empty) | text |
|
|
383
366
|
| `{ "record_id": true }` | The row's **own** record id — requires a direct `from_table` parent; always text, non-null, never writable | text |
|
|
384
|
-
| `{ "link": { "source": "customer", "field": "Company name" } }` | A field on the **first** linked record of a `select_record_link` field
|
|
367
|
+
| `{ "link": { "source": "customer", "field": "Company name" } }` | A field on the **first** linked record of a `select_record_link` field | the target field's type |
|
|
368
|
+
| `{ "link_agg": { "source": "orders", "field": "Amount", "operation": "sum" } }` | `sum` / `avg` / `min` / `max` / `count` / `string_agg` over **every** linked record, one value per row. `count` counts the linked records, whether or not `field` holds a value; with no linked record it is `0` and the rest are NULL | declared |
|
|
385
369
|
| `{ "expression": "…" }` | jexpr escape hatch (below) | declared |
|
|
386
370
|
|
|
387
371
|
**Every variant in that table except the bare field key and `record_id` needs an explicit
|
|
@@ -401,14 +385,23 @@ a scoped-out or stale link extracts as NULL.
|
|
|
401
385
|
**Warning:** only the **first** link in the cell is followed. Multi-link analytics go through
|
|
402
386
|
`unnest` + join (§8). And because `field` resolves by name first, renaming a field on the
|
|
403
387
|
target table can silently re-point (or break) the extraction — prefer field **keys** when the
|
|
404
|
-
target's names are volatile.
|
|
405
|
-
|
|
388
|
+
target's names are volatile.
|
|
389
|
+
|
|
390
|
+
**Cost.** Every `link` column over one `source` shares one read of the linked record per row, and
|
|
391
|
+
every `link_agg` column over one `source` shares one aggregate, so ten columns through `customer` cost
|
|
392
|
+
about what one does. A source whose columns nothing above the `project` reads costs nothing: a `count`
|
|
393
|
+
over the projection skips it. When a `limit` sits over the `project` — through any `sort`s and a
|
|
394
|
+
`filter` between — a source no filter condition, sort key or seek key reads is read only for the rows
|
|
395
|
+
the page keeps. A source they do read is read for every row before the sort, so sorting a large table
|
|
396
|
+
on a link column costs a linked-record read per row. An `unpivot` reads its sources for every row.
|
|
406
397
|
|
|
407
398
|
An extracted column **is** addressed to the field it reads on the target table, so the delivery
|
|
408
399
|
layer treats it like any other field-backed column: an extracted `select` enriches to
|
|
409
400
|
`{key,label}` and appears in `useFieldOptions`, and an extracted date resolves relative-date
|
|
410
|
-
filters in the source field's timezone. It is still never **writable** — a
|
|
411
|
-
not a write path, and `writable_target` rejects it.
|
|
401
|
+
filters in the source field's timezone. It is still never **writable** — a read through a link is
|
|
402
|
+
not a write path, and `writable_target` rejects it. `link_agg` has the same parent and `source` rules,
|
|
403
|
+
carries no field addressing (an aggregate of many cells is no one field's value), and its `operation`
|
|
404
|
+
must be valid over `field`'s type — a refusal lists the valid ones.
|
|
412
405
|
|
|
413
406
|
### The expression escape hatch
|
|
414
407
|
|
|
@@ -468,12 +461,12 @@ derived surfaces share one implementation.
|
|
|
468
461
|
|
|
469
462
|
| Field type | Column type | Source-layer operators | Derived-layer differences | Sort / group / aggregate notes |
|
|
470
463
|
| --- | --- | --- | --- | --- |
|
|
471
|
-
| 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). The LIKE family (`contains`, `does_not_contain`, `starts_with`, `ends_with`) additionally folds **diacritics**, so `contains "ha noi"` matches `"Hà Nội"`; the identity operators (`equals`, `not_equals`, `is_any_of`, `is_none_of`) do **not** — they compare a stored value, and `mã` is not `ma`. |
|
|
472
|
-
| 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). |
|
|
473
|
-
| 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. |
|
|
464
|
+
| text | `text` | `equals`, `not_equals`, `contains`, `does_not_contain`, `starts_with`, `ends_with`, `is_empty`, `is_not_empty`, `is_any_of`, `is_none_of`, `contains_any_of` | same set | Sort is lexicographic in the database's default collation — no locale control. Text comparisons normalize both sides (trim + case-insensitive). The LIKE family (`contains`, `does_not_contain`, `starts_with`, `ends_with`, and `contains_any_of`, whose value is a list and which holds when ANY listed word is in the cell) additionally folds **diacritics**, so `contains "ha noi"` matches `"Hà Nội"`; the identity operators (`equals`, `not_equals`, `is_any_of`, `is_none_of`) do **not** — they compare a stored value, and `mã` is not `ma`. |
|
|
465
|
+
| number | `number` | `equals`, `not_equals`, `greater_than`, `less_than`, `greater_than_or_equal_to`, `less_than_or_equal_to`, `is_empty`, `is_not_empty` | same set — except a figure read in each row's own unit or currency (`unit_field` / `currency_field`, or a rollup totalling in its row's), which a comparison matches only at the source layer: a condition on a column passing it straight through moves there, anywhere else it is refused | Full numeric aggregate set (§8). A comparison on a figure read per row states `unit_option`, the key of the option of that select its value is in — `{ "field_key": "amount", "operator": "greater_than", "value": 100, "unit_option": "opt_usd" }` — and matches only rows holding that option; rows in a measured unit of the same dimension compare converted (`≥ 1` in `t` matches `1500` in `kg`), a counted unit or a currency never. One stating none is refused. |
|
|
466
|
+
| date / datetime | `date` / `datetime` (datetime when the field's format includes time) | `on`, `before`, `after`, `on_or_before`, `on_or_after`, `between`, `time_of_day`, `is_empty`, `is_not_empty` — values are `DateTimePoint`s (below) | same set | Day-level filters on datetime values expand to the full-day window; a `date` column compares on the day alone, so a time in the filter value is ignored. A reduced-precision value (`"2026-05"`) is its period start, and a value with no time is at `00:00` for `time_of_day`. Sortable; bucketable in `group.by` (§8); `earliest`/`latest`/`min`/`max`/`date_range` aggregate. |
|
|
474
467
|
| boolean | `boolean` | `equals` (value `true`/`false`; `false` matches NULL/missing) | same | `checked`/`unchecked`/`percent_*` aggregate (`unchecked` counts false **or** empty). |
|
|
475
|
-
| select (single & multi) | `select` | `has_any_of`, `has_none_of`, `has_all_of`, `is_empty`, `is_not_empty` — values are **option keys** (`opt_*`), never labels | the **runtime** `filter` (§9) also takes option **names**, resolving each to its key — an unknown one is refused rather than matching nothing, and the value stays an array either way; a select column the query DERIVES instead of projecting from a field carries no option set, so addressing one there is refused | Cells are arrays even for single-selects. Sorting a select column
|
|
476
|
-
| 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). |
|
|
468
|
+
| select (single & multi) | `select` | `has_any_of`, `has_none_of`, `has_all_of`, `is_empty`, `is_not_empty` — values are **option keys** (`opt_*`), never labels | the **runtime** `filter` (§9) also takes option **names**, resolving each to its key — an unknown one is refused rather than matching nothing, and the value stays an array either way; a select column the query DERIVES instead of projecting from a field carries no option set, so addressing one there is refused | Cells are arrays even for single-selects. Sorting a select column orders by its options' order (a multi-select by its first option), an empty cell last; a select the query derives carries no options and orders by raw JSON. `unnest` fans option keys. |
|
|
469
|
+
| select_member | `select_member` | select ops + `is_current_member`, `is_not_current_member` (field-scoped, no value; `is_not_current_member` matches every cell not holding the requester, an empty one included) | same — `is_current_member` **works at the runtime layer** too | `unnest` fans member ids. Anonymous public request: current-member binds the app owner (§1). |
|
|
477
470
|
| 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). |
|
|
478
471
|
| 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. |
|
|
479
472
|
| 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). |
|
|
@@ -490,14 +483,11 @@ derived surfaces share one implementation.
|
|
|
490
483
|
| `current_member` | `in_any_group` (value: group ids — gates by the *viewer's* group membership) | source layer only |
|
|
491
484
|
| `record_id` | `is_any_of`, `is_none_of` (value: record ids) | source, derived, **and** runtime layers; rejected over a `group` |
|
|
492
485
|
|
|
493
|
-
**
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
`has_any_of: []`) match **nothing** at the derived layer, but are a silent no-op (no
|
|
499
|
-
constraint — like a null value) at the source layer; negative ones (`is_none_of: []`,
|
|
500
|
-
`has_none_of: []`) constrain nothing at either layer.
|
|
486
|
+
**A value that states nothing constrains nothing.** A list operator with an **empty list**
|
|
487
|
+
(`is_any_of: []`, `contains_any_of: []`, `has_any_of: []`, `is_none_of: []`, `has_none_of: []`) contributes no
|
|
488
|
+
constraint at either layer, and so does a source-layer condition whose value is `null`/absent.
|
|
489
|
+
The date-*range* operators (`overlaps`, `within`, `starts_before`, `duration_*`) are refused on
|
|
490
|
+
the query path at both layers.
|
|
501
491
|
|
|
502
492
|
### DateTimePoint
|
|
503
493
|
|
|
@@ -638,8 +628,8 @@ It AND-s with `filter` (search *within* a scope) and is templatable
|
|
|
638
628
|
(`"search": "{{params.q}}"`). An empty or whitespace-only term matches everything; an
|
|
639
629
|
unresolved optional token prunes to match-all (§6).
|
|
640
630
|
|
|
641
|
-
**Search-as-you-type over a LARGE table uses `search`, never a `contains` OR-group.** Per-field
|
|
642
|
-
`contains` is unindexed — a zero-match keystroke forces a full-
|
|
631
|
+
**Search-as-you-type over a LARGE table uses `search`, never a `contains` OR-group alone.** Per-field
|
|
632
|
+
`contains` is unindexed — a zero-match keystroke forces a full-table scan that hangs the
|
|
643
633
|
picker. (It is not accent-sensitive; the LIKE family folds diacritics, §5.) Gate the fetch on a
|
|
644
634
|
non-empty term client-side (`enabled`), or first paint dumps the table.
|
|
645
635
|
|
|
@@ -650,7 +640,11 @@ into. A box captioned "find a person" then matches rows that merely share a stag
|
|
|
650
640
|
owner, and folding makes near-homographs collide (`tiến` ≡ `tiền`). When the box means *find
|
|
651
641
|
this person*, bound it: an OR-group of `contains` over the two or three identity fields (name,
|
|
652
642
|
national id, phone), with the term param `required: false` so an empty box prunes the group to
|
|
653
|
-
match-all
|
|
643
|
+
match-all, and put the same term in `search` beside it: the search document holds every field the
|
|
644
|
+
OR-group reads, folded alike for diacritics and case, so the pair returns the OR-group's rows, found
|
|
645
|
+
through the trigram index rather than a scan. (A term whose letters `contains` folds further — `ß`,
|
|
646
|
+
`ł`, curly quotes — can miss a row.) Use `search` alone when the box genuinely means *find this
|
|
647
|
+
anywhere in the record*.
|
|
654
648
|
|
|
655
649
|
---
|
|
656
650
|
|
|
@@ -690,9 +684,8 @@ sizes on a shipment, the tags on a ticket) without a second query.
|
|
|
690
684
|
- **`distinct`** defaults to **true**: three containers sized 40HC/40HC/20DC give `20DC, 40HC`.
|
|
691
685
|
Pass `false` to keep every occurrence. Values are always sorted, so the column doesn't
|
|
692
686
|
reshuffle between reads.
|
|
693
|
-
- **`max_values`** (default 20, max 100) caps the emitted values
|
|
694
|
-
|
|
695
|
-
column to render an honest `+N more`.
|
|
687
|
+
- **`max_values`** (default 20, max 100) caps the emitted values silently — pair it with a
|
|
688
|
+
`unique` aggregate over the same column to render an honest `+N more`.
|
|
696
689
|
- **`separator`** defaults to `", "` (max 8 chars).
|
|
697
690
|
- Empty values are dropped by the same emptiness contract below, so a partly-blank column
|
|
698
691
|
doesn't emit empty slots. A group with nothing present yields NULL.
|
|
@@ -700,15 +693,17 @@ sizes on a shipment, the tags on a ticket) without a second query.
|
|
|
700
693
|
are mutually exclusive in SQL. Setting `distinct` / `separator` / `max_values` on any other
|
|
701
694
|
operation is rejected rather than ignored.
|
|
702
695
|
|
|
703
|
-
It is
|
|
704
|
-
|
|
705
|
-
cell. Query-time only.
|
|
696
|
+
It is query-time only, never a rollup field type: an unbounded concatenation does not belong in a
|
|
697
|
+
stored cell.
|
|
706
698
|
|
|
707
699
|
**The one emptiness contract.** `filled`/`empty`/`unique`/`percent_*` use the same definition
|
|
708
700
|
of "present" as the filter layer's `is_empty` and the `isEmpty` source: array-valued cells are
|
|
709
701
|
empty at NULL / JSON `null` / `[]`; text at NULL or blank (whitespace-only); opaque json at
|
|
710
702
|
NULL / JSON `null`; other scalars at SQL NULL. A cleared multi-select or a whitespace-only text
|
|
711
|
-
cell counts **empty** everywhere — filters and aggregates never disagree.
|
|
703
|
+
cell counts **empty** everywhere — filters and aggregates never disagree. A cleared cell stored
|
|
704
|
+
as JSON `null` reads as NULL wherever a query reads it: a `group` key puts it in one empty group
|
|
705
|
+
with the cells that lack the key, `from_table.sort` places it by `blank_position`, and `isEmpty`
|
|
706
|
+
over a `link` read of it is `true`.
|
|
712
707
|
|
|
713
708
|
### Date-bucket group keys
|
|
714
709
|
|
|
@@ -738,17 +733,14 @@ Sort by the bucket column for a time series; filter it with date operators for a
|
|
|
738
733
|
window. **Always bucket server-side** — shipping raw rows to bucket in JS burns the row cap and
|
|
739
734
|
the timeout for nothing.
|
|
740
735
|
|
|
741
|
-
|
|
742
|
-
**`functions`** (ranking / navigation). At least one must be non-empty; a node may carry both.
|
|
743
|
-
|
|
744
|
-
**`aggregates` — the OVER-legal aggregate subset.** Only operations that compile to a single
|
|
736
|
+
**Window `aggregates` — the OVER-legal aggregate subset.** Only operations that compile to a single
|
|
745
737
|
legal SQL window call are accepted: **`count`, `sum`, `avg`, `min`, `max`, `earliest`, `latest`,
|
|
746
738
|
`filled`, `checked`, `unchecked`.** The rest cannot take an OVER clause (`median` is an
|
|
747
739
|
ordered-set aggregate; `unique`/`percent_unique`/`string_agg` need DISTINCT;
|
|
748
740
|
`range`/`empty`/`date_range`/`percent_*` compose multiple calls) — rejected at deploy. The optional `frame` applies **only**
|
|
749
741
|
to these; a `frame` on a window with no `aggregates` is rejected as dead config.
|
|
750
742
|
|
|
751
|
-
|
|
743
|
+
**Window `functions` — ranking / navigation.** Each is `{ "output", "fn", … }` (its own arg shape, no
|
|
752
744
|
`operation`/`type` — the output type is fixed or inherited). They never take a `frame` and
|
|
753
745
|
**require a non-empty `order_by`** (ranking without an order is nondeterministic):
|
|
754
746
|
|
|
@@ -807,24 +799,34 @@ template as derived nodes, in this order: `filter` (narrow) → `sort` (order)
|
|
|
807
799
|
layer; `record_id` allowed). A `field_key` that isn't a projected output column → 400. This
|
|
808
800
|
is the exposure invariant: a caller can refine what the query already exposes, never widen
|
|
809
801
|
it. Record-link membership (`has_any_of` by id) **works** here; so does `is_current_member`
|
|
810
|
-
on a member column.
|
|
802
|
+
on a member column. Words (`contains`, `starts_with`, …) on an option or a member column — its
|
|
803
|
+
own, or a lookup of one — match the option's or the member's NAME, and on a text lookup match
|
|
804
|
+
the words its column carries, all folded for case and marks like text — so one search over
|
|
805
|
+
text, options, people and what a row looks up finds what the reader reads. A number, a date or
|
|
806
|
+
a rollup has no words; narrow it by range. Traversals / `locked` / `current_member` do not — bake those into the
|
|
811
807
|
template.
|
|
812
808
|
- **`sort`** — `[{ field_key, order, blank_position? }]` over output columns — the typed
|
|
813
809
|
`QuerySortKey` the hooks accept. `blank_position` is `"top" | "bottom"`, defaulting to
|
|
814
|
-
`"bottom"
|
|
815
|
-
a pipeline's per-step date stamps
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
cannot be expressed at all.
|
|
819
|
-
- **`limit` / `offset` / keyset `cursor`** — pagination. `useQuery` / `usePaginatedQuery` page by
|
|
820
|
-
`offset` (`limit` clamped to the 10,000-row cap, §10); `useInfiniteQuery` opts into **keyset
|
|
810
|
+
`"bottom"` — the only way to express the ascending reading of a **multi-key** sort over
|
|
811
|
+
columns whose blankness IS the ranking (a pipeline's per-step date stamps).
|
|
812
|
+
- **`limit` / `offset` / keyset `cursor`** — pagination. `useQuery` with `limit` or `page` reads by
|
|
813
|
+
`offset` (`limit` clamped to the 10,000-row cap, §10); `useQuery` with `more` opts into **keyset
|
|
821
814
|
(seek)** by sending `keyset: true` + the prior page's `cursor`, and the server returns the next
|
|
822
815
|
`next_cursor` (null on the last page). Seek stays O(page) and never skips/duplicates a row as the
|
|
823
816
|
set shifts, falling back to offset for a multi-key sort it can't seek.
|
|
824
|
-
- **`count: true`** — returns `{ total }` only: a
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
817
|
+
- **`count: true`** — returns `{ total }` only: a COUNT over the *filtered* set, ignoring
|
|
818
|
+
sort/limit/offset. Drives "Page 1 of N". With **`count_by: "<column>"`** the same scan also
|
|
819
|
+
returns `counts` — rows per value of that projected column (a multi-value row under each value,
|
|
820
|
+
a row holding none in `total` only); a column whose values are not plain (a link) → 400. It is a
|
|
821
|
+
**second full execution** of the template — sent only where a read asks for its `total`, and
|
|
822
|
+
one count serves every read of the same set ([data_fetching](./data_fetching.md)).
|
|
823
|
+
- **`aggregate: { by?, day?, sum? }`** — the groups of the *filtered* set in place of its rows: one
|
|
824
|
+
row per value of up to two `by` output columns (a cell holding several values groups by all of
|
|
825
|
+
them) and per day of a `day` output date, each holding its `by` columns, `__day`, `__count`, and
|
|
826
|
+
`__sum` of a `sum` output number (`null` where no row holds one). Every column it names is an
|
|
827
|
+
output column, like `filter`'s, so what leaves is counts and sums of what a page of the same
|
|
828
|
+
query returns. Sent with `params` and `filter` alone; `truncated` past the row cap. One read
|
|
829
|
+
over a list's rows serves the figures above it — its filter is the list's own.
|
|
828
830
|
|
|
829
831
|
**A terminal aggregate cannot be refined — plan the total accordingly.** Because refinement wraps
|
|
830
832
|
*around* the template over its **output** columns, a query that has already collapsed (a `group`
|
|
@@ -836,21 +838,12 @@ runtime facet (a filter TREE is not a scalar param), and leaving the total unfil
|
|
|
836
838
|
that follows the facet next to a sum that does not. **Project the summed column plus the columns
|
|
837
839
|
the facet can name, fetch under the same runtime `filter`, and total in the browser** — correct and
|
|
838
840
|
facet-aware, but it ships every matching row, so it holds only where the scope is already small
|
|
839
|
-
(one member's own records, a single project). A workspace-wide total that
|
|
840
|
-
|
|
841
|
+
(one member's own records, a single project). A workspace-wide total that follows a runtime facet
|
|
842
|
+
has no server-side shape.
|
|
841
843
|
|
|
842
|
-
The
|
|
843
|
-
[data_fetching](./data_fetching.md)
|
|
844
|
-
|
|
845
|
-
Build `filter` from UI column-filters with `columnFilterToConditions` (`@lotics/ui`); prefer
|
|
846
|
-
`useFieldOptions` for a select filter's option set.
|
|
847
|
-
|
|
848
|
-
**Warning (transport gaps):** the embedded product host and the `lotics app dev` forwarder pass
|
|
849
|
-
`sort`/`filter`/`count` through. The **standalone public transport** (the app's own origin)
|
|
850
|
-
forwards only `alias`/`params`/`limit`/`offset` — runtime `sort`/`filter` are silently ignored
|
|
851
|
-
there and a `count` never resolves ([data_fetching](./data_fetching.md) → Standalone transport).
|
|
852
|
-
A standalone app must bake ordering/scoping into the template (or params) rather than rely on
|
|
853
|
-
runtime refinement.
|
|
844
|
+
**The standalone public transport drops runtime `sort`/`filter`/`count`**
|
|
845
|
+
([data_fetching](./data_fetching.md#standalone-public-transport)), so a standalone app bakes
|
|
846
|
+
ordering and scoping into the template or its params.
|
|
854
847
|
|
|
855
848
|
---
|
|
856
849
|
|
|
@@ -872,8 +865,8 @@ may embed SQL — is server-logged only). Deploy-time and validation errors are
|
|
|
872
865
|
|
|
873
866
|
### Index reality, in plain terms
|
|
874
867
|
|
|
875
|
-
Records
|
|
876
|
-
slice, and within
|
|
868
|
+
Records are stored partitioned by workspace, so a query reads only its own workspace's slice; a
|
|
869
|
+
`from_table` narrows that slice to the table's rows, and within them:
|
|
877
870
|
|
|
878
871
|
- **GIN-served (fast at any size):** *positive* exact-membership filters at the **source
|
|
879
872
|
layer** — `select`/`select_member`/`select_record_link` `has_any_of` / `has_all_of`, and
|
|
@@ -882,12 +875,11 @@ slice, and within it:
|
|
|
882
875
|
- **B-tree-served (automatic for deployed queries):** text `equals`, number and date
|
|
883
876
|
comparisons (exact and range), and `sort` fields — for fields referenced in a **deployed
|
|
884
877
|
named query's template**. The platform provisions a partial expression index per referenced
|
|
885
|
-
field automatically: built online whenever a query is
|
|
886
|
-
draft's publish — a deploy does not write queries), re-synced daily,
|
|
878
|
+
field automatically: built online whenever a query is bound (`set_app_query` or `apply_model`), re-synced daily,
|
|
887
879
|
capped at 8 per table (fields past the cap fall back to the scan tier, with a server WARN).
|
|
888
880
|
A `{{params.…}}` value hole doesn't change this — the field key is static in the template,
|
|
889
881
|
so it still gets its index. Index-seek speed at any table size once provisioned.
|
|
890
|
-
- **
|
|
882
|
+
- **Table scan (linear in table size):** everything else — text `contains`, negations
|
|
891
883
|
(`has_none_of`, `is_none_of`, `not_*`), emptiness, files predicates, and predicates/sorts on
|
|
892
884
|
fields that appear **only** in the runtime `filter`/`sort` options rather than the deployed
|
|
893
885
|
template. Fine on thousands of rows; on very large tables these dominate latency and are the
|
|
@@ -910,35 +902,29 @@ per-table queries you merge client-side; the merge already happens in the index.
|
|
|
910
902
|
sort key to be a bare field projection — a computed/literal/type-overridden sort column falls back
|
|
911
903
|
to the whole-union sort.)
|
|
912
904
|
|
|
913
|
-
**An aggregate arm you cannot filter costs its whole table, every execution.**
|
|
914
|
-
is
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
the source), or drop the arm entirely if the value it fetches now lives on the row already.
|
|
922
|
-
|
|
923
|
-
**Measure before you change a shape, and after.** Latency is observable per alias: each `useQuery`
|
|
924
|
-
goes out as its own RPC, so a screen's cold load attributes a duration to every query by name in
|
|
925
|
-
the browser's network panel. Time it, fix the one that dominates, time it again. Reasoning
|
|
926
|
-
alone mis-ranks these — the union above looks expensive and is fast, the join here looks ordinary
|
|
927
|
-
and is not — and a playbook rule applied to the wrong query costs effort while proving nothing.
|
|
905
|
+
**An aggregate arm you cannot filter costs its whole table, every execution.** A `join` whose right
|
|
906
|
+
side is a `group` over entire tables, keyed on a value the LEFT side supplies at run time, has no
|
|
907
|
+
static predicate to push down — a four-table arm totalling ~14k rows is **seconds, not
|
|
908
|
+
milliseconds**, on every execution. Prefer a materialized **lookup/rollup field** on the row.
|
|
909
|
+
|
|
910
|
+
**Measure before you change a shape, and after.** Each `useQuery` goes out as its own RPC, so the
|
|
911
|
+
browser's network panel times every query by name. Reasoning alone mis-ranks these — the union
|
|
912
|
+
above looks expensive and is fast, the join here looks ordinary and is not.
|
|
928
913
|
|
|
929
914
|
### The authoring rules
|
|
930
915
|
|
|
931
916
|
1. **Filter at the source.** Push every static predicate into `from_table.filter`.
|
|
932
917
|
2. **Match the operator to what the box MEANS.** *Find this anywhere in the record* → `search`.
|
|
933
|
-
*Find this person/order* → a `contains` OR-group over the identity fields
|
|
934
|
-
|
|
918
|
+
*Find this person/order* → a `contains` OR-group over the identity fields, with the same term in
|
|
919
|
+
`search` beside it (§7) — `search` alone surfaces rows that only share an owner or a status,
|
|
920
|
+
and the OR-group alone scans.
|
|
935
921
|
3. **Aggregate and bucket server-side.** A dashboard reads grouped rows, never raw rows it
|
|
936
922
|
reduces in JS — raw-row shipping burns the 10k cap, the timeout, and bandwidth at once.
|
|
937
923
|
4. **Project narrow.** Every un-rendered column is wasted bytes; every un-rendered `files`
|
|
938
924
|
column risks the presign ceiling and over-exposes storage metadata.
|
|
939
925
|
5. **Files columns only where rendered.** A list view projects no files; the detail query does.
|
|
940
926
|
6. **Avoid deep offsets.** Filter first so the browsable set is small; sort by a stable key.
|
|
941
|
-
7. **Don't bake a `limit` into a browsable query** — it caps the total; let `
|
|
927
|
+
7. **Don't bake a `limit` into a browsable query** — it caps the total; let `page` drive.
|
|
942
928
|
8. **Parameterize lookups.** A code/id lookup is a `{{params.x}}` filter (or a `record_id`
|
|
943
929
|
runtime filter) returning one row — never load-all-then-find in JS.
|
|
944
930
|
9. **Expect and handle the three runtime outcomes**: a 400 with a message (surface it), the
|
|
@@ -951,24 +937,12 @@ and is not — and a playbook rule applied to the wrong query costs effort while
|
|
|
951
937
|
`count` and `sum` fold exactly. `unique` does NOT fold across groups (the same value can
|
|
952
938
|
appear in many groups), so a rendered distinct-count needs its own query — **unless there
|
|
953
939
|
are no group keys**, where `by: []` carries sums, counts and distinct counts together in one
|
|
954
|
-
scan.
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
11. **A paginated table's count is a second full execution.** `usePaginatedQuery` issues
|
|
961
|
-
`count: true` alongside the page, and a count re-runs the whole query — it costs what the
|
|
962
|
-
query costs and grows with the filtered set, so on a union/unpivot pipeline it is a whole
|
|
963
|
-
extra scan, not a cheap lookup. When the screen already renders that figure from an
|
|
964
|
-
aggregate, hand it over as the hook's `total` instead of buying it twice
|
|
965
|
-
([data_fetching](./data_fetching.md) → `usePaginatedQuery`).
|
|
966
|
-
12. **Re-derive a query when its tables change shape.** A `join` or `union` that exists to bridge
|
|
967
|
-
two tables becomes pure cost the moment those tables become one — and nothing fails, because
|
|
968
|
-
it keeps returning the right answer at the old price. Migrations that merge, move or back-fill
|
|
969
|
-
a table are exactly when this happens, and exactly when nobody re-reads the queries. After
|
|
970
|
-
one, open every query over the affected tables and ask what it would look like written today,
|
|
971
|
-
not what it needs to keep working.
|
|
940
|
+
scan. **Group keys are not free scaffolding** — a `select`/jsonb key especially — so keys
|
|
941
|
+
kept for a facet the screen no longer renders cost a real multiple of the ungrouped aggregate.
|
|
942
|
+
11. **Re-derive a query when its tables change shape.** A `join` or `union` that bridged two
|
|
943
|
+
tables becomes pure cost once they are one, and keeps returning the right answer at the old
|
|
944
|
+
price — so after a migration that merges, moves or back-fills a table, re-read every query
|
|
945
|
+
over it.
|
|
972
946
|
|
|
973
947
|
---
|
|
974
948
|
|
|
@@ -1066,40 +1040,3 @@ A lookup projection (and unnesting the lookup column) yields only the **first**
|
|
|
1066
1040
|
value (§5). To read the looked-up field across *all* links: unnest the **link column**, join to
|
|
1067
1041
|
the target on `record_id`, and project the field from the target side — the link-identity join
|
|
1068
1042
|
above, with `region` replaced by whatever the lookup pointed at.
|
|
1069
|
-
|
|
1070
|
-
---
|
|
1071
|
-
|
|
1072
|
-
## 12. Current limitations
|
|
1073
|
-
|
|
1074
|
-
- **No pivot/crosstab node.** Row-values-to-columns happens client-side over grouped results
|
|
1075
|
-
(§8).
|
|
1076
|
-
- **No `first_value`/`last_value`/`nth_value` navigation** — the `functions` set is `row_number`,
|
|
1077
|
-
`rank`, `dense_rank`, `percent_rank`, `cume_dist`, `ntile`, `lag`, `lead` (§8). Frame aggregates
|
|
1078
|
-
remain the 10-item OVER-legal subset.
|
|
1079
|
-
- **`useQuery` / numbered pagination is offset.** Deep pages cost the full skipped prefix and can
|
|
1080
|
-
shift under concurrent writes; `useInfiniteQuery` uses keyset (seek), which stays O(page) and
|
|
1081
|
-
never skips or duplicates rows (§9).
|
|
1082
|
-
- **No collation control.** Text ORDER BY uses the database default collation —
|
|
1083
|
-
locale-specific alphabetical order (e.g. accented-letter ordering) is not configurable.
|
|
1084
|
-
- **Select / member / link columns sort by raw JSON** in queries — not by configured option
|
|
1085
|
-
order or display text. Sort a projected text form instead.
|
|
1086
|
-
- **Lookup projection returns the first element**; unnesting a lookup column fans only the
|
|
1087
|
-
first linked record's value. All-values access goes through unnest-the-link + join (§11).
|
|
1088
|
-
- **Link extraction follows the first link only**, resolves the target field by display name
|
|
1089
|
-
before key, and costs a correlated subquery per row (§3).
|
|
1090
|
-
- **Join is single-column equality** with SQL-category-matched types; addressing follows the
|
|
1091
|
-
left side (§2).
|
|
1092
|
-
- **Text / number / date range predicates are unindexed within the partition** — the usual
|
|
1093
|
-
cause of slow queries and timeouts on large tables; positive membership filters and `search`
|
|
1094
|
-
are the indexed paths (§10).
|
|
1095
|
-
- **Traversals, `locked`, and `current_member` conditions are source-layer only** — not
|
|
1096
|
-
available in derived or runtime filters (§6).
|
|
1097
|
-
- **The standalone public transport drops runtime `sort`/`filter`/`count`** — refinement is
|
|
1098
|
-
silently ignored and paginated totals never resolve there; bake ordering/scoping into the
|
|
1099
|
-
template for standalone apps (§9).
|
|
1100
|
-
- **Unsupported operators at the source layer are silent no-ops** (notably the date-range
|
|
1101
|
-
operator family) — the condition contributes no constraint rather than erroring (§5).
|
|
1102
|
-
- **The expression allowlist is small and flat** — no indexing, nested access, or functions
|
|
1103
|
-
beyond the §3 list; compute anything richer client-side from projected columns.
|
|
1104
|
-
- **`from_table` is the only leaf** — a query reads tables; there is no way to query a view,
|
|
1105
|
-
another query, or an external source.
|