@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.
@@ -0,0 +1,261 @@
1
+ /**
2
+ * `query({ input, policy, download })` — a read whose answer is a FILE, served at a short-lived
3
+ * signed URL. Still a `query`: the same input parse, the same policy, the same declared limit, the
4
+ * same audit, one name in the registry and the manifest. Only the answer differs, so the rows
5
+ * projections (`live`, `page`, `as`, MCP, `explain`) are refused by the `sql` this file stores.
6
+ *
7
+ * Two calls make a download. `url(input)` runs the read's front half as the caller and seals a
8
+ * token naming the caller, the input and an expiry. `GET <url>` opens the token, re-parses the
9
+ * input from the URL and runs `handle` as the minting actor — the policy decided at mint, and the
10
+ * token is what it decided. That is what lets a `<video src>` or a link handed to someone without
11
+ * the session's headers fetch the bytes, and what keeps a URL from outliving `expiresInMs`.
12
+ */
13
+
14
+ import type { Ctx, Deprecation } from '@ultimat3/core';
15
+ import { queryPath, useContext } from '@ultimat3/core';
16
+ import type { RequestedRange } from '@ultimat3/http';
17
+ import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
18
+ import type { DownloadUrl, QueryCallOptions, QueryClientOptions } from './client';
19
+ import { downloadClientFor } from './client';
20
+ import { QueryDownloadInvalidError, QueryDownloadOnlyError } from './download-errors';
21
+ import {
22
+ assertGrantedInput,
23
+ DOWNLOAD_GRANT_PARAM,
24
+ mintDownloadToken,
25
+ openDownloadToken,
26
+ } from './download-token';
27
+ import type { QueryPolicy } from './policy-gate';
28
+ import type { AnyQuery, AnyQueryDef, QueryDescriptor, QueryOptions, QueryRateLimit } from './query';
29
+ import { admittedCall, defOf, queryName, stashDef } from './read';
30
+ import { searchOf } from './search-string';
31
+
32
+ export type { DownloadUrl } from './client';
33
+
34
+ /** How long a minted URL works when the declaration names nothing: long enough to start a player. */
35
+ export const DEFAULT_DOWNLOAD_TTL_MS = 300_000;
36
+ /** The floor and ceiling of `expiresInMs`. A URL is a bearer capability; an hour is the most. */
37
+ export const MIN_DOWNLOAD_TTL_MS = 1_000;
38
+ export const MAX_DOWNLOAD_TTL_MS = 3_600_000;
39
+
40
+ /** The written reason the token's serve skips the policy — `admit`'s escape, never a boolean. */
41
+ const TOKEN_IS_THE_GRANT =
42
+ 'a download token is sealed only by url(), after this query admitted the minting actor; the token is that decision';
43
+
44
+ interface DownloadFileHead {
45
+ readonly contentType: string;
46
+ /** The name a browser saves it under — `content-disposition`'s `filename` and `filename*`. */
47
+ readonly filename: string;
48
+ /** `attachment` (default) saves it; `inline` lets a tab render it. `<video>` ignores both. */
49
+ readonly disposition?: 'attachment' | 'inline' | undefined;
50
+ }
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
+
78
+ export interface DownloadArgs<TInput extends StandardSchemaV1> {
79
+ readonly input: InferOutput<TInput>;
80
+ readonly ctx: Ctx;
81
+ }
82
+
83
+ export interface QueryDownload<TInput extends StandardSchemaV1> {
84
+ /** How long a minted URL works: 1 000 … 3 600 000 ms. Default `DEFAULT_DOWNLOAD_TTL_MS` (5 min). */
85
+ readonly expiresInMs?: number | undefined;
86
+ /**
87
+ * The file, for an input the policy already admitted. Runs as the actor that MINTED the URL;
88
+ * decrypt a sealed object here (`open()` from `@ultimat3/core`) — an AES-GCM envelope opens whole,
89
+ * so a sealed object is buffered, bounded by its disk's `maxGetBytes`.
90
+ */
91
+ handle(args: DownloadArgs<TInput>): Promise<DownloadFile>;
92
+ }
93
+
94
+ type RowsOnly = 'sql' | 'live' | 'single' | 'subscribes' | 'rows' | 'cache' | 'mcp';
95
+
96
+ /** A download declares no rows option: each is a type error here and `X_QUERY_DOWNLOAD_INVALID` at runtime. */
97
+ export type DownloadQueryDef<TInput extends StandardSchemaV1> = {
98
+ readonly input: TInput;
99
+ readonly policy: QueryPolicy;
100
+ readonly download: QueryDownload<TInput>;
101
+ /** Spent at MINT — one URL is one admitted read, however many `Range:` requests a player makes. */
102
+ readonly rateLimit?: QueryRateLimit;
103
+ readonly deprecated?: Deprecation;
104
+ /** Records the MINT: the decision of who may read what. A ranged GET is not a second read. */
105
+ readonly audit?: boolean;
106
+ } & { readonly [K in RowsOnly]?: never };
107
+
108
+ /** `client.recording.url(input)` — the browser half: the server mints, this prefixes the base. */
109
+ export interface DownloadClientMethod<TInput extends StandardSchemaV1> {
110
+ url(input: InferInput<TInput>, options?: QueryCallOptions): Promise<DownloadUrl>;
111
+ }
112
+
113
+ export interface DownloadQuery<TInput extends StandardSchemaV1 = StandardSchemaV1>
114
+ extends AnyQuery {
115
+ /** The file, in-process: the same parse and policy as `url()`, then `handle` as this caller. */
116
+ (input: InferInput<TInput>, options?: QueryOptions): Promise<DownloadFile>;
117
+ readonly input: TInput;
118
+ readonly download: true;
119
+ /** Mint a signed URL — a PATH, `/_x/query/<kebab>?…&_token=…` — for this caller and this input. */
120
+ url(input: InferInput<TInput>, options?: QueryOptions): Promise<DownloadUrl>;
121
+ client(options: QueryClientOptions): DownloadClientMethod<TInput>;
122
+ named(name: string): DownloadQuery<TInput>;
123
+ }
124
+
125
+ const isFunction = (value: unknown): value is (...args: never[]) => unknown =>
126
+ typeof value === 'function';
127
+
128
+ /** The declaration, judged where it is written: an untyped caller can still pass `sql` beside it. */
129
+ export function assertDownloadDef(def: Readonly<Record<string, unknown>>): void {
130
+ const download = def['download'];
131
+ if (typeof download !== 'object' || download === null) {
132
+ throw new QueryDownloadInvalidError('download is not an object with a handle');
133
+ }
134
+ const rows = (['sql', 'live', 'single', 'subscribes', 'rows', 'cache', 'mcp'] as const).filter(
135
+ (key) => def[key] !== undefined,
136
+ );
137
+ if (rows.length > 0) {
138
+ throw new QueryDownloadInvalidError(`it also declares ${rows.join(', ')}, which answer rows`);
139
+ }
140
+ if (!isFunction(Reflect.get(download, 'handle'))) {
141
+ throw new QueryDownloadInvalidError('download.handle is not a function');
142
+ }
143
+ const ttl: unknown = Reflect.get(download, 'expiresInMs');
144
+ const whole = typeof ttl === 'number' && Number.isSafeInteger(ttl);
145
+ if (ttl !== undefined && !(whole && ttl >= MIN_DOWNLOAD_TTL_MS && ttl <= MAX_DOWNLOAD_TTL_MS)) {
146
+ throw new QueryDownloadInvalidError(
147
+ `download.expiresInMs is ${String(ttl)}, not a whole number of ms from ${MIN_DOWNLOAD_TTL_MS} to ${MAX_DOWNLOAD_TTL_MS}`,
148
+ );
149
+ }
150
+ }
151
+
152
+ /** `describe` is handed in, so this file never value-imports `query.ts` (which imports it). */
153
+ export function buildDownloadQuery<TInput extends StandardSchemaV1>(
154
+ def: DownloadQueryDef<TInput>,
155
+ name: string,
156
+ describe: (target: AnyQuery) => QueryDescriptor,
157
+ ): DownloadQuery<TInput> {
158
+ const refuse = (): Promise<never> => Promise.reject(new QueryDownloadOnlyError(queryName(self)));
159
+ const callable = (input: InferInput<TInput>, options: QueryOptions = {}): Promise<DownloadFile> =>
160
+ readDownloadFile(self, input, options);
161
+ const self: DownloadQuery<TInput> = Object.assign(callable, {
162
+ kind: 'query' as const,
163
+ isLive: false,
164
+ download: true as const,
165
+ input: def.input,
166
+ policy: def.policy,
167
+ ...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }),
168
+ ...(def.deprecated === undefined ? {} : { deprecated: def.deprecated }),
169
+ describe: (): QueryDescriptor => describe(self),
170
+ named: (next: string): DownloadQuery<TInput> => buildDownloadQuery(def, next, describe),
171
+ as: refuse,
172
+ page: refuse,
173
+ live: refuse,
174
+ url: (input: InferInput<TInput>, options: QueryOptions = {}) =>
175
+ mintDownloadUrl(self, input, options),
176
+ client: (options: QueryClientOptions): DownloadClientMethod<TInput> => ({
177
+ url: downloadClientFor<TInput>(queryName(self), options).url,
178
+ }),
179
+ });
180
+ Object.defineProperty(self, 'name', { value: name, configurable: true });
181
+ const stored: AnyQueryDef = {
182
+ input: def.input,
183
+ policy: def.policy,
184
+ ...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }),
185
+ ...(def.deprecated === undefined ? {} : { deprecated: def.deprecated }),
186
+ ...(def.audit === undefined ? {} : { audit: def.audit }),
187
+ download: def.download,
188
+ // Every rows projection ends here — one refusal, wherever the rows were asked from.
189
+ sql: () => {
190
+ throw new QueryDownloadOnlyError(queryName(self));
191
+ },
192
+ };
193
+ stashDef(self, stored);
194
+ return self;
195
+ }
196
+
197
+ function handlerOf(target: AnyQuery): QueryDownload<StandardSchemaV1> {
198
+ const download = defOf(target).download;
199
+ if (download === undefined) throw new QueryDownloadOnlyError(queryName(target));
200
+ return download;
201
+ }
202
+
203
+ /** The in-process read: admitted as the caller, answered by `handle`. */
204
+ export function readDownloadFile(
205
+ target: AnyQuery,
206
+ raw: unknown,
207
+ options: QueryOptions,
208
+ ): Promise<DownloadFile> {
209
+ const download = handlerOf(target);
210
+ return admittedCall(target, raw, options, (input, ctx) => download.handle({ input, ctx }));
211
+ }
212
+
213
+ /**
214
+ * Admit the caller exactly as a read would, then seal the decision. The URL carries the input as
215
+ * it was HANDED in (the route re-parses it to the same value) and the token binds the PARSED one.
216
+ */
217
+ export function mintDownloadUrl(
218
+ target: AnyQuery,
219
+ raw: unknown,
220
+ options: QueryOptions,
221
+ ): Promise<DownloadUrl> {
222
+ const name = queryName(target);
223
+ const ttl = handlerOf(target).expiresInMs ?? DEFAULT_DOWNLOAD_TTL_MS;
224
+ return admittedCall(target, raw, options, async (input, ctx) => {
225
+ const expiresAtMs = ctx.now().getTime() + ttl;
226
+ const token = await mintDownloadToken(name, input, ctx.actor, expiresAtMs);
227
+ const search = searchOf(raw);
228
+ const tokenParam = `${DOWNLOAD_GRANT_PARAM}=${encodeURIComponent(token)}`;
229
+ return {
230
+ url: `${queryPath(name)}?${search === '' ? '' : `${search}&`}${tokenParam}`,
231
+ expiresAt: new Date(expiresAtMs).toISOString(),
232
+ };
233
+ });
234
+ }
235
+
236
+ /**
237
+ * The file a token grants: the token opened against THIS query and THIS input on the context's
238
+ * clock, then `handle` as the minting actor. The input is still parsed — `handle` is owed the
239
+ * schema's output — and the policy is not re-run: it ran at mint, with facts this token cannot
240
+ * carry, and the written reason above is what `admit` logs for the skip.
241
+ */
242
+ export async function serveDownloadFile(
243
+ target: AnyQuery,
244
+ raw: unknown,
245
+ token: string,
246
+ options: QueryOptions,
247
+ ): Promise<DownloadFile> {
248
+ const name = queryName(target);
249
+ const download = handlerOf(target);
250
+ const now = (options.ctx ?? useContext()).now().getTime();
251
+ const grant = await openDownloadToken(name, token, now);
252
+ return admittedCall(
253
+ target,
254
+ raw,
255
+ { ...options, actor: grant.actor, unenforced: TOKEN_IS_THE_GRANT },
256
+ (input, ctx) => {
257
+ assertGrantedInput(name, grant, input);
258
+ return download.handle({ input, ctx });
259
+ },
260
+ );
261
+ }
package/src/errors.ts CHANGED
@@ -31,6 +31,13 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
31
31
  X_QUERY_AUDIT_SINK_MISSING: 'a query declares audit: true and no audit sink is installed',
32
32
  X_QUERY_AUDIT_SINK_FAILED:
33
33
  'an audited read was refused because the audit sink rejected its record',
34
+ X_QUERY_DOWNLOAD_INVALID: 'a query declares a download it cannot serve',
35
+ X_QUERY_DOWNLOAD_ONLY: 'rows were asked of a query that answers a file',
36
+ X_QUERY_DOWNLOAD_TOKEN_INVALID: 'a download URL carries a token this app did not mint for it',
37
+ X_QUERY_DOWNLOAD_TOKEN_EXPIRED: 'a download URL is past its expiry',
38
+ X_QUERY_HTTP_PATH_INVALID: "a query's pinned http.path is not a valid path template",
39
+ X_QUERY_ETAG_INVALID: 'etag: true on a read that has its own validator',
40
+ X_QUERY_TIMEOUT_INVALID: 'a read declares a timeoutMs that is not a whole positive number of ms',
34
41
  };
35
42
 
36
43
  /**
@@ -40,7 +47,15 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
40
47
  * re-declares it: two copies of a title are two titles, one of which is stale. `X_RPC_FAILED` left this list in
41
48
  * 21.0.0: the typed read client's wire failures are `@ultimat3/core`'s `clientTransport`'s now.
42
49
  */
43
- export const QUERY_BORROWED_ERROR_CODES = ['X_INPUT_INVALID', 'X_NOT_FOUND'] as const;
50
+ // `X_CONTRACT_*`: the exercise assertion `contract-test.ts` runs, a twin of action's (`exercise.ts`).
51
+ export const QUERY_BORROWED_ERROR_CODES = [
52
+ 'X_INPUT_INVALID',
53
+ 'X_NOT_FOUND',
54
+ 'X_CONTRACT_DRIFT',
55
+ 'X_CONTRACT_DEFECT',
56
+ 'X_CONTRACT_EXERCISE_UNREACHED',
57
+ 'X_CONTRACT_EXERCISE_UNDECLARED',
58
+ ] as const;
44
59
 
45
60
  // One unconditional call: a presence guard would turn "another package claims one of these codes"
46
61
  // from an X_ERROR_CODE_DUPLICATE at import into whichever module loaded first deciding the title.
@@ -154,12 +169,12 @@ export class QueryInputUnencodableError extends UltimateError {
154
169
  * the cause describes the declaration, exactly as `X_QUERY_INPUT_UNENCODABLE` does.
155
170
  */
156
171
  export class QueryCacheTtlInvalidError extends UltimateError {
157
- constructor(ttlMs: number) {
172
+ constructor(ttlMs: number, field: 'ttlMs' | 'negativeTtlMs' = 'ttlMs') {
158
173
  super({
159
174
  code: 'X_QUERY_CACHE_TTL_INVALID',
160
- cause: `a query declares cache.ttlMs as ${ttlMs}, and every cache tier refuses a lease that is not positive and finite`,
161
- fix: 'set `cache: { ttlMs: 60_000 }` to a positive whole number of milliseconds, or drop ttlMs to take the read cache default',
162
- meta: { ttlMs },
175
+ cause: `a query declares cache.${field} as ${ttlMs}, and every cache tier refuses a lease that is not positive and finite`,
176
+ fix: `set \`cache: { ${field}: ${field === 'ttlMs' ? '60_000' : '1_000'} }\` to a positive whole number of milliseconds, or drop ${field} to take the default`,
177
+ meta: { ttlMs, field },
163
178
  });
164
179
  }
165
180
  }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * A read's adapter over `@ultimat3/core`'s exercise loop (`runExercise`, shared with `action`):
3
+ * run the read with inputs its own schema accepts, seeded by its name, and see whether `sql()` and
4
+ * its execution survive. A read writes nothing, so no transaction wraps it. The codes are action's.
5
+ */
6
+
7
+ import type { Actor, Ctx, DrawnInput, ExerciseReport } from '@ultimat3/core';
8
+ import {
9
+ bindDrawn,
10
+ ctxOf,
11
+ exerciseReplay,
12
+ isUltimateError,
13
+ renderFixShellArg,
14
+ runExercise,
15
+ tryUseContext,
16
+ UltimateError,
17
+ } from '@ultimat3/core';
18
+ import { statusFor } from '@ultimat3/http';
19
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
20
+ import { validateAsync } from '@ultimat3/schema';
21
+ import { arbitrary } from '@ultimat3/schema/sample';
22
+ import { QueryAuditSinkMissingError } from './audit-errors';
23
+ import { QueryDeniedError } from './errors';
24
+ import type { AnyQuery } from './query';
25
+ import { queryName, sourceFor } from './read';
26
+
27
+ /** Who reads, and what binds the parts a generated value cannot. Same shape as an action's. */
28
+ export interface QueryExerciseActorOptions {
29
+ readonly actor: Actor | null;
30
+ readonly input?: (drawn: DrawnInput) => unknown;
31
+ readonly runs?: number;
32
+ }
33
+
34
+ export interface QueryExerciseOffOptions {
35
+ readonly off: string;
36
+ }
37
+
38
+ export type QueryExerciseOptions = QueryExerciseActorOptions | QueryExerciseOffOptions;
39
+
40
+ export type QueryExerciseReport = ExerciseReport;
41
+
42
+ export const queryExerciseTestName = (name: string): string =>
43
+ `${name}: read survives generated input`;
44
+
45
+ /**
46
+ * Whether a refusal came from the front half every surface shares — the policy, the parse, the
47
+ * declared limit, a missing audit sink — rather than from `sql()` or its execution. Read off the
48
+ * refusal itself because `read.ts` keeps no counter: anything else was thrown past the front half.
49
+ */
50
+ function beforeSql(error: unknown): boolean {
51
+ if (error instanceof QueryDeniedError || error instanceof QueryAuditSinkMissingError) return true;
52
+ return isUltimateError(error) && FRONT_HALF_CODES.has(error.code);
53
+ }
54
+
55
+ const FRONT_HALF_CODES: ReadonlySet<string> = new Set(['X_INPUT_INVALID', 'X_RATE_LIMITED']);
56
+
57
+ const accepts = async (schema: StandardSchemaV1, input: unknown): Promise<boolean> =>
58
+ (await validateAsync(schema, input)).issues === undefined;
59
+
60
+ /**
61
+ * Exercise `target`. "Reached" is `sql()` having built a source: everything before it — the
62
+ * policy, the declared limit, the parse — is the front half every surface shares.
63
+ *
64
+ * A test-time call: refused in `staging`/`production` (`X_CONTRACT_EXERCISE_ENVIRONMENT`). Nothing
65
+ * is rolled back, because a read writes nothing; what it touches anyway (a rate-limit counter, an
66
+ * audit record, an outbound fetch — sealed under the test preload) is kept.
67
+ */
68
+ export async function exerciseQuery(
69
+ target: AnyQuery,
70
+ options: QueryExerciseActorOptions,
71
+ ctx?: Ctx,
72
+ ): Promise<QueryExerciseReport> {
73
+ const name = queryName(target);
74
+ const base = ctx ?? tryUseContext() ?? ctxOf({});
75
+ const verdict = await runExercise({
76
+ name,
77
+ runs: options.runs,
78
+ stoppedLabel: 'stopped before sql()',
79
+ draw: (seed, run) => arbitrary(target.input, { seed, run }),
80
+ bind: bindDrawn(options.input),
81
+ accepts: (input) => accepts(target.input, input),
82
+ attempt: async (input) => {
83
+ try {
84
+ const source = await sourceFor(target, input, {
85
+ ctx: base,
86
+ actor: options.actor,
87
+ surface: 'http',
88
+ });
89
+ await source.execute();
90
+ return { ok: true, reached: true };
91
+ } catch (error) {
92
+ return { ok: false, reached: !beforeSql(error), error };
93
+ }
94
+ },
95
+ statusOf: statusFor,
96
+ });
97
+ switch (verdict.kind) {
98
+ case 'pass':
99
+ return verdict.report;
100
+ case 'drift':
101
+ throw queryDrawsRefused(name);
102
+ case 'unreached':
103
+ throw queryUnreached(name, verdict.outcomes);
104
+ case 'defect':
105
+ throw queryDefect(name, verdict.run, verdict.input, verdict.defect, verdict.error);
106
+ }
107
+ }
108
+
109
+ function queryDefect(
110
+ name: string,
111
+ run: number,
112
+ input: unknown,
113
+ defect: string,
114
+ error: unknown,
115
+ ): UltimateError {
116
+ return new UltimateError({
117
+ code: 'X_CONTRACT_DEFECT',
118
+ cause: `query ${name} ${defect} on generated run ${run}, for input its own schema accepts (meta.input, meta.failure)`,
119
+ fix: `${exerciseReplay(name, queryExerciseTestName(name))} # replays the run; fix the ${name} read or tighten its input: first`,
120
+ meta: {
121
+ run,
122
+ input,
123
+ ...(isUltimateError(error) ? { failure: { code: error.code, cause: error.cause } } : {}),
124
+ },
125
+ sourceError: error,
126
+ });
127
+ }
128
+
129
+ function queryUnreached(name: string, outcomes: readonly string[]): UltimateError {
130
+ return new UltimateError({
131
+ code: 'X_CONTRACT_EXERCISE_UNREACHED',
132
+ cause: `none of ${outcomes.length} generated runs of query ${name} reached its sql() (meta.outcomes lists what each one hit)`,
133
+ fix: `${renderFixShellArg(name, '<name>')}.contract({ exercise: { actor } }) with an actor the policy permits`,
134
+ meta: { outcomes },
135
+ });
136
+ }
137
+
138
+ function queryDrawsRefused(name: string): UltimateError {
139
+ return new UltimateError({
140
+ code: 'X_CONTRACT_DRIFT',
141
+ cause: `query ${name}: its input schema refused every generated value, so the read was never exercised`,
142
+ fix: `${renderFixShellArg(name, '<name>')}.contract({ exercise: { actor, input: (drawn) => ({ ...drawn, field: '<a value the refinement accepts>' }) } })`,
143
+ });
144
+ }
145
+
146
+ /** A guarded read with no exercise said, or an opt-out with a blank reason. */
147
+ export function queryExerciseUndeclared(name: string, blankReason: boolean): UltimateError {
148
+ const call = renderFixShellArg(name, '<name>');
149
+ return new UltimateError({
150
+ code: 'X_CONTRACT_EXERCISE_UNDECLARED',
151
+ cause: blankReason
152
+ ? `${name}.contract({ exercise: { off } }) gives a blank reason, so the skip is silent`
153
+ : `query ${name} refuses an anonymous reader and its .contract() names no actor to read as`,
154
+ fix: `${call}.contract({ exercise: { actor } }) — or ${call}.contract({ exercise: { off: '<why this read cannot run in a test>' } })`,
155
+ });
156
+ }
package/src/facade.ts CHANGED
@@ -7,9 +7,12 @@
7
7
  * (`toolFrom(orgFeed)`), a tier this package cannot import.
8
8
  */
9
9
 
10
+ import { checkDeclaredErrors } from '@ultimat3/core';
11
+ import { declaredStatusFor } from '@ultimat3/http';
10
12
  import type { StandardSchemaV1 } from '@ultimat3/schema';
11
13
  import type { QueryClientMethodOf } from './client';
12
14
  import { queryClientMethodFor } from './client';
15
+ import { queryContractTestsFor } from './contract-test';
13
16
  import { toLiveQuery } from './live';
14
17
  import { paginate } from './pagination';
15
18
  import type { Query, QueryDef, QueryFacade } from './query';
@@ -38,6 +41,10 @@ export function facadeFor<
38
41
  ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
39
42
  ...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }),
40
43
  ...(def.deprecated === undefined ? {} : { deprecated: def.deprecated }),
44
+ // Checked here, the one place a declaration is lifted: frozen and sorted, or refused.
45
+ ...(def.errors === undefined
46
+ ? {}
47
+ : { errors: checkDeclaredErrors(def.errors, declaredStatusFor, 'this query') }),
41
48
  // `.as()` is impersonation on the one read path: `runQuery` keeps the
42
49
  // surrounding context whole and swaps only the actor.
43
50
  as: (actor, input, options) => runQuery(self(), input, { ...options, actor }),
@@ -45,13 +52,14 @@ export function facadeFor<
45
52
  // signed cursor is only reachable through the query that issued it.
46
53
  page: (input, args) => paginate(self(), input, args),
47
54
  live: (input, options) => toLiveQuery(self(), input, options),
55
+ contract: (options) => queryContractTestsFor(self(), options),
48
56
  // One implementation for both wire shapes: the route decides the body, the transport parses
49
57
  // it, and only the TYPE differs — a single read's method answers the row, and has no `page`.
50
58
  client: (options) =>
51
- queryClientMethodFor<TInput, TRow>(queryName(self()), options) as QueryClientMethodOf<
52
- TInput,
53
- TRow,
54
- TSingle
55
- >,
59
+ queryClientMethodFor<TInput, TRow>(
60
+ queryName(self()),
61
+ options,
62
+ def.http?.path,
63
+ ) as QueryClientMethodOf<TInput, TRow, TSingle>,
56
64
  };
57
65
  }
@@ -0,0 +1,44 @@
1
+ // Single responsibility: a declared read's validator. The ETag is a strong hash of the bytes the
2
+ // policy already judged, keyed to the actor who was judged, and the 304 is `@ultimat3/http`'s
3
+ // `notModified` — the one `If-None-Match` reader, so this file never compares a header itself.
4
+
5
+ import { notModified, PRIVATE_REVALIDATE, type UltimateRequest } from '@ultimat3/http';
6
+ import { readAuthority } from './cache';
7
+
8
+ /**
9
+ * `"<sha-256, base64url>"` over the actor's authority and the body. The authority is in the hash so
10
+ * one actor's validator never confirms that another's rows are unchanged: two callers seeing the
11
+ * same bytes still hold two validators.
12
+ */
13
+ function etagOf(authority: string, body: ArrayBuffer): string {
14
+ const hasher = new Bun.CryptoHasher('sha256');
15
+ hasher.update(authority);
16
+ hasher.update('\0');
17
+ hasher.update(body);
18
+ return `"${hasher.digest('base64url')}"`;
19
+ }
20
+
21
+ /**
22
+ * The answer with its validator, or the bodiless 304 when the caller already holds it. Called with
23
+ * the response `runQuery` produced, so the policy and the rate limit have already run: a 304 is
24
+ * still a read of that person's rows. Only a 200 carries one — a refusal is never revalidated.
25
+ */
26
+ export async function withValidator(
27
+ request: UltimateRequest,
28
+ response: Response,
29
+ ): Promise<Response> {
30
+ if (response.status !== 200) return response;
31
+ const body = await response.arrayBuffer();
32
+ const etag = etagOf(readAuthority(request.ctx.actor, 'actor'), body);
33
+ const headers = new Headers(response.headers);
34
+ headers.set('cache-control', PRIVATE_REVALIDATE);
35
+ headers.set('etag', etag);
36
+ const unchanged = notModified(request, etag, headers);
37
+ if (unchanged !== undefined) {
38
+ // A 304 carries no body, and no header describing one.
39
+ unchanged.headers.delete('content-length');
40
+ unchanged.headers.delete('content-type');
41
+ return unchanged;
42
+ }
43
+ return new Response(body, { status: 200, headers });
44
+ }
@@ -0,0 +1,17 @@
1
+ // Single responsibility: `X_QUERY_HTTP_PATH_INVALID`, the refusal of a read's pinned `http.path`.
2
+ // Its own leaf over `@ultimat3/core/page`, so the browser read client can refuse a URL it cannot
3
+ // fill without loading `errors.ts`'s titles table (`client-bundle.test.ts`); the title is there.
4
+
5
+ import { UltimateError } from '@ultimat3/core/page';
6
+
7
+ export class QueryHttpPathInvalidError extends UltimateError {
8
+ constructor(input: { readonly name: string; readonly path: string; readonly reason: string }) {
9
+ super({
10
+ code: 'X_QUERY_HTTP_PATH_INVALID',
11
+ cause: `query${input.name === '' ? '' : ` "${input.name}"`} pins http.path ${input.path}, which ${input.reason}`,
12
+ // Short on purpose: this class rides the browser read client (`client-bundle.test.ts`).
13
+ fix: "query({ input: t.object({ id: t.string }), http: { path: '/orders/:id' }, ... }) — every :param a required t.string key of input:, and passed to client()",
14
+ meta: { query: input.name },
15
+ });
16
+ }
17
+ }
@@ -0,0 +1,67 @@
1
+ // Single responsibility: where a read is served — its pinned `http.path` (`/orders/:id`) when it
2
+ // declares one, else core's `queryPath(name)` — and the check of that pin at `query()`. The
3
+ // template rule is core's, shared with `@ultimat3/action`'s pins; only the code is this package's.
4
+
5
+ import {
6
+ pathTemplateProblem,
7
+ queryPath,
8
+ renderCauseValue,
9
+ templateKeyProblem,
10
+ templateParams,
11
+ } from '@ultimat3/core';
12
+ import { tryIntrospect } from '@ultimat3/schema';
13
+ import { QueryHttpPathInvalidError } from './http-pin-error';
14
+ import type { AnyQuery } from './query';
15
+ import { defOf } from './read';
16
+
17
+ /** What a read may say about its own HTTP projection. */
18
+ export interface QueryHttp {
19
+ /**
20
+ * Pin the URL instead of `/_x/query/<kebab>`: lowercase static segments and `:param`s, each a
21
+ * REQUIRED string key of `input:`. The URL's value joins the search string as the one input the
22
+ * schema parses; a search string naming the key differently is `X_HTTP_PATH_INPUT_CONFLICT`.
23
+ */
24
+ readonly path?: string;
25
+ /** A read is a `GET`; stated only for symmetry with an action's pin. */
26
+ readonly method?: 'GET';
27
+ }
28
+
29
+ /** Refuses at `query()` a pin no route could serve or no input could fill. */
30
+ export function assertQueryHttp(http: QueryHttp | undefined, input: unknown): void {
31
+ if (http === undefined) return;
32
+ const method: unknown = http.method;
33
+ const path: unknown = http.path;
34
+ const shown = renderCauseValue(path);
35
+ if (method !== undefined && method !== 'GET') {
36
+ throw new QueryHttpPathInvalidError({
37
+ name: '',
38
+ path: shown,
39
+ reason: `is declared with http.method ${renderCauseValue(method)}, and a read is a GET`,
40
+ });
41
+ }
42
+ if (path === undefined) {
43
+ if (method === undefined) return;
44
+ throw new QueryHttpPathInvalidError({
45
+ name: '',
46
+ path: shown,
47
+ reason: 'is absent, and http.method has no pinned path to answer on',
48
+ });
49
+ }
50
+ const reason =
51
+ pathTemplateProblem(path) ??
52
+ templateKeyProblem(templateParams(path as string), tryIntrospect(input)?.properties);
53
+ if (reason !== undefined) throw new QueryHttpPathInvalidError({ name: '', path: shown, reason });
54
+ }
55
+
56
+ /** The path `name` is served at: the pin, else the derived `/_x/query/<kebab>`. */
57
+ export function servedQueryPath(name: string, http: QueryHttp | undefined): string {
58
+ return http?.path ?? queryPath(name);
59
+ }
60
+
61
+ /** `{ path, method: 'GET' }` exactly when `target` pins its URL — the manifest's fact. */
62
+ export function queryHttpPinOf(
63
+ target: AnyQuery,
64
+ ): { readonly path: string; readonly method: 'GET' } | undefined {
65
+ const path = defOf(target).http?.path;
66
+ return path === undefined ? undefined : { path, method: 'GET' };
67
+ }