@ultimat3/query 29.3.2 → 30.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 +32 -4
- package/README.md +103 -1
- package/package.json +18 -7
- package/src/cache.ts +15 -3
- package/src/client.ts +77 -56
- package/src/contract-test.ts +59 -0
- package/src/declared-errors-contract.ts +34 -0
- package/src/declared-errors.ts +26 -0
- package/src/download-errors.ts +65 -0
- package/src/download-http.ts +237 -0
- package/src/download-token.ts +216 -0
- package/src/download.ts +261 -0
- package/src/errors.ts +20 -5
- package/src/exercise.ts +156 -0
- package/src/facade.ts +13 -5
- package/src/http-etag.ts +44 -0
- package/src/http-pin-error.ts +17 -0
- package/src/http-pin.ts +67 -0
- package/src/http.ts +41 -5
- package/src/index.ts +41 -1
- package/src/input-shape.ts +4 -0
- package/src/live-trace.ts +36 -0
- package/src/live.ts +3 -2
- package/src/openapi-errors.ts +19 -0
- package/src/openapi-etag.ts +43 -0
- package/src/openapi.ts +162 -49
- package/src/query.ts +78 -13
- package/src/read-freshness.ts +63 -0
- package/src/read.ts +108 -24
- package/src/record-answer.ts +18 -6
- package/src/registry.ts +4 -0
- package/src/search-string.ts +47 -0
- package/src/sql.ts +2 -1
- package/src/type-pins.ts +45 -0
package/CLAUDE.md
CHANGED
|
@@ -14,6 +14,12 @@ 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
|
+
| `contract-test.ts` | `.contract()`, the generated suite. The three `X_CONTRACT_*` codes and `X_CONTRACT_DRIFT` are borrowed (`QUERY_BORROWED_ERROR_CODES`) |
|
|
18
|
+
| `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
|
+
| `declared-errors.ts` | `judgedSql` / `judgedRead`, around `sql` and the source's `execute` in `read.ts`. `errors:` is checked where it is lifted (`facade.ts`), not in `query.ts` |
|
|
20
|
+
| `declared-errors-contract.ts`, `openapi-errors.ts` | `.contract()`'s source scan; core's builder bound to http's status |
|
|
21
|
+
| `http-pin.ts` | a read's pinned `http.path`, its check at `query()`, `servedQueryPath`, `queryHttpPinOf` |
|
|
22
|
+
| `http-pin-error.ts` | `X_QUERY_HTTP_PATH_INVALID`, a leaf over `@ultimat3/core/page` so the browser client can raise it without `errors.ts`'s titles table |
|
|
17
23
|
| `facade.ts` | the fluent surface — binds each projection to the query, re-implements none |
|
|
18
24
|
| `http.ts` | route projection (`GET /_x/query/<kebab>`, `enforcedBy: 'handler'`) |
|
|
19
25
|
| `client.ts` | typed read client (browser-safe); dispatches through `@ultimat3/core`'s `clientTransport`, never `fetch` |
|
|
@@ -38,6 +44,12 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
38
44
|
| `policy-gate.ts` | **the only** file that touches `@ultimat3/policy` |
|
|
39
45
|
| `audit-gate.ts` | `audit: true` — **the only** file that calls an `AuditSink` (core's): one record per call, an audited source per `execute()` |
|
|
40
46
|
| `audit-errors.ts` | `X_QUERY_AUDIT_SINK_MISSING` / `X_QUERY_AUDIT_SINK_FAILED` (titles in `errors.ts`) |
|
|
47
|
+
| `download.ts` | `query({ download })`: the build, the in-process file, the mint, the serve — through `admittedCall` |
|
|
48
|
+
| `download-token.ts` | the `_token`: claims `seal()`ed under the master key, opened against one query, one input, the clock |
|
|
49
|
+
| `download-http.ts` | the download route: mint JSON without `_token`, the file with it (`content-disposition`, and `Range:` by http's `objectResponse`; only `bodyOf` slices a body) |
|
|
50
|
+
| `download-errors.ts` | the four `X_QUERY_DOWNLOAD_*` classes (titles in `errors.ts`) |
|
|
51
|
+
| `search-string.ts` | `searchOf`: the input as a search string — the client's request and a minted URL, one encoding |
|
|
52
|
+
| `openapi.ts` | the read half of `openapi.json`; a declared `rows:` is published inline through `toWireOutputSchema`, today's bytes otherwise |
|
|
41
53
|
|
|
42
54
|
## Invariants — the read path
|
|
43
55
|
|
|
@@ -65,7 +77,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
65
77
|
- **A read has ONE tool name, the export name verbatim.** `toToolName` is deleted.
|
|
66
78
|
`index.test.ts` asserts the barrel exports nothing matching `/tool_?name/i`.
|
|
67
79
|
- **`queryClient` is the map-wide read client (mirror of `rpc`)**; both spellings run
|
|
68
|
-
`queryClientMethodFor`. **`toQueryRoute` and `client()` derive the same URL from core's `queryPath
|
|
80
|
+
`queryClientMethodFor`. **`toQueryRoute` and `client()` derive the same URL from core's `queryPath`, or from the read's pin, which both read from the declaration.**
|
|
69
81
|
- **`QueryClient` typechecks at any app size, and a `.test.ts` cannot say so** (`tsconfig.json`
|
|
70
82
|
excludes them): `client-scale-pins.ts` is source, so a regression is a `typecheck` error. The map
|
|
71
83
|
it pins is the intersection `@ultimat3/action`'s `Merge` hands over; that package pins `Merge`.
|
|
@@ -112,6 +124,22 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
112
124
|
- **A live read keyed on a sealed column is refused at subscribe** (`assertNoSealedKey`, called by
|
|
113
125
|
`toLiveQuery`): a change row carries none, so the window would be read once and never patched.
|
|
114
126
|
The refusal names the column, never the filter's value.
|
|
127
|
+
- **The document's row and the MCP tool's are one narrowing** (`toWireOutputSchema`, `As of 2026-11`). Inline, never a component: the CLI merges only `paths`. Structure only: rows are never re-parsed, so a bound or a closed object is a promise nothing enforces. `openapi.test.ts`, `sealed-egress.test.ts`.
|
|
128
|
+
|
|
129
|
+
## Invariants — download
|
|
130
|
+
|
|
131
|
+
- **A download is a `query`, never a ninth primitive** (`docs/history/primitive-factories.md`):
|
|
132
|
+
`download:` replaces `sql:` and every rows option is refused beside it (`X_QUERY_DOWNLOAD_INVALID`).
|
|
133
|
+
- **Its stored `sql` throws `X_QUERY_DOWNLOAD_ONLY`** — the ONE refusal every rows projection meets.
|
|
134
|
+
Never add a per-projection `download` check.
|
|
135
|
+
- **Mint and in-process call go through `admittedCall`** (`read.ts`, the front half `buildSource`
|
|
136
|
+
shares). The serve re-parses through it as `unenforced` with a written reason: the token IS the
|
|
137
|
+
policy's decision. Never re-run the policy at serve — the token carries no `facts`.
|
|
138
|
+
- **The token is SEALED, never signed**: it carries the actor. Seal before expiry before input —
|
|
139
|
+
an expired forgery is a forgery. `X_SEAL_KEY_MISSING` stays a 500 (the server's fault).
|
|
140
|
+
- **`audit: true` records the mint, `rateLimit:` spends at the mint** — a player's `Range:` GETs
|
|
141
|
+
are one admitted read.
|
|
142
|
+
- `download-http.ts`: a function `body` is handed the window by `objectResponse`, with `size` required by type and by `assertFile`.
|
|
115
143
|
|
|
116
144
|
## Invariants — audit
|
|
117
145
|
|
|
@@ -143,9 +171,9 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
143
171
|
- **`@ultimat3/query/client` is the read client without the barrel** — `client.ts`
|
|
144
172
|
imports core from `@ultimat3/core/page`, the page keys live in the leaf `page-keys.ts`, and
|
|
145
173
|
`client.ts` never imports `page-controls.ts` or `stable.ts`. `client-bundle.test.ts` fails on any
|
|
146
|
-
titles table in the graph or past
|
|
147
|
-
(browser, minified): barrel `queryClient` 25,237 B (`As of 2026-10-02`); `./client`
|
|
148
|
-
(`As of 2026-
|
|
174
|
+
titles table in the graph or past 14.5 kB; `client-subpath.test.ts` pins the specifier. Byte figures
|
|
175
|
+
(browser, minified): barrel `queryClient` 25,237 B (`As of 2026-10-02`); `./client` 14,494 B
|
|
176
|
+
(`As of 2026-11-05`: +1,013 B a pinned read's URL fill, the rest wave 5a's core `./page` additions).
|
|
149
177
|
- **The record envelope is derived from `rows:`** — `answersRecords(rows)` (`hasEntityRows`,
|
|
150
178
|
`@ultimat3/entity`), decided once at projection for `http.ts` and `openapi.ts`, sent on every
|
|
151
179
|
answer. No `rows:` is byte-identical on the wire.
|
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
A read. Optionally live. Never a mutation — writes are `action`.
|
|
4
4
|
|
|
5
|
+
`bun add @ultimat3/query` · guide: [Queries And Live Queries](https://github.com/developerz-ai/ultimate/wiki/Queries-And-Live-Queries)
|
|
6
|
+
|
|
5
7
|
```ts
|
|
6
8
|
import { query, t } from '@ultimat3/query';
|
|
7
9
|
|
|
@@ -32,6 +34,7 @@ Every projection is a method on the query itself. A query has no `.def`.
|
|
|
32
34
|
| `toolFrom(liveFeed)` | the MCP read tool, from `@ultimat3/mcp` — named `liveFeed`, exactly what `tools/list` serves, reading fresh through `sourceFor` with the same policy object. There is no `liveFeed.tool()` (`As of 25.0.0`): it was a second projection a tier below the served one |
|
|
33
35
|
| `liveFeed.client({ baseUrl })` | `GET /_x/query/live-feed?orgId=…`, typed both ways. `.page(input, { first, after })` on the same method reads one page over the wire |
|
|
34
36
|
| `liveFeed.describe()` | the manifest row |
|
|
37
|
+
| `liveFeed.contract({ exercise: { actor } })` | the generated contract suite: the read survives 16 generated legal inputs (`exerciseQuery`), seeded by its name. `exercise` is required unless the policy admits an anonymous reader; `{ off: '<reason>' }` skips it out loud |
|
|
35
38
|
|
|
36
39
|
The route on the other end of that client is `toQueryRoute(liveFeed)`, and — `As of 2026-08` —
|
|
37
40
|
`x dev` and a container both mount it for every registered read, the framework's job, not the
|
|
@@ -53,6 +56,13 @@ const [post] = await queries.publicPost({ slug }); // typed input, typed rows
|
|
|
53
56
|
|
|
54
57
|
Both spellings run `queryClientMethodFor`, so a read has one URL however it is addressed.
|
|
55
58
|
|
|
59
|
+
A read may pin its URL: `query({ …, http: { path: '/orders/:id' } })` is served at `GET /orders/:id`
|
|
60
|
+
in place of `/_x/query/<kebab>`. Each `:param` is a required `t.string` key of `input:`
|
|
61
|
+
(`X_QUERY_HTTP_PATH_INVALID` at `query()`); the URL's value joins the search string before the
|
|
62
|
+
input is parsed, and a search string naming it differently is 400 `X_HTTP_PATH_INPUT_CONFLICT`.
|
|
63
|
+
`read.client()` fills the URL; `queryClient` by name cannot see a pin. `queryHttpPinOf(read)` is
|
|
64
|
+
the manifest's `{ path, method: 'GET' }`.
|
|
65
|
+
|
|
56
66
|
`queryClient<Api['queries']>` typechecks at any app size: `client-scale-pins.ts` compiles it over
|
|
57
67
|
100 reads in 50 modules — a list read, its `.page` and a `single: true` read — on every `typecheck`.
|
|
58
68
|
|
|
@@ -122,6 +132,13 @@ or with a schema no `entity()` branded (a `t.object` look-alike, a `.pick()`ed p
|
|
|
122
132
|
is the bare rows, byte-identical to before. `rows:` exists because a read's `sql:` names its table
|
|
123
133
|
as a STRING, so nothing else carries a schema the envelope could be derived from.
|
|
124
134
|
|
|
135
|
+
**Any introspectable `rows:` is also the published row** (`As of 2026-11`), entity or not:
|
|
136
|
+
`openapi.json` and a mount's document print its members inline in the list, the page envelope and
|
|
137
|
+
the single answer, and `x.manifest.json` carries the same wire schema, so `x verify` refuses a
|
|
138
|
+
member removed, retyped or made optional without a major. One narrowing, `toWireOutputSchema`, the
|
|
139
|
+
one the MCP tool's `outputSchema` uses. No `rows:`, or one that cannot be introspected: the bytes
|
|
140
|
+
it had (`items: {}`).
|
|
141
|
+
|
|
125
142
|
### Flight control — `clientFlight`, opt-in
|
|
126
143
|
|
|
127
144
|
A typed read is one dispatch and nothing else until a `flight` is installed. With one, N concurrent
|
|
@@ -205,7 +222,10 @@ forgetting the policy, and the reason is what tells the next reader which of the
|
|
|
205
222
|
| `client.ts` | the typed read client (browser-safe) |
|
|
206
223
|
| `http.ts` | the route projection — `GET /_x/query/<kebab>`, the URL the client derives |
|
|
207
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` |
|
|
208
|
-
| `openapi.ts` | the read half of `openapi.json` — one `GET` path item per registered query |
|
|
225
|
+
| `openapi.ts` | the read half of `openapi.json` — one `GET` path item per registered query, publishing a declared `rows:` |
|
|
226
|
+
| `openapi-errors.ts` | a read operation's `responses` with its declared `errors:` in them |
|
|
227
|
+
| `declared-errors.ts` / `declared-errors-contract.ts` | `errors:` judged around `sql` and `execute`; `.contract()`'s scan of `sql`'s source |
|
|
228
|
+
| `contract-test.ts` / `exercise.ts` | `.contract()`: the exercise assertion, a twin of `@ultimat3/action`'s; its three `X_CONTRACT_*` codes are borrowed |
|
|
209
229
|
| `live.ts` | the `LiveQuery` descriptor `@ultimat3/realtime` subscribes to |
|
|
210
230
|
| `matcher.ts` | change event → minimal patch (`add` / `update` / `remove` / `refill`) |
|
|
211
231
|
| `pagination.ts` | `paginate()` — keyset pages over core's cursor codec |
|
|
@@ -216,6 +236,11 @@ forgetting the policy, and the reason is what tells the next reader which of the
|
|
|
216
236
|
| `source.ts` | the `SqlSource` contract + `from()`, the in-memory reference |
|
|
217
237
|
| `column-kinds.ts` | `kindsOf(relation)` — the declared kind of each column, which every comparison is decided by |
|
|
218
238
|
| `sealed-shape.ts` | a live read may not filter or order on a `.sealed()` column — refused at subscribe |
|
|
239
|
+
| `download.ts` | `query({ download })` — the declaration, the in-process file, the mint and the serve |
|
|
240
|
+
| `download-token.ts` | the `_token`: claims sealed with core's `seal()`, opened against one query, one input, the clock |
|
|
241
|
+
| `download-http.ts` | the route's two answers — mint JSON or the file: `content-disposition`, one `Range:` window, `no-store` |
|
|
242
|
+
| `download-errors.ts` | `X_QUERY_DOWNLOAD_INVALID` / `_ONLY` / `_TOKEN_INVALID` / `_TOKEN_EXPIRED` |
|
|
243
|
+
| `search-string.ts` | the input as a search string — what the client sends and a minted URL carries |
|
|
219
244
|
|
|
220
245
|
## Live queries
|
|
221
246
|
|
|
@@ -551,6 +576,13 @@ tier registered with a frozen clock is drivable end to end — and omitting it i
|
|
|
551
576
|
for its own default. `@ultimat3/cache`'s tiers refuse a non-positive `ttlMs` and have no immortal
|
|
552
577
|
entry to offer, so there is no "never" to spell.
|
|
553
578
|
|
|
579
|
+
`cache: { negativeTtlMs }` is a `null`'s own lease: a `single: true` read that matched nothing is
|
|
580
|
+
stored as `null` and the stack keeps it `negativeTtlMs`. An empty list is a list, and keeps `ttlMs`.
|
|
581
|
+
|
|
582
|
+
`etag: true` answers `private, no-cache` with a strong `ETag` and a bodiless 304 for a held one —
|
|
583
|
+
`@ultimat3/http`'s `notModified`, after the policy and the rate limit (`http-etag.ts`).
|
|
584
|
+
`timeoutMs` runs the source inside `@ultimat3/db`'s `withStatementTimeout`: always the primary.
|
|
585
|
+
|
|
554
586
|
The memo entry is the read **in flight**, not its value, so "twice" covers reads that race as
|
|
555
587
|
well as reads that follow: five holes rendering concurrently share one execution and one tier
|
|
556
588
|
round trip. A rejection is evicted — a failed read is not the request's answer.
|
|
@@ -598,6 +630,14 @@ The conversion is `toBucket` from `@ultimat3/http` — the same one the action r
|
|
|
598
630
|
http owns `Bucket` and `action` is the same tier as this package. A pair the limiter cannot run
|
|
599
631
|
on is `X_RATE_LIMIT_INVALID`, at projection.
|
|
600
632
|
|
|
633
|
+
## `errors:` — the refusals a reader may branch on, closed
|
|
634
|
+
|
|
635
|
+
An action's rule, read side: `query({ errors: [FeedGone.code], … })`, judged around `sql` and the
|
|
636
|
+
source's `execute` (`declared-errors.ts`), scanned in `sql`'s own source by `.contract()`
|
|
637
|
+
(`declared-errors-contract.ts`), named per status in the OpenAPI operation (`openapi-errors.ts`)
|
|
638
|
+
and on the MCP tool's description. Checked where it is lifted (`facade.ts`): sorted, frozen, or
|
|
639
|
+
`X_ACTION_ERRORS_INVALID`. `describe().errors` is the list, `null` when absent.
|
|
640
|
+
|
|
601
641
|
## `deprecated:` — a compat window, not a version
|
|
602
642
|
|
|
603
643
|
```ts
|
|
@@ -665,12 +705,70 @@ before the parse; a sink refusing a **denied/failed** record is logged (`audit.s
|
|
|
665
705
|
original error still reaches the caller; a sink refusing an **allowed** record withholds the rows,
|
|
666
706
|
`X_QUERY_AUDIT_SINK_FAILED` — a read commits nothing, so a retry is safe.
|
|
667
707
|
|
|
708
|
+
## `download:` — a file at a short-lived signed URL
|
|
709
|
+
|
|
710
|
+
A read whose answer is BYTES — a recording, an export, an attachment — is still a `query`: one
|
|
711
|
+
input, one policy, one name, one route. `download:` replaces `sql:`; every rows option (`sql`,
|
|
712
|
+
`live`, `single`, `rows`, `subscribes`, `cache`, `mcp`) beside it is a type error and
|
|
713
|
+
`X_QUERY_DOWNLOAD_INVALID`.
|
|
714
|
+
|
|
715
|
+
```ts
|
|
716
|
+
import { open } from '@ultimat3/core';
|
|
717
|
+
import { can } from '@ultimat3/policy';
|
|
718
|
+
import { query, t } from '@ultimat3/query';
|
|
719
|
+
import { disk } from '@ultimat3/storage';
|
|
720
|
+
|
|
721
|
+
export const runRecording = query({
|
|
722
|
+
input: t.object({ runId: t.uuid, index: t.number.int().min(0) }),
|
|
723
|
+
policy: can('recording:watch'),
|
|
724
|
+
download: {
|
|
725
|
+
expiresInMs: 600_000, // 1 000 … 3 600 000; default 300 000
|
|
726
|
+
async handle({ input }) {
|
|
727
|
+
const key = `runs/${input.runId}/recordings/${input.index}`;
|
|
728
|
+
const { bytes } = await disk('debug').get(key);
|
|
729
|
+
// A SEALED object opens whole (one AES-GCM envelope), then is served as plain bytes.
|
|
730
|
+
const plain = await open(new TextDecoder().decode(bytes), { purpose: `run-debug:${key}` });
|
|
731
|
+
return { body: plain, contentType: 'video/mp4', filename: `run-${input.runId}.mp4` };
|
|
732
|
+
},
|
|
733
|
+
},
|
|
734
|
+
});
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
| Call | Answers |
|
|
738
|
+
|---|---|
|
|
739
|
+
| `client.runRecording.url(input)` (browser, `queryClient`/`.client()`) | `{ url, expiresAt }` — `url` absolute on the client's `baseUrl` |
|
|
740
|
+
| `await runRecording.url(input, { ctx })` (server) | the same, `url` a path: `/_x/query/run-recording?runId=…&index=0&_token=…` |
|
|
741
|
+
| `GET <url>` | the file — to ANY requester holding the URL until `expiresAt`; no session needed |
|
|
742
|
+
| `await runRecording(input, { ctx })` | the `DownloadFile` in-process, as the caller |
|
|
743
|
+
|
|
744
|
+
| Rule | |
|
|
745
|
+
|---|---|
|
|
746
|
+
| 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
|
+
| 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
|
+
| 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 |
|
|
749
|
+
| 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
|
+
| `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
|
+
| 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` |
|
|
752
|
+
| `Range:` | one `bytes=` window, 206 + `content-range`; past the end 416. A `Uint8Array`/`Blob` always (a `Blob` slices lazily); a `ReadableStream` only with `size`, else `accept-ranges: none` and the whole body; the function form is handed the window, so `(range) => disk.streamRange(key, [range.start, range.end])` reads nothing before it, and a 304 or a 416 opens nothing |
|
|
753
|
+
| Rows | `.live()`, `.page()`, `.as()`, `sourceFor`, `explain`, an MCP tool: `X_QUERY_DOWNLOAD_ONLY`. `describeSql` lists it with `sql: null` |
|
|
754
|
+
| Audit | `audit: true` records the MINT — the decision of who may read what. A serve, and every `Range:` a player sends, is not a second read |
|
|
755
|
+
| Manifest / OpenAPI | `download: true` on the `QueryFact` (rows ↔ download is breaking); one GET with `_token`, `200` as JSON or `application/octet-stream`, `206`, `410`, `416` |
|
|
756
|
+
|
|
757
|
+
**A sealed object is buffered.** WebCrypto's AES-GCM opens a whole envelope or nothing, so a
|
|
758
|
+
sealed recording is read with `get()` (bounded by the disk's `maxGetBytes`) and opened before a
|
|
759
|
+
byte is served; ranges are then sliced from the plaintext. A PLAIN object can be a `Bun.file`
|
|
760
|
+
or a stream with `size`, served without buffering.
|
|
761
|
+
|
|
668
762
|
## Errors
|
|
669
763
|
|
|
670
764
|
| Code | When | Fix |
|
|
671
765
|
|---|---|---|
|
|
672
766
|
| `X_QUERY_AUDIT_SINK_MISSING` | `audit: true` and no audit sink installed — refused before the parse | `setAuditSink(sink)` from `@ultimat3/core` at boot |
|
|
673
767
|
| `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
|
+
| `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
|
+
| `X_QUERY_DOWNLOAD_ONLY` | rows asked of a download (`.live()`, `.page()`, `.as()`, `sourceFor`) | `await q.url(input)`, or `await q(input)` for the file |
|
|
770
|
+
| `X_QUERY_DOWNLOAD_TOKEN_INVALID` | a `_token` forged, truncated, another query's, under a retired key, or bound to other input — 403 | mint again: `client.<name>.url(input)` |
|
|
771
|
+
| `X_QUERY_DOWNLOAD_TOKEN_EXPIRED` | a genuine `_token` past `expiresAt` — 410 | mint again right before the request |
|
|
674
772
|
| `X_QUERY_DUPLICATE` | two queries under one name | rename one export |
|
|
675
773
|
| `X_QUERY_DEPRECATION_INVALID` | `deprecated:` with a `since`/`sunset` that is not a date | use an ISO-8601 instant |
|
|
676
774
|
| `X_QUERY_POLICY_MISSING` | registration without `policy:` | add `policy: can('…')` |
|
|
@@ -700,6 +798,10 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
700
798
|
| `QueryAuditSinkMissingError` | `X_QUERY_AUDIT_SINK_MISSING` | `src/audit-errors.ts` |
|
|
701
799
|
| `QueryDeniedError` | the policy denial's own code (`X_FORBIDDEN`, `X_UNAUTHENTICATED`, …), kept on `.denial` | `src/errors.ts` |
|
|
702
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` |
|
|
703
805
|
| `QueryDuplicateError` | `X_QUERY_DUPLICATE` | `src/errors.ts` |
|
|
704
806
|
| `QueryForeignError` | `X_QUERY_FOREIGN` | `src/errors.ts` |
|
|
705
807
|
| `QueryInputInvalidError` | `X_INPUT_INVALID` | `src/errors.ts` |
|
package/package.json
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/query",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "30.0.0",
|
|
4
4
|
"description": "The query primitive: a policy-checked read, optionally live, with cursor pagination and an incremental matcher",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ultimate",
|
|
7
|
+
"bun",
|
|
8
|
+
"framework",
|
|
9
|
+
"query",
|
|
10
|
+
"live-query",
|
|
11
|
+
"pagination",
|
|
12
|
+
"realtime"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/developerz-ai/ultimate/wiki/Queries-And-Live-Queries",
|
|
5
15
|
"license": "MIT",
|
|
6
16
|
"type": "module",
|
|
7
17
|
"sideEffects": [
|
|
@@ -37,11 +47,12 @@
|
|
|
37
47
|
"test": "bun test"
|
|
38
48
|
},
|
|
39
49
|
"dependencies": {
|
|
40
|
-
"@ultimat3/cache": "
|
|
41
|
-
"@ultimat3/core": "
|
|
42
|
-
"@ultimat3/
|
|
43
|
-
"@ultimat3/
|
|
44
|
-
"@ultimat3/
|
|
45
|
-
"@ultimat3/
|
|
50
|
+
"@ultimat3/cache": "30.0.0",
|
|
51
|
+
"@ultimat3/core": "30.0.0",
|
|
52
|
+
"@ultimat3/db": "30.0.0",
|
|
53
|
+
"@ultimat3/entity": "30.0.0",
|
|
54
|
+
"@ultimat3/http": "30.0.0",
|
|
55
|
+
"@ultimat3/policy": "30.0.0",
|
|
56
|
+
"@ultimat3/schema": "30.0.0"
|
|
46
57
|
}
|
|
47
58
|
}
|
package/src/cache.ts
CHANGED
|
@@ -199,8 +199,15 @@ export function readThrough<T>(
|
|
|
199
199
|
ttlMs: number | null,
|
|
200
200
|
run: () => Promise<T>,
|
|
201
201
|
tags: readonly CacheTag[] = [],
|
|
202
|
+
negativeTtlMs: number | null = null,
|
|
202
203
|
): Promise<T> {
|
|
203
|
-
return readOnce(ctx, key, () => fill(ctx.clock, key, ttlMs, tags, run));
|
|
204
|
+
return readOnce(ctx, key, () => fill(ctx.clock, key, { ttlMs, negativeTtlMs }, tags, run));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The two leases a filled entry may take: a value's, and a `null`'s when it differs. */
|
|
208
|
+
interface Leases {
|
|
209
|
+
readonly ttlMs: number | null;
|
|
210
|
+
readonly negativeTtlMs: number | null;
|
|
204
211
|
}
|
|
205
212
|
|
|
206
213
|
/**
|
|
@@ -216,11 +223,16 @@ export function readThrough<T>(
|
|
|
216
223
|
function fill<T>(
|
|
217
224
|
clock: Clock,
|
|
218
225
|
key: string,
|
|
219
|
-
ttlMs
|
|
226
|
+
{ ttlMs, negativeTtlMs }: Leases,
|
|
220
227
|
tags: readonly CacheTag[],
|
|
221
228
|
run: () => Promise<T>,
|
|
222
229
|
): Promise<T> {
|
|
223
230
|
// `null` is "the caller named none", never "never": every tier refuses a non-positive `ttlMs`
|
|
224
231
|
// and none has an immortal entry to offer, so omitting it falls to the tier's own default.
|
|
225
|
-
|
|
232
|
+
// `negativeTtlMs` is the stack's to apply (`ttlOptionsFor`): it alone knows the load was a null.
|
|
233
|
+
return stackFor(clock).read(key, run, {
|
|
234
|
+
...(ttlMs === null ? {} : { ttlMs }),
|
|
235
|
+
...(negativeTtlMs === null ? {} : { negativeTtlMs }),
|
|
236
|
+
tags,
|
|
237
|
+
});
|
|
226
238
|
}
|
package/src/client.ts
CHANGED
|
@@ -18,12 +18,14 @@
|
|
|
18
18
|
|
|
19
19
|
import type { ClientFlight, ClientRetry } from '@ultimat3/core';
|
|
20
20
|
import type { FetchLike, RecordEnvelope } from '@ultimat3/core/page';
|
|
21
|
-
import { clientTransport,
|
|
21
|
+
import { clientTransport, fillWire, queryPath, renderedQueryPin } from '@ultimat3/core/page';
|
|
22
22
|
import type { InferInput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
23
|
+
import type { DownloadClientMethod, DownloadQuery } from './download';
|
|
24
|
+
import { QueryHttpPathInvalidError } from './http-pin-error';
|
|
23
25
|
import type { PageControls } from './page-controls';
|
|
24
|
-
import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-keys';
|
|
25
26
|
import type { Page } from './pagination';
|
|
26
27
|
import type { Query } from './query';
|
|
28
|
+
import { searchOf } from './search-string';
|
|
27
29
|
|
|
28
30
|
/** Core's, re-exported under the name this package always exported it by — one declaration. */
|
|
29
31
|
export type { FetchLike } from '@ultimat3/core/page';
|
|
@@ -78,6 +80,15 @@ export interface QueryClientMethod<TInput extends StandardSchemaV1, TRow extends
|
|
|
78
80
|
): Promise<Page<TRow>>;
|
|
79
81
|
}
|
|
80
82
|
|
|
83
|
+
/**
|
|
84
|
+
* A download query's signed URL, as `url()` answers it. `url` is a PATH on the server and absolute
|
|
85
|
+
* from a client with a `baseUrl`; `expiresAt` is an ISO instant — mint again past it.
|
|
86
|
+
*/
|
|
87
|
+
export interface DownloadUrl {
|
|
88
|
+
readonly url: string;
|
|
89
|
+
readonly expiresAt: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
81
92
|
/**
|
|
82
93
|
* A `single: true` read's method: the route answers the row itself, or 404 `X_NOT_FOUND` — which
|
|
83
94
|
* the transport rejects with, as it does every problem document. No `page`: the route refuses the
|
|
@@ -112,13 +123,11 @@ export type QueryMap = Record<string, QueryLike>;
|
|
|
112
123
|
|
|
113
124
|
/** `queries.publicPost({ slug })`, with the input schema and the row type both inferred. */
|
|
114
125
|
export type QueryClient<TQueries extends QueryMap> = {
|
|
115
|
-
readonly [K in keyof TQueries]: TQueries[K] extends
|
|
116
|
-
|
|
117
|
-
infer TRow,
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
? QueryClientMethodOf<TInput, TRow, TSingle>
|
|
121
|
-
: never;
|
|
126
|
+
readonly [K in keyof TQueries]: TQueries[K] extends DownloadQuery<infer TInput>
|
|
127
|
+
? DownloadClientMethod<TInput>
|
|
128
|
+
: TQueries[K] extends Query<infer TInput, infer TRow, infer TSingle extends boolean>
|
|
129
|
+
? QueryClientMethodOf<TInput, TRow, TSingle>
|
|
130
|
+
: never;
|
|
122
131
|
};
|
|
123
132
|
|
|
124
133
|
/**
|
|
@@ -152,21 +161,63 @@ export function queryClient<TQueries extends QueryMap>(
|
|
|
152
161
|
return proxy as QueryClient<TQueries>;
|
|
153
162
|
}
|
|
154
163
|
|
|
155
|
-
/**
|
|
164
|
+
/**
|
|
165
|
+
* One query's method — what `query.client()` returns, and what `queryClient` proxies to. It
|
|
166
|
+
* carries `url` too, the download half; the declaration's TYPE decides which half a caller sees.
|
|
167
|
+
*/
|
|
156
168
|
export function queryClientMethodFor<TInput extends StandardSchemaV1, TRow extends object>(
|
|
157
169
|
name: string,
|
|
158
170
|
options: QueryClientOptions,
|
|
171
|
+
pinnedPath?: string,
|
|
159
172
|
): QueryClientMethod<TInput, TRow> {
|
|
173
|
+
return methodsOf<TInput, TRow>(name, options, pinnedPath);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A download query's client half: `url(input)` asks the server to mint. A GET with no `_token` IS
|
|
178
|
+
* the mint, so the request is the plain read's and only the answer — `{ url, expiresAt }` — differs.
|
|
179
|
+
*/
|
|
180
|
+
export function downloadClientFor<TInput extends StandardSchemaV1>(
|
|
181
|
+
name: string,
|
|
182
|
+
options: QueryClientOptions,
|
|
183
|
+
): DownloadClientMethod<TInput> {
|
|
184
|
+
return { url: methodsOf<TInput, object>(name, options).url };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The rows call, `page` and `url` over one base — `url` typed only on a download's method. */
|
|
188
|
+
function methodsOf<TInput extends StandardSchemaV1, TRow extends object>(
|
|
189
|
+
name: string,
|
|
190
|
+
options: QueryClientOptions,
|
|
191
|
+
pinnedPath?: string,
|
|
192
|
+
) {
|
|
160
193
|
const base = options.baseUrl.replace(/\/+$/, '');
|
|
194
|
+
// A browser caller naming the read only by name — `queryClient`, `useQuery`, a channel's
|
|
195
|
+
// catch-up — goes where the server's document says it pinned it, never to a derived 404.
|
|
196
|
+
const path = pinnedPath ?? renderedQueryPin(name) ?? queryPath(name);
|
|
161
197
|
// Erased at the wire seam; the row type is this query's by construction.
|
|
162
198
|
const rows = (input: InferInput<TInput>, callOptions: QueryCallOptions = {}) =>
|
|
163
|
-
read(base, options, name, input, callOptions) as Promise<readonly TRow[]>;
|
|
199
|
+
read(base, options, name, input, callOptions, undefined, path) as Promise<readonly TRow[]>;
|
|
164
200
|
const page = (
|
|
165
201
|
input: InferInput<TInput>,
|
|
166
202
|
args: PageControls,
|
|
167
203
|
callOptions: QueryCallOptions = {},
|
|
168
|
-
) => read(base, options, name, input, callOptions, args) as Promise<Page<TRow>>;
|
|
169
|
-
|
|
204
|
+
) => read(base, options, name, input, callOptions, args, path) as Promise<Page<TRow>>;
|
|
205
|
+
const url = async (
|
|
206
|
+
input: InferInput<TInput>,
|
|
207
|
+
callOptions: QueryCallOptions = {},
|
|
208
|
+
): Promise<DownloadUrl> => {
|
|
209
|
+
const minted = (await read(
|
|
210
|
+
base,
|
|
211
|
+
options,
|
|
212
|
+
name,
|
|
213
|
+
input,
|
|
214
|
+
callOptions,
|
|
215
|
+
undefined,
|
|
216
|
+
path,
|
|
217
|
+
)) as DownloadUrl;
|
|
218
|
+
return { ...minted, url: base + minted.url };
|
|
219
|
+
};
|
|
220
|
+
return Object.assign(rows, { page, url });
|
|
170
221
|
}
|
|
171
222
|
|
|
172
223
|
/**
|
|
@@ -181,12 +232,22 @@ function read(
|
|
|
181
232
|
name: string,
|
|
182
233
|
input: unknown,
|
|
183
234
|
callOptions: QueryCallOptions,
|
|
184
|
-
page
|
|
235
|
+
page: PageControls | undefined,
|
|
236
|
+
path: string,
|
|
185
237
|
): Promise<unknown> {
|
|
186
|
-
|
|
238
|
+
// The URL carries a `:param` pin's values, each one encoded segment the router decodes back; the
|
|
239
|
+
// search string carries the rest. A missing value is refused before any request exists.
|
|
240
|
+
const filled = fillWire(path, input);
|
|
241
|
+
if (!filled.ok) {
|
|
242
|
+
return Promise.reject(
|
|
243
|
+
new QueryHttpPathInvalidError({ name, path, reason: `:${filled.missing} ${filled.reason}` }),
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
const at = filled.url;
|
|
247
|
+
const search = searchOf(filled.rest, page);
|
|
187
248
|
return clientTransport({
|
|
188
249
|
method: 'GET',
|
|
189
|
-
url: `${base}${
|
|
250
|
+
url: `${base}${at}${search === '' ? '' : `?${search}`}`,
|
|
190
251
|
// The trace and budget are the transport's, from its server-side outbound slot, placed
|
|
191
252
|
// before these so an explicit `traceparent` still wins; a browser bundles none of it.
|
|
192
253
|
...(options.headers === undefined ? {} : { headers: options.headers }),
|
|
@@ -199,43 +260,3 @@ function read(
|
|
|
199
260
|
...(options.fetch === undefined ? {} : { fetchImpl: options.fetch }),
|
|
200
261
|
});
|
|
201
262
|
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Input as a query string. Keys are sorted so the same input always produces the
|
|
205
|
-
* same URL — a GET is a cache key, and an unstable one caches nothing.
|
|
206
|
-
*
|
|
207
|
-
* The page controls come LAST, after the sorted input and in a fixed order, so a paged URL is the
|
|
208
|
-
* plain URL with a suffix — the same dedup key for the same page, and a log line a reader can
|
|
209
|
-
* split at `_first=` to recover the read underneath.
|
|
210
|
-
*
|
|
211
|
-
* An EMPTY array sends nothing, and the route reads a required array nobody sent as `[]`
|
|
212
|
-
* (`absentArraysOf`, `input-shape.ts`) — the two halves of one rule, so `{ tags: [] }` arrives.
|
|
213
|
-
*/
|
|
214
|
-
function searchOf(input: unknown, page?: PageControls): string {
|
|
215
|
-
const params = new URLSearchParams();
|
|
216
|
-
if (isJsonObject(input)) {
|
|
217
|
-
for (const key of Object.keys(input).sort()) {
|
|
218
|
-
const value = input[key];
|
|
219
|
-
if (value === undefined || value === null) continue;
|
|
220
|
-
for (const item of Array.isArray(value) ? (value as readonly unknown[]) : [value]) {
|
|
221
|
-
params.append(key, wireText(item));
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
if (page !== undefined) {
|
|
226
|
-
params.append(PAGE_FIRST_KEY, String(page.first));
|
|
227
|
-
if (page.after !== undefined) params.append(PAGE_AFTER_KEY, page.after);
|
|
228
|
-
}
|
|
229
|
-
return params.toString();
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* One value as the characters `coerceQuery` reads back. A `Date` is its ISO instant: through
|
|
234
|
-
* `JSON.stringify` it arrived wrapped in quotes, which `t.date` refuses on the far side. An
|
|
235
|
-
* Invalid Date has no instant, and `toISOString()` throws a bare `RangeError` on it, so it is sent
|
|
236
|
-
* as the text it prints and the read's own schema says what is wrong with it.
|
|
237
|
-
*/
|
|
238
|
-
function wireText(item: unknown): string {
|
|
239
|
-
if (item instanceof Date) return Number.isNaN(item.getTime()) ? String(item) : item.toISOString();
|
|
240
|
-
return typeof item === 'object' ? JSON.stringify(item) : String(item);
|
|
241
|
-
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A read's generated contract suite, `orgFeed.contract()`: what the `contract` step runs for a
|
|
3
|
+
* query. Today one assertion — the read survives generated legal input — beside which other
|
|
4
|
+
* claims a read owes (its declared refusals) are appended here, never in a second suite.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Ctx } from '@ultimat3/core';
|
|
8
|
+
import { admitsAnonymous } from '@ultimat3/policy';
|
|
9
|
+
import { declaredErrorsContract } from './declared-errors-contract';
|
|
10
|
+
import type { QueryExerciseOptions } from './exercise';
|
|
11
|
+
import { exerciseQuery, queryExerciseTestName, queryExerciseUndeclared } from './exercise';
|
|
12
|
+
import type { AnyQuery } from './query';
|
|
13
|
+
import { queryName } from './read';
|
|
14
|
+
|
|
15
|
+
/** One generated assertion: a name to register under `test()`, and its body. */
|
|
16
|
+
export interface QueryContractTest {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
run(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface QueryContractTestOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Who the generated runs read as — required unless the policy admits an anonymous reader — or
|
|
24
|
+
* `{ off: '<reason>' }`, which skips the runs out loud: the reason is in the test's name.
|
|
25
|
+
*/
|
|
26
|
+
readonly exercise?: QueryExerciseOptions;
|
|
27
|
+
/** The context each run starts from; the actor is swapped in, the rest kept. */
|
|
28
|
+
readonly ctx?: Ctx;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function queryContractTestsFor(
|
|
32
|
+
target: AnyQuery,
|
|
33
|
+
options: QueryContractTestOptions = {},
|
|
34
|
+
): readonly QueryContractTest[] {
|
|
35
|
+
return [exerciseContract(target, options), ...declaredErrorsContract(target)];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function exerciseContract(target: AnyQuery, options: QueryContractTestOptions): QueryContractTest {
|
|
39
|
+
const name = queryName(target);
|
|
40
|
+
const exercise = options.exercise;
|
|
41
|
+
if (exercise !== undefined && 'off' in exercise) {
|
|
42
|
+
const reason = exercise.off.trim();
|
|
43
|
+
if (reason.length > 0) {
|
|
44
|
+
return { name: `${name}: read exercise off: ${reason}`, run: () => Promise.resolve() };
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
name: queryExerciseTestName(name),
|
|
48
|
+
run: () => Promise.reject(queryExerciseUndeclared(name, true)),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const chosen = exercise ?? (admitsAnonymous(target.policy) ? { actor: null } : undefined);
|
|
52
|
+
return {
|
|
53
|
+
name: queryExerciseTestName(name),
|
|
54
|
+
run: async () => {
|
|
55
|
+
if (chosen === undefined) throw queryExerciseUndeclared(name, false);
|
|
56
|
+
await exerciseQuery(target, chosen, options.ctx);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Single responsibility: `.contract()`'s assertion for a query declaring `errors:` — the codes its
|
|
2
|
+
// `sql` names in its own source, judged by core's rule (the action's twin, one tier over). First
|
|
3
|
+
// hop only: what a repository helper throws is the read path's wrap to judge (`declared-errors.ts`).
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
definedErrorClassCodes,
|
|
7
|
+
ErrorUndeclaredError,
|
|
8
|
+
judgeThrown,
|
|
9
|
+
scanHandlerSource,
|
|
10
|
+
} from '@ultimat3/core';
|
|
11
|
+
import { declaredStatusFor } from '@ultimat3/http';
|
|
12
|
+
import type { QueryContractTest } from './contract-test';
|
|
13
|
+
import type { AnyQuery } from './query';
|
|
14
|
+
import { defOf, queryName } from './read';
|
|
15
|
+
|
|
16
|
+
/** One assertion for a read that declares `errors:`; none for an open contract or a download. */
|
|
17
|
+
export function declaredErrorsContract(target: AnyQuery): readonly QueryContractTest[] {
|
|
18
|
+
const declared = target.errors;
|
|
19
|
+
if (declared === undefined || target.download === true) return [];
|
|
20
|
+
const name = queryName(target);
|
|
21
|
+
return [
|
|
22
|
+
{
|
|
23
|
+
name: `${name}: its read names only the errors it declares`,
|
|
24
|
+
run: async () => {
|
|
25
|
+
const stray = scanHandlerSource(
|
|
26
|
+
defOf(target).sql.toString(),
|
|
27
|
+
await definedErrorClassCodes(),
|
|
28
|
+
).filter((code) => judgeThrown(declared, code, declaredStatusFor(code)) === 'undeclared');
|
|
29
|
+
if (stray.length === 0) return;
|
|
30
|
+
throw new ErrorUndeclaredError({ primitive: 'query', name }, stray.join("', '"));
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Single responsibility: the judgement the read path puts around `sql` and the source's `execute`
|
|
2
|
+
// for a query declaring `errors:` — core's rule (`declared-errors.ts`, shared with
|
|
3
|
+
// `@ultimat3/action`), `@ultimat3/http`'s status. Undeclared, each call is the read itself.
|
|
4
|
+
|
|
5
|
+
import type { DeclaredErrorsOwner } from '@ultimat3/core';
|
|
6
|
+
import { enforceDeclared, withDeclaredErrors } from '@ultimat3/core';
|
|
7
|
+
import { declaredStatusFor } from '@ultimat3/http';
|
|
8
|
+
import type { AnyQuery } from './query';
|
|
9
|
+
|
|
10
|
+
const ownerOf = (name: string): DeclaredErrorsOwner => ({ primitive: 'query', name });
|
|
11
|
+
|
|
12
|
+
/** `sql(input, ctx)`, which builds a source synchronously: a throw from it is judged here. */
|
|
13
|
+
export function judgedSql<T>(target: AnyQuery, name: string, build: () => T): T {
|
|
14
|
+
if (target.errors === undefined) return build();
|
|
15
|
+
try {
|
|
16
|
+
return build();
|
|
17
|
+
} catch (error) {
|
|
18
|
+
throw enforceDeclared(error, target.errors, declaredStatusFor, ownerOf(name));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The source's `execute()`: the same promise when undeclared, a judged one otherwise. */
|
|
23
|
+
export function judgedRead<T>(target: AnyQuery, name: string, run: () => Promise<T>): Promise<T> {
|
|
24
|
+
if (target.errors === undefined) return run();
|
|
25
|
+
return withDeclaredErrors(target.errors, declaredStatusFor, ownerOf(name), run);
|
|
26
|
+
}
|