@ultimat3/query 30.8.0 → 31.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
@@ -14,6 +14,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
14
14
  |---|---|
15
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
+ | `rows-override.ts`, `adapter.ts` | `@ultimat3/query/adapter`'s `overrideRows(query, rows \| fn \| undefined)`: canned rows where `sql()` is, answered in `buildSource` AFTER the parse and the policy, never cached (`rows-override.test.ts`). Never on the barrel — `@ultimat3/testing`'s `stub()` is its one caller |
17
18
  | `contract-test.ts` | `.contract()`, the generated suite. The three `X_CONTRACT_*` codes and `X_CONTRACT_DRIFT` are borrowed (`QUERY_BORROWED_ERROR_CODES`) |
18
19
  | `exercise.ts` | the exercise twin. "Reached" means `sql()` built a source, read off the refusal (`beforeSql`), because `read.ts` keeps no counter. No transaction: a read writes nothing |
19
20
  | `exercise-declarations.ts` | action's twin: `.contract({ exercise })` takes the app's `defineExercise()` value and reads the entry by the query's name (`apps/web/exercise.ts`, the one home) |
@@ -37,6 +38,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
37
38
  | `cursor-value.ts` | what a sort value becomes inside a cursor, and back |
38
39
  | `input-shape.ts` | what a read's `input:` may be, given that its route is a query STRING |
39
40
  | `sql.ts` | `explainQuery()` / `describeSql()` |
41
+ | `validate.ts` | the input parse — `X_INPUT_INVALID` with `issues` + `issuesTruncated` on `meta`, as action's twin |
40
42
  | `cache.ts` | the read path: the request memo, and the fill through `@ultimat3/cache`'s registered tiers |
41
43
  | `search.ts` | `search()` — the query FACTORY over an entity's `.searchable()` columns |
42
44
  | `source.ts` | `SqlSource` contract + `from()` in-memory reference |
@@ -91,7 +93,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
91
93
  - **A `single: true` read answers the row or `X_NOT_FOUND` by ONE rule** (`single-answer.ts`:
92
94
  `oneRowOf`, `readAnswer`) — the route and `@ultimat3/mcp`'s served tool both call it.
93
95
  - **`rateLimit:` is spent ONCE, in `buildSource`** (`rate-limit-gate.ts`'s `spendReadLimit`), for
94
- every enforced build — route, `page()`, the MCP tool (`surface: 'mcp'`, judged as `'server'`).
96
+ every enforced build — route, `page()`, the MCP tool (`surface: 'mcp'`, judged as `'mcp'`, as an MCP action is).
95
97
  `'server'` and an `unenforced` build spend nothing. A live subscribe spends via `live.ts`'s
96
98
  `spendQueryLimit`, called by realtime per subscriber. `toQueryRoute` sets `rateLimitedBy:
97
99
  'handler'` and no stage bucket. Counted in `@ultimat3/http`'s installed store; key
@@ -113,6 +115,9 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
113
115
  re-export; `index.test.ts` pins the absence.
114
116
  - `registry.ts` announces `registerQueries` in core's registrar table at import; `defineApi` in
115
117
  `@ultimat3/action` depends on it (`X_REGISTRAR_MISSING` otherwise).
118
+ - `registerQuery`/`registerQueries` are on `@ultimat3/query/loader` (`loader.ts`), never the barrel:
119
+ `defineApi` is an app's one path; `x`'s loader and framework fixtures import the subpath
120
+ (`loader.test.ts` pins both).
116
121
  - Authz goes through `enforce(surface, policy, { input, actor, ctx })`; a live denial keeps its 4403
117
122
  close code on `QueryDeniedError.denial`. Policy runs per subscriber for live queries — never cache a
118
123
  decision across actors.
@@ -146,7 +151,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
146
151
 
147
152
  - **`audit: true` observes the read path, never forks it**: `buildSource` fills the trace (`input`,
148
153
  parsed-or-nothing on a pre-parse denial); `readRowsIn` sets `replayed`. The record shape and the
149
- sink are `@ultimat3/core`'s (`AuditRecord`, `setAuditSink`) — shared with `@ultimat3/action`, and
154
+ sink are `@ultimat3/core`'s (`AuditRecord`, `installAuditSink` on `./adapter`) — shared with `@ultimat3/action`, and
150
155
  `AUDIT_RECORD_FIELDS` is the parity pin both packages' tests assert (`audit.test.ts`).
151
156
  - **A memo or cache hit is recorded** (`replayed: true`): an audit of a read is who saw what.
152
157
  - **One record per CALL**: `runQuery` and `readCall` (`.page()`) record the whole call;
@@ -205,7 +210,9 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
205
210
  - The matcher patches from `QueryShape`, never from SQL text.
206
211
  - `paginate` has no `offset` and never grows one; it is reachable only as
207
212
  `query.page(input, { first, after })`. **A page is bounded**: `first` is 1…`MAX_PAGE_SIZE`
208
- (10,000 — `@ultimat3/entity`'s, imported; this package never re-publishes it).
213
+ (10,000 — `@ultimat3/entity`'s, imported; this package never re-publishes it). **The wire's
214
+ bound is `MAX_WIRE_PAGE_SIZE` (500)**, raised per read by `maxPage` (`assertMaxPage` at
215
+ `query()`, `X_QUERY_MAX_PAGE_INVALID`); the route and `openapi.ts` both read `target.maxPage`.
209
216
  - **A declared `.limit()` bounds the LISTING, on every page**: the window is
210
217
  `min(rowsLeft, first + 1)`, and a limited read's cursor carries the rows served so far at the
211
218
  tail of its key (after the typed id). A cursor without it is `X_CURSOR_INVALID` on a limited
@@ -256,6 +263,8 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
256
263
  - **A cache key carries the read's AUTHORITY**: `readAuthority(actor, scope)` is its one producer and
257
264
  `cacheKeyFor`'s fourth argument is required. `scope` defaults to `'actor'`; `'tenant'` with no
258
265
  `orgId` (`undefined`, `''` or `null` — `orgless()`) narrows to the actor. The authority is JSON.
266
+ **`'actor'` carries the grant set** (sorted roles, scopes, permissions, `onBehalfOf`): one id is
267
+ not one grant set (`cache-authority.test.ts`).
259
268
  - **`cache.ttlMs` is judged at `query()`** (`X_QUERY_CACHE_TTL_INVALID`). A `cache:` read always
260
269
  expires: `def.cache.ttlMs ?? DEFAULT_READ_CACHE_TTL_MS` (60 s); an unset TTL is OMITTED so the tier
261
270
  defaults.
@@ -273,7 +282,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
273
282
  ## Commands
274
283
 
275
284
  ```
276
- bun test packages/query
285
+ bun test ./packages/query/
277
286
  bun run typecheck
278
287
  ```
279
288
 
package/README.md CHANGED
@@ -18,8 +18,9 @@ export const liveFeed = query({
18
18
  const rows = await liveFeed({ orgId }); // typed rows, policy enforced
19
19
  ```
20
20
 
21
- Register once at boot: `registerQueries(await import('./live'))`. Export names become
22
- query names, which the manifest, the live protocol and `/_x` all address.
21
+ Register once at boot: `defineApi({ queries: [liveQueries] })` in `apps/web/api/index.ts`. Export
22
+ names become query names, which the manifest, the live protocol and `/_x` all address.
23
+ `registerQuery`/`registerQueries` are `x`'s loader's (`@ultimat3/query/loader`), never the barrel's.
23
24
 
24
25
  ## One declaration, five projections
25
26
 
@@ -220,8 +221,11 @@ forgetting the policy, and the reason is what tells the next reader which of the
220
221
  | `facade.ts` | binds each projection to the query; re-implements none of them |
221
222
  | `mcp-tool.ts` | the MCP read descriptor |
222
223
  | `client.ts` | the typed read client (browser-safe) |
224
+ | `loader.ts` | `@ultimat3/query/loader` — `registerQuery`/`registerQueries`, for `x`'s loader and framework fixtures; never the barrel |
225
+ | `adapter.ts` | `@ultimat3/query/adapter` — `overrideRows`, the test seam `@ultimat3/testing`'s `stub()` drives, and `QueryClient` / `QueryClientOptions`, the client's own shape |
223
226
  | `http.ts` | the route projection — `GET /_x/query/<kebab>`, the URL the client derives |
224
- | `page-controls.ts` | `_first` / `_after` — the two search-string keys that page the route, split out and judged at the wire against `@ultimat3/entity`'s `MAX_PAGE_SIZE` |
227
+ | `page-controls.ts` | `_first` / `_after` — the two search-string keys that page the route, split out and judged at the wire against `MAX_WIRE_PAGE_SIZE` (500) or the read's `maxPage`; `assertMaxPage` |
228
+ | `validate.ts` | the input parse: `X_INPUT_INVALID` carrying `issues` (by path) and `issuesTruncated` — action's `validate.ts` twin |
225
229
  | `openapi.ts` | the read half of `openapi.json` — one `GET` path item per registered query, publishing a declared `rows:` |
226
230
  | `openapi-errors.ts` | a read operation's `responses` with its declared `errors:` in them |
227
231
  | `declared-errors.ts` / `declared-errors-contract.ts` | `errors:` judged around `sql` and `execute`; `.contract()`'s scan of `sql`'s source |
@@ -420,7 +424,8 @@ GET /_x/query/live-feed?orgId=… → [ …rows ] (uncha
420
424
  |---|---|
421
425
  | the keys are `_first` and `_after`, not `first` and `after` | the search string is ALSO the input, and an input member named `first` was legal on every release before this one — a listing's page-size field is the natural name for it. Reserving the bare names would have refused every such declaration at `query()`, in a minor. The underscore is the framework's own mark, as `/_x/` is |
422
426
  | a declaration naming either is refused at `query()` | `X_QUERY_INPUT_UNENCODABLE`, in the declaring file: the route strips the key before the schema runs, so the field could never be filled |
423
- | `_first` outside 1–10,000, not a whole number, or `_after` without `_first` | **400** `X_INPUT_INVALID` with the read's own fix line — judged at the wire (`page-controls.ts`), because `paginate` asserts the same bound as `X_INVARIANT`, which is a 500 blaming the server for a number the caller typed |
427
+ | `_first` outside 1–500 (`MAX_WIRE_PAGE_SIZE`), not a whole number, or `_after` without `_first` | **400** `X_INPUT_INVALID` with the read's own fix line — judged at the wire (`page-controls.ts`), because `paginate` asserts the same bound as `X_INVARIANT`, which is a 500 blaming the server for a number the caller typed |
428
+ | `query({ maxPage: 2000 })` | raises THIS read's wire bound, up to `MAX_PAGE_SIZE` (10,000); `openapi.json`'s `maximum` follows it. `As of 31.0.0`: the wire took 10,000 for every read, and 10,000 rows × a per-address budget (the demo's 120/s) is a table dump per second from one caller. A server `read.page()` keeps `MAX_PAGE_SIZE`. Not a whole number from 1 to 10,000: `X_QUERY_MAX_PAGE_INVALID` at `query()` |
424
429
  | a cursor that is not this read's | **400** `X_CURSOR_INVALID` — `decodeCursor` is the one judge of a cursor; the route checks only that `_after` is one non-empty string |
425
430
  | a control sent twice | refused, never resolved to the last one as a declared scalar is: two page sizes is two pages asked for in one request |
426
431
  | a declared `.limit()` is the size of the LISTING | `first` is the size of a page and never widens it: the window is `min(what the limit has left, first + 1)`, a limited read's cursor carries how many rows are already served, and the last page inside it answers `hasMore: false` with `nextCursor: null`. `As of 2026-10`; until then `first` REPLACED the limit, so `?_first=10000` walked a "top 3" to the end of the table. A read paged past its limit on purpose drops the `.limit()` |
@@ -541,7 +546,7 @@ declares who may be served one entry:
541
546
 
542
547
  | `scope` | Key holds | Use it when |
543
548
  |---|---|---|
544
- | `actor` (default) | the actor's kind, id and org | anything. Declaring nothing gets this, and this is always correct |
549
+ | `actor` (default) | the actor's kind, id, org and GRANT SET (roles, scopes, permissions, `onBehalfOf`, order-free) — one id under two grant sets is two entries, so a role revoked inside `ttlMs` is never served the wider grant's rows (`As of 31.0.0`) | anything. Declaring nothing gets this, and this is always correct |
545
550
  | `tenant` | the actor's org — the actor itself when there is none | every member of one org sees the same rows |
546
551
  | `global` | nothing | the rows are the same for everyone, signed-in or not |
547
552
 
@@ -555,8 +560,8 @@ required positional argument of `cacheKeyFor`, because an optional one is one a
555
560
  re-asks it per rung before each write, so a bust that lands mid-read cannot be republished for the
556
561
  whole TTL — the caller still gets the rows it read, because those are its answer; only publishing is
557
562
  refused. Every tier call goes through `bestEffort`, so a Redis refusal is a miss rather than a
558
- failed business read and shows up in `recentTierFailures()` under the name of the tier that
559
- refused. Concurrent misses of one key share one `load()`. `@ultimat3/query` used to carry its own
563
+ failed business read and is published as a `cache.tier_failure` event under the name of the tier
564
+ that refused — `subscribe('cache.tier_failure', handler)`. Concurrent misses of one key share one `load()`. `@ultimat3/query` used to carry its own
560
565
  copy of the first two and none of the third.
561
566
 
562
567
  `cache.ttlMs` is refused at `query()` unless it is positive and finite
@@ -673,9 +678,9 @@ export const patientChart = query({
673
678
 
674
679
  Every **call** is one record in the installed `AuditSink` — allowed, denied (before or after the
675
680
  input parse) and failed. The sink, the record and the installed slot are `@ultimat3/core`'s, the
676
- same ones an action's `audit: true` writes to: `setAuditSink(sink)` from `@ultimat3/core` (or
677
- `@ultimat3/action` — the same function) installs one sink for both, and `record.primitive` tells a
678
- read from a write.
681
+ same ones an action's `audit: true` writes to: `auditSink` in `apps/web/runtime.ts` (the boot
682
+ installs it through core's `installAuditSink`, `@ultimat3/core/adapter`) is one sink for both, and
683
+ `record.primitive` tells a read from a write.
679
684
 
680
685
  **A memo or cache hit is recorded too**, with `replayed: true`. An audit of a read is about who saw
681
686
  what, not what was computed: a reader handed rows by the request memo saw them exactly as much as
@@ -746,6 +751,7 @@ export const runRecording = query({
746
751
  | The route | `GET /_x/query/<kebab>` — the read's own. No `_token`: MINT (`application/json`). `_token`: SERVE (the bytes). `auth: 'public'`, `enforcedBy: 'handler'`, `cache: no-store` |
747
752
  | Policy | evaluated at MINT, exactly as for a rows read: actor half, the declared `rateLimit:`, the parse, the predicate. A refusal is the policy's own 401/403 |
748
753
  | The token | `seal()`ed (`@ultimat3/core`, AES-256-GCM under the app master key, purpose `ultimate.query.download:<name>`) claims: the query, `fingerprint()` of the PARSED input, the expiry on `ctx.now()`, the minting actor minus `facts`. The ACTOR is ciphertext — no id, role or grant readable; the query's path and its input are plain in the URL, as on any read |
754
+ | Revocation | **the token IS the grant for its TTL** (`DEFAULT_DOWNLOAD_TTL_MS`, 5 min; `expiresInMs` up to 1 h): a logout, a revoked session or a lost role does not void a URL already minted — the serve checks no session. Keep `expiresInMs` as short as the player or tab needs |
749
755
  | The serve | the seal first (forged, truncated, another query's, a retired key → `X_QUERY_DOWNLOAD_TOKEN_INVALID`, 403), then the expiry (`X_QUERY_DOWNLOAD_TOKEN_EXPIRED`, 410), then the URL's input re-parsed against the bound fingerprint (edited → 403). `handle` runs as the MINTING actor; the policy is not re-run — it decided at mint, with `facts` the token cannot carry |
750
756
  | `handle`'s answer | `{ body: Uint8Array \| Blob \| ReadableStream, contentType, filename, disposition?: 'attachment' \| 'inline', size? }`, or `body: (range?) => BodyInit` (`DownloadBodyOpener`) with `size` required — bytes, never base64. Anything else is a 500 (`X_INVARIANT`) naming the fix |
751
757
  | Headers | `content-type`, `content-disposition` (`attachment` default; ASCII `filename` + exact `filename*=UTF-8''…`), `x-content-type-options: nosniff`, `content-security-policy: default-src 'none'; img-src 'self'; media-src 'self'; sandbox`, `referrer-policy: no-referrer`, `cache-control: no-store`; `content-length` only when the size is known — a `Uint8Array`, a `Blob`, or a stream with `size` |
@@ -763,7 +769,7 @@ or a stream with `size`, served without buffering.
763
769
 
764
770
  | Code | When | Fix |
765
771
  |---|---|---|
766
- | `X_QUERY_AUDIT_SINK_MISSING` | `audit: true` and no audit sink installed — refused before the parse | `setAuditSink(sink)` from `@ultimat3/core` at boot |
772
+ | `X_QUERY_AUDIT_SINK_MISSING` | `audit: true` and no audit sink installed — refused before the parse | `auditSink: postgresAuditSink({ executor })` in `apps/web/runtime.ts` |
767
773
  | `X_QUERY_AUDIT_SINK_FAILED` | the sink refused the record of an allowed read; the rows were withheld | fix the sink, then retry the read |
768
774
  | `X_QUERY_DOWNLOAD_INVALID` | `download:` beside a rows option, a `handle` that is not a function, `expiresInMs` outside 1 000 … 3 600 000 | declare `download: { handle }` alone |
769
775
  | `X_QUERY_DOWNLOAD_ONLY` | rows asked of a download (`.live()`, `.page()`, `.as()`, `sourceFor`) | `await q.url(input)`, or `await q(input)` for the file |
@@ -775,10 +781,11 @@ or a stream with `size`, served without buffering.
775
781
  | `X_MATCHER_UNSUPPORTED` | live query the matcher cannot patch | reshape it, or `live: false` |
776
782
  | `X_CURSOR_INVALID` | tampered / foreign / malformed cursor | request the first page again |
777
783
  | `X_QUERY_NOT_PAGEABLE` | a paged or live read returned a row with no `id` | select the primary key: `db.<rows>.select({ id: true, … })` |
778
- | `X_INPUT_INVALID` | input failed the Standard Schema; a page control the wire refuses; a `search()` handed a blank term, a cursor, or a window that would cut rows | `x queries show <name> --json` |
779
- | `X_QUERY_UNREGISTERED` | used before `registerQueries()` ran | register at boot |
784
+ | `X_INPUT_INVALID` | input failed the Standard Schema (`meta.issues` by path, at most `MAX_ISSUES`, `meta.issuesTruncated` when cut); a page control the wire refuses; a `search()` handed a blank term, a cursor, or a window that would cut rows | `x queries show <name> --json` |
785
+ | `X_QUERY_UNREGISTERED` | used before `defineApi()` named it | hand its module to `defineApi` at boot |
780
786
  | `X_NOT_FOUND` | a `single: true` read matched no row — the HTTP route's 404, and the MCP tool's refusal (entity's code) | `x queries show <name> --json` prints the SQL |
781
787
  | `X_QUERY_SINGLE_INVALID` | `single:` declared as something other than a boolean | `single: true`, or drop the key |
788
+ | `X_QUERY_MAX_PAGE_INVALID` | `maxPage:` not a whole number of rows from 1 to 10,000 | `query({ maxPage: 2000 })`, or drop the key for the 500-row wire cap |
782
789
  | `X_QUERY_FOREIGN` | a look-alike was projected as a query | declare it with `query({ … })` |
783
790
  | `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` |
784
791
 
@@ -792,27 +799,19 @@ a job boundary the class is gone and the `code` is what survives — match on th
792
799
 
793
800
  | Class | Code | Declared in |
794
801
  |---|---|---|
795
- | `CursorValueUnsupportedError` | `X_CURSOR_VALUE_UNSUPPORTED` | `src/errors.ts` |
796
- | `MatcherUnsupportedError` | `X_MATCHER_UNSUPPORTED` | `src/errors.ts` |
797
- | `QueryAuditSinkFailedError` | `X_QUERY_AUDIT_SINK_FAILED` | `src/audit-errors.ts` |
798
- | `QueryAuditSinkMissingError` | `X_QUERY_AUDIT_SINK_MISSING` | `src/audit-errors.ts` |
799
802
  | `QueryDeniedError` | the policy denial's own code (`X_FORBIDDEN`, `X_UNAUTHENTICATED`, …), kept on `.denial` | `src/errors.ts` |
800
- | `QueryDeprecationInvalidError` | `X_QUERY_DEPRECATION_INVALID` | `src/errors.ts` |
801
- | `QueryDownloadInvalidError` | `X_QUERY_DOWNLOAD_INVALID` | `src/download-errors.ts` |
802
- | `QueryDownloadOnlyError` | `X_QUERY_DOWNLOAD_ONLY` | `src/download-errors.ts` |
803
- | `QueryDownloadTokenExpiredError` | `X_QUERY_DOWNLOAD_TOKEN_EXPIRED` | `src/download-errors.ts` |
804
- | `QueryDownloadTokenInvalidError` | `X_QUERY_DOWNLOAD_TOKEN_INVALID` | `src/download-errors.ts` |
805
- | `QueryDuplicateError` | `X_QUERY_DUPLICATE` | `src/errors.ts` |
806
- | `QueryForeignError` | `X_QUERY_FOREIGN` | `src/errors.ts` |
807
803
  | `QueryInputInvalidError` | `X_INPUT_INVALID` | `src/errors.ts` |
808
- | `QueryInputUnencodableError` | `X_QUERY_INPUT_UNENCODABLE` | `src/errors.ts` |
809
- | `QueryNotPageableError` | `X_QUERY_NOT_PAGEABLE` | `src/errors.ts` |
810
- | `QueryPolicyMissingError` | `X_QUERY_POLICY_MISSING` | `src/errors.ts` |
811
804
  | `QueryRowNotFoundError` | `X_NOT_FOUND` | `src/errors.ts` |
812
805
  | `QuerySingleInvalidError` | `X_QUERY_SINGLE_INVALID` | `src/errors.ts` |
813
- | `QuerySubscribesDriftError` | `X_QUERY_SUBSCRIBES_DRIFT` | `src/errors.ts` |
814
- | `QuerySubscribesInvalidError` | `X_QUERY_SUBSCRIBES_INVALID` | `src/errors.ts` |
815
- | `QueryUnregisteredError` | `X_QUERY_UNREGISTERED` | `src/errors.ts` |
806
+
807
+ ## `@ultimat3/query/adapter` — canned rows for a test
808
+
809
+ `overrideRows(target, rows | (({ input, ctx }) => rows) | undefined)` answers a read with canned
810
+ rows where its `sql()` is. The actor half, the declared rate limit, the input parse and the policy
811
+ still run first, so a stubbed read still refuses whoever its policy refuses; every surface (route,
812
+ MCP, live, pages) gets the same rows, and none is cached, so nothing outlives `undefined`, which
813
+ restores the declared read. Not on the `.` barrel: an app module cannot answer a read with rows its
814
+ `sql()` did not produce. Reach it through `@ultimat3/testing`'s `stub()`.
816
815
 
817
816
  ## Boundaries
818
817
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/query",
3
- "version": "30.8.0",
3
+ "version": "31.0.0",
4
4
  "description": "The query primitive: a policy-checked read, optionally live, with cursor pagination and an incremental matcher",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -29,7 +29,9 @@
29
29
  },
30
30
  "exports": {
31
31
  ".": "./src/index.ts",
32
- "./client": "./src/client.ts"
32
+ "./adapter": "./src/adapter.ts",
33
+ "./client": "./src/client.ts",
34
+ "./loader": "./src/loader.ts"
33
35
  },
34
36
  "files": [
35
37
  "src",
@@ -47,12 +49,12 @@
47
49
  "test": "bun test"
48
50
  },
49
51
  "dependencies": {
50
- "@ultimat3/cache": "30.8.0",
51
- "@ultimat3/core": "30.8.0",
52
- "@ultimat3/db": "30.8.0",
53
- "@ultimat3/entity": "30.8.0",
54
- "@ultimat3/http": "30.8.0",
55
- "@ultimat3/policy": "30.8.0",
56
- "@ultimat3/schema": "30.8.0"
52
+ "@ultimat3/cache": "31.0.0",
53
+ "@ultimat3/core": "31.0.0",
54
+ "@ultimat3/db": "31.0.0",
55
+ "@ultimat3/entity": "31.0.0",
56
+ "@ultimat3/http": "31.0.0",
57
+ "@ultimat3/policy": "31.0.0",
58
+ "@ultimat3/schema": "31.0.0"
57
59
  }
58
60
  }
package/src/adapter.ts ADDED
@@ -0,0 +1,7 @@
1
+ // `@ultimat3/query/adapter` — what a test harness reaches beside the barrel: the rows-override seam
2
+ // `@ultimat3/testing`'s `stub(query)` installs. Never the `.` barrel: an app module cannot answer a
3
+ // read with rows its `sql()` did not produce.
4
+
5
+ export type { QueryClient, QueryClientOptions } from './client';
6
+ export { overrideRows } from './read';
7
+ export type { RowsOverride } from './rows-override';
@@ -18,7 +18,7 @@ export class QueryAuditSinkMissingError extends UltimateError {
18
18
  code: 'X_QUERY_AUDIT_SINK_MISSING',
19
19
  cause: `query "${query}" declares \`audit: true\` and no audit sink is installed`,
20
20
  // The registered fix, verbatim: code first, so the line can be pasted (`wiki/Error-Codes.md`).
21
- fix: 'setAuditSink(sink) # from @ultimat3/core, at boot before defineApi()',
21
+ fix: 'auditSink: postgresAuditSink({ executor }) # in export const runtime = { … }, apps/<app>/runtime.ts',
22
22
  });
23
23
  }
24
24
  }
@@ -35,7 +35,7 @@ export class QueryAuditSinkFailedError extends UltimateError {
35
35
  code: 'X_QUERY_AUDIT_SINK_FAILED',
36
36
  cause: `"${query}" was read and its audit sink refused the record, so the rows were withheld — a read commits nothing, so nothing is left to reconcile`,
37
37
  // The sink's own error is `sourceError`; a read commits nothing, so the retry is safe.
38
- fix: 'setAuditSink(sink) # install a sink that accepts the record, then retry the read',
38
+ fix: 'auditSink: postgresAuditSink({ executor }) # apps/<app>/runtime.ts — a sink that accepts the record; then retry the read',
39
39
  meta: { query },
40
40
  sourceError,
41
41
  });
package/src/audit-gate.ts CHANGED
@@ -5,7 +5,13 @@
5
5
  */
6
6
 
7
7
  import type { AuditOutcome, AuditRecord, AuditSink, Ctx } from '@ultimat3/core';
8
- import { getAuditSink, isUltimateError, logger } from '@ultimat3/core';
8
+ import {
9
+ AUDIT_SINK_FAILED_EVENT,
10
+ auditFailureOf,
11
+ auditOutcomeOf,
12
+ getAuditSink,
13
+ logger,
14
+ } from '@ultimat3/core';
9
15
  import { QueryAuditSinkFailedError, QueryAuditSinkMissingError } from './audit-errors';
10
16
  import { QueryDeniedError } from './errors';
11
17
  import type { QuerySurface } from './policy-gate';
@@ -126,11 +132,7 @@ export function auditedSource<TRow>(source: SqlSource<TRow>, audit: ReadAudit):
126
132
  * this `catch` would replace the caller's error with its own.
127
133
  */
128
134
  export function readOutcomeFor(error: unknown): AuditOutcome {
129
- try {
130
- return error instanceof QueryDeniedError ? 'denied' : 'failed';
131
- } catch {
132
- return 'failed';
133
- }
135
+ return auditOutcomeOf(error, (thrown) => thrown instanceof QueryDeniedError);
134
136
  }
135
137
 
136
138
  function recordOf(audit: ReadAudit, at: Date, outcome: AuditOutcome, error: unknown): AuditRecord {
@@ -148,8 +150,7 @@ function recordOf(audit: ReadAudit, at: Date, outcome: AuditOutcome, error: unkn
148
150
  idempotencyKey: null,
149
151
  replayed: audit.trace.replayed,
150
152
  outcome,
151
- failure:
152
- outcome === 'allowed' ? null : { code: isUltimateError(error) ? error.code : null, error },
153
+ failure: outcome === 'allowed' ? null : auditFailureOf(error),
153
154
  };
154
155
  }
155
156
 
@@ -168,7 +169,7 @@ async function recordThrew(sink: AuditSink, record: AuditRecord): Promise<void>
168
169
  // The same line `@ultimat3/action` writes, so `audit.sink.failed` is one alert rule over every
169
170
  // audited primitive. Never the record: rendering an input the sink just choked on is the
170
171
  // second throw this branch exists to prevent.
171
- logger.error('audit.sink.failed', {
172
+ logger.error(AUDIT_SINK_FAILED_EVENT, {
172
173
  query: record.name,
173
174
  outcome: record.outcome,
174
175
  error,
package/src/cache.ts CHANGED
@@ -94,8 +94,24 @@ export function readAuthority(actor: Actor, scope: QueryCacheScope): string {
94
94
  }
95
95
  }
96
96
 
97
+ /**
98
+ * The actor AND its grant set. One id is not one grant set — a role revoked inside a `ttlMs`, or a
99
+ * shared dev identity holding two role sets — and `sql(input, ctx)` may read `ctx.actor.roles`, so
100
+ * keying on the id alone served the narrower grant the wider grant's rows. Sorted copies: a set,
101
+ * so two orderings of one grant are one entry.
102
+ */
97
103
  const actorAuthority = (actor: Actor): string =>
98
- JSON.stringify([actor.kind, actor.id, actor.orgId ?? null]);
104
+ JSON.stringify([
105
+ actor.kind,
106
+ actor.id,
107
+ actor.orgId ?? null,
108
+ grantSet(actor.roles),
109
+ grantSet(actor.scopes),
110
+ grantSet(actor.permissions),
111
+ actor.onBehalfOf === undefined ? null : [actor.onBehalfOf.actorKind, actor.onBehalfOf.actorId],
112
+ ]);
113
+
114
+ const grantSet = (values: readonly string[]): readonly string[] => [...new Set(values)].sort();
99
115
 
100
116
  /**
101
117
  * Deterministic: same query + same input + same tags + same authority => same key.
package/src/errors.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Every failure @ultimat3/query can produce, one subclass per stable code. */
2
- import { assertNever, deniedCallerFix, registerErrorCodes, UltimateError } from '@ultimat3/core';
3
- import type { SurfaceDenial } from '@ultimat3/policy';
2
+ import { deniedCallerFix, registerErrorCodes, UltimateError } from '@ultimat3/core';
3
+ import type { PrimitiveDenial, SurfaceDenial } from '@ultimat3/policy';
4
+ import type { ValidationIssue } from '@ultimat3/schema';
4
5
 
5
6
  // No `docs:` on the classes below.
6
7
  // `UltimateError` fills it from `describeErrorCode(code).docs`, which is `@ultimat3/core`'s
@@ -38,6 +39,7 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
38
39
  X_QUERY_HTTP_PATH_INVALID: "a query's pinned http.path is not a valid path template",
39
40
  X_QUERY_ETAG_INVALID: 'etag: true on a read that has its own validator',
40
41
  X_QUERY_TIMEOUT_INVALID: 'a read declares a timeoutMs that is not a whole positive number of ms',
42
+ X_QUERY_MAX_PAGE_INVALID: 'a query declares a maxPage no page bound can hold',
41
43
  };
42
44
 
43
45
  /**
@@ -63,34 +65,6 @@ registerErrorCodes(
63
65
  Object.fromEntries(Object.entries(OWNED_TITLES).map(([code, title]) => [code, { title }])),
64
66
  );
65
67
 
66
- function denialCode(denial: SurfaceDenial): string {
67
- switch (denial.surface) {
68
- case 'http':
69
- return denial.problem.code;
70
- case 'live':
71
- case 'job':
72
- return denial.code;
73
- case 'mcp':
74
- return denial.content[0]?.text.split(':')[0] ?? 'X_FORBIDDEN';
75
- default:
76
- return assertNever(denial);
77
- }
78
- }
79
-
80
- function denialReason(denial: SurfaceDenial): string {
81
- switch (denial.surface) {
82
- case 'http':
83
- return denial.problem.detail;
84
- case 'live':
85
- case 'job':
86
- return denial.reason;
87
- case 'mcp':
88
- return denial.content[0]?.text ?? 'denied';
89
- default:
90
- return assertNever(denial);
91
- }
92
- }
93
-
94
68
  /**
95
69
  * An authz denial from `guardQuery()`. The code and reason come from the policy
96
70
  * decision — this package never invents an authz code — and the surface-shaped
@@ -99,25 +73,28 @@ function denialReason(denial: SurfaceDenial): string {
99
73
  export class QueryDeniedError extends UltimateError {
100
74
  readonly denial: SurfaceDenial;
101
75
 
102
- constructor(query: string, denial: SurfaceDenial) {
103
- const code = denialCode(denial);
76
+ /**
77
+ * Built from `guardPrimitive`'s answer, which already read the code and the reason out of the
78
+ * surface shape — a type-only import, so the browser-reachable error module pulls in no policy.
79
+ */
80
+ constructor(refused: PrimitiveDenial<'query'>) {
104
81
  super({
105
- code,
106
- cause: `${query} denied: ${denialReason(denial)}`,
107
- fix: `x policy explain ${query} --json # shows which clause decided and why`,
82
+ code: refused.code,
83
+ cause: `${refused.name} denied: ${refused.reason}`,
84
+ fix: `x policy explain ${refused.name} --json # shows which clause decided and why`,
108
85
  callerFix: deniedCallerFix(),
109
86
  });
110
- this.denial = denial;
87
+ this.denial = refused.denial;
111
88
  }
112
89
  }
113
90
 
114
- /** Thrown when a read is used before `registerQueries()` gave it a name. */
91
+ /** Thrown when a read is used before `defineApi()` gave it a name. */
115
92
  export class QueryUnregisteredError extends UltimateError {
116
93
  constructor() {
117
94
  super({
118
95
  code: 'X_QUERY_UNREGISTERED',
119
96
  cause: 'a query was used before it was registered, so it has no name',
120
- fix: "call registerQueries(await import('./live')) at boot, before serving reads",
97
+ fix: "list the query's module in defineApi({ queries }) in apps/<app>/api/index.ts — the boot names it before serving reads",
121
98
  });
122
99
  }
123
100
  }
@@ -337,12 +314,29 @@ export class QuerySubscribesDriftError extends UltimateError {
337
314
  }
338
315
 
339
316
  export class QueryInputInvalidError extends UltimateError {
340
- constructor(name: string, detail: string) {
317
+ /** The rejections, addressed by path — `undefined` for a refusal judged before the schema ran. */
318
+ readonly issues: readonly ValidationIssue[] | undefined;
319
+ /** `true` when validation stopped at `MAX_ISSUES`: `issues` is a prefix. Also on `meta`. */
320
+ readonly issuesTruncated: boolean;
321
+
322
+ /** `detail` is `formatIssues(issues).join('; ')` when both are passed (`validate.ts`). */
323
+ constructor(
324
+ name: string,
325
+ detail: string,
326
+ issues?: readonly ValidationIssue[],
327
+ issuesTruncated = false,
328
+ ) {
341
329
  super({
342
330
  code: 'X_INPUT_INVALID',
343
331
  cause: `input for query "${name}" failed validation: ${detail}`,
344
332
  fix: `x queries show ${name} --json # prints the expected input schema`,
333
+ // Action's shape (`InputInvalidError`): http lifts `meta.issues` to the problem's `issues`.
334
+ ...(issues === undefined
335
+ ? {}
336
+ : { meta: issuesTruncated ? { issues, issuesTruncated: true } : { issues } }),
345
337
  });
338
+ this.issues = issues;
339
+ this.issuesTruncated = issuesTruncated;
346
340
  }
347
341
  }
348
342
 
package/src/exercise.ts CHANGED
@@ -19,7 +19,8 @@ import {
19
19
  UltimateError,
20
20
  } from '@ultimat3/core';
21
21
  import type { ExerciseRequestOption } from '@ultimat3/http';
22
- import { statusFor, withRequestAs, withScratchRateLimitStore } from '@ultimat3/http';
22
+ import { statusFor, withRequestAs } from '@ultimat3/http';
23
+ import { withScratchRateLimitStore } from '@ultimat3/http/adapter';
23
24
  import { admitsAnonymous } from '@ultimat3/policy';
24
25
  import type { StandardSchemaV1 } from '@ultimat3/schema';
25
26
  import { validateAsync } from '@ultimat3/schema';
package/src/facade.ts CHANGED
@@ -38,6 +38,7 @@ export function facadeFor<
38
38
  ...(def.subscribes === undefined ? {} : { subscribes: def.subscribes }),
39
39
  ...(def.rows === undefined ? {} : { rows: def.rows }),
40
40
  ...(def.cache === undefined ? {} : { cache: def.cache }),
41
+ ...(def.maxPage === undefined ? {} : { maxPage: def.maxPage }),
41
42
  ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
42
43
  ...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }),
43
44
  ...(def.deprecated === undefined ? {} : { deprecated: def.deprecated }),
package/src/http.ts CHANGED
@@ -100,6 +100,7 @@ export function toQueryRoute(target: AnyQuery): Route {
100
100
  raw: search,
101
101
  }) as typeof search)
102
102
  : search,
103
+ target.maxPage,
103
104
  );
104
105
  const input = absentArraysOf(target.input, coerceQuery(target.input, values));
105
106
  if (target.single === true) {
package/src/index.ts CHANGED
@@ -33,9 +33,8 @@ export type { Infer } from '@ultimat3/schema';
33
33
  export { t } from '@ultimat3/schema';
34
34
  /**
35
35
  * The two refusals `query({ audit: true })` adds. The record and the sink are `@ultimat3/core`'s
36
- * (`setAuditSink`) — one sink for every audited primitive, so this package re-exports neither.
36
+ * (`runtime.ts`'s `auditSink`) — one sink for every audited primitive, so this package re-exports neither.
37
37
  */
38
- export { QueryAuditSinkFailedError, QueryAuditSinkMissingError } from './audit-errors';
39
38
  export type { QueryCacheScope } from './cache';
40
39
  /** `readAuthority` is the ONLY producer of `cacheKeyFor`'s authority — never spell one by hand. */
41
40
  export {
@@ -50,16 +49,14 @@ export type {
50
49
  DownloadUrl,
51
50
  FetchLike,
52
51
  QueryCallOptions,
53
- QueryClient,
54
52
  QueryClientMethod,
55
53
  QueryClientMethodOf,
56
- QueryClientOptions,
57
54
  QueryLike,
58
55
  QueryMap,
59
56
  QuerySingleClientMethod,
60
57
  } from './client';
61
58
  /** `queryClient` is the map-wide read client; `queryClientMethodFor` is what `.client()` binds. */
62
- export { downloadClientFor, queryClient, queryClientMethodFor } from './client';
59
+ export { queryClient, queryClientMethodFor } from './client';
63
60
  /**
64
61
  * `isNull` is the one definition of SQL NULL a custom `SqlSource` has to agree with, and
65
62
  * `totalOrder` is the one definition of the order it must serve a page in. `compareRows`,
@@ -84,34 +81,11 @@ export type {
84
81
  QueryDownload,
85
82
  } from './download';
86
83
  export {
87
- DEFAULT_DOWNLOAD_TTL_MS,
88
- MAX_DOWNLOAD_TTL_MS,
89
- MIN_DOWNLOAD_TTL_MS,
90
- } from './download';
91
- export type { DownloadTokenProblem } from './download-errors';
92
- export {
93
- QueryDownloadInvalidError,
94
- QueryDownloadOnlyError,
95
- QueryDownloadTokenExpiredError,
96
- QueryDownloadTokenInvalidError,
97
- } from './download-errors';
98
- export {
99
- CursorValueUnsupportedError,
100
- MatcherUnsupportedError,
101
84
  QueryColumnUnselectedError,
102
85
  QueryDeniedError,
103
- QueryDeprecationInvalidError,
104
- QueryDuplicateError,
105
- QueryForeignError,
106
86
  QueryInputInvalidError,
107
- QueryInputUnencodableError,
108
- QueryNotPageableError,
109
- QueryPolicyMissingError,
110
87
  QueryRowNotFoundError,
111
88
  QuerySingleInvalidError,
112
- QuerySubscribesDriftError,
113
- QuerySubscribesInvalidError,
114
- QueryUnregisteredError,
115
89
  } from './errors';
116
90
  export type {
117
91
  QueryExerciseActorOptions,
@@ -141,10 +115,12 @@ export { assertMatchable, match, positionFor } from './matcher';
141
115
  */
142
116
  export { queryOpenApiPaths, toQueryOpenApiOperation } from './openapi';
143
117
  /**
144
- * The shape the typed client's `.page()` takes. The bound both ends check is `@ultimat3/entity`'s
145
- * `MAX_PAGE_SIZE` — imported from there, never re-published here.
118
+ * The shape the typed client's `.page()` takes. A server page is bounded by `@ultimat3/entity`'s
119
+ * `MAX_PAGE_SIZE` — imported from there, never re-published here; the route's `_first` by
120
+ * `MAX_WIRE_PAGE_SIZE`, or the read's declared `maxPage`.
146
121
  */
147
122
  export type { PageControls } from './page-controls';
123
+ export { MAX_WIRE_PAGE_SIZE } from './page-controls';
148
124
  /**
149
125
  * The shapes `query.page(input, { first, after })` takes and answers with. `paginate` itself is
150
126
  * deliberately unexported: a page is the read's own answer, and a second, importable way to ask
@@ -166,7 +142,6 @@ export type {
166
142
  QueryCache,
167
143
  QueryDef,
168
144
  QueryDescriptor,
169
- QueryFacade,
170
145
  QueryOptions,
171
146
  QueryRateLimit,
172
147
  SourceOptions,
@@ -174,12 +149,12 @@ export type {
174
149
  export { describeQuery, isQuery, query, queryHash } from './query';
175
150
  /** The one read path. `defOf` stays unexported — that is the enforcement. */
176
151
  export { queryName, runQuery, sourceFor } from './read';
152
+ // `registerQuery`/`registerQueries` are deliberately absent: `defineApi({ queries })` is an app's
153
+ // one registration path, and `x`'s loader reaches them through `@ultimat3/query/loader`.
177
154
  export {
178
155
  describeQueries,
179
156
  getQuery,
180
157
  listQueries,
181
- registerQueries,
182
- registerQuery,
183
158
  resetQueries,
184
159
  } from './registry';
185
160
  /** The query FACTORY over an entity's searchable columns — a `query`, never a ninth primitive. */
package/src/loader.ts ADDED
@@ -0,0 +1,5 @@
1
+ // `@ultimat3/query/loader` — where an export is NAMED as a query: `x`'s module scan and the
2
+ // framework's own fixtures. Never the `.` barrel: an app hands its modules to `defineApi`, the one
3
+ // registration path (axiom 1), and that reaches these through core's registrar table.
4
+
5
+ export { registerQueries, registerQuery } from './registry';
package/src/openapi.ts CHANGED
@@ -23,7 +23,6 @@ import {
23
23
  templateParams,
24
24
  toOpenApiPath,
25
25
  } from '@ultimat3/core';
26
- import { MAX_PAGE_SIZE } from '@ultimat3/entity';
27
26
  import { policyCapability } from '@ultimat3/policy';
28
27
  import type { SchemaNode } from '@ultimat3/schema';
29
28
  import { nodeToJsonSchema, toWireOutputSchema, tryIntrospect } from '@ultimat3/schema';
@@ -31,7 +30,7 @@ import { DOWNLOAD_GRANT_PARAM } from './download-token';
31
30
  import { servedQueryPath } from './http-pin';
32
31
  import { withDeclaredErrors } from './openapi-errors';
33
32
  import { withValidatorDoc } from './openapi-etag';
34
- import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-controls';
33
+ import { MAX_WIRE_PAGE_SIZE, PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-controls';
35
34
  import type { AnyQuery } from './query';
36
35
  import { defOf, queryName } from './read';
37
36
  import { answersRecords } from './record-answer';
@@ -131,7 +130,7 @@ function rowsOperation(target: AnyQuery, name: string): Record<string, unknown>
131
130
  tags: [QUERY_TAG],
132
131
  summary: target.mcp?.description ?? name,
133
132
  ...(target.deprecated === undefined ? {} : { deprecated: true }),
134
- parameters: [...inputParameters(target), ...PAGE_PARAMETERS],
133
+ parameters: [...inputParameters(target), ...pageParameters(target.maxPage)],
135
134
  responses: withDeclaredErrors(target, PROBLEM_SCHEMA_REF, {
136
135
  '200': {
137
136
  description: 'ok',
@@ -264,14 +263,19 @@ const MINTED_URL: Readonly<Record<string, unknown>> = {
264
263
  },
265
264
  };
266
265
 
267
- /** The two controls, spelled from `page-controls.ts` so the document cannot drift from the route. */
268
- const PAGE_PARAMETERS: readonly Record<string, unknown>[] = [
266
+ /**
267
+ * The two controls, spelled from `page-controls.ts` so the document cannot drift from the route —
268
+ * the bound included: the read's declared `maxPage`, else the wire's `MAX_WIRE_PAGE_SIZE`.
269
+ */
270
+ const pageParameters = (
271
+ maxPage: number = MAX_WIRE_PAGE_SIZE,
272
+ ): readonly Record<string, unknown>[] => [
269
273
  {
270
274
  name: PAGE_FIRST_KEY,
271
275
  in: 'query',
272
276
  required: false,
273
- description: `page size; present, the response is the page envelope rather than the bare rows (1 to ${MAX_PAGE_SIZE})`,
274
- schema: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE },
277
+ description: `page size; present, the response is the page envelope rather than the bare rows (1 to ${maxPage})`,
278
+ schema: { type: 'integer', minimum: 1, maximum: maxPage },
275
279
  },
276
280
  {
277
281
  name: PAGE_AFTER_KEY,
@@ -11,6 +11,7 @@
11
11
  // the framework's own namespace mark — the route lives under `/_x/` for the same reason — and
12
12
  // `input-shape.ts` refuses a declaration that reaches for it, so the two names can never collide.
13
13
 
14
+ import { renderCauseValue, UltimateError } from '@ultimat3/core';
14
15
  import { MAX_PAGE_SIZE } from '@ultimat3/entity';
15
16
  import { QueryInputInvalidError } from './errors';
16
17
  import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-keys';
@@ -20,8 +21,35 @@ export const PAGE_CONTROL_KEYS: readonly string[] = [PAGE_FIRST_KEY, PAGE_AFTER_
20
21
 
21
22
  // The largest page a read will serve is `@ultimat3/entity`'s `MAX_PAGE_SIZE`, imported — a batch
22
23
  // and a page are one memory bound, and this package already depends on entity. `first` reaches
23
- // `paginate` straight from a route parameter, so the route judges it against that bound BEFORE
24
- // `paginate` runs (`pageControlsOf`), and one request can never ask for five million rows.
24
+ // `paginate` straight from a route parameter, so the route judges it BEFORE `paginate` runs
25
+ // (`pageControlsOf`), and one request can never ask for five million rows.
26
+ //
27
+ // The WIRE's bound is tighter: `MAX_WIRE_PAGE_SIZE`. 10,000 rows per request times a per-address
28
+ // budget (the demo's: 120/s) is a table dump a second from one caller, so a remote page stops at
29
+ // 500 unless the read declares `maxPage` — a written statement, in the file that owns the rows.
30
+ // A server call (`read.page()`) keeps `MAX_PAGE_SIZE`: the caller is the app's own code.
31
+
32
+ /** The largest `_first` the route serves for a read that declares no `maxPage`. */
33
+ export const MAX_WIRE_PAGE_SIZE = 500;
34
+
35
+ export class QueryMaxPageInvalidError extends UltimateError {
36
+ constructor(value: unknown) {
37
+ super({
38
+ code: 'X_QUERY_MAX_PAGE_INVALID',
39
+ cause: `query({ maxPage: ${renderCauseValue(value)} }) — not a whole number of rows from 1 to ${MAX_PAGE_SIZE}`,
40
+ fix: `query({ maxPage: 2000 }) # a whole number from 1 to ${MAX_PAGE_SIZE} — or drop maxPage for the ${MAX_WIRE_PAGE_SIZE}-row wire cap`,
41
+ });
42
+ }
43
+ }
44
+
45
+ /** Judged at `query()`, where the file that wrote it fails — never on every read forever. */
46
+ export function assertMaxPage(value: unknown): void {
47
+ if (value === undefined) return;
48
+ if (typeof value === 'number' && Number.isInteger(value) && value >= 1) {
49
+ if (value <= MAX_PAGE_SIZE) return;
50
+ }
51
+ throw new QueryMaxPageInvalidError(value);
52
+ }
25
53
 
26
54
  /** What the route hands `query.page()` — `PaginateArgs` minus the server-side options. */
27
55
  export interface PageControls {
@@ -55,7 +83,11 @@ const WHOLE_NUMBER = /^\d+$/;
55
83
  * 400 — and a second reader of the codec's envelope would be a second opinion about what a cursor
56
84
  * is. Only its shape as a search value is checked: one string, non-empty.
57
85
  */
58
- export function pageControlsOf(name: string, values: SearchValues): SplitSearch {
86
+ export function pageControlsOf(
87
+ name: string,
88
+ values: SearchValues,
89
+ maxPage: number = MAX_WIRE_PAGE_SIZE,
90
+ ): SplitSearch {
59
91
  // `Object.create(null)`, never `{}`, and the same rule `@ultimat3/http`'s `collectFields`
60
92
  // follows: a repeated `?__proto__=a&__proto__=b` arrives as an ARRAY, which is exactly what
61
93
  // `Object.prototype.__proto__`'s setter accepts — one assignment and this object's prototype IS
@@ -75,10 +107,10 @@ export function pageControlsOf(name: string, values: SearchValues): SplitSearch
75
107
  );
76
108
  }
77
109
  const size = single(name, PAGE_FIRST_KEY, first);
78
- if (!WHOLE_NUMBER.test(size) || Number(size) < 1 || Number(size) > MAX_PAGE_SIZE) {
110
+ if (!WHOLE_NUMBER.test(size) || Number(size) < 1 || Number(size) > maxPage) {
79
111
  throw new QueryInputInvalidError(
80
112
  name,
81
- `${PAGE_FIRST_KEY} must be a whole number of rows between 1 and ${MAX_PAGE_SIZE}, got "${size}"`,
113
+ `${PAGE_FIRST_KEY} must be a whole number of rows between 1 and ${maxPage}, got "${size}"`,
82
114
  );
83
115
  }
84
116
  if (after === undefined) return { input, page: { first: Number(size) } };
@@ -1,33 +1,27 @@
1
1
  /**
2
- * The single point of contact with @ultimat3/policy. A live query evaluates its
3
- * policy **per subscriber**, never once at subscribe time for everyone — so this
4
- * gate is called on every fanout decision and its result is never cached.
2
+ * The single point of contact with @ultimat3/policy, through `guardPrimitive` — the one gate
3
+ * `@ultimat3/action` runs too. A live query evaluates its policy **per subscriber**: this gate is
4
+ * called on every fanout decision and its result is never cached.
5
5
  */
6
6
 
7
7
  import type { Actor, Ctx } from '@ultimat3/core';
8
- import { assertNever } from '@ultimat3/core';
9
- import type { Policy, Surface as PolicySurface } from '@ultimat3/policy';
10
- import { enforce, enforceBeforeInput } from '@ultimat3/policy';
8
+ import type { GuardSubject, Policy, PrimitiveSurface } from '@ultimat3/policy';
9
+ import { guardPrimitive } from '@ultimat3/policy';
11
10
  import { QueryDeniedError } from './errors';
12
11
 
13
12
  /** Policies are opaque here: we evaluate them, we never introspect their rules. */
14
13
  export type QueryPolicy = Policy<unknown>;
15
14
 
16
15
  /**
17
- * Where a read came from. `'mcp'` is an agent's tool call — named so a declared `rateLimit:`
18
- * spends for it (`'server'`, in-process app code, does not) — and is judged by the policy exactly
19
- * as `'server'` always judged it, so no MCP read's authz answer moved.
16
+ * Where a read came from. `'mcp'` is an agent's tool call: it spends a declared `rateLimit:` and,
17
+ * since v31, is judged on policy's `mcp` surface exactly as an MCP action is.
20
18
  */
21
- export type QuerySurface = 'server' | 'http' | 'live' | 'mcp';
19
+ export type QuerySurface = PrimitiveSurface<'query'>;
22
20
 
23
21
  export interface QuerySubject {
24
22
  readonly actor: Actor | null;
25
23
  readonly input: unknown;
26
- /**
27
- * The already-loaded row a row-level rule decides about — the live row gate supplies it
28
- * per subscriber per row. Omitted for a subscribe-time or whole-query decision. It is a
29
- * field of its own and never folded into `input`: the predicate reads `args.row`.
30
- */
24
+ /** The loaded row a row-level rule decides about — never folded into `input`. */
31
25
  readonly row?: unknown;
32
26
  readonly ctx: Ctx;
33
27
  readonly query: string;
@@ -38,43 +32,24 @@ export function guardQuery(
38
32
  subject: QuerySubject,
39
33
  surface: QuerySurface,
40
34
  ): void {
41
- const denial = enforce(policySurface(surface), policy, {
42
- input: subject.input,
43
- actor: subject.actor,
44
- row: subject.row,
45
- ctx: subject.ctx,
46
- });
47
- if (denial !== undefined) throw new QueryDeniedError(subject.query, denial);
35
+ const { query: name, ...rest } = subject;
36
+ guard(policy, { stage: 'input', name, ...rest }, surface);
48
37
  }
49
38
 
50
- /**
51
- * The actor-only half of `guardQuery`, run BEFORE the input is parsed — the twin of
52
- * `@ultimat3/action`'s. A reader the policy refuses whatever they send is answered 403 (or 401),
53
- * never `X_INPUT_INVALID` with the read's input schema in it. Undecided passes to `guardQuery`.
54
- */
39
+ /** The actor-only half, BEFORE the input parse: refused is 403/401, never `X_INPUT_INVALID`. */
55
40
  export function guardQueryBeforeInput(
56
41
  policy: QueryPolicy,
57
42
  subject: Omit<QuerySubject, 'input' | 'row'>,
58
43
  surface: QuerySurface,
59
44
  ): void {
60
- const denial = enforceBeforeInput(policySurface(surface), policy, {
61
- actor: subject.actor,
62
- ctx: subject.ctx,
63
- });
64
- if (denial !== undefined) throw new QueryDeniedError(subject.query, denial);
45
+ guard(
46
+ policy,
47
+ { stage: 'actor', name: subject.query, actor: subject.actor, ctx: subject.ctx },
48
+ surface,
49
+ );
65
50
  }
66
51
 
67
- /** A direct server read is the job surface: no request, no socket to close. */
68
- function policySurface(surface: QuerySurface): PolicySurface {
69
- switch (surface) {
70
- case 'http':
71
- return 'http';
72
- case 'live':
73
- return 'live';
74
- case 'server':
75
- case 'mcp':
76
- return 'job';
77
- default:
78
- return assertNever(surface);
79
- }
52
+ function guard(policy: QueryPolicy, subject: GuardSubject, surface: QuerySurface): void {
53
+ const refused = guardPrimitive('query', policy, subject, surface);
54
+ if (refused !== undefined) throw new QueryDeniedError(refused);
80
55
  }
package/src/query.ts CHANGED
@@ -23,6 +23,7 @@ import { facadeFor } from './facade';
23
23
  import { assertQueryHttp, type QueryHttp } from './http-pin';
24
24
  import { assertEncodableInput } from './input-shape';
25
25
  import type { LiveQuery, ToLiveOptions } from './live';
26
+ import { assertMaxPage } from './page-controls';
26
27
  import type { Page, PaginateArgs } from './pagination';
27
28
  import type { QueryPolicy, QuerySurface } from './policy-gate';
28
29
  import { hasDef, queryName, runQuery, stashDef } from './read';
@@ -111,6 +112,12 @@ export interface QueryDef<
111
112
  readonly rows?: StandardSchemaV1<unknown, TRow>;
112
113
  sql(input: InferOutput<TInput>, ctx: Ctx): SqlSource<TRow>;
113
114
  readonly cache?: QueryCache;
115
+ /**
116
+ * The largest `_first` the route serves this read, raising the wire's `MAX_WIRE_PAGE_SIZE` (500)
117
+ * up to `MAX_PAGE_SIZE` (10,000). A server `read.page()` is bounded by `MAX_PAGE_SIZE` either
118
+ * way. Not a whole number in that range: `X_QUERY_MAX_PAGE_INVALID` at `query()`.
119
+ */
120
+ readonly maxPage?: number;
114
121
  /**
115
122
  * Answer `GET` with an `ETag` and `private, no-cache`, and a matching `If-None-Match` with a
116
123
  * bodiless 304 — after the policy and the rate limit, since a 304 is still a read of the caller's
@@ -243,6 +250,7 @@ export interface AnyQueryDef {
243
250
  readonly rows?: StandardSchemaV1;
244
251
  sql(input: unknown, ctx: Ctx): SqlSource<object>;
245
252
  readonly cache?: QueryCache;
253
+ readonly maxPage?: number;
246
254
  readonly etag?: true;
247
255
  readonly timeoutMs?: number;
248
256
  readonly mcp?: McpExposureDeclaration;
@@ -269,6 +277,8 @@ export interface AnyQuery {
269
277
  /** Lifted for the route: whether an answer is a record envelope is decided at projection. */
270
278
  readonly rows?: StandardSchemaV1;
271
279
  readonly cache?: QueryCache;
280
+ /** Declared `maxPage` — the route's `_first` bound. Absent is `MAX_WIRE_PAGE_SIZE`. */
281
+ readonly maxPage?: number;
272
282
  readonly mcp?: McpExposureDeclaration;
273
283
  /** Lifted so `toQueryRoute` reads the declaration without reaching through `defOf`. */
274
284
  readonly rateLimit?: QueryRateLimit;
@@ -326,6 +336,7 @@ export type QueryFacade<
326
336
  | 'subscribes'
327
337
  | 'rows'
328
338
  | 'cache'
339
+ | 'maxPage'
329
340
  | 'mcp'
330
341
  | 'rateLimit'
331
342
  | 'deprecated'
@@ -368,6 +379,7 @@ export function query<
368
379
  }
369
380
  assertFreshness(def);
370
381
  assertSubscribes(def.subscribes, def.live);
382
+ assertMaxPage(def.maxPage);
371
383
  // A JS caller or generated code can write `single: 'yes'`; truthiness would read it as `true`
372
384
  // on the route and `false` in the type, so anything but a boolean is refused where it is written.
373
385
  if (def.single !== undefined && typeof def.single !== 'boolean') {
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Actor } from '@ultimat3/core';
8
- import { isAnonymous, systemClock } from '@ultimat3/core';
8
+ import { liveClock, rateLimitScopeOf, rateLimitSpendsOn, rateLimitSubjectOf } from '@ultimat3/core';
9
9
  import type { RateLimitDecision } from '@ultimat3/http';
10
10
  // The bucket maths, the subject precedence and the refusal are `@ultimat3/http`'s (tier 2): a copy
11
11
  // here would be a second answer to "what does this limit mean" and "whose bucket is it".
@@ -13,12 +13,6 @@ import { installedRateLimitStore, rateLimited, rateLimitSpends, toBucket } from
13
13
  import type { QuerySurface } from './policy-gate';
14
14
  import type { QueryRateLimit } from './query';
15
15
 
16
- /**
17
- * Namespaced, never the bare query name: the HTTP stage still spends its own buckets under
18
- * `<route name>|<subject>`, and one key taken against two bucket shapes corrupts both counters.
19
- */
20
- const QUERY_SCOPE = 'query';
21
-
22
16
  export interface QueryLimitCaller {
23
17
  /** Who is reading — `ctx.actor` on a read, the socket's actor on a live subscribe. */
24
18
  readonly actor: Actor;
@@ -45,19 +39,18 @@ export async function spendReadLimit(
45
39
  limit: QueryRateLimit | undefined,
46
40
  caller: QueryLimitCaller,
47
41
  ): Promise<void> {
48
- if (limit === undefined || caller.surface === 'server') return;
49
- const actor = isAnonymous(caller.actor) ? null : caller.actor;
42
+ if (limit === undefined || !rateLimitSpendsOn(caller.surface)) return;
50
43
  const spends = rateLimitSpends(
51
44
  {
52
- actorId: actor?.id ?? null,
53
- orgId: actor?.orgId ?? null,
54
- ip: caller.clientAddress ?? null,
55
- routeName: `${QUERY_SCOPE}:${name}`,
45
+ // Core's one subject rule, `@ultimat3/action`'s too: an anonymous actor's org is still read.
46
+ ...rateLimitSubjectOf(caller.actor, caller.clientAddress ?? null),
47
+ // Namespaced, never the bare name: the HTTP stage spends `<route name>|<subject>`.
48
+ routeName: rateLimitScopeOf('query', name),
56
49
  },
57
50
  // The tenant allowance is the HTTP pipeline's; counting it here too would charge twice.
58
51
  { route: name, tenant: null },
59
52
  );
60
- const nowMs = caller.nowMs ?? systemClock.now().getTime();
53
+ const nowMs = caller.nowMs ?? liveClock.now().getTime();
61
54
  for (const spend of spends) {
62
55
  const decision = await installedRateLimitStore().take(
63
56
  spend.key,
package/src/read.ts CHANGED
@@ -14,10 +14,12 @@ import {
14
14
  ctxOf,
15
15
  failureCaptureActive,
16
16
  finiteOption,
17
+ instrument,
17
18
  logger,
18
19
  REDACTED,
19
20
  recordFailureInput,
20
21
  runWithContext,
22
+ subscribed,
21
23
  tryUseContext,
22
24
  useContext,
23
25
  withChildContext,
@@ -27,7 +29,7 @@ import {
27
29
  import { withStatementTimeout } from '@ultimat3/db';
28
30
  import { statusFor } from '@ultimat3/http';
29
31
  import type { StandardSchemaV1 } from '@ultimat3/schema';
30
- import { formatPath, maskSensitive, validateAsync } from '@ultimat3/schema';
32
+ import { maskSensitive } from '@ultimat3/schema';
31
33
  import type { ReadAudit, ReadTrace } from './audit-gate';
32
34
  import { audited, auditedBuild, readAuditFor } from './audit-gate';
33
35
  import {
@@ -39,11 +41,15 @@ import {
39
41
  readThrough,
40
42
  } from './cache';
41
43
  import { judgedRead, judgedSql } from './declared-errors';
42
- import { QueryForeignError, QueryInputInvalidError, QueryUnregisteredError } from './errors';
44
+ import { QueryForeignError, QueryUnregisteredError } from './errors';
43
45
  import { guardQuery, guardQueryBeforeInput } from './policy-gate';
44
46
  import type { AnyQuery, AnyQueryDef, Query, QueryOptions, SourceOptions } from './query';
45
47
  import { spendReadLimit } from './rate-limit-gate';
48
+ import type { RowsOverride } from './rows-override';
49
+ import { rowsOverrideOf, setRowsOverride } from './rows-override';
46
50
  import type { SqlSource } from './source';
51
+ import { from } from './source';
52
+ import { parsedOrNothing, parseQueryInput } from './validate';
47
53
 
48
54
  /**
49
55
  * Private on purpose. `@ultimat3/query` exports no way to read this back, which is
@@ -69,6 +75,15 @@ export function defOf(target: AnyQuery): AnyQueryDef {
69
75
  return def;
70
76
  }
71
77
 
78
+ /**
79
+ * Canned rows where `target`'s `sql()` is, on every read surface — the actor half, the declared
80
+ * limit, the parse and the policy still run first. `undefined` restores. On
81
+ * `@ultimat3/query/adapter` only: `@ultimat3/testing`'s `stub()` is its caller.
82
+ */
83
+ export function overrideRows(target: AnyQuery, rows: RowsOverride | undefined): void {
84
+ setRowsOverride(defOf(target), rows);
85
+ }
86
+
72
87
  /** Projections need a stable name; an unregistered query has none yet. */
73
88
  export function queryName(target: AnyQuery): string {
74
89
  if (target.name.length === 0) throw new QueryUnregisteredError();
@@ -209,7 +224,20 @@ function readRows(
209
224
  // read is filled — and which of the two a slow read took is the first thing to ask.
210
225
  'ultimate.cached': defOf(target).cache !== undefined,
211
226
  });
212
- const rows = await readRowsIn(target, raw, ctx, options, trace);
227
+ const startedAt = performance.now();
228
+ let rows: readonly object[];
229
+ try {
230
+ rows = await readRowsIn(target, raw, ctx, options, trace);
231
+ } catch (error) {
232
+ // A refused or failed read is still a read; the bus hears it sampled or not.
233
+ if (subscribed('query.read')) {
234
+ instrument('query.read', { query: name, durationMs: performance.now() - startedAt, error });
235
+ }
236
+ throw error;
237
+ }
238
+ if (subscribed('query.read')) {
239
+ instrument('query.read', { query: name, durationMs: performance.now() - startedAt });
240
+ }
213
241
  span.setAttribute('ultimate.rows', rows.length);
214
242
  return rows;
215
243
  });
@@ -252,6 +280,9 @@ async function readRowsIn(
252
280
  // a cache whose lifetime is the request. It still *publishes* into the memo: this read is the
253
281
  // newest answer the request has, so the next plain read of the key joins it rather than the
254
282
  // entry a write earlier in the request already moved past.
283
+ // Canned rows are never stored — not in the tier, not in the request memo (a `fresh` read
284
+ // publishes into it): an entry filled from a stub would outlive its undo.
285
+ if (rowsOverrideOf(def) !== undefined) return await read();
255
286
  if (options.fresh === true) return await readFresh(ctx, key, read);
256
287
  // `cache:` buys the tier, never the memo: a read asked twice in one request is one execution
257
288
  // whether or not its author opted into caching.
@@ -305,6 +336,13 @@ async function buildSource(
305
336
  trace: ReadTrace | null,
306
337
  ): Promise<SqlSource<object>> {
307
338
  const input = await admit(target, raw, ctx, options, trace);
339
+ const canned = rowsOverrideOf(defOf(target));
340
+ if (canned !== undefined) {
341
+ const rows = canned;
342
+ return from(queryName(target), () =>
343
+ typeof rows === 'function' ? rows({ input, ctx }) : rows,
344
+ );
345
+ }
308
346
  // `sql` and the source's `execute` are where `errors:` judges a throw (`declared-errors.ts`).
309
347
  const source = judgedSql(target, queryName(target), () => defOf(target).sql(input, ctx));
310
348
  // A live window is served in the order its patches are placed in. The matcher breaks a tie on
@@ -378,7 +416,7 @@ async function admit(
378
416
  nowMs: ctx.now().getTime(),
379
417
  });
380
418
  }
381
- const input = await validate(def.input, raw, name);
419
+ const input = await parseQueryInput(def.input, raw, name);
382
420
  if (failureCaptureActive()) recordFailureInput(maskSensitive(def.input, input, REDACTED));
383
421
  // The audit record's copy, masked by THIS read's own schema — never a key table (`action`'s rule).
384
422
  if (trace !== null) trace.input = maskSensitive(def.input, input, REDACTED);
@@ -404,23 +442,3 @@ async function admit(
404
442
  }
405
443
  return input;
406
444
  }
407
-
408
- /** The parse without the refusal — only for the record of a read already refused on other grounds. */
409
- async function parsedOrNothing(schema: StandardSchemaV1, raw: unknown): Promise<unknown> {
410
- const result = await validateAsync(schema, raw);
411
- return result.issues === undefined ? result.value : undefined;
412
- }
413
-
414
- async function validate(schema: StandardSchemaV1, raw: unknown, name: string): Promise<unknown> {
415
- const result = await validateAsync(schema, raw);
416
- if (result.issues !== undefined) {
417
- const detail = result.issues
418
- .map((issue) => {
419
- const path = formatPath(issue.path);
420
- return path === '' ? issue.message : `${path}: ${issue.message}`;
421
- })
422
- .join('; ');
423
- throw new QueryInputInvalidError(name, detail);
424
- }
425
- return result.value;
426
- }
@@ -0,0 +1,23 @@
1
+ // The rows-override slot: canned rows where a stored declaration's `sql()` is, for a test. Keyed by
2
+ // the declaration `read.ts` builds from, read per call after the parse and the policy — so a stub
3
+ // answers on every read surface and never decides who may read.
4
+
5
+ import type { Ctx } from '@ultimat3/core';
6
+
7
+ /** Rows, or a function of the parsed input and the caller that answers them. */
8
+ export type RowsOverride =
9
+ | readonly object[]
10
+ | ((args: {
11
+ readonly input: unknown;
12
+ readonly ctx: Ctx;
13
+ }) => readonly object[] | Promise<readonly object[]>);
14
+
15
+ const OVERRIDES = new WeakMap<object, RowsOverride>();
16
+
17
+ /** `undefined` puts the declared `sql()` back. */
18
+ export function setRowsOverride(def: object, rows: RowsOverride | undefined): void {
19
+ if (rows === undefined) OVERRIDES.delete(def);
20
+ else OVERRIDES.set(def, rows);
21
+ }
22
+
23
+ export const rowsOverrideOf = (def: object): RowsOverride | undefined => OVERRIDES.get(def);
@@ -0,0 +1,35 @@
1
+ /**
2
+ * A read's input parse — `@ultimat3/action`'s `validate.ts` twin. Bounded by `@ultimat3/schema`'s
3
+ * `MAX_ISSUES` (`validateAsync` stops collecting there), and the refusal carries the list a form
4
+ * binds to plus `issuesTruncated`, so a hostile 10k-item input costs a hundred rejections.
5
+ */
6
+
7
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
8
+ import { formatIssues, toValidationIssues, validateAsync } from '@ultimat3/schema';
9
+ import { QueryInputInvalidError } from './errors';
10
+
11
+ /**
12
+ * `toValidationIssues`, never the raw `result.issues`: a conforming library's issue may carry the
13
+ * rejected VALUE, and this list reaches the HTTP problem document.
14
+ */
15
+ export async function parseQueryInput(
16
+ schema: StandardSchemaV1,
17
+ raw: unknown,
18
+ name: string,
19
+ ): Promise<unknown> {
20
+ const result = await validateAsync(schema, raw);
21
+ if (result.issues === undefined) return result.value;
22
+ const issues = toValidationIssues(result.issues);
23
+ throw new QueryInputInvalidError(
24
+ name,
25
+ formatIssues(issues).join('; '),
26
+ issues,
27
+ result.issuesTruncated === true,
28
+ );
29
+ }
30
+
31
+ /** The parse without the refusal — only for the record of a read already refused on other grounds. */
32
+ export async function parsedOrNothing(schema: StandardSchemaV1, raw: unknown): Promise<unknown> {
33
+ const result = await validateAsync(schema, raw);
34
+ return result.issues === undefined ? result.value : undefined;
35
+ }