@ultimat3/query 29.4.0 → 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 +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/errors.ts
CHANGED
|
@@ -35,6 +35,9 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
|
|
|
35
35
|
X_QUERY_DOWNLOAD_ONLY: 'rows were asked of a query that answers a file',
|
|
36
36
|
X_QUERY_DOWNLOAD_TOKEN_INVALID: 'a download URL carries a token this app did not mint for it',
|
|
37
37
|
X_QUERY_DOWNLOAD_TOKEN_EXPIRED: 'a download URL is past its expiry',
|
|
38
|
+
X_QUERY_HTTP_PATH_INVALID: "a query's pinned http.path is not a valid path template",
|
|
39
|
+
X_QUERY_ETAG_INVALID: 'etag: true on a read that has its own validator',
|
|
40
|
+
X_QUERY_TIMEOUT_INVALID: 'a read declares a timeoutMs that is not a whole positive number of ms',
|
|
38
41
|
};
|
|
39
42
|
|
|
40
43
|
/**
|
|
@@ -44,7 +47,15 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
|
|
|
44
47
|
* re-declares it: two copies of a title are two titles, one of which is stale. `X_RPC_FAILED` left this list in
|
|
45
48
|
* 21.0.0: the typed read client's wire failures are `@ultimat3/core`'s `clientTransport`'s now.
|
|
46
49
|
*/
|
|
47
|
-
|
|
50
|
+
// `X_CONTRACT_*`: the exercise assertion `contract-test.ts` runs, a twin of action's (`exercise.ts`).
|
|
51
|
+
export const QUERY_BORROWED_ERROR_CODES = [
|
|
52
|
+
'X_INPUT_INVALID',
|
|
53
|
+
'X_NOT_FOUND',
|
|
54
|
+
'X_CONTRACT_DRIFT',
|
|
55
|
+
'X_CONTRACT_DEFECT',
|
|
56
|
+
'X_CONTRACT_EXERCISE_UNREACHED',
|
|
57
|
+
'X_CONTRACT_EXERCISE_UNDECLARED',
|
|
58
|
+
] as const;
|
|
48
59
|
|
|
49
60
|
// One unconditional call: a presence guard would turn "another package claims one of these codes"
|
|
50
61
|
// from an X_ERROR_CODE_DUPLICATE at import into whichever module loaded first deciding the title.
|
|
@@ -158,12 +169,12 @@ export class QueryInputUnencodableError extends UltimateError {
|
|
|
158
169
|
* the cause describes the declaration, exactly as `X_QUERY_INPUT_UNENCODABLE` does.
|
|
159
170
|
*/
|
|
160
171
|
export class QueryCacheTtlInvalidError extends UltimateError {
|
|
161
|
-
constructor(ttlMs: number) {
|
|
172
|
+
constructor(ttlMs: number, field: 'ttlMs' | 'negativeTtlMs' = 'ttlMs') {
|
|
162
173
|
super({
|
|
163
174
|
code: 'X_QUERY_CACHE_TTL_INVALID',
|
|
164
|
-
cause: `a query declares cache
|
|
165
|
-
fix:
|
|
166
|
-
meta: { ttlMs },
|
|
175
|
+
cause: `a query declares cache.${field} as ${ttlMs}, and every cache tier refuses a lease that is not positive and finite`,
|
|
176
|
+
fix: `set \`cache: { ${field}: ${field === 'ttlMs' ? '60_000' : '1_000'} }\` to a positive whole number of milliseconds, or drop ${field} to take the default`,
|
|
177
|
+
meta: { ttlMs, field },
|
|
167
178
|
});
|
|
168
179
|
}
|
|
169
180
|
}
|
package/src/exercise.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A read's adapter over `@ultimat3/core`'s exercise loop (`runExercise`, shared with `action`):
|
|
3
|
+
* run the read with inputs its own schema accepts, seeded by its name, and see whether `sql()` and
|
|
4
|
+
* its execution survive. A read writes nothing, so no transaction wraps it. The codes are action's.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Actor, Ctx, DrawnInput, ExerciseReport } from '@ultimat3/core';
|
|
8
|
+
import {
|
|
9
|
+
bindDrawn,
|
|
10
|
+
ctxOf,
|
|
11
|
+
exerciseReplay,
|
|
12
|
+
isUltimateError,
|
|
13
|
+
renderFixShellArg,
|
|
14
|
+
runExercise,
|
|
15
|
+
tryUseContext,
|
|
16
|
+
UltimateError,
|
|
17
|
+
} from '@ultimat3/core';
|
|
18
|
+
import { statusFor } from '@ultimat3/http';
|
|
19
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
20
|
+
import { validateAsync } from '@ultimat3/schema';
|
|
21
|
+
import { arbitrary } from '@ultimat3/schema/sample';
|
|
22
|
+
import { QueryAuditSinkMissingError } from './audit-errors';
|
|
23
|
+
import { QueryDeniedError } from './errors';
|
|
24
|
+
import type { AnyQuery } from './query';
|
|
25
|
+
import { queryName, sourceFor } from './read';
|
|
26
|
+
|
|
27
|
+
/** Who reads, and what binds the parts a generated value cannot. Same shape as an action's. */
|
|
28
|
+
export interface QueryExerciseActorOptions {
|
|
29
|
+
readonly actor: Actor | null;
|
|
30
|
+
readonly input?: (drawn: DrawnInput) => unknown;
|
|
31
|
+
readonly runs?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface QueryExerciseOffOptions {
|
|
35
|
+
readonly off: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type QueryExerciseOptions = QueryExerciseActorOptions | QueryExerciseOffOptions;
|
|
39
|
+
|
|
40
|
+
export type QueryExerciseReport = ExerciseReport;
|
|
41
|
+
|
|
42
|
+
export const queryExerciseTestName = (name: string): string =>
|
|
43
|
+
`${name}: read survives generated input`;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether a refusal came from the front half every surface shares — the policy, the parse, the
|
|
47
|
+
* declared limit, a missing audit sink — rather than from `sql()` or its execution. Read off the
|
|
48
|
+
* refusal itself because `read.ts` keeps no counter: anything else was thrown past the front half.
|
|
49
|
+
*/
|
|
50
|
+
function beforeSql(error: unknown): boolean {
|
|
51
|
+
if (error instanceof QueryDeniedError || error instanceof QueryAuditSinkMissingError) return true;
|
|
52
|
+
return isUltimateError(error) && FRONT_HALF_CODES.has(error.code);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const FRONT_HALF_CODES: ReadonlySet<string> = new Set(['X_INPUT_INVALID', 'X_RATE_LIMITED']);
|
|
56
|
+
|
|
57
|
+
const accepts = async (schema: StandardSchemaV1, input: unknown): Promise<boolean> =>
|
|
58
|
+
(await validateAsync(schema, input)).issues === undefined;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Exercise `target`. "Reached" is `sql()` having built a source: everything before it — the
|
|
62
|
+
* policy, the declared limit, the parse — is the front half every surface shares.
|
|
63
|
+
*
|
|
64
|
+
* A test-time call: refused in `staging`/`production` (`X_CONTRACT_EXERCISE_ENVIRONMENT`). Nothing
|
|
65
|
+
* is rolled back, because a read writes nothing; what it touches anyway (a rate-limit counter, an
|
|
66
|
+
* audit record, an outbound fetch — sealed under the test preload) is kept.
|
|
67
|
+
*/
|
|
68
|
+
export async function exerciseQuery(
|
|
69
|
+
target: AnyQuery,
|
|
70
|
+
options: QueryExerciseActorOptions,
|
|
71
|
+
ctx?: Ctx,
|
|
72
|
+
): Promise<QueryExerciseReport> {
|
|
73
|
+
const name = queryName(target);
|
|
74
|
+
const base = ctx ?? tryUseContext() ?? ctxOf({});
|
|
75
|
+
const verdict = await runExercise({
|
|
76
|
+
name,
|
|
77
|
+
runs: options.runs,
|
|
78
|
+
stoppedLabel: 'stopped before sql()',
|
|
79
|
+
draw: (seed, run) => arbitrary(target.input, { seed, run }),
|
|
80
|
+
bind: bindDrawn(options.input),
|
|
81
|
+
accepts: (input) => accepts(target.input, input),
|
|
82
|
+
attempt: async (input) => {
|
|
83
|
+
try {
|
|
84
|
+
const source = await sourceFor(target, input, {
|
|
85
|
+
ctx: base,
|
|
86
|
+
actor: options.actor,
|
|
87
|
+
surface: 'http',
|
|
88
|
+
});
|
|
89
|
+
await source.execute();
|
|
90
|
+
return { ok: true, reached: true };
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return { ok: false, reached: !beforeSql(error), error };
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
statusOf: statusFor,
|
|
96
|
+
});
|
|
97
|
+
switch (verdict.kind) {
|
|
98
|
+
case 'pass':
|
|
99
|
+
return verdict.report;
|
|
100
|
+
case 'drift':
|
|
101
|
+
throw queryDrawsRefused(name);
|
|
102
|
+
case 'unreached':
|
|
103
|
+
throw queryUnreached(name, verdict.outcomes);
|
|
104
|
+
case 'defect':
|
|
105
|
+
throw queryDefect(name, verdict.run, verdict.input, verdict.defect, verdict.error);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function queryDefect(
|
|
110
|
+
name: string,
|
|
111
|
+
run: number,
|
|
112
|
+
input: unknown,
|
|
113
|
+
defect: string,
|
|
114
|
+
error: unknown,
|
|
115
|
+
): UltimateError {
|
|
116
|
+
return new UltimateError({
|
|
117
|
+
code: 'X_CONTRACT_DEFECT',
|
|
118
|
+
cause: `query ${name} ${defect} on generated run ${run}, for input its own schema accepts (meta.input, meta.failure)`,
|
|
119
|
+
fix: `${exerciseReplay(name, queryExerciseTestName(name))} # replays the run; fix the ${name} read or tighten its input: first`,
|
|
120
|
+
meta: {
|
|
121
|
+
run,
|
|
122
|
+
input,
|
|
123
|
+
...(isUltimateError(error) ? { failure: { code: error.code, cause: error.cause } } : {}),
|
|
124
|
+
},
|
|
125
|
+
sourceError: error,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function queryUnreached(name: string, outcomes: readonly string[]): UltimateError {
|
|
130
|
+
return new UltimateError({
|
|
131
|
+
code: 'X_CONTRACT_EXERCISE_UNREACHED',
|
|
132
|
+
cause: `none of ${outcomes.length} generated runs of query ${name} reached its sql() (meta.outcomes lists what each one hit)`,
|
|
133
|
+
fix: `${renderFixShellArg(name, '<name>')}.contract({ exercise: { actor } }) with an actor the policy permits`,
|
|
134
|
+
meta: { outcomes },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function queryDrawsRefused(name: string): UltimateError {
|
|
139
|
+
return new UltimateError({
|
|
140
|
+
code: 'X_CONTRACT_DRIFT',
|
|
141
|
+
cause: `query ${name}: its input schema refused every generated value, so the read was never exercised`,
|
|
142
|
+
fix: `${renderFixShellArg(name, '<name>')}.contract({ exercise: { actor, input: (drawn) => ({ ...drawn, field: '<a value the refinement accepts>' }) } })`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A guarded read with no exercise said, or an opt-out with a blank reason. */
|
|
147
|
+
export function queryExerciseUndeclared(name: string, blankReason: boolean): UltimateError {
|
|
148
|
+
const call = renderFixShellArg(name, '<name>');
|
|
149
|
+
return new UltimateError({
|
|
150
|
+
code: 'X_CONTRACT_EXERCISE_UNDECLARED',
|
|
151
|
+
cause: blankReason
|
|
152
|
+
? `${name}.contract({ exercise: { off } }) gives a blank reason, so the skip is silent`
|
|
153
|
+
: `query ${name} refuses an anonymous reader and its .contract() names no actor to read as`,
|
|
154
|
+
fix: `${call}.contract({ exercise: { actor } }) — or ${call}.contract({ exercise: { off: '<why this read cannot run in a test>' } })`,
|
|
155
|
+
});
|
|
156
|
+
}
|
package/src/facade.ts
CHANGED
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
* (`toolFrom(orgFeed)`), a tier this package cannot import.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { checkDeclaredErrors } from '@ultimat3/core';
|
|
11
|
+
import { declaredStatusFor } from '@ultimat3/http';
|
|
10
12
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
11
13
|
import type { QueryClientMethodOf } from './client';
|
|
12
14
|
import { queryClientMethodFor } from './client';
|
|
15
|
+
import { queryContractTestsFor } from './contract-test';
|
|
13
16
|
import { toLiveQuery } from './live';
|
|
14
17
|
import { paginate } from './pagination';
|
|
15
18
|
import type { Query, QueryDef, QueryFacade } from './query';
|
|
@@ -38,6 +41,10 @@ export function facadeFor<
|
|
|
38
41
|
...(def.mcp === undefined ? {} : { mcp: def.mcp }),
|
|
39
42
|
...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }),
|
|
40
43
|
...(def.deprecated === undefined ? {} : { deprecated: def.deprecated }),
|
|
44
|
+
// Checked here, the one place a declaration is lifted: frozen and sorted, or refused.
|
|
45
|
+
...(def.errors === undefined
|
|
46
|
+
? {}
|
|
47
|
+
: { errors: checkDeclaredErrors(def.errors, declaredStatusFor, 'this query') }),
|
|
41
48
|
// `.as()` is impersonation on the one read path: `runQuery` keeps the
|
|
42
49
|
// surrounding context whole and swaps only the actor.
|
|
43
50
|
as: (actor, input, options) => runQuery(self(), input, { ...options, actor }),
|
|
@@ -45,13 +52,14 @@ export function facadeFor<
|
|
|
45
52
|
// signed cursor is only reachable through the query that issued it.
|
|
46
53
|
page: (input, args) => paginate(self(), input, args),
|
|
47
54
|
live: (input, options) => toLiveQuery(self(), input, options),
|
|
55
|
+
contract: (options) => queryContractTestsFor(self(), options),
|
|
48
56
|
// One implementation for both wire shapes: the route decides the body, the transport parses
|
|
49
57
|
// it, and only the TYPE differs — a single read's method answers the row, and has no `page`.
|
|
50
58
|
client: (options) =>
|
|
51
|
-
queryClientMethodFor<TInput, TRow>(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
>,
|
|
59
|
+
queryClientMethodFor<TInput, TRow>(
|
|
60
|
+
queryName(self()),
|
|
61
|
+
options,
|
|
62
|
+
def.http?.path,
|
|
63
|
+
) as QueryClientMethodOf<TInput, TRow, TSingle>,
|
|
56
64
|
};
|
|
57
65
|
}
|
package/src/http-etag.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Single responsibility: a declared read's validator. The ETag is a strong hash of the bytes the
|
|
2
|
+
// policy already judged, keyed to the actor who was judged, and the 304 is `@ultimat3/http`'s
|
|
3
|
+
// `notModified` — the one `If-None-Match` reader, so this file never compares a header itself.
|
|
4
|
+
|
|
5
|
+
import { notModified, PRIVATE_REVALIDATE, type UltimateRequest } from '@ultimat3/http';
|
|
6
|
+
import { readAuthority } from './cache';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `"<sha-256, base64url>"` over the actor's authority and the body. The authority is in the hash so
|
|
10
|
+
* one actor's validator never confirms that another's rows are unchanged: two callers seeing the
|
|
11
|
+
* same bytes still hold two validators.
|
|
12
|
+
*/
|
|
13
|
+
function etagOf(authority: string, body: ArrayBuffer): string {
|
|
14
|
+
const hasher = new Bun.CryptoHasher('sha256');
|
|
15
|
+
hasher.update(authority);
|
|
16
|
+
hasher.update('\0');
|
|
17
|
+
hasher.update(body);
|
|
18
|
+
return `"${hasher.digest('base64url')}"`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The answer with its validator, or the bodiless 304 when the caller already holds it. Called with
|
|
23
|
+
* the response `runQuery` produced, so the policy and the rate limit have already run: a 304 is
|
|
24
|
+
* still a read of that person's rows. Only a 200 carries one — a refusal is never revalidated.
|
|
25
|
+
*/
|
|
26
|
+
export async function withValidator(
|
|
27
|
+
request: UltimateRequest,
|
|
28
|
+
response: Response,
|
|
29
|
+
): Promise<Response> {
|
|
30
|
+
if (response.status !== 200) return response;
|
|
31
|
+
const body = await response.arrayBuffer();
|
|
32
|
+
const etag = etagOf(readAuthority(request.ctx.actor, 'actor'), body);
|
|
33
|
+
const headers = new Headers(response.headers);
|
|
34
|
+
headers.set('cache-control', PRIVATE_REVALIDATE);
|
|
35
|
+
headers.set('etag', etag);
|
|
36
|
+
const unchanged = notModified(request, etag, headers);
|
|
37
|
+
if (unchanged !== undefined) {
|
|
38
|
+
// A 304 carries no body, and no header describing one.
|
|
39
|
+
unchanged.headers.delete('content-length');
|
|
40
|
+
unchanged.headers.delete('content-type');
|
|
41
|
+
return unchanged;
|
|
42
|
+
}
|
|
43
|
+
return new Response(body, { status: 200, headers });
|
|
44
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Single responsibility: `X_QUERY_HTTP_PATH_INVALID`, the refusal of a read's pinned `http.path`.
|
|
2
|
+
// Its own leaf over `@ultimat3/core/page`, so the browser read client can refuse a URL it cannot
|
|
3
|
+
// fill without loading `errors.ts`'s titles table (`client-bundle.test.ts`); the title is there.
|
|
4
|
+
|
|
5
|
+
import { UltimateError } from '@ultimat3/core/page';
|
|
6
|
+
|
|
7
|
+
export class QueryHttpPathInvalidError extends UltimateError {
|
|
8
|
+
constructor(input: { readonly name: string; readonly path: string; readonly reason: string }) {
|
|
9
|
+
super({
|
|
10
|
+
code: 'X_QUERY_HTTP_PATH_INVALID',
|
|
11
|
+
cause: `query${input.name === '' ? '' : ` "${input.name}"`} pins http.path ${input.path}, which ${input.reason}`,
|
|
12
|
+
// Short on purpose: this class rides the browser read client (`client-bundle.test.ts`).
|
|
13
|
+
fix: "query({ input: t.object({ id: t.string }), http: { path: '/orders/:id' }, ... }) — every :param a required t.string key of input:, and passed to client()",
|
|
14
|
+
meta: { query: input.name },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/http-pin.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Single responsibility: where a read is served — its pinned `http.path` (`/orders/:id`) when it
|
|
2
|
+
// declares one, else core's `queryPath(name)` — and the check of that pin at `query()`. The
|
|
3
|
+
// template rule is core's, shared with `@ultimat3/action`'s pins; only the code is this package's.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
pathTemplateProblem,
|
|
7
|
+
queryPath,
|
|
8
|
+
renderCauseValue,
|
|
9
|
+
templateKeyProblem,
|
|
10
|
+
templateParams,
|
|
11
|
+
} from '@ultimat3/core';
|
|
12
|
+
import { tryIntrospect } from '@ultimat3/schema';
|
|
13
|
+
import { QueryHttpPathInvalidError } from './http-pin-error';
|
|
14
|
+
import type { AnyQuery } from './query';
|
|
15
|
+
import { defOf } from './read';
|
|
16
|
+
|
|
17
|
+
/** What a read may say about its own HTTP projection. */
|
|
18
|
+
export interface QueryHttp {
|
|
19
|
+
/**
|
|
20
|
+
* Pin the URL instead of `/_x/query/<kebab>`: lowercase static segments and `:param`s, each a
|
|
21
|
+
* REQUIRED string key of `input:`. The URL's value joins the search string as the one input the
|
|
22
|
+
* schema parses; a search string naming the key differently is `X_HTTP_PATH_INPUT_CONFLICT`.
|
|
23
|
+
*/
|
|
24
|
+
readonly path?: string;
|
|
25
|
+
/** A read is a `GET`; stated only for symmetry with an action's pin. */
|
|
26
|
+
readonly method?: 'GET';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Refuses at `query()` a pin no route could serve or no input could fill. */
|
|
30
|
+
export function assertQueryHttp(http: QueryHttp | undefined, input: unknown): void {
|
|
31
|
+
if (http === undefined) return;
|
|
32
|
+
const method: unknown = http.method;
|
|
33
|
+
const path: unknown = http.path;
|
|
34
|
+
const shown = renderCauseValue(path);
|
|
35
|
+
if (method !== undefined && method !== 'GET') {
|
|
36
|
+
throw new QueryHttpPathInvalidError({
|
|
37
|
+
name: '',
|
|
38
|
+
path: shown,
|
|
39
|
+
reason: `is declared with http.method ${renderCauseValue(method)}, and a read is a GET`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
if (path === undefined) {
|
|
43
|
+
if (method === undefined) return;
|
|
44
|
+
throw new QueryHttpPathInvalidError({
|
|
45
|
+
name: '',
|
|
46
|
+
path: shown,
|
|
47
|
+
reason: 'is absent, and http.method has no pinned path to answer on',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const reason =
|
|
51
|
+
pathTemplateProblem(path) ??
|
|
52
|
+
templateKeyProblem(templateParams(path as string), tryIntrospect(input)?.properties);
|
|
53
|
+
if (reason !== undefined) throw new QueryHttpPathInvalidError({ name: '', path: shown, reason });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The path `name` is served at: the pin, else the derived `/_x/query/<kebab>`. */
|
|
57
|
+
export function servedQueryPath(name: string, http: QueryHttp | undefined): string {
|
|
58
|
+
return http?.path ?? queryPath(name);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `{ path, method: 'GET' }` exactly when `target` pins its URL — the manifest's fact. */
|
|
62
|
+
export function queryHttpPinOf(
|
|
63
|
+
target: AnyQuery,
|
|
64
|
+
): { readonly path: string; readonly method: 'GET' } | undefined {
|
|
65
|
+
const path = defOf(target).http?.path;
|
|
66
|
+
return path === undefined ? undefined : { path, method: 'GET' };
|
|
67
|
+
}
|
package/src/http.ts
CHANGED
|
@@ -7,17 +7,26 @@
|
|
|
7
7
|
|
|
8
8
|
import { tagKeys } from '@ultimat3/cache';
|
|
9
9
|
import type { Deprecation } from '@ultimat3/core';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
mergePathParams,
|
|
12
|
+
queryPath,
|
|
13
|
+
recordDeprecatedCall,
|
|
14
|
+
renderDeprecation,
|
|
15
|
+
templateParams,
|
|
16
|
+
useContext,
|
|
17
|
+
} from '@ultimat3/core';
|
|
11
18
|
import type { RateLimitDecision, Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
|
|
12
19
|
import { publishRateLimit, toBucket } from '@ultimat3/http';
|
|
13
20
|
import { admitsAnonymous, policyCapability } from '@ultimat3/policy';
|
|
14
21
|
import { coerceQuery } from '@ultimat3/schema';
|
|
15
22
|
import { toDownloadRoute } from './download-http';
|
|
16
23
|
import { QueryDeprecationInvalidError, QueryInputInvalidError } from './errors';
|
|
24
|
+
import { withValidator } from './http-etag';
|
|
25
|
+
import { servedQueryPath } from './http-pin';
|
|
17
26
|
import { absentArraysOf } from './input-shape';
|
|
18
27
|
import { PAGE_FIRST_KEY, pageControlsOf } from './page-controls';
|
|
19
28
|
import type { AnyQuery, QueryRateLimit } from './query';
|
|
20
|
-
import { queryName, runQuery } from './read';
|
|
29
|
+
import { defOf, queryName, runQuery } from './read';
|
|
21
30
|
import { recordAnswerFor, recordRowAnswerFor } from './record-answer';
|
|
22
31
|
import { oneRowOf } from './single-answer';
|
|
23
32
|
|
|
@@ -34,8 +43,19 @@ export function toQueryRoute(target: AnyQuery): Route {
|
|
|
34
43
|
if (target.download === true) return toDownloadRoute(target, name, sunsetting);
|
|
35
44
|
const answer = recordAnswerFor(target.rows);
|
|
36
45
|
const answerRow = recordRowAnswerFor(target.rows);
|
|
46
|
+
// A pinned `http.path` replaces `/_x/query/<kebab>`; its `:param`s join the search string as
|
|
47
|
+
// the one input, and a search string naming one differently is `X_HTTP_PATH_INPUT_CONFLICT`.
|
|
48
|
+
const path = servedQueryPath(name, defOf(target).http);
|
|
49
|
+
const pinned = templateParams(path).length > 0;
|
|
50
|
+
const owner = `query "${name}"`;
|
|
51
|
+
const etagged = defOf(target).etag === true;
|
|
37
52
|
|
|
38
53
|
const handler = async (request: UltimateRequest): Promise<Response> => {
|
|
54
|
+
const answered = await answerRead(request);
|
|
55
|
+
return etagged ? withValidator(request, answered) : answered;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const answerRead = async (request: UltimateRequest): Promise<Response> => {
|
|
39
59
|
if (sunsetting !== undefined) {
|
|
40
60
|
recordDeprecatedCall('query', name);
|
|
41
61
|
// On the CONTEXT, before anything can fail: the pipeline's `response` stage merges
|
|
@@ -68,7 +88,19 @@ export function toQueryRoute(target: AnyQuery): Route {
|
|
|
68
88
|
onRateLimit: (decision: RateLimitDecision) =>
|
|
69
89
|
publishRateLimit(request.ctx, decision, useContext().now().getTime()),
|
|
70
90
|
} as const;
|
|
71
|
-
const
|
|
91
|
+
const search = request.queryRaw();
|
|
92
|
+
const { input: values, page } = pageControlsOf(
|
|
93
|
+
name,
|
|
94
|
+
pinned
|
|
95
|
+
? // The search values plus string params: still `SearchValues` by construction.
|
|
96
|
+
(mergePathParams({
|
|
97
|
+
owner,
|
|
98
|
+
template: path,
|
|
99
|
+
params: request.params,
|
|
100
|
+
raw: search,
|
|
101
|
+
}) as typeof search)
|
|
102
|
+
: search,
|
|
103
|
+
);
|
|
72
104
|
const input = absentArraysOf(target.input, coerceQuery(target.input, values));
|
|
73
105
|
if (target.single === true) {
|
|
74
106
|
// Refused, not ignored: a caller paging a read of one object has the wrong read in mind, and
|
|
@@ -122,6 +154,8 @@ export function toQueryRoute(target: AnyQuery): Route {
|
|
|
122
154
|
// one actor's rows to the next caller of that URL, so a read is `no-store` and a
|
|
123
155
|
// shared cache is something a CDN in front of the app configures knowingly. The tags
|
|
124
156
|
// ride along so a purge can still name the read the tier keys by.
|
|
157
|
+
// `etag: true` is the declared exception: its handler writes `private, no-cache` itself
|
|
158
|
+
// (`http-etag.ts`), which the stage leaves alone, and this hint is never reached.
|
|
125
159
|
cache: { mode: 'no-store', tags: tagKeys(target.cache?.tags ?? []) },
|
|
126
160
|
tags: ['query'],
|
|
127
161
|
// The read spends its own declared bucket on every surface (`read.ts`), so the stage spends
|
|
@@ -135,7 +169,7 @@ export function toQueryRoute(target: AnyQuery): Route {
|
|
|
135
169
|
...(target.mcp?.description === undefined ? {} : { description: target.mcp.description }),
|
|
136
170
|
};
|
|
137
171
|
|
|
138
|
-
return { method: 'GET', path
|
|
172
|
+
return { method: 'GET', path, handler, meta };
|
|
139
173
|
}
|
|
140
174
|
|
|
141
175
|
/** Validates the declaration through the limiter's own conversion, then hands the route its flag. */
|
package/src/index.ts
CHANGED
|
@@ -68,12 +68,15 @@ export { downloadClientFor, queryClient, queryClientMethodFor } from './client';
|
|
|
68
68
|
*/
|
|
69
69
|
export type { KindOf } from './column-kinds';
|
|
70
70
|
export { kindsOf } from './column-kinds';
|
|
71
|
+
export type { QueryContractTest, QueryContractTestOptions } from './contract-test';
|
|
72
|
+
export { queryContractTestsFor } from './contract-test';
|
|
71
73
|
/**
|
|
72
74
|
* `query({ download })` — a read answered by a FILE at a short-lived signed URL. Still a `query`:
|
|
73
75
|
* one policy, one name, one route (`GET /_x/query/<kebab>`: mint without `_token`, serve with it).
|
|
74
76
|
*/
|
|
75
77
|
export type {
|
|
76
78
|
DownloadArgs,
|
|
79
|
+
DownloadBodyOpener,
|
|
77
80
|
DownloadClientMethod,
|
|
78
81
|
DownloadFile,
|
|
79
82
|
DownloadQuery,
|
|
@@ -110,10 +113,22 @@ export {
|
|
|
110
113
|
QuerySubscribesInvalidError,
|
|
111
114
|
QueryUnregisteredError,
|
|
112
115
|
} from './errors';
|
|
116
|
+
export type {
|
|
117
|
+
QueryExerciseActorOptions,
|
|
118
|
+
QueryExerciseOffOptions,
|
|
119
|
+
QueryExerciseOptions,
|
|
120
|
+
QueryExerciseReport,
|
|
121
|
+
} from './exercise';
|
|
122
|
+
export { exerciseQuery } from './exercise';
|
|
113
123
|
/** The HTTP projection: `GET /_x/query/<kebab>`, the URL `client()` derives. */
|
|
114
124
|
export { toQueryRoute } from './http';
|
|
125
|
+
export type { QueryHttp } from './http-pin';
|
|
126
|
+
export { queryHttpPinOf } from './http-pin';
|
|
127
|
+
export { QueryHttpPathInvalidError } from './http-pin-error';
|
|
115
128
|
export type { LiveCursor, LiveQuery, ResumeMode, ResumePlan, ToLiveOptions } from './live';
|
|
116
129
|
export { planResume, seekOf, spendQueryLimit, toLiveQuery } from './live';
|
|
130
|
+
export type { LiveReadPhase } from './live-trace';
|
|
131
|
+
export { traceLiveRead } from './live-trace';
|
|
117
132
|
export type { ChangeEvent, ChangeOp, Patch } from './matcher';
|
|
118
133
|
export { assertMatchable, match, positionFor } from './matcher';
|
|
119
134
|
/**
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Single responsibility: the span a LIVE read runs in, continued from the subscriber's own
|
|
2
|
+
// `traceparent`. The snapshot answers the subscribe, so it is that trace's child; a re-run answers
|
|
3
|
+
// a change, not a request, so it LINKS to the subscriber and parents on whatever caused it.
|
|
4
|
+
|
|
5
|
+
import { parseTraceparent, withSpan } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/** Which read: the one a subscribe asked for, or one a change fanned out. */
|
|
8
|
+
export type LiveReadPhase = 'snapshot' | 'rerun';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Runs `read` inside `live.<phase> <query>`. `traceparent` is the value the client put on its
|
|
12
|
+
* `subscribe` frame; an invalid or absent one is IGNORED, as an unknown time zone is: a garbage
|
|
13
|
+
* header must cost the subscriber its trace, never its subscription.
|
|
14
|
+
*/
|
|
15
|
+
export function traceLiveRead<T>(
|
|
16
|
+
query: string,
|
|
17
|
+
phase: LiveReadPhase,
|
|
18
|
+
traceparent: string | null | undefined,
|
|
19
|
+
read: () => Promise<T>,
|
|
20
|
+
): Promise<T> {
|
|
21
|
+
const subscriber = parseTraceparent(traceparent);
|
|
22
|
+
const name = `live.${phase} ${query}`;
|
|
23
|
+
const attributes = { 'ultimate.primitive': 'query', 'ultimate.query': query };
|
|
24
|
+
if (phase === 'snapshot') {
|
|
25
|
+
return withSpan(name, read, {
|
|
26
|
+
kind: 'server',
|
|
27
|
+
attributes,
|
|
28
|
+
...(subscriber === undefined ? {} : { parent: subscriber }),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return withSpan(name, read, {
|
|
32
|
+
kind: 'internal',
|
|
33
|
+
attributes,
|
|
34
|
+
links: subscriber === undefined ? [] : [subscriber],
|
|
35
|
+
});
|
|
36
|
+
}
|
package/src/live.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { tagKeys } from '@ultimat3/cache';
|
|
10
10
|
import type { Ctx } from '@ultimat3/core';
|
|
11
|
+
import { appVersion } from '@ultimat3/core';
|
|
11
12
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
12
13
|
import type { Patch } from './matcher';
|
|
13
14
|
import { assertMatchable } from './matcher';
|
|
@@ -119,9 +120,9 @@ export function spendQueryLimit(
|
|
|
119
120
|
return spendReadLimit(queryName(target), target.rateLimit, { ...caller, surface: 'live' });
|
|
120
121
|
}
|
|
121
122
|
|
|
122
|
-
/** Changing the build changes the epoch, which forces reconnects to refetch
|
|
123
|
+
/** Changing the build changes the epoch, which forces reconnects to refetch: core's `APP_VERSION`. */
|
|
123
124
|
export function liveEpoch(): string {
|
|
124
|
-
return
|
|
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
|
+
}
|