@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/src/errors.ts CHANGED
@@ -35,6 +35,9 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
35
35
  X_QUERY_DOWNLOAD_ONLY: 'rows were asked of a query that answers a file',
36
36
  X_QUERY_DOWNLOAD_TOKEN_INVALID: 'a download URL carries a token this app did not mint for it',
37
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',
38
41
  };
39
42
 
40
43
  /**
@@ -44,7 +47,15 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
44
47
  * re-declares it: two copies of a title are two titles, one of which is stale. `X_RPC_FAILED` left this list in
45
48
  * 21.0.0: the typed read client's wire failures are `@ultimat3/core`'s `clientTransport`'s now.
46
49
  */
47
- 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;
48
59
 
49
60
  // One unconditional call: a presence guard would turn "another package claims one of these codes"
50
61
  // from an X_ERROR_CODE_DUPLICATE at import into whichever module loaded first deciding the title.
@@ -158,12 +169,12 @@ export class QueryInputUnencodableError extends UltimateError {
158
169
  * the cause describes the declaration, exactly as `X_QUERY_INPUT_UNENCODABLE` does.
159
170
  */
160
171
  export class QueryCacheTtlInvalidError extends UltimateError {
161
- constructor(ttlMs: number) {
172
+ constructor(ttlMs: number, field: 'ttlMs' | 'negativeTtlMs' = 'ttlMs') {
162
173
  super({
163
174
  code: 'X_QUERY_CACHE_TTL_INVALID',
164
- cause: `a query declares cache.ttlMs as ${ttlMs}, and every cache tier refuses a lease that is not positive and finite`,
165
- fix: 'set `cache: { ttlMs: 60_000 }` to a positive whole number of milliseconds, or drop ttlMs to take the read cache default',
166
- 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 },
167
178
  });
168
179
  }
169
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
+ }
package/src/http.ts CHANGED
@@ -7,17 +7,26 @@
7
7
 
8
8
  import { tagKeys } from '@ultimat3/cache';
9
9
  import type { Deprecation } from '@ultimat3/core';
10
- import { queryPath, recordDeprecatedCall, renderDeprecation, useContext } from '@ultimat3/core';
10
+ import {
11
+ mergePathParams,
12
+ queryPath,
13
+ recordDeprecatedCall,
14
+ renderDeprecation,
15
+ templateParams,
16
+ useContext,
17
+ } from '@ultimat3/core';
11
18
  import type { RateLimitDecision, Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
12
19
  import { publishRateLimit, toBucket } from '@ultimat3/http';
13
20
  import { admitsAnonymous, policyCapability } from '@ultimat3/policy';
14
21
  import { coerceQuery } from '@ultimat3/schema';
15
22
  import { toDownloadRoute } from './download-http';
16
23
  import { QueryDeprecationInvalidError, QueryInputInvalidError } from './errors';
24
+ import { withValidator } from './http-etag';
25
+ import { servedQueryPath } from './http-pin';
17
26
  import { absentArraysOf } from './input-shape';
18
27
  import { PAGE_FIRST_KEY, pageControlsOf } from './page-controls';
19
28
  import type { AnyQuery, QueryRateLimit } from './query';
20
- import { queryName, runQuery } from './read';
29
+ import { defOf, queryName, runQuery } from './read';
21
30
  import { recordAnswerFor, recordRowAnswerFor } from './record-answer';
22
31
  import { oneRowOf } from './single-answer';
23
32
 
@@ -34,8 +43,19 @@ export function toQueryRoute(target: AnyQuery): Route {
34
43
  if (target.download === true) return toDownloadRoute(target, name, sunsetting);
35
44
  const answer = recordAnswerFor(target.rows);
36
45
  const answerRow = recordRowAnswerFor(target.rows);
46
+ // A pinned `http.path` replaces `/_x/query/<kebab>`; its `:param`s join the search string as
47
+ // the one input, and a search string naming one differently is `X_HTTP_PATH_INPUT_CONFLICT`.
48
+ const path = servedQueryPath(name, defOf(target).http);
49
+ const pinned = templateParams(path).length > 0;
50
+ const owner = `query "${name}"`;
51
+ const etagged = defOf(target).etag === true;
37
52
 
38
53
  const handler = async (request: UltimateRequest): Promise<Response> => {
54
+ const answered = await answerRead(request);
55
+ return etagged ? withValidator(request, answered) : answered;
56
+ };
57
+
58
+ const answerRead = async (request: UltimateRequest): Promise<Response> => {
39
59
  if (sunsetting !== undefined) {
40
60
  recordDeprecatedCall('query', name);
41
61
  // On the CONTEXT, before anything can fail: the pipeline's `response` stage merges
@@ -68,7 +88,19 @@ export function toQueryRoute(target: AnyQuery): Route {
68
88
  onRateLimit: (decision: RateLimitDecision) =>
69
89
  publishRateLimit(request.ctx, decision, useContext().now().getTime()),
70
90
  } as const;
71
- const { input: values, page } = pageControlsOf(name, request.queryRaw());
91
+ const search = request.queryRaw();
92
+ const { input: values, page } = pageControlsOf(
93
+ name,
94
+ pinned
95
+ ? // The search values plus string params: still `SearchValues` by construction.
96
+ (mergePathParams({
97
+ owner,
98
+ template: path,
99
+ params: request.params,
100
+ raw: search,
101
+ }) as typeof search)
102
+ : search,
103
+ );
72
104
  const input = absentArraysOf(target.input, coerceQuery(target.input, values));
73
105
  if (target.single === true) {
74
106
  // Refused, not ignored: a caller paging a read of one object has the wrong read in mind, and
@@ -122,6 +154,8 @@ export function toQueryRoute(target: AnyQuery): Route {
122
154
  // one actor's rows to the next caller of that URL, so a read is `no-store` and a
123
155
  // shared cache is something a CDN in front of the app configures knowingly. The tags
124
156
  // ride along so a purge can still name the read the tier keys by.
157
+ // `etag: true` is the declared exception: its handler writes `private, no-cache` itself
158
+ // (`http-etag.ts`), which the stage leaves alone, and this hint is never reached.
125
159
  cache: { mode: 'no-store', tags: tagKeys(target.cache?.tags ?? []) },
126
160
  tags: ['query'],
127
161
  // The read spends its own declared bucket on every surface (`read.ts`), so the stage spends
@@ -135,7 +169,7 @@ export function toQueryRoute(target: AnyQuery): Route {
135
169
  ...(target.mcp?.description === undefined ? {} : { description: target.mcp.description }),
136
170
  };
137
171
 
138
- return { method: 'GET', path: queryPath(name), handler, meta };
172
+ return { method: 'GET', path, handler, meta };
139
173
  }
140
174
 
141
175
  /** Validates the declaration through the limiter's own conversion, then hands the route its flag. */
package/src/index.ts CHANGED
@@ -68,12 +68,15 @@ export { downloadClientFor, queryClient, queryClientMethodFor } from './client';
68
68
  */
69
69
  export type { KindOf } from './column-kinds';
70
70
  export { kindsOf } from './column-kinds';
71
+ export type { QueryContractTest, QueryContractTestOptions } from './contract-test';
72
+ export { queryContractTestsFor } from './contract-test';
71
73
  /**
72
74
  * `query({ download })` — a read answered by a FILE at a short-lived signed URL. Still a `query`:
73
75
  * one policy, one name, one route (`GET /_x/query/<kebab>`: mint without `_token`, serve with it).
74
76
  */
75
77
  export type {
76
78
  DownloadArgs,
79
+ DownloadBodyOpener,
77
80
  DownloadClientMethod,
78
81
  DownloadFile,
79
82
  DownloadQuery,
@@ -110,10 +113,22 @@ export {
110
113
  QuerySubscribesInvalidError,
111
114
  QueryUnregisteredError,
112
115
  } from './errors';
116
+ export type {
117
+ QueryExerciseActorOptions,
118
+ QueryExerciseOffOptions,
119
+ QueryExerciseOptions,
120
+ QueryExerciseReport,
121
+ } from './exercise';
122
+ export { exerciseQuery } from './exercise';
113
123
  /** The HTTP projection: `GET /_x/query/<kebab>`, the URL `client()` derives. */
114
124
  export { toQueryRoute } from './http';
125
+ export type { QueryHttp } from './http-pin';
126
+ export { queryHttpPinOf } from './http-pin';
127
+ export { QueryHttpPathInvalidError } from './http-pin-error';
115
128
  export type { LiveCursor, LiveQuery, ResumeMode, ResumePlan, ToLiveOptions } from './live';
116
129
  export { planResume, seekOf, spendQueryLimit, toLiveQuery } from './live';
130
+ export type { LiveReadPhase } from './live-trace';
131
+ export { traceLiveRead } from './live-trace';
117
132
  export type { ChangeEvent, ChangeOp, Patch } from './matcher';
118
133
  export { assertMatchable, match, positionFor } from './matcher';
119
134
  /**
@@ -0,0 +1,36 @@
1
+ // Single responsibility: the span a LIVE read runs in, continued from the subscriber's own
2
+ // `traceparent`. The snapshot answers the subscribe, so it is that trace's child; a re-run answers
3
+ // a change, not a request, so it LINKS to the subscriber and parents on whatever caused it.
4
+
5
+ import { parseTraceparent, withSpan } from '@ultimat3/core';
6
+
7
+ /** Which read: the one a subscribe asked for, or one a change fanned out. */
8
+ export type LiveReadPhase = 'snapshot' | 'rerun';
9
+
10
+ /**
11
+ * Runs `read` inside `live.<phase> <query>`. `traceparent` is the value the client put on its
12
+ * `subscribe` frame; an invalid or absent one is IGNORED, as an unknown time zone is: a garbage
13
+ * header must cost the subscriber its trace, never its subscription.
14
+ */
15
+ export function traceLiveRead<T>(
16
+ query: string,
17
+ phase: LiveReadPhase,
18
+ traceparent: string | null | undefined,
19
+ read: () => Promise<T>,
20
+ ): Promise<T> {
21
+ const subscriber = parseTraceparent(traceparent);
22
+ const name = `live.${phase} ${query}`;
23
+ const attributes = { 'ultimate.primitive': 'query', 'ultimate.query': query };
24
+ if (phase === 'snapshot') {
25
+ return withSpan(name, read, {
26
+ kind: 'server',
27
+ attributes,
28
+ ...(subscriber === undefined ? {} : { parent: subscriber }),
29
+ });
30
+ }
31
+ return withSpan(name, read, {
32
+ kind: 'internal',
33
+ attributes,
34
+ links: subscriber === undefined ? [] : [subscriber],
35
+ });
36
+ }
package/src/live.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { tagKeys } from '@ultimat3/cache';
10
10
  import type { Ctx } from '@ultimat3/core';
11
+ import { appVersion } from '@ultimat3/core';
11
12
  import type { StandardSchemaV1 } from '@ultimat3/schema';
12
13
  import type { Patch } from './matcher';
13
14
  import { assertMatchable } from './matcher';
@@ -119,9 +120,9 @@ export function spendQueryLimit(
119
120
  return spendReadLimit(queryName(target), target.rateLimit, { ...caller, surface: 'live' });
120
121
  }
121
122
 
122
- /** Changing the build changes the epoch, which forces reconnects to refetch. */
123
+ /** Changing the build changes the epoch, which forces reconnects to refetch: core's `APP_VERSION`. */
123
124
  export function liveEpoch(): string {
124
- return Bun.env['X_BUILD_ID'] ?? 'dev';
125
+ return appVersion();
125
126
  }
126
127
 
127
128
  export async function toLiveQuery<TInput extends StandardSchemaV1, TRow extends object>(
@@ -0,0 +1,19 @@
1
+ // Single responsibility: a read operation's `responses` with its declared `errors:` in them —
2
+ // core's builder, bound to `@ultimat3/http`'s status table and Problem `type`; the `Problem` ref is
3
+ // `openapi.ts`'s own, handed in. Undeclared, the members are today's.
4
+
5
+ import { withDeclaredResponses } from '@ultimat3/core';
6
+ import { declaredStatusFor, problemTypeFor } from '@ultimat3/http';
7
+ import type { AnyQuery } from './query';
8
+
9
+ export function withDeclaredErrors(
10
+ target: AnyQuery,
11
+ problemRef: string,
12
+ responses: Readonly<Record<string, unknown>>,
13
+ ): Record<string, unknown> {
14
+ return withDeclaredResponses(responses, target.errors, {
15
+ statusOf: declaredStatusFor,
16
+ typeFor: problemTypeFor,
17
+ problemRef,
18
+ });
19
+ }
@@ -0,0 +1,43 @@
1
+ // Single responsibility: what `etag: true` adds to a read's OpenAPI operation — the `If-None-Match`
2
+ // header parameter, the `ETag` on the 200, and the bodiless 304. Applied after the operation is
3
+ // built, so the list and single operations stay one description each.
4
+
5
+ const IF_NONE_MATCH = {
6
+ name: 'If-None-Match',
7
+ in: 'header',
8
+ required: false,
9
+ description: 'The ETag of an answer this caller already holds; a match is 304 with no body.',
10
+ schema: { type: 'string' },
11
+ } as const;
12
+
13
+ const ETAG_HEADER = {
14
+ ETag: {
15
+ description: 'A strong validator of this answer, for this caller. Send it as If-None-Match.',
16
+ schema: { type: 'string' },
17
+ },
18
+ } as const;
19
+
20
+ type Operation = Readonly<Record<string, unknown>>;
21
+
22
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
23
+ typeof value === 'object' && value !== null && !Array.isArray(value);
24
+
25
+ /** `operation` documenting its validator. An operation with no 200 is returned unchanged. */
26
+ export function withValidatorDoc(operation: Operation): Operation {
27
+ const responses = operation['responses'];
28
+ const parameters = operation['parameters'];
29
+ if (!isRecord(responses) || !Array.isArray(parameters)) return operation;
30
+ const ok = responses['200'];
31
+ if (!isRecord(ok)) return operation;
32
+ const headers = isRecord(ok['headers']) ? ok['headers'] : {};
33
+ return {
34
+ ...operation,
35
+ // Last, after the page controls: a header is not one of the read's input members.
36
+ parameters: [...parameters, IF_NONE_MATCH],
37
+ responses: {
38
+ ...responses,
39
+ '200': { ...ok, headers: { ...headers, ...ETAG_HEADER } },
40
+ '304': { description: 'not modified — the If-None-Match validator still names this answer' },
41
+ },
42
+ };
43
+ }