@ultimat3/query 22.15.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -18,12 +18,14 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
18
18
  | `http.ts` | route projection (`GET /_x/query/<kebab>`, `enforcedBy: 'handler'`) |
19
19
  | `mcp-tool.ts` | MCP read descriptor, same `sourceFor` |
20
20
  | `client.ts` | typed read client (browser-safe); dispatches through `@ultimat3/core`'s `clientTransport`, never `fetch` |
21
+ | `client-scale-pins.ts` | compile-time pin: `queryClient<Api['queries']>` over 100 reads in 50 modules — list, `.page` and `single: true` |
21
22
  | `record-answer.ts` | a read's HTTP answer: bare rows, or the record envelope when `rows:` is an entity's branded row schema |
22
23
  | — | flight control is **`@ultimat3/core`**'s `client-flight.ts` + `client-wire.ts`, re-exported. No local copy |
23
24
  | `naming.ts` | export name → `/_x/query/<kebab>` — `derivePath` IS core's `queryPath`. **Paths only** |
24
25
  | `registry.ts` | export-name registration, `describeQueries()`, the `registerPrimitiveRegistrar('query', …)` announcement |
25
26
  | `live.ts` | `LiveQuery` descriptor + cursor arithmetic |
26
27
  | `subscribes.ts` | the relations a live read declares, and the two assertions that keep them true |
28
+ | `sealed-shape.ts` | a live read keyed on a `.sealed()` column is refused at subscribe (`X_MATCHER_UNSUPPORTED`) |
27
29
  | `matcher.ts` | change event → minimal patch, `refill` when the window cannot place the row, or `X_MATCHER_UNSUPPORTED` |
28
30
  | `pagination.ts` | `paginate()` over core's cursor codec — no offset, ever |
29
31
  | `cursor-value.ts` | what a sort value becomes inside a cursor, and back |
@@ -33,6 +35,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
33
35
  | `search.ts` | `search()` — the query FACTORY over an entity's `.searchable()` columns |
34
36
  | `source.ts` | `SqlSource` contract + `from()` in-memory reference |
35
37
  | `shape.ts` | shared read vocabulary (filters, ordering, seek keys) |
38
+ | `column-kinds.ts` | `kindsOf(relation)`: a column's DECLARED kind, from `QueryShape.entity` |
36
39
  | `policy-gate.ts` | **the only** file that touches `@ultimat3/policy` |
37
40
  | `deprecation.ts` | `Deprecation` + RFC 9745/8594 render + `deprecated_calls_total` — TWINNED with `@ultimat3/action`'s |
38
41
 
@@ -61,8 +64,18 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
61
64
  barrel exports nothing matching `/tool_?name/i`.
62
65
  - **`queryClient` is the map-wide read client (mirror of `rpc`)**; both spellings run
63
66
  `queryClientMethodFor`. **`toQueryRoute` and `client()` derive the same URL from `naming.ts`.**
67
+ - **`QueryClient` typechecks at any app size, and a `.test.ts` cannot say so** (`tsconfig.json`
68
+ excludes them): `client-scale-pins.ts` is source, so a regression is a `typecheck` error. The map
69
+ it pins is the intersection `@ultimat3/action`'s `Merge` hands over; that package pins `Merge`.
64
70
  - **The route coerces (`coerceQuery`), `runQuery` validates.** No `request.query(schema)` and no
65
71
  `meta.input` on the route.
72
+ - **The client and the route are two halves of one encoding.** `searchOf` sends a `Date` as its ISO
73
+ instant and nothing for `[]`; `absentArraysOf` (`input-shape.ts`) reads a REQUIRED array nobody
74
+ sent as `[]`. Never for an optional or defaulted one — absence there is the schema's answer.
75
+ `http-round-trip.test.ts` pins both ends against each other.
76
+ - **A `single: true` read answers the row or `X_NOT_FOUND` by ONE rule** (`single-answer.ts`:
77
+ `oneRowOf`, `readAnswer`) — the route, `tool().read()` and `@ultimat3/mcp`'s served tool all call
78
+ it. `QueryToolDescriptor<TSingle>` types the answer: rows for a list read, the row for a single.
66
79
  - **`rateLimit:` is declarable; `toQueryRoute` sets both `meta.rateLimit` and
67
80
  `meta.rateLimitBucket`**, via `@ultimat3/http`'s `toBucket` — never a local copy.
68
81
  - **`deprecated:` is a compat WINDOW**: `Deprecation` / `Sunset` / `rel="successor-version"` on every
@@ -84,6 +97,15 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
84
97
  - Authz goes through `enforce(surface, policy, { input, actor, ctx })`; a live denial keeps its 4403
85
98
  close code on `QueryDeniedError.denial`. Policy runs per subscriber for live queries — never cache a
86
99
  decision across actors.
100
+ - **This package strips NO column, and must not start.** A query has no output parse; a
101
+ `.sealed()` column is absent from every projection because `@ultimat3/entity`'s repository row
102
+ does not ENUMERATE it (`serverOnly`, `packages/entity/src/sealed.ts`). So nothing on the read
103
+ path may rebuild a row property by property from a column list or `Object.getOwnPropertyNames`
104
+ — `JSON.stringify`, a spread and `Object.keys` are the only walks. `sealed-egress.test.ts` pins
105
+ HTTP (list, `Page`, single, envelope), MCP, live, the cache tier and a derived row.
106
+ - **A live read keyed on a sealed column is refused at subscribe** (`assertNoSealedKey`, called by
107
+ `toLiveQuery`): a change row carries none, so the window would be read once and never patched.
108
+ The refusal names the column, never the filter's value.
87
109
 
88
110
  ## Invariants — the client
89
111
 
@@ -102,7 +124,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
102
124
  import core from `@ultimat3/core/page`, the page keys live in the leaf `page-keys.ts`, and
103
125
  `client.ts` never imports `page-controls.ts` or `stable.ts`. `client-bundle.test.ts` fails on any
104
126
  titles table in the graph or past 11 kB; `client-subpath.test.ts` pins the specifier. Byte figures
105
- (`As of 2026-09-22`, browser, minified): barrel `queryClient` 24,114 B; `./client` 10,210 B.
127
+ (`As of 2026-10-02`, browser, minified): barrel `queryClient` 25,237 B; `./client` 11,012 B.
106
128
  - **The record envelope is derived from `rows:`** — `answersRecords(rows)` (`hasEntityRows`,
107
129
  `@ultimat3/entity`), decided once at projection for `http.ts` and `openapi.ts`, sent on every
108
130
  answer. No `rows:` is byte-identical on the wire.
@@ -117,7 +139,9 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
117
139
  `SearchChain`). No `PRIMITIVE_FACTORIES` row (it returns a query from the query package). **It serves
118
140
  ONE page**: `windowOf.execute` refuses only when rows would be CUT; `onePage.seek` narrows with
119
141
  `Builder.limit()`, never `Builder.seek()`, so the chain's own ranking survives; no `total()`.
120
- `search.test.ts`'s fixture must stay NOT id-ascending.
142
+ Its three refusals (blank term, a cursor, a window that cuts rows) are caller-reachable, so each
143
+ is `X_INPUT_INVALID` — never core's `assert`. `search.test.ts`'s fixture must stay NOT
144
+ id-ascending.
121
145
  - **`subscribes:` is DECLARED (nothing can derive it) and CROSS-CHECKED**: `toLiveQuery` asserts
122
146
  `shape.entity` is among the declared names at first subscribe (`X_QUERY_SUBSCRIBES_DRIFT`) —
123
147
  membership, never equality. An empty list, or one on a non-live read, is refused at `query()`
@@ -132,6 +156,10 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
132
156
  - `paginate` has no `offset` and never grows one; it is reachable only as
133
157
  `query.page(input, { first, after })`. **A page is bounded**: `first` is 1…`MAX_PAGE_SIZE`
134
158
  (10,000, a twin of `@ultimat3/entity`'s).
159
+ - **A declared `.limit()` bounds the LISTING, on every page**: the window is
160
+ `min(rowsLeft, first + 1)`, and a limited read's cursor carries the rows served so far at the
161
+ tail of its key (after the typed id). A cursor without it is `X_CURSOR_INVALID` on a limited
162
+ read. `Builder.seek()` takes the smaller limit too. `pagination-limit.test.ts`.
135
163
  - **A `RowProvider` may be a list, a sync function or an async one** (`source.test.ts`).
136
164
  - **A cursor is a position, not a row**: `isAfterKey` (`source.ts`) is the one definition of "after";
137
165
  never reintroduce a row lookup. The seek predicate is spelled out per key
@@ -149,10 +177,14 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
149
177
  - **A sort value carries its TYPE through the cursor** (`cursor-value.ts`: `{ $x: 'date' | 'bigint',
150
178
  v }`; `undefined` → `null`). Untaggable values are `X_CURSOR_VALUE_UNSUPPORTED` where the cursor is
151
179
  minted.
152
- - **`compareValues` orders numbers and bigints in one order**; equality treats `number` and `bigint`
153
- as one family (`shape.ts` `same`). `shape-order.test.ts` reads `@ultimat3/entity`'s `COLUMN_KINDS`.
154
- - **`numeric` and the TEXT form of `bigint` are a DECLARED gap** (`DECLARED_GAP` in
155
- `shape-order.test.ts`): closing it needs an `OrderKey` carrying a kind, never a guessing comparator.
180
+ - **This package has NO comparator.** How two values compare is `@ultimat3/entity`'s
181
+ `compareByKind` / `sameValueOfKind`, asked under the column's declared kind (`kindsOf(shape.entity)`,
182
+ `column-kinds.ts`). `compareRows`, `matchesFilter(s)` and `isAfterKey` REQUIRE the `KindOf`; never
183
+ add a `typeof` branch here — fix the rule in entity. `equalOrBothNull` adds only this package's
184
+ NULL rule. `compare-parity-fixture.ts` is the one table: `compare-parity.test.ts` (entity, matcher,
185
+ `from()`) and `compare-parity.live.test.ts` (Postgres). `grep compareValues src` stays empty.
186
+ - **A relation no entity declares has no kinds** — digits there are text, as in a `text` column.
187
+ `shape-order.test.ts` reads `@ultimat3/entity`'s `COLUMN_KINDS`, one case per kind.
156
188
  - **A refill is owed by a FULL window only** (`held >= shape.limit`). **A move OUT of a full window is
157
189
  a `refill`, never an `add`.**
158
190
  - **A position is decided against the rows the WINDOW holds**: `unprojectedOrderKey` — a held row
package/README.md CHANGED
@@ -53,6 +53,9 @@ const [post] = await queries.publicPost({ slug }); // typed input, typed rows
53
53
 
54
54
  Both spellings run `queryClientMethodFor`, so a read has one URL however it is addressed.
55
55
 
56
+ `queryClient<Api['queries']>` typechecks at any app size: `client-scale-pins.ts` compiles it over
57
+ 100 reads in 50 modules — a list read, its `.page` and a `single: true` read — on every `typecheck`.
58
+
56
59
  Every read goes through `@ultimat3/core`'s **`clientTransport`** — the one browser HTTP function,
57
60
  `As of 2026-09-22` (21.0.0). There is no `fetch` call in this package: the `fetch` option is handed
58
61
  on as the transport's `fetchImpl`. The transport owns `Accept`, the trace/budget headers, the
@@ -65,8 +68,11 @@ A detail read declares `single: true`. The route answers the first row as the bo
65
68
  `X_NOT_FOUND`** when `sql` returns none (a list read answers `200 []`), refuses `_first`/`_after`
66
69
  (400), and `openapi.json` documents one object with a `404` and no page controls. `.client()` and
67
70
  `queryClient` type it `Promise<TRow>` with no `.page`. In process nothing moves: `postById({ id })`,
68
- `.as()`, `.page()`, `.live()` and the MCP tool still answer `readonly TRow[]`, so opting in breaks no
69
- `[0]` already written. `As of 2026-09-29`.
71
+ `.as()`, `.page()` and `.live()` still answer `readonly TRow[]`, so opting in breaks no `[0]`
72
+ already written. `As of 2026-09-29`. **`tool().read()` answers as the route does**
73
+ (`As of 2026-10`): the row itself, and `X_NOT_FOUND` when `sql` returns none — an agent given `[]`
74
+ for a missing id read "found, and empty" where HTTP said 404. The tool `@ultimat3/mcp` serves over
75
+ `tools/call` answers the same two ways.
70
76
 
71
77
  ```ts
72
78
  import { can } from '@ultimat3/policy';
@@ -205,6 +211,8 @@ forgetting the policy, and the reason is what tells the next reader which of the
205
211
  | `sql.ts` | `explain()` — the generated SQL, verbatim |
206
212
  | `cache.ts` | request memo + tag-keyed tier, one invalidation graph |
207
213
  | `source.ts` | the `SqlSource` contract + `from()`, the in-memory reference |
214
+ | `column-kinds.ts` | `kindsOf(relation)` — the declared kind of each column, which every comparison is decided by |
215
+ | `sealed-shape.ts` | a live read may not filter or order on a `.sealed()` column — refused at subscribe |
208
216
 
209
217
  ## Live queries
210
218
 
@@ -262,6 +270,22 @@ cursor cannot prove that rows sorting *before* it are unchanged, so any epoch ch
262
270
  new build, new policy, new schema — forces a full refetch instead of a resume. Bounded
263
271
  server memory bought with an occasional extra page fetch.
264
272
 
273
+ ## A sealed column never rides on a row
274
+
275
+ A query has no output schema; nothing here strips a column. A `.sealed()` column is absent from
276
+ every projection because `@ultimat3/entity` answers it as a server-only (non-enumerable) property —
277
+ `row.password` on the server, nothing in `JSON.stringify(row)`.
278
+
279
+ | Projection | Sealed column |
280
+ |---|---|
281
+ | HTTP — list, `Page`, `single: true`, the record envelope | absent |
282
+ | MCP read, a `cache:` entry, a live snapshot | absent |
283
+ | a derived row `({ ...row, extra })` | absent |
284
+ | a derived row naming it `({ token: row.token })` | sent — the name is the statement |
285
+ | `query(input)` on the server | `row.password` reads the plaintext |
286
+
287
+ `sealed-egress.test.ts` holds one assertion per projection.
288
+
265
289
  ## Matcher support
266
290
 
267
291
  | Shape | Result |
@@ -270,6 +294,7 @@ server memory bought with an occasional extra page fetch.
270
294
  | `orderBy` (any number of keys) | insert position computed, moves become remove + add |
271
295
  | `limit` | tail eviction on insert, `refill` patch on removal |
272
296
  | joins, aggregates, `group by`, subqueries | `X_MATCHER_UNSUPPORTED` with a fix line |
297
+ | a `.sealed()` column in a filter or an order | `X_MATCHER_UNSUPPORTED` — a change row carries no sealed column |
273
298
 
274
299
  An honest refusal beats a silently wrong result set, so unsupported shapes fail at
275
300
  **subscribe** time, not on the first change event.
@@ -320,7 +345,16 @@ empty box is not an empty result set.
320
345
  **It serves one page.** The rows come from the entity chain, which pages by its own signed cursor;
321
346
  that cursor cannot cross the `SqlSource` seam, and slicing in memory instead would cut inside the
322
347
  page the provider fetched and report `hasNextPage: false` at its edge. So a second page is
323
- refused, and the `fix:` names the chain — `db.posts.search(term).after(cursor).page()`.
348
+ refused, and the cause names the chain — `db.posts.search(term).after(cursor).page()`.
349
+
350
+ | A request `search()` refuses | Answer |
351
+ |---|---|
352
+ | `q` of only whitespace (`?q=%20%20` passes `t.string.min(1)`) | **400** `X_INPUT_INVALID` |
353
+ | a cursor (`?_after=…`) | **400** `X_INPUT_INVALID` |
354
+ | a window that would cut rows (`?_first=1` with two matches) | **400** `X_INPUT_INVALID`, naming both edits: widen `first` to the read's `limit`, or narrow `limit` |
355
+
356
+ All three arrive on the request, so all three are the caller's. `As of 2026-10`; until then they
357
+ were core `assert`s — `X_INVARIANT`, a 500 blaming the server.
324
358
 
325
359
  ## Pagination is cursor-only
326
360
 
@@ -359,11 +393,14 @@ GET /_x/query/live-feed?orgId=… → [ …rows ] (uncha
359
393
  | `_first` outside 1–10,000, not a whole number, or `_after` without `_first` | **400** `X_INPUT_INVALID` with the read's own fix line — judged at the wire (`page-controls.ts`), because `paginate` asserts the same bound as `X_INVARIANT`, which is a 500 blaming the server for a number the caller typed |
360
394
  | a cursor that is not this read's | **400** `X_CURSOR_INVALID` — `decodeCursor` is the one judge of a cursor; the route checks only that `_after` is one non-empty string |
361
395
  | a control sent twice | refused, never resolved to the last one as a declared scalar is: two page sizes is two pages asked for in one request |
396
+ | a declared `.limit()` is the size of the LISTING | `first` is the size of a page and never widens it: the window is `min(what the limit has left, first + 1)`, a limited read's cursor carries how many rows are already served, and the page after the last is empty with `nextCursor: null`. `As of 2026-10`; until then `first` REPLACED the limit, so `?_first=10000` walked a "top 3" to the end of the table. A read paged past its limit on purpose drops the `.limit()` |
397
+ | a cursor a limited read minted before that | **400** `X_CURSOR_INVALID` — it does not say how much of the limit is spent, and honouring it would start a second "top N" below the first |
362
398
  | the typed client | `feed.client({ baseUrl }).page(input, { first, after })` and `queries.feed.page(…)` append the two controls after the sorted input, so a paged URL is the plain URL plus a suffix |
363
399
  | `openapi.json` | every read is a `GET` path item with its input as `in: query` parameters, the two controls after them, and a `200` that is `oneOf` the bare rows and the envelope — `queryOpenApiPaths`, merged into `@ultimat3/action`'s document by the CLI |
364
400
 
365
401
  The MCP read tool does not page: `read()` answers the source's rows, bounded by the read's own
366
- `limit`, and an agent that wants more asks a narrower question.
402
+ `limit` — or the one row of a `single: true` read — and an agent that wants more asks a narrower
403
+ question.
367
404
 
368
405
  The codec lives in `@ultimat3/core`, not here: `encodeCursor`, `decodeCursor`,
369
406
  `configureCursorSigning` (set the signing secret once at boot; rotating it invalidates every
@@ -432,9 +469,32 @@ the rows after it were unreachable through a cursor. An ascending key now reache
432
469
  NULL under `nulls last`, and the page continues on the id tiebreak, which is never NULL.
433
470
 
434
471
  `nulls last` / `nulls first` are Postgres' own defaults, written down rather than inherited: it is
435
- the rule `compareValues` implements, so the in-memory sort and the seek predicate can only agree
472
+ the rule `compareRows` implements, so the in-memory sort and the seek predicate can only agree
436
473
  with it, and a driver whose default differs cannot re-open the divergence.
437
474
 
475
+ ## How two values compare
476
+
477
+ **By the column's declared kind, and by `@ultimat3/entity`'s comparator — this package has none.**
478
+ `kindsOf(shape.entity)` resolves each column's `ColumnKind` from the entity declared on that table,
479
+ and `compareRows`, `matchesFilter`, `isAfterKey` and the matcher hand it to entity's
480
+ `compareByKind` / `sameValueOfKind`: the one JS statement of how Postgres compares a column.
481
+ `As of 2026-10`; until then a second comparator here decided by `typeof`.
482
+
483
+ | Column | What its row holds | Decided as |
484
+ |---|---|---|
485
+ | `bigint()`, `decimal()` | decimal TEXT | digits, exactly: `'9'` before `'10'`, `'2.50'` equal to `'2.5'` and to `2.5` |
486
+ | `uuid()` | text | a value: upper and lower case are one id |
487
+ | `timestamp()` | `Date` | an instant: a `Date` equals the ISO text naming it |
488
+ | `text()` | text | characters: `'10'` before `'9'`, as `order by` on a text column answers |
489
+ | a relation no entity declares | whatever the row provider returned | no kind — numbers numerically (a `number` and a `bigint` in one order), `Date`s by instant, the rest as text |
490
+
491
+ `compare-parity-fixture.ts` is the one `(kind, left, right)` table: `compare-parity.test.ts` runs it
492
+ through entity's evaluator, the matcher and `from()`; `compare-parity.live.test.ts` runs it through
493
+ Postgres, and walks a live window ordered on a `bigint()` and a `decimal()` column against the
494
+ database's own `order by`. A custom `SqlSource` calling `compareRows`, `matchesFilter` or
495
+ `isAfterKey` passes `kindsOf(shape.entity)` — the argument is required, because a comparison with
496
+ no kind is the defect.
497
+
438
498
  ## Caching
439
499
 
440
500
  Request memo (same read twice in one render ⇒ one round trip), then the tier ladder
@@ -543,9 +603,9 @@ router feature here.
543
603
  | `X_MATCHER_UNSUPPORTED` | live query the matcher cannot patch | reshape it, or `live: false` |
544
604
  | `X_CURSOR_INVALID` | tampered / foreign / malformed cursor | request the first page again |
545
605
  | `X_QUERY_NOT_PAGEABLE` | a paged or live read returned a row with no `id` | select the primary key: `db.<rows>.select({ id: true, … })` |
546
- | `X_INPUT_INVALID` | input failed the Standard Schema | `x queries describe <name> --json` |
606
+ | `X_INPUT_INVALID` | input failed the Standard Schema; a page control the wire refuses; a `search()` handed a blank term, a cursor, or a window that would cut rows | `x queries describe <name> --json` |
547
607
  | `X_QUERY_UNREGISTERED` | used before `registerQueries()` ran | register at boot |
548
- | `X_NOT_FOUND` | a `single: true` read matched no row — the HTTP route's 404 only (entity's code) | `x queries describe <name> --json` prints the SQL |
608
+ | `X_NOT_FOUND` | a `single: true` read matched no row — the HTTP route's 404, and the MCP tool's refusal (entity's code) | `x queries describe <name> --json` prints the SQL |
549
609
  | `X_QUERY_SINGLE_INVALID` | `single:` declared as something other than a boolean | `single: true`, or drop the key |
550
610
  | `X_QUERY_FOREIGN` | a look-alike was projected as a query | declare it with `query({ … })` |
551
611
  | `X_CLIENT_TRANSPORT_FAILED` | `.client()` got no response, or a non-`problem+json` failure (core's code, since 21.0.0; was `X_RPC_FAILED`) | check the gateway in front of the app: `x doctor --json` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/query",
3
- "version": "22.15.0",
3
+ "version": "24.0.0",
4
4
  "description": "The query primitive: a policy-checked read, optionally live, with cursor pagination and an incremental matcher",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,6 +24,7 @@
24
24
  "files": [
25
25
  "src",
26
26
  "!src/**/*.test.ts",
27
+ "!src/**/*-fixture.ts",
27
28
  "CLAUDE.md",
28
29
  "README.md",
29
30
  "LICENSE"
@@ -36,11 +37,11 @@
36
37
  "test": "bun test"
37
38
  },
38
39
  "dependencies": {
39
- "@ultimat3/cache": "22.15.0",
40
- "@ultimat3/core": "22.15.0",
41
- "@ultimat3/entity": "22.15.0",
42
- "@ultimat3/http": "22.15.0",
43
- "@ultimat3/policy": "22.15.0",
44
- "@ultimat3/schema": "22.15.0"
40
+ "@ultimat3/cache": "24.0.0",
41
+ "@ultimat3/core": "24.0.0",
42
+ "@ultimat3/entity": "24.0.0",
43
+ "@ultimat3/http": "24.0.0",
44
+ "@ultimat3/policy": "24.0.0",
45
+ "@ultimat3/schema": "24.0.0"
45
46
  }
46
47
  }
@@ -0,0 +1,78 @@
1
+ // Compile-time pin: `queryClient<Api['queries']>` at 100 reads in 50 modules. Source, not a
2
+ // `.test.ts` — `tsconfig.json` excludes those, so `tsc -b` would never read the claim. Types only:
3
+ // nothing here is emitted, and a regression is a build error in the `typecheck` step.
4
+ //
5
+ // `QueryClient` is its own mapped type and regresses on its own. `defineApi` is `@ultimat3/action`'s
6
+ // (sideways), so the map is spelled the way its `Merge` hands it over: one intersection of every
7
+ // module's registered reads. `@ultimat3/action`'s `client-scale-pins.ts` holds the `Merge` half.
8
+
9
+ import type { NumberSchema, ObjectSchema, StringSchema } from '@ultimat3/schema';
10
+ import type { queryClient } from './client';
11
+ import type { Page } from './pagination';
12
+ import type { Query } from './query';
13
+
14
+ type Assert<T extends true> = T;
15
+
16
+ type Equals<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
17
+
18
+ /** Distinct per module, so no two modules collapse into one instantiation. */
19
+ type ScaleInput<N extends number> = ObjectSchema<
20
+ { readonly orgId: StringSchema; readonly limit: NumberSchema } & {
21
+ readonly [K in `q${N}`]: StringSchema;
22
+ }
23
+ >;
24
+
25
+ type ScaleRow<N extends number> = { readonly id: string } & { readonly [K in `col${N}`]: number };
26
+
27
+ /** One feature's reads: a list and a `single: true`, the two methods `QueryClient` projects. */
28
+ type QueryModule<N extends number> = {
29
+ readonly [K in `listRows${N}`]: Query<ScaleInput<N>, ScaleRow<N>>;
30
+ } & { readonly [K in `oneRow${N}`]: Query<ScaleInput<N>, ScaleRow<N>, true> };
31
+
32
+ /** `Module<0> & … & Module<TCount - 1>`. A tail call, so the count costs no depth here. */
33
+ type MergedQueries<
34
+ TCount extends number,
35
+ TSeen extends readonly unknown[] = [],
36
+ TMap = unknown,
37
+ > = TSeen['length'] extends TCount
38
+ ? TMap
39
+ : MergedQueries<TCount, [...TSeen, unknown], TMap & QueryModule<TSeen['length']>>;
40
+
41
+ type ScaleQueries = MergedQueries<50>;
42
+
43
+ /** The idiom itself, through `queryClient`'s own `QueryMap` constraint. */
44
+ type ScaleClient = ReturnType<typeof queryClient<ScaleQueries>>;
45
+
46
+ export type _EveryModuleReachesTheClient = Assert<
47
+ 'listRows0' | 'oneRow24' | 'listRows49' extends keyof ScaleClient ? true : false
48
+ >;
49
+
50
+ export type _TheClientHasExactlyTheMergedReads = Assert<
51
+ Equals<keyof ScaleClient, keyof ScaleQueries>
52
+ >;
53
+
54
+ type LastInput = { readonly orgId: string; readonly limit: number; readonly q49: string };
55
+
56
+ /** One call of each shape, read off the LAST module's own declaration. */
57
+ export type _AListReadAnswersRows = Assert<
58
+ Equals<
59
+ [Parameters<ScaleClient['listRows49']>[0], Awaited<ReturnType<ScaleClient['listRows49']>>],
60
+ [LastInput, readonly ScaleRow<49>[]]
61
+ >
62
+ >;
63
+
64
+ export type _AListReadPages = Assert<
65
+ Equals<Awaited<ReturnType<ScaleClient['listRows49']['page']>>, Page<ScaleRow<49>>>
66
+ >;
67
+
68
+ export type _ASingleReadAnswersTheRow = Assert<
69
+ Equals<
70
+ [Parameters<ScaleClient['oneRow49']>[0], Awaited<ReturnType<ScaleClient['oneRow49']>>],
71
+ [LastInput, ScaleRow<49>]
72
+ >
73
+ >;
74
+
75
+ /** A single read has no `page` — the route refuses the controls, so the method would be a 400. */
76
+ export type _ASingleReadHasNoPage = Assert<
77
+ 'page' extends keyof ScaleClient['oneRow49'] ? false : true
78
+ >;
package/src/client.ts CHANGED
@@ -203,6 +203,9 @@ function read(
203
203
  * The page controls come LAST, after the sorted input and in a fixed order, so a paged URL is the
204
204
  * plain URL with a suffix — the same dedup key for the same page, and a log line a reader can
205
205
  * split at `_first=` to recover the read underneath.
206
+ *
207
+ * An EMPTY array sends nothing, and the route reads a required array nobody sent as `[]`
208
+ * (`absentArraysOf`, `input-shape.ts`) — the two halves of one rule, so `{ tags: [] }` arrives.
206
209
  */
207
210
  function searchOf(input: unknown, page?: PageControls): string {
208
211
  const params = new URLSearchParams();
@@ -211,7 +214,7 @@ function searchOf(input: unknown, page?: PageControls): string {
211
214
  const value = input[key];
212
215
  if (value === undefined || value === null) continue;
213
216
  for (const item of Array.isArray(value) ? (value as readonly unknown[]) : [value]) {
214
- params.append(key, typeof item === 'object' ? JSON.stringify(item) : String(item));
217
+ params.append(key, wireText(item));
215
218
  }
216
219
  }
217
220
  }
@@ -221,3 +224,14 @@ function searchOf(input: unknown, page?: PageControls): string {
221
224
  }
222
225
  return params.toString();
223
226
  }
227
+
228
+ /**
229
+ * One value as the characters `coerceQuery` reads back. A `Date` is its ISO instant: through
230
+ * `JSON.stringify` it arrived wrapped in quotes, which `t.date` refuses on the far side. An
231
+ * Invalid Date has no instant, and `toISOString()` throws a bare `RangeError` on it, so it is sent
232
+ * as the text it prints and the read's own schema says what is wrong with it.
233
+ */
234
+ function wireText(item: unknown): string {
235
+ if (item instanceof Date) return Number.isNaN(item.getTime()) ? String(item) : item.toISOString();
236
+ return typeof item === 'object' ? JSON.stringify(item) : String(item);
237
+ }
@@ -0,0 +1,28 @@
1
+ // Single responsibility: the DECLARED kind of each column of the relation a read names. Postgres
2
+ // compares two values by their column's type, so every comparison this package makes asks here
3
+ // first and hands the answer to `@ultimat3/entity`'s comparator — never `typeof` the value.
4
+
5
+ import type { ColumnKind } from '@ultimat3/entity';
6
+ import { entityForTable } from '@ultimat3/entity';
7
+
8
+ /** A column's declared kind, or `undefined` when nothing declares one. */
9
+ export type KindOf = (column: string) => ColumnKind | undefined;
10
+
11
+ const UNDECLARED: KindOf = () => undefined;
12
+
13
+ /**
14
+ * The kinds of `QueryShape.entity`'s columns. Resolved once per read, sort or change event — the
15
+ * registry is a scan — and never per comparison.
16
+ *
17
+ * A relation no entity declares (`from('report', rows)` over a projection) has no kinds: its
18
+ * values compare as `@ultimat3/entity` compares an undeclared column, by what they are. `money` is
19
+ * two physical columns behind one property, so the property alone names no single value to order.
20
+ */
21
+ export function kindsOf(relation: string): KindOf {
22
+ const columns = entityForTable(relation)?.$columns;
23
+ if (columns === undefined) return UNDECLARED;
24
+ return (column) => {
25
+ const kind = Object.hasOwn(columns, column) ? columns[column]?.$meta.kind : undefined;
26
+ return kind === 'money' ? undefined : kind;
27
+ };
28
+ }
@@ -1,11 +1,11 @@
1
1
  // Single responsibility: what a sort value becomes inside a cursor, and what it becomes again on
2
2
  // the way out. The codec is `@ultimat3/core`'s and it is JSON, so a `Date` went in and an ISO
3
- // STRING came back: `isAfterKey` then compared `"1769904000000"` against `"2026-02-01T…"` through
4
- // `compareValues`' string branch and page two came back empty. A `bigint` was worse — a bare
3
+ // STRING came back: `isAfterKey` then compared `"1769904000000"` against `"2026-02-01T…"` as
4
+ // text and page two came back empty. A `bigint` was worse — a bare
5
5
  // `TypeError` out of `JSON.stringify`, with no code and no fix.
6
6
  //
7
7
  // `@ultimat3/entity`'s `cursor.ts` solves the same problem by reading the column's declared kind.
8
- // A `query` has no column kinds — `QueryShape.orderBy` is a name and a direction — so the value
8
+ // A read over a relation NO entity declares has no kinds to read (`column-kinds.ts`), so the value
9
9
  // carries its own tag instead. Self-describing, which is also what makes the revive total: nothing
10
10
  // here has to know which read minted the cursor.
11
11
 
package/src/facade.ts CHANGED
@@ -45,7 +45,7 @@ export function facadeFor<
45
45
  // signed cursor is only reachable through the query that issued it.
46
46
  page: (input, args) => paginate(self(), input, args),
47
47
  live: (input, options) => toLiveQuery(self(), input, options),
48
- tool: () => toQueryTool(self()),
48
+ tool: () => toQueryTool<TSingle>(self()),
49
49
  // One implementation for both wire shapes: the route decides the body, the transport parses
50
50
  // it, and only the TYPE differs — a single read's method answers the row, and has no `page`.
51
51
  client: (options) =>
package/src/http.ts CHANGED
@@ -11,17 +11,15 @@ import { toBucket } from '@ultimat3/http';
11
11
  import { coerceQuery } from '@ultimat3/schema';
12
12
  import type { Deprecation } from './deprecation';
13
13
  import { recordDeprecatedCall, renderDeprecation } from './deprecation';
14
- import {
15
- QueryDeprecationInvalidError,
16
- QueryInputInvalidError,
17
- QueryRowNotFoundError,
18
- } from './errors';
14
+ import { QueryDeprecationInvalidError, QueryInputInvalidError } from './errors';
15
+ import { absentArraysOf } from './input-shape';
19
16
  import { derivePath } from './naming';
20
17
  import { PAGE_FIRST_KEY, pageControlsOf } from './page-controls';
21
18
  import { admitsAnonymous, policyCapability } from './policy-gate';
22
19
  import type { AnyQuery } from './query';
23
20
  import { queryName, runQuery } from './read';
24
21
  import { recordAnswerFor, recordRowAnswerFor } from './record-answer';
22
+ import { oneRowOf } from './single-answer';
25
23
 
26
24
  /**
27
25
  * `liveFeed` -> `GET /_x/query/live-feed`. Named for the primitive rather than spelled
@@ -63,7 +61,7 @@ export function toQueryRoute(target: AnyQuery): Route {
63
61
  // The two page controls come OUT first (`page-controls.ts`): they are the route's, not the
64
62
  // read's, and a schema that refused unknown keys would otherwise refuse every paged call.
65
63
  const { input: values, page } = pageControlsOf(name, request.queryRaw());
66
- const input = coerceQuery(target.input, values);
64
+ const input = absentArraysOf(target.input, coerceQuery(target.input, values));
67
65
  if (target.single === true) {
68
66
  // Refused, not ignored: a caller paging a read of one object has the wrong read in mind, and
69
67
  // a silently dropped `_first` would answer a shape it did not ask for.
@@ -75,9 +73,7 @@ export function toQueryRoute(target: AnyQuery): Route {
75
73
  }
76
74
  // The first row — what every in-process `[0]` of the same read already takes. None is the
77
75
  // 404 a detail URL means, where a list read answers `200 []`.
78
- const [row] = await runQuery(target, input, { surface: 'http' });
79
- if (row === undefined) throw new QueryRowNotFoundError(name);
80
- return answerRow(row);
76
+ return answerRow(oneRowOf(name, await runQuery(target, input, { surface: 'http' })));
81
77
  }
82
78
  // With a page control the answer is the `Page` envelope `query.page()` answers a server
83
79
  // caller with — `{ rows, nextCursor, hasMore, endCursor, hasNextPage }`, the same names (the
package/src/index.ts CHANGED
@@ -65,6 +65,14 @@ export type {
65
65
  } from './client';
66
66
  /** `queryClient` is the map-wide read client; `queryClientMethodFor` is what `.client()` binds. */
67
67
  export { queryClient, queryClientMethodFor } from './client';
68
+ /**
69
+ * `isNull` is the one definition of SQL NULL a custom `SqlSource` has to agree with, and
70
+ * `totalOrder` is the one definition of the order it must serve a page in. `compareRows`,
71
+ * `matchesFilter` and `isAfterKey` take the relation's declared kinds — `kindsOf(shape.entity)` —
72
+ * because how two values compare is `@ultimat3/entity`'s answer for the column's kind.
73
+ */
74
+ export type { KindOf } from './column-kinds';
75
+ export { kindsOf } from './column-kinds';
68
76
  /** The compat window a retirement gets. Versioning is two deployments, not a router feature. */
69
77
  export type { Deprecation, DeprecationField, DeprecationRender } from './deprecation';
70
78
  export { recordDeprecatedCall, renderDeprecation } from './deprecation';
@@ -93,7 +101,7 @@ export type { LiveCursor, LiveQuery, ResumeMode, ResumePlan, ToLiveOptions } fro
93
101
  export { planResume, seekOf, toLiveQuery } from './live';
94
102
  export type { ChangeEvent, ChangeOp, Patch } from './matcher';
95
103
  export { assertMatchable, match, positionFor } from './matcher';
96
- export type { QueryToolDescriptor, QueryToolReadOptions } from './mcp-tool';
104
+ export type { QueryToolAnswer, QueryToolDescriptor, QueryToolReadOptions } from './mcp-tool';
97
105
  export { isExposed, toQueryTool, toQueryTools } from './mcp-tool';
98
106
  /**
99
107
  * Path derivation only. There is no `toToolName`: an MCP tool is served under the export name
@@ -151,7 +159,6 @@ export type {
151
159
  export { describeQuery, isQuery, query, queryHash } from './query';
152
160
  /** The one read path. `defOf` stays unexported — that is the enforcement. */
153
161
  export { queryName, runQuery, sourceFor } from './read';
154
-
155
162
  export {
156
163
  describeQueries,
157
164
  getQuery,
@@ -164,18 +171,15 @@ export {
164
171
  export type { SearchChain, SearchDef, SearchInput, SearchPage } from './search';
165
172
  export { search } from './search';
166
173
  export type { Filter, FilterOp, OrderKey, QueryShape, SeekKey } from './shape';
167
- /**
168
- * `isNull` is the one definition of SQL NULL a custom `SqlSource` has to agree with, and
169
- * `totalOrder` is the one definition of the order it must serve a page in.
170
- */
171
174
  export {
172
175
  compareRows,
173
- compareValues,
174
176
  isNull,
175
177
  matchesFilter,
176
178
  seekKeyOf,
177
179
  totalOrder,
178
180
  } from './shape';
181
+ /** What a `single: true` read answers — one rule, also `@ultimat3/mcp`'s served tool's. */
182
+ export { readAnswer } from './single-answer';
179
183
  export type { RowProvider, SqlSource, SqlText } from './source';
180
184
  /** `isAfterKey` is the one definition of "after this position" — both seek paths use it. */
181
185
  export { Builder, from, isAfterKey } from './source';
@@ -79,3 +79,23 @@ export function assertEncodableInput(input: unknown): void {
79
79
  const offender = unencodable(node);
80
80
  if (offender !== undefined) throw new QueryInputUnencodableError(offender);
81
81
  }
82
+
83
+ /**
84
+ * The route's half of an empty array. `searchOf` sends NOTHING for `[]` — a query string has no
85
+ * spelling for "this key, zero times" — so a REQUIRED array nobody sent can only be the empty one:
86
+ * left absent it fails validation, on a call the typed client accepted.
87
+ *
88
+ * Required only. An optional or defaulted array already has a meaning for absence, and it is the
89
+ * schema's: `[]` there would overwrite a declared default with a value the caller never wrote.
90
+ */
91
+ export function absentArraysOf(
92
+ schema: unknown,
93
+ input: Readonly<Record<string, unknown>>,
94
+ ): Record<string, unknown> {
95
+ const out: Record<string, unknown> = { ...input };
96
+ for (const [key, child] of Object.entries(tryIntrospect(schema)?.properties ?? {})) {
97
+ if (child.kind !== 'array' || child.optional === true || child.hasDefault === true) continue;
98
+ if (!Object.hasOwn(input, key)) out[key] = [];
99
+ }
100
+ return out;
101
+ }
package/src/live.ts CHANGED
@@ -16,6 +16,7 @@ import { guard } from './policy-gate';
16
16
  import type { Query } from './query';
17
17
  import { queryHash } from './query';
18
18
  import { queryName, sourceFor } from './read';
19
+ import { assertNoSealedKey } from './sealed-shape';
19
20
  import type { QueryShape, SeekKey } from './shape';
20
21
  import { seekKeyOf } from './shape';
21
22
  import { assertSubscribed } from './subscribes';
@@ -128,6 +129,8 @@ export async function toLiveQuery<TInput extends StandardSchemaV1, TRow extends
128
129
  // outgrown means `x db gen` granted REPLICA IDENTITY FULL somewhere else, so every UPDATE on this
129
130
  // relation arrives without its old row and no patch is computable from it.
130
131
  assertSubscribed(name, target.subscribes, shape.entity);
132
+ // And once more for the same moment: a window keyed on a sealed column is read and never patched.
133
+ assertNoSealedKey(name, shape);
131
134
 
132
135
  const epoch = options.epoch ?? liveEpoch();
133
136
  const hash = queryHash(name, input);