@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 +13 -4
- package/README.md +29 -30
- package/package.json +11 -9
- package/src/adapter.ts +7 -0
- package/src/audit-errors.ts +2 -2
- package/src/audit-gate.ts +10 -9
- package/src/cache.ts +17 -1
- package/src/errors.ts +33 -39
- package/src/exercise.ts +2 -1
- package/src/facade.ts +1 -0
- package/src/http.ts +1 -0
- package/src/index.ts +8 -33
- package/src/loader.ts +5 -0
- package/src/openapi.ts +11 -7
- package/src/page-controls.ts +37 -5
- package/src/policy-gate.ts +20 -45
- package/src/query.ts +12 -0
- package/src/rate-limit-gate.ts +7 -14
- package/src/read.ts +42 -24
- package/src/rows-override.ts +23 -0
- package/src/validate.ts +35 -0
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 `'
|
|
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`, `
|
|
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: `
|
|
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
|
|
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–
|
|
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
|
|
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
|
|
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: `
|
|
677
|
-
`@ultimat3/
|
|
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 | `
|
|
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 `
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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": "
|
|
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
|
-
"./
|
|
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": "
|
|
51
|
-
"@ultimat3/core": "
|
|
52
|
-
"@ultimat3/db": "
|
|
53
|
-
"@ultimat3/entity": "
|
|
54
|
-
"@ultimat3/http": "
|
|
55
|
-
"@ultimat3/policy": "
|
|
56
|
-
"@ultimat3/schema": "
|
|
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';
|
package/src/audit-errors.ts
CHANGED
|
@@ -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: '
|
|
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: '
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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([
|
|
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 {
|
|
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
|
-
|
|
103
|
-
|
|
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: `${
|
|
107
|
-
fix: `x policy explain ${
|
|
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 `
|
|
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: "
|
|
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
|
-
|
|
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
|
|
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
|
-
* (`
|
|
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 {
|
|
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.
|
|
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), ...
|
|
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
|
-
/**
|
|
268
|
-
|
|
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 ${
|
|
274
|
-
schema: { type: 'integer', minimum: 1, maximum:
|
|
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,
|
package/src/page-controls.ts
CHANGED
|
@@ -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
|
|
24
|
-
//
|
|
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(
|
|
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) >
|
|
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 ${
|
|
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) } };
|
package/src/policy-gate.ts
CHANGED
|
@@ -1,33 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The single point of contact with @ultimat3/policy
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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 {
|
|
9
|
-
import
|
|
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
|
|
18
|
-
*
|
|
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 = '
|
|
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
|
|
42
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
ctx: subject.ctx,
|
|
63
|
-
|
|
64
|
-
|
|
45
|
+
guard(
|
|
46
|
+
policy,
|
|
47
|
+
{ stage: 'actor', name: subject.query, actor: subject.actor, ctx: subject.ctx },
|
|
48
|
+
surface,
|
|
49
|
+
);
|
|
65
50
|
}
|
|
66
51
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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') {
|
package/src/rate-limit-gate.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Actor } from '@ultimat3/core';
|
|
8
|
-
import {
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
routeName:
|
|
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 ??
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
|
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);
|
package/src/validate.ts
ADDED
|
@@ -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
|
+
}
|