@ultimat3/query 22.15.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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. */
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A live read may not FILTER or ORDER on a sealed column, refused at subscribe. The matcher places
3
+ * a change from the row the change feed carries, and that row names no sealed column — the log
4
+ * holds the stored string, which `@ultimat3/realtime` drops rather than forwards. So a window keyed
5
+ * on one could be read once and never patched: every later change would miss the filter or land
6
+ * at no position, silently.
7
+ */
8
+
9
+ import { entityForTable, sealedFields } from '@ultimat3/entity';
10
+ import { MatcherUnsupportedError } from './errors';
11
+ import type { QueryShape } from './shape';
12
+
13
+ export function assertNoSealedKey(name: string, shape: QueryShape): void {
14
+ // A relation no entity declares has no sealed column to name: `from('report', rows)` over a
15
+ // projection is common, and it is not this rule's to refuse.
16
+ const entity = entityForTable(shape.entity);
17
+ if (entity === undefined) return;
18
+ const sealed = new Set(sealedFields(entity).map((field) => field.property));
19
+ if (sealed.size === 0) return;
20
+ const keyed = [...shape.filters, ...shape.orderBy].find((key) => sealed.has(key.column));
21
+ if (keyed === undefined) return;
22
+ // The column's NAME, never the filter's value: that value is the secret being matched on.
23
+ throw new MatcherUnsupportedError(
24
+ name,
25
+ `the sealed column "${keyed.column}" in a filter or an order — a change row carries no sealed column`,
26
+ );
27
+ }
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
  };