@ultimat3/query 29.4.0 → 30.1.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 +14 -5
- package/README.md +38 -3
- package/package.json +18 -7
- package/src/cache.ts +15 -3
- package/src/client.ts +32 -8
- 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-http.ts +38 -27
- package/src/download.ts +28 -9
- package/src/errors.ts +16 -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 +38 -4
- package/src/index.ts +15 -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 +100 -51
- package/src/query.ts +41 -14
- package/src/read-freshness.ts +63 -0
- package/src/read.ts +69 -18
- package/src/record-answer.ts +18 -6
- package/src/registry.ts +4 -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` |
|
|
@@ -40,9 +46,10 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
40
46
|
| `audit-errors.ts` | `X_QUERY_AUDIT_SINK_MISSING` / `X_QUERY_AUDIT_SINK_FAILED` (titles in `errors.ts`) |
|
|
41
47
|
| `download.ts` | `query({ download })`: the build, the in-process file, the mint, the serve — through `admittedCall` |
|
|
42
48
|
| `download-token.ts` | the `_token`: claims `seal()`ed under the master key, opened against one query, one input, the clock |
|
|
43
|
-
| `download-http.ts` | the download route: mint JSON without `_token`, the file with it (`
|
|
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) |
|
|
44
50
|
| `download-errors.ts` | the four `X_QUERY_DOWNLOAD_*` classes (titles in `errors.ts`) |
|
|
45
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 |
|
|
46
53
|
|
|
47
54
|
## Invariants — the read path
|
|
48
55
|
|
|
@@ -70,7 +77,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
70
77
|
- **A read has ONE tool name, the export name verbatim.** `toToolName` is deleted.
|
|
71
78
|
`index.test.ts` asserts the barrel exports nothing matching `/tool_?name/i`.
|
|
72
79
|
- **`queryClient` is the map-wide read client (mirror of `rpc`)**; both spellings run
|
|
73
|
-
`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.**
|
|
74
81
|
- **`QueryClient` typechecks at any app size, and a `.test.ts` cannot say so** (`tsconfig.json`
|
|
75
82
|
excludes them): `client-scale-pins.ts` is source, so a regression is a `typecheck` error. The map
|
|
76
83
|
it pins is the intersection `@ultimat3/action`'s `Merge` hands over; that package pins `Merge`.
|
|
@@ -117,6 +124,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
117
124
|
- **A live read keyed on a sealed column is refused at subscribe** (`assertNoSealedKey`, called by
|
|
118
125
|
`toLiveQuery`): a change row carries none, so the window would be read once and never patched.
|
|
119
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`.
|
|
120
128
|
|
|
121
129
|
## Invariants — download
|
|
122
130
|
|
|
@@ -131,6 +139,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
131
139
|
an expired forgery is a forgery. `X_SEAL_KEY_MISSING` stays a 500 (the server's fault).
|
|
132
140
|
- **`audit: true` records the mint, `rateLimit:` spends at the mint** — a player's `Range:` GETs
|
|
133
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`.
|
|
134
143
|
|
|
135
144
|
## Invariants — audit
|
|
136
145
|
|
|
@@ -162,9 +171,9 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher.
|
|
|
162
171
|
- **`@ultimat3/query/client` is the read client without the barrel** — `client.ts`
|
|
163
172
|
imports core from `@ultimat3/core/page`, the page keys live in the leaf `page-keys.ts`, and
|
|
164
173
|
`client.ts` never imports `page-controls.ts` or `stable.ts`. `client-bundle.test.ts` fails on any
|
|
165
|
-
titles table in the graph or past
|
|
166
|
-
(browser, minified): barrel `queryClient` 25,237 B (`As of 2026-10-02`); `./client`
|
|
167
|
-
(`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).
|
|
168
177
|
- **The record envelope is derived from `rows:`** — `answersRecords(rows)` (`hasEntityRows`,
|
|
169
178
|
`@ultimat3/entity`), decided once at projection for `http.ts` and `openapi.ts`, sent on every
|
|
170
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 |
|
|
@@ -556,6 +576,13 @@ tier registered with a frozen clock is drivable end to end — and omitting it i
|
|
|
556
576
|
for its own default. `@ultimat3/cache`'s tiers refuse a non-positive `ttlMs` and have no immortal
|
|
557
577
|
entry to offer, so there is no "never" to spell.
|
|
558
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
|
+
|
|
559
586
|
The memo entry is the read **in flight**, not its value, so "twice" covers reads that race as
|
|
560
587
|
well as reads that follow: five holes rendering concurrently share one execution and one tier
|
|
561
588
|
round trip. A rejection is evicted — a failed read is not the request's answer.
|
|
@@ -603,6 +630,14 @@ The conversion is `toBucket` from `@ultimat3/http` — the same one the action r
|
|
|
603
630
|
http owns `Bucket` and `action` is the same tier as this package. A pair the limiter cannot run
|
|
604
631
|
on is `X_RATE_LIMIT_INVALID`, at projection.
|
|
605
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
|
+
|
|
606
641
|
## `deprecated:` — a compat window, not a version
|
|
607
642
|
|
|
608
643
|
```ts
|
|
@@ -712,9 +747,9 @@ export const runRecording = query({
|
|
|
712
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 |
|
|
713
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 |
|
|
714
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 |
|
|
715
|
-
| `handle`'s answer | `{ body: Uint8Array \| Blob \| ReadableStream, contentType, filename, disposition?: 'attachment' \| 'inline', size? }` — bytes, never base64. Anything else is a 500 (`X_INVARIANT`) naming the fix |
|
|
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 |
|
|
716
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` |
|
|
717
|
-
| `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 |
|
|
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 |
|
|
718
753
|
| Rows | `.live()`, `.page()`, `.as()`, `sourceFor`, `explain`, an MCP tool: `X_QUERY_DOWNLOAD_ONLY`. `describeSql` lists it with `sql: null` |
|
|
719
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 |
|
|
720
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` |
|
package/package.json
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/query",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "30.1.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.1.0",
|
|
51
|
+
"@ultimat3/core": "30.1.0",
|
|
52
|
+
"@ultimat3/db": "30.1.0",
|
|
53
|
+
"@ultimat3/entity": "30.1.0",
|
|
54
|
+
"@ultimat3/http": "30.1.0",
|
|
55
|
+
"@ultimat3/policy": "30.1.0",
|
|
56
|
+
"@ultimat3/schema": "30.1.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,9 +18,10 @@
|
|
|
18
18
|
|
|
19
19
|
import type { ClientFlight, ClientRetry } from '@ultimat3/core';
|
|
20
20
|
import type { FetchLike, RecordEnvelope } from '@ultimat3/core/page';
|
|
21
|
-
import { clientTransport, queryPath } from '@ultimat3/core/page';
|
|
21
|
+
import { clientTransport, fillWire, queryPath, renderedQueryPin } from '@ultimat3/core/page';
|
|
22
22
|
import type { InferInput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
23
23
|
import type { DownloadClientMethod, DownloadQuery } from './download';
|
|
24
|
+
import { QueryHttpPathInvalidError } from './http-pin-error';
|
|
24
25
|
import type { PageControls } from './page-controls';
|
|
25
26
|
import type { Page } from './pagination';
|
|
26
27
|
import type { Query } from './query';
|
|
@@ -167,8 +168,9 @@ export function queryClient<TQueries extends QueryMap>(
|
|
|
167
168
|
export function queryClientMethodFor<TInput extends StandardSchemaV1, TRow extends object>(
|
|
168
169
|
name: string,
|
|
169
170
|
options: QueryClientOptions,
|
|
171
|
+
pinnedPath?: string,
|
|
170
172
|
): QueryClientMethod<TInput, TRow> {
|
|
171
|
-
return methodsOf<TInput, TRow>(name, options);
|
|
173
|
+
return methodsOf<TInput, TRow>(name, options, pinnedPath);
|
|
172
174
|
}
|
|
173
175
|
|
|
174
176
|
/**
|
|
@@ -186,21 +188,33 @@ export function downloadClientFor<TInput extends StandardSchemaV1>(
|
|
|
186
188
|
function methodsOf<TInput extends StandardSchemaV1, TRow extends object>(
|
|
187
189
|
name: string,
|
|
188
190
|
options: QueryClientOptions,
|
|
191
|
+
pinnedPath?: string,
|
|
189
192
|
) {
|
|
190
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);
|
|
191
197
|
// Erased at the wire seam; the row type is this query's by construction.
|
|
192
198
|
const rows = (input: InferInput<TInput>, callOptions: QueryCallOptions = {}) =>
|
|
193
|
-
read(base, options, name, input, callOptions) as Promise<readonly TRow[]>;
|
|
199
|
+
read(base, options, name, input, callOptions, undefined, path) as Promise<readonly TRow[]>;
|
|
194
200
|
const page = (
|
|
195
201
|
input: InferInput<TInput>,
|
|
196
202
|
args: PageControls,
|
|
197
203
|
callOptions: QueryCallOptions = {},
|
|
198
|
-
) => read(base, options, name, input, callOptions, args) as Promise<Page<TRow>>;
|
|
204
|
+
) => read(base, options, name, input, callOptions, args, path) as Promise<Page<TRow>>;
|
|
199
205
|
const url = async (
|
|
200
206
|
input: InferInput<TInput>,
|
|
201
207
|
callOptions: QueryCallOptions = {},
|
|
202
208
|
): Promise<DownloadUrl> => {
|
|
203
|
-
const minted = (await read(
|
|
209
|
+
const minted = (await read(
|
|
210
|
+
base,
|
|
211
|
+
options,
|
|
212
|
+
name,
|
|
213
|
+
input,
|
|
214
|
+
callOptions,
|
|
215
|
+
undefined,
|
|
216
|
+
path,
|
|
217
|
+
)) as DownloadUrl;
|
|
204
218
|
return { ...minted, url: base + minted.url };
|
|
205
219
|
};
|
|
206
220
|
return Object.assign(rows, { page, url });
|
|
@@ -218,12 +232,22 @@ function read(
|
|
|
218
232
|
name: string,
|
|
219
233
|
input: unknown,
|
|
220
234
|
callOptions: QueryCallOptions,
|
|
221
|
-
page
|
|
235
|
+
page: PageControls | undefined,
|
|
236
|
+
path: string,
|
|
222
237
|
): Promise<unknown> {
|
|
223
|
-
|
|
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);
|
|
224
248
|
return clientTransport({
|
|
225
249
|
method: 'GET',
|
|
226
|
-
url: `${base}${
|
|
250
|
+
url: `${base}${at}${search === '' ? '' : `?${search}`}`,
|
|
227
251
|
// The trace and budget are the transport's, from its server-side outbound slot, placed
|
|
228
252
|
// before these so an explicit `traceparent` still wins; a browser bundles none of it.
|
|
229
253
|
...(options.headers === undefined ? {} : { headers: options.headers }),
|
|
@@ -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
|
+
}
|
package/src/download-http.ts
CHANGED
|
@@ -9,11 +9,10 @@ import { assert, queryPath, useContext } from '@ultimat3/core';
|
|
|
9
9
|
import type { RateLimitDecision, Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
|
|
10
10
|
import {
|
|
11
11
|
byteWindow,
|
|
12
|
-
contentRangeOf,
|
|
13
12
|
jsonResponse,
|
|
14
|
-
|
|
13
|
+
type ObjectRequest,
|
|
14
|
+
objectResponse,
|
|
15
15
|
publishRateLimit,
|
|
16
|
-
RANGE_UNSATISFIABLE,
|
|
17
16
|
type RequestedRange,
|
|
18
17
|
} from '@ultimat3/http';
|
|
19
18
|
import { policyCapability } from '@ultimat3/policy';
|
|
@@ -56,7 +55,7 @@ export function toDownloadRoute(
|
|
|
56
55
|
return jsonResponse(minted);
|
|
57
56
|
}
|
|
58
57
|
const file = await serveDownloadFile(target, input, token, { surface: 'http' });
|
|
59
|
-
return fileResponse(
|
|
58
|
+
return fileResponse(request, file);
|
|
60
59
|
};
|
|
61
60
|
|
|
62
61
|
const meta: RouteMeta = {
|
|
@@ -120,10 +119,18 @@ const hasControl = (text: string): boolean =>
|
|
|
120
119
|
function assertFile(file: DownloadFile): void {
|
|
121
120
|
const { body, contentType, filename, disposition, size } = file;
|
|
122
121
|
assert(
|
|
123
|
-
body instanceof Uint8Array ||
|
|
124
|
-
|
|
122
|
+
body instanceof Uint8Array ||
|
|
123
|
+
body instanceof Blob ||
|
|
124
|
+
body instanceof ReadableStream ||
|
|
125
|
+
typeof body === 'function',
|
|
126
|
+
'a download handle() answered a body that is not a Uint8Array, a Blob, a ReadableStream or a function',
|
|
125
127
|
"return { body: bytes, contentType: 'video/mp4', filename: 'run.mp4' } // bytes, never a base64 string",
|
|
126
128
|
);
|
|
129
|
+
assert(
|
|
130
|
+
typeof body !== 'function' || size !== undefined,
|
|
131
|
+
'a download handle() answered a function body with no size, so no Range: can be judged',
|
|
132
|
+
"return { body: (range) => open(range), size: object.size, contentType: 'video/mp4', filename: 'run.mp4' }",
|
|
133
|
+
);
|
|
127
134
|
assert(
|
|
128
135
|
typeof contentType === 'string' && contentType !== '' && !hasControl(contentType),
|
|
129
136
|
'a download handle() answered a contentType that is empty, not a string, or carries a control character',
|
|
@@ -180,8 +187,9 @@ function bytesBody(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
|
|
180
187
|
: bytes.slice();
|
|
181
188
|
}
|
|
182
189
|
|
|
183
|
-
function bodyOf(file: DownloadFile, range?: RequestedRange): BodyInit {
|
|
190
|
+
function bodyOf(file: DownloadFile, range?: RequestedRange): BodyInit | Promise<BodyInit> {
|
|
184
191
|
const { body } = file;
|
|
192
|
+
if (typeof body === 'function') return body(range);
|
|
185
193
|
if (body instanceof Uint8Array) {
|
|
186
194
|
const whole = bytesBody(body);
|
|
187
195
|
return range === undefined ? whole : whole.subarray(range.start, range.end + 1);
|
|
@@ -192,35 +200,38 @@ function bodyOf(file: DownloadFile, range?: RequestedRange): BodyInit {
|
|
|
192
200
|
return range === undefined ? body : byteWindow(body, range);
|
|
193
201
|
}
|
|
194
202
|
|
|
195
|
-
/**
|
|
196
|
-
|
|
203
|
+
/**
|
|
204
|
+
* Status, headers and body for one file and the request's `Range:`, by `objectResponse`'s rules.
|
|
205
|
+
* The headers are the file's own and stand in for its sandbox rule. A file of unknown size has no
|
|
206
|
+
* window to serve, so it answers whole; there is no etag on a file either, so no 304.
|
|
207
|
+
*/
|
|
208
|
+
export async function fileResponse(request: ObjectRequest, file: DownloadFile): Promise<Response> {
|
|
197
209
|
assertFile(file);
|
|
198
|
-
const
|
|
199
|
-
'content-type': file.contentType,
|
|
210
|
+
const own = {
|
|
200
211
|
'content-disposition': contentDisposition(file),
|
|
201
212
|
'x-content-type-options': 'nosniff',
|
|
202
213
|
// Loads nothing but itself, in an opaque origin with no script — and names no URL on the way
|
|
203
214
|
// out: the one this file is served at is a bearer grant until it expires.
|
|
204
215
|
'content-security-policy': DOWNLOAD_CSP,
|
|
205
216
|
'referrer-policy': 'no-referrer',
|
|
206
|
-
}
|
|
217
|
+
};
|
|
207
218
|
const size = sizeOf(file);
|
|
208
219
|
if (size === undefined) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
headers.set('accept-ranges', 'bytes');
|
|
213
|
-
const range = parseByteRange(rangeHeader, size);
|
|
214
|
-
if (range === RANGE_UNSATISFIABLE) {
|
|
215
|
-
if (file.body instanceof ReadableStream) void file.body.cancel().catch(() => undefined);
|
|
216
|
-
headers.set('content-range', `bytes */${String(size)}`);
|
|
217
|
-
return new Response(null, { status: 416, headers });
|
|
220
|
+
return new Response(await bodyOf(file), {
|
|
221
|
+
headers: { 'content-type': file.contentType, 'accept-ranges': 'none', ...own },
|
|
222
|
+
});
|
|
218
223
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
224
|
+
const response = await objectResponse(
|
|
225
|
+
request,
|
|
226
|
+
{
|
|
227
|
+
object: { contentType: file.contentType, size },
|
|
228
|
+
open: (range) => bodyOf(file, range),
|
|
229
|
+
},
|
|
230
|
+
{ headers: own },
|
|
231
|
+
);
|
|
232
|
+
// Nothing opened it, and nothing else will read it: a stream the handler started is let go.
|
|
233
|
+
if (response.status === 416 && file.body instanceof ReadableStream) {
|
|
234
|
+
void file.body.cancel().catch(() => undefined);
|
|
222
235
|
}
|
|
223
|
-
|
|
224
|
-
headers.set('content-length', String(range.end - range.start + 1));
|
|
225
|
-
return new Response(bodyOf(file, range), { status: 206, headers });
|
|
236
|
+
return response;
|
|
226
237
|
}
|
package/src/download.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { Ctx, Deprecation } from '@ultimat3/core';
|
|
15
15
|
import { queryPath, useContext } from '@ultimat3/core';
|
|
16
|
+
import type { RequestedRange } from '@ultimat3/http';
|
|
16
17
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
17
18
|
import type { DownloadUrl, QueryCallOptions, QueryClientOptions } from './client';
|
|
18
19
|
import { downloadClientFor } from './client';
|
|
@@ -40,22 +41,40 @@ export const MAX_DOWNLOAD_TTL_MS = 3_600_000;
|
|
|
40
41
|
const TOKEN_IS_THE_GRANT =
|
|
41
42
|
'a download token is sealed only by url(), after this query admitted the minting actor; the token is that decision';
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
export interface DownloadFile {
|
|
45
|
-
/**
|
|
46
|
-
* A `Uint8Array` or a `Blob` (a `Bun.file`, a slice) is range-served from its own length; a
|
|
47
|
-
* `ReadableStream` is range-served only with `size`, else whole with `accept-ranges: none`.
|
|
48
|
-
*/
|
|
49
|
-
readonly body: Uint8Array | Blob | ReadableStream<Uint8Array>;
|
|
44
|
+
interface DownloadFileHead {
|
|
50
45
|
readonly contentType: string;
|
|
51
46
|
/** The name a browser saves it under — `content-disposition`'s `filename` and `filename*`. */
|
|
52
47
|
readonly filename: string;
|
|
53
48
|
/** `attachment` (default) saves it; `inline` lets a tab render it. `<video>` ignores both. */
|
|
54
49
|
readonly disposition?: 'attachment' | 'inline' | undefined;
|
|
55
|
-
/** The stream's length in bytes. Ignored for a `Uint8Array` or a `Blob`, which know theirs. */
|
|
56
|
-
readonly size?: number | undefined;
|
|
57
50
|
}
|
|
58
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Opens the bytes asked for: the window with a `Range:`, the whole file with none, and not at all
|
|
54
|
+
* for a 304 or a 416. So a stored object's seek reads only its window, with no `storage` edge here:
|
|
55
|
+
* `(range) => range === undefined ? disk.stream(key) : disk.streamRange(key, [range.start, range.end])`.
|
|
56
|
+
*/
|
|
57
|
+
export type DownloadBodyOpener = (range?: RequestedRange) => BodyInit | Promise<BodyInit>;
|
|
58
|
+
|
|
59
|
+
/** What `handle` answers. Bytes, never a base64 string — that is the +33 % this exists to remove. */
|
|
60
|
+
export type DownloadFile =
|
|
61
|
+
| (DownloadFileHead & {
|
|
62
|
+
/**
|
|
63
|
+
* A `Uint8Array` or a `Blob` (a `Bun.file`, a slice) is range-served from its own length; a
|
|
64
|
+
* `ReadableStream` is range-served only with `size`, else whole with `accept-ranges: none`,
|
|
65
|
+
* and a window of one has read and dropped everything before it.
|
|
66
|
+
*/
|
|
67
|
+
readonly body: Uint8Array | Blob | ReadableStream<Uint8Array>;
|
|
68
|
+
/** The stream's length in bytes. Ignored for a `Uint8Array` or a `Blob`, which know theirs. */
|
|
69
|
+
readonly size?: number | undefined;
|
|
70
|
+
})
|
|
71
|
+
| (DownloadFileHead & {
|
|
72
|
+
/** The disk's own window. A sealed object cannot take it: an AES-GCM envelope opens whole. */
|
|
73
|
+
readonly body: DownloadBodyOpener;
|
|
74
|
+
/** Required: it is what a `Range:` is judged against before anything is opened. */
|
|
75
|
+
readonly size: number;
|
|
76
|
+
});
|
|
77
|
+
|
|
59
78
|
export interface DownloadArgs<TInput extends StandardSchemaV1> {
|
|
60
79
|
readonly input: InferOutput<TInput>;
|
|
61
80
|
readonly ctx: Ctx;
|