@ultimat3/query 23.0.0 → 25.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 +75 -37
- package/README.md +147 -32
- package/package.json +8 -8
- package/src/audit-errors.ts +43 -0
- package/src/audit-gate.ts +177 -0
- package/src/cache.ts +4 -4
- package/src/client.ts +19 -6
- package/src/column-kinds.ts +28 -0
- package/src/cursor-value.ts +3 -3
- package/src/errors.ts +6 -3
- package/src/facade.ts +6 -7
- package/src/http.ts +43 -33
- package/src/index.ts +37 -61
- package/src/input-shape.ts +21 -1
- package/src/live.ts +19 -3
- package/src/matcher.ts +14 -7
- package/src/openapi.ts +24 -17
- package/src/page-controls.ts +5 -9
- package/src/page-keys.ts +1 -1
- package/src/pagination.ts +88 -46
- package/src/policy-gate.ts +17 -49
- package/src/query.ts +22 -56
- package/src/rate-limit-gate.ts +71 -0
- package/src/read.ts +97 -14
- package/src/record-answer.ts +9 -5
- package/src/registry.ts +1 -1
- package/src/search.ts +53 -35
- package/src/shape.ts +52 -85
- package/src/single-answer.ts +22 -0
- package/src/source.ts +26 -11
- package/src/sql.ts +3 -3
- package/src/stable.ts +0 -3
- package/src/subscribes.ts +3 -3
- package/src/type-pins.ts +62 -0
- package/src/deprecation.ts +0 -76
- package/src/mcp-tool.ts +0 -81
- package/src/naming.ts +0 -22
package/src/read.ts
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
import type { Ctx } from '@ultimat3/core';
|
|
9
9
|
import {
|
|
10
|
+
actorOf,
|
|
10
11
|
anonymousActor,
|
|
11
12
|
assert,
|
|
12
|
-
|
|
13
|
+
ctxOf,
|
|
13
14
|
finiteOption,
|
|
14
15
|
logger,
|
|
15
16
|
runWithContext,
|
|
@@ -20,6 +21,8 @@ import {
|
|
|
20
21
|
} from '@ultimat3/core';
|
|
21
22
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
22
23
|
import { formatPath, validateAsync } from '@ultimat3/schema';
|
|
24
|
+
import type { ReadAudit, ReadTrace } from './audit-gate';
|
|
25
|
+
import { audited, auditedBuild, readAuditFor } from './audit-gate';
|
|
23
26
|
import {
|
|
24
27
|
cacheKeyFor,
|
|
25
28
|
DEFAULT_READ_CACHE_TTL_MS,
|
|
@@ -29,8 +32,9 @@ import {
|
|
|
29
32
|
readThrough,
|
|
30
33
|
} from './cache';
|
|
31
34
|
import { QueryForeignError, QueryInputInvalidError, QueryUnregisteredError } from './errors';
|
|
32
|
-
import {
|
|
35
|
+
import { guardQuery, guardQueryBeforeInput } from './policy-gate';
|
|
33
36
|
import type { AnyQuery, AnyQueryDef, Query, QueryOptions, SourceOptions } from './query';
|
|
37
|
+
import { spendReadLimit } from './rate-limit-gate';
|
|
34
38
|
import type { SqlSource } from './source';
|
|
35
39
|
|
|
36
40
|
/**
|
|
@@ -84,7 +88,12 @@ export function runQuery(
|
|
|
84
88
|
raw: unknown,
|
|
85
89
|
options: QueryOptions = {},
|
|
86
90
|
): Promise<readonly object[]> {
|
|
87
|
-
return asActor(options, (ctx) =>
|
|
91
|
+
return asActor(options, (ctx) => {
|
|
92
|
+
const audit = auditFor(target, ctx, options);
|
|
93
|
+
return audit === null
|
|
94
|
+
? readRows(target, raw, ctx, options, null)
|
|
95
|
+
: audited(audit, () => readRows(target, raw, ctx, options, audit.trace));
|
|
96
|
+
});
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
/**
|
|
@@ -97,7 +106,45 @@ export function sourceFor(
|
|
|
97
106
|
raw: unknown,
|
|
98
107
|
options: SourceOptions = {},
|
|
99
108
|
): Promise<SqlSource<object>> {
|
|
100
|
-
return asActor(options, (ctx) =>
|
|
109
|
+
return asActor(options, (ctx) => {
|
|
110
|
+
const audit = auditFor(target, ctx, options);
|
|
111
|
+
// A source handed out is executed by its caller — `@ultimat3/mcp`'s served tool among them —
|
|
112
|
+
// so an audited read records per `execute()`, and a build that refuses records here.
|
|
113
|
+
return audit === null
|
|
114
|
+
? buildSource(target, raw, ctx, options, null)
|
|
115
|
+
: auditedBuild(audit, () => buildSource(target, raw, ctx, options, audit.trace));
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* One whole call over a built source, for a projection inside this package whose call is more
|
|
121
|
+
* than one `execute()` — `paginate`, which may answer a spent limit without executing at all. The
|
|
122
|
+
* record covers the build and `use` together, so the call is recorded exactly once either way.
|
|
123
|
+
*/
|
|
124
|
+
export function readCall<T>(
|
|
125
|
+
target: AnyQuery,
|
|
126
|
+
raw: unknown,
|
|
127
|
+
options: SourceOptions,
|
|
128
|
+
use: (source: SqlSource<object>) => Promise<T>,
|
|
129
|
+
): Promise<T> {
|
|
130
|
+
return asActor(options, async (ctx) => {
|
|
131
|
+
const audit = auditFor(target, ctx, options);
|
|
132
|
+
if (audit === null) return use(await buildSource(target, raw, ctx, options, null));
|
|
133
|
+
return audited(audit, async () =>
|
|
134
|
+
use(await buildSource(target, raw, ctx, options, audit.trace)),
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Resolved before the parse, so an audited read with no sink refuses while it has read nothing. */
|
|
140
|
+
function auditFor(target: AnyQuery, ctx: Ctx, options: SourceOptions): ReadAudit | null {
|
|
141
|
+
return readAuditFor(
|
|
142
|
+
defOf(target).audit,
|
|
143
|
+
options.unenforced,
|
|
144
|
+
queryName(target),
|
|
145
|
+
ctx,
|
|
146
|
+
options.surface ?? 'server',
|
|
147
|
+
);
|
|
101
148
|
}
|
|
102
149
|
|
|
103
150
|
/**
|
|
@@ -118,7 +165,7 @@ function asActor<T>(options: QueryOptions, run: (ctx: Ctx) => Promise<T>): Promi
|
|
|
118
165
|
const inChild = (): Promise<T> => run(useContext());
|
|
119
166
|
const base = options.ctx ?? tryUseContext();
|
|
120
167
|
return base === undefined
|
|
121
|
-
? runWithContext(
|
|
168
|
+
? runWithContext(ctxOf(patch), inChild)
|
|
122
169
|
: runWithContext(base, () => withChildContext(patch, inChild));
|
|
123
170
|
}
|
|
124
171
|
|
|
@@ -136,6 +183,7 @@ function readRows(
|
|
|
136
183
|
raw: unknown,
|
|
137
184
|
ctx: Ctx,
|
|
138
185
|
options: QueryOptions,
|
|
186
|
+
trace: ReadTrace | null,
|
|
139
187
|
): Promise<readonly object[]> {
|
|
140
188
|
const name = queryName(target);
|
|
141
189
|
return withSpan(`query.${name}`, async (span) => {
|
|
@@ -150,7 +198,7 @@ function readRows(
|
|
|
150
198
|
// read is filled — and which of the two a slow read took is the first thing to ask.
|
|
151
199
|
'ultimate.cached': defOf(target).cache !== undefined,
|
|
152
200
|
});
|
|
153
|
-
const rows = await readRowsIn(target, raw, ctx, options);
|
|
201
|
+
const rows = await readRowsIn(target, raw, ctx, options, trace);
|
|
154
202
|
span.setAttribute('ultimate.rows', rows.length);
|
|
155
203
|
return rows;
|
|
156
204
|
});
|
|
@@ -161,11 +209,18 @@ async function readRowsIn(
|
|
|
161
209
|
raw: unknown,
|
|
162
210
|
ctx: Ctx,
|
|
163
211
|
options: QueryOptions,
|
|
212
|
+
trace: ReadTrace | null,
|
|
164
213
|
): Promise<readonly object[]> {
|
|
165
214
|
const def = defOf(target);
|
|
166
215
|
const name = queryName(target);
|
|
167
|
-
const source = await buildSource(target, raw, ctx, options);
|
|
168
|
-
|
|
216
|
+
const source = await buildSource(target, raw, ctx, options, trace);
|
|
217
|
+
// Whether THIS call executed, for the audit record: a memo or tier hit never calls `read`, and
|
|
218
|
+
// its record says so (`replayed: true`) rather than being skipped.
|
|
219
|
+
if (trace !== null) trace.replayed = true;
|
|
220
|
+
const read = (): Promise<readonly object[]> => {
|
|
221
|
+
if (trace !== null) trace.replayed = false;
|
|
222
|
+
return source.execute();
|
|
223
|
+
};
|
|
169
224
|
// The source came from this query's own `sql()`, so its rows are TRow throughout —
|
|
170
225
|
// which is what the typed overload above states, and this body never has to assert.
|
|
171
226
|
const tags = def.cache?.tags ?? [];
|
|
@@ -201,6 +256,7 @@ async function buildSource(
|
|
|
201
256
|
raw: unknown,
|
|
202
257
|
ctx: Ctx,
|
|
203
258
|
options: SourceOptions,
|
|
259
|
+
trace: ReadTrace | null,
|
|
204
260
|
): Promise<SqlSource<object>> {
|
|
205
261
|
const def = defOf(target);
|
|
206
262
|
const name = queryName(target);
|
|
@@ -208,15 +264,36 @@ async function buildSource(
|
|
|
208
264
|
// The actor half of the policy first, exactly as `invoke` does it: a reader refused whatever
|
|
209
265
|
// they send learns nothing about this read's input schema from an `X_INPUT_INVALID`.
|
|
210
266
|
if (unenforced === undefined) {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
267
|
+
try {
|
|
268
|
+
guardQueryBeforeInput(
|
|
269
|
+
def.policy,
|
|
270
|
+
{ actor: actorOf(ctx), ctx, query: name },
|
|
271
|
+
options.surface ?? 'server',
|
|
272
|
+
);
|
|
273
|
+
} catch (denial) {
|
|
274
|
+
// The record still names what was attempted, as an action's does: the denial is already
|
|
275
|
+
// decided, so parsing now tells the CALLER nothing, and an unparseable payload is no input.
|
|
276
|
+
if (trace !== null) trace.input = await parsedOrNothing(def.input, raw);
|
|
277
|
+
throw denial;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// The ONE place a read's declared `rateLimit:` is spent for every surface it runs on — the
|
|
281
|
+
// route, a paged read, the MCP tool. After the actor half (a refused reader learns that, not a
|
|
282
|
+
// 429), before the parse and `sql()`, which are the work being limited. An unenforced build has
|
|
283
|
+
// no caller to charge: the shared live window spends per subscriber (`spendQueryLimit`).
|
|
284
|
+
if (unenforced === undefined) {
|
|
285
|
+
await spendReadLimit(name, def.rateLimit, {
|
|
286
|
+
actor: ctx.actor,
|
|
287
|
+
surface: options.surface ?? 'server',
|
|
288
|
+
clientAddress: options.clientAddress,
|
|
289
|
+
onRateLimit: options.onRateLimit,
|
|
290
|
+
nowMs: ctx.now().getTime(),
|
|
291
|
+
});
|
|
216
292
|
}
|
|
217
293
|
const input = await validate(def.input, raw, name);
|
|
294
|
+
if (trace !== null) trace.input = input;
|
|
218
295
|
if (unenforced === undefined) {
|
|
219
|
-
|
|
296
|
+
guardQuery(
|
|
220
297
|
def.policy,
|
|
221
298
|
{ actor: actorOf(ctx), input, ctx, query: name },
|
|
222
299
|
options.surface ?? 'server',
|
|
@@ -244,6 +321,12 @@ async function buildSource(
|
|
|
244
321
|
return options.surface === 'live' && source.total !== undefined ? source.total() : source;
|
|
245
322
|
}
|
|
246
323
|
|
|
324
|
+
/** The parse without the refusal — only for the record of a read already refused on other grounds. */
|
|
325
|
+
async function parsedOrNothing(schema: StandardSchemaV1, raw: unknown): Promise<unknown> {
|
|
326
|
+
const result = await validateAsync(schema, raw);
|
|
327
|
+
return result.issues === undefined ? result.value : undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
247
330
|
async function validate(schema: StandardSchemaV1, raw: unknown, name: string): Promise<unknown> {
|
|
248
331
|
const result = await validateAsync(schema, raw);
|
|
249
332
|
if (result.issues !== undefined) {
|
package/src/record-answer.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import type { RecordRows, Row } from '@ultimat3/core';
|
|
11
11
|
import { encodeRecordEnvelope, RECORDS_HEADER } from '@ultimat3/core';
|
|
12
12
|
import { hasEntityRows, rowsOf } from '@ultimat3/entity';
|
|
13
|
-
import {
|
|
13
|
+
import { jsonResponse } from '@ultimat3/http';
|
|
14
14
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
15
15
|
import type { Page } from './pagination';
|
|
16
16
|
|
|
@@ -23,10 +23,12 @@ export type RecordAnswer = (answer: readonly object[] | Page<object>) => Respons
|
|
|
23
23
|
* `Page` — is the route's, not the declaration's.
|
|
24
24
|
*/
|
|
25
25
|
export function recordAnswerFor(rows: StandardSchemaV1 | undefined): RecordAnswer {
|
|
26
|
-
if (!answersRecords(rows)) return (answer) =>
|
|
26
|
+
if (!answersRecords(rows)) return (answer) => jsonResponse(answer);
|
|
27
27
|
return (answer) => {
|
|
28
28
|
const records = collect(rows, isPage(answer) ? answer.rows : answer);
|
|
29
|
-
return
|
|
29
|
+
return jsonResponse(encodeRecordEnvelope(answer, records), {
|
|
30
|
+
headers: { [RECORDS_HEADER]: '1' },
|
|
31
|
+
});
|
|
30
32
|
};
|
|
31
33
|
}
|
|
32
34
|
|
|
@@ -36,9 +38,11 @@ export function recordAnswerFor(rows: StandardSchemaV1 | undefined): RecordAnswe
|
|
|
36
38
|
* answers for a single output, so the transport unwraps both the same way.
|
|
37
39
|
*/
|
|
38
40
|
export function recordRowAnswerFor(rows: StandardSchemaV1 | undefined): (row: object) => Response {
|
|
39
|
-
if (!answersRecords(rows)) return (row) =>
|
|
41
|
+
if (!answersRecords(rows)) return (row) => jsonResponse(row);
|
|
40
42
|
return (row) =>
|
|
41
|
-
|
|
43
|
+
jsonResponse(encodeRecordEnvelope(row, collect(rows, [row])), {
|
|
44
|
+
headers: { [RECORDS_HEADER]: '1' },
|
|
45
|
+
});
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
/** The one predicate the route and the OpenAPI projection both ask: can this read answer records? */
|
package/src/registry.ts
CHANGED
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, type McpExposureDeclaration } 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
|
-
import type { QueryCache,
|
|
14
|
-
import { query } from './query';
|
|
14
|
+
import type { QueryCache, QueryRateLimit } 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. */
|
|
@@ -60,7 +60,7 @@ export interface SearchDef<S extends Shape, Row extends object> {
|
|
|
60
60
|
readonly termMax?: number;
|
|
61
61
|
readonly page?: SearchPage;
|
|
62
62
|
readonly cache?: QueryCache;
|
|
63
|
-
readonly mcp?:
|
|
63
|
+
readonly mcp?: McpExposureDeclaration;
|
|
64
64
|
readonly rateLimit?: QueryRateLimit;
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -84,12 +84,16 @@ const limitSchema = (max: number, fallback: number) =>
|
|
|
84
84
|
* `SqlSource` is handed a `SeekKey` (the previous page's sort VALUES) and the chain wants its own
|
|
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
|
-
* `
|
|
88
|
-
* a release removing from the timestamp seek. So it is a refusal
|
|
89
|
-
*
|
|
87
|
+
* `hasMore: 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 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
|
-
* used to serve `first` rows, mint an `
|
|
96
|
+
* used to serve `first` rows, mint an `nextCursor` and report `hasMore: true` — a connection
|
|
93
97
|
* protocol saying "call me again with this" for a call the cursor assert is guaranteed to throw on.
|
|
94
98
|
* A first repair demanded `first >= limit` and refused the window itself, which refuses the
|
|
95
99
|
* framework's OWN default pair: `limit` defaults to 20 and `first` arrives from a client
|
|
@@ -99,7 +103,7 @@ const limitSchema = (max: number, fallback: number) =>
|
|
|
99
103
|
*
|
|
100
104
|
* So the condition is the one thing that is actually wrong: the read answered MORE rows than the
|
|
101
105
|
* window carries, and this seam has no second page to put the rest on. Below that, the page is
|
|
102
|
-
* whole and `
|
|
106
|
+
* whole and `hasMore` is false by construction — the true stop signal, and no cursor is ever
|
|
103
107
|
* handed back that a second call is guaranteed to refuse.
|
|
104
108
|
*
|
|
105
109
|
* **`seek` narrows the WINDOW and never the ORDER, and that is the whole reason this wrapper takes a
|
|
@@ -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
|
-
|
|
131
|
-
|
|
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
|
+
}
|
|
138
|
+
// `paginate` asks for `first + 1` — the extra row IS `hasMore` — 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,14 +149,15 @@ 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
|
-
/** Rows `paginate` will serve. It fetched one more, to decide `
|
|
160
|
+
/** Rows `paginate` will serve. It fetched one more, to decide `hasMore`. */
|
|
153
161
|
first: number,
|
|
154
162
|
): SqlSource<Row> => {
|
|
155
163
|
const windowed = base.limit(first + 1);
|
|
@@ -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
|
+
}
|