@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/src/http.ts CHANGED
@@ -7,16 +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';
22
+ import { toDownloadRoute } from './download-http';
15
23
  import { QueryDeprecationInvalidError, QueryInputInvalidError } from './errors';
24
+ import { withValidator } from './http-etag';
25
+ import { servedQueryPath } from './http-pin';
16
26
  import { absentArraysOf } from './input-shape';
17
27
  import { PAGE_FIRST_KEY, pageControlsOf } from './page-controls';
18
28
  import type { AnyQuery, QueryRateLimit } from './query';
19
- import { queryName, runQuery } from './read';
29
+ import { defOf, queryName, runQuery } from './read';
20
30
  import { recordAnswerFor, recordRowAnswerFor } from './record-answer';
21
31
  import { oneRowOf } from './single-answer';
22
32
 
@@ -30,10 +40,22 @@ export function toQueryRoute(target: AnyQuery): Route {
30
40
  // Rendered ONCE, at projection: a date that cannot become a header is a mount-time refusal,
31
41
  // not a surprise on the first read.
32
42
  const sunsetting = deprecationHeadersFor(name, target.deprecated);
43
+ if (target.download === true) return toDownloadRoute(target, name, sunsetting);
33
44
  const answer = recordAnswerFor(target.rows);
34
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;
35
52
 
36
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> => {
37
59
  if (sunsetting !== undefined) {
38
60
  recordDeprecatedCall('query', name);
39
61
  // On the CONTEXT, before anything can fail: the pipeline's `response` stage merges
@@ -66,7 +88,19 @@ export function toQueryRoute(target: AnyQuery): Route {
66
88
  onRateLimit: (decision: RateLimitDecision) =>
67
89
  publishRateLimit(request.ctx, decision, useContext().now().getTime()),
68
90
  } as const;
69
- 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
+ );
70
104
  const input = absentArraysOf(target.input, coerceQuery(target.input, values));
71
105
  if (target.single === true) {
72
106
  // Refused, not ignored: a caller paging a read of one object has the wrong read in mind, and
@@ -120,6 +154,8 @@ export function toQueryRoute(target: AnyQuery): Route {
120
154
  // one actor's rows to the next caller of that URL, so a read is `no-store` and a
121
155
  // shared cache is something a CDN in front of the app configures knowingly. The tags
122
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.
123
159
  cache: { mode: 'no-store', tags: tagKeys(target.cache?.tags ?? []) },
124
160
  tags: ['query'],
125
161
  // The read spends its own declared bucket on every surface (`read.ts`), so the stage spends
@@ -133,11 +169,11 @@ export function toQueryRoute(target: AnyQuery): Route {
133
169
  ...(target.mcp?.description === undefined ? {} : { description: target.mcp.description }),
134
170
  };
135
171
 
136
- return { method: 'GET', path: queryPath(name), handler, meta };
172
+ return { method: 'GET', path, handler, meta };
137
173
  }
138
174
 
139
175
  /** Validates the declaration through the limiter's own conversion, then hands the route its flag. */
140
- function rateLimitedByHandler(name: string, limit: QueryRateLimit): 'handler' {
176
+ export function rateLimitedByHandler(name: string, limit: QueryRateLimit): 'handler' {
141
177
  toBucket(name, limit);
142
178
  return 'handler';
143
179
  }
package/src/index.ts CHANGED
@@ -47,6 +47,7 @@ export {
47
47
  requestMemo,
48
48
  } from './cache';
49
49
  export type {
50
+ DownloadUrl,
50
51
  FetchLike,
51
52
  QueryCallOptions,
52
53
  QueryClient,
@@ -58,7 +59,7 @@ export type {
58
59
  QuerySingleClientMethod,
59
60
  } from './client';
60
61
  /** `queryClient` is the map-wide read client; `queryClientMethodFor` is what `.client()` binds. */
61
- export { queryClient, queryClientMethodFor } from './client';
62
+ export { downloadClientFor, queryClient, queryClientMethodFor } from './client';
62
63
  /**
63
64
  * `isNull` is the one definition of SQL NULL a custom `SqlSource` has to agree with, and
64
65
  * `totalOrder` is the one definition of the order it must serve a page in. `compareRows`,
@@ -67,6 +68,33 @@ export { queryClient, queryClientMethodFor } from './client';
67
68
  */
68
69
  export type { KindOf } from './column-kinds';
69
70
  export { kindsOf } from './column-kinds';
71
+ export type { QueryContractTest, QueryContractTestOptions } from './contract-test';
72
+ export { queryContractTestsFor } from './contract-test';
73
+ /**
74
+ * `query({ download })` — a read answered by a FILE at a short-lived signed URL. Still a `query`:
75
+ * one policy, one name, one route (`GET /_x/query/<kebab>`: mint without `_token`, serve with it).
76
+ */
77
+ export type {
78
+ DownloadArgs,
79
+ DownloadBodyOpener,
80
+ DownloadClientMethod,
81
+ DownloadFile,
82
+ DownloadQuery,
83
+ DownloadQueryDef,
84
+ QueryDownload,
85
+ } from './download';
86
+ export {
87
+ DEFAULT_DOWNLOAD_TTL_MS,
88
+ MAX_DOWNLOAD_TTL_MS,
89
+ MIN_DOWNLOAD_TTL_MS,
90
+ } from './download';
91
+ export type { DownloadTokenProblem } from './download-errors';
92
+ export {
93
+ QueryDownloadInvalidError,
94
+ QueryDownloadOnlyError,
95
+ QueryDownloadTokenExpiredError,
96
+ QueryDownloadTokenInvalidError,
97
+ } from './download-errors';
70
98
  export {
71
99
  CursorValueUnsupportedError,
72
100
  MatcherUnsupportedError,
@@ -85,10 +113,22 @@ export {
85
113
  QuerySubscribesInvalidError,
86
114
  QueryUnregisteredError,
87
115
  } from './errors';
116
+ export type {
117
+ QueryExerciseActorOptions,
118
+ QueryExerciseOffOptions,
119
+ QueryExerciseOptions,
120
+ QueryExerciseReport,
121
+ } from './exercise';
122
+ export { exerciseQuery } from './exercise';
88
123
  /** The HTTP projection: `GET /_x/query/<kebab>`, the URL `client()` derives. */
89
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';
90
128
  export type { LiveCursor, LiveQuery, ResumeMode, ResumePlan, ToLiveOptions } from './live';
91
129
  export { planResume, seekOf, spendQueryLimit, toLiveQuery } from './live';
130
+ export type { LiveReadPhase } from './live-trace';
131
+ export { traceLiveRead } from './live-trace';
92
132
  export type { ChangeEvent, ChangeOp, Patch } from './matcher';
93
133
  export { assertMatchable, match, positionFor } from './matcher';
94
134
  /**
@@ -16,6 +16,7 @@
16
16
  // input cannot be written.
17
17
 
18
18
  import { type SchemaNode, tryIntrospect } from '@ultimat3/schema';
19
+ import { DOWNLOAD_GRANT_PARAM } from './download-token';
19
20
  import { QueryInputUnencodableError } from './errors';
20
21
  import { PAGE_CONTROL_KEYS } from './page-controls';
21
22
 
@@ -34,6 +35,9 @@ function unencodable(node: SchemaNode): string | undefined {
34
35
  if (PAGE_CONTROL_KEYS.includes(key)) {
35
36
  return `${key} is reserved — the route reads it as a page control (see query.page())`;
36
37
  }
38
+ if (key === DOWNLOAD_GRANT_PARAM) {
39
+ return `${key} is reserved — a download's route reads it as the signed token (see query.url())`;
40
+ }
37
41
  // Required AND nullable: `searchOf` sends nothing for a `null`, and absence is what the far
38
42
  // side then sees — so the value the caller explicitly chose fails validation on arrival.
39
43
  // Optional or defaulted, absence is already the schema's own answer and nothing is lost.
@@ -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
+ }
package/src/openapi.ts CHANGED
@@ -18,17 +18,22 @@
18
18
  import { tagKeys } from '@ultimat3/cache';
19
19
  import {
20
20
  isMcpExposed,
21
- queryPath,
22
21
  RECORDS_OPENAPI_HEADER,
23
22
  recordEnvelopeSchema,
23
+ templateParams,
24
+ toOpenApiPath,
24
25
  } from '@ultimat3/core';
25
26
  import { MAX_PAGE_SIZE } from '@ultimat3/entity';
26
27
  import { policyCapability } from '@ultimat3/policy';
27
28
  import type { SchemaNode } from '@ultimat3/schema';
28
- import { nodeToJsonSchema, tryIntrospect } from '@ultimat3/schema';
29
+ import { nodeToJsonSchema, toWireOutputSchema, tryIntrospect } from '@ultimat3/schema';
30
+ import { DOWNLOAD_GRANT_PARAM } from './download-token';
31
+ import { servedQueryPath } from './http-pin';
32
+ import { withDeclaredErrors } from './openapi-errors';
33
+ import { withValidatorDoc } from './openapi-etag';
29
34
  import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-controls';
30
35
  import type { AnyQuery } from './query';
31
- import { queryName } from './read';
36
+ import { defOf, queryName } from './read';
32
37
  import { answersRecords } from './record-answer';
33
38
  import { listQueries } from './registry';
34
39
 
@@ -43,33 +48,53 @@ const QUERY_TAG = 'query';
43
48
 
44
49
  export type OpenApiPathItem = Record<string, unknown>;
45
50
 
51
+ /** Today's answer for a read that publishes no row: any item, any object. */
52
+ const UNTYPED_ITEM: Readonly<Record<string, unknown>> = {};
53
+ const UNTYPED_ROW: Readonly<Record<string, unknown>> = { type: 'object' };
54
+
55
+ /**
56
+ * The one row a read declares, or `undefined` when it declares none or none this package can read
57
+ * — the narrowing `@ultimat3/mcp` publishes as the same read's `outputSchema`, so the two surfaces
58
+ * cannot disagree. STRUCTURE only: a read's rows are never re-parsed, so a bound or a closed object
59
+ * is a promise nothing enforces on the way out. Inline, never a component: the CLI merges only
60
+ * `paths` from this package (`packages/cli/src/app-openapi.ts`).
61
+ */
62
+ const rowOf = (target: AnyQuery): Readonly<Record<string, unknown>> | undefined => {
63
+ const row = toWireOutputSchema(target.rows);
64
+ return row === undefined ? undefined : { ...row };
65
+ };
66
+
46
67
  /**
47
68
  * Two shapes, decided by the request: the bare rows every client read before the controls
48
69
  * existed, and the page envelope when `_first` is sent. `oneOf` and not a second operation — it is
49
70
  * one route, one policy evaluation, one URL prefix.
50
71
  */
51
- const ANSWERS: readonly Record<string, unknown>[] = [
52
- { type: 'array', items: {} },
53
- {
54
- type: 'object',
55
- // The one page shape: the `endCursor`/`hasNextPage` alias pair left in 25.0.0 (O-12).
56
- required: ['rows', 'nextCursor', 'hasMore'],
57
- properties: {
58
- rows: { type: 'array', items: {} },
59
- nextCursor: {
60
- type: ['string', 'null'],
61
- description: `the signed cursor for the next page — send it as ${PAGE_AFTER_KEY}; null exactly when hasMore is false`,
72
+ function listAnswers(
73
+ item: Readonly<Record<string, unknown>> = UNTYPED_ITEM,
74
+ ): readonly Record<string, unknown>[] {
75
+ return [
76
+ { type: 'array', items: item },
77
+ {
78
+ type: 'object',
79
+ // The one page shape: the `endCursor`/`hasNextPage` alias pair left in 25.0.0 (O-12).
80
+ required: ['rows', 'nextCursor', 'hasMore'],
81
+ properties: {
82
+ rows: { type: 'array', items: item },
83
+ nextCursor: {
84
+ type: ['string', 'null'],
85
+ description: `the signed cursor for the next page — send it as ${PAGE_AFTER_KEY}; null exactly when hasMore is false`,
86
+ },
87
+ hasMore: { type: 'boolean', description: 'whether another page follows' },
62
88
  },
63
- hasMore: { type: 'boolean', description: 'whether another page follows' },
89
+ // `@ultimat3/core`'s `Page` is a union, and so is its wire shape: the two facts cannot
90
+ // disagree in a body this document validates (25.0.0 — the last page used to keep a cursor).
91
+ oneOf: [
92
+ { properties: { nextCursor: { type: 'string' }, hasMore: { const: true } } },
93
+ { properties: { nextCursor: { type: 'null' }, hasMore: { const: false } } },
94
+ ],
64
95
  },
65
- // `@ultimat3/core`'s `Page` is a union, and so is its wire shape: the two facts cannot
66
- // disagree in a body this document validates (25.0.0 — the last page used to keep a cursor).
67
- oneOf: [
68
- { properties: { nextCursor: { type: 'string' }, hasMore: { const: true } } },
69
- { properties: { nextCursor: { type: 'null' }, hasMore: { const: false } } },
70
- ],
71
- },
72
- ];
96
+ ];
97
+ }
73
98
 
74
99
  /** `{ [path]: { get: operation } }` for every registered read, name-sorted like the actions. */
75
100
  export function queryOpenApiPaths(
@@ -77,11 +102,14 @@ export function queryOpenApiPaths(
77
102
  ): Record<string, OpenApiPathItem> {
78
103
  const paths: Record<string, OpenApiPathItem> = {};
79
104
  for (const target of [...queries].sort(compareByName)) {
80
- paths[queryPath(queryName(target))] = { get: toQueryOpenApiOperation(target) };
105
+ paths[toOpenApiPath(pathOf(target))] = { get: toQueryOpenApiOperation(target) };
81
106
  }
82
107
  return paths;
83
108
  }
84
109
 
110
+ /** Where the read is served: its pin (a `:param` template), else `/_x/query/<kebab>`. */
111
+ const pathOf = (target: AnyQuery): string => servedQueryPath(queryName(target), defOf(target).http);
112
+
85
113
  /**
86
114
  * The GET operation for one read. The input's members are `in: query` parameters — a read is
87
115
  * served from its search string (`input-shape.ts` is what guarantees every member fits one) — and
@@ -90,14 +118,21 @@ export function queryOpenApiPaths(
90
118
  */
91
119
  export function toQueryOpenApiOperation(target: AnyQuery): Record<string, unknown> {
92
120
  const name = queryName(target);
121
+ if (target.download === true) return downloadOperation(target, name);
122
+ const operation = rowsOperation(target, name);
123
+ return defOf(target).etag === true ? { ...withValidatorDoc(operation) } : operation;
124
+ }
125
+
126
+ function rowsOperation(target: AnyQuery, name: string): Record<string, unknown> {
93
127
  if (target.single === true) return singleOperation(target, name);
128
+ const answers = { oneOf: listAnswers(rowOf(target)) };
94
129
  return {
95
130
  operationId: name,
96
131
  tags: [QUERY_TAG],
97
132
  summary: target.mcp?.description ?? name,
98
133
  ...(target.deprecated === undefined ? {} : { deprecated: true }),
99
- parameters: [...inputParameters(target.input), ...PAGE_PARAMETERS],
100
- responses: {
134
+ parameters: [...inputParameters(target), ...PAGE_PARAMETERS],
135
+ responses: withDeclaredErrors(target, PROBLEM_SCHEMA_REF, {
101
136
  '200': {
102
137
  description: 'ok',
103
138
  content: {
@@ -107,9 +142,7 @@ export function toQueryOpenApiOperation(target: AnyQuery): Record<string, unknow
107
142
  // second operation — it is one route, one policy evaluation, one URL prefix.
108
143
  // A read declaring an entity's `rows:` ALWAYS answers the envelope, with the same two
109
144
  // shapes under `data` — core's one description, shared with the action projection.
110
- schema: answersRecords(target.rows)
111
- ? recordEnvelopeSchema({ oneOf: ANSWERS })
112
- : { oneOf: ANSWERS },
145
+ schema: answersRecords(target.rows) ? recordEnvelopeSchema(answers) : answers,
113
146
  },
114
147
  },
115
148
  ...(answersRecords(target.rows) ? { headers: RECORDS_OPENAPI_HEADER } : {}),
@@ -117,9 +150,9 @@ export function toQueryOpenApiOperation(target: AnyQuery): Record<string, unknow
117
150
  // `X_INPUT_INVALID` is the search string failing the read's own schema, or a page control
118
151
  // outside its bound; `X_CURSOR_INVALID` is an `_after` that is not one of this read's. Both
119
152
  // are the caller's to repair, which is what 400 says.
120
- '400': problemResponse('X_INPUT_INVALID or X_CURSOR_INVALID'),
153
+ '400': problemResponse(`X_INPUT_INVALID or X_CURSOR_INVALID${conflictOf(target)}`),
121
154
  '403': problemResponse('policy denied'),
122
- },
155
+ }),
123
156
  'x-ultimate': {
124
157
  capability: policyCapability(target.policy),
125
158
  live: target.isLive,
@@ -138,26 +171,27 @@ export function toQueryOpenApiOperation(target: AnyQuery): Record<string, unknow
138
171
  */
139
172
  function singleOperation(target: AnyQuery, name: string): Record<string, unknown> {
140
173
  const records = answersRecords(target.rows);
174
+ const answer = rowOf(target) ?? UNTYPED_ROW;
141
175
  return {
142
176
  operationId: name,
143
177
  tags: [QUERY_TAG],
144
178
  summary: target.mcp?.description ?? name,
145
179
  ...(target.deprecated === undefined ? {} : { deprecated: true }),
146
- parameters: inputParameters(target.input),
147
- responses: {
180
+ parameters: inputParameters(target),
181
+ responses: withDeclaredErrors(target, PROBLEM_SCHEMA_REF, {
148
182
  '200': {
149
183
  description: 'ok',
150
184
  content: {
151
185
  'application/json': {
152
- schema: records ? recordEnvelopeSchema(SINGLE_ANSWER) : SINGLE_ANSWER,
186
+ schema: records ? recordEnvelopeSchema(answer) : answer,
153
187
  },
154
188
  },
155
189
  ...(records ? { headers: RECORDS_OPENAPI_HEADER } : {}),
156
190
  },
157
- '400': problemResponse('X_INPUT_INVALID'),
191
+ '400': problemResponse(`X_INPUT_INVALID${conflictOf(target)}`),
158
192
  '403': problemResponse('policy denied'),
159
193
  '404': problemResponse('X_NOT_FOUND — the read matched no row for this input'),
160
- },
194
+ }),
161
195
  'x-ultimate': {
162
196
  capability: policyCapability(target.policy),
163
197
  live: target.isLive,
@@ -168,8 +202,67 @@ function singleOperation(target: AnyQuery, name: string): Record<string, unknown
168
202
  };
169
203
  }
170
204
 
171
- /** One row. Its members are not published — a query declares no output schema, as `ANSWERS` says. */
172
- const SINGLE_ANSWER: Readonly<Record<string, unknown>> = { type: 'object' };
205
+ /**
206
+ * A download: one URL, two answers decided by `_token` — the mint (`{ url, expiresAt }`, JSON) and
207
+ * the file (bytes, a `Range:` window as 206). No page controls, no rows, no MCP tool.
208
+ */
209
+ function downloadOperation(target: AnyQuery, name: string): Record<string, unknown> {
210
+ return {
211
+ operationId: name,
212
+ tags: [QUERY_TAG],
213
+ summary: name,
214
+ ...(target.deprecated === undefined ? {} : { deprecated: true }),
215
+ parameters: [
216
+ ...inputParameters(target),
217
+ {
218
+ name: DOWNLOAD_GRANT_PARAM,
219
+ in: 'query',
220
+ required: false,
221
+ description: 'absent: mint a signed URL for this input; present: the file that URL grants',
222
+ schema: { type: 'string' },
223
+ },
224
+ ],
225
+ responses: {
226
+ '200': {
227
+ description: `without ${DOWNLOAD_GRANT_PARAM}, the minted URL; with it, the file`,
228
+ content: {
229
+ 'application/json': { schema: MINTED_URL },
230
+ // The file's own type — whatever `handle` answered — so any media type, as bytes.
231
+ '*/*': { schema: FILE_BYTES },
232
+ },
233
+ },
234
+ '206': {
235
+ description: 'the one byte range the Range header named, with content-range',
236
+ content: { '*/*': { schema: FILE_BYTES } },
237
+ },
238
+ '400': problemResponse('X_INPUT_INVALID'),
239
+ '401': problemResponse(
240
+ 'X_UNAUTHENTICATED — a mint with no session, under a policy that needs one',
241
+ ),
242
+ '403': problemResponse('policy denied, or X_QUERY_DOWNLOAD_TOKEN_INVALID'),
243
+ '410': problemResponse('X_QUERY_DOWNLOAD_TOKEN_EXPIRED — mint again'),
244
+ '416': { description: 'the Range header starts past the end of the file' },
245
+ },
246
+ 'x-ultimate': {
247
+ capability: policyCapability(target.policy),
248
+ live: false,
249
+ download: true,
250
+ cacheTags: [],
251
+ tool: null,
252
+ },
253
+ };
254
+ }
255
+
256
+ const FILE_BYTES: Readonly<Record<string, unknown>> = { type: 'string', format: 'binary' };
257
+
258
+ const MINTED_URL: Readonly<Record<string, unknown>> = {
259
+ type: 'object',
260
+ required: ['url', 'expiresAt'],
261
+ properties: {
262
+ url: { type: 'string', description: `this path, with the input and ${DOWNLOAD_GRANT_PARAM}` },
263
+ expiresAt: { type: 'string', format: 'date-time' },
264
+ },
265
+ };
173
266
 
174
267
  /** The two controls, spelled from `page-controls.ts` so the document cannot drift from the route. */
175
268
  const PAGE_PARAMETERS: readonly Record<string, unknown>[] = [
@@ -189,24 +282,44 @@ const PAGE_PARAMETERS: readonly Record<string, unknown>[] = [
189
282
  },
190
283
  ];
191
284
 
285
+ /** A pinned read's 400 also names the path/search disagreement; an unpinned one's bytes stay. */
286
+ const conflictOf = (target: AnyQuery): string =>
287
+ templateParams(pathOf(target)).length === 0 ? '' : ' or X_HTTP_PATH_INPUT_CONFLICT';
288
+
192
289
  /**
193
- * A member per declared property, name-sorted. A schema this package cannot introspect — a
194
- * foreign Standard Schema, the seam `configureSchemaProvider` exists for — publishes no
195
- * parameters rather than guessed ones, the same answer `assertEncodableInput` gives it.
290
+ * A member per declared property: the pin's `:param`s first, `in: path` and required, in template
291
+ * order; the rest `in: query`, name-sorted. A schema this package cannot introspect — a foreign
292
+ * Standard Schema, the seam `configureSchemaProvider` exists for — publishes no parameters rather
293
+ * than guessed ones, the same answer `assertEncodableInput` gives it.
196
294
  */
197
- function inputParameters(input: unknown): readonly Record<string, unknown>[] {
198
- const node = tryIntrospect(input);
295
+ function inputParameters(target: AnyQuery): readonly Record<string, unknown>[] {
296
+ const node = tryIntrospect(target.input);
199
297
  if (node?.properties === undefined) return [];
200
- return Object.entries(node.properties)
201
- .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
202
- .map(([key, child]) => parameterOf(key, child));
298
+ const properties = node.properties;
299
+ const inPath = templateParams(pathOf(target));
300
+ const fromPath = inPath.flatMap((key) => {
301
+ const child = Object.hasOwn(properties, key) ? properties[key] : undefined;
302
+ return child === undefined ? [] : [parameterOf(key, child, 'path')];
303
+ });
304
+ return [
305
+ ...fromPath,
306
+ ...Object.entries(properties)
307
+ .filter(([key]) => !inPath.includes(key))
308
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
309
+ .map(([key, child]) => parameterOf(key, child, 'query')),
310
+ ];
203
311
  }
204
312
 
205
- function parameterOf(name: string, node: SchemaNode): Record<string, unknown> {
313
+ function parameterOf(
314
+ name: string,
315
+ node: SchemaNode,
316
+ where: 'path' | 'query',
317
+ ): Record<string, unknown> {
206
318
  return {
207
319
  name,
208
- in: 'query',
209
- required: node.optional !== true && node.hasDefault !== true,
320
+ in: where,
321
+ // A path parameter is required by OpenAPI's rule, and by `assertQueryHttp`'s at `query()`.
322
+ required: where === 'path' || (node.optional !== true && node.hasDefault !== true),
210
323
  schema: nodeToJsonSchema(node),
211
324
  };
212
325
  }