@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 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 12 kB; `client-subpath.test.ts` pins the specifier. Byte figures
147
- (browser, minified): barrel `queryClient` 25,237 B (`As of 2026-10-02`); `./client` 11,983 B
148
- (`As of 2026-10-05`, +971 B: core reads a refusal's `Retry-After` and body title).
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": "29.3.2",
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": "29.3.2",
41
- "@ultimat3/core": "29.3.2",
42
- "@ultimat3/entity": "29.3.2",
43
- "@ultimat3/http": "29.3.2",
44
- "@ultimat3/policy": "29.3.2",
45
- "@ultimat3/schema": "29.3.2"
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: number | null,
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
- return stackFor(clock).read(key, run, { ...(ttlMs === null ? {} : { ttlMs }), tags });
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, isJsonObject, 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
+ 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 Query<
116
- infer TInput,
117
- infer TRow,
118
- infer TSingle extends boolean
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
- /** One query's method — what `query.client()` returns, and what `queryClient` proxies to. */
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
- return Object.assign(rows, { page });
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?: PageControls,
235
+ page: PageControls | undefined,
236
+ path: string,
185
237
  ): Promise<unknown> {
186
- const search = searchOf(input, page);
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}${queryPath(name)}${search === '' ? '' : `?${search}`}`,
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
+ }