@ultimat3/query 22.15.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +38 -6
- package/README.md +67 -7
- package/package.json +8 -7
- package/src/client-scale-pins.ts +78 -0
- 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/live.ts +3 -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/sealed-shape.ts +27 -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/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. */
|
|
@@ -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 {
|
|
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
|
};
|