@ultimat3/query 21.0.0 → 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/CLAUDE.md CHANGED
@@ -12,21 +12,21 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
12
12
 
13
13
  | File | Job |
14
14
  |---|---|
15
- | `query.ts` | the primitive: `query()`, `describeQuery`, `queryHash`; the package's front door for the read path |
15
+ | `query.ts` | the primitive: `query()`, `describeQuery`, `queryHash`; the front door for the read path |
16
16
  | `read.ts` | **the one read path** (`runQuery`, `sourceFor`) + the private declaration store `sql` lives in |
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); dispatches through `@ultimat3/core`'s `clientTransport`, never `fetch` |
20
+ | `client.ts` | typed read client (browser-safe); dispatches through `@ultimat3/core`'s `clientTransport`, never `fetch` |
21
21
  | `record-answer.ts` | a read's HTTP answer: bare rows, or the record envelope when `rows:` is an entity's branded row schema |
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 |
23
- | `naming.ts` | export name → `/_x/query/<kebab>` — `derivePath` IS `@ultimat3/core`'s `queryPath`, re-exported. **Paths only** — no tool name |
24
- | `registry.ts` | export-name registration, `describeQueries()`, and the `registerPrimitiveRegistrar('query', …)` announcement |
22
+ | — | flight control is **`@ultimat3/core`**'s `client-flight.ts` + `client-wire.ts`, re-exported. No local copy |
23
+ | `naming.ts` | export name → `/_x/query/<kebab>` — `derivePath` IS core's `queryPath`. **Paths only** |
24
+ | `registry.ts` | export-name registration, `describeQueries()`, the `registerPrimitiveRegistrar('query', …)` announcement |
25
25
  | `live.ts` | `LiveQuery` descriptor + cursor arithmetic |
26
26
  | `subscribes.ts` | the relations a live read declares, and the two assertions that keep them true |
27
27
  | `matcher.ts` | change event → minimal patch, `refill` when the window cannot place the row, or `X_MATCHER_UNSUPPORTED` |
28
28
  | `pagination.ts` | `paginate()` over core's cursor codec — no offset, ever |
29
- | `cursor-value.ts` | what a sort value becomes inside a cursor, and what it becomes again |
29
+ | `cursor-value.ts` | what a sort value becomes inside a cursor, and back |
30
30
  | `input-shape.ts` | what a read's `input:` may be, given that its route is a query STRING |
31
31
  | `sql.ts` | `explain()` / `describeSql()` |
32
32
  | `cache.ts` | the read path: the request memo, and the fill through `@ultimat3/cache`'s registered tiers |
@@ -34,561 +34,159 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
34
34
  | `source.ts` | `SqlSource` contract + `from()` in-memory reference |
35
35
  | `shape.ts` | shared read vocabulary (filters, ordering, seek keys) |
36
36
  | `policy-gate.ts` | **the only** file that touches `@ultimat3/policy` |
37
- | `deprecation.ts` | `Deprecation` + the RFC 9745/8594 render + the `deprecated_calls_total` counter — TWINNED with `@ultimat3/action`'s |
37
+ | `deprecation.ts` | `Deprecation` + RFC 9745/8594 render + `deprecated_calls_total` — TWINNED with `@ultimat3/action`'s |
38
38
 
39
- ## Invariants
39
+ ## Invariants — the read path
40
+
41
+ - Every surface goes through `sourceFor`: parse input, evaluate policy, build the source. A second
42
+ read path is the one unforgivable change here.
43
+ - **An explicit `ctx` is INSTALLED, never merely passed** — the ambient context (which
44
+ `@ultimat3/entity`'s tenant guard reads) must be the identity `guard()` decided about.
45
+ `read-context.test.ts` asserts ambient, `options.actor` and `options.ctx` are one caller.
46
+ - **Skipping a read's policy costs a WRITTEN REASON** (`unenforced?: string`; blank refused).
47
+ `ToLiveOptions.enforce` stays a boolean, translated into `SHARED_WINDOW_REASON`.
48
+ - The declaration never leaves `read.ts`. `defOf`/`stashDef`/`hasDef` are never re-exported from
49
+ `src/index.ts` — that omission is the enforcement. A query has no `.def`; outside the package read
50
+ `.input`/`.policy`/`.cache`/`.mcp`/`.isLive` or `describe()`. A projection is reached through the
51
+ query (`liveFeed.tool()`); new methods are bound in `facade.ts`.
52
+ - `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim** (`index.test.ts` asserts identity).
53
+ - **`LiveQuery` describes the read *and* runs it** (`execute()`, never memoised).
54
+ `@ultimat3/realtime`'s shared window reads through it.
55
+ - `isLive` is the declared boolean, `live()` the subscription. `QueryDescriptor.live` keeps its name
56
+ (`manifest` and `admin` read it).
57
+ - `mcp` is opt-in; `isExposed` delegates to core's `isMcpExposed`.
58
+ - **A read has ONE tool name, the export name verbatim.** `toToolName` is deleted.
59
+ `mcp-tool.test.ts` asserts `toQueryTool(q).name === queryName(q)`; `index.test.ts` asserts the
60
+ barrel exports nothing matching `/tool_?name/i`.
61
+ - **`queryClient` is the map-wide read client (mirror of `rpc`)**; both spellings run
62
+ `queryClientMethodFor`. **`toQueryRoute` and `client()` derive the same URL from `naming.ts`.**
63
+ - **The route coerces (`coerceQuery`), `runQuery` validates.** No `request.query(schema)` and no
64
+ `meta.input` on the route.
65
+ - **`rateLimit:` is declarable; `toQueryRoute` sets both `meta.rateLimit` and
66
+ `meta.rateLimitBucket`**, via `@ultimat3/http`'s `toBucket` — never a local copy.
67
+ - **`deprecated:` is a compat WINDOW**: `Deprecation` / `Sunset` / `rel="successor-version"` on every
68
+ answer, rendered ONCE at projection (`X_QUERY_DEPRECATION_INVALID` at mount). Versioning is two
69
+ deployments behind one ingress.
70
+ - **The span wraps the whole read** (`readRows` holds it, `readRowsIn` is the body); bounded
71
+ attributes only — never the input or an actor id. `telemetry.test.ts` asserts the extent through
72
+ `currentSpan()`.
73
+ - **`policyCapability` is a display label; `policyPermissions` (`QueryDescriptor.permissions`) is what
74
+ a report matches on.**
75
+ - **`client.ts` injects `traceparent`** before the caller's headers; an incomplete span context sends
76
+ nothing.
77
+ - **A read is `no-store`, and its policy is `enforcedBy: 'handler'`** — `runQuery` is the one
78
+ evaluation (`http.test.ts` counts exactly one).
79
+ - **`meta.auth` comes from `@ultimat3/policy`'s `admitsAnonymous`** (a walk of the tree, via
80
+ `policy-gate.ts`), never the root combinator.
81
+ - `registry.ts` announces `registerQueries` in core's registrar table at import; `defineApi` in
82
+ `@ultimat3/action` depends on it (`X_REGISTRAR_MISSING` otherwise).
83
+ - Authz goes through `enforce(surface, policy, { input, actor, ctx })`; a live denial keeps its 4403
84
+ close code on `QueryDeniedError.denial`. Policy runs per subscriber for live queries — never cache a
85
+ decision across actors.
86
+
87
+ ## Invariants — the client
88
+
89
+ - **Flight control is `@ultimat3/core`'s, re-exported** (`createClientFlight`,
90
+ `DEFAULT_CLIENT_RETRY`, `isTransientFailure`, `isSuperseded`, the types) — the same objects
91
+ `@ultimat3/action` exports. Fix the pipeline in `packages/core/src/client-flight.ts`. No new error
92
+ code, curve, fence or retry loop here; `bun run flight-copies` says so.
93
+ - **`isTransientFailure` INVERTS `retryDecision`'s unclassified default** — a caller's own
94
+ `AbortError` is terminal. Must survive.
95
+ - **`ClientFlight` is a TYPE inside `client.ts`, never a value.**
96
+ - **Every read dispatches through core's `clientTransport`; `client.ts` calls no `fetch`.** The
97
+ transport owns `Accept`, trace/budget headers, the principal fence, the error decode and the record
98
+ envelope; the flight handed to it dedups concurrent identical reads. `bun run browser-transport`
99
+ is the guard.
100
+ - **`@ultimat3/query/client` is the read client without the barrel** — `client.ts` and `naming.ts`
101
+ import core from `@ultimat3/core/page`, the page keys live in the leaf `page-keys.ts`, and
102
+ `client.ts` never imports `page-controls.ts` or `stable.ts`. `client-bundle.test.ts` fails on any
103
+ titles table in the graph or past 11 kB; `client-subpath.test.ts` pins the specifier. Byte figures
104
+ (`As of 2026-09-22`, browser, minified): barrel `queryClient` 24,114 B; `./client` 10,210 B.
105
+ - **The record envelope is derived from `rows:`** — `answersRecords(rows)` (`hasEntityRows`,
106
+ `@ultimat3/entity`), decided once at projection for `http.ts` and `openapi.ts`, sent on every
107
+ answer. No `rows:` is byte-identical on the wire.
108
+ - **The `sideEffects` array is load-bearing**: `errors.ts` and `registry.ts` run at import. Never `false`.
109
+
110
+ ## Invariants — numbers, search, live declarations
111
+
112
+ - **Every numeric option is refused when not FINITE** (`finiteOption()`: the read cache's `ttlMs`,
113
+ `search()`'s `page.max`, `page.default`, `termMax`). `finite-bounds` pins this package at zero.
114
+ - **`search()` is a FACTORY over `query()`**: it owns the input schema (`q` + a bounded `limit`),
115
+ refuses a blank term and calls `.search(term)` on the chain the app hands it (structural
116
+ `SearchChain`). No `PRIMITIVE_FACTORIES` row (it returns a query from the query package). **It serves
117
+ ONE page**: `windowOf.execute` refuses only when rows would be CUT; `onePage.seek` narrows with
118
+ `Builder.limit()`, never `Builder.seek()`, so the chain's own ranking survives; no `total()`.
119
+ `search.test.ts`'s fixture must stay NOT id-ascending.
120
+ - **`subscribes:` is DECLARED (nothing can derive it) and CROSS-CHECKED**: `toLiveQuery` asserts
121
+ `shape.entity` is among the declared names at first subscribe (`X_QUERY_SUBSCRIBES_DRIFT`) —
122
+ membership, never equality. An empty list, or one on a non-live read, is refused at `query()`
123
+ (`X_QUERY_SUBSCRIBES_INVALID`).
124
+ - **A read's `input:` must survive a query STRING** (`input-shape.ts`, `X_QUERY_INPUT_UNENCODABLE`):
125
+ refused are structural members (`object`, `record`, `money`, or an array/union of one), a REQUIRED
126
+ nullable member, and a non-object top level. An un-introspectable schema is left alone.
127
+
128
+ ## Invariants — ordering, cursors, the matcher
40
129
 
41
- - Every surface goes through `sourceFor`: parse input, evaluate policy, build the source.
42
- Adding a second read path is the one unforgivable change here.
43
- - **An explicit `ctx` is INSTALLED, never merely passed** (`As of 2026-08`). `asActor` used to hand
44
- `options.ctx` to `run(ctx)` and enter no `runWithContext` unless an `actor` was also given, so
45
- `guard()` decided about that actor while everything reading the AMBIENT context — above all
46
- `@ultimat3/entity`'s tenant guard, which derives from `tryUseContext()` and not from the ctx it is
47
- handed — saw a different identity, or none. A read was authorised as one caller and scoped to
48
- nobody. Absent a `ctx` it reinstalls the ambient one, which is a no-op on every path that already
49
- worked. The twin fix is `@ultimat3/action`'s `invoke`, and `read-context.test.ts` is written as an
50
- equality between the three spellings of one caller — ambient, `options.actor`, `options.ctx` —
51
- because three independent expectations are exactly what let this ship.
52
- - **Skipping a read's policy costs a WRITTEN REASON, never a boolean** (`As of 2026-08`).
53
- `SourceOptions.enforce?: boolean` is gone; it is `unenforced?: string`, and a blank one is
54
- refused before the source is built. The bar is `@ultimat3/entity`'s `cross-tenant.ts`: a boolean
55
- argument "reads exactly like forgetting the tenant", and a forgotten policy reads the same way —
56
- so the reason IS the mechanism and every skipped policy is one `grep` away with its justification
57
- attached. It is deliberately NOT capability-gated the way `crossTenant` is: `explain` runs from
58
- the CLI with no actor to check a scope against, and gating it would close the one surface it
59
- exists for. **`ToLiveOptions.enforce` stays a boolean**, because it is that one use with exactly
60
- one reason — `toLiveQuery` translates it into the reason string spelled once as
61
- `SHARED_WINDOW_REASON`, so a sync node and this file cannot disagree about why the shared window
62
- has no subject. Two shipped callers, both reading no rows for a subscriber: `sql.ts` and that
63
- window.
64
- - The declaration never leaves `read.ts`. `defOf`/`stashDef`/`hasDef` are internal and must
65
- never be re-exported from `src/index.ts` — that omission is the enforcement.
66
- - A query has no `.def`. Inside the package read it with `defOf(target)`; outside, read the
67
- lifted `.input`/`.policy`/`.cache`/`.mcp`/`.isLive` or `describe()`.
68
- - App code reaches a projection through the query (`liveFeed.tool()`), never through `.def`
69
- and never by importing the projection function. `facade.ts` is where a new method is bound;
70
- the projection itself keeps living in its own file.
71
- - `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim**, so a query file imports
72
- one package. Never wrap, spread or re-declare it: `t` delegates to `schemaProvider()` on every
73
- access, and a copy would freeze the provider at import time. `index.test.ts` asserts identity.
74
- - **`LiveQuery` describes the read *and* runs it.** `execute()` is the source the shape, the reads
75
- and `sqlText` were taken from — one build of one `(query, input)`. A caller that wanted rows and
76
- called `sourceFor` itself was a second build: twice the parse, twice the `sql()`, and a matcher
77
- describing a source the rows never came from. `@ultimat3/realtime`'s shared window is the one
78
- consumer, and it reads through `execute()`. It never memoises — a subscriber joining an existing
79
- subscription must see the rows as they are now, not the window someone else opened.
80
- - `isLive` is the declared boolean, `live()` is the subscription. Never name one after the other.
81
- `QueryDescriptor.live` keeps its name — `@ultimat3/manifest` and `@ultimat3/admin` read it.
82
- - `mcp` is opt-in (`expose: true`), exactly as it is for an action: rows reach an agent only when
83
- the author said so. `isExposed` here delegates to `isMcpExposed` in `@ultimat3/core` — the one
84
- predicate every reader in the framework asks — rather than spelling `=== true` a second time.
85
- - **A read has ONE tool name, and it is the export name verbatim** (`As of 2026-08`). `toQueryTool`
86
- snake_cased it (`liveFeed` → `live_feed`) while `@ultimat3/mcp` serves the read under
87
- `queryName(target)` and answers `tools/call` for nothing else — so anything that read the name off
88
- the descriptor rather than off `tools/list` called a tool the server had never heard of, and the
89
- scope map in `defineAppMcp` is keyed on the verbatim name too. `toToolName` is **deleted**, not
90
- merely unused: an exported derivation is a second way to spell one tool. Two pins, both in this
91
- package because this is where the rule can be broken — `mcp-tool.test.ts` asserts
92
- `toQueryTool(q).name === queryName(q)` (the presence), and `index.test.ts` asserts the barrel
93
- exports no key matching `/tool_?name/i` (the absence, which nothing else catches: the only other
94
- guard is `packages/mcp/src/cross-surface.test.ts`, tier 4 and unimportable from here).
95
- `naming.ts` derives PATHS only.
96
- - `client.ts` stays free of server imports — it is bundled into the browser. `@ultimat3/action`
97
- is the same tier, so its naming is ported here, never imported.
98
- - **`queryClient` is the map-wide read client and the mirror of `rpc`; both spellings run
99
- `queryClientMethodFor`.** `queryClient<Api['queries']>({ baseUrl })` is how a surface that must
100
- not import a feature reaches every registered read — `site/` in an app, whose one edge into
101
- `app/` is a boundary violation, so `.client()` (which needs the query object) is unreachable
102
- there. The map-wide client re-deriving a path from the property name would be the second URL
103
- derivation this package spent a release removing; it proxies to the per-query method instead.
104
- - **`toQueryRoute` is the other half of `client()`, and the two derive the same URL from the same
105
- `naming.ts`.** The client shipped fetching `/_x/query/<kebab>` while nothing built a route for
106
- it, so every typed read compiled and 404'd; a projection whose only consumer is a URL string is
107
- the failure this pairing exists to prevent. Named for the primitive rather than `toRoute`,
108
- because a host mounts it beside `@ultimat3/action`'s — the same reason the tool projection here
109
- is `toQueryTool`.
110
- - **The route coerces, `runQuery` validates, and only the first belongs to the wire.** A search
111
- string is characters, so the boundary decodes it with `@ultimat3/schema`'s `coerceQuery` — the
112
- one HTTP-boundary decoder, which never invents data and hands on what it cannot convert.
113
- Validating there as well (`request.query(schema)`) would be the second parser: the same read
114
- would answer `X_BODY_INVALID` where every other surface answers `X_INPUT_INVALID` and prints
115
- its schema. For the same reason `meta.input` stays **absent** — the pipeline's body stage
116
- validates it against a body, and a GET has none, so declaring it fails every read on nothing.
117
- - **`rateLimit:` is declarable on a read, and `toQueryRoute` sets the NAME and the NUMBERS.**
118
- `QueryDef` had neither until 2026-08, so every `GET /_x/query/*` fell through `bucketFor` to
119
- `default` — 120 burst, 2/s per actor — and one authenticated caller could hold 120 cross-tenant
120
- aggregates in flight and then 2/s indefinitely, from a single account, with no declaration able
121
- to say otherwise. The conversion is `toBucket` from **`@ultimat3/http`**, never a copy here:
122
- http owns `Bucket` and the maths, `@ultimat3/action` is the same tier as this package, and a
123
- copy in either is a second answer for the other. The field is lifted onto the facade so
124
- `toQueryRoute` reads it without `defOf`, exactly as `cache` and `mcp` are.
125
- - **`deprecated:` is a compat WINDOW; versioning is two deployments behind one ingress.** Headers
126
- are rendered ONCE at projection, so an unparseable date is `X_QUERY_DEPRECATION_INVALID` at
127
- mount and not on the first read. `deprecation.ts` is a TWIN of `@ultimat3/action`'s — both are
128
- tier 3, so neither may import the other, and the shared home is `@ultimat3/http` if it grows
129
- one. The same compromise `naming.ts` is ported under; keep the two files identical in behaviour.
130
- - **The span wraps the whole read, not `source.execute()`.** Parse, policy and `sql()`'s own
131
- construction were outside every span, so a read whose cost was in building the source reported
132
- milliseconds under a parent that reported seconds — a gap with no name. `readRows` holds the
133
- span and `readRowsIn` is the body; attributes are bounded (surface, actor KIND, `live`,
134
- `cached`, `fresh`) plus the row count, and never the input or an actor id — a read is keyed per
135
- tenant and per cursor, so either would be unbounded. `telemetry.test.ts` asserts the extent
136
- structurally through `currentSpan()`, because the test clock is frozen. `sourceFor` still has no
137
- span of its own: adding one would double-span every read that goes through `readRows`.
138
- - **`policyCapability` is a display label and `policyPermissions` is what a report matches on.**
139
- A composite renders as `or(feed:read, org:administer)`, which equals no permission string, so
140
- `x policy list` matching on `capability` reported every non-trivially-guarded read's permissions
141
- as unenforced. `QueryDescriptor.permissions` is the flattened list, published beside
142
- `capability` and never instead of it.
143
- - **`client.ts` injects `traceparent`**, before the caller's own headers so an explicit one wins,
144
- and sends nothing when the span context is incomplete — `00-<trace>--01` is a header every
145
- collector drops. The twin of `@ultimat3/action`'s, ported for the same tier reason.
146
- - **A read is `no-store`, and its policy is `enforcedBy: 'handler'`.** The URL names no actor
147
- while the answer is scoped to one, so `public` would hand one reader's rows to the next caller
148
- of that URL; and `runQuery` is the read's one evaluation, deciding from the parsed input, so an
149
- authz stage deciding first would be a second authz system holding raw strings — and would
150
- demand an `authorize` hook to decide at all. `http.test.ts` drives both over the real pipeline
151
- with no hook wired and counts the evaluations: exactly one.
152
- - **`meta.auth` is derived from a WALK of the policy tree**, never from the root combinator.
153
- `target.policy.kind === 'allow'` answered `'required'` for `or(allow(), can('x:y'))`, so the
154
- pipeline's `auth` stage 401'd an anonymous caller the policy itself ALLOWS — while the MCP tool
155
- and a direct server read let that caller through the same object. One policy, a different answer
156
- per surface. `'public'` here is `meta.auth` only: the read is still `cache: no-store` and
157
- `runQuery` still evaluates the policy per caller.
158
- **`admitsAnonymous` is `@ultimat3/policy`'s** (`policy.ts`, beside `policyPermissions`) and
159
- reaches this package through `policy-gate.ts` like every other authz question — never a copy
160
- here. It cannot be one: `@ultimat3/action` needs the identical answer and is the same tier, so a
161
- copy in either is a second answer for the other. It is EXACT rather than heuristic — with
162
- `actor === null`, `can()` short-circuits before its predicate and `allow()`/`deny()` ignore their
163
- arguments, so the tree alone decides. `packages/policy/src/policy.test.ts` asserts it against
164
- `policy.run({ actor: null })` itself, case for case; `http.test.ts` proves this projection reads
165
- the answer, over the real pipeline.
166
- - `registry.ts` announces `registerQueries` in core's registrar table at import. That is how
167
- `defineApi({ queries })` in `@ultimat3/action` registers a read without importing this package
168
- sideways. Never remove the announcement: `defineApi` would then throw `X_REGISTRAR_MISSING`.
169
- - **Flight control is `@ultimat3/core`'s, and this package RE-EXPORTS it.** `client-flight.ts` and
170
- `client-wire.ts` shipped here and in the other tier-3 client package as byte-identical copies —
171
- 288 and 85 lines — kept in step by a `client-twin.test.ts` in each. A test that makes drift LOUD
172
- is not the same as a file that cannot drift, and this package's own thesis is that duplication is
173
- the defect. Both files import nothing but tier 0, which was always the argument for where they
174
- belong; the one blocker was `isJsonObject`, now `@ultimat3/core`'s `json-object.ts`, re-exported
175
- from `./stable` here. `createClientFlight`, `DEFAULT_CLIENT_RETRY`, `isTransientFailure`,
176
- `isSuperseded`, `ClientFlight`, `ClientFlightOptions`, `ClientRetry`, `FlightKeyOptions`,
177
- `FlightPlan` and `WireAnswer` are all still importable from `@ultimat3/query` — the same names,
178
- and now literally the same objects the other package exports. Never re-declare one here; the
179
- fix for anything wrong with the pipeline is an edit in `packages/core/src/client-flight.ts`.
180
- - **Every mechanism underneath the flight is `@ultimat3/core`'s, the pipeline included.**
181
- `createSingleFlight` for dedup, `createFence`/`isSuperseded` for supersession, `createFlightGate`
182
- for the ceiling, `retry` + `backoffDelay` for the schedule, `isRetryableStatus` for the status
183
- table, `X_TIMEOUT` for the deadline — and `createClientFlight`, which composes them. This package
184
- declares NO new error code for any of it; never add a second curve, a second fence or a private
185
- retry loop here, and `bun run flight-copies` is what says so.
186
- - **`isTransientFailure` INVERTS `retryDecision`'s unclassified default, and the inversion must
187
- survive** (`As of 2026-08-23`). `retryDecision` sends a throw nobody classified again until the
188
- attempts run out; `@ultimat3/ai` and `@ultimat3/db` each refused the executor outright over it.
189
- The client keeps the executor and supplies a predicate instead: a declared
190
- `retryable`/`retry-after`, plus a dispatch that produced no response at all (`fetch` rejecting
191
- with a plain `TypeError`), and nothing else — a caller's own `AbortError` and a foreign value are
192
- terminal. The loop is stopped by RESOLVING to a private sentinel rather than by throwing, so the
193
- original value still reaches the caller unwrapped, which is the property `retry`'s own header
194
- promises. It lives in `packages/core/src/client-flight.ts` now; the tests that pin it from this
195
- side still drive it through this package's own client.
196
- - **`ClientFlight` is a TYPE inside `client.ts` and never a value.** That erasure is the entire
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`.
237
- - **The `sideEffects` array is what makes the barrel shakable, and it is load-bearing** (`As of
238
- 2026-08-23`). Declaring nothing meant a bundler had to assume every module ran at import, so
239
- `import { rpc } from '@ultimat3/action'` was 43,104 B and `import { queryClient } from
240
- '@ultimat3/query'` was 40,859 B — three times the deep-import cost, through the ONLY specifier
241
- the `exports` map offers. The arrays are the ones `bun run scripts/side-effects.ts --explain
242
- --json` measures, and they must stay that: `errors.ts` runs `registerErrorCodes` at import in both
243
- packages, and query's `registry.ts` runs `registerPrimitiveRegistrar('query', …)` — drop either
244
- and a bundled app loses its error titles or throws `X_REGISTRAR_MISSING`. Never `false`.
245
- - **Every numeric option is refused when it is not a FINITE number, `As of 2026-08-26`.**
246
- `@ultimat3/core`'s `finiteOption()` guards the read cache's `ttlMs` and `search()`'s `page.max`,
247
- `page.default` and `termMax`; `bun run finite-bounds` is the ratchet and this package is pinned
248
- at **zero**. `??` guards nullish and `NaN` is not, so an unchecked bound is a comparison that
249
- reads false forever — and `Math.max`/`Math.floor` propagate `NaN` rather than validating it.
250
- - **`search()` is a FACTORY over `query()`, never a ninth primitive.** It owns the input schema
251
- (`q` + a `limit` bounded in the schema, beside the read's own keys), trims and refuses a blank
252
- term, and calls `.search(term)` on the chain the app hands it — which is what makes the term
253
- unable to arrive any other way. The chain crosses **structurally** (`SearchChain`): this package
254
- may import `@ultimat3/entity` and nothing here does, so four methods are not worth a new
255
- `package.json` edge and a new `bun.lock` block — the trade `@ultimat3/db`'s `entity-shape.ts`
256
- makes one tier down. It needs no row in `PRIMITIVE_FACTORIES`: that table is for a factory
257
- returning an `action` or a `job` from OUTSIDE the primitive's own package, and this returns a
258
- query from the query package. **It serves ONE page and refuses a second**, because a `SqlSource`
259
- is handed a `SeekKey` and the entity chain wants its own signed, plan-scoped cursor — there is no
260
- minting one from the other here, and falling through to `paginate`'s in-memory slice would cut
261
- inside the one page the provider fetched and report `hasNextPage: false` at its edge. Rows served
262
- on no page at all is the defect 12.0.0 spent a release removing from the timestamp seek; the
263
- `fix:` names the entity chain, which pages this read correctly. **The refusal is on the rows that
264
- would be CUT, never on the window**, `As of 2026-08-26`: a screen demanding `first >= limit`
265
- refuses the framework's own default pair — `limit` defaults to 20 and `first` arrives from a
266
- client — so every `pageSize` under 20 was a 500 at page ONE, including a search matching three
267
- rows that fit the window twice over. `windowOf.execute` asserts on `rows.length` instead, so a
268
- page that is whole is served with `hasNextPage: false` and no cursor is ever handed back that a
269
- second call is guaranteed to refuse. **The one page it serves is the
270
- chain's own ORDER**, `As of 2026-08-26`: `onePage.seek` narrows the window with `Builder.limit()`
271
- and never with `Builder.seek()`, which sets `totalized` — `servedOrder()` is then `totalOrder([])`
272
- = `id asc`, because the ranking lives inside the chain behind the row thunk and no `OrderKey` here
273
- can name it, and `execute()` re-sorts the page the provider already ranked. Measured with a chain
274
- serving `z, m, a`: `runQuery` answered `z, m, a` and `.page(input, { first: 2 })` answered `a, m`,
275
- dropping the top-ranked row off page one. It exposes no `total()` for the same reason — an absent
276
- `total` is how `SqlSource` says the source already serves one order it can be resumed in. The
277
- fixture in `search.test.ts` must stay NOT id-ascending: `ROWS` is `a, b, c`, which is why the
278
- shipped page test could not see this.
279
- - **`subscribes:` is DECLARED because nothing can derive it, and CROSS-CHECKED because a
280
- declaration drifts** (`subscribes.ts`, `As of 2026-08-26`). `@ultimat3/realtime` refuses a
281
- subscription to a table that is not `REPLICA IDENTITY FULL` — logical replication carries no old
282
- row on an `UPDATE`, so no patch is computable — and nothing emitted the `alter`, so a scaffolded
283
- app with a live query generated a schema its own preflight rejected (#357). It cannot be derived:
284
- the relation name is a string inside `sql:`, a callback no generator can invoke without valid
285
- input, so `QueryDescriptor` carried no table and `x db gen` had no live-query set to read. The
286
- field is optional and a read that declares nothing is unchanged — the emitter sees no table.
287
- The cross-check is what makes it worth shipping: `toLiveQuery` asserts `shape.entity` is among the
288
- declared names at the first subscribe (`X_QUERY_SUBSCRIBES_DRIFT`), one line under
289
- `assertMatchable` and for its reason — a live read whose old row never arrives cannot be patched,
290
- so a WARNING would preserve only a subscription that silently stops updating, and the stale
291
- declaration has already granted the identity to the wrong table. `pg-preflight.ts`'s
292
- `warnPartialIdentity` warns instead, and that precedent does NOT transfer: it fires for every app
293
- on the postgres default, which nobody wrote, while this fires only on a declaration an author
294
- wrote wrong. **Membership, never equality** — a `QueryShape` names one relation and a read may
295
- join several, and `@ultimat3/db` keeps only the declared names an entity's table matches, so an
296
- extra name costs nothing and the resolved one going missing is the whole failure. An empty list,
297
- or one on a read that is not `live: true`, is refused at `query()`
298
- (`X_QUERY_SUBSCRIBES_INVALID`) beside `assertCacheTtl`: a declaration nothing can act on is wrong
299
- for every subscriber, and one on a non-live read would never reach the cross-check at all —
300
- declared and never wired, which is the defect this field exists to close.
301
- - Policy runs per subscriber for live queries. Never cache a decision across actors.
302
130
  - The matcher patches from `QueryShape`, never from SQL text.
303
- - `paginate` has no `offset` parameter and must never grow one, and it is reachable **only** as
304
- `query.page(input, { first, after })` — a page is the read's own answer, not an imported helper.
305
- `src/index.ts` exports `Page` and `PaginateArgs` and not the function: re-exporting it would be
306
- a second way to ask for the thing `.page()` already does.
307
- - **A `RowProvider` may be a list, a sync function or an async one** (`As of 2026-08-19`).
308
- `execute()` awaits whatever the function returns, so all three were always accepted at runtime —
309
- the type declared only `() => Promise<readonly TRow[]>`, which refused a repo method already
310
- holding its page and every in-memory fixture. `source.test.ts` pins all three.
311
- - **A cursor is a position, not a row.** `isAfterKey` in `source.ts` is the one definition of
312
- "after this position": `Builder.seek()` compiles it to SQL and `paginate()` applies it when a
313
- source cannot push the seek down. The fallback used to find the cursor's row by id and slice
314
- after it — which restarts the listing from the top the moment that row is deleted, the exact
315
- failure keyset pagination exists to prevent. Never reintroduce a row lookup here.
316
- - The seek predicate is spelled out per key (`(a < $1) or (a = $2 and id > $3)`), the way
317
- `@ultimat3/entity`'s `seekSql` spells it. A row-value comparison cannot express a mixed
318
- `createdAt desc, id asc` ordering, and the id-tiebreak-only fallback it replaced returned rows
319
- the ordering was already past — with `execute()` disagreeing with the SQL it printed.
320
- - **NULL has one meaning, and `isNull` is it.** `null` and a column the row omits are the same
321
- absence, in the SQL and in memory alike. `=`/`!=`/`in` read NULL as a **value** — `is null`,
322
- `is not null`, `is distinct from`, `in (…) or is null`, the pair `@ultimat3/entity`'s
323
- `predicateSql` emits; `>`/`>=`/`<`/`<=` read it as **unknown**, matching nothing on either side,
324
- which is why they need no special emission; `order by` reads it as the **largest value**, spelled
325
- `asc nulls last` / `desc nulls first` rather than inherited from the driver. `= $n` with a NULL
326
- argument is never true in Postgres, so `where({ deletedAt: null })` matched every row in memory
327
- and none in the database, and `"col" > $n` blanked page two at the first NULL. Never emit a bound
328
- parameter where NULL is the value being tested, and never let `compareValues` sort a NULL as the
329
- string `"null"` again — `compareRows`, `isAfterKey` and the matcher's insertion position all
330
- read it, so one string compare moves rows on three surfaces. `in` takes a list or nothing: an
331
- empty one is `1 = 0`, and so is an operand that is not an array at all — `matchesFilter` answers
332
- no rows for it, and `"col" in $n` is a syntax error the driver reports instead of that answer.
333
- - **The id is the tiebreak that makes the order total.** A row without one is
334
- `X_QUERY_NOT_PAGEABLE` at `seekKeyOf` **and** at the matcher's `idOf`, never `String(undefined)`:
335
- `"undefined"` is a position every row matches, signed and opaque, so page two would be page one
336
- forever and one row's patch would land on another's index.
337
- - **`totalOrder` is the order a read is served in, and all three readers use it.** The declared
338
- keys then `id asc`, unless the ordering already names `id` — `Builder.servedOrder()` compiles it,
339
- the in-memory sort applies it, and `positionFor` places a row by it. The matcher comparing
340
- `shape.orderBy` alone appended a tied row after its whole tie group, which is a position no
341
- re-read returns and a cursor that skips every tie it was pushed past. `SeekKey` is the same list
342
- decomposed — `key` for the declared part, `id` for the tiebreak — so never add `id` to
343
- `QueryShape.orderBy` to get it: `seekKeyOf` would then sign the id twice. An unordered query
344
- appends, because SQL promises no position there to get wrong.
345
- - **A live read asks for that order explicitly, and `sourceFor` is where it asks.** `total()` is
346
- the `SqlSource` method for "the same read, served in `totalOrder`" — no cursor, no window — and
347
- `buildSource` calls it when `surface === 'live'`, for nothing else, and only when the source
348
- implements it. A live window served by the declared keys alone puts a tied row wherever the
349
- database returned it, while `positionFor` places the patch by id and the resume re-read seeks by
350
- id: the client then renders an order no re-read answers. Never reach for `seek(null, limit)`
351
- instead — a live query need not carry a limit, and inventing one is a window nobody asked for.
352
- - **A sort value carries its own TYPE through the cursor** (`cursor-value.ts`, `As of 2026-08`).
353
- The codec is JSON, so `paginate()` putting raw column values in meant a `Date` went out and an
354
- ISO STRING came back: `isAfterKey` compared `"1769904000000"` against `"2026-02-01T…"` through
355
- `compareValues`' string branch and **page two came back empty** — and a `bigint` sort key was a
356
- bare `TypeError` out of `JSON.stringify`, with no code and no fix. `@ultimat3/entity`'s
357
- `cursor.ts` solves the same problem by reading the column's declared kind; a `query` has no
358
- column kinds — `QueryShape.orderBy` is a name and a direction — so the value is TAGGED instead
359
- (`{ $x: 'date' | 'bigint', v }`) and `reviveSortKey` is total without knowing which read minted
360
- it. `undefined` encodes as `null`, because SQL has one absence and dropping the key would shift
361
- every later one a position left. Anything JSON cannot carry and this cannot tag — an object, an
362
- array, `NaN`, `±Infinity` — is `X_CURSOR_VALUE_UNSUPPORTED` where the cursor is MINTED, never
363
- `X_CURSOR_INVALID`: the mistake is the read's own `orderBy` and no retry repairs it.
364
- - **`compareValues` orders numbers and bigints in ONE order, because Postgres does.** The numeric
365
- fast path was `typeof === 'number'` on both sides, so an `int8` fell to
366
- `String(left) < String(right)`: `compareValues(9n, 10n)` answered `1` and a sort came out
367
- `["10", "100", "9"]`. `bigint` is the physical type of every `<p>_minor` column and
368
- `@ultimat3/entity`'s `count-by.ts` lists it as groupable, so the in-memory source, the live
369
- matcher and the seek fallback ALL disagreed with the database on any bigint-ordered read.
370
- `shape-order.test.ts` is the pin, and `As of 2026-08` it reads a REAL kind list: `COLUMN_KINDS`
371
- is the runtime array `@ultimat3/entity`'s `ColumnKind` derives from, and entity is tier 2 so a
372
- test here may import it as a VALUE — which is what a `satisfies Record<ColumnKind, …>` could not
373
- be, since `tsconfig.json` excludes `*.test.ts` and `tsc` never reads one. It had a spelled-out
374
- list and `const COUNT = 9` beside a union of THIRTEEN members: `9 === 9`, a test that could not
375
- fail, with `numeric`, `date`, `bytea` and `array` carrying no case at all.
376
- - **`numeric` and the TEXT form of `bigint` are a DECLARED gap here, and closing it is a
377
- declaration change** (`shape-order.test.ts`, `As of 2026-08`). `@ultimat3/entity`'s `bigint()`
378
- and `decimal()` hand digits back as strings, so `["9","10","100","2"]` sorts to
379
- `["10","100","2","9"]` here and `["2","9","10","100"]` in the database — and a cursor's revived
380
- `bigint` against a stored decimal string (`compareValues(9n, "10")` → `1`) cuts page two where
381
- the database does not. It is **not** fixed by calling `@ultimat3/core`'s `compareDecimalText`
382
- from `compareValues`: that function answers only for a caller holding the column's declared kind
383
- (`@ultimat3/entity`'s `compareByKind`), and `QueryShape.orderBy` is a name and a direction —
384
- nothing here can tell a `numeric` holding `"10"` from a `text` holding `"10"`, which Postgres
385
- orders lexically, so a comparator guessing would trade one disagreement with the SQL it prints
386
- for another. The fix is an `OrderKey` that carries a kind, from `sourceFor` down. Until then the
387
- `DECLARED_GAP` block asserts both halves, so the gap cannot be silently re-discovered or
388
- silently widened.
389
- - **A read's `input:` must survive a query STRING, and `query()` refuses one that cannot**
390
- (`input-shape.ts`, `X_QUERY_INPUT_UNENCODABLE`, `As of 2026-08`). `client.ts` encoded a nested
391
- member as `JSON.stringify(item)` and skipped a `null`, while `coerceQuery` has no inverse for
392
- either — `case 'object'` hands the raw value back untouched — so the typed client type-checked
393
- calls the server's own route then rejected, which is the exact failure `client.ts`'s header
394
- claims to prevent. **The declaration is the fix, not the encoder**: teaching `coerceQuery` to
395
- `JSON.parse` a string would make the ONE HTTP-boundary decoder invent structure for every
396
- surface that shares it — forms and route params included — against that file's own rule that it
397
- never invents data, and a `null` sentinel would be a reserved string colliding with the value
398
- `"null"`. Refused: a structural member (`object`, `record`, `money`, or an array/union of one),
399
- a REQUIRED nullable member, and a top-level input that is not an object. A schema
400
- `tryIntrospect` cannot read is left alone, or `configureSchemaProvider` would be unusable.
401
- - **A refill is owed by a FULL window and by nothing else** (`matcher.ts`, `As of 2026-08`).
402
- `removeAt` pushed one whenever `shape.limit !== null`, with no reference to how many rows the
403
- window holds: three rows under `limit: 50`, delete one, and the patch list was
404
- `[{remove, position:1}, {refill, from:49}]` — a position no two-row result set has. It is not a
405
- harmless extra: `@ultimat3/realtime`'s `matcher-bridge` folds any refill into
406
- `BridgeResult.refill`, and `live-fanout` then sends **no patch frame at all** that round, marking
407
- every subscriber desynced instead — so on a quiet feed the deleted row stays rendered until some
408
- other change to the same query id arrives, and on a busy one it is a full DB re-read plus one
409
- snapshot per subscriber per delete. A window under `limit` has no unknown tail: the source served
410
- fewer rows than it was allowed to, so what the client holds IS the result set. `held >=
411
- shape.limit` is the gate, and it is `wasFull` one branch away, already written.
412
- - **A move OUT of a full window is a `refill`, never an `add`** (`matcher.ts`). `insert()` places a
413
- moved row among the `limit - 1` rows the client still holds, so its position can never reach
414
- `shape.limit` and the `position >= shape.limit` bail is unreachable on that path — the row was
415
- re-inserted INSIDE the window. Proven with `limit: 3`, window `[a:1, b:2, c:3]` and a server also
416
- holding `d:4, e:5`: moving `a` to `99` rendered `[b, c, a:99]` where the true window is
417
- `[b, c, d]`. Only the server can answer the tail, and whether the moved row is still in the
418
- window is its answer too.
419
- - **A page is bounded whether or not the caller bounded it.** `paginate` asserts `first` is a whole
420
- number of rows in `1…MAX_PAGE_SIZE` (10,000) before anything else — `args.first + 1` bound
421
- whatever an action's input or a route parameter carried, so one request could ask for five
422
- million rows. The constant is a TWIN of `@ultimat3/entity`'s, under the same tier compromise
423
- `naming.ts` and `deprecation.ts` are ported under.
424
- - **`tagKeys` is `@ultimat3/cache`'s, not this package's — moved 2026-08.** `src/tags.ts` here and
425
- `@ultimat3/action`'s were byte-identical, and both packages are tier 3, so neither can import the
426
- other and a copy in either is a second answer for the other — the same move `toBucket` made into
427
- `@ultimat3/http`. `tagKey` went with it: `serializeTag` under a second name, zero call sites.
428
- `@ultimat3/render` exports a *different* function under the same name (declaration order kept);
429
- never import that one here.
430
- - **A fingerprint is an identity, so two different inputs may not share one — and it is
431
- `@ultimat3/core`'s, not this package's, `As of 2026-08`.** `canonicalJson` + `fingerprint` moved
432
- down to tier 0 because `@ultimat3/action` and `@ultimat3/realtime` needed the identical function
433
- and all three are tier 3, so a copy in any of them was a second answer for the other two — and
434
- the copies had already diverged. This one had **no `Date` branch**: `Object.keys(date)` is `[]`,
435
- so the object branch rendered every date as `{}` and `queryHash({from: 2020…, to: 2020…})`
436
- equalled `queryHash({from: 2026…, to: 2026…})`. Reachable on the ordinary HTTP path — `http.ts`
437
- decodes a query string through `coerceQuery`, which turns a `t.date` member into a real `Date`,
438
- and `input-shape.ts` permits `date` members — so ONE read-cache entry answered every date window
439
- of that read for the TTL, page two of range A was served from range B's cursor scope, and every
440
- date window shared one live query id. The hash form tags a `Date`, a `Map` and a `Set`
441
- (`Date(<epoch>)`, `Map(…)`, `Set(…)`) beside the bare `NaN` / `±Infinity` / `-0` tokens it
442
- already emitted, all for the reason a bare token exists: `'null'` collided with JSON `null` and
443
- `String(-0)` is `"0"`. `stable.ts` keeps `columnOf` and a re-export of core's `isJsonObject` —
444
- that predicate went down to tier 0 the same way, when `client-wire.ts` did. Ordinary
445
- inputs are byte-identical, so the durable-key cost is confined to reads whose input carries a
446
- `Date`, a `Map` or a `Set`: those cursors answer `X_CURSOR_INVALID` once, and their cache entries
447
- are cold once. `query-hash.test.ts` is the pin, at `queryHash` and at `cacheKeyFor`.
448
- - The cursor codec is `@ultimat3/core`'s (`encodeCursor` / `decodeCursor` / `configureCursorSigning`).
449
- This package supplies only the scope a cursor is bound to — `queryHash(name, input)` — and never
450
- signs, encodes or parses one itself. An unverified or foreign cursor is `X_CURSOR_INVALID`, thrown
451
- by core's `CursorInvalidError`, which `errors.ts` re-exports so the name stays on this surface.
452
- - **The request memo holds the read, not the rows.** `readOnce` publishes the in-flight promise
453
- before its first await, so two reads of one key in one request are one execution and one tier
454
- round trip whether the second follows the first or races it. A value-keyed map could not express
455
- that, and could not tell a memoized `undefined` from a miss either. A rejection is evicted — a
456
- failed read is not the request's answer, and the next read retries. `requestMemo(ctx)` is
457
- therefore `Map<string, Promise<unknown>>`; never put a settled value in it.
458
- - **Every read is memoized; only a `cache:` read goes through the tier.** `readThrough` is
459
- `readOnce` plus the fill through the ladder, and `readRows` picks between them on `def.cache`
460
- alone. The memo is not what `cache:` buys — a list that renders one uncached lookup per row pays for
461
- every row otherwise, which is the N+1 this collapses. Never gate `readOnce` on `def.cache`
462
- again, and never let a second key function grow beside `cacheKeyFor`.
463
- - **A cache key carries the read's AUTHORITY, and `cache.scope` is what widens it** (`As of
464
- 2026-08`). `cacheKeyFor` held the name, the input and the tags — nothing about who asked — while
465
- `sql(input, ctx)` is handed the context and `@ultimat3/entity` derives every tenant predicate
466
- from `ctx.actor.orgId`. The tier is process-wide, so the first actor to ask filled the entry and
467
- the next was served it: a query filtering on `ctx.actor.orgId` returned `org-a`'s row to an
468
- `org-b` actor. `readAuthority(actor, scope)` is the ONE producer of the component and
469
- `cacheKeyFor`'s fourth argument is **required and positional**, because an optional one is one a
470
- call site forgets and a forgotten one is that read. `scope` defaults to `'actor'` and the default
471
- is the mechanism: declaring nothing gets the narrowest key. `'tenant'` and `'global'` are written
472
- statements about the rows — the `unenforced:` shape one field over — and `'tenant'` with no
473
- `orgId` narrows to the actor rather than widening to everyone, because nothing here can prove two
474
- org-less callers share a tenant. **All THREE spellings of "no org" take that branch**, `As of
475
- 2026-08`: `undefined`, `''` and `null`. The last one missed it, so every org-less caller shared
476
- the single key `["org",null]` and was served the rows of whoever asked first. `orgless()` widens
477
- its parameter past core's `orgId?: string` because **`orgId` is a value off the wire** — an app's
478
- adapter, a decoded session row, a JSON payload — not because a declared type permits a `null`.
479
- `@ultimat3/policy`'s `PolicyActorFields` reads like the reason and is not it (corrected
480
- 2026-08-19): `Actor = CoreActor & PolicyActorFields`, and that intersection collapses its
481
- `string | null | undefined` back to `string | undefined`, so the widening is **inert** at the type
482
- level and `{ orgId: null }` is a type error. Its `testActor` mints `orgId: null` through the one
483
- cast left in `packages/policy/src/test-kit.ts`, which is why `cache-authority.test.ts` can reach
484
- this branch at all — the repo's only producer of that `null`, and a test seam rather than a proof.
485
- The authority is JSON, never a joined string, for the reason
486
- `@ultimat3/entity`'s `scopeKey` gives: an actor id is app data and may carry the separator.
487
- - **`cache.ttlMs` is judged at `query()`, not on the first read.** Every `CacheTier` refuses a
488
- lease that is not positive and finite (`assertTtl`), and the read tier's one catch absorbs
489
- `X_CACHE_TOO_LARGE` only — so `ttlMs: Infinity` turned a typo into a permanently failing business
490
- read whose cause named a cache key. `X_QUERY_CACHE_TTL_INVALID`, on the line that wrote it. It
491
- restates `assertTtl`'s bar as a refusal and never as a second resolution.
492
- - **`fingerprint` is SHA-256/16, never a 32-bit hash** (`@ultimat3/core`, `As of 2026-08`). It is a
493
- SHARING key over client-chosen input — which read-cache entry two callers are served from, which
494
- scope a cursor is bound to — so FNV-1a/32's 4×10⁹ values are a collision found offline in
495
- seconds. `@ultimat3/realtime`'s `stableDigest` was the same primitive at the same width and is
496
- gone with the rest of that copy: a `qid` is `queryHash(name, input)` now, imported across the
497
- declared `realtime -> query` edge, so the two hashes cannot drift apart while `planResume`
498
- compares one against a cursor's.
499
- - **A fill is FENCED, and the fence is `@ultimat3/cache`'s** (`As of 2026-08`). `run()` answers with
500
- rows it read in the past: a mutator committing in between busts a key not yet in the tier, so the
501
- drop is a no-op reporting `errors: []`, and the fill then publishes the pre-write rows for the
502
- full TTL — invisible to every reader until it expires. The sample happens inside
503
- `createCacheStack.read`, immediately before `load()`, and is re-asked per rung before each write.
504
- This package no longer samples one of its own — that copy went with the private store. The caller
505
- is answered either way: those rows ARE its answer, and only publishing is refused.
506
- `cache-fence.test.ts` is the proof the property survived the move.
507
- - **This package owns NO cache store, and that is the enforcement** (`As of 2026-08`). A `cache:`
508
- read fills `createCacheStack(registeredTiers(), { clock })` — the tiers `@ultimat3/cache` has
509
- registered — and there is nothing here to install, swap or wire. There used to be: a private
510
- `ReadCache` seam (`setReadCache`/`getReadCache`/`MemoryReadCache`) that `invalidateTags` could not
511
- reach, because that fan-out walks the registered `CacheTier`s and nothing else. The gap was closed
512
- by `packages/cli/src/dev-cache.ts` installing the read cache **over** an object it also
513
- registered — a correctness property held by a wiring line in a CLI file, one edit from being
514
- wrong, and carrying a second `ReadCache` implementation (`tierReadCache`) that dated entries with
515
- `Date.now()`. And `invalidateQueryTags` was a second fan-out path, which
516
- `packages/cache/CLAUDE.md` forbids in as many words. One registry, one fan-out, no seam. **Never
517
- reintroduce a store here**, and never call `tier.invalidateTags()` from this package.
518
- - **A tier refusal degrades the cache, never the read** — and so does every other tier concern.
519
- `bestEffort`, the fence, the single flight, the promotion and the TTL are all `createCacheStack`'s,
520
- which is what "no store here" buys: a refused `get` reads as a miss, a refused `set` as "that tier
521
- is unchanged", and the failure lands in `recentTierFailures()` under the name of the tier that
522
- actually refused rather than under a `'query-read'` label for a rung in no registry. Never wrap a
523
- tier call here in a private try/catch, and never sample a second fence.
524
- - **The read path reads NO clock, and that is what makes it drivable.** It hands the stack a
525
- RELATIVE `ttlMs`; the tier's own clock turns it into an absolute expiry. `fill` used to compute
526
- `nowMs(clock) + ttlMs` and `tierReadCache` used to compute `expiresAt - Date.now()`, so the two
527
- `ReadCache` implementations disagreed about "now" and no frozen clock could drive the
528
- Redis-backed one. `read-tier.test.ts` pins it: a `createLruTier({ clock, jitterFraction: 0 })` and
529
- a `ctx.clock` frozen at the same instant produce an expiry a test can assert exactly.
530
- - **A `cache:` read always expires, and the bound is the tier's.**
531
- `def.cache.ttlMs ?? DEFAULT_READ_CACHE_TTL_MS` (60s) is what `readRows` passes — a query keyed on
532
- `{ orgId, cursor }` has as many distinct keys as the deployment has tenants, and unbounded that is
533
- one permanent entry per page per org. `null` is "the caller named none", never "never": it reaches
534
- the stack as an OMITTED `ttlMs`, which is how a tier is asked for its own default. Every tier
535
- refuses a non-positive lease outright, so there is no immortal entry to spell.
536
- - **A process that registered no tier reads uncached.** `createCacheStack([])` loads, answers and
537
- writes nowhere — correct, and slower. That is the trade for deleting the module-default store: a
538
- script, a worker boot or a test that wants caching calls `registerTier`, the same call every
539
- other cached surface in the framework already uses.
540
- - **The memo holds an execution, never a decision.** `readRows` runs `buildSource` — parse, guard,
541
- `sql()` — *before* it reaches the memo, on every call, and `.as()` reads in a child context whose
542
- identity is its own memo. So a memoized answer is still one this actor was allowed to ask for,
543
- and no impersonated read can join a read made as someone else. Moving the memo above
544
- `buildSource` would turn it into an authz bypass.
545
- - **`fresh: true` skips the memo on the way in and publishes to it on the way out.** A memo is a
546
- cache whose lifetime is the request, so `fresh` refuses to *join* an entry — `readFresh` is
547
- `readOnce` minus the join, both sharing one `publish` — but it must still *become* one. Returning
548
- the rows early instead left the pre-write entry standing, so "the one way to read past a write
549
- made earlier in the same request" ended at the single call that asked for it and the next plain
550
- read of that key got the stale answer back. Invalidation still drops tier entries only.
551
- - **A read can declare a `rateLimit:`, and `toQueryRoute` enforces it — added 2026-08.** `QueryDef`
552
- had no such field and the route set no bucket, so **every** `GET /_x/query/*` fell through
553
- `bucketFor` to `default` — 120 burst, 2/s per actor. One authenticated caller could hold 120
554
- cross-tenant aggregates in flight and then 2/s indefinitely, from a single account, and the
555
- declaration that would have throttled it did not exist in the type. `meta.rateLimit` (the name)
556
- **and** `meta.rateLimitBucket` (the numbers) are both set, because a name nothing registers is
557
- the same silent fall-through. The conversion is `toBucket` from **`@ultimat3/http`** — http owns
558
- `Bucket` and the maths, and `@ultimat3/action` is this tier, so a copy here would be a second
559
- answer for the write half. Never derive a bucket locally.
560
- - **`deprecated:` is a compat WINDOW; versioning is not here and will not be.** `Deprecation`
561
- (RFC 9745, `@<unix seconds>`) and `Sunset` (RFC 8594, IMF-fixdate) on every answer including the
562
- failures, a `rel="successor-version"` link built through `derivePath` — the same derivation
563
- `client()` uses, never a second one — the dates on the descriptor, and
564
- `deprecated_calls_total{primitive,name}`, which is the only way to answer "is anyone still
565
- reading it?" before deleting the read. Rendered ONCE at projection, so a date that cannot become
566
- a header is `X_QUERY_DEPRECATION_INVALID` at mount rather than on the first read. Running two
567
- versions side by side is two deployments behind one ingress (axiom 7). `deprecation.ts` is a
568
- twin of `@ultimat3/action`'s: both are tier 3, the shared home is `@ultimat3/http` if it ever
569
- grows one, and this is the same compromise `naming.ts` is ported under.
570
- - **The span wraps the whole read, not `source.execute()`.** Wrapping the execution alone left the
571
- input parse, the policy evaluation and `sql()`'s own construction outside every span, so a read
572
- whose cost was in building the source reported milliseconds under a parent reporting seconds —
573
- a gap with no name, which reads as framework overhead. Attributes are bounded: surface, actor
574
- KIND, `live`, `cached`, `fresh`, and the row count. Never the input and never an actor id — a
575
- read is keyed per tenant and per cursor, so either would be unbounded. `telemetry.test.ts`
576
- asserts the EXTENT structurally, reading `currentSpan()` from inside the policy predicate and
577
- `sql:`, because the test clock is frozen and a timing assertion would hang on it.
578
- - **`policyCapability` is a display label; `policyPermissions` is what a report matches on.** A
579
- composite renders as `or(feed:read, org:administer)`, which equals no permission string, so
580
- `x policy list` matching on `capability` reported every non-trivially-guarded read's permissions
581
- as *unenforced*. `QueryDescriptor.permissions` is the flattened list from `@ultimat3/policy`,
582
- published beside `capability` and never instead of it.
583
- - **`queryClient`/`client()` inject `traceparent`.** Core's `traceparent()` had no caller in the
584
- repo, so a service-to-service read began a fresh root trace on the far side. Set BEFORE the
585
- caller's own headers so an explicit one wins; an incomplete span context (`spanId: ''`) sends
586
- nothing rather than a header every collector drops. In a browser there is no ambient context, so
587
- a cross-origin read gains no CORS preflight it did not already have. The helper is twinned in
588
- `@ultimat3/action`'s client for the same tier reason `naming.ts` is.
589
- - Authz goes through `enforce(surface, policy, { input, actor, ctx })` from
590
- `@ultimat3/policy`; a live denial keeps its 4403 close code on `QueryDeniedError.denial`.
591
- `policy-gate.ts` is the only file that imports the policy package.
131
+ - `paginate` has no `offset` and never grows one; it is reachable only as
132
+ `query.page(input, { first, after })`. **A page is bounded**: `first` is 1…`MAX_PAGE_SIZE`
133
+ (10,000, a twin of `@ultimat3/entity`'s).
134
+ - **A `RowProvider` may be a list, a sync function or an async one** (`source.test.ts`).
135
+ - **A cursor is a position, not a row**: `isAfterKey` (`source.ts`) is the one definition of "after";
136
+ never reintroduce a row lookup. The seek predicate is spelled out per key
137
+ (`(a < $1) or (a = $2 and id > $3)`), never a row-value comparison.
138
+ - **NULL has one meaning, `isNull`**: `=`/`!=`/`in` treat NULL as a value (`is null`,
139
+ `is distinct from`, `in (…) or is null`); range operators treat it as unknown; `order by` treats it
140
+ as the largest value (`asc nulls last` / `desc nulls first`). Never bind a parameter where NULL is
141
+ the tested value. `in` with an empty or non-array operand is `1 = 0`.
142
+ - **The id is the tiebreak that makes the order total.** No id is `X_QUERY_NOT_PAGEABLE`, never
143
+ `String(undefined)`. **`totalOrder`** (declared keys, then `id asc`) is the served order for SQL,
144
+ memory and `positionFor`; never add `id` to `QueryShape.orderBy`. A live read asks for it via
145
+ `SqlSource.total()` (called by `buildSource` for `surface === 'live'`), never `seek(null, limit)`.
146
+ - **The keyset tiebreak keeps the id's TYPE** (`SeekKey.id: unknown`), carried at the tail of `key`
147
+ through `serializeSortValue` (`pagination-id-type.test.ts`).
148
+ - **A sort value carries its TYPE through the cursor** (`cursor-value.ts`: `{ $x: 'date' | 'bigint',
149
+ v }`; `undefined` → `null`). Untaggable values are `X_CURSOR_VALUE_UNSUPPORTED` where the cursor is
150
+ minted.
151
+ - **`compareValues` orders numbers and bigints in one order**; equality treats `number` and `bigint`
152
+ as one family (`shape.ts` `same`). `shape-order.test.ts` reads `@ultimat3/entity`'s `COLUMN_KINDS`.
153
+ - **`numeric` and the TEXT form of `bigint` are a DECLARED gap** (`DECLARED_GAP` in
154
+ `shape-order.test.ts`): closing it needs an `OrderKey` carrying a kind, never a guessing comparator.
155
+ - **A refill is owed by a FULL window only** (`held >= shape.limit`). **A move OUT of a full window is
156
+ a `refill`, never an `add`.**
157
+ - **A position is decided against the rows the WINDOW holds**: `unprojectedOrderKey` — a held row
158
+ missing an ordering column (`Object.hasOwn`, never a value check) gets a `refill` rather than a
159
+ guess. A delete is addressed by index and still patches.
160
+ - **`tagKeys` is `@ultimat3/cache`'s.** `@ultimat3/render` exports a different `tagKeys`; never import
161
+ that one here.
162
+ - **A fingerprint is `@ultimat3/core`'s** (`canonicalJson` + SHA-256/16 `fingerprint`), tagging `Date`,
163
+ `Map`, `Set`. `query-hash.test.ts` pins it at `queryHash` and `cacheKeyFor`. `stable.ts` keeps
164
+ `columnOf` and re-exports core's `isJsonObject`.
165
+ - The cursor codec is `@ultimat3/core`'s; this package supplies only the scope (`queryHash(name,
166
+ input)`). An unverified cursor is `X_CURSOR_INVALID` (`CursorInvalidError`, re-exported).
167
+
168
+ ## Invariants — caching
169
+
170
+ - **The request memo holds the read, not the rows**: `readOnce` publishes the in-flight promise
171
+ before its first await; a rejection is evicted. `requestMemo(ctx)` is `Map<string, Promise<unknown>>`.
172
+ - **Every read is memoized; only a `cache:` read goes through the tier** (`readThrough`). One key
173
+ function, `cacheKeyFor`.
174
+ - **A cache key carries the read's AUTHORITY**: `readAuthority(actor, scope)` is its one producer and
175
+ `cacheKeyFor`'s fourth argument is required. `scope` defaults to `'actor'`; `'tenant'` with no
176
+ `orgId` (`undefined`, `''` or `null` — `orgless()`) narrows to the actor. The authority is JSON.
177
+ - **`cache.ttlMs` is judged at `query()`** (`X_QUERY_CACHE_TTL_INVALID`). A `cache:` read always
178
+ expires: `def.cache.ttlMs ?? DEFAULT_READ_CACHE_TTL_MS` (60 s); an unset TTL is OMITTED so the tier
179
+ defaults.
180
+ - **A fill is FENCED, by `@ultimat3/cache`'s fence inside `createCacheStack.read`** — never a second
181
+ one here (`cache-fence.test.ts`).
182
+ - **This package owns NO cache store**: a `cache:` read fills
183
+ `createCacheStack(registeredTiers(), { clock })`. Never reintroduce a store, never call
184
+ `tier.invalidateTags()` here. A tier refusal degrades the cache, never the read.
185
+ - **The read path reads NO clock** — it hands the stack a relative `ttlMs` (`read-tier.test.ts`).
186
+ - **A process that registered no tier reads uncached** — correct, and slower.
187
+ - **The memo holds an execution, never a decision**: `buildSource` (parse, guard, `sql()`) runs on
188
+ every call before the memo. Moving the memo above it would be an authz bypass.
189
+ - **`fresh: true` skips the memo on the way in and publishes to it on the way out.**
592
190
 
593
191
  ## Commands
594
192
 
@@ -597,21 +195,4 @@ bun test packages/query
597
195
  bun run typecheck
598
196
  ```
599
197
 
600
- **A position is decided against the rows the WINDOW holds, so a window that cannot answer gets a
601
- `refill` rather than a guess** — `unprojectedOrderKey`, `As of 2026-08-20`. A result set is whatever
602
- the query's `sql` returned, and a PROJECTION that drops an ordering column left `compareRows`
603
- measuring the change row's real value against nothing: never equal on the update path, so every
604
- change read as a move, and arbitrary on the insert path, so a row created last landed wherever
605
- `undefined` sorted. `examples/dummy`'s feed ordered by `createdAt` and projected without it, and one
606
- publish became a `remove` + `insert` whose re-inserted row was the raw table row (#230).
607
-
608
- The discriminator is **`Object.hasOwn`, never a value check**: a nullable column that IS null still
609
- carries its key, and everywhere else in this package an absent key and a SQL NULL are one absence
610
- (`isNull` says so). Here they are different facts — "this row's value is nothing" versus "this shape
611
- cannot answer" — and only the key tells them apart. Asked of the row the window holds and never of
612
- the change row, which comes off the table and can always answer. A **delete** never reaches the rule:
613
- it is addressed by the index its id was found at, so a projected query still removes incrementally.
614
-
615
- The rule generalises what `assertSeekable` already applies to a cursor: **a sort key has to be
616
- readable on the row.** A live query whose rows omit one still works — it re-reads instead of
617
- patching — which is correct and slower, and the fix is to project the key.
198
+ Why each rule above is shaped the way it is: [`docs/history/query.md`](../../docs/history/query.md).
package/README.md CHANGED
@@ -520,6 +520,27 @@ router feature here.
520
520
  Denials re-throw the policy layer's own codes and keep the surface denial on
521
521
  `QueryDeniedError.denial`, so a live socket closes with 4403 instead of guessing.
522
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
+
523
544
  ## Boundaries
524
545
 
525
546
  Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `entity`, `http`, `policy`. Never imports `action`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/query",
3
- "version": "21.0.0",
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",
@@ -36,11 +36,11 @@
36
36
  "test": "bun test"
37
37
  },
38
38
  "dependencies": {
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"
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"
45
45
  }
46
46
  }
@@ -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
@@ -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',
@@ -240,6 +241,23 @@ export class CursorValueUnsupportedError extends UltimateError {
240
241
  }
241
242
  }
242
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
+
243
261
  /** The honest fallback: the matcher refuses to guess rather than patch wrongly. */
244
262
  export class MatcherUnsupportedError extends UltimateError {
245
263
  constructor(name: string, feature: string) {
package/src/http.ts CHANGED
@@ -6,12 +6,11 @@
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 { 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';
@@ -34,43 +33,43 @@ export function toQueryRoute(target: AnyQuery): Route {
34
33
  const answer = recordAnswerFor(target.rows);
35
34
 
36
35
  const handler = async (request: UltimateRequest): Promise<Response> => {
37
- if (sunsetting !== undefined) recordDeprecatedCall('query', name);
38
- try {
39
- // Coerced, then validated — two different jobs, and only the first one belongs to a
40
- // wire. A search string is characters, so `t.number` and `t.boolean` need the HTTP
41
- // boundary's decode (`coerceQuery` never invents data: what it cannot convert it
42
- // hands on untouched). VALIDATING here — `request.query(schema)` — would be the
43
- // second parser: the same read would answer `X_BODY_INVALID` where every other
44
- // surface answers `X_INPUT_INVALID` with the line that prints its schema. `runQuery`
45
- // is the one that decides, exactly as it does for a direct server call.
46
- //
47
- // The two page controls come OUT first (`page-controls.ts`): they are the route's, not the
48
- // read's, and a schema that refused unknown keys would otherwise refuse every paged call.
49
- const { input: values, page } = pageControlsOf(name, request.queryRaw());
50
- const input = coerceQuery(target.input, values);
51
- // With a page control the answer is the `Page` envelope `query.page()` answers a server
52
- // caller with — `{ rows, endCursor, hasNextPage }`, the same names, so a cursor read off the
53
- // wire and one read off a direct call are the same string in the same field. Without one the
54
- // answer is the bare array it has always been: every client written before the controls
55
- // existed keeps reading rows, and `hasNextPage` never rides on a row where a page marker
56
- // has no business being.
57
- const response = answer(
58
- page === undefined
59
- ? await runQuery(target, input, { surface: 'http' })
60
- : await target.page(input, { ...page, surface: 'http' }),
61
- );
62
- // On the failure path too, below: a client polling a deprecated read that is currently
63
- // 403ing still has to learn the read is going away.
64
- if (sunsetting !== undefined) applyHeaders(response, sunsetting);
65
- return response;
66
- } catch (error) {
67
- // Framework errors carry their own code, status and fix line; anything else is
68
- // a bug and belongs to the server's error boundary, not to this route.
69
- if (!isUltimateError(error)) throw error;
70
- const response = problem(error);
71
- if (sunsetting !== undefined) applyHeaders(response, sunsetting);
72
- 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);
73
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
+ );
74
73
  };
75
74
 
76
75
  const meta: RouteMeta = {
package/src/index.ts CHANGED
@@ -70,6 +70,7 @@ export {
70
70
  CursorInvalidError,
71
71
  CursorValueUnsupportedError,
72
72
  MatcherUnsupportedError,
73
+ QueryColumnUnselectedError,
73
74
  QueryDeniedError,
74
75
  QueryDeprecationInvalidError,
75
76
  QueryDuplicateError,
@@ -85,7 +86,7 @@ export {
85
86
  /** The HTTP projection: `GET /_x/query/<kebab>`, the URL `client()` derives. */
86
87
  export { toQueryRoute } from './http';
87
88
  export type { LiveCursor, LiveQuery, ResumeMode, ResumePlan, ToLiveOptions } from './live';
88
- export { advanceCursor, liveEpoch, planResume, seekOf, toLiveQuery } from './live';
89
+ export { planResume, seekOf, toLiveQuery } from './live';
89
90
  export type { ChangeEvent, ChangeOp, Patch } from './matcher';
90
91
  export { assertMatchable, match, positionFor } from './matcher';
91
92
  export type { QueryToolDescriptor, QueryToolReadOptions } from './mcp-tool';
@@ -94,7 +95,7 @@ export { isExposed, toQueryTool, toQueryTools } from './mcp-tool';
94
95
  * Path derivation only. There is no `toToolName`: an MCP tool is served under the export name
95
96
  * verbatim, and an exported derivation would be a second way to spell one tool.
96
97
  */
97
- export { derivePath, toKebabCase } from './naming';
98
+ export { derivePath } from './naming';
98
99
  /**
99
100
  * The read half of `openapi.json`. `@ultimat3/cli` merges these paths into `@ultimat3/action`'s
100
101
  * `buildOpenApi` document — the two packages are one tier and cannot compose each other.
@@ -105,7 +106,7 @@ export { queryOpenApiPaths, toQueryOpenApiOperation } from './openapi';
105
106
  * shape the typed client's `.page()` takes. `MAX_PAGE_SIZE` is the bound both ends check.
106
107
  */
107
108
  export type { PageControls } from './page-controls';
108
- export { MAX_PAGE_SIZE, PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-controls';
109
+ export { MAX_PAGE_SIZE } from './page-controls';
109
110
  /**
110
111
  * The shapes `query.page(input, { first, after })` takes and answers with. `paginate` itself is
111
112
  * deliberately unexported: a page is the read's own answer, and a second, importable way to ask
@@ -139,7 +140,7 @@ export type {
139
140
  QueryRateLimit,
140
141
  SourceOptions,
141
142
  } from './query';
142
- export { describeQuery, isQuery, nameQuery, query, queryHash } from './query';
143
+ export { describeQuery, isQuery, query, queryHash } from './query';
143
144
  /** The one read path. `defOf` stays unexported — that is the enforcement. */
144
145
  export { queryName, runQuery, sourceFor } from './read';
145
146
 
@@ -164,7 +165,6 @@ export {
164
165
  compareValues,
165
166
  isNull,
166
167
  matchesFilter,
167
- matchesFilters,
168
168
  seekKeyOf,
169
169
  totalOrder,
170
170
  } from './shape';
package/src/pagination.ts CHANGED
@@ -50,13 +50,13 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
50
50
  // The scope is this read plus these arguments: a cursor from anywhere else is
51
51
  // already `X_CURSOR_INVALID` by the time it gets here.
52
52
  const decoded = args.after === undefined ? null : decodeCursor(args.after, hash);
53
+ const base = await sourceFor(target, input, args);
54
+ const shape = base.shape();
53
55
  // Revived to the types the columns hold, never left as the strings JSON handed back: a `Date`
54
56
  // key decoded as an ISO string reaches `compareValues` as text and is compared against the
55
57
  // row's own millisecond number, so page two matched nothing at all. See `cursor-value.ts`.
56
58
  const after: SeekKey | null =
57
- decoded === null ? null : { key: reviveSortKey(decoded.key), id: decoded.id };
58
- const base = await sourceFor(target, input, args);
59
- const shape = base.shape();
59
+ decoded === null ? null : seekFromCursor(decoded, shape.orderBy.length);
60
60
 
61
61
  // `MAX_PAGE_SIZE` lives in `page-controls.ts`: the route checks the same bound at the wire, as
62
62
  // a 400, before this `assert` — which is a 500 — can see the number.
@@ -75,7 +75,13 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
75
75
  endCursor:
76
76
  seek === null
77
77
  ? null
78
- : encodeCursor({ scope: hash, key: seek.key.map(serializeSortValue), id: seek.id }),
78
+ : encodeCursor({
79
+ scope: hash,
80
+ // The id rides TYPED at the tail of the key — core's `id` slot is a string — so a
81
+ // numeric or bigint tiebreak compares as a number on the next page, not lexically.
82
+ key: [...seek.key, seek.id].map(serializeSortValue),
83
+ id: String(seek.id),
84
+ }),
79
85
  hasNextPage: scoped.length > args.first,
80
86
  };
81
87
  }
@@ -109,3 +115,16 @@ function inTotalOrder(
109
115
  if (after === null) return ordered;
110
116
  return ordered.filter((row) => isAfterKey(row, after, shape.orderBy));
111
117
  }
118
+
119
+ /**
120
+ * The seek a decoded cursor names. A cursor carrying one more key than the ordering has its typed
121
+ * id at the tail; an older cursor, minted before the id rode there, falls back to the string slot.
122
+ */
123
+ function seekFromCursor(
124
+ decoded: { readonly key: readonly unknown[]; readonly id: string },
125
+ width: number,
126
+ ): SeekKey {
127
+ const key = reviveSortKey(decoded.key);
128
+ if (key.length !== width + 1) return { key, id: decoded.id };
129
+ return { key: key.slice(0, width), id: key[width] };
130
+ }
package/src/shape.ts CHANGED
@@ -31,10 +31,14 @@ export interface QueryShape {
31
31
  readonly unsupported: readonly string[];
32
32
  }
33
33
 
34
- /** Where a page resumes: the sort-key values of the last row plus its id tiebreak. */
34
+ /**
35
+ * Where a page resumes: the sort-key values of the last row plus its id tiebreak. `id` keeps the
36
+ * row's own TYPE: a stringified numeric id compared lexically against the next page's rows, so
37
+ * `"10" < "7"` and a row tied on the sort key after id 7 was never served.
38
+ */
35
39
  export interface SeekKey {
36
40
  readonly key: readonly unknown[];
37
- readonly id: string;
41
+ readonly id: unknown;
38
42
  }
39
43
 
40
44
  /**
@@ -53,7 +57,7 @@ export function seekKeyOf(
53
57
  if (id === undefined || id === null) throw new QueryNotPageableError(shape.entity);
54
58
  return {
55
59
  key: shape.orderBy.map((order) => columnOf(row, order.column)),
56
- id: typeof id === 'string' ? id : String(id),
60
+ id,
57
61
  };
58
62
  }
59
63
 
@@ -193,11 +197,18 @@ function normalize(value: unknown): unknown {
193
197
  return value instanceof Date ? value.getTime() : value;
194
198
  }
195
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
+ */
196
205
  function same(a: unknown, b: unknown): boolean {
197
206
  if (isNull(a) || isNull(b)) return isNull(a) && isNull(b);
198
- return compareValues(a, b) === 0 && typeof normalize(a) === typeof normalize(b);
207
+ return compareValues(a, b) === 0 && family(normalize(a)) === family(normalize(b));
199
208
  }
200
209
 
210
+ const family = (value: unknown): string => (isNumeric(value) ? 'numeric' : typeof value);
211
+
201
212
  /** Row ordering under an `orderBy` list. Stable, and total when an id key is last. */
202
213
  export function compareRows(a: object, b: object, orderBy: readonly OrderKey[]): number {
203
214
  for (const key of orderBy) {
package/src/source.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * app's `sql:` returns `from()` over an `@ultimat3/entity` repo, and a source only has to
5
5
  * answer these four questions.
6
6
  */
7
+ import { QueryColumnUnselectedError } from './errors';
7
8
  import type { Filter, FilterOp, OrderKey, QueryShape, SeekKey } from './shape';
8
9
  import { compareRows, compareValues, isNull, matchesFilters, totalOrder } from './shape';
9
10
  import { columnOf } from './stable';
@@ -132,6 +133,7 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
132
133
 
133
134
  async execute(): Promise<readonly TRow[]> {
134
135
  const source = typeof this.rows === 'function' ? await this.rows() : this.rows;
136
+ this.assertCarried(source);
135
137
  let result = source.filter((row) => matchesFilters(row, this.filters));
136
138
  const keys = this.servedOrder();
137
139
  if (keys.length > 0) {
@@ -144,6 +146,19 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
144
146
  return this.rowLimit === null ? result : result.slice(0, this.rowLimit);
145
147
  }
146
148
 
149
+ /**
150
+ * Every column a filter or the declared ordering reads must be ON the rows. Only the declared
151
+ * keys: the `id` tiebreak `servedOrder()` adds is `seek()`'s concern, refused there by name.
152
+ */
153
+ private assertCarried(rows: readonly TRow[]): void {
154
+ const read = new Set([...this.filters, ...this.order].map((entry) => entry.column));
155
+ for (const row of rows) {
156
+ for (const column of read) {
157
+ if (!(column in row)) throw new QueryColumnUnselectedError(this.entity, column);
158
+ }
159
+ }
160
+ }
161
+
147
162
  /** True when the ordering already names `id`, so the tiebreak is neither added nor doubled. */
148
163
  private get ordersById(): boolean {
149
164
  return this.order.some((key) => key.column === 'id');