@lotics/app-sdk 0.100.1 → 0.101.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.
Files changed (108) hide show
  1. package/AGENTS.md +32 -47
  2. package/dist/agent_stream.d.ts +131 -0
  3. package/dist/ask_ai.d.ts +27 -0
  4. package/dist/attachments.d.ts +58 -0
  5. package/dist/chunk-ARV5FAU5.js +1132 -0
  6. package/dist/comments.d.ts +89 -0
  7. package/dist/error_report.d.ts +9 -0
  8. package/dist/folder_pick.d.ts +8 -0
  9. package/dist/geolocation.d.ts +42 -0
  10. package/dist/hooks.d.ts +251 -0
  11. package/dist/{src/index.d.ts → index.d.ts} +13 -22
  12. package/dist/index.js +31309 -0
  13. package/dist/index.js.LEGAL.txt +11 -0
  14. package/dist/members.d.ts +32 -0
  15. package/dist/mock.d.ts +37 -0
  16. package/dist/mount.d.ts +19 -0
  17. package/dist/new_record.d.ts +37 -0
  18. package/dist/open_app.d.ts +12 -0
  19. package/dist/open_external.d.ts +10 -0
  20. package/dist/overlay.d.ts +25 -0
  21. package/dist/queries.d.ts +231 -0
  22. package/dist/recording.d.ts +47 -0
  23. package/dist/recording_state.d.ts +43 -0
  24. package/dist/rename_file.d.ts +13 -0
  25. package/dist/router.d.ts +10 -0
  26. package/dist/router.js +97 -0
  27. package/dist/row.d.ts +87 -0
  28. package/dist/rpc.d.ts +114 -0
  29. package/dist/select.d.ts +24 -0
  30. package/dist/shared_types.d.ts +8 -0
  31. package/dist/store.d.ts +43 -0
  32. package/dist/types.d.ts +36 -0
  33. package/dist/upload/optimize.d.ts +30 -0
  34. package/dist/upload/pipeline.d.ts +36 -0
  35. package/dist/upload/transport.d.ts +19 -0
  36. package/dist/url_params.d.ts +55 -0
  37. package/dist/use_recents.d.ts +15 -0
  38. package/dist/{src/use_url_state.d.ts → use_url_state.d.ts} +0 -2
  39. package/dist/viewer.d.ts +41 -0
  40. package/dist/written.d.ts +77 -0
  41. package/docs/ai.md +74 -133
  42. package/docs/data_fetching.md +209 -290
  43. package/docs/files.md +61 -51
  44. package/docs/members_and_options.md +92 -62
  45. package/docs/mutations.md +135 -205
  46. package/docs/navigation_and_state.md +26 -35
  47. package/docs/queries.md +144 -207
  48. package/docs/recipes.md +21 -45
  49. package/docs/runtime.md +74 -137
  50. package/docs/security.md +8 -11
  51. package/docs/workflows.md +189 -174
  52. package/package.json +27 -28
  53. package/dist/src/agent_stream.d.ts +0 -200
  54. package/dist/src/agent_stream.js +0 -314
  55. package/dist/src/ask_ai.d.ts +0 -40
  56. package/dist/src/ask_ai.js +0 -35
  57. package/dist/src/attachments.d.ts +0 -68
  58. package/dist/src/attachments.js +0 -93
  59. package/dist/src/comments.d.ts +0 -127
  60. package/dist/src/comments.js +0 -192
  61. package/dist/src/download.js +0 -54
  62. package/dist/src/geolocation.d.ts +0 -64
  63. package/dist/src/geolocation.js +0 -96
  64. package/dist/src/hooks.d.ts +0 -781
  65. package/dist/src/hooks.js +0 -860
  66. package/dist/src/index.js +0 -34
  67. package/dist/src/members.d.ts +0 -105
  68. package/dist/src/members.js +0 -62
  69. package/dist/src/mock.d.ts +0 -118
  70. package/dist/src/mock.js +0 -124
  71. package/dist/src/mount.d.ts +0 -47
  72. package/dist/src/mount.js +0 -34
  73. package/dist/src/new_record.d.ts +0 -74
  74. package/dist/src/new_record.js +0 -117
  75. package/dist/src/open_app.d.ts +0 -15
  76. package/dist/src/open_app.js +0 -18
  77. package/dist/src/open_external.d.ts +0 -16
  78. package/dist/src/open_external.js +0 -19
  79. package/dist/src/recording.d.ts +0 -59
  80. package/dist/src/recording.js +0 -30
  81. package/dist/src/recording_state.d.ts +0 -59
  82. package/dist/src/recording_state.js +0 -94
  83. package/dist/src/router.d.ts +0 -17
  84. package/dist/src/router.js +0 -144
  85. package/dist/src/row.d.ts +0 -159
  86. package/dist/src/row.js +0 -254
  87. package/dist/src/rpc.d.ts +0 -207
  88. package/dist/src/rpc.js +0 -904
  89. package/dist/src/select.d.ts +0 -48
  90. package/dist/src/select.js +0 -40
  91. package/dist/src/types.d.ts +0 -115
  92. package/dist/src/types.js +0 -1
  93. package/dist/src/upload/optimize.d.ts +0 -54
  94. package/dist/src/upload/optimize.js +0 -207
  95. package/dist/src/upload/pipeline.d.ts +0 -55
  96. package/dist/src/upload/pipeline.js +0 -52
  97. package/dist/src/upload/transport.d.ts +0 -42
  98. package/dist/src/upload/transport.js +0 -128
  99. package/dist/src/url_params.d.ts +0 -93
  100. package/dist/src/url_params.js +0 -215
  101. package/dist/src/use_optimistic.d.ts +0 -27
  102. package/dist/src/use_optimistic.js +0 -27
  103. package/dist/src/use_recents.d.ts +0 -19
  104. package/dist/src/use_recents.js +0 -71
  105. package/dist/src/use_url_state.js +0 -73
  106. package/dist/src/viewer.d.ts +0 -26
  107. package/dist/src/viewer.js +0 -47
  108. /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 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.
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 in the app's `package.json` under `lotics.queries` — an alias → declaration map:
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
- "lotics": {
22
- "queries": {
23
- "openOrders": {
24
- "ast": {
25
- "kind": "project",
26
- "from": {
27
- "kind": "from_table",
28
- "table_id": "tbl_orders",
29
- "filter": { "node_type": "condition", "field_key": "status",
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. It is for the **agents** that reach this app's data — the app's own declared
53
- agents, and a member's chat while they have the app open — which otherwise see only an alias,
54
- a JS identifier that names a query without saying what it covers. Write it for whoever has to
55
- choose between your aliases; longer guidance belongs in an agent's own `instructions`.
56
- - Aliases must be valid JS identifiers (`useQuery("openOrders")` and codegen depend on it).
57
-
58
- `lotics app query set <alias>` (or `--all`) pushes this map to the server — a **deploy does
59
- not**, it echoes the live row back unchanged. The **server holds the canonical template**;
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: codegen writes them into `AppQueryColumns`, so reading a column this query does not
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 server reads the source tables' field definitions to resolve option and member
123
- cells and then keeps them: sending them beside every page was 94% of a real register
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. Carry the keys out
149
- (`readSelect(cell).map((o) => o.key)`) and resolve them against an alias that projects the
150
- underlying field DIRECTLY — `useFieldOptions` takes no params, so any alias whose schema
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
- `__created_at` / `__updated_at` are DISPLAY values, not just reserved names: a table with no date
167
- field of its own still has a chronological anchor. They arrive as ISO-8601 **instants** with an
168
- offset (`"2026-07-05T09:00:00.000Z"`) — not the timezone-less wall-clock a `date`/`datetime` cell
169
- carries — so read them with `readCreatedAt(row)` / `readUpdatedAt(row)`, which parse them as
170
- instants ([./data_fetching.md](./data_fetching.md)).
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
- Without it the cell yields the whole array and the response pays to resolve every entry it
222
- will never show — at scale, into the response-wide presign ceiling (§10). The bound is per
223
- CELL and caps at `FILES_PROJECTION_MAX_LIMIT` (10) — a row wanting more than a handful is
224
- asking for the collection, which belongs to the record surface that opens it. It is a
225
- different axis from the query's own `limit`, which counts ROWS; `limit` on any non-`files`
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 / `[]`; otherwise NULL-or-blank-text | boolean |
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 (correlated per-row subquery) | the target field's type |
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. Each extraction is a correlated subquery evaluated per output
405
- row — cheap on a filtered detail read, expensive over thousands of rows.
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 correlated subquery is
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 in a query orders by raw JSON, **not** configured option order. `unnest` fans option keys. |
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
- **Warning (silent no-op):** a condition whose operator has no translation for the field's type
494
- at the source layer — e.g. the date-*range* operators (`overlaps`, `within`, `starts_before`,
495
- `duration_*`) — contributes **no constraint** rather than erroring. The same applies to a
496
- condition whose value is `null`/absent. At the *derived* layer, unsupported operators fail
497
- loudly instead. Positive list operators with an **empty list** (`is_any_of: []`,
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-partition scan that hangs the
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. Use `search` when the box genuinely means *find this anywhere in the record*.
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 so an unbounded child set can't
694
- produce a giant cell. It is a silent cap — pair it with a `unique` aggregate over the same
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 deliberately **not** a rollup field type: a rollup persists its value into record data and
704
- rewrites it on every child change, and an unbounded concatenation does not belong in a stored
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
- A `window` node carries two independent column lists — **`aggregates`** (frame aggregates) and
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
- **`functions` — ranking / navigation.** Each is `{ "output", "fn", … }` (its own arg shape, no
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. Traversals / `locked` / `current_member` do not — bake those into the
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"`. It matters on a **multi-key** sort over columns whose blankness IS the ranking —
815
- a pipeline's per-step date stamps, where the first non-blank column gives the row's rung.
816
- Listing the ladder top-down with blanks at the bottom orders it furthest-along-first; the
817
- ascending reading needs `blank_position: "top"` on every key, and without it that direction
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 single-row COUNT over the *filtered* set,
825
- ignoring sort/limit/offset. Drives "Page 1 of N". It is a **second full execution** of the
826
- template, not a cheap lookup off the page request — it costs what the query costs and grows with
827
- the filtered set, which on a union/unpivot pipeline is a whole extra scan.
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 must follow a runtime
840
- facet has no server-side shape today.
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 SDK hooks map onto this directly — the hook-side contract is
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 live in one big partitioned store; a `from_table` scan narrows to the table's partition
876
- slice, and within it:
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 written (`app query set`, or a chat
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
- - **Partition scan (linear in table size):** everything else — text `contains`, negations
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.** The shape to watch
914
- is the mirror of the one above: a `join` whose right side is a `group` over one or more entire
915
- tables — a lookup built by scanning everything in order to decorate a small left side. Rule 1 is
916
- no help here, and that is what makes it easy to ship: the arm keys on a value the LEFT side
917
- supplies at run time (a code, a serial number), so there is no static predicate to push down. The
918
- left side's size is irrelevant; you pay for the arms. As a unit to budget with: a four-table arm
919
- totalling ~14k rows is **seconds, not milliseconds**, on every single execution. Prefer a
920
- materialized **lookup/rollup field** on the row (the platform keeps it current and it filters at
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 (§7) — the
934
- whole-record index will surface rows that only share an owner or a status.
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 `pageSize` drive.
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. (It counts distinct *present* values, §8 — so a pre-filter that excludes the rows whose
955
- value is blank buys nothing but its own scan.) Which makes the rule's own corollary the thing
956
- to check first: **group keys are not free scaffolding.** They are the expensive half of a
957
- group — a `select`/jsonb key especially — so keys kept for a facet the screen no longer
958
- renders cost a real multiple of the ungrouped aggregate, and a query that outlived its cards
959
- is invisible because it still returns the right answer.
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.