@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/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
- createContext,
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 { actorOf, guard, guardBeforeInput } from './policy-gate';
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) => readRows(target, raw, ctx, options));
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) => buildSource(target, raw, ctx, options));
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(createContext(patch), inChild)
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
- const read = (): Promise<readonly object[]> => source.execute();
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
- guardBeforeInput(
212
- def.policy,
213
- { actor: actorOf(ctx), ctx, query: name },
214
- options.surface ?? 'server',
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
- guard(
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) {
@@ -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 { json } from '@ultimat3/http';
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) => json(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 json(encodeRecordEnvelope(answer, records), { headers: { [RECORDS_HEADER]: '1' } });
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) => json(row);
41
+ if (!answersRecords(rows)) return (row) => jsonResponse(row);
40
42
  return (row) =>
41
- json(encodeRecordEnvelope(row, collect(rows, [row])), { headers: { [RECORDS_HEADER]: '1' } });
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
@@ -64,6 +64,6 @@ export function describeQueries(): readonly QueryDescriptor[] {
64
64
  }
65
65
 
66
66
  /** Test-only. Production registers once at boot. */
67
- export function resetRegistry(): void {
67
+ export function resetQueries(): void {
68
68
  registry.clear();
69
69
  }
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, 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, QueryMcp, QueryRateLimit } from './query';
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
- * 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. */
@@ -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?: QueryMcp;
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
- * `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`.
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 `endCursor` and report `hasNextPage: true` — a connection
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 `hasNextPage` is false by construction — the true stop signal, and no cursor is ever
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
- 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
- );
131
- // `paginate` asks for `first + 1` — the extra row IS `hasNextPage` — so the window it names is
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 `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
- /** Rows `paginate` will serve. It fetched one more, to decide `hasNextPage`. */
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
- assert(
165
- rows.length <= first,
166
- `search of ${entity} answered more than the ${String(first)} rows .page() asked for, and this read has no second page: the rest would be on no page at all`,
167
- `read.page(input, { first: ${String(served)} }) # widen the window to the read's whole page — or narrow the page: read({ q, limit: ${String(first)} })`,
168
- );
172
+ if (rows.length > first) {
173
+ throw new QueryInputInvalidError(
174
+ read,
175
+ `search of ${entity} answered more than the ${String(first)} rows .page() asked for, and this read has no second page: the rest would be on no page at all — widen the window to the read's whole page with read.page(input, { first: ${String(served)} }), or narrow the read with read({ q, limit: ${String(first)} })`,
176
+ );
177
+ }
169
178
  return rows;
170
179
  },
171
180
  };
@@ -190,28 +199,37 @@ export const search = <Row extends object, S extends Shape = Record<string, neve
190
199
  ),
191
200
  } as SearchShape<S>;
192
201
 
193
- return query({
202
+ const self = query({
194
203
  input: t.object(shape),
195
204
  policy: def.policy,
196
205
  live: false,
197
206
  ...(def.cache === undefined ? {} : { cache: def.cache }),
198
207
  ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
199
208
  ...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }),
200
- sql: (input, ctx) => {
209
+ // The return type is WRITTEN: the body names `self`, and an inferred one would make this
210
+ // declaration's type depend on itself.
211
+ sql: (input, ctx): SqlSource<Row> => {
201
212
  const parsed = input as SearchInput<S> & { readonly q: string; readonly limit: number };
202
213
  // Trimmed and refused HERE, before the chain exists: `websearch_to_tsquery('english', ' ')`
203
214
  // is a legal empty tsquery matching nothing, so a blank box would answer "no results" as if
204
215
  // it had searched. Saying so is the difference between an empty answer and an empty question.
205
216
  const term = parsed.q.trim();
206
- assert(
207
- term.length > 0,
208
- 'a search term of only whitespace is not a search',
209
- 'guard the input before calling: if (term.trim() === "") return [] — an empty box is not an empty result set',
210
- );
217
+ if (term.length === 0) {
218
+ throw new QueryInputInvalidError(
219
+ queryName(self),
220
+ 'q is only whitespace, which is not a search — guard the box before calling: an empty box is not an empty result set',
221
+ );
222
+ }
211
223
  const chain = def.in({ input: parsed, ctx });
212
224
  const name = chain.plan().entity;
213
225
  const rows = () => chain.search(term).limit(parsed.limit).all();
214
- return onePage(from<Row>(name, rows).raw('full-text search'), name, parsed.limit);
226
+ return onePage(
227
+ from<Row>(name, rows).raw('full-text search'),
228
+ queryName(self),
229
+ name,
230
+ parsed.limit,
231
+ );
215
232
  },
216
233
  });
234
+ return self;
217
235
  };
package/src/shape.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  /**
2
2
  * The read vocabulary shared by the matcher, the SQL sources, pagination and the
3
- * live descriptor. Types only plus two pure predicates — no I/O lives here.
3
+ * live descriptor: the types, and the two predicates that read a row. How two VALUES compare is
4
+ * `@ultimat3/entity`'s answer (`compareByKind`, `sameValueOfKind`) — no comparator lives here.
4
5
  */
6
+ import type { ColumnKind } from '@ultimat3/entity';
7
+ import { compareByKind, sameValueOfKind } from '@ultimat3/entity';
8
+ import type { KindOf } from './column-kinds';
5
9
  import { QueryNotPageableError } from './errors';
6
10
  import { columnOf } from './stable';
7
11
 
@@ -87,24 +91,33 @@ export function isNull(value: unknown): boolean {
87
91
  return value === null || value === undefined;
88
92
  }
89
93
 
90
- export function matchesFilters(row: object, filters: readonly Filter[]): boolean {
91
- return filters.every((filter) => matchesFilter(row, filter));
94
+ export function matchesFilters(row: object, filters: readonly Filter[], kindOf: KindOf): boolean {
95
+ return filters.every((filter) => matchesFilter(row, filter, kindOf));
92
96
  }
93
97
 
94
- export function matchesFilter(row: object, filter: Filter): boolean {
98
+ /**
99
+ * One filter against one row, as Postgres answers it for that column. `kindOf` is required: a
100
+ * comparison with no declared kind to decide by is how `'10'` in a `bigint()` column sorted before
101
+ * `'9'`, and an optional argument is one a caller forgets.
102
+ */
103
+ export function matchesFilter(row: object, filter: Filter, kindOf: KindOf): boolean {
95
104
  const actual = columnOf(row, filter.column);
105
+ const kind = kindOf(filter.column);
96
106
  switch (filter.op) {
97
107
  case '=':
98
- return same(actual, filter.value);
108
+ return equalOrBothNull(kind, actual, filter.value);
99
109
  case '!=':
100
- return !same(actual, filter.value);
110
+ return !equalOrBothNull(kind, actual, filter.value);
101
111
  case 'in':
102
- return Array.isArray(filter.value) && filter.value.some((item) => same(actual, item));
112
+ return (
113
+ Array.isArray(filter.value) &&
114
+ filter.value.some((item) => equalOrBothNull(kind, actual, item))
115
+ );
103
116
  case '>':
104
117
  case '>=':
105
118
  case '<':
106
119
  case '<=':
107
- return ordered(filter.op, actual, filter.value);
120
+ return ordered(filter.op, kind, actual, filter.value);
108
121
  default:
109
122
  return false;
110
123
  }
@@ -115,9 +128,14 @@ export function matchesFilter(row: object, filter: Filter): boolean {
115
128
  * ordering operator matches nothing here either. Only `=`, `!=` and `in` read NULL as a value —
116
129
  * and those are exactly the three `Builder.toSQL()` compiles to `is null` / `is distinct from`.
117
130
  */
118
- function ordered(op: '>' | '>=' | '<' | '<=', actual: unknown, value: unknown): boolean {
131
+ function ordered(
132
+ op: '>' | '>=' | '<' | '<=',
133
+ kind: ColumnKind | undefined,
134
+ actual: unknown,
135
+ value: unknown,
136
+ ): boolean {
119
137
  if (isNull(actual) || isNull(value)) return false;
120
- const result = compareValues(actual, value);
138
+ const result = compareByKind(kind, actual, value);
121
139
  switch (op) {
122
140
  case '>':
123
141
  return result > 0;
@@ -131,88 +149,37 @@ function ordered(op: '>' | '>=' | '<' | '<=', actual: unknown, value: unknown):
131
149
  }
132
150
 
133
151
  /**
134
- * Dates compare by instant, everything else by value. No coercion across types.
135
- *
136
- * NULL is greater than every value and equal to itself — Postgres' own sort rule, which is what
137
- * lets `Builder.toSQL()` write it down as `asc nulls last` / `desc nulls first` and mean this
138
- * function. Sorting only: a comparison *filter* against NULL matches nothing (`ordered`). Before
139
- * this, `null` sorted as the string `"null"`, so it landed between `"m"` and `"o"` in memory and
140
- * at the end in the database — the same page read two ways.
152
+ * This package's NULL rule around `@ultimat3/entity`'s equality: `=`, `!=` and `in` read NULL as a
153
+ * value, so two absences are equal and an absence equals nothing else. What two PRESENT values
154
+ * are to each other is not decided here — `sameValueOfKind` answers by the column's kind.
141
155
  */
142
- export function compareValues(a: unknown, b: unknown): number {
143
- if (isNull(a) || isNull(b)) return isNull(a) ? (isNull(b) ? 0 : 1) : -1;
144
- const left = normalize(a);
145
- const right = normalize(b);
146
- if (isNumeric(left) && isNumeric(right)) return compareNumeric(left, right);
147
- const l = String(left);
148
- const r = String(right);
149
- return l < r ? -1 : l > r ? 1 : 0;
150
- }
151
-
152
- function isNumeric(value: unknown): value is number | bigint {
153
- return typeof value === 'number' || typeof value === 'bigint';
156
+ function equalOrBothNull(kind: ColumnKind | undefined, a: unknown, b: unknown): boolean {
157
+ if (isNull(a) || isNull(b)) return isNull(a) && isNull(b);
158
+ return sameValueOfKind(kind, a, b);
154
159
  }
155
160
 
156
161
  /**
157
- * Numbers and bigints, in one order, because **Postgres orders them in one order**.
162
+ * Row ordering under an `orderBy` list. Stable, and total when an id key is last.
158
163
  *
159
- * `bigint` is a first-class `ColumnKind` — the physical type of every `<p>_minor` column — and
160
- * `@ultimat3/entity`'s `count-by.ts` lists it as groupable, so these values do reach the
161
- * comparator. The old numeric fast path was `typeof left === 'number' && typeof right ===
162
- * 'number'` alone, so a bigint fell through to `String(left) < String(right)`:
163
- * `compareValues(9n, 10n)` answered `1` and a sort came out `["10", "100", "9"]`, which means the
164
- * in-memory source, the live matcher and the seek fallback all disagreed with the database on any
165
- * bigint-ordered read — including page two of one.
166
- *
167
- * A bigint pair never subtracts: the difference is exact but the return type is a `number`. A
168
- * mixed pair goes through `BigInt` when the number is whole, so a value past 2^53 keeps its exact
169
- * place; a fractional number cannot equal a bigint, so comparing it as a float is enough to place
170
- * it. `Number.isInteger` is false for `NaN` and `±Infinity`, which is what keeps them out of the
171
- * `BigInt()` call that would throw on them.
164
+ * Each key is ordered by `@ultimat3/entity`'s `compareByKind` under the column's declared kind —
165
+ * the one JS statement of how Postgres orders a column. NULL is the largest value there and equal
166
+ * to itself, which is what lets `Builder.toSQL()` write `asc nulls last` / `desc nulls first` and
167
+ * mean this function. This package used to keep a second comparator that decided by `typeof`: a
168
+ * `bigint()` or `decimal()` row is decimal TEXT, so `'10'` sorted before `'9'` in the in-memory
169
+ * source, the live matcher and the seek fallback while the database answered the other way.
172
170
  */
173
- function compareNumeric(left: number | bigint, right: number | bigint): number {
174
- if (typeof left === 'number' && typeof right === 'number') return left - right;
175
- if (typeof left === 'bigint' && typeof right === 'bigint') return sign(left, right);
176
- // Widened rather than negated: `-sign(a, b)` answers `-0` for a tie, and `-0` is a different
177
- // value from `0` to `Object.is` and to a caller writing `=== 0`.
178
- return typeof left === 'bigint'
179
- ? mixed(left, right as number)
180
- : -mixed(right as bigint, left) || 0;
181
- }
182
-
183
- /** A bigint against a number, in that order. Whole numbers go through `BigInt` so a value past
184
- * 2^53 keeps its exact place; a fractional number can never equal a bigint, so comparing it as a
185
- * float is enough to place it. `Number.isInteger` is false for `NaN` and `±Infinity`, which is
186
- * what keeps them out of the `BigInt()` call that would throw on them. */
187
- function mixed(big: bigint, other: number): number {
188
- return Number.isInteger(other) ? sign(big, BigInt(other)) : sign(Number(big), other);
189
- }
190
-
191
- function sign<T extends number | bigint>(left: T, right: T): number {
192
- return left < right ? -1 : left > right ? 1 : 0;
193
- }
194
-
195
- /** A `Date` compares by instant, so two of them order by time and never by their ISO text. */
196
- function normalize(value: unknown): unknown {
197
- return value instanceof Date ? value.getTime() : value;
198
- }
199
-
200
- /**
201
- * Equality as Postgres answers it. A number and a bigint are ONE family — an `int8` arrives as a
202
- * `bigint` from one driver while a filter value is usually a `number` literal, and `5n = 5` is
203
- * true there — so the type check that keeps `'5'` from equalling `5` treats them as one.
204
- */
205
- function same(a: unknown, b: unknown): boolean {
206
- if (isNull(a) || isNull(b)) return isNull(a) && isNull(b);
207
- return compareValues(a, b) === 0 && family(normalize(a)) === family(normalize(b));
208
- }
209
-
210
- const family = (value: unknown): string => (isNumeric(value) ? 'numeric' : typeof value);
211
-
212
- /** Row ordering under an `orderBy` list. Stable, and total when an id key is last. */
213
- export function compareRows(a: object, b: object, orderBy: readonly OrderKey[]): number {
171
+ export function compareRows(
172
+ a: object,
173
+ b: object,
174
+ orderBy: readonly OrderKey[],
175
+ kindOf: KindOf,
176
+ ): number {
214
177
  for (const key of orderBy) {
215
- const result = compareValues(columnOf(a, key.column), columnOf(b, key.column));
178
+ const result = compareByKind(
179
+ kindOf(key.column),
180
+ columnOf(a, key.column),
181
+ columnOf(b, key.column),
182
+ );
216
183
  if (result !== 0) return key.direction === 'asc' ? result : -result;
217
184
  }
218
185
  return 0;
@@ -0,0 +1,22 @@
1
+ // Single responsibility: what a `single: true` read ANSWERS — its first row, or `X_NOT_FOUND`.
2
+ // One rule for the route, the read's own tool and `@ultimat3/mcp`'s served tool: three copies of
3
+ // "take the first row, refuse when there is none" are three places for one surface to drift.
4
+
5
+ import { QueryRowNotFoundError } from './errors';
6
+ import type { AnyQuery } from './query';
7
+ import { queryName } from './read';
8
+
9
+ /** The first row — what every in-process `[0]` of the same read takes — or `X_NOT_FOUND`. */
10
+ export function oneRowOf<TRow extends object>(name: string, rows: readonly TRow[]): TRow {
11
+ const [row] = rows;
12
+ if (row === undefined) throw new QueryRowNotFoundError(name);
13
+ return row;
14
+ }
15
+
16
+ /**
17
+ * A read's answer on a surface that hands back one value: the rows of a list read, the one row of
18
+ * a `single: true` read. `rows` is what the read's own source executed to.
19
+ */
20
+ export function readAnswer(target: AnyQuery, rows: readonly object[]): readonly object[] | object {
21
+ return target.single === true ? oneRowOf(queryName(target), rows) : rows;
22
+ }