@ultimat3/query 20.2.1 → 22.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/README.md CHANGED
@@ -53,9 +53,44 @@ const [post] = await queries.publicPost({ slug }); // typed input, typed rows
53
53
 
54
54
  Both spellings run `queryClientMethodFor`, so a read has one URL however it is addressed.
55
55
 
56
+ Every read goes through `@ultimat3/core`'s **`clientTransport`** — the one browser HTTP function,
57
+ `As of 2026-09-22` (21.0.0). There is no `fetch` call in this package: the `fetch` option is handed
58
+ on as the transport's `fetchImpl`. The transport owns `Accept`, the trace/budget headers, the
59
+ principal fence, and the error decode — a non-`problem+json` failure (a gateway's HTML) is core's
60
+ `X_CLIENT_TRANSPORT_FAILED`, a `problem+json` one is the server's own code, verbatim.
61
+
62
+ ### Records — `rows:` puts a read's answer in the page's store
63
+
64
+ ```ts
65
+ import { entity, text, uuid } from '@ultimat3/entity';
66
+ import { allow } from '@ultimat3/policy';
67
+ import { from, query, t } from '@ultimat3/query';
68
+
69
+ const Post = entity('post', {
70
+ columns: { id: uuid().primaryKey(), orgId: uuid(), title: text({ max: 120 }) },
71
+ });
72
+ type PostRow = { id: string; orgId: string; title: string };
73
+ declare const list: (orgId: string) => Promise<readonly PostRow[]>;
74
+
75
+ export const postList = query({
76
+ input: t.object({ orgId: t.uuid }),
77
+ policy: allow('public'),
78
+ rows: Post.$schema, // the entity's row schema — typed against what `sql:` answers
79
+ sql: ({ orgId }) => from<PostRow>('posts', () => list(orgId)).where({ orgId }).orderBy('id'),
80
+ });
81
+ ```
82
+
83
+ With `rows:` naming an **entity's** row schema, the route answers `x-ultimate-records: 1` and the
84
+ record envelope `{ data, records }` on EVERY answer — `records` is `{}` when no row came back, so
85
+ the operation has one body shape and one OpenAPI schema; the typed client adopts
86
+ `records` into `pageClient().store` and returns `data` — the same rows, the same type. Without it,
87
+ or with a schema no `entity()` branded (a `t.object` look-alike, a `.pick()`ed partial), the wire
88
+ is the bare rows, byte-identical to before. `rows:` exists because a read's `sql:` names its table
89
+ as a STRING, so nothing else carries a schema the envelope could be derived from.
90
+
56
91
  ### Flight control — `createClientFlight`, opt-in
57
92
 
58
- A typed read is one `fetch` and nothing else until a `flight` is installed. With one, N concurrent
93
+ A typed read is one dispatch and nothing else until a `flight` is installed. With one, N concurrent
59
94
  identical reads become ONE dispatch, a fence can retire everything issued before now, and a failure
60
95
  worth sending again is sent again on `@ultimat3/core`'s one backoff curve.
61
96
 
@@ -95,11 +130,12 @@ flight.bump();
95
130
  It shipped as a byte-identical copy in each; the copies are gone and every name is importable from
96
131
  this package exactly as before.
97
132
 
98
- Bundle cost is why `ClientFlight` is a TYPE inside `client.ts` and never a value: importing
99
- `queryClient` alone from this package is **12,755 B** minified for the browser, and adding
100
- `createClientFlight` is **17,912 B**. A caller who wants a plain typed fetch pays for none of it.
101
- Expect ±376 B run to run — `Bun.build` 1.4.0 drops `@ultimat3/core`'s `schema-error-codes.ts` from
102
- some builds even though `sideEffects` names it (issue #273).
133
+ Bundle cost is why `ClientFlight` is a TYPE inside `client.ts` and never a value. Importing
134
+ `queryClient` alone through this package's barrel, `bun build --target=browser --minify`,
135
+ `As of 2026-09-22`: **19,026 B** before the move to `clientTransport` (20.2.1), **24,114 B** after.
136
+ The growth is core's transport and its graph (`client-transport.ts`, `client-problem.ts`,
137
+ `record-envelope.ts`, `client-scope.ts`, `record-sink.ts`); `client.ts` itself got smaller. The
138
+ 12,755 B this page quoted before was no longer true at 20.2.1 — re-measure, never quote.
103
139
 
104
140
  The declaration is lifted too: `.input`, `.policy`, `.cache`, `.mcp`, `.isLive`. `sql` is not
105
141
  among them — it lives in a private store inside `read.ts`, so `sourceFor` is the only thing
@@ -479,12 +515,33 @@ router feature here.
479
515
  | `X_INPUT_INVALID` | input failed the Standard Schema | `x queries describe <name> --json` |
480
516
  | `X_QUERY_UNREGISTERED` | used before `registerQueries()` ran | register at boot |
481
517
  | `X_QUERY_FOREIGN` | a look-alike was projected as a query | declare it with `query({ … })` |
482
- | `X_RPC_FAILED` | `.client()` got a non-`problem+json` failure | check the gateway in front of the app |
518
+ | `X_CLIENT_TRANSPORT_FAILED` | `.client()` got no response, or a non-`problem+json` failure (core's code, since 21.0.0; was `X_RPC_FAILED`) | check the gateway in front of the app: `x doctor --json` |
483
519
 
484
520
  Denials re-throw the policy layer's own codes and keep the surface denial on
485
521
  `QueryDeniedError.denial`, so a live socket closes with 4403 instead of guessing.
486
522
 
523
+ ### Error classes
524
+
525
+ Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
526
+ a job boundary the class is gone and the `code` is what survives — match on that.
527
+
528
+ | Class | Code | Declared in |
529
+ |---|---|---|
530
+ | `CursorValueUnsupportedError` | `X_CURSOR_VALUE_UNSUPPORTED` | `src/errors.ts` |
531
+ | `MatcherUnsupportedError` | `X_MATCHER_UNSUPPORTED` | `src/errors.ts` |
532
+ | `QueryDeniedError` | the policy denial's own code (`X_FORBIDDEN`, `X_UNAUTHENTICATED`, …), kept on `.denial` | `src/errors.ts` |
533
+ | `QueryDeprecationInvalidError` | `X_QUERY_DEPRECATION_INVALID` | `src/errors.ts` |
534
+ | `QueryDuplicateError` | `X_QUERY_DUPLICATE` | `src/errors.ts` |
535
+ | `QueryForeignError` | `X_QUERY_FOREIGN` | `src/errors.ts` |
536
+ | `QueryInputInvalidError` | `X_INPUT_INVALID` | `src/errors.ts` |
537
+ | `QueryInputUnencodableError` | `X_QUERY_INPUT_UNENCODABLE` | `src/errors.ts` |
538
+ | `QueryNotPageableError` | `X_QUERY_NOT_PAGEABLE` | `src/errors.ts` |
539
+ | `QueryPolicyMissingError` | `X_QUERY_POLICY_MISSING` | `src/errors.ts` |
540
+ | `QuerySubscribesDriftError` | `X_QUERY_SUBSCRIBES_DRIFT` | `src/errors.ts` |
541
+ | `QuerySubscribesInvalidError` | `X_QUERY_SUBSCRIBES_INVALID` | `src/errors.ts` |
542
+ | `QueryUnregisteredError` | `X_QUERY_UNREGISTERED` | `src/errors.ts` |
543
+
487
544
  ## Boundaries
488
545
 
489
- Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `policy`. Never imports `action`,
546
+ Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `entity`, `http`, `policy`. Never imports `action`,
490
547
  `jobs` or `realtime` (same tier) — `realtime` consumes `LiveQuery` from here.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/query",
3
- "version": "20.2.1",
3
+ "version": "22.0.0",
4
4
  "description": "The query primitive: a policy-checked read, optionally live, with cursor pagination and an incremental matcher",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,7 +18,8 @@
18
18
  "provenance": true
19
19
  },
20
20
  "exports": {
21
- ".": "./src/index.ts"
21
+ ".": "./src/index.ts",
22
+ "./client": "./src/client.ts"
22
23
  },
23
24
  "files": [
24
25
  "src",
@@ -35,10 +36,11 @@
35
36
  "test": "bun test"
36
37
  },
37
38
  "dependencies": {
38
- "@ultimat3/cache": "20.2.1",
39
- "@ultimat3/core": "20.2.1",
40
- "@ultimat3/http": "20.2.1",
41
- "@ultimat3/policy": "20.2.1",
42
- "@ultimat3/schema": "20.2.1"
39
+ "@ultimat3/cache": "22.0.0",
40
+ "@ultimat3/core": "22.0.0",
41
+ "@ultimat3/entity": "22.0.0",
42
+ "@ultimat3/http": "22.0.0",
43
+ "@ultimat3/policy": "22.0.0",
44
+ "@ultimat3/schema": "22.0.0"
43
45
  }
44
46
  }
package/src/client.ts CHANGED
@@ -4,30 +4,29 @@
4
4
  * in a Solid component rather than a 404 at runtime. Browser-safe on purpose: no
5
5
  * server imports, nothing here touches a context, a policy or a database.
6
6
  *
7
- * Rows arrive as JSON and are handed back as parsed, exactly as `rpc` does: a query declares no
8
- * output schema — row types come from the `SqlSource` its `sql:` returns — so there is nothing
9
- * here to rehydrate a `Date` with, and an instant reaches a caller as the ISO string
10
- * `JSON.stringify` wrote. A surface that formats one converts at its own edge.
7
+ * Every read goes through `@ultimat3/core`'s `clientTransport`, the one browser HTTP function —
8
+ * there is no `fetch` call here, only the injected `fetch` option handed on as `fetchImpl`. Rows
9
+ * are handed back as parsed: an instant reaches a caller as the ISO string `JSON.stringify` wrote,
10
+ * and a surface that formats one converts at its own edge. A record envelope is unwrapped by the
11
+ * transport, its rows adopted into the page's store, so the return type is the rows either way.
11
12
  *
12
13
  * `ClientFlight` is a TYPE here and never a value: dedup, retry, the deadline and the fence are
13
14
  * `@ultimat3/core`'s `client-flight.ts`, and a caller that never calls `createClientFlight` does
14
15
  * not pay a byte for any of them — an `import type` is erased and the value import would not be.
15
- * That erasure is the whole reason two islands in this repo write a bare `fetch` instead of
16
- * importing a typed client.
17
16
  */
18
17
 
19
- import type { ClientFlight, ClientRetry, WireAnswer } from '@ultimat3/core';
20
- import { problemOf, traceHeaders } from '@ultimat3/core';
18
+ import type { ClientFlight, ClientRetry } from '@ultimat3/core';
19
+ import type { FetchLike, RecordEnvelope } from '@ultimat3/core/page';
20
+ import { clientTransport, isJsonObject } from '@ultimat3/core/page';
21
21
  import type { InferInput, StandardSchemaV1 } from '@ultimat3/schema';
22
- import { QueryRequestFailedError } from './errors';
23
22
  import { derivePath } from './naming';
24
23
  import type { PageControls } from './page-controls';
25
- import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-controls';
24
+ import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-keys';
26
25
  import type { Page } from './pagination';
27
26
  import type { Query } from './query';
28
- import { isJsonObject } from './stable';
29
27
 
30
- export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
28
+ /** Core's, re-exported under the name this package always exported it by — one declaration. */
29
+ export type { FetchLike } from '@ultimat3/core/page';
31
30
 
32
31
  export interface QueryClientOptions {
33
32
  readonly baseUrl: string;
@@ -35,8 +34,8 @@ export interface QueryClientOptions {
35
34
  readonly headers?: Readonly<Record<string, string>>;
36
35
  /**
37
36
  * Opt-in flight control for every read this client makes — `createClientFlight({ principal })`.
38
- * Absent, a read is one `fetch` and nothing else, which is what every caller written before this
39
- * option existed already gets.
37
+ * Absent, a read is one dispatch and nothing else, which is what every caller written before
38
+ * this option existed already gets.
40
39
  */
41
40
  readonly flight?: ClientFlight;
42
41
  }
@@ -51,6 +50,11 @@ export interface QueryCallOptions {
51
50
  readonly fresh?: boolean;
52
51
  /** Overrides the flight's retry policy for this one read. Ignored with no `flight` installed. */
53
52
  readonly retry?: ClientRetry;
53
+ /**
54
+ * The decoded record envelope, after its rows were adopted — only for a read declaring an
55
+ * entity's `rows:`. The store is keyed and unordered; this is where a caller reads the ORDER.
56
+ */
57
+ readonly onEnvelope?: (envelope: RecordEnvelope) => void;
54
58
  }
55
59
 
56
60
  /**
@@ -126,21 +130,25 @@ export function queryClientMethodFor<TInput extends StandardSchemaV1, TRow exten
126
130
  name: string,
127
131
  options: QueryClientOptions,
128
132
  ): QueryClientMethod<TInput, TRow> {
129
- const doFetch: FetchLike = options.fetch ?? ((input, init) => fetch(input, init));
130
133
  const base = options.baseUrl.replace(/\/+$/, '');
131
134
  // Erased at the wire seam; the row type is this query's by construction.
132
135
  const rows = (input: InferInput<TInput>, callOptions: QueryCallOptions = {}) =>
133
- read(doFetch, base, options, name, input, callOptions) as Promise<readonly TRow[]>;
136
+ read(base, options, name, input, callOptions) as Promise<readonly TRow[]>;
134
137
  const page = (
135
138
  input: InferInput<TInput>,
136
139
  args: PageControls,
137
140
  callOptions: QueryCallOptions = {},
138
- ) => read(doFetch, base, options, name, input, callOptions, args) as Promise<Page<TRow>>;
141
+ ) => read(base, options, name, input, callOptions, args) as Promise<Page<TRow>>;
139
142
  return Object.assign(rows, { page });
140
143
  }
141
144
 
142
- async function read(
143
- doFetch: FetchLike,
145
+ /**
146
+ * One read, through `@ultimat3/core`'s `clientTransport` — the one browser HTTP function. It owns
147
+ * the wire: `Accept`, the error decode, the principal fence, and the record envelope — whose rows it adopts
148
+ * into the page's store before handing back `data`, so the return type here never changed. The
149
+ * `fetch` option is the transport's injected `fetchImpl`, never a call made here.
150
+ */
151
+ function read(
144
152
  base: string,
145
153
  options: QueryClientOptions,
146
154
  name: string,
@@ -149,51 +157,19 @@ async function read(
149
157
  page?: PageControls,
150
158
  ): Promise<unknown> {
151
159
  const search = searchOf(input, page);
152
- const url = `${base}${derivePath(name)}${search === '' ? '' : `?${search}`}`;
153
- const dispatch = (signal: AbortSignal | undefined): Promise<WireAnswer> =>
154
- fetchOnce(doFetch, url, options, name, signal ?? callOptions.signal);
155
-
156
- const flight = options.flight;
157
- const answer =
158
- flight === undefined
159
- ? await dispatch(undefined)
160
- : await flight.run({
161
- key: flight.keyFor(url, callOptions),
162
- // A caller holding its own signal owns this read's lifecycle: it is neither shared nor
163
- // aborted by a fence bump, and its signal is the only one that reaches the wire.
164
- abortable: callOptions.signal === undefined,
165
- ...(callOptions.retry === undefined ? {} : { retry: callOptions.retry }),
166
- run: dispatch,
167
- });
168
- // Parsed per CALLER, never once per dispatch: N joiners of one deduped read may not be handed
169
- // one mutable array between them, and the shared value is the immutable body TEXT for that
170
- // reason alone.
171
- return JSON.parse(answer.text) as unknown;
172
- }
173
-
174
- /** One dispatch. Everything above it decides how many times this happens; it decides none. */
175
- async function fetchOnce(
176
- doFetch: FetchLike,
177
- url: string,
178
- options: QueryClientOptions,
179
- name: string,
180
- signal: AbortSignal | undefined,
181
- ): Promise<WireAnswer> {
182
- const init: RequestInit = {
183
- // `traceHeaders()` before the caller's, so an explicit `traceparent` still wins. Without it a
184
- // service-to-service read started a fresh root trace on the other side, and "which of my
185
- // downstreams is slow" was unanswerable across every Ultimate-to-Ultimate hop.
186
- headers: { accept: 'application/json', ...traceHeaders(), ...options.headers },
160
+ return clientTransport({
187
161
  method: 'GET',
188
- ...(signal === undefined ? {} : { signal }),
189
- };
190
-
191
- const response = await doFetch(url, init);
192
- // Read as TEXT once: a `Response` body is a single-use stream, so the failure path and the row
193
- // path cannot both have it, and a shared answer has to be something a joiner can re-read.
194
- const text = await response.text();
195
- if (!response.ok) throw new QueryRequestFailedError(name, response.status, problemOf(text));
196
- return { status: response.status, text };
162
+ url: `${base}${derivePath(name)}${search === '' ? '' : `?${search}`}`,
163
+ // The trace and budget are the transport's, from its server-side outbound slot, placed
164
+ // before these so an explicit `traceparent` still wins; a browser bundles none of it.
165
+ ...(options.headers === undefined ? {} : { headers: options.headers }),
166
+ ...(callOptions.signal === undefined ? {} : { signal: callOptions.signal }),
167
+ ...(options.flight === undefined ? {} : { flight: options.flight }),
168
+ ...(callOptions.fresh === undefined ? {} : { fresh: callOptions.fresh }),
169
+ ...(callOptions.retry === undefined ? {} : { retry: callOptions.retry }),
170
+ ...(callOptions.onEnvelope === undefined ? {} : { onEnvelope: callOptions.onEnvelope }),
171
+ ...(options.fetch === undefined ? {} : { fetchImpl: options.fetch }),
172
+ });
197
173
  }
198
174
 
199
175
  /**
@@ -74,8 +74,3 @@ export function renderDeprecation(
74
74
  };
75
75
  return { ok: true, headers, meta };
76
76
  }
77
-
78
- /** Set on a response that already exists, so a problem document carries them too. */
79
- export function applyHeaders(response: Response, headers: Readonly<Record<string, string>>): void {
80
- for (const [name, value] of Object.entries(headers)) response.headers.set(name, value);
81
- }
package/src/errors.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /** Every failure @ultimat3/query can produce, one subclass per stable code. */
2
- import { assertNever, registerErrorCodes, retryForStatus, UltimateError } from '@ultimat3/core';
2
+ import { assertNever, registerErrorCodes, UltimateError } from '@ultimat3/core';
3
3
  import type { SurfaceDenial } from '@ultimat3/policy';
4
4
 
5
- // No `docs:` on the classes below, with one exception noted at `QueryRequestFailedError`.
5
+ // No `docs:` on the classes below.
6
6
  // `UltimateError` fills it from `describeErrorCode(code).docs`, which is `@ultimat3/core`'s
7
7
  // `ERROR_DOCS_URL` — one page for every code, never one per code, because `wiki/` is the
8
8
  // framework's only public documentation surface and a code lives there in a TABLE ROW, which has
@@ -17,6 +17,7 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
17
17
  X_CURSOR_VALUE_UNSUPPORTED: 'a sort value cannot be carried in a cursor',
18
18
  X_MATCHER_UNSUPPORTED: 'live query shape cannot be patched incrementally',
19
19
  X_QUERY_CACHE_TTL_INVALID: 'a query declares a cache ttlMs no tier can hold',
20
+ X_QUERY_COLUMN_UNSELECTED: 'a read filters or sorts on a column its rows do not carry',
20
21
  X_QUERY_DEPRECATION_INVALID: 'a query declares a deprecation whose dates cannot be rendered',
21
22
  X_QUERY_DUPLICATE: 'two queries are registered under one name',
22
23
  X_QUERY_FOREIGN: 'a value that is not a query was projected as one',
@@ -29,11 +30,12 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
29
30
  };
30
31
 
31
32
  /**
32
- * Codes `@ultimat3/action` owns that this package only throws. Both describe an action's job —
33
- * enforcing an input schema, and speaking the typed RPC wire — so action declares the title and
34
- * query never re-declares it: two copies of a title are two titles, one of which is stale.
33
+ * Codes `@ultimat3/action` owns that this package only throws. It describes an action's job —
34
+ * enforcing an input schema — so action declares the title and query never re-declares it: two
35
+ * copies of a title are two titles, one of which is stale. `X_RPC_FAILED` left this list in
36
+ * 21.0.0: the typed read client's wire failures are `@ultimat3/core`'s `clientTransport`'s now.
35
37
  */
36
- export const QUERY_BORROWED_ERROR_CODES = ['X_INPUT_INVALID', 'X_RPC_FAILED'] as const;
38
+ export const QUERY_BORROWED_ERROR_CODES = ['X_INPUT_INVALID'] as const;
37
39
 
38
40
  // One unconditional call: a presence guard would turn "another package claims one of these codes"
39
41
  // from an X_ERROR_CODE_DUPLICATE at import into whichever module loaded first deciding the title.
@@ -239,6 +241,23 @@ export class CursorValueUnsupportedError extends UltimateError {
239
241
  }
240
242
  }
241
243
 
244
+ /**
245
+ * A `where()`, `compare()` or `orderBy()` on a column the provider's rows do not carry. Absent is
246
+ * not "not equal": every row failed the filter and the read answered `[]` — a loader that selected
247
+ * `slug` and `updatedAt` and filtered on `status` built a blog with no article in it, under a
248
+ * green gate. Refused by name, because the repair is one edit to the loader's `select`.
249
+ */
250
+ export class QueryColumnUnselectedError extends UltimateError {
251
+ constructor(entity: string, column: string) {
252
+ super({
253
+ code: 'X_QUERY_COLUMN_UNSELECTED',
254
+ cause: `the rows of "${entity}" carry no "${column}" column, so filtering or sorting on it matches nothing`,
255
+ fix: 'select it in the loader: db.posts.select({ slug: true, status: true, publishedAt: true }) — or drop the filter the loader already applied',
256
+ meta: { entity, column },
257
+ });
258
+ }
259
+ }
260
+
242
261
  /** The honest fallback: the matcher refuses to guess rather than patch wrongly. */
243
262
  export class MatcherUnsupportedError extends UltimateError {
244
263
  constructor(name: string, feature: string) {
@@ -305,46 +324,3 @@ export class QueryInputInvalidError extends UltimateError {
305
324
  });
306
325
  }
307
326
  }
308
-
309
- /** The `problem+json` fields a failing read can send back. All optional: a proxy sends none. */
310
- export interface QueryProblem {
311
- readonly code?: unknown;
312
- readonly cause?: unknown;
313
- readonly detail?: unknown;
314
- readonly fix?: unknown;
315
- readonly docs?: unknown;
316
- }
317
-
318
- /**
319
- * The typed client's failure. A `problem+json` body is re-thrown verbatim — the
320
- * server already said what broke and how to fix it, and inventing a second story
321
- * here would bury it. Anything else answered instead of the app, so it is
322
- * `X_RPC_FAILED` and the fix line points at the gateway.
323
- */
324
- export class QueryRequestFailedError extends UltimateError {
325
- constructor(name: string, status: number, problem: QueryProblem = {}) {
326
- const code = text(problem.code) ?? 'X_RPC_FAILED';
327
- const served = text(problem.docs);
328
- super({
329
- code,
330
- // The status is what says "send it again", and only where nobody has classified the code:
331
- // `UltimateError` otherwise fills `retry` from `retryFor(code)`, which fails closed, so a
332
- // 502 out of a typed read announced itself as `terminal` on the one field the framework
333
- // promises a client never has to infer.
334
- retry: retryForStatus(code, status),
335
- cause: text(problem.cause) ?? text(problem.detail) ?? `${name} returned HTTP ${status}`,
336
- fix:
337
- text(problem.fix) ??
338
- `check the gateway in front of the app, then: x queries describe ${name} --json`,
339
- // The one place a `docs` is passed: the SERVER's, re-thrown verbatim beside the cause and
340
- // fix it came with. An app that documents its own codes somewhere else is entitled to say
341
- // so, and overwriting it with this framework's page would bury the answer. Absent, the
342
- // constructor resolves `ERROR_DOCS_URL` like every other error here.
343
- ...(served === undefined ? {} : { docs: served }),
344
- });
345
- }
346
- }
347
-
348
- function text(value: unknown): string | undefined {
349
- return typeof value === 'string' && value.length > 0 ? value : undefined;
350
- }
package/src/facade.ts CHANGED
@@ -26,6 +26,7 @@ export function facadeFor<TInput extends StandardSchemaV1, TRow extends object>(
26
26
  input: def.input,
27
27
  policy: def.policy,
28
28
  ...(def.subscribes === undefined ? {} : { subscribes: def.subscribes }),
29
+ ...(def.rows === undefined ? {} : { rows: def.rows }),
29
30
  ...(def.cache === undefined ? {} : { cache: def.cache }),
30
31
  ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
31
32
  ...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }),
package/src/http.ts CHANGED
@@ -6,18 +6,18 @@
6
6
  */
7
7
 
8
8
  import { tagKeys } from '@ultimat3/cache';
9
- import { isUltimateError } from '@ultimat3/core';
10
9
  import type { Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
11
- import { json, problem, toBucket } from '@ultimat3/http';
10
+ import { toBucket } from '@ultimat3/http';
12
11
  import { coerceQuery } from '@ultimat3/schema';
13
12
  import type { Deprecation } from './deprecation';
14
- import { applyHeaders, recordDeprecatedCall, renderDeprecation } from './deprecation';
13
+ import { recordDeprecatedCall, renderDeprecation } from './deprecation';
15
14
  import { QueryDeprecationInvalidError } from './errors';
16
15
  import { derivePath } from './naming';
17
16
  import { pageControlsOf } from './page-controls';
18
17
  import { admitsAnonymous, policyCapability } from './policy-gate';
19
18
  import type { AnyQuery } from './query';
20
19
  import { queryName, runQuery } from './read';
20
+ import { recordAnswerFor } from './record-answer';
21
21
 
22
22
  /**
23
23
  * `liveFeed` -> `GET /_x/query/live-feed`. Named for the primitive rather than spelled
@@ -30,45 +30,46 @@ export function toQueryRoute(target: AnyQuery): Route {
30
30
  // Rendered ONCE, at projection: a date that cannot become a header is a mount-time refusal,
31
31
  // not a surprise on the first read.
32
32
  const sunsetting = deprecationHeadersFor(name, target.deprecated);
33
+ const answer = recordAnswerFor(target.rows);
33
34
 
34
35
  const handler = async (request: UltimateRequest): Promise<Response> => {
35
- if (sunsetting !== undefined) recordDeprecatedCall('query', name);
36
- try {
37
- // Coerced, then validated — two different jobs, and only the first one belongs to a
38
- // wire. A search string is characters, so `t.number` and `t.boolean` need the HTTP
39
- // boundary's decode (`coerceQuery` never invents data: what it cannot convert it
40
- // hands on untouched). VALIDATING here — `request.query(schema)` — would be the
41
- // second parser: the same read would answer `X_BODY_INVALID` where every other
42
- // surface answers `X_INPUT_INVALID` with the line that prints its schema. `runQuery`
43
- // is the one that decides, exactly as it does for a direct server call.
44
- //
45
- // The two page controls come OUT first (`page-controls.ts`): they are the route's, not the
46
- // read's, and a schema that refused unknown keys would otherwise refuse every paged call.
47
- const { input: values, page } = pageControlsOf(name, request.queryRaw());
48
- const input = coerceQuery(target.input, values);
49
- // With a page control the answer is the `Page` envelope `query.page()` answers a server
50
- // caller with — `{ rows, endCursor, hasNextPage }`, the same names, so a cursor read off the
51
- // wire and one read off a direct call are the same string in the same field. Without one the
52
- // answer is the bare array it has always been: every client written before the controls
53
- // existed keeps reading rows, and `hasNextPage` never rides on a row where a page marker
54
- // has no business being.
55
- const response = json(
56
- page === undefined
57
- ? await runQuery(target, input, { surface: 'http' })
58
- : await target.page(input, { ...page, surface: 'http' }),
59
- );
60
- // On the failure path too, below: a client polling a deprecated read that is currently
61
- // 403ing still has to learn the read is going away.
62
- if (sunsetting !== undefined) applyHeaders(response, sunsetting);
63
- return response;
64
- } catch (error) {
65
- // Framework errors carry their own code, status and fix line; anything else is
66
- // a bug and belongs to the server's error boundary, not to this route.
67
- if (!isUltimateError(error)) throw error;
68
- const response = problem(error);
69
- if (sunsetting !== undefined) applyHeaders(response, sunsetting);
70
- return response;
36
+ if (sunsetting !== undefined) {
37
+ recordDeprecatedCall('query', name);
38
+ // On the CONTEXT, before anything can fail: the pipeline's `response` stage merges
39
+ // `ctx.headers` into whatever answers — this handler's rows and the `error-map` stage's
40
+ // problem or error page alike. A client polling a deprecated read that is currently 403ing
41
+ // still has to learn the read is going away.
42
+ for (const [header, value] of Object.entries(sunsetting))
43
+ request.ctx.headers.set(header, value);
71
44
  }
45
+ // No `catch`: an error — framework or not — takes the path every route's error takes, the
46
+ // repair `@ultimat3/action`'s `toRoute` got. This answered `problem(error)` itself, so the
47
+ // `error-map` stage never saw it: no `onError`/`reportError` on a 5xx, no HTML error page or
48
+ // sign-in redirect for a browser, no `requestId`, no `Retry-After`.
49
+ //
50
+ // Coerced, then validated — two different jobs, and only the first one belongs to a
51
+ // wire. A search string is characters, so `t.number` and `t.boolean` need the HTTP
52
+ // boundary's decode (`coerceQuery` never invents data: what it cannot convert it
53
+ // hands on untouched). VALIDATING here — `request.query(schema)` — would be the
54
+ // second parser: the same read would answer `X_BODY_INVALID` where every other
55
+ // surface answers `X_INPUT_INVALID` with the line that prints its schema. `runQuery`
56
+ // is the one that decides, exactly as it does for a direct server call.
57
+ //
58
+ // The two page controls come OUT first (`page-controls.ts`): they are the route's, not the
59
+ // read's, and a schema that refused unknown keys would otherwise refuse every paged call.
60
+ const { input: values, page } = pageControlsOf(name, request.queryRaw());
61
+ const input = coerceQuery(target.input, values);
62
+ // With a page control the answer is the `Page` envelope `query.page()` answers a server
63
+ // caller with — `{ rows, endCursor, hasNextPage }`, the same names, so a cursor read off the
64
+ // wire and one read off a direct call are the same string in the same field. Without one the
65
+ // answer is the bare array it has always been: every client written before the controls
66
+ // existed keeps reading rows, and `hasNextPage` never rides on a row where a page marker
67
+ // has no business being.
68
+ return answer(
69
+ page === undefined
70
+ ? await runQuery(target, input, { surface: 'http' })
71
+ : await target.page(input, { ...page, surface: 'http' }),
72
+ );
72
73
  };
73
74
 
74
75
  const meta: RouteMeta = {
package/src/index.ts CHANGED
@@ -66,11 +66,11 @@ export { queryClient, queryClientMethodFor } from './client';
66
66
  /** The compat window a retirement gets. Versioning is two deployments, not a router feature. */
67
67
  export type { Deprecation, DeprecationField, DeprecationRender } from './deprecation';
68
68
  export { recordDeprecatedCall, renderDeprecation } from './deprecation';
69
- export type { QueryProblem } from './errors';
70
69
  export {
71
70
  CursorInvalidError,
72
71
  CursorValueUnsupportedError,
73
72
  MatcherUnsupportedError,
73
+ QueryColumnUnselectedError,
74
74
  QueryDeniedError,
75
75
  QueryDeprecationInvalidError,
76
76
  QueryDuplicateError,
@@ -79,7 +79,6 @@ export {
79
79
  QueryInputUnencodableError,
80
80
  QueryNotPageableError,
81
81
  QueryPolicyMissingError,
82
- QueryRequestFailedError,
83
82
  QuerySubscribesDriftError,
84
83
  QuerySubscribesInvalidError,
85
84
  QueryUnregisteredError,
@@ -87,7 +86,7 @@ export {
87
86
  /** The HTTP projection: `GET /_x/query/<kebab>`, the URL `client()` derives. */
88
87
  export { toQueryRoute } from './http';
89
88
  export type { LiveCursor, LiveQuery, ResumeMode, ResumePlan, ToLiveOptions } from './live';
90
- export { advanceCursor, liveEpoch, planResume, seekOf, toLiveQuery } from './live';
89
+ export { planResume, seekOf, toLiveQuery } from './live';
91
90
  export type { ChangeEvent, ChangeOp, Patch } from './matcher';
92
91
  export { assertMatchable, match, positionFor } from './matcher';
93
92
  export type { QueryToolDescriptor, QueryToolReadOptions } from './mcp-tool';
@@ -96,7 +95,7 @@ export { isExposed, toQueryTool, toQueryTools } from './mcp-tool';
96
95
  * Path derivation only. There is no `toToolName`: an MCP tool is served under the export name
97
96
  * verbatim, and an exported derivation would be a second way to spell one tool.
98
97
  */
99
- export { derivePath, toKebabCase } from './naming';
98
+ export { derivePath } from './naming';
100
99
  /**
101
100
  * The read half of `openapi.json`. `@ultimat3/cli` merges these paths into `@ultimat3/action`'s
102
101
  * `buildOpenApi` document — the two packages are one tier and cannot compose each other.
@@ -107,7 +106,7 @@ export { queryOpenApiPaths, toQueryOpenApiOperation } from './openapi';
107
106
  * shape the typed client's `.page()` takes. `MAX_PAGE_SIZE` is the bound both ends check.
108
107
  */
109
108
  export type { PageControls } from './page-controls';
110
- export { MAX_PAGE_SIZE, PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-controls';
109
+ export { MAX_PAGE_SIZE } from './page-controls';
111
110
  /**
112
111
  * The shapes `query.page(input, { first, after })` takes and answers with. `paginate` itself is
113
112
  * deliberately unexported: a page is the read's own answer, and a second, importable way to ask
@@ -141,7 +140,7 @@ export type {
141
140
  QueryRateLimit,
142
141
  SourceOptions,
143
142
  } from './query';
144
- export { describeQuery, isQuery, nameQuery, query, queryHash } from './query';
143
+ export { describeQuery, isQuery, query, queryHash } from './query';
145
144
  /** The one read path. `defOf` stays unexported — that is the enforcement. */
146
145
  export { queryName, runQuery, sourceFor } from './read';
147
146
 
@@ -166,7 +165,6 @@ export {
166
165
  compareValues,
167
166
  isNull,
168
167
  matchesFilter,
169
- matchesFilters,
170
168
  seekKeyOf,
171
169
  totalOrder,
172
170
  } from './shape';
package/src/naming.ts CHANGED
@@ -1,23 +1,13 @@
1
1
  /**
2
- * The one naming rule for reads: a query's export name derives its HTTP path.
3
- * Pure string math, so the browser client derives the same URL without importing
4
- * a byte of server code. Ported rather than imported from @ultimat3/action: that
5
- * package is the same tier, and tiers never go sideways. The MCP tool name is NOT
6
- * derived — it is the export name verbatim, so there is one name to call.
2
+ * The naming rule for reads, as this package has always exported it: a query's export name
3
+ * derives its HTTP path. The rule itself is `@ultimat3/core`'s `client-paths.ts` — one tier-0 file
4
+ * that `action`, `query` and `realtime` all read, so the path cannot be derived two ways. The MCP
5
+ * tool name is NOT derived — it is the export name verbatim, so there is one name to call.
7
6
  */
8
7
 
9
- /** Every read is served under one prefix, so a router can claim it in one rule. */
10
- const QUERY_PREFIX = '/_x/query';
8
+ import { queryPath, splitWords } from '@ultimat3/core/page';
11
9
 
12
- /** camelCase / PascalCase / SCREAMING_SNAKE -> lowercase words. */
13
- export function splitWords(name: string): string[] {
14
- return name
15
- .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
16
- .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
17
- .split(/[\s_-]+/)
18
- .filter((word) => word.length > 0)
19
- .map((word) => word.toLowerCase());
20
- }
10
+ export { splitWords };
21
11
 
22
12
  /** `liveFeed` -> `live-feed`. */
23
13
  export function toKebabCase(name: string): string {
@@ -25,10 +15,8 @@ export function toKebabCase(name: string): string {
25
15
  }
26
16
 
27
17
  /**
28
- * `liveFeed` -> `/_x/query/live-feed`, read with `GET …?orgId=…`. A read is a GET
29
- * under its own prefix so a CDN, a browser cache and a log line can all tell it
30
- * apart from an action's `POST /api/...` without parsing a body.
18
+ * `liveFeed` -> `/_x/query/live-feed`, read with `GET …?orgId=…`. A read is a GET under its own
19
+ * prefix so a CDN, a browser cache and a log line can all tell it apart from an action's
20
+ * `POST /api/...` without parsing a body.
31
21
  */
32
- export function derivePath(name: string): string {
33
- return `${QUERY_PREFIX}/${toKebabCase(name)}`;
34
- }
22
+ export const derivePath: (name: string) => string = queryPath;