@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 +24 -6
- package/README.md +46 -7
- package/package.json +7 -7
- package/src/client.ts +15 -1
- package/src/column-kinds.ts +28 -0
- package/src/cursor-value.ts +3 -3
- package/src/facade.ts +1 -1
- package/src/http.ts +5 -9
- package/src/index.ts +11 -7
- package/src/input-shape.ts +20 -0
- package/src/matcher.ts +14 -7
- package/src/mcp-tool.ts +29 -8
- package/src/pagination.ts +58 -15
- package/src/query.ts +2 -0
- package/src/search.ts +46 -28
- package/src/shape.ts +52 -85
- package/src/single-answer.ts +22 -0
- package/src/source.ts +24 -9
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-
|
|
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
|
-
|
|
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
|
-
-
|
|
167
|
-
|
|
168
|
-
-
|
|
169
|
-
`
|
|
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()
|
|
72
|
-
|
|
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
|
|
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
|
|
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 `
|
|
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
|
|
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": "
|
|
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": "
|
|
41
|
-
"@ultimat3/core": "
|
|
42
|
-
"@ultimat3/entity": "
|
|
43
|
-
"@ultimat3/http": "
|
|
44
|
-
"@ultimat3/policy": "
|
|
45
|
-
"@ultimat3/schema": "
|
|
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,
|
|
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
|
+
}
|
package/src/cursor-value.ts
CHANGED
|
@@ -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…"`
|
|
4
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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';
|
package/src/input-shape.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
10
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
77
|
-
//
|
|
78
|
-
const
|
|
79
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
138
|
-
*
|
|
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
|
-
):
|
|
182
|
+
): Resumed {
|
|
144
183
|
const key = reviveSortKey(decoded.key);
|
|
145
|
-
if (key.length
|
|
146
|
-
|
|
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 {
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
89
|
-
*
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
108
|
+
return equalOrBothNull(kind, actual, filter.value);
|
|
99
109
|
case '!=':
|
|
100
|
-
return !
|
|
110
|
+
return !equalOrBothNull(kind, actual, filter.value);
|
|
101
111
|
case 'in':
|
|
102
|
-
return
|
|
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(
|
|
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 =
|
|
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
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
|
-
|
|
143
|
-
if (isNull(a) || isNull(b)) return isNull(a)
|
|
144
|
-
|
|
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
|
-
*
|
|
162
|
+
* Row ordering under an `orderBy` list. Stable, and total when an id key is last.
|
|
158
163
|
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* `
|
|
164
|
-
*
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
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(
|
|
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 =
|
|
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
|
|
336
|
+
return compareByKind(kindOf('id'), columnOf(row, 'id'), cursor.id) > 0;
|
|
322
337
|
}
|