@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 +32 -4
- package/README.md +103 -1
- package/package.json +18 -7
- package/src/cache.ts +15 -3
- package/src/client.ts +77 -56
- package/src/contract-test.ts +59 -0
- package/src/declared-errors-contract.ts +34 -0
- package/src/declared-errors.ts +26 -0
- package/src/download-errors.ts +65 -0
- package/src/download-http.ts +237 -0
- package/src/download-token.ts +216 -0
- package/src/download.ts +261 -0
- package/src/errors.ts +20 -5
- package/src/exercise.ts +156 -0
- package/src/facade.ts +13 -5
- package/src/http-etag.ts +44 -0
- package/src/http-pin-error.ts +17 -0
- package/src/http-pin.ts +67 -0
- package/src/http.ts +41 -5
- package/src/index.ts +41 -1
- package/src/input-shape.ts +4 -0
- package/src/live-trace.ts +36 -0
- package/src/live.ts +3 -2
- package/src/openapi-errors.ts +19 -0
- package/src/openapi-etag.ts +43 -0
- package/src/openapi.ts +162 -49
- package/src/query.ts +78 -13
- package/src/read-freshness.ts +63 -0
- package/src/read.ts +108 -24
- package/src/record-answer.ts +18 -6
- package/src/registry.ts +4 -0
- package/src/search-string.ts +47 -0
- package/src/sql.ts +2 -1
- package/src/type-pins.ts +45 -0
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 {
|
|
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
|
|
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
|
|
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
|
/**
|
package/src/input-shape.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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[
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
186
|
+
schema: records ? recordEnvelopeSchema(answer) : answer,
|
|
153
187
|
},
|
|
154
188
|
},
|
|
155
189
|
...(records ? { headers: RECORDS_OPENAPI_HEADER } : {}),
|
|
156
190
|
},
|
|
157
|
-
'400': problemResponse(
|
|
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
|
-
/**
|
|
172
|
-
|
|
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
|
|
194
|
-
*
|
|
195
|
-
*
|
|
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(
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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(
|
|
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:
|
|
209
|
-
required
|
|
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
|
}
|