@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/query.ts
CHANGED
|
@@ -15,13 +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 {
|
|
18
|
+
import type { QueryContractTest, QueryContractTestOptions } from './contract-test';
|
|
19
|
+
import type { DownloadQuery, DownloadQueryDef, QueryDownload } from './download';
|
|
20
|
+
import { assertDownloadDef, buildDownloadQuery } from './download';
|
|
21
|
+
import { QuerySingleInvalidError } from './errors';
|
|
19
22
|
import { facadeFor } from './facade';
|
|
23
|
+
import { assertQueryHttp, type QueryHttp } from './http-pin';
|
|
20
24
|
import { assertEncodableInput } from './input-shape';
|
|
21
25
|
import type { LiveQuery, ToLiveOptions } from './live';
|
|
22
26
|
import type { Page, PaginateArgs } from './pagination';
|
|
23
27
|
import type { QueryPolicy, QuerySurface } from './policy-gate';
|
|
24
28
|
import { hasDef, queryName, runQuery, stashDef } from './read';
|
|
29
|
+
import { assertDownloadFreshness, assertFreshness } from './read-freshness';
|
|
25
30
|
import type { SqlSource } from './source';
|
|
26
31
|
import { assertSubscribes } from './subscribes';
|
|
27
32
|
|
|
@@ -34,6 +39,12 @@ export interface QueryCache {
|
|
|
34
39
|
* every read of that query forever. Omitted takes `DEFAULT_READ_CACHE_TTL_MS`.
|
|
35
40
|
*/
|
|
36
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;
|
|
37
48
|
/**
|
|
38
49
|
* Who a cached answer may be handed back to. **Defaults to `actor`**, and that default is the
|
|
39
50
|
* mechanism: a read that declares nothing gets the narrowest key, which is always correct.
|
|
@@ -100,6 +111,20 @@ export interface QueryDef<
|
|
|
100
111
|
readonly rows?: StandardSchemaV1<unknown, TRow>;
|
|
101
112
|
sql(input: InferOutput<TInput>, ctx: Ctx): SqlSource<TRow>;
|
|
102
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;
|
|
103
128
|
readonly mcp?: McpExposureDeclaration;
|
|
104
129
|
/** Burst and refill for THIS read's route, in the limiter's own vocabulary. */
|
|
105
130
|
readonly rateLimit?: QueryRateLimit;
|
|
@@ -118,6 +143,15 @@ export interface QueryDef<
|
|
|
118
143
|
* `X_QUERY_AUDIT_SINK_MISSING` before the input is parsed.
|
|
119
144
|
*/
|
|
120
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;
|
|
150
|
+
/**
|
|
151
|
+
* Absent on a rows read, by type: a declaration holding `sql` AND `download` — a variable, where
|
|
152
|
+
* no excess-property check reaches — must match neither `query()` overload. `download.ts`.
|
|
153
|
+
*/
|
|
154
|
+
readonly download?: never;
|
|
121
155
|
}
|
|
122
156
|
|
|
123
157
|
export interface QueryOptions {
|
|
@@ -190,6 +224,9 @@ export interface QueryDescriptor {
|
|
|
190
224
|
readonly ttlMs: number | null;
|
|
191
225
|
readonly rateLimit: QueryRateLimit | null;
|
|
192
226
|
readonly deprecated: Deprecation | null;
|
|
227
|
+
/** Declared `download:` — the read answers a file at a signed URL, never rows. */
|
|
228
|
+
readonly download: boolean;
|
|
229
|
+
readonly errors: readonly string[] | null;
|
|
193
230
|
}
|
|
194
231
|
|
|
195
232
|
/**
|
|
@@ -206,10 +243,15 @@ export interface AnyQueryDef {
|
|
|
206
243
|
readonly rows?: StandardSchemaV1;
|
|
207
244
|
sql(input: unknown, ctx: Ctx): SqlSource<object>;
|
|
208
245
|
readonly cache?: QueryCache;
|
|
246
|
+
readonly etag?: true;
|
|
247
|
+
readonly timeoutMs?: number;
|
|
209
248
|
readonly mcp?: McpExposureDeclaration;
|
|
210
249
|
readonly rateLimit?: QueryRateLimit;
|
|
211
250
|
readonly deprecated?: Deprecation;
|
|
212
251
|
readonly audit?: boolean;
|
|
252
|
+
readonly http?: QueryHttp;
|
|
253
|
+
/** A download's handler. Private like `sql`: `download.ts` reaches it through `defOf`. */
|
|
254
|
+
readonly download?: QueryDownload<StandardSchemaV1>;
|
|
213
255
|
}
|
|
214
256
|
|
|
215
257
|
export interface AnyQuery {
|
|
@@ -231,6 +273,9 @@ export interface AnyQuery {
|
|
|
231
273
|
/** Lifted so `toQueryRoute` reads the declaration without reaching through `defOf`. */
|
|
232
274
|
readonly rateLimit?: QueryRateLimit;
|
|
233
275
|
readonly deprecated?: Deprecation;
|
|
276
|
+
/** `true` on a `query({ download })` — the route mints a URL or serves a file. Absent otherwise. */
|
|
277
|
+
readonly download?: true;
|
|
278
|
+
readonly errors?: readonly string[];
|
|
234
279
|
describe(): QueryDescriptor;
|
|
235
280
|
/** A twin under another name. Registration names through `named`. */
|
|
236
281
|
named(name: string): AnyQuery;
|
|
@@ -264,6 +309,8 @@ export interface Query<
|
|
|
264
309
|
* so it lives here and not on the schema-erased `AnyQuery` view.
|
|
265
310
|
*/
|
|
266
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[];
|
|
267
314
|
}
|
|
268
315
|
|
|
269
316
|
/** The fluent half of a query: lifted declaration plus one method per projection. */
|
|
@@ -282,22 +329,44 @@ export type QueryFacade<
|
|
|
282
329
|
| 'mcp'
|
|
283
330
|
| 'rateLimit'
|
|
284
331
|
| 'deprecated'
|
|
332
|
+
| 'errors'
|
|
285
333
|
| 'as'
|
|
286
334
|
| 'page'
|
|
287
335
|
| 'live'
|
|
288
336
|
| 'client'
|
|
337
|
+
| 'contract'
|
|
289
338
|
>;
|
|
290
339
|
|
|
340
|
+
/**
|
|
341
|
+
* A read whose answer is a FILE at a short-lived signed URL — `download.ts`. Same primitive: one
|
|
342
|
+
* policy, one name, one route; `sql` and every rows option are refused beside it.
|
|
343
|
+
*/
|
|
344
|
+
export function query<TInput extends StandardSchemaV1>(
|
|
345
|
+
def: DownloadQueryDef<TInput>,
|
|
346
|
+
): DownloadQuery<TInput>;
|
|
347
|
+
export function query<
|
|
348
|
+
TInput extends StandardSchemaV1,
|
|
349
|
+
TRow extends object,
|
|
350
|
+
TSingle extends boolean = false,
|
|
351
|
+
>(def: QueryDef<TInput, TRow, TSingle>): Query<TInput, TRow, TSingle>;
|
|
291
352
|
export function query<
|
|
292
353
|
TInput extends StandardSchemaV1,
|
|
293
354
|
TRow extends object,
|
|
294
355
|
TSingle extends boolean = false,
|
|
295
|
-
>(
|
|
356
|
+
>(
|
|
357
|
+
def: QueryDef<TInput, TRow, TSingle> | DownloadQueryDef<TInput>,
|
|
358
|
+
): Query<TInput, TRow, TSingle> | DownloadQuery<TInput> {
|
|
296
359
|
// Here and not in `toQueryRoute`: a read is projected to `GET /_x/query/<kebab>` whether or not
|
|
297
360
|
// anyone mounts it, and the typed client derives that same URL — so an input a query string
|
|
298
361
|
// cannot carry is wrong for every call, and the file that declared it is where it is repaired.
|
|
299
362
|
assertEncodableInput(def.input);
|
|
300
|
-
|
|
363
|
+
if (!isDownloadDef(def)) assertQueryHttp(def.http, def.input);
|
|
364
|
+
if (isDownloadDef(def)) {
|
|
365
|
+
assertDownloadFreshness(def);
|
|
366
|
+
assertDownloadDef(def);
|
|
367
|
+
return buildDownloadQuery(def, '', describeQuery);
|
|
368
|
+
}
|
|
369
|
+
assertFreshness(def);
|
|
301
370
|
assertSubscribes(def.subscribes, def.live);
|
|
302
371
|
// A JS caller or generated code can write `single: 'yes'`; truthiness would read it as `true`
|
|
303
372
|
// on the route and `false` in the type, so anything but a boolean is refused where it is written.
|
|
@@ -312,16 +381,10 @@ export function query<
|
|
|
312
381
|
return build(subscribes === undefined ? def : { ...def, subscribes }, '');
|
|
313
382
|
}
|
|
314
383
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
* resolution — there is no "never expires" to fall back to.
|
|
320
|
-
*/
|
|
321
|
-
function assertCacheTtl(cache: QueryCache | undefined): void {
|
|
322
|
-
const ttlMs = cache?.ttlMs;
|
|
323
|
-
if (ttlMs === undefined) return;
|
|
324
|
-
if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new QueryCacheTtlInvalidError(ttlMs);
|
|
384
|
+
function isDownloadDef<TInput extends StandardSchemaV1>(
|
|
385
|
+
def: QueryDef<TInput, object, boolean> | DownloadQueryDef<TInput>,
|
|
386
|
+
): def is DownloadQueryDef<TInput> {
|
|
387
|
+
return 'download' in def && def.download !== undefined;
|
|
325
388
|
}
|
|
326
389
|
|
|
327
390
|
/**
|
|
@@ -384,6 +447,8 @@ export function describeQuery(target: AnyQuery): QueryDescriptor {
|
|
|
384
447
|
ttlMs: target.cache?.ttlMs ?? null,
|
|
385
448
|
rateLimit: target.rateLimit ?? null,
|
|
386
449
|
deprecated: target.deprecated ?? null,
|
|
450
|
+
download: target.download === true,
|
|
451
|
+
errors: target.errors ?? null,
|
|
387
452
|
};
|
|
388
453
|
}
|
|
389
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(
|
|
@@ -258,6 +304,46 @@ async function buildSource(
|
|
|
258
304
|
options: SourceOptions,
|
|
259
305
|
trace: ReadTrace | null,
|
|
260
306
|
): Promise<SqlSource<object>> {
|
|
307
|
+
const input = await admit(target, raw, ctx, options, trace);
|
|
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));
|
|
310
|
+
// A live window is served in the order its patches are placed in. The matcher breaks a tie on
|
|
311
|
+
// the declared keys with `id` (`totalOrder`) and so does the keyset re-read a reconnect resumes
|
|
312
|
+
// with, so an initial window served in the declared keys alone puts tied rows where neither of
|
|
313
|
+
// them would: the client renders one order and the next read answers another. A source that
|
|
314
|
+
// cannot say (`total` absent) already serves one order it can be resumed in.
|
|
315
|
+
return options.surface === 'live' && source.total !== undefined ? source.total() : source;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* One whole call for a read that is ANSWERED by something other than `sql` — a download's file,
|
|
320
|
+
* or the URL minted for it. The same front half every surface shares (actor, audit, the declared
|
|
321
|
+
* limit, the parse, the policy), so a download is not a second read path: only what is done with
|
|
322
|
+
* the admitted input differs. `options.unenforced` is honoured exactly as `sourceFor` honours it.
|
|
323
|
+
*/
|
|
324
|
+
export function admittedCall<T>(
|
|
325
|
+
target: AnyQuery,
|
|
326
|
+
raw: unknown,
|
|
327
|
+
options: SourceOptions,
|
|
328
|
+
use: (input: unknown, ctx: Ctx) => Promise<T>,
|
|
329
|
+
): Promise<T> {
|
|
330
|
+
return asActor(options, async (ctx) => {
|
|
331
|
+
const audit = auditFor(target, ctx, options);
|
|
332
|
+
if (audit === null) return use(await admit(target, raw, ctx, options, null), ctx);
|
|
333
|
+
return audited(audit, async () =>
|
|
334
|
+
use(await admit(target, raw, ctx, options, audit.trace), ctx),
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Everything before `sql()`: the actor half, the declared limit, the parse, the policy. */
|
|
340
|
+
async function admit(
|
|
341
|
+
target: AnyQuery,
|
|
342
|
+
raw: unknown,
|
|
343
|
+
ctx: Ctx,
|
|
344
|
+
options: SourceOptions,
|
|
345
|
+
trace: ReadTrace | null,
|
|
346
|
+
): Promise<unknown> {
|
|
261
347
|
const def = defOf(target);
|
|
262
348
|
const name = queryName(target);
|
|
263
349
|
const unenforced = options.unenforced;
|
|
@@ -273,7 +359,9 @@ async function buildSource(
|
|
|
273
359
|
} catch (denial) {
|
|
274
360
|
// The record still names what was attempted, as an action's does: the denial is already
|
|
275
361
|
// decided, so parsing now tells the CALLER nothing, and an unparseable payload is no input.
|
|
276
|
-
if (trace !== null)
|
|
362
|
+
if (trace !== null) {
|
|
363
|
+
trace.input = maskSensitive(def.input, await parsedOrNothing(def.input, raw), REDACTED);
|
|
364
|
+
}
|
|
277
365
|
throw denial;
|
|
278
366
|
}
|
|
279
367
|
}
|
|
@@ -291,7 +379,9 @@ async function buildSource(
|
|
|
291
379
|
});
|
|
292
380
|
}
|
|
293
381
|
const input = await validate(def.input, raw, name);
|
|
294
|
-
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);
|
|
295
385
|
if (unenforced === undefined) {
|
|
296
386
|
guardQuery(
|
|
297
387
|
def.policy,
|
|
@@ -312,13 +402,7 @@ async function buildSource(
|
|
|
312
402
|
// comparison when nothing is listening.
|
|
313
403
|
logger.debug('query.policy.unenforced', { query: name, reason: unenforced });
|
|
314
404
|
}
|
|
315
|
-
|
|
316
|
-
// A live window is served in the order its patches are placed in. The matcher breaks a tie on
|
|
317
|
-
// the declared keys with `id` (`totalOrder`) and so does the keyset re-read a reconnect resumes
|
|
318
|
-
// with, so an initial window served in the declared keys alone puts tied rows where neither of
|
|
319
|
-
// them would: the client renders one order and the next read answers another. A source that
|
|
320
|
-
// cannot say (`total` absent) already serves one order it can be resumed in.
|
|
321
|
-
return options.surface === 'live' && source.total !== undefined ? source.total() : source;
|
|
405
|
+
return input;
|
|
322
406
|
}
|
|
323
407
|
|
|
324
408
|
/** The parse without the refusal — only for the record of a read already refused on other grounds. */
|
package/src/record-answer.ts
CHANGED
|
@@ -25,8 +25,8 @@ export type RecordAnswer = (answer: readonly object[] | Page<object>) => Respons
|
|
|
25
25
|
export function recordAnswerFor(rows: StandardSchemaV1 | undefined): RecordAnswer {
|
|
26
26
|
if (!answersRecords(rows)) return (answer) => jsonResponse(answer, datesOf(answer));
|
|
27
27
|
return (answer) => {
|
|
28
|
-
const records = collect(rows, isPage(answer) ? answer.rows : answer);
|
|
29
|
-
return jsonResponse(encodeRecordEnvelope(answer, records), {
|
|
28
|
+
const { records, order } = collect(rows, isPage(answer) ? answer.rows : answer);
|
|
29
|
+
return jsonResponse(encodeRecordEnvelope(answer, records, undefined, order), {
|
|
30
30
|
headers: { [RECORDS_HEADER]: '1', ...datesOf(answer).headers },
|
|
31
31
|
});
|
|
32
32
|
};
|
|
@@ -49,10 +49,12 @@ function datesOf(data: unknown): { readonly headers: Record<string, string> } {
|
|
|
49
49
|
*/
|
|
50
50
|
export function recordRowAnswerFor(rows: StandardSchemaV1 | undefined): (row: object) => Response {
|
|
51
51
|
if (!answersRecords(rows)) return (row) => jsonResponse(row, datesOf(row));
|
|
52
|
-
return (row) =>
|
|
53
|
-
|
|
52
|
+
return (row) => {
|
|
53
|
+
const { records, order } = collect(rows, [row]);
|
|
54
|
+
return jsonResponse(encodeRecordEnvelope(row, records, undefined, order), {
|
|
54
55
|
headers: { [RECORDS_HEADER]: '1', ...datesOf(row).headers },
|
|
55
56
|
});
|
|
57
|
+
};
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
/** The one predicate the route and the OpenAPI projection both ask: can this read answer records? */
|
|
@@ -69,11 +71,18 @@ function isPage(answer: readonly object[] | Page<object>): answer is Page<object
|
|
|
69
71
|
* Every entity row across the answer's rows: record type -> record key -> row. First sighting wins,
|
|
70
72
|
* `rowsOf`'s own rule — one record shown twice is one record. Built in `Map`s and handed out as
|
|
71
73
|
* null-prototype objects: a record type and a record key are data, and `__proto__` must stay a key.
|
|
74
|
+
*
|
|
75
|
+
* `order` is each type's keys as the answer met them. An integer-like key is an array index to
|
|
76
|
+
* every object, so the records object re-sorts it; `encodeRecordEnvelope` sends the order only for
|
|
77
|
+
* a type whose object lost it, so a uuid-keyed answer's body is what it always was.
|
|
72
78
|
*/
|
|
73
79
|
function collect(
|
|
74
80
|
schema: StandardSchemaV1,
|
|
75
81
|
list: readonly object[],
|
|
76
|
-
):
|
|
82
|
+
): {
|
|
83
|
+
readonly records: Readonly<Record<string, RecordRows>>;
|
|
84
|
+
readonly order: Readonly<Record<string, readonly string[]>>;
|
|
85
|
+
} {
|
|
77
86
|
const out = new Map<string, Map<string, Row>>();
|
|
78
87
|
for (const row of list) {
|
|
79
88
|
for (const [type, byKey] of Object.entries(rowsOf(schema, row))) {
|
|
@@ -83,7 +92,10 @@ function collect(
|
|
|
83
92
|
if (!bucket.has(key)) bucket.set(key, found);
|
|
84
93
|
}
|
|
85
94
|
}
|
|
86
|
-
return
|
|
95
|
+
return {
|
|
96
|
+
records: nullProto([...out].map(([type, bucket]) => [type, nullProto(bucket)] as const)),
|
|
97
|
+
order: nullProto([...out].map(([type, bucket]) => [type, [...bucket.keys()]] as const)),
|
|
98
|
+
};
|
|
87
99
|
}
|
|
88
100
|
|
|
89
101
|
function nullProto<V>(entries: Iterable<readonly [string, V]>): Readonly<Record<string, V>> {
|
package/src/registry.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* identifier the source file uses.
|
|
5
5
|
*/
|
|
6
6
|
import { registerPrimitiveRegistrar } from '@ultimat3/core';
|
|
7
|
+
import { assertNoSensitiveOutput } from '@ultimat3/schema';
|
|
7
8
|
import { QueryDuplicateError, QueryPolicyMissingError } from './errors';
|
|
8
9
|
import type { AnyQuery, QueryDescriptor } from './query';
|
|
9
10
|
import { isQuery, nameQuery } from './query';
|
|
@@ -28,6 +29,9 @@ export function registerQuery<Q extends AnyQuery>(name: string, target: Q): Q {
|
|
|
28
29
|
if (target.policy === undefined || target.policy === null) {
|
|
29
30
|
throw new QueryPolicyMissingError(name);
|
|
30
31
|
}
|
|
32
|
+
// Rows are sent back, which `.sensitive()` (`writeOnly`) forbids. PII read back and only hidden
|
|
33
|
+
// in logs is `redactKeys`, not this flag.
|
|
34
|
+
if (target.rows !== undefined) assertNoSensitiveOutput(`query "${name}" rows`, target.rows);
|
|
31
35
|
const named = nameQuery(target, name);
|
|
32
36
|
registry.set(name, named);
|
|
33
37
|
return named;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The read's input as a search string — the ONE encoding the typed client sends and a download's
|
|
2
|
+
// minted URL carries, so the route's `coerceQuery` reads both back the same way. A leaf: the
|
|
3
|
+
// browser client imports it, and it imports nothing that registers a code table.
|
|
4
|
+
|
|
5
|
+
import { isJsonObject } from '@ultimat3/core/page';
|
|
6
|
+
import type { PageControls } from './page-controls';
|
|
7
|
+
import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-keys';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Input as a query string. Keys are sorted so the same input always produces the
|
|
11
|
+
* same URL — a GET is a cache key, and an unstable one caches nothing.
|
|
12
|
+
*
|
|
13
|
+
* The page controls come LAST, after the sorted input and in a fixed order, so a paged URL is the
|
|
14
|
+
* plain URL with a suffix — the same dedup key for the same page, and a log line a reader can
|
|
15
|
+
* split at `_first=` to recover the read underneath.
|
|
16
|
+
*
|
|
17
|
+
* An EMPTY array sends nothing, and the route reads a required array nobody sent as `[]`
|
|
18
|
+
* (`absentArraysOf`, `input-shape.ts`) — the two halves of one rule, so `{ tags: [] }` arrives.
|
|
19
|
+
*/
|
|
20
|
+
export function searchOf(input: unknown, page?: PageControls): string {
|
|
21
|
+
const params = new URLSearchParams();
|
|
22
|
+
if (isJsonObject(input)) {
|
|
23
|
+
for (const key of Object.keys(input).sort()) {
|
|
24
|
+
const value = input[key];
|
|
25
|
+
if (value === undefined || value === null) continue;
|
|
26
|
+
for (const item of Array.isArray(value) ? (value as readonly unknown[]) : [value]) {
|
|
27
|
+
params.append(key, wireText(item));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (page !== undefined) {
|
|
32
|
+
params.append(PAGE_FIRST_KEY, String(page.first));
|
|
33
|
+
if (page.after !== undefined) params.append(PAGE_AFTER_KEY, page.after);
|
|
34
|
+
}
|
|
35
|
+
return params.toString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One value as the characters `coerceQuery` reads back. A `Date` is its ISO instant: through
|
|
40
|
+
* `JSON.stringify` it arrived wrapped in quotes, which `t.date` refuses on the far side. An
|
|
41
|
+
* Invalid Date has no instant, and `toISOString()` throws a bare `RangeError` on it, so it is sent
|
|
42
|
+
* as the text it prints and the read's own schema says what is wrong with it.
|
|
43
|
+
*/
|
|
44
|
+
function wireText(item: unknown): string {
|
|
45
|
+
if (item instanceof Date) return Number.isNaN(item.getTime()) ? String(item) : item.toISOString();
|
|
46
|
+
return typeof item === 'object' ? JSON.stringify(item) : String(item);
|
|
47
|
+
}
|
package/src/sql.ts
CHANGED
|
@@ -62,7 +62,8 @@ export async function describeSql(
|
|
|
62
62
|
// `Object.hasOwn`, because `samples` is a caller's object literal and a name it never carried
|
|
63
63
|
// — `constructor`, `toString` — would otherwise resolve to a FUNCTION and be parsed as input.
|
|
64
64
|
const sample = Object.hasOwn(samples, name) ? samples[name] : undefined;
|
|
65
|
-
|
|
65
|
+
// A download has no statement to render, sample or not: it answers a file.
|
|
66
|
+
if (sample === undefined || target.download === true) {
|
|
66
67
|
entries.push({ query: name, live: target.isLive, sql: null });
|
|
67
68
|
continue;
|
|
68
69
|
}
|
package/src/type-pins.ts
CHANGED
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
|
|
6
6
|
import type { McpExposureDeclaration } from '@ultimat3/core';
|
|
7
7
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
8
|
+
import type { QueryClient } from './client';
|
|
9
|
+
import type {
|
|
10
|
+
DownloadClientMethod,
|
|
11
|
+
DownloadQuery,
|
|
12
|
+
DownloadQueryDef,
|
|
13
|
+
QueryDownload,
|
|
14
|
+
} from './download';
|
|
8
15
|
import type { AnyQuery, Query, QueryDef } from './query';
|
|
9
16
|
|
|
10
17
|
/** Fails to compile when `T` is anything but `true`. The whole mechanism. */
|
|
@@ -60,3 +67,41 @@ export type _NoQueryMcpTwin = import('./index').QueryMcp;
|
|
|
60
67
|
export type _NoQueryMcpAnnotationsTwin = import('./index').QueryMcpAnnotations;
|
|
61
68
|
// @ts-expect-error — `QueryListParams` is core's `McpListParams`.
|
|
62
69
|
export type _NoQueryListParamsTwin = import('./index').QueryListParams;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A download query's client is `url()` and nothing else: no rows call, no `page` — the route
|
|
73
|
+
* answers a minted URL or a file, never rows, so a method that promised rows would be a lie.
|
|
74
|
+
*/
|
|
75
|
+
type DownloadInput = StandardSchemaV1<{ runId: string }, { runId: string }>;
|
|
76
|
+
export type _DownloadClientIsUrlOnly = Assert<
|
|
77
|
+
Identical<
|
|
78
|
+
QueryClient<{ rec: DownloadQuery<DownloadInput> }>['rec'],
|
|
79
|
+
DownloadClientMethod<DownloadInput>
|
|
80
|
+
>
|
|
81
|
+
>;
|
|
82
|
+
/** And a download declares no rows option: `sql` beside `download` does not compile. */
|
|
83
|
+
export type _DownloadDeclaresNoSql = Assert<
|
|
84
|
+
Equals<NonNullable<DownloadQueryDef<DownloadInput>['sql']>, never>
|
|
85
|
+
>;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* And the other way: a rows declaration names `download?: never`, so a VARIABLE holding both `sql`
|
|
89
|
+
* and `download` (no excess-property check reaches one) matches neither overload — a compile
|
|
90
|
+
* error where it is written, never `X_QUERY_DOWNLOAD_INVALID` on the first call.
|
|
91
|
+
*/
|
|
92
|
+
type RowsDownload =
|
|
93
|
+
QueryDef<DownloadInput, object> extends { readonly download?: infer D } ? D : 'absent';
|
|
94
|
+
export type _RowsDeclareNoDownload = Assert<Equals<NonNullable<RowsDownload>, never>>;
|
|
95
|
+
export type _BothDoNotCompile = Assert<
|
|
96
|
+
Equals<
|
|
97
|
+
{
|
|
98
|
+
input: DownloadInput;
|
|
99
|
+
policy: QueryDef<DownloadInput, object>['policy'];
|
|
100
|
+
sql: () => never;
|
|
101
|
+
download: QueryDownload<DownloadInput>;
|
|
102
|
+
} extends QueryDef<DownloadInput, object> | DownloadQueryDef<DownloadInput>
|
|
103
|
+
? true
|
|
104
|
+
: false,
|
|
105
|
+
false
|
|
106
|
+
>
|
|
107
|
+
>;
|