@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/CLAUDE.md +14 -5
- package/README.md +38 -3
- package/package.json +18 -7
- package/src/cache.ts +15 -3
- package/src/client.ts +32 -8
- 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-http.ts +38 -27
- package/src/download.ts +28 -9
- package/src/errors.ts +16 -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 +38 -4
- package/src/index.ts +15 -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 +100 -51
- package/src/query.ts +41 -14
- package/src/read-freshness.ts +63 -0
- package/src/read.ts +69 -18
- package/src/record-answer.ts +18 -6
- package/src/registry.ts +4 -0
package/src/openapi.ts
CHANGED
|
@@ -18,18 +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';
|
|
29
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';
|
|
30
34
|
import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-controls';
|
|
31
35
|
import type { AnyQuery } from './query';
|
|
32
|
-
import { queryName } from './read';
|
|
36
|
+
import { defOf, queryName } from './read';
|
|
33
37
|
import { answersRecords } from './record-answer';
|
|
34
38
|
import { listQueries } from './registry';
|
|
35
39
|
|
|
@@ -44,33 +48,53 @@ const QUERY_TAG = 'query';
|
|
|
44
48
|
|
|
45
49
|
export type OpenApiPathItem = Record<string, unknown>;
|
|
46
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
|
+
|
|
47
67
|
/**
|
|
48
68
|
* Two shapes, decided by the request: the bare rows every client read before the controls
|
|
49
69
|
* existed, and the page envelope when `_first` is sent. `oneOf` and not a second operation — it is
|
|
50
70
|
* one route, one policy evaluation, one URL prefix.
|
|
51
71
|
*/
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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' },
|
|
63
88
|
},
|
|
64
|
-
|
|
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
|
+
],
|
|
65
95
|
},
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
oneOf: [
|
|
69
|
-
{ properties: { nextCursor: { type: 'string' }, hasMore: { const: true } } },
|
|
70
|
-
{ properties: { nextCursor: { type: 'null' }, hasMore: { const: false } } },
|
|
71
|
-
],
|
|
72
|
-
},
|
|
73
|
-
];
|
|
96
|
+
];
|
|
97
|
+
}
|
|
74
98
|
|
|
75
99
|
/** `{ [path]: { get: operation } }` for every registered read, name-sorted like the actions. */
|
|
76
100
|
export function queryOpenApiPaths(
|
|
@@ -78,11 +102,14 @@ export function queryOpenApiPaths(
|
|
|
78
102
|
): Record<string, OpenApiPathItem> {
|
|
79
103
|
const paths: Record<string, OpenApiPathItem> = {};
|
|
80
104
|
for (const target of [...queries].sort(compareByName)) {
|
|
81
|
-
paths[
|
|
105
|
+
paths[toOpenApiPath(pathOf(target))] = { get: toQueryOpenApiOperation(target) };
|
|
82
106
|
}
|
|
83
107
|
return paths;
|
|
84
108
|
}
|
|
85
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
|
+
|
|
86
113
|
/**
|
|
87
114
|
* The GET operation for one read. The input's members are `in: query` parameters — a read is
|
|
88
115
|
* served from its search string (`input-shape.ts` is what guarantees every member fits one) — and
|
|
@@ -92,14 +119,20 @@ export function queryOpenApiPaths(
|
|
|
92
119
|
export function toQueryOpenApiOperation(target: AnyQuery): Record<string, unknown> {
|
|
93
120
|
const name = queryName(target);
|
|
94
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> {
|
|
95
127
|
if (target.single === true) return singleOperation(target, name);
|
|
128
|
+
const answers = { oneOf: listAnswers(rowOf(target)) };
|
|
96
129
|
return {
|
|
97
130
|
operationId: name,
|
|
98
131
|
tags: [QUERY_TAG],
|
|
99
132
|
summary: target.mcp?.description ?? name,
|
|
100
133
|
...(target.deprecated === undefined ? {} : { deprecated: true }),
|
|
101
|
-
parameters: [...inputParameters(target
|
|
102
|
-
responses: {
|
|
134
|
+
parameters: [...inputParameters(target), ...PAGE_PARAMETERS],
|
|
135
|
+
responses: withDeclaredErrors(target, PROBLEM_SCHEMA_REF, {
|
|
103
136
|
'200': {
|
|
104
137
|
description: 'ok',
|
|
105
138
|
content: {
|
|
@@ -109,9 +142,7 @@ export function toQueryOpenApiOperation(target: AnyQuery): Record<string, unknow
|
|
|
109
142
|
// second operation — it is one route, one policy evaluation, one URL prefix.
|
|
110
143
|
// A read declaring an entity's `rows:` ALWAYS answers the envelope, with the same two
|
|
111
144
|
// shapes under `data` — core's one description, shared with the action projection.
|
|
112
|
-
schema: answersRecords(target.rows)
|
|
113
|
-
? recordEnvelopeSchema({ oneOf: ANSWERS })
|
|
114
|
-
: { oneOf: ANSWERS },
|
|
145
|
+
schema: answersRecords(target.rows) ? recordEnvelopeSchema(answers) : answers,
|
|
115
146
|
},
|
|
116
147
|
},
|
|
117
148
|
...(answersRecords(target.rows) ? { headers: RECORDS_OPENAPI_HEADER } : {}),
|
|
@@ -119,9 +150,9 @@ export function toQueryOpenApiOperation(target: AnyQuery): Record<string, unknow
|
|
|
119
150
|
// `X_INPUT_INVALID` is the search string failing the read's own schema, or a page control
|
|
120
151
|
// outside its bound; `X_CURSOR_INVALID` is an `_after` that is not one of this read's. Both
|
|
121
152
|
// are the caller's to repair, which is what 400 says.
|
|
122
|
-
'400': problemResponse(
|
|
153
|
+
'400': problemResponse(`X_INPUT_INVALID or X_CURSOR_INVALID${conflictOf(target)}`),
|
|
123
154
|
'403': problemResponse('policy denied'),
|
|
124
|
-
},
|
|
155
|
+
}),
|
|
125
156
|
'x-ultimate': {
|
|
126
157
|
capability: policyCapability(target.policy),
|
|
127
158
|
live: target.isLive,
|
|
@@ -140,26 +171,27 @@ export function toQueryOpenApiOperation(target: AnyQuery): Record<string, unknow
|
|
|
140
171
|
*/
|
|
141
172
|
function singleOperation(target: AnyQuery, name: string): Record<string, unknown> {
|
|
142
173
|
const records = answersRecords(target.rows);
|
|
174
|
+
const answer = rowOf(target) ?? UNTYPED_ROW;
|
|
143
175
|
return {
|
|
144
176
|
operationId: name,
|
|
145
177
|
tags: [QUERY_TAG],
|
|
146
178
|
summary: target.mcp?.description ?? name,
|
|
147
179
|
...(target.deprecated === undefined ? {} : { deprecated: true }),
|
|
148
|
-
parameters: inputParameters(target
|
|
149
|
-
responses: {
|
|
180
|
+
parameters: inputParameters(target),
|
|
181
|
+
responses: withDeclaredErrors(target, PROBLEM_SCHEMA_REF, {
|
|
150
182
|
'200': {
|
|
151
183
|
description: 'ok',
|
|
152
184
|
content: {
|
|
153
185
|
'application/json': {
|
|
154
|
-
schema: records ? recordEnvelopeSchema(
|
|
186
|
+
schema: records ? recordEnvelopeSchema(answer) : answer,
|
|
155
187
|
},
|
|
156
188
|
},
|
|
157
189
|
...(records ? { headers: RECORDS_OPENAPI_HEADER } : {}),
|
|
158
190
|
},
|
|
159
|
-
'400': problemResponse(
|
|
191
|
+
'400': problemResponse(`X_INPUT_INVALID${conflictOf(target)}`),
|
|
160
192
|
'403': problemResponse('policy denied'),
|
|
161
193
|
'404': problemResponse('X_NOT_FOUND — the read matched no row for this input'),
|
|
162
|
-
},
|
|
194
|
+
}),
|
|
163
195
|
'x-ultimate': {
|
|
164
196
|
capability: policyCapability(target.policy),
|
|
165
197
|
live: target.isLive,
|
|
@@ -181,7 +213,7 @@ function downloadOperation(target: AnyQuery, name: string): Record<string, unkno
|
|
|
181
213
|
summary: name,
|
|
182
214
|
...(target.deprecated === undefined ? {} : { deprecated: true }),
|
|
183
215
|
parameters: [
|
|
184
|
-
...inputParameters(target
|
|
216
|
+
...inputParameters(target),
|
|
185
217
|
{
|
|
186
218
|
name: DOWNLOAD_GRANT_PARAM,
|
|
187
219
|
in: 'query',
|
|
@@ -232,9 +264,6 @@ const MINTED_URL: Readonly<Record<string, unknown>> = {
|
|
|
232
264
|
},
|
|
233
265
|
};
|
|
234
266
|
|
|
235
|
-
/** One row. Its members are not published — a query declares no output schema, as `ANSWERS` says. */
|
|
236
|
-
const SINGLE_ANSWER: Readonly<Record<string, unknown>> = { type: 'object' };
|
|
237
|
-
|
|
238
267
|
/** The two controls, spelled from `page-controls.ts` so the document cannot drift from the route. */
|
|
239
268
|
const PAGE_PARAMETERS: readonly Record<string, unknown>[] = [
|
|
240
269
|
{
|
|
@@ -253,24 +282,44 @@ const PAGE_PARAMETERS: readonly Record<string, unknown>[] = [
|
|
|
253
282
|
},
|
|
254
283
|
];
|
|
255
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
|
+
|
|
256
289
|
/**
|
|
257
|
-
* A member per declared property
|
|
258
|
-
*
|
|
259
|
-
*
|
|
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.
|
|
260
294
|
*/
|
|
261
|
-
function inputParameters(
|
|
262
|
-
const node = tryIntrospect(input);
|
|
295
|
+
function inputParameters(target: AnyQuery): readonly Record<string, unknown>[] {
|
|
296
|
+
const node = tryIntrospect(target.input);
|
|
263
297
|
if (node?.properties === undefined) return [];
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
+
];
|
|
267
311
|
}
|
|
268
312
|
|
|
269
|
-
function parameterOf(
|
|
313
|
+
function parameterOf(
|
|
314
|
+
name: string,
|
|
315
|
+
node: SchemaNode,
|
|
316
|
+
where: 'path' | 'query',
|
|
317
|
+
): Record<string, unknown> {
|
|
270
318
|
return {
|
|
271
319
|
name,
|
|
272
|
-
in:
|
|
273
|
-
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),
|
|
274
323
|
schema: nodeToJsonSchema(node),
|
|
275
324
|
};
|
|
276
325
|
}
|
package/src/query.ts
CHANGED
|
@@ -15,15 +15,18 @@ import { policyCapability, policyPermissions } from '@ultimat3/policy';
|
|
|
15
15
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
16
16
|
import type { QueryCacheScope } from './cache';
|
|
17
17
|
import type { QueryClientMethodOf, QueryClientOptions } from './client';
|
|
18
|
+
import type { QueryContractTest, QueryContractTestOptions } from './contract-test';
|
|
18
19
|
import type { DownloadQuery, DownloadQueryDef, QueryDownload } from './download';
|
|
19
20
|
import { assertDownloadDef, buildDownloadQuery } from './download';
|
|
20
|
-
import {
|
|
21
|
+
import { QuerySingleInvalidError } from './errors';
|
|
21
22
|
import { facadeFor } from './facade';
|
|
23
|
+
import { assertQueryHttp, type QueryHttp } from './http-pin';
|
|
22
24
|
import { assertEncodableInput } from './input-shape';
|
|
23
25
|
import type { LiveQuery, ToLiveOptions } from './live';
|
|
24
26
|
import type { Page, PaginateArgs } from './pagination';
|
|
25
27
|
import type { QueryPolicy, QuerySurface } from './policy-gate';
|
|
26
28
|
import { hasDef, queryName, runQuery, stashDef } from './read';
|
|
29
|
+
import { assertDownloadFreshness, assertFreshness } from './read-freshness';
|
|
27
30
|
import type { SqlSource } from './source';
|
|
28
31
|
import { assertSubscribes } from './subscribes';
|
|
29
32
|
|
|
@@ -36,6 +39,12 @@ export interface QueryCache {
|
|
|
36
39
|
* every read of that query forever. Omitted takes `DEFAULT_READ_CACHE_TTL_MS`.
|
|
37
40
|
*/
|
|
38
41
|
readonly ttlMs?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Lifetime of a `null` answer, when it should be shorter than `ttlMs`: a `single: true` lookup
|
|
44
|
+
* that misses 40 ms before its row lands otherwise says "does not exist" for the whole `ttlMs`.
|
|
45
|
+
* Only `single: true` answers `null` — an empty list is a list. Positive and finite, as `ttlMs`.
|
|
46
|
+
*/
|
|
47
|
+
readonly negativeTtlMs?: number;
|
|
39
48
|
/**
|
|
40
49
|
* Who a cached answer may be handed back to. **Defaults to `actor`**, and that default is the
|
|
41
50
|
* mechanism: a read that declares nothing gets the narrowest key, which is always correct.
|
|
@@ -102,6 +111,20 @@ export interface QueryDef<
|
|
|
102
111
|
readonly rows?: StandardSchemaV1<unknown, TRow>;
|
|
103
112
|
sql(input: InferOutput<TInput>, ctx: Ctx): SqlSource<TRow>;
|
|
104
113
|
readonly cache?: QueryCache;
|
|
114
|
+
/**
|
|
115
|
+
* Answer `GET` with an `ETag` and `private, no-cache`, and a matching `If-None-Match` with a
|
|
116
|
+
* bodiless 304 — after the policy and the rate limit, since a 304 is still a read of the caller's
|
|
117
|
+
* rows. Opt-in because it moves the answer from `no-store` to the browser's disk. Refused on a
|
|
118
|
+
* `live` read (`X_QUERY_ETAG_INVALID`): its cursor is its validator.
|
|
119
|
+
*/
|
|
120
|
+
readonly etag?: true;
|
|
121
|
+
/**
|
|
122
|
+
* This read's own statement bound, in ms, tighter than the role's `statement_timeout`. The source
|
|
123
|
+
* runs inside `withStatementTimeout`, a transaction — so **a bounded read never reaches a
|
|
124
|
+
* replica**. Above the role's bound is `X_DB_STATEMENT_BOUND_INVALID`; firing is
|
|
125
|
+
* `X_DB_STATEMENT_TIMEOUT`. Not a whole positive number: `X_QUERY_TIMEOUT_INVALID`.
|
|
126
|
+
*/
|
|
127
|
+
readonly timeoutMs?: number;
|
|
105
128
|
readonly mcp?: McpExposureDeclaration;
|
|
106
129
|
/** Burst and refill for THIS read's route, in the limiter's own vocabulary. */
|
|
107
130
|
readonly rateLimit?: QueryRateLimit;
|
|
@@ -120,6 +143,10 @@ export interface QueryDef<
|
|
|
120
143
|
* `X_QUERY_AUDIT_SINK_MISSING` before the input is parsed.
|
|
121
144
|
*/
|
|
122
145
|
readonly audit?: boolean;
|
|
146
|
+
/** The refusals `sql` may throw, closed — an action's `errors:`, same rule (`declared-errors.ts`). */
|
|
147
|
+
readonly errors?: readonly string[];
|
|
148
|
+
/** Pin the URL: `http: { path: '/orders/:id' }` (`http-pin.ts`). */
|
|
149
|
+
readonly http?: QueryHttp;
|
|
123
150
|
/**
|
|
124
151
|
* Absent on a rows read, by type: a declaration holding `sql` AND `download` — a variable, where
|
|
125
152
|
* no excess-property check reaches — must match neither `query()` overload. `download.ts`.
|
|
@@ -199,6 +226,7 @@ export interface QueryDescriptor {
|
|
|
199
226
|
readonly deprecated: Deprecation | null;
|
|
200
227
|
/** Declared `download:` — the read answers a file at a signed URL, never rows. */
|
|
201
228
|
readonly download: boolean;
|
|
229
|
+
readonly errors: readonly string[] | null;
|
|
202
230
|
}
|
|
203
231
|
|
|
204
232
|
/**
|
|
@@ -215,10 +243,13 @@ export interface AnyQueryDef {
|
|
|
215
243
|
readonly rows?: StandardSchemaV1;
|
|
216
244
|
sql(input: unknown, ctx: Ctx): SqlSource<object>;
|
|
217
245
|
readonly cache?: QueryCache;
|
|
246
|
+
readonly etag?: true;
|
|
247
|
+
readonly timeoutMs?: number;
|
|
218
248
|
readonly mcp?: McpExposureDeclaration;
|
|
219
249
|
readonly rateLimit?: QueryRateLimit;
|
|
220
250
|
readonly deprecated?: Deprecation;
|
|
221
251
|
readonly audit?: boolean;
|
|
252
|
+
readonly http?: QueryHttp;
|
|
222
253
|
/** A download's handler. Private like `sql`: `download.ts` reaches it through `defOf`. */
|
|
223
254
|
readonly download?: QueryDownload<StandardSchemaV1>;
|
|
224
255
|
}
|
|
@@ -244,6 +275,7 @@ export interface AnyQuery {
|
|
|
244
275
|
readonly deprecated?: Deprecation;
|
|
245
276
|
/** `true` on a `query({ download })` — the route mints a URL or serves a file. Absent otherwise. */
|
|
246
277
|
readonly download?: true;
|
|
278
|
+
readonly errors?: readonly string[];
|
|
247
279
|
describe(): QueryDescriptor;
|
|
248
280
|
/** A twin under another name. Registration names through `named`. */
|
|
249
281
|
named(name: string): AnyQuery;
|
|
@@ -277,6 +309,8 @@ export interface Query<
|
|
|
277
309
|
* so it lives here and not on the schema-erased `AnyQuery` view.
|
|
278
310
|
*/
|
|
279
311
|
client(options: QueryClientOptions): QueryClientMethodOf<TInput, TRow, TSingle>;
|
|
312
|
+
/** The generated contract suite the `contract` step runs (`contract-test.ts`). */
|
|
313
|
+
contract(options?: QueryContractTestOptions): readonly QueryContractTest[];
|
|
280
314
|
}
|
|
281
315
|
|
|
282
316
|
/** The fluent half of a query: lifted declaration plus one method per projection. */
|
|
@@ -295,10 +329,12 @@ export type QueryFacade<
|
|
|
295
329
|
| 'mcp'
|
|
296
330
|
| 'rateLimit'
|
|
297
331
|
| 'deprecated'
|
|
332
|
+
| 'errors'
|
|
298
333
|
| 'as'
|
|
299
334
|
| 'page'
|
|
300
335
|
| 'live'
|
|
301
336
|
| 'client'
|
|
337
|
+
| 'contract'
|
|
302
338
|
>;
|
|
303
339
|
|
|
304
340
|
/**
|
|
@@ -324,11 +360,13 @@ export function query<
|
|
|
324
360
|
// anyone mounts it, and the typed client derives that same URL — so an input a query string
|
|
325
361
|
// cannot carry is wrong for every call, and the file that declared it is where it is repaired.
|
|
326
362
|
assertEncodableInput(def.input);
|
|
363
|
+
if (!isDownloadDef(def)) assertQueryHttp(def.http, def.input);
|
|
327
364
|
if (isDownloadDef(def)) {
|
|
365
|
+
assertDownloadFreshness(def);
|
|
328
366
|
assertDownloadDef(def);
|
|
329
367
|
return buildDownloadQuery(def, '', describeQuery);
|
|
330
368
|
}
|
|
331
|
-
|
|
369
|
+
assertFreshness(def);
|
|
332
370
|
assertSubscribes(def.subscribes, def.live);
|
|
333
371
|
// A JS caller or generated code can write `single: 'yes'`; truthiness would read it as `true`
|
|
334
372
|
// on the route and `false` in the type, so anything but a boolean is refused where it is written.
|
|
@@ -349,18 +387,6 @@ function isDownloadDef<TInput extends StandardSchemaV1>(
|
|
|
349
387
|
return 'download' in def && def.download !== undefined;
|
|
350
388
|
}
|
|
351
389
|
|
|
352
|
-
/**
|
|
353
|
-
* The same rule, for the same reason: a lease every `CacheTier` refuses is wrong for every read of
|
|
354
|
-
* this query, so it fails on the line that wrote it rather than on the first request. Positive and
|
|
355
|
-
* finite is `assertTtl`'s bar in `@ultimat3/cache`, restated as a refusal and never as a second
|
|
356
|
-
* resolution — there is no "never expires" to fall back to.
|
|
357
|
-
*/
|
|
358
|
-
function assertCacheTtl(cache: QueryCache | undefined): void {
|
|
359
|
-
const ttlMs = cache?.ttlMs;
|
|
360
|
-
if (ttlMs === undefined) return;
|
|
361
|
-
if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new QueryCacheTtlInvalidError(ttlMs);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
390
|
/**
|
|
365
391
|
* Structural, not nominal: an object only counts as a query if `query()` built it,
|
|
366
392
|
* because only then does a declaration exist for `sourceFor` to read. A look-alike
|
|
@@ -422,6 +448,7 @@ export function describeQuery(target: AnyQuery): QueryDescriptor {
|
|
|
422
448
|
rateLimit: target.rateLimit ?? null,
|
|
423
449
|
deprecated: target.deprecated ?? null,
|
|
424
450
|
download: target.download === true,
|
|
451
|
+
errors: target.errors ?? null,
|
|
425
452
|
};
|
|
426
453
|
}
|
|
427
454
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Single responsibility: the declaration-time screen for a read's freshness options — `etag`, the
|
|
2
|
+
// time bound and the null's lease. Refused on the line that wrote them, never on the first request:
|
|
3
|
+
// a value wrong for one call of a query is wrong for every call of it.
|
|
4
|
+
|
|
5
|
+
import { renderCauseValue, UltimateError } from '@ultimat3/core';
|
|
6
|
+
import { QueryCacheTtlInvalidError } from './errors';
|
|
7
|
+
|
|
8
|
+
export class QueryEtagInvalidError extends UltimateError {
|
|
9
|
+
constructor(why: 'download' | 'live') {
|
|
10
|
+
super({
|
|
11
|
+
code: 'X_QUERY_ETAG_INVALID',
|
|
12
|
+
cause:
|
|
13
|
+
why === 'download'
|
|
14
|
+
? 'query({ download, etag: true }) — a download answers its own ETag on the file it serves'
|
|
15
|
+
: 'query({ live: true, etag: true }) — a live read is revalidated by its cursor, not a validator',
|
|
16
|
+
fix: "query({ input, policy, sql }) # the declaration the cause names, with etag: true removed; a live read's cursor is its validator",
|
|
17
|
+
meta: { why },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class QueryTimeoutInvalidError extends UltimateError {
|
|
23
|
+
constructor(value: unknown, why: string) {
|
|
24
|
+
super({
|
|
25
|
+
code: 'X_QUERY_TIMEOUT_INVALID',
|
|
26
|
+
cause: `query({ timeoutMs: ${renderCauseValue(value)} }) — ${why}`,
|
|
27
|
+
fix: 'query({ timeoutMs: 500 }) # a whole number of ms under the role bound, read with: psql "$DATABASE_URL" -c "show statement_timeout" — or drop timeoutMs',
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface FreshnessDecl {
|
|
33
|
+
readonly etag?: unknown;
|
|
34
|
+
readonly live?: boolean | undefined;
|
|
35
|
+
readonly timeoutMs?: unknown;
|
|
36
|
+
readonly cache?: { readonly ttlMs?: number; readonly negativeTtlMs?: number } | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A lease every tier refuses, refused here with the field that wrote it. */
|
|
40
|
+
function assertLease(value: number | undefined, field: 'ttlMs' | 'negativeTtlMs'): void {
|
|
41
|
+
if (value === undefined) return;
|
|
42
|
+
if (!Number.isFinite(value) || value <= 0) throw new QueryCacheTtlInvalidError(value, field);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Rows reads. `download` reads go through `assertDownloadFreshness` instead. */
|
|
46
|
+
export function assertFreshness(def: FreshnessDecl): void {
|
|
47
|
+
assertLease(def.cache?.ttlMs, 'ttlMs');
|
|
48
|
+
assertLease(def.cache?.negativeTtlMs, 'negativeTtlMs');
|
|
49
|
+
if (def.etag === true && def.live === true) throw new QueryEtagInvalidError('live');
|
|
50
|
+
const timeoutMs = def.timeoutMs;
|
|
51
|
+
if (timeoutMs === undefined) return;
|
|
52
|
+
if (typeof timeoutMs !== 'number' || !Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
|
53
|
+
throw new QueryTimeoutInvalidError(timeoutMs, 'a bound is a whole number of ms above 0');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A download has its own validators and no `sql` to bound: both options are refused beside it. */
|
|
58
|
+
export function assertDownloadFreshness(def: object): void {
|
|
59
|
+
if ('etag' in def && def.etag !== undefined) throw new QueryEtagInvalidError('download');
|
|
60
|
+
if ('timeoutMs' in def && def.timeoutMs !== undefined) {
|
|
61
|
+
throw new QueryTimeoutInvalidError(def.timeoutMs, 'a download read runs no sql to bound');
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/read.ts
CHANGED
|
@@ -5,22 +5,29 @@
|
|
|
5
5
|
* payload, and none of them can become a second read path or a second authz path.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { CacheTag } from '@ultimat3/cache';
|
|
8
9
|
import type { Ctx } from '@ultimat3/core';
|
|
9
10
|
import {
|
|
10
11
|
actorOf,
|
|
11
12
|
anonymousActor,
|
|
12
13
|
assert,
|
|
13
14
|
ctxOf,
|
|
15
|
+
failureCaptureActive,
|
|
14
16
|
finiteOption,
|
|
15
17
|
logger,
|
|
18
|
+
REDACTED,
|
|
19
|
+
recordFailureInput,
|
|
16
20
|
runWithContext,
|
|
17
21
|
tryUseContext,
|
|
18
22
|
useContext,
|
|
19
23
|
withChildContext,
|
|
24
|
+
withFailureCapture,
|
|
20
25
|
withSpan,
|
|
21
26
|
} from '@ultimat3/core';
|
|
27
|
+
import { withStatementTimeout } from '@ultimat3/db';
|
|
28
|
+
import { statusFor } from '@ultimat3/http';
|
|
22
29
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
23
|
-
import { formatPath, validateAsync } from '@ultimat3/schema';
|
|
30
|
+
import { formatPath, maskSensitive, validateAsync } from '@ultimat3/schema';
|
|
24
31
|
import type { ReadAudit, ReadTrace } from './audit-gate';
|
|
25
32
|
import { audited, auditedBuild, readAuditFor } from './audit-gate';
|
|
26
33
|
import {
|
|
@@ -31,6 +38,7 @@ import {
|
|
|
31
38
|
readOnce,
|
|
32
39
|
readThrough,
|
|
33
40
|
} from './cache';
|
|
41
|
+
import { judgedRead, judgedSql } from './declared-errors';
|
|
34
42
|
import { QueryForeignError, QueryInputInvalidError, QueryUnregisteredError } from './errors';
|
|
35
43
|
import { guardQuery, guardQueryBeforeInput } from './policy-gate';
|
|
36
44
|
import type { AnyQuery, AnyQueryDef, Query, QueryOptions, SourceOptions } from './query';
|
|
@@ -88,12 +96,15 @@ export function runQuery(
|
|
|
88
96
|
raw: unknown,
|
|
89
97
|
options: QueryOptions = {},
|
|
90
98
|
): Promise<readonly object[]> {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
99
|
+
// A 5xx keeps a replayable bundle when `failureCapture` is on (`x repro`); off, it reads bare.
|
|
100
|
+
return asActor(options, (ctx) =>
|
|
101
|
+
withFailureCapture('query', queryName(target), statusFor, () => {
|
|
102
|
+
const audit = auditFor(target, ctx, options);
|
|
103
|
+
return audit === null
|
|
104
|
+
? readRows(target, raw, ctx, options, null)
|
|
105
|
+
: audited(audit, () => readRows(target, raw, ctx, options, audit.trace));
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
97
108
|
}
|
|
98
109
|
|
|
99
110
|
/**
|
|
@@ -217,9 +228,16 @@ async function readRowsIn(
|
|
|
217
228
|
// Whether THIS call executed, for the audit record: a memo or tier hit never calls `read`, and
|
|
218
229
|
// its record says so (`replayed: true`) rather than being skipped.
|
|
219
230
|
if (trace !== null) trace.replayed = true;
|
|
231
|
+
// `timeoutMs` runs the source inside its own bounded read transaction — on the primary, since a
|
|
232
|
+
// transaction is pinned there. Inside `judgedRead`, so a fired bound is judged like any refusal.
|
|
233
|
+
const bound = def.timeoutMs;
|
|
234
|
+
const execute =
|
|
235
|
+
bound === undefined
|
|
236
|
+
? () => source.execute()
|
|
237
|
+
: () => withStatementTimeout(bound, () => source.execute());
|
|
220
238
|
const read = (): Promise<readonly object[]> => {
|
|
221
239
|
if (trace !== null) trace.replayed = false;
|
|
222
|
-
return
|
|
240
|
+
return judgedRead(target, name, execute);
|
|
223
241
|
};
|
|
224
242
|
// The source came from this query's own `sql()`, so its rows are TRow throughout —
|
|
225
243
|
// which is what the typed overload above states, and this body never has to assert.
|
|
@@ -242,13 +260,41 @@ async function readRowsIn(
|
|
|
242
260
|
// the process — a paginated feed over 10k tenants is 10k immortal entries.
|
|
243
261
|
return def.cache === undefined
|
|
244
262
|
? await readOnce(ctx, key, read)
|
|
245
|
-
: await
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
263
|
+
: await readThroughLeased(ctx, key, def, read, tags);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The tier read with both leases. Only a `single: true` read can answer "none": its miss is stored
|
|
268
|
+
* as `null`, so the stack gives it `negativeTtlMs` — and an empty LIST stays a list with `ttlMs`.
|
|
269
|
+
*/
|
|
270
|
+
async function readThroughLeased(
|
|
271
|
+
ctx: Ctx,
|
|
272
|
+
key: string,
|
|
273
|
+
def: AnyQueryDef,
|
|
274
|
+
read: () => Promise<readonly object[]>,
|
|
275
|
+
tags: readonly CacheTag[],
|
|
276
|
+
): Promise<readonly object[]> {
|
|
277
|
+
const ttlMs = finiteOption(
|
|
278
|
+
'the read cache',
|
|
279
|
+
'ttlMs',
|
|
280
|
+
def.cache?.ttlMs ?? DEFAULT_READ_CACHE_TTL_MS,
|
|
281
|
+
);
|
|
282
|
+
const negativeTtlMs = def.cache?.negativeTtlMs;
|
|
283
|
+
if (def.single !== true || negativeTtlMs === undefined) {
|
|
284
|
+
return readThrough(ctx, key, ttlMs, read, tags);
|
|
285
|
+
}
|
|
286
|
+
const held = await readThrough(
|
|
287
|
+
ctx,
|
|
288
|
+
key,
|
|
289
|
+
ttlMs,
|
|
290
|
+
async (): Promise<readonly object[] | null> => {
|
|
291
|
+
const rows = await read();
|
|
292
|
+
return rows.length === 0 ? null : rows;
|
|
293
|
+
},
|
|
294
|
+
tags,
|
|
295
|
+
negativeTtlMs,
|
|
296
|
+
);
|
|
297
|
+
return held ?? [];
|
|
252
298
|
}
|
|
253
299
|
|
|
254
300
|
async function buildSource(
|
|
@@ -259,7 +305,8 @@ async function buildSource(
|
|
|
259
305
|
trace: ReadTrace | null,
|
|
260
306
|
): Promise<SqlSource<object>> {
|
|
261
307
|
const input = await admit(target, raw, ctx, options, trace);
|
|
262
|
-
|
|
308
|
+
// `sql` and the source's `execute` are where `errors:` judges a throw (`declared-errors.ts`).
|
|
309
|
+
const source = judgedSql(target, queryName(target), () => defOf(target).sql(input, ctx));
|
|
263
310
|
// A live window is served in the order its patches are placed in. The matcher breaks a tie on
|
|
264
311
|
// the declared keys with `id` (`totalOrder`) and so does the keyset re-read a reconnect resumes
|
|
265
312
|
// with, so an initial window served in the declared keys alone puts tied rows where neither of
|
|
@@ -312,7 +359,9 @@ async function admit(
|
|
|
312
359
|
} catch (denial) {
|
|
313
360
|
// The record still names what was attempted, as an action's does: the denial is already
|
|
314
361
|
// decided, so parsing now tells the CALLER nothing, and an unparseable payload is no input.
|
|
315
|
-
if (trace !== null)
|
|
362
|
+
if (trace !== null) {
|
|
363
|
+
trace.input = maskSensitive(def.input, await parsedOrNothing(def.input, raw), REDACTED);
|
|
364
|
+
}
|
|
316
365
|
throw denial;
|
|
317
366
|
}
|
|
318
367
|
}
|
|
@@ -330,7 +379,9 @@ async function admit(
|
|
|
330
379
|
});
|
|
331
380
|
}
|
|
332
381
|
const input = await validate(def.input, raw, name);
|
|
333
|
-
if (
|
|
382
|
+
if (failureCaptureActive()) recordFailureInput(maskSensitive(def.input, input, REDACTED));
|
|
383
|
+
// The audit record's copy, masked by THIS read's own schema — never a key table (`action`'s rule).
|
|
384
|
+
if (trace !== null) trace.input = maskSensitive(def.input, input, REDACTED);
|
|
334
385
|
if (unenforced === undefined) {
|
|
335
386
|
guardQuery(
|
|
336
387
|
def.policy,
|