@ultimat3/query 20.2.0 → 21.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 CHANGED
@@ -17,9 +17,10 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
17
17
  | `facade.ts` | the fluent surface — binds each projection to the query, re-implements none |
18
18
  | `http.ts` | route projection (`GET /_x/query/<kebab>`, `enforcedBy: 'handler'`) |
19
19
  | `mcp-tool.ts` | MCP read descriptor, same `sourceFor` |
20
- | `client.ts` | typed read client (browser-safe: no server imports) |
20
+ | `client.ts` | typed read client (browser-safe: no server imports); dispatches through `@ultimat3/core`'s `clientTransport`, never `fetch` |
21
+ | `record-answer.ts` | a read's HTTP answer: bare rows, or the record envelope when `rows:` is an entity's branded row schema |
21
22
  | — | opt-in flight control is **`@ultimat3/core`**'s `client-flight.ts` + `client-wire.ts`, re-exported from `src/index.ts`. There is no local copy and must not be one |
22
- | `naming.ts` | export name → `/_x/query/<kebab>`. Pure string math. **Paths only** — no tool name |
23
+ | `naming.ts` | export name → `/_x/query/<kebab>` — `derivePath` IS `@ultimat3/core`'s `queryPath`, re-exported. **Paths only** — no tool name |
23
24
  | `registry.ts` | export-name registration, `describeQueries()`, and the `registerPrimitiveRegistrar('query', …)` announcement |
24
25
  | `live.ts` | `LiveQuery` descriptor + cursor arithmetic |
25
26
  | `subscribes.ts` | the relations a live read declares, and the two assertions that keep them true |
@@ -193,14 +194,46 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
193
194
  promises. It lives in `packages/core/src/client-flight.ts` now; the tests that pin it from this
194
195
  side still drive it through this package's own client.
195
196
  - **`ClientFlight` is a TYPE inside `client.ts` and never a value.** That erasure is the entire
196
- tree-shaking story: `rpc` alone is 14,759 B minified for the browser and `queryClient` alone is
197
- 12,755 B, against 20,292 B / 17,912 B with `createClientFlight` imported beside them — ±376 B run
198
- to run, which is `Bun.build` 1.4.0 dropping core's `schema-error-codes.ts` (issue #273). A caller
199
- who wants a plain typed fetch must not pay for the fence, the dedup map or the retry loop —
200
- `packages/cli/src/templates/resource-form-island.ts` and `examples/dummy`'s contact-sales island
201
- both write a bare `fetch` today because that bill used to be unavoidable. Never import
202
- `createClientFlight` for a VALUE from `client.ts` — `ClientFlight` and `ClientRetry` are
203
- `import type` from `@ultimat3/core` and must stay that way.
197
+ tree-shaking story: a caller who wants a plain typed read must not pay for the fence, the dedup
198
+ map or the retry loop. Never import `createClientFlight` for a VALUE from `client.ts` —
199
+ `ClientFlight` and `ClientRetry` are `import type` from `@ultimat3/core` and must stay that way.
200
+ - **Every read dispatches through `@ultimat3/core`'s `clientTransport`, and `client.ts` calls no
201
+ `fetch`** (`As of 2026-09-22`, 21.0.0, plan 101 slice 05). The `fetch` option is handed on as the
202
+ transport's injected `fetchImpl`. The transport owns `Accept`, the trace/budget headers, the
203
+ principal fence, the error decode and the record envelope — so a gateway's HTML is core's
204
+ `X_CLIENT_TRANSPORT_FAILED` now, and `QueryRequestFailedError` / `X_RPC_FAILED` are gone from this
205
+ package. The flight is handed to the transport, which is what dedups concurrent identical reads
206
+ (`client.test.ts`, one `fetchImpl` call); `fresh` and a per-call `retry` ride on the request
207
+ to it. The path is core's `queryPath` — `naming.ts` re-exports it as `derivePath`. `slice 15`'s `browser-transport` guard is what will make
208
+ a `fetch(` call here a build error; until then the check is
209
+ `rg -n 'fetch\(' packages/query/src --glob '!*.test.ts'` finding nothing.
210
+ - **Bundle, `bun build --target=browser --minify`, `import { queryClient } from '@ultimat3/query'`**
211
+ (`As of 2026-09-22`): **19,026 B** at 20.2.1 (raw `fetch`), **24,114 B** on `clientTransport`;
212
+ the deep `src/client.ts` import 15,690 → 20,774 B. The +5.1 kB is entirely core's transport graph
213
+ (`client-transport.ts`, `client-dispatch.ts`, `client-problem.ts`, `record-envelope.ts`,
214
+ `client-scope.ts`, `record-sink.ts`, `client-paths.ts`); `client.ts` itself shrank. The 12,755 B this file
215
+ quoted was already false at 20.2.1. Shrinking it is a `packages/core` edit, never a raw `fetch`
216
+ here.
217
+ - **`@ultimat3/query/client` is the read client without the barrel** (`As of 2026-09-22`). The
218
+ barrel anchors `errors.ts` and `registry.ts` (`sideEffects`) and exports the server projections;
219
+ an island that only reads needs neither. `import { queryClient } from '@ultimat3/query/client'`,
220
+ measured `bun build --target=browser --minify`: **16,458 B** against **23,611 B** through the
221
+ barrel. `@ultimat3/realtime`'s `useQuery` is the caller it exists for. `client-subpath.test.ts`
222
+ pins that the specifier resolves and carries no server export.
223
+ - **…and reaches no titles table** (`As of 2026-09-22`, same method): **10,210 B**, from 16,640 B
224
+ that morning. `client.ts` and `naming.ts` import core from `@ultimat3/core/page`, and the two
225
+ page keys live in `page-keys.ts` — a leaf, because `page-controls.ts` refuses a bad value and so
226
+ imports `errors.ts`, which registers this package's whole code table at import. `client.ts` may
227
+ never import `page-controls.ts` or `stable.ts` again; `client-bundle.test.ts` fails on
228
+ `core-error-codes.ts`, `schema-error-codes.ts` or `errors.ts` in the graph, and past 11 kB.
229
+ - **The record envelope is derived from `rows:`, never switched on** (`record-answer.ts`). A read's
230
+ `sql:` names its table as a STRING, so the query declaration carries no schema the envelope could
231
+ be derived from; `rows?: StandardSchemaV1<unknown, TRow>` is that carrier, typed against the row
232
+ `sql:` answers so a projection cannot claim a full entity's schema. `answersRecords(rows)`
233
+ (`hasEntityRows`, `@ultimat3/entity`) is decided ONCE at projection and read by both `http.ts`
234
+ and `openapi.ts`; the envelope is sent on EVERY answer (`records: {}` when none came back) — one body
235
+ shape per operation, action's rule, described once by core's `recordEnvelopeSchema`. No `rows:`,
236
+ or an unbranded one, is byte-identical on the wire and in `openapi.json`.
204
237
  - **The `sideEffects` array is what makes the barrel shakable, and it is load-bearing** (`As of
205
238
  2026-08-23`). Declaring nothing meant a bundler had to assume every module ran at import, so
206
239
  `import { rpc } from '@ultimat3/action'` was 43,104 B and `import { queryClient } from
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,12 @@ 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
 
487
523
  ## Boundaries
488
524
 
489
- Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `policy`. Never imports `action`,
525
+ Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `entity`, `http`, `policy`. Never imports `action`,
490
526
  `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.0",
3
+ "version": "21.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.0",
39
- "@ultimat3/core": "20.2.0",
40
- "@ultimat3/http": "20.2.0",
41
- "@ultimat3/policy": "20.2.0",
42
- "@ultimat3/schema": "20.2.0"
39
+ "@ultimat3/cache": "21.0.0",
40
+ "@ultimat3/core": "21.0.0",
41
+ "@ultimat3/entity": "21.0.0",
42
+ "@ultimat3/http": "21.0.0",
43
+ "@ultimat3/policy": "21.0.0",
44
+ "@ultimat3/schema": "21.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
  /**
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
@@ -29,11 +29,12 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
29
29
  };
30
30
 
31
31
  /**
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.
32
+ * Codes `@ultimat3/action` owns that this package only throws. It describes an action's job —
33
+ * enforcing an input schema — so action declares the title and query never re-declares it: two
34
+ * copies of a title are two titles, one of which is stale. `X_RPC_FAILED` left this list in
35
+ * 21.0.0: the typed read client's wire failures are `@ultimat3/core`'s `clientTransport`'s now.
35
36
  */
36
- export const QUERY_BORROWED_ERROR_CODES = ['X_INPUT_INVALID', 'X_RPC_FAILED'] as const;
37
+ export const QUERY_BORROWED_ERROR_CODES = ['X_INPUT_INVALID'] as const;
37
38
 
38
39
  // One unconditional call: a presence guard would turn "another package claims one of these codes"
39
40
  // from an X_ERROR_CODE_DUPLICATE at import into whichever module loaded first deciding the title.
@@ -305,46 +306,3 @@ export class QueryInputInvalidError extends UltimateError {
305
306
  });
306
307
  }
307
308
  }
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
@@ -8,7 +8,7 @@
8
8
  import { tagKeys } from '@ultimat3/cache';
9
9
  import { isUltimateError } from '@ultimat3/core';
10
10
  import type { Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
11
- import { json, problem, toBucket } from '@ultimat3/http';
11
+ import { problem, toBucket } from '@ultimat3/http';
12
12
  import { coerceQuery } from '@ultimat3/schema';
13
13
  import type { Deprecation } from './deprecation';
14
14
  import { applyHeaders, recordDeprecatedCall, renderDeprecation } from './deprecation';
@@ -18,6 +18,7 @@ import { pageControlsOf } from './page-controls';
18
18
  import { admitsAnonymous, policyCapability } from './policy-gate';
19
19
  import type { AnyQuery } from './query';
20
20
  import { queryName, runQuery } from './read';
21
+ import { recordAnswerFor } from './record-answer';
21
22
 
22
23
  /**
23
24
  * `liveFeed` -> `GET /_x/query/live-feed`. Named for the primitive rather than spelled
@@ -30,6 +31,7 @@ export function toQueryRoute(target: AnyQuery): Route {
30
31
  // Rendered ONCE, at projection: a date that cannot become a header is a mount-time refusal,
31
32
  // not a surprise on the first read.
32
33
  const sunsetting = deprecationHeadersFor(name, target.deprecated);
34
+ const answer = recordAnswerFor(target.rows);
33
35
 
34
36
  const handler = async (request: UltimateRequest): Promise<Response> => {
35
37
  if (sunsetting !== undefined) recordDeprecatedCall('query', name);
@@ -52,7 +54,7 @@ export function toQueryRoute(target: AnyQuery): Route {
52
54
  // answer is the bare array it has always been: every client written before the controls
53
55
  // existed keeps reading rows, and `hasNextPage` never rides on a row where a page marker
54
56
  // has no business being.
55
- const response = json(
57
+ const response = answer(
56
58
  page === undefined
57
59
  ? await runQuery(target, input, { surface: 'http' })
58
60
  : await target.page(input, { ...page, surface: 'http' }),
package/src/index.ts CHANGED
@@ -66,7 +66,6 @@ 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,
@@ -79,7 +78,6 @@ export {
79
78
  QueryInputUnencodableError,
80
79
  QueryNotPageableError,
81
80
  QueryPolicyMissingError,
82
- QueryRequestFailedError,
83
81
  QuerySubscribesDriftError,
84
82
  QuerySubscribesInvalidError,
85
83
  QueryUnregisteredError,
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;
package/src/openapi.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { tagKeys } from '@ultimat3/cache';
19
+ import { RECORDS_OPENAPI_HEADER, recordEnvelopeSchema } from '@ultimat3/core';
19
20
  import type { SchemaNode } from '@ultimat3/schema';
20
21
  import { nodeToJsonSchema, tryIntrospect } from '@ultimat3/schema';
21
22
  import { isExposed } from './mcp-tool';
@@ -24,6 +25,7 @@ import { MAX_PAGE_SIZE, PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-controls';
24
25
  import { policyCapability } from './policy-gate';
25
26
  import type { AnyQuery } from './query';
26
27
  import { queryName } from './read';
28
+ import { answersRecords } from './record-answer';
27
29
  import { listQueries } from './registry';
28
30
 
29
31
  /**
@@ -37,6 +39,24 @@ const QUERY_TAG = 'query';
37
39
 
38
40
  export type OpenApiPathItem = Record<string, unknown>;
39
41
 
42
+ /**
43
+ * Two shapes, decided by the request: the bare rows every client read before the controls
44
+ * existed, and the page envelope when `_first` is sent. `oneOf` and not a second operation — it is
45
+ * one route, one policy evaluation, one URL prefix.
46
+ */
47
+ const ANSWERS: readonly Record<string, unknown>[] = [
48
+ { type: 'array', items: {} },
49
+ {
50
+ type: 'object',
51
+ required: ['rows', 'endCursor', 'hasNextPage'],
52
+ properties: {
53
+ rows: { type: 'array', items: {} },
54
+ endCursor: { type: ['string', 'null'] },
55
+ hasNextPage: { type: 'boolean' },
56
+ },
57
+ },
58
+ ];
59
+
40
60
  /** `{ [path]: { get: operation } }` for every registered read, name-sorted like the actions. */
41
61
  export function queryOpenApiPaths(
42
62
  queries: readonly AnyQuery[] = listQueries(),
@@ -70,22 +90,14 @@ export function toQueryOpenApiOperation(target: AnyQuery): Record<string, unknow
70
90
  // Two shapes, decided by the request: the bare rows every client read before the
71
91
  // controls existed, and the page envelope when `_first` is sent. `oneOf` and not a
72
92
  // second operation — it is one route, one policy evaluation, one URL prefix.
73
- schema: {
74
- oneOf: [
75
- { type: 'array', items: {} },
76
- {
77
- type: 'object',
78
- required: ['rows', 'endCursor', 'hasNextPage'],
79
- properties: {
80
- rows: { type: 'array', items: {} },
81
- endCursor: { type: ['string', 'null'] },
82
- hasNextPage: { type: 'boolean' },
83
- },
84
- },
85
- ],
86
- },
93
+ // A read declaring an entity's `rows:` ALWAYS answers the envelope, with the same two
94
+ // shapes under `data` — core's one description, shared with the action projection.
95
+ schema: answersRecords(target.rows)
96
+ ? recordEnvelopeSchema({ oneOf: ANSWERS })
97
+ : { oneOf: ANSWERS },
87
98
  },
88
99
  },
100
+ ...(answersRecords(target.rows) ? { headers: RECORDS_OPENAPI_HEADER } : {}),
89
101
  },
90
102
  // `X_INPUT_INVALID` is the search string failing the read's own schema, or a page control
91
103
  // outside its bound; `X_CURSOR_INVALID` is an `_after` that is not one of this read's. Both
@@ -12,11 +12,9 @@
12
12
  // `input-shape.ts` refuses a declaration that reaches for it, so the two names can never collide.
13
13
 
14
14
  import { QueryInputInvalidError } from './errors';
15
+ import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-keys';
15
16
 
16
- /** `?_first=20` — the page size. Present is what makes the route answer a `Page`. */
17
- export const PAGE_FIRST_KEY = '_first';
18
- /** `?_after=<cursor>` — the signed cursor the previous page's `endCursor` carried. */
19
- export const PAGE_AFTER_KEY = '_after';
17
+ export { PAGE_AFTER_KEY, PAGE_FIRST_KEY };
20
18
  export const PAGE_CONTROL_KEYS: readonly string[] = [PAGE_FIRST_KEY, PAGE_AFTER_KEY];
21
19
 
22
20
  /**
@@ -0,0 +1,9 @@
1
+ // The two search-string keys that turn `GET /_x/query/<name>` into a page — and nothing else. A
2
+ // leaf apart from `page-controls.ts` because that module refuses a bad value, which means it imports
3
+ // this package's `errors.ts`, which registers the whole code table at import: the browser client
4
+ // that only SPELLS the keys must not carry it. `page-controls.ts` re-exports both names.
5
+
6
+ /** `?_first=20` — the page size. Present is what makes the route answer a `Page`. */
7
+ export const PAGE_FIRST_KEY = '_first';
8
+ /** `?_after=<cursor>` — the signed cursor the previous page's `endCursor` carried. */
9
+ export const PAGE_AFTER_KEY = '_after';
package/src/query.ts CHANGED
@@ -89,6 +89,15 @@ export interface QueryDef<TInput extends StandardSchemaV1, TRow extends object>
89
89
  * is legal — a join reads relations the shape's single `entity` cannot name.
90
90
  */
91
91
  readonly subscribes?: readonly string[];
92
+ /**
93
+ * The row schema, when every row IS an entity's row — `rows: Post.$schema`. Typed against the
94
+ * row `sql:` answers, so a projection (`from<PostSummary>`) cannot claim a full entity's schema.
95
+ * It is what the route reads to answer the record envelope (`x-ultimate-records: 1`) so the
96
+ * page's one store adopts the rows; absent, or unbranded, the wire is the bare rows it has
97
+ * always been. `sql:` names a table as a STRING, so nothing else carries the schema to derive
98
+ * from — this key is that carrier, never a `records:` switch.
99
+ */
100
+ readonly rows?: StandardSchemaV1<unknown, TRow>;
92
101
  sql(input: InferOutput<TInput>, ctx: Ctx): SqlSource<TRow>;
93
102
  readonly cache?: QueryCache;
94
103
  readonly mcp?: QueryMcp;
@@ -179,6 +188,7 @@ export interface AnyQueryDef {
179
188
  readonly policy: QueryPolicy;
180
189
  readonly live?: boolean;
181
190
  readonly subscribes?: readonly string[];
191
+ readonly rows?: StandardSchemaV1;
182
192
  sql(input: unknown, ctx: Ctx): SqlSource<object>;
183
193
  readonly cache?: QueryCache;
184
194
  readonly mcp?: QueryMcp;
@@ -196,6 +206,8 @@ export interface AnyQuery {
196
206
  readonly policy: QueryPolicy;
197
207
  /** Lifted for the same reason `rateLimit` is: `toLiveQuery` cross-checks it without `defOf`. */
198
208
  readonly subscribes?: readonly string[];
209
+ /** Lifted for the route: whether an answer is a record envelope is decided at projection. */
210
+ readonly rows?: StandardSchemaV1;
199
211
  readonly cache?: QueryCache;
200
212
  readonly mcp?: QueryMcp;
201
213
  /** Lifted so `toQueryRoute` reads the declaration without reaching through `defOf`. */
@@ -240,6 +252,7 @@ export type QueryFacade<TInput extends StandardSchemaV1, TRow extends object> =
240
252
  | 'input'
241
253
  | 'policy'
242
254
  | 'subscribes'
255
+ | 'rows'
243
256
  | 'cache'
244
257
  | 'mcp'
245
258
  | 'rateLimit'
@@ -0,0 +1,68 @@
1
+ /**
2
+ * A read's HTTP answer. Decided ONCE per query, at projection, from its `rows:` schema — the rule
3
+ * `@ultimat3/action`'s `record-wire.ts` already follows: a query whose `rows:` is an entity row
4
+ * schema ALWAYS answers `{ data, records }` behind `x-ultimate-records: 1` (records may be `{}`),
5
+ * and every other query answers the bare rows (or `Page`) it always did. One body shape per
6
+ * operation — until 21.0.0 a read sent the envelope only when a row came back, so its OpenAPI was a
7
+ * `oneOf` over "sometimes" and a generated client had to branch on the data.
8
+ */
9
+
10
+ import type { RecordRows, Row } from '@ultimat3/core';
11
+ import { encodeRecordEnvelope, RECORDS_HEADER } from '@ultimat3/core';
12
+ import { hasEntityRows, rowsOf } from '@ultimat3/entity';
13
+ import { json } from '@ultimat3/http';
14
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
15
+ import type { Page } from './pagination';
16
+
17
+ /** Turns one read's answer into its response. Built once per route, never per request. */
18
+ export type RecordAnswer = (answer: readonly object[] | Page<object>) => Response;
19
+
20
+ /**
21
+ * `undefined` or an unbranded schema answers the plain `json()` it always did. The rows are
22
+ * collected one at a time against the ROW schema, since the answer's own shape — an array or a
23
+ * `Page` — is the route's, not the declaration's.
24
+ */
25
+ export function recordAnswerFor(rows: StandardSchemaV1 | undefined): RecordAnswer {
26
+ if (!answersRecords(rows)) return (answer) => json(answer);
27
+ return (answer) => {
28
+ const records = collect(rows, isPage(answer) ? answer.rows : answer);
29
+ return json(encodeRecordEnvelope(answer, records), { headers: { [RECORDS_HEADER]: '1' } });
30
+ };
31
+ }
32
+
33
+ /** The one predicate the route and the OpenAPI projection both ask: can this read answer records? */
34
+ export function answersRecords(rows: StandardSchemaV1 | undefined): rows is StandardSchemaV1 {
35
+ return rows !== undefined && hasEntityRows(rows);
36
+ }
37
+
38
+ /** `Array.isArray` does not narrow a `readonly` array out of a union, so this says it once. */
39
+ function isPage(answer: readonly object[] | Page<object>): answer is Page<object> {
40
+ return !Array.isArray(answer);
41
+ }
42
+
43
+ /**
44
+ * Every entity row across the answer's rows: record type -> record key -> row. First sighting wins,
45
+ * `rowsOf`'s own rule — one record shown twice is one record. Built in `Map`s and handed out as
46
+ * null-prototype objects: a record type and a record key are data, and `__proto__` must stay a key.
47
+ */
48
+ function collect(
49
+ schema: StandardSchemaV1,
50
+ list: readonly object[],
51
+ ): Readonly<Record<string, RecordRows>> {
52
+ const out = new Map<string, Map<string, Row>>();
53
+ for (const row of list) {
54
+ for (const [type, byKey] of Object.entries(rowsOf(schema, row))) {
55
+ const bucket = out.get(type) ?? new Map<string, Row>();
56
+ out.set(type, bucket);
57
+ for (const [key, found] of Object.entries(byKey))
58
+ if (!bucket.has(key)) bucket.set(key, found);
59
+ }
60
+ }
61
+ return nullProto([...out].map(([type, bucket]) => [type, nullProto(bucket)] as const));
62
+ }
63
+
64
+ function nullProto<V>(entries: Iterable<readonly [string, V]>): Readonly<Record<string, V>> {
65
+ const target = Object.create(null) as Record<string, V>;
66
+ for (const [key, value] of entries) target[key] = value;
67
+ return target;
68
+ }