@ultimat3/query 23.0.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
@@ -35,6 +35,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
35
35
  | `search.ts` | `search()` — the query FACTORY over an entity's `.searchable()` columns |
36
36
  | `source.ts` | `SqlSource` contract + `from()` in-memory reference |
37
37
  | `shape.ts` | shared read vocabulary (filters, ordering, seek keys) |
38
+ | `column-kinds.ts` | `kindsOf(relation)`: a column's DECLARED kind, from `QueryShape.entity` |
38
39
  | `policy-gate.ts` | **the only** file that touches `@ultimat3/policy` |
39
40
  | `deprecation.ts` | `Deprecation` + RFC 9745/8594 render + `deprecated_calls_total` — TWINNED with `@ultimat3/action`'s |
40
41
 
@@ -68,6 +69,13 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
68
69
  it pins is the intersection `@ultimat3/action`'s `Merge` hands over; that package pins `Merge`.
69
70
  - **The route coerces (`coerceQuery`), `runQuery` validates.** No `request.query(schema)` and no
70
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.
71
79
  - **`rateLimit:` is declarable; `toQueryRoute` sets both `meta.rateLimit` and
72
80
  `meta.rateLimitBucket`**, via `@ultimat3/http`'s `toBucket` — never a local copy.
73
81
  - **`deprecated:` is a compat WINDOW**: `Deprecation` / `Sunset` / `rel="successor-version"` on every
@@ -116,7 +124,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
116
124
  import core from `@ultimat3/core/page`, the page keys live in the leaf `page-keys.ts`, and
117
125
  `client.ts` never imports `page-controls.ts` or `stable.ts`. `client-bundle.test.ts` fails on any
118
126
  titles table in the graph or past 11 kB; `client-subpath.test.ts` pins the specifier. Byte figures
119
- (`As of 2026-10-01`, browser, minified): barrel `queryClient` 23,997 B; `./client` 10,899 B.
127
+ (`As of 2026-10-02`, browser, minified): barrel `queryClient` 25,237 B; `./client` 11,012 B.
120
128
  - **The record envelope is derived from `rows:`** — `answersRecords(rows)` (`hasEntityRows`,
121
129
  `@ultimat3/entity`), decided once at projection for `http.ts` and `openapi.ts`, sent on every
122
130
  answer. No `rows:` is byte-identical on the wire.
@@ -131,7 +139,9 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
131
139
  `SearchChain`). No `PRIMITIVE_FACTORIES` row (it returns a query from the query package). **It serves
132
140
  ONE page**: `windowOf.execute` refuses only when rows would be CUT; `onePage.seek` narrows with
133
141
  `Builder.limit()`, never `Builder.seek()`, so the chain's own ranking survives; no `total()`.
134
- `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.
135
145
  - **`subscribes:` is DECLARED (nothing can derive it) and CROSS-CHECKED**: `toLiveQuery` asserts
136
146
  `shape.entity` is among the declared names at first subscribe (`X_QUERY_SUBSCRIBES_DRIFT`) —
137
147
  membership, never equality. An empty list, or one on a non-live read, is refused at `query()`
@@ -146,6 +156,10 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
146
156
  - `paginate` has no `offset` and never grows one; it is reachable only as
147
157
  `query.page(input, { first, after })`. **A page is bounded**: `first` is 1…`MAX_PAGE_SIZE`
148
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`.
149
163
  - **A `RowProvider` may be a list, a sync function or an async one** (`source.test.ts`).
150
164
  - **A cursor is a position, not a row**: `isAfterKey` (`source.ts`) is the one definition of "after";
151
165
  never reintroduce a row lookup. The seek predicate is spelled out per key
@@ -163,10 +177,14 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
163
177
  - **A sort value carries its TYPE through the cursor** (`cursor-value.ts`: `{ $x: 'date' | 'bigint',
164
178
  v }`; `undefined` → `null`). Untaggable values are `X_CURSOR_VALUE_UNSUPPORTED` where the cursor is
165
179
  minted.
166
- - **`compareValues` orders numbers and bigints in one order**; equality treats `number` and `bigint`
167
- as one family (`shape.ts` `same`). `shape-order.test.ts` reads `@ultimat3/entity`'s `COLUMN_KINDS`.
168
- - **`numeric` and the TEXT form of `bigint` are a DECLARED gap** (`DECLARED_GAP` in
169
- `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.
170
188
  - **A refill is owed by a FULL window only** (`held >= shape.limit`). **A move OUT of a full window is
171
189
  a `refill`, never an `add`.**
172
190
  - **A position is decided against the rows the WINDOW holds**: `unprojectedOrderKey` — a held row
package/README.md CHANGED
@@ -68,8 +68,11 @@ A detail read declares `single: true`. The route answers the first row as the bo
68
68
  `X_NOT_FOUND`** when `sql` returns none (a list read answers `200 []`), refuses `_first`/`_after`
69
69
  (400), and `openapi.json` documents one object with a `404` and no page controls. `.client()` and
70
70
  `queryClient` type it `Promise<TRow>` with no `.page`. In process nothing moves: `postById({ id })`,
71
- `.as()`, `.page()`, `.live()` and the MCP tool still answer `readonly TRow[]`, so opting in breaks no
72
- `[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.
73
76
 
74
77
  ```ts
75
78
  import { can } from '@ultimat3/policy';
@@ -208,6 +211,7 @@ forgetting the policy, and the reason is what tells the next reader which of the
208
211
  | `sql.ts` | `explain()` — the generated SQL, verbatim |
209
212
  | `cache.ts` | request memo + tag-keyed tier, one invalidation graph |
210
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 |
211
215
  | `sealed-shape.ts` | a live read may not filter or order on a `.sealed()` column — refused at subscribe |
212
216
 
213
217
  ## Live queries
@@ -341,7 +345,16 @@ empty box is not an empty result set.
341
345
  **It serves one page.** The rows come from the entity chain, which pages by its own signed cursor;
342
346
  that cursor cannot cross the `SqlSource` seam, and slicing in memory instead would cut inside the
343
347
  page the provider fetched and report `hasNextPage: false` at its edge. So a second page is
344
- 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.
345
358
 
346
359
  ## Pagination is cursor-only
347
360
 
@@ -380,11 +393,14 @@ GET /_x/query/live-feed?orgId=… → [ …rows ] (uncha
380
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 |
381
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 |
382
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 |
383
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 |
384
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 |
385
400
 
386
401
  The MCP read tool does not page: `read()` answers the source's rows, bounded by the read's own
387
- `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.
388
404
 
389
405
  The codec lives in `@ultimat3/core`, not here: `encodeCursor`, `decodeCursor`,
390
406
  `configureCursorSigning` (set the signing secret once at boot; rotating it invalidates every
@@ -453,9 +469,32 @@ the rows after it were unreachable through a cursor. An ascending key now reache
453
469
  NULL under `nulls last`, and the page continues on the id tiebreak, which is never NULL.
454
470
 
455
471
  `nulls last` / `nulls first` are Postgres' own defaults, written down rather than inherited: it is
456
- 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
457
473
  with it, and a driver whose default differs cannot re-open the divergence.
458
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
+
459
498
  ## Caching
460
499
 
461
500
  Request memo (same read twice in one render ⇒ one round trip), then the tier ladder
@@ -564,9 +603,9 @@ router feature here.
564
603
  | `X_MATCHER_UNSUPPORTED` | live query the matcher cannot patch | reshape it, or `live: false` |
565
604
  | `X_CURSOR_INVALID` | tampered / foreign / malformed cursor | request the first page again |
566
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, … })` |
567
- | `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` |
568
607
  | `X_QUERY_UNREGISTERED` | used before `registerQueries()` ran | register at boot |
569
- | `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 |
570
609
  | `X_QUERY_SINGLE_INVALID` | `single:` declared as something other than a boolean | `single: true`, or drop the key |
571
610
  | `X_QUERY_FOREIGN` | a look-alike was projected as a query | declare it with `query({ … })` |
572
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": "23.0.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",
@@ -37,11 +37,11 @@
37
37
  "test": "bun test"
38
38
  },
39
39
  "dependencies": {
40
- "@ultimat3/cache": "23.0.0",
41
- "@ultimat3/core": "23.0.0",
42
- "@ultimat3/entity": "23.0.0",
43
- "@ultimat3/http": "23.0.0",
44
- "@ultimat3/policy": "23.0.0",
45
- "@ultimat3/schema": "23.0.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"
46
46
  }
47
47
  }
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/matcher.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  * filters + orderBy + limit; anything else throws X_MATCHER_UNSUPPORTED, because
5
5
  * an honest refusal beats a silently wrong result set.
6
6
  */
7
+ import type { KindOf } from './column-kinds';
8
+ import { kindsOf } from './column-kinds';
7
9
  import { MatcherUnsupportedError, QueryNotPageableError } from './errors';
8
10
  import type { QueryShape } from './shape';
9
11
  import { compareRows, matchesFilters, totalOrder } from './shape';
@@ -58,7 +60,10 @@ export function match<TRow extends object>(
58
60
  const id = idOf(event.row, shape.entity);
59
61
  const index = rows.findIndex((row) => idOf(row, shape.entity) === id);
60
62
  const inSet = index >= 0;
61
- const belongs = event.op !== 'delete' && matchesFilters(event.row, shape.filters);
63
+ // The declared kinds of the relation's columns, resolved once for this event: every comparison
64
+ // below is `@ultimat3/entity`'s, asked under the kind Postgres would decide by.
65
+ const kindOf = kindsOf(shape.entity);
66
+ const belongs = event.op !== 'delete' && matchesFilters(event.row, shape.filters, kindOf);
62
67
 
63
68
  if (event.op === 'delete' || (inSet && !belongs)) {
64
69
  return inSet ? removeAt(shape, index, id, true, rows.length) : [];
@@ -81,14 +86,14 @@ export function match<TRow extends object>(
81
86
  return [{ kind: 'refill', from: 0 }];
82
87
  }
83
88
 
84
- if (!inSet) return insert(shape, rows, event.row);
89
+ if (!inSet) return insert(shape, rows, event.row, kindOf);
85
90
 
86
91
  // Present and still matching: a change to an ordering column is a move, not an update.
87
92
  const current = rows[index];
88
93
  const moved =
89
94
  shape.orderBy.length > 0 &&
90
95
  current !== undefined &&
91
- compareRows(event.row, current, shape.orderBy) !== 0;
96
+ compareRows(event.row, current, shape.orderBy, kindOf) !== 0;
92
97
  if (!moved) return [{ kind: 'update', position: index, row: event.row }];
93
98
 
94
99
  const without = [...rows.slice(0, index), ...rows.slice(index + 1)];
@@ -100,12 +105,12 @@ export function match<TRow extends object>(
100
105
  // window is `[b, c, d]`. Whether the moved row is still in the window is the server's answer
101
106
  // too, so the refill covers both.
102
107
  const wasFull = shape.limit !== null && rows.length >= shape.limit;
103
- if (wasFull && positionFor(shape, without, event.row) >= without.length) {
108
+ if (wasFull && positionFor(shape, without, event.row, kindOf) >= without.length) {
104
109
  return removeAt<TRow>(shape, index, id, true, rows.length);
105
110
  }
106
111
  return [
107
112
  ...removeAt<TRow>(shape, index, id, false, rows.length),
108
- ...insert(shape, without, event.row),
113
+ ...insert(shape, without, event.row, kindOf),
109
114
  ];
110
115
  }
111
116
 
@@ -113,8 +118,9 @@ function insert<TRow extends object>(
113
118
  shape: QueryShape,
114
119
  rows: readonly TRow[],
115
120
  row: TRow,
121
+ kindOf: KindOf,
116
122
  ): readonly Patch<TRow>[] {
117
- const position = positionFor(shape, rows, row);
123
+ const position = positionFor(shape, rows, row, kindOf);
118
124
  // Sorted past the end of a full window: the row exists but nobody sees it.
119
125
  if (shape.limit !== null && position >= shape.limit) return [];
120
126
  const patches: Patch<TRow>[] = [{ kind: 'add', position, row }];
@@ -186,10 +192,11 @@ export function positionFor<TRow extends object>(
186
192
  shape: QueryShape,
187
193
  rows: readonly TRow[],
188
194
  row: TRow,
195
+ kindOf: KindOf = kindsOf(shape.entity),
189
196
  ): number {
190
197
  if (shape.orderBy.length === 0) return rows.length;
191
198
  const order = totalOrder(shape.orderBy);
192
- const found = rows.findIndex((current) => compareRows(row, current, order) < 0);
199
+ const found = rows.findIndex((current) => compareRows(row, current, order, kindOf) < 0);
193
200
  return found === -1 ? rows.length : found;
194
201
  }
195
202
 
package/src/mcp-tool.ts CHANGED
@@ -6,12 +6,13 @@
6
6
 
7
7
  import type { Actor, Ctx } from '@ultimat3/core';
8
8
  import { isMcpExposed } from '@ultimat3/core';
9
- import type { JsonSchema } from '@ultimat3/schema';
10
- import { toMcpInputSchema } from '@ultimat3/schema';
9
+ import type { WireJsonSchema } from '@ultimat3/schema';
10
+ import { toWireSchema } from '@ultimat3/schema';
11
11
  import type { QueryPolicy } from './policy-gate';
12
12
  import type { AnyQuery } from './query';
13
13
  import { queryName, sourceFor } from './read';
14
14
  import { listQueries } from './registry';
15
+ import { readAnswer } from './single-answer';
15
16
 
16
17
  export interface QueryToolReadOptions {
17
18
  readonly ctx?: Ctx;
@@ -19,7 +20,18 @@ export interface QueryToolReadOptions {
19
20
  readonly actor?: Actor | null;
20
21
  }
21
22
 
22
- export interface QueryToolDescriptor {
23
+ /**
24
+ * What `read()` resolves to, decided by the declaration: a list read's rows, a `single: true`
25
+ * read's one row. Only a schema-erased query (`AnyQuery`, a mixed registry) answers the union —
26
+ * a KNOWN list read typed as "rows or a row" made `.map` on its own answer a compile error.
27
+ */
28
+ export type QueryToolAnswer<TSingle extends boolean> = [TSingle] extends [true]
29
+ ? object
30
+ : [TSingle] extends [false]
31
+ ? readonly object[]
32
+ : readonly object[] | object;
33
+
34
+ export interface QueryToolDescriptor<TSingle extends boolean = boolean> {
23
35
  /**
24
36
  * The export name VERBATIM, and identical to `query` below. `@ultimat3/mcp` serves a read
25
37
  * under `queryName(target)` and answers `tools/call` for nothing else, so a snake_cased
@@ -34,20 +46,28 @@ export interface QueryToolDescriptor {
34
46
  * is what makes "an MCP call cannot reach a different authz path" checkable.
35
47
  */
36
48
  readonly policy: QueryPolicy;
37
- readonly inputSchema: JsonSchema;
49
+ /** What `@ultimat3/mcp`'s `tools/list` serves for this read — `@ultimat3/schema`'s wire subset. */
50
+ readonly inputSchema: WireJsonSchema;
38
51
  /** Always false: a query reads. Drives the rate-limit bucket in @ultimat3/mcp. */
39
52
  readonly mutates: false;
40
- read(input: unknown, options?: QueryToolReadOptions): Promise<readonly object[]>;
53
+ /**
54
+ * The rows — or, for a `single: true` read, the one row itself and `X_NOT_FOUND` when there is
55
+ * none. The route's answer on both counts: an agent handed `[]` for a missing id reads "found,
56
+ * and empty" where the same read over HTTP said 404.
57
+ */
58
+ read(input: unknown, options?: QueryToolReadOptions): Promise<QueryToolAnswer<TSingle>>;
41
59
  }
42
60
 
43
- export function toQueryTool(target: AnyQuery): QueryToolDescriptor {
61
+ export function toQueryTool<TSingle extends boolean = boolean>(
62
+ target: AnyQuery & { readonly single?: TSingle },
63
+ ): QueryToolDescriptor<TSingle> {
44
64
  const name = queryName(target);
45
65
  return {
46
66
  name,
47
67
  description: target.mcp?.description ?? name,
48
68
  query: name,
49
69
  policy: target.policy,
50
- inputSchema: toMcpInputSchema(target.input),
70
+ inputSchema: toWireSchema(target.input),
51
71
  mutates: false,
52
72
  read: async (input, options = {}) => {
53
73
  // Executed without the cache tiers on purpose: an agent diffing two tool
@@ -56,7 +76,8 @@ export function toQueryTool(target: AnyQuery): QueryToolDescriptor {
56
76
  ...(options.ctx === undefined ? {} : { ctx: options.ctx }),
57
77
  ...(options.actor === undefined ? {} : { actor: options.actor }),
58
78
  });
59
- return source.execute();
79
+ // The declaration decides the shape, and `TSingle` is that declaration as a type.
80
+ return readAnswer(target, await source.execute()) as QueryToolAnswer<TSingle>;
60
81
  },
61
82
  };
62
83
  }
package/src/pagination.ts CHANGED
@@ -11,7 +11,9 @@
11
11
  */
12
12
  import { assert, decodeCursor, encodeCursor } from '@ultimat3/core';
13
13
  import type { StandardSchemaV1 } from '@ultimat3/schema';
14
+ import { kindsOf } from './column-kinds';
14
15
  import { reviveSortKey, serializeSortValue } from './cursor-value';
16
+ import { CursorInvalidError } from './errors';
15
17
  import { MAX_PAGE_SIZE } from './page-controls';
16
18
  import type { Query, SourceOptions } from './query';
17
19
  import { queryHash, queryName, sourceFor } from './query';
@@ -73,18 +75,24 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
73
75
  const base = await sourceFor(target, input, args);
74
76
  const shape = base.shape();
75
77
  // Revived to the types the columns hold, never left as the strings JSON handed back: a `Date`
76
- // key decoded as an ISO string reaches `compareValues` as text and is compared against the
77
- // row's own millisecond number, so page two matched nothing at all. See `cursor-value.ts`.
78
- const after: SeekKey | null =
79
- decoded === null ? null : seekFromCursor(decoded, shape.orderBy.length);
78
+ // key decoded as an ISO string was compared as text against the row's own instant, so page two
79
+ // matched nothing at all. See `cursor-value.ts`.
80
+ const resumed = decoded === null ? null : seekFromCursor(decoded, shape.orderBy.length);
81
+ const after = resumed?.seek ?? null;
82
+ const left = rowsLeft(shape.limit, resumed);
83
+ // The whole of a declared limit is already served: there is no row to ask the source for.
84
+ if (left === 0) return pageOf<TRow>([], null, false);
80
85
 
81
86
  // `MAX_PAGE_SIZE` lives in `page-controls.ts`: the route checks the same bound at the wire, as
82
87
  // a 400, before this `assert` — which is a 500 — can see the number.
83
- // Fetch one extra row: its presence *is* `hasNextPage`, with no count query.
84
- const window = args.first + 1;
88
+ // Fetch one extra row: its presence *is* `hasNextPage`, with no count query. Never past what a
89
+ // declared `.limit()` has left — `first` used to REPLACE that limit, so `?_first=10000` walked a
90
+ // "top 3" to the end of the table.
91
+ const window = Math.min(left, args.first + 1);
85
92
  const source: SqlSource<object> = base.seek === undefined ? base : base.seek(after, window);
86
93
  const executed = await source.execute();
87
- const scoped = base.seek === undefined ? inTotalOrder(executed, after, shape) : executed;
94
+ const scoped =
95
+ base.seek === undefined ? inTotalOrder(executed, after, shape).slice(0, window) : executed;
88
96
  // The source came from this query's own `sql()`, so its rows are TRow.
89
97
  const rows = scoped.slice(0, args.first) as unknown as readonly TRow[];
90
98
  const last = rows[rows.length - 1];
@@ -97,12 +105,41 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
97
105
  scope: hash,
98
106
  // The id rides TYPED at the tail of the key — core's `id` slot is a string — so a
99
107
  // numeric or bigint tiebreak compares as a number on the next page, not lexically.
100
- key: [...seek.key, seek.id].map(serializeSortValue),
108
+ // A limited read appends how much of its limit is now spent: a keyset cursor names a
109
+ // position and no count, and the count is the only thing that can end a "top N".
110
+ key: [
111
+ ...[...seek.key, seek.id].map(serializeSortValue),
112
+ ...(shape.limit === null ? [] : [(resumed?.served ?? 0) + rows.length]),
113
+ ],
101
114
  id: String(seek.id),
102
115
  });
103
116
  return pageOf(rows, nextCursor, scoped.length > args.first);
104
117
  }
105
118
 
119
+ /** A decoded cursor: where the page resumes, and how many rows of a declared limit are spent. */
120
+ interface Resumed {
121
+ readonly seek: SeekKey;
122
+ readonly served: number | undefined;
123
+ }
124
+
125
+ /**
126
+ * Rows a declared limit still allows, or no bound at all for a read that declares none.
127
+ *
128
+ * A cursor on a limited read MUST say how many rows came before it. One that does not was minted
129
+ * before the limit bounded the listing (or before the read declared one), and honouring it would
130
+ * start a fresh "top N" below the real one — so it is refused as any other cursor this read did
131
+ * not mint, with core's own instruction: request the first page again.
132
+ */
133
+ function rowsLeft(limit: number | null, resumed: Resumed | null): number {
134
+ if (limit === null) return Number.POSITIVE_INFINITY;
135
+ if (resumed === null) return Math.max(0, limit);
136
+ const { served } = resumed;
137
+ if (served === undefined || !Number.isInteger(served) || served < 1) {
138
+ throw new CursorInvalidError('it does not say how much of this read’s limit is already served');
139
+ }
140
+ return Math.max(0, limit - served);
141
+ }
142
+
106
143
  /**
107
144
  * The cursor names a POSITION in the ordering, so the fallback filters by that position — the
108
145
  * same comparison `Builder.seek()` pushes into SQL. Locating the cursor's row by id instead
@@ -128,20 +165,26 @@ function inTotalOrder(
128
165
  shape: QueryShape,
129
166
  ): readonly object[] {
130
167
  const keys = totalOrder(shape.orderBy);
131
- const ordered = [...rows].sort((left, right) => compareRows(left, right, keys));
168
+ const kindOf = kindsOf(shape.entity);
169
+ const ordered = [...rows].sort((left, right) => compareRows(left, right, keys, kindOf));
132
170
  if (after === null) return ordered;
133
- return ordered.filter((row) => isAfterKey(row, after, shape.orderBy));
171
+ return ordered.filter((row) => isAfterKey(row, after, shape.orderBy, kindOf));
134
172
  }
135
173
 
136
174
  /**
137
- * The seek a decoded cursor names. A cursor carrying one more key than the ordering has its typed
138
- * id at the tail; an older cursor, minted before the id rode there, falls back to the string slot.
175
+ * The seek a decoded cursor names. A cursor carrying more keys than the ordering has its typed id
176
+ * next, then — on a limited read — the count of rows served so far; an older cursor, minted before
177
+ * the id rode there, falls back to the string slot.
139
178
  */
140
179
  function seekFromCursor(
141
180
  decoded: { readonly key: readonly unknown[]; readonly id: string },
142
181
  width: number,
143
- ): SeekKey {
182
+ ): Resumed {
144
183
  const key = reviveSortKey(decoded.key);
145
- if (key.length !== width + 1) return { key, id: decoded.id };
146
- return { key: key.slice(0, width), id: key[width] };
184
+ if (key.length <= width) return { seek: { key, id: decoded.id }, served: undefined };
185
+ const count = key[width + 1];
186
+ return {
187
+ seek: { key: key.slice(0, width), id: key[width] },
188
+ served: typeof count === 'number' ? count : undefined,
189
+ };
147
190
  }
package/src/query.ts CHANGED
@@ -297,6 +297,8 @@ export interface Query<
297
297
  * so it lives here and not on the schema-erased `AnyQuery` view.
298
298
  */
299
299
  client(options: QueryClientOptions): QueryClientMethodOf<TInput, TRow, TSingle>;
300
+ /** Narrowed by the declaration: a list read's tool answers rows, a single read's one row. */
301
+ tool(): QueryToolDescriptor<TSingle>;
300
302
  }
301
303
 
302
304
  /** The fluent half of a query: lifted declaration plus one method per projection. */
package/src/search.ts CHANGED
@@ -6,12 +6,13 @@
6
6
  * syntax, and the tenant predicate is never optional.
7
7
  */
8
8
 
9
- import { assert, type Ctx, finiteOption } from '@ultimat3/core';
9
+ import { type Ctx, finiteOption } from '@ultimat3/core';
10
10
  import type { InferOutput, Shape, Simplify } from '@ultimat3/schema';
11
11
  import { t } from '@ultimat3/schema';
12
+ import { QueryInputInvalidError } from './errors';
12
13
  import type { QueryPolicy } from './policy-gate';
13
14
  import type { QueryCache, QueryMcp, QueryRateLimit } from './query';
14
- import { query } from './query';
15
+ import { query, queryName } from './query';
15
16
  import type { SeekKey } from './shape';
16
17
  import type { Builder, SqlSource, SqlText } from './source';
17
18
  import { from } from './source';
@@ -19,10 +20,9 @@ import { from } from './source';
19
20
  /**
20
21
  * What `@ultimat3/entity`'s `ReadBuilder` answers, crossed STRUCTURALLY.
21
22
  *
22
- * This package may import `@ultimat3/entity` (tier 2) and deliberately does not: nothing here does
23
- * today, so a real dependency would be a new edge in `package.json` and a new block in `bun.lock`
24
- * for four methods. Same trade `@ultimat3/db`'s `entity-shape.ts` makes one tier down, and the same
25
- * discipline — the shape is the contract, and a chain that does not satisfy it does not compile.
23
+ * Four methods are all this file reads, so the chain is named by its shape rather than by
24
+ * `ReadBuilder`'s type: an app may hand over a wrapper of its own, and one that does not satisfy
25
+ * the shape does not compile.
26
26
  */
27
27
  export interface SearchChain<Row extends object> {
28
28
  /** Appends the full-text predicate. The TERM, never a tsquery. */
@@ -85,8 +85,12 @@ const limitSchema = (max: number, fallback: number) =>
85
85
  * signed, plan-scoped string, and there is no way to mint one from the other here. Falling through
86
86
  * to `paginate`'s in-memory slice would cut inside the one page the provider fetched and report
87
87
  * `hasNextPage: false` at its edge — rows served on no page at all, which is the defect 12.0.0 spent
88
- * a release removing from the timestamp seek. So it is a refusal with the alternative in the `fix`:
89
- * page with the entity chain's own `.search(term).after(cursor)`, or raise this read's `limit`.
88
+ * a release removing from the timestamp seek. So it is a refusal naming the alternative: page with
89
+ * the entity chain's own `.search(term).after(cursor)`, or raise this read's `limit`.
90
+ *
91
+ * **Every refusal here is the CALLER's, so each is `X_INPUT_INVALID`** — a 400. A cursor, a window
92
+ * and a blank term all arrive on the request (`?_after=`, `?_first=`, `?q=%20`); raised with core's
93
+ * `assert` they were `X_INVARIANT`, a 500 that blamed the server and paged whoever watches it.
90
94
  *
91
95
  * **The refusal is on the rows that would be CUT, never on the window** (`As of 2026-08-26`). It
92
96
  * used to serve `first` rows, mint an `endCursor` and report `hasNextPage: true` — a connection
@@ -112,6 +116,8 @@ const limitSchema = (max: number, fallback: number) =>
112
116
  */
113
117
  const onePage = <Row extends object>(
114
118
  base: Builder<Row>,
119
+ /** The read's registered name — what the refusal's `fix:` asks `x queries describe` about. */
120
+ read: string,
115
121
  entity: string,
116
122
  /** Rows the chain was capped at — this read's `limit` input, and its whole page. */
117
123
  served: number,
@@ -123,14 +129,15 @@ const onePage = <Row extends object>(
123
129
  // resumed in", which is exactly true of a relevance ranking — and `total()` would totalize the
124
130
  // Builder, re-sorting that ranking by id for the same reason `seek` must not.
125
131
  seek: (after: SeekKey | null, window: number): SqlSource<Row> => {
126
- assert(
127
- after === null,
128
- `search of ${entity} serves one page: a relevance-filtered read has no cursor this layer can carry`,
129
- `db.${entity}.search(term).orderBy('<key>').after(cursor).page() # the entity chain pages this read — or raise its limit`,
130
- );
132
+ if (after !== null) {
133
+ throw new QueryInputInvalidError(
134
+ read,
135
+ `search of ${entity} serves one page: a relevance-filtered read has no cursor this layer can carry — raise this read's limit, or page the entity chain itself with .search(term).after(cursor)`,
136
+ );
137
+ }
131
138
  // `paginate` asks for `first + 1` — the extra row IS `hasNextPage` — so the window it names is
132
139
  // one wider than the page it will serve.
133
- return windowOf(base, entity, served, window - 1);
140
+ return windowOf(base, read, entity, served, window - 1);
134
141
  },
135
142
  });
136
143
 
@@ -142,11 +149,12 @@ const onePage = <Row extends object>(
142
149
  * rows or fewer, which is most searches. Refusing it on the declaration alone made the framework's
143
150
  * own defaults a 500.
144
151
  *
145
- * The `fix:` names `served` — the read's declared `limit` — and never the count that came back: a
152
+ * The refusal names `served` — the read's declared `limit` — and never the count that came back: a
146
153
  * window sized to today's result set breaks on the first row added to the corpus.
147
154
  */
148
155
  const windowOf = <Row extends object>(
149
156
  base: Builder<Row>,
157
+ read: string,
150
158
  entity: string,
151
159
  served: number,
152
160
  /** Rows `paginate` will serve. It fetched one more, to decide `hasNextPage`. */
@@ -161,11 +169,12 @@ const windowOf = <Row extends object>(
161
169
  // ranking, which `total()` would re-sort by id for the same reason `seek` must not.
162
170
  execute: async () => {
163
171
  const rows = await windowed.execute();
164
- assert(
165
- rows.length <= first,
166
- `search of ${entity} answered more than the ${String(first)} rows .page() asked for, and this read has no second page: the rest would be on no page at all`,
167
- `read.page(input, { first: ${String(served)} }) # widen the window to the read's whole page — or narrow the page: read({ q, limit: ${String(first)} })`,
168
- );
172
+ if (rows.length > first) {
173
+ throw new QueryInputInvalidError(
174
+ read,
175
+ `search of ${entity} answered more than the ${String(first)} rows .page() asked for, and this read has no second page: the rest would be on no page at all — widen the window to the read's whole page with read.page(input, { first: ${String(served)} }), or narrow the read with read({ q, limit: ${String(first)} })`,
176
+ );
177
+ }
169
178
  return rows;
170
179
  },
171
180
  };
@@ -190,28 +199,37 @@ export const search = <Row extends object, S extends Shape = Record<string, neve
190
199
  ),
191
200
  } as SearchShape<S>;
192
201
 
193
- return query({
202
+ const self = query({
194
203
  input: t.object(shape),
195
204
  policy: def.policy,
196
205
  live: false,
197
206
  ...(def.cache === undefined ? {} : { cache: def.cache }),
198
207
  ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
199
208
  ...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }),
200
- sql: (input, ctx) => {
209
+ // The return type is WRITTEN: the body names `self`, and an inferred one would make this
210
+ // declaration's type depend on itself.
211
+ sql: (input, ctx): SqlSource<Row> => {
201
212
  const parsed = input as SearchInput<S> & { readonly q: string; readonly limit: number };
202
213
  // Trimmed and refused HERE, before the chain exists: `websearch_to_tsquery('english', ' ')`
203
214
  // is a legal empty tsquery matching nothing, so a blank box would answer "no results" as if
204
215
  // it had searched. Saying so is the difference between an empty answer and an empty question.
205
216
  const term = parsed.q.trim();
206
- assert(
207
- term.length > 0,
208
- 'a search term of only whitespace is not a search',
209
- 'guard the input before calling: if (term.trim() === "") return [] — an empty box is not an empty result set',
210
- );
217
+ if (term.length === 0) {
218
+ throw new QueryInputInvalidError(
219
+ queryName(self),
220
+ 'q is only whitespace, which is not a search — guard the box before calling: an empty box is not an empty result set',
221
+ );
222
+ }
211
223
  const chain = def.in({ input: parsed, ctx });
212
224
  const name = chain.plan().entity;
213
225
  const rows = () => chain.search(term).limit(parsed.limit).all();
214
- return onePage(from<Row>(name, rows).raw('full-text search'), name, parsed.limit);
226
+ return onePage(
227
+ from<Row>(name, rows).raw('full-text search'),
228
+ queryName(self),
229
+ name,
230
+ parsed.limit,
231
+ );
215
232
  },
216
233
  });
234
+ return self;
217
235
  };
package/src/shape.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  /**
2
2
  * The read vocabulary shared by the matcher, the SQL sources, pagination and the
3
- * live descriptor. Types only plus two pure predicates — no I/O lives here.
3
+ * live descriptor: the types, and the two predicates that read a row. How two VALUES compare is
4
+ * `@ultimat3/entity`'s answer (`compareByKind`, `sameValueOfKind`) — no comparator lives here.
4
5
  */
6
+ import type { ColumnKind } from '@ultimat3/entity';
7
+ import { compareByKind, sameValueOfKind } from '@ultimat3/entity';
8
+ import type { KindOf } from './column-kinds';
5
9
  import { QueryNotPageableError } from './errors';
6
10
  import { columnOf } from './stable';
7
11
 
@@ -87,24 +91,33 @@ export function isNull(value: unknown): boolean {
87
91
  return value === null || value === undefined;
88
92
  }
89
93
 
90
- export function matchesFilters(row: object, filters: readonly Filter[]): boolean {
91
- return filters.every((filter) => matchesFilter(row, filter));
94
+ export function matchesFilters(row: object, filters: readonly Filter[], kindOf: KindOf): boolean {
95
+ return filters.every((filter) => matchesFilter(row, filter, kindOf));
92
96
  }
93
97
 
94
- export function matchesFilter(row: object, filter: Filter): boolean {
98
+ /**
99
+ * One filter against one row, as Postgres answers it for that column. `kindOf` is required: a
100
+ * comparison with no declared kind to decide by is how `'10'` in a `bigint()` column sorted before
101
+ * `'9'`, and an optional argument is one a caller forgets.
102
+ */
103
+ export function matchesFilter(row: object, filter: Filter, kindOf: KindOf): boolean {
95
104
  const actual = columnOf(row, filter.column);
105
+ const kind = kindOf(filter.column);
96
106
  switch (filter.op) {
97
107
  case '=':
98
- return same(actual, filter.value);
108
+ return equalOrBothNull(kind, actual, filter.value);
99
109
  case '!=':
100
- return !same(actual, filter.value);
110
+ return !equalOrBothNull(kind, actual, filter.value);
101
111
  case 'in':
102
- return Array.isArray(filter.value) && filter.value.some((item) => same(actual, item));
112
+ return (
113
+ Array.isArray(filter.value) &&
114
+ filter.value.some((item) => equalOrBothNull(kind, actual, item))
115
+ );
103
116
  case '>':
104
117
  case '>=':
105
118
  case '<':
106
119
  case '<=':
107
- return ordered(filter.op, actual, filter.value);
120
+ return ordered(filter.op, kind, actual, filter.value);
108
121
  default:
109
122
  return false;
110
123
  }
@@ -115,9 +128,14 @@ export function matchesFilter(row: object, filter: Filter): boolean {
115
128
  * ordering operator matches nothing here either. Only `=`, `!=` and `in` read NULL as a value —
116
129
  * and those are exactly the three `Builder.toSQL()` compiles to `is null` / `is distinct from`.
117
130
  */
118
- function ordered(op: '>' | '>=' | '<' | '<=', actual: unknown, value: unknown): boolean {
131
+ function ordered(
132
+ op: '>' | '>=' | '<' | '<=',
133
+ kind: ColumnKind | undefined,
134
+ actual: unknown,
135
+ value: unknown,
136
+ ): boolean {
119
137
  if (isNull(actual) || isNull(value)) return false;
120
- const result = compareValues(actual, value);
138
+ const result = compareByKind(kind, actual, value);
121
139
  switch (op) {
122
140
  case '>':
123
141
  return result > 0;
@@ -131,88 +149,37 @@ function ordered(op: '>' | '>=' | '<' | '<=', actual: unknown, value: unknown):
131
149
  }
132
150
 
133
151
  /**
134
- * Dates compare by instant, everything else by value. No coercion across types.
135
- *
136
- * NULL is greater than every value and equal to itself — Postgres' own sort rule, which is what
137
- * lets `Builder.toSQL()` write it down as `asc nulls last` / `desc nulls first` and mean this
138
- * function. Sorting only: a comparison *filter* against NULL matches nothing (`ordered`). Before
139
- * this, `null` sorted as the string `"null"`, so it landed between `"m"` and `"o"` in memory and
140
- * at the end in the database — the same page read two ways.
152
+ * This package's NULL rule around `@ultimat3/entity`'s equality: `=`, `!=` and `in` read NULL as a
153
+ * value, so two absences are equal and an absence equals nothing else. What two PRESENT values
154
+ * are to each other is not decided here — `sameValueOfKind` answers by the column's kind.
141
155
  */
142
- export function compareValues(a: unknown, b: unknown): number {
143
- if (isNull(a) || isNull(b)) return isNull(a) ? (isNull(b) ? 0 : 1) : -1;
144
- const left = normalize(a);
145
- const right = normalize(b);
146
- if (isNumeric(left) && isNumeric(right)) return compareNumeric(left, right);
147
- const l = String(left);
148
- const r = String(right);
149
- return l < r ? -1 : l > r ? 1 : 0;
150
- }
151
-
152
- function isNumeric(value: unknown): value is number | bigint {
153
- return typeof value === 'number' || typeof value === 'bigint';
156
+ function equalOrBothNull(kind: ColumnKind | undefined, a: unknown, b: unknown): boolean {
157
+ if (isNull(a) || isNull(b)) return isNull(a) && isNull(b);
158
+ return sameValueOfKind(kind, a, b);
154
159
  }
155
160
 
156
161
  /**
157
- * Numbers and bigints, in one order, because **Postgres orders them in one order**.
162
+ * Row ordering under an `orderBy` list. Stable, and total when an id key is last.
158
163
  *
159
- * `bigint` is a first-class `ColumnKind` — the physical type of every `<p>_minor` column — and
160
- * `@ultimat3/entity`'s `count-by.ts` lists it as groupable, so these values do reach the
161
- * comparator. The old numeric fast path was `typeof left === 'number' && typeof right ===
162
- * 'number'` alone, so a bigint fell through to `String(left) < String(right)`:
163
- * `compareValues(9n, 10n)` answered `1` and a sort came out `["10", "100", "9"]`, which means the
164
- * in-memory source, the live matcher and the seek fallback all disagreed with the database on any
165
- * bigint-ordered read — including page two of one.
166
- *
167
- * A bigint pair never subtracts: the difference is exact but the return type is a `number`. A
168
- * mixed pair goes through `BigInt` when the number is whole, so a value past 2^53 keeps its exact
169
- * place; a fractional number cannot equal a bigint, so comparing it as a float is enough to place
170
- * it. `Number.isInteger` is false for `NaN` and `±Infinity`, which is what keeps them out of the
171
- * `BigInt()` call that would throw on them.
164
+ * Each key is ordered by `@ultimat3/entity`'s `compareByKind` under the column's declared kind —
165
+ * the one JS statement of how Postgres orders a column. NULL is the largest value there and equal
166
+ * to itself, which is what lets `Builder.toSQL()` write `asc nulls last` / `desc nulls first` and
167
+ * mean this function. This package used to keep a second comparator that decided by `typeof`: a
168
+ * `bigint()` or `decimal()` row is decimal TEXT, so `'10'` sorted before `'9'` in the in-memory
169
+ * source, the live matcher and the seek fallback while the database answered the other way.
172
170
  */
173
- function compareNumeric(left: number | bigint, right: number | bigint): number {
174
- if (typeof left === 'number' && typeof right === 'number') return left - right;
175
- if (typeof left === 'bigint' && typeof right === 'bigint') return sign(left, right);
176
- // Widened rather than negated: `-sign(a, b)` answers `-0` for a tie, and `-0` is a different
177
- // value from `0` to `Object.is` and to a caller writing `=== 0`.
178
- return typeof left === 'bigint'
179
- ? mixed(left, right as number)
180
- : -mixed(right as bigint, left) || 0;
181
- }
182
-
183
- /** A bigint against a number, in that order. Whole numbers go through `BigInt` so a value past
184
- * 2^53 keeps its exact place; a fractional number can never equal a bigint, so comparing it as a
185
- * float is enough to place it. `Number.isInteger` is false for `NaN` and `±Infinity`, which is
186
- * what keeps them out of the `BigInt()` call that would throw on them. */
187
- function mixed(big: bigint, other: number): number {
188
- return Number.isInteger(other) ? sign(big, BigInt(other)) : sign(Number(big), other);
189
- }
190
-
191
- function sign<T extends number | bigint>(left: T, right: T): number {
192
- return left < right ? -1 : left > right ? 1 : 0;
193
- }
194
-
195
- /** A `Date` compares by instant, so two of them order by time and never by their ISO text. */
196
- function normalize(value: unknown): unknown {
197
- return value instanceof Date ? value.getTime() : value;
198
- }
199
-
200
- /**
201
- * Equality as Postgres answers it. A number and a bigint are ONE family — an `int8` arrives as a
202
- * `bigint` from one driver while a filter value is usually a `number` literal, and `5n = 5` is
203
- * true there — so the type check that keeps `'5'` from equalling `5` treats them as one.
204
- */
205
- function same(a: unknown, b: unknown): boolean {
206
- if (isNull(a) || isNull(b)) return isNull(a) && isNull(b);
207
- return compareValues(a, b) === 0 && family(normalize(a)) === family(normalize(b));
208
- }
209
-
210
- const family = (value: unknown): string => (isNumeric(value) ? 'numeric' : typeof value);
211
-
212
- /** Row ordering under an `orderBy` list. Stable, and total when an id key is last. */
213
- export function compareRows(a: object, b: object, orderBy: readonly OrderKey[]): number {
171
+ export function compareRows(
172
+ a: object,
173
+ b: object,
174
+ orderBy: readonly OrderKey[],
175
+ kindOf: KindOf,
176
+ ): number {
214
177
  for (const key of orderBy) {
215
- const result = compareValues(columnOf(a, key.column), columnOf(b, key.column));
178
+ const result = compareByKind(
179
+ kindOf(key.column),
180
+ columnOf(a, key.column),
181
+ columnOf(b, key.column),
182
+ );
216
183
  if (result !== 0) return key.direction === 'asc' ? result : -result;
217
184
  }
218
185
  return 0;
@@ -0,0 +1,22 @@
1
+ // Single responsibility: what a `single: true` read ANSWERS — its first row, or `X_NOT_FOUND`.
2
+ // One rule for the route, the read's own tool and `@ultimat3/mcp`'s served tool: three copies of
3
+ // "take the first row, refuse when there is none" are three places for one surface to drift.
4
+
5
+ import { QueryRowNotFoundError } from './errors';
6
+ import type { AnyQuery } from './query';
7
+ import { queryName } from './read';
8
+
9
+ /** The first row — what every in-process `[0]` of the same read takes — or `X_NOT_FOUND`. */
10
+ export function oneRowOf<TRow extends object>(name: string, rows: readonly TRow[]): TRow {
11
+ const [row] = rows;
12
+ if (row === undefined) throw new QueryRowNotFoundError(name);
13
+ return row;
14
+ }
15
+
16
+ /**
17
+ * A read's answer on a surface that hands back one value: the rows of a list read, the one row of
18
+ * a `single: true` read. `rows` is what the read's own source executed to.
19
+ */
20
+ export function readAnswer(target: AnyQuery, rows: readonly object[]): readonly object[] | object {
21
+ return target.single === true ? oneRowOf(queryName(target), rows) : rows;
22
+ }
package/src/source.ts CHANGED
@@ -4,9 +4,13 @@
4
4
  * app's `sql:` returns `from()` over an `@ultimat3/entity` repo, and a source only has to
5
5
  * answer these four questions.
6
6
  */
7
+
8
+ import { compareByKind } from '@ultimat3/entity';
9
+ import type { KindOf } from './column-kinds';
10
+ import { kindsOf } from './column-kinds';
7
11
  import { QueryColumnUnselectedError } from './errors';
8
12
  import type { Filter, FilterOp, OrderKey, QueryShape, SeekKey } from './shape';
9
- import { compareRows, compareValues, isNull, matchesFilters, totalOrder } from './shape';
13
+ import { compareRows, isNull, matchesFilters, totalOrder } from './shape';
10
14
  import { columnOf } from './stable';
11
15
 
12
16
  /** Nothing matches. `in ()` is a syntax error in Postgres, so an empty set needs a constant. */
@@ -97,8 +101,13 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
97
101
  return this.derive({ unsupported: [...this.unsupported, feature] });
98
102
  }
99
103
 
104
+ /**
105
+ * The window is the SMALLER of the two limits. It used to replace a declared `.limit()`, so a
106
+ * page wider than a "top N" served rows the read itself never would.
107
+ */
100
108
  seek(after: SeekKey | null, limit: number): Builder<TRow> {
101
- return this.derive({ after, rowLimit: limit, totalized: true });
109
+ const rowLimit = this.rowLimit === null ? limit : Math.min(this.rowLimit, limit);
110
+ return this.derive({ after, rowLimit, totalized: true });
102
111
  }
103
112
 
104
113
  /**
@@ -134,14 +143,15 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
134
143
  async execute(): Promise<readonly TRow[]> {
135
144
  const source = typeof this.rows === 'function' ? await this.rows() : this.rows;
136
145
  this.assertCarried(source);
137
- let result = source.filter((row) => matchesFilters(row, this.filters));
146
+ const kindOf = kindsOf(this.entity);
147
+ let result = source.filter((row) => matchesFilters(row, this.filters, kindOf));
138
148
  const keys = this.servedOrder();
139
149
  if (keys.length > 0) {
140
- result = [...result].sort((a, b) => compareRows(a, b, keys));
150
+ result = [...result].sort((a, b) => compareRows(a, b, keys, kindOf));
141
151
  }
142
152
  if (this.after !== null) {
143
153
  const cut = this.after;
144
- result = result.filter((row) => isAfterKey(row, cut, this.order));
154
+ result = result.filter((row) => isAfterKey(row, cut, this.order, kindOf));
145
155
  }
146
156
  return this.rowLimit === null ? result : result.slice(0, this.rowLimit);
147
157
  }
@@ -264,7 +274,7 @@ function filterClause(filter: Filter, slot: Slot): string {
264
274
 
265
275
  /**
266
276
  * NULL's place in the ordering, written down rather than inherited. Postgres already defaults
267
- * to `nulls last` under `asc` and `nulls first` under `desc` — that is the rule `compareValues`
277
+ * to `nulls last` under `asc` and `nulls first` under `desc` — that is the rule `compareRows`
268
278
  * implements, so the in-memory sort, the live matcher and `seekClause` below can only agree
269
279
  * with it. Saying it out loud is what keeps a driver whose default differs from re-opening the
270
280
  * divergence, and it is what an agent reads when a nullable sort key surprises it.
@@ -312,11 +322,16 @@ interface BuilderState {
312
322
  * pagination needs the same answer when a source cannot push the seek down — a second definition
313
323
  * of "after" is how one path skips a row the other returns.
314
324
  */
315
- export function isAfterKey(row: object, cursor: SeekKey, order: readonly OrderKey[]): boolean {
325
+ export function isAfterKey(
326
+ row: object,
327
+ cursor: SeekKey,
328
+ order: readonly OrderKey[],
329
+ kindOf: KindOf,
330
+ ): boolean {
316
331
  for (const [index, key] of order.entries()) {
317
- const result = compareValues(columnOf(row, key.column), cursor.key[index]);
332
+ const result = compareByKind(kindOf(key.column), columnOf(row, key.column), cursor.key[index]);
318
333
  const signed = key.direction === 'asc' ? result : -result;
319
334
  if (signed !== 0) return signed > 0;
320
335
  }
321
- return compareValues(columnOf(row, 'id'), cursor.id) > 0;
336
+ return compareByKind(kindOf('id'), columnOf(row, 'id'), cursor.id) > 0;
322
337
  }