@ultimat3/query 29.3.1 → 29.4.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 +19 -0
- package/README.md +67 -0
- package/package.json +7 -7
- package/src/client.ts +48 -51
- package/src/download-errors.ts +65 -0
- package/src/download-http.ts +226 -0
- package/src/download-token.ts +216 -0
- package/src/download.ts +242 -0
- package/src/errors.ts +4 -0
- package/src/http.ts +3 -1
- package/src/index.ts +26 -1
- package/src/input-shape.ts +4 -0
- package/src/openapi.ts +64 -0
- package/src/query.ts +39 -1
- package/src/read.ts +40 -7
- 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,6 +15,8 @@ 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 { DownloadQuery, DownloadQueryDef, QueryDownload } from './download';
|
|
19
|
+
import { assertDownloadDef, buildDownloadQuery } from './download';
|
|
18
20
|
import { QueryCacheTtlInvalidError, QuerySingleInvalidError } from './errors';
|
|
19
21
|
import { facadeFor } from './facade';
|
|
20
22
|
import { assertEncodableInput } from './input-shape';
|
|
@@ -118,6 +120,11 @@ export interface QueryDef<
|
|
|
118
120
|
* `X_QUERY_AUDIT_SINK_MISSING` before the input is parsed.
|
|
119
121
|
*/
|
|
120
122
|
readonly audit?: boolean;
|
|
123
|
+
/**
|
|
124
|
+
* Absent on a rows read, by type: a declaration holding `sql` AND `download` — a variable, where
|
|
125
|
+
* no excess-property check reaches — must match neither `query()` overload. `download.ts`.
|
|
126
|
+
*/
|
|
127
|
+
readonly download?: never;
|
|
121
128
|
}
|
|
122
129
|
|
|
123
130
|
export interface QueryOptions {
|
|
@@ -190,6 +197,8 @@ export interface QueryDescriptor {
|
|
|
190
197
|
readonly ttlMs: number | null;
|
|
191
198
|
readonly rateLimit: QueryRateLimit | null;
|
|
192
199
|
readonly deprecated: Deprecation | null;
|
|
200
|
+
/** Declared `download:` — the read answers a file at a signed URL, never rows. */
|
|
201
|
+
readonly download: boolean;
|
|
193
202
|
}
|
|
194
203
|
|
|
195
204
|
/**
|
|
@@ -210,6 +219,8 @@ export interface AnyQueryDef {
|
|
|
210
219
|
readonly rateLimit?: QueryRateLimit;
|
|
211
220
|
readonly deprecated?: Deprecation;
|
|
212
221
|
readonly audit?: boolean;
|
|
222
|
+
/** A download's handler. Private like `sql`: `download.ts` reaches it through `defOf`. */
|
|
223
|
+
readonly download?: QueryDownload<StandardSchemaV1>;
|
|
213
224
|
}
|
|
214
225
|
|
|
215
226
|
export interface AnyQuery {
|
|
@@ -231,6 +242,8 @@ export interface AnyQuery {
|
|
|
231
242
|
/** Lifted so `toQueryRoute` reads the declaration without reaching through `defOf`. */
|
|
232
243
|
readonly rateLimit?: QueryRateLimit;
|
|
233
244
|
readonly deprecated?: Deprecation;
|
|
245
|
+
/** `true` on a `query({ download })` — the route mints a URL or serves a file. Absent otherwise. */
|
|
246
|
+
readonly download?: true;
|
|
234
247
|
describe(): QueryDescriptor;
|
|
235
248
|
/** A twin under another name. Registration names through `named`. */
|
|
236
249
|
named(name: string): AnyQuery;
|
|
@@ -288,15 +301,33 @@ export type QueryFacade<
|
|
|
288
301
|
| 'client'
|
|
289
302
|
>;
|
|
290
303
|
|
|
304
|
+
/**
|
|
305
|
+
* A read whose answer is a FILE at a short-lived signed URL — `download.ts`. Same primitive: one
|
|
306
|
+
* policy, one name, one route; `sql` and every rows option are refused beside it.
|
|
307
|
+
*/
|
|
308
|
+
export function query<TInput extends StandardSchemaV1>(
|
|
309
|
+
def: DownloadQueryDef<TInput>,
|
|
310
|
+
): DownloadQuery<TInput>;
|
|
291
311
|
export function query<
|
|
292
312
|
TInput extends StandardSchemaV1,
|
|
293
313
|
TRow extends object,
|
|
294
314
|
TSingle extends boolean = false,
|
|
295
|
-
>(def: QueryDef<TInput, TRow, TSingle>): Query<TInput, TRow, TSingle
|
|
315
|
+
>(def: QueryDef<TInput, TRow, TSingle>): Query<TInput, TRow, TSingle>;
|
|
316
|
+
export function query<
|
|
317
|
+
TInput extends StandardSchemaV1,
|
|
318
|
+
TRow extends object,
|
|
319
|
+
TSingle extends boolean = false,
|
|
320
|
+
>(
|
|
321
|
+
def: QueryDef<TInput, TRow, TSingle> | DownloadQueryDef<TInput>,
|
|
322
|
+
): Query<TInput, TRow, TSingle> | DownloadQuery<TInput> {
|
|
296
323
|
// Here and not in `toQueryRoute`: a read is projected to `GET /_x/query/<kebab>` whether or not
|
|
297
324
|
// anyone mounts it, and the typed client derives that same URL — so an input a query string
|
|
298
325
|
// cannot carry is wrong for every call, and the file that declared it is where it is repaired.
|
|
299
326
|
assertEncodableInput(def.input);
|
|
327
|
+
if (isDownloadDef(def)) {
|
|
328
|
+
assertDownloadDef(def);
|
|
329
|
+
return buildDownloadQuery(def, '', describeQuery);
|
|
330
|
+
}
|
|
300
331
|
assertCacheTtl(def.cache);
|
|
301
332
|
assertSubscribes(def.subscribes, def.live);
|
|
302
333
|
// A JS caller or generated code can write `single: 'yes'`; truthiness would read it as `true`
|
|
@@ -312,6 +343,12 @@ export function query<
|
|
|
312
343
|
return build(subscribes === undefined ? def : { ...def, subscribes }, '');
|
|
313
344
|
}
|
|
314
345
|
|
|
346
|
+
function isDownloadDef<TInput extends StandardSchemaV1>(
|
|
347
|
+
def: QueryDef<TInput, object, boolean> | DownloadQueryDef<TInput>,
|
|
348
|
+
): def is DownloadQueryDef<TInput> {
|
|
349
|
+
return 'download' in def && def.download !== undefined;
|
|
350
|
+
}
|
|
351
|
+
|
|
315
352
|
/**
|
|
316
353
|
* The same rule, for the same reason: a lease every `CacheTier` refuses is wrong for every read of
|
|
317
354
|
* this query, so it fails on the line that wrote it rather than on the first request. Positive and
|
|
@@ -384,6 +421,7 @@ export function describeQuery(target: AnyQuery): QueryDescriptor {
|
|
|
384
421
|
ttlMs: target.cache?.ttlMs ?? null,
|
|
385
422
|
rateLimit: target.rateLimit ?? null,
|
|
386
423
|
deprecated: target.deprecated ?? null,
|
|
424
|
+
download: target.download === true,
|
|
387
425
|
};
|
|
388
426
|
}
|
|
389
427
|
|
package/src/read.ts
CHANGED
|
@@ -258,6 +258,45 @@ async function buildSource(
|
|
|
258
258
|
options: SourceOptions,
|
|
259
259
|
trace: ReadTrace | null,
|
|
260
260
|
): Promise<SqlSource<object>> {
|
|
261
|
+
const input = await admit(target, raw, ctx, options, trace);
|
|
262
|
+
const source = defOf(target).sql(input, ctx);
|
|
263
|
+
// A live window is served in the order its patches are placed in. The matcher breaks a tie on
|
|
264
|
+
// the declared keys with `id` (`totalOrder`) and so does the keyset re-read a reconnect resumes
|
|
265
|
+
// with, so an initial window served in the declared keys alone puts tied rows where neither of
|
|
266
|
+
// them would: the client renders one order and the next read answers another. A source that
|
|
267
|
+
// cannot say (`total` absent) already serves one order it can be resumed in.
|
|
268
|
+
return options.surface === 'live' && source.total !== undefined ? source.total() : source;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* One whole call for a read that is ANSWERED by something other than `sql` — a download's file,
|
|
273
|
+
* or the URL minted for it. The same front half every surface shares (actor, audit, the declared
|
|
274
|
+
* limit, the parse, the policy), so a download is not a second read path: only what is done with
|
|
275
|
+
* the admitted input differs. `options.unenforced` is honoured exactly as `sourceFor` honours it.
|
|
276
|
+
*/
|
|
277
|
+
export function admittedCall<T>(
|
|
278
|
+
target: AnyQuery,
|
|
279
|
+
raw: unknown,
|
|
280
|
+
options: SourceOptions,
|
|
281
|
+
use: (input: unknown, ctx: Ctx) => Promise<T>,
|
|
282
|
+
): Promise<T> {
|
|
283
|
+
return asActor(options, async (ctx) => {
|
|
284
|
+
const audit = auditFor(target, ctx, options);
|
|
285
|
+
if (audit === null) return use(await admit(target, raw, ctx, options, null), ctx);
|
|
286
|
+
return audited(audit, async () =>
|
|
287
|
+
use(await admit(target, raw, ctx, options, audit.trace), ctx),
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Everything before `sql()`: the actor half, the declared limit, the parse, the policy. */
|
|
293
|
+
async function admit(
|
|
294
|
+
target: AnyQuery,
|
|
295
|
+
raw: unknown,
|
|
296
|
+
ctx: Ctx,
|
|
297
|
+
options: SourceOptions,
|
|
298
|
+
trace: ReadTrace | null,
|
|
299
|
+
): Promise<unknown> {
|
|
261
300
|
const def = defOf(target);
|
|
262
301
|
const name = queryName(target);
|
|
263
302
|
const unenforced = options.unenforced;
|
|
@@ -312,13 +351,7 @@ async function buildSource(
|
|
|
312
351
|
// comparison when nothing is listening.
|
|
313
352
|
logger.debug('query.policy.unenforced', { query: name, reason: unenforced });
|
|
314
353
|
}
|
|
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;
|
|
354
|
+
return input;
|
|
322
355
|
}
|
|
323
356
|
|
|
324
357
|
/** The parse without the refusal — only for the record of a read already refused on other grounds. */
|
|
@@ -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
|
+
>;
|