@ultimat3/query 22.8.2 → 22.9.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/README.md +32 -0
- package/package.json +7 -7
- package/src/client.ts +26 -2
- package/src/errors.ts +31 -4
- package/src/facade.ts +19 -5
- package/src/http.ts +23 -3
- package/src/index.ts +4 -0
- package/src/live.ts +1 -1
- package/src/openapi.ts +41 -0
- package/src/pagination.ts +1 -1
- package/src/query.ts +47 -15
- package/src/read.ts +1 -1
- package/src/record-answer.ts +11 -0
- package/src/sql.ts +1 -1
package/README.md
CHANGED
|
@@ -59,6 +59,34 @@ on as the transport's `fetchImpl`. The transport owns `Accept`, the trace/budget
|
|
|
59
59
|
principal fence, and the error decode — a non-`problem+json` failure (a gateway's HTML) is core's
|
|
60
60
|
`X_CLIENT_TRANSPORT_FAILED`, a `problem+json` one is the server's own code, verbatim.
|
|
61
61
|
|
|
62
|
+
### One object — `single: true`
|
|
63
|
+
|
|
64
|
+
A detail read declares `single: true`. The route answers the first row as the body and **404
|
|
65
|
+
`X_NOT_FOUND`** when `sql` returns none (a list read answers `200 []`), refuses `_first`/`_after`
|
|
66
|
+
(400), and `openapi.json` documents one object with a `404` and no page controls. `.client()` and
|
|
67
|
+
`queryClient` type it `Promise<TRow>` with no `.page`. In process nothing moves: `postById({ id })`,
|
|
68
|
+
`.as()`, `.page()`, `.live()` and the MCP tool still answer `readonly TRow[]`, so opting in breaks no
|
|
69
|
+
`[0]` already written. `As of 2026-09-29`.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { can } from '@ultimat3/policy';
|
|
73
|
+
import { from, query, queryClient, t } from '@ultimat3/query';
|
|
74
|
+
|
|
75
|
+
type Post = { readonly id: string; readonly title: string };
|
|
76
|
+
declare const posts: readonly Post[];
|
|
77
|
+
|
|
78
|
+
export const postById = query({
|
|
79
|
+
input: t.object({ id: t.uuid }),
|
|
80
|
+
policy: can('post:read'),
|
|
81
|
+
single: true,
|
|
82
|
+
sql: ({ id }) => from<Post>('posts', posts).where({ id }).limit(1),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const queries = queryClient<{ postById: typeof postById }>({ baseUrl: '' });
|
|
86
|
+
const post: Post = await queries.postById({ id: '018f4a1c-1b2c-7d3e-8f90-abcdef012345' }); // or X_NOT_FOUND
|
|
87
|
+
const rows: readonly Post[] = await postById({ id: post.id }); // in process: still the rows
|
|
88
|
+
```
|
|
89
|
+
|
|
62
90
|
### Records — `rows:` puts a read's answer in the page's store
|
|
63
91
|
|
|
64
92
|
```ts
|
|
@@ -514,6 +542,8 @@ router feature here.
|
|
|
514
542
|
| `X_QUERY_NOT_PAGEABLE` | a paged or live read returned a row with no `id` | select the primary key: `db.<rows>.select({ id: true, … })` |
|
|
515
543
|
| `X_INPUT_INVALID` | input failed the Standard Schema | `x queries describe <name> --json` |
|
|
516
544
|
| `X_QUERY_UNREGISTERED` | used before `registerQueries()` ran | register at boot |
|
|
545
|
+
| `X_NOT_FOUND` | a `single: true` read matched no row — the HTTP route's 404 only (entity's code) | `x queries describe <name> --json` prints the SQL |
|
|
546
|
+
| `X_QUERY_SINGLE_INVALID` | `single:` declared as something other than a boolean | `single: true`, or drop the key |
|
|
517
547
|
| `X_QUERY_FOREIGN` | a look-alike was projected as a query | declare it with `query({ … })` |
|
|
518
548
|
| `X_CLIENT_TRANSPORT_FAILED` | `.client()` got no response, or a non-`problem+json` failure (core's code, since 21.0.0; was `X_RPC_FAILED`) | check the gateway in front of the app: `x doctor --json` |
|
|
519
549
|
|
|
@@ -537,6 +567,8 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
537
567
|
| `QueryInputUnencodableError` | `X_QUERY_INPUT_UNENCODABLE` | `src/errors.ts` |
|
|
538
568
|
| `QueryNotPageableError` | `X_QUERY_NOT_PAGEABLE` | `src/errors.ts` |
|
|
539
569
|
| `QueryPolicyMissingError` | `X_QUERY_POLICY_MISSING` | `src/errors.ts` |
|
|
570
|
+
| `QueryRowNotFoundError` | `X_NOT_FOUND` | `src/errors.ts` |
|
|
571
|
+
| `QuerySingleInvalidError` | `X_QUERY_SINGLE_INVALID` | `src/errors.ts` |
|
|
540
572
|
| `QuerySubscribesDriftError` | `X_QUERY_SUBSCRIBES_DRIFT` | `src/errors.ts` |
|
|
541
573
|
| `QuerySubscribesInvalidError` | `X_QUERY_SUBSCRIBES_INVALID` | `src/errors.ts` |
|
|
542
574
|
| `QueryUnregisteredError` | `X_QUERY_UNREGISTERED` | `src/errors.ts` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/query",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.9.0",
|
|
4
4
|
"description": "The query primitive: a policy-checked read, optionally live, with cursor pagination and an incremental matcher",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,11 +36,11 @@
|
|
|
36
36
|
"test": "bun test"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@ultimat3/cache": "22.
|
|
40
|
-
"@ultimat3/core": "22.
|
|
41
|
-
"@ultimat3/entity": "22.
|
|
42
|
-
"@ultimat3/http": "22.
|
|
43
|
-
"@ultimat3/policy": "22.
|
|
44
|
-
"@ultimat3/schema": "22.
|
|
39
|
+
"@ultimat3/cache": "22.9.0",
|
|
40
|
+
"@ultimat3/core": "22.9.0",
|
|
41
|
+
"@ultimat3/entity": "22.9.0",
|
|
42
|
+
"@ultimat3/http": "22.9.0",
|
|
43
|
+
"@ultimat3/policy": "22.9.0",
|
|
44
|
+
"@ultimat3/schema": "22.9.0"
|
|
45
45
|
}
|
|
46
46
|
}
|
package/src/client.ts
CHANGED
|
@@ -75,6 +75,26 @@ export interface QueryClientMethod<TInput extends StandardSchemaV1, TRow extends
|
|
|
75
75
|
): Promise<Page<TRow>>;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* A `single: true` read's method: the route answers the row itself, or 404 `X_NOT_FOUND` — which
|
|
80
|
+
* the transport rejects with, as it does every problem document. No `page`: the route refuses the
|
|
81
|
+
* page controls on a single read, so the method a list read carries would be a guaranteed 400.
|
|
82
|
+
*/
|
|
83
|
+
export type QuerySingleClientMethod<TInput extends StandardSchemaV1, TRow extends object> = (
|
|
84
|
+
input: InferInput<TInput>,
|
|
85
|
+
options?: QueryCallOptions,
|
|
86
|
+
) => Promise<TRow>;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The method a read's declaration projects to. DISTRIBUTIVE on purpose: for `boolean` it is the
|
|
90
|
+
* union, so a `Query<I, R, true>` is still assignable where `Query<I, R, boolean>` is asked for.
|
|
91
|
+
*/
|
|
92
|
+
export type QueryClientMethodOf<
|
|
93
|
+
TInput extends StandardSchemaV1,
|
|
94
|
+
TRow extends object,
|
|
95
|
+
TSingle extends boolean,
|
|
96
|
+
> = TSingle extends true ? QuerySingleClientMethod<TInput, TRow> : QueryClientMethod<TInput, TRow>;
|
|
97
|
+
|
|
78
98
|
/**
|
|
79
99
|
* Loose constraint on purpose: a map of concrete `Query<TInput, TRow>` values must be
|
|
80
100
|
* assignable to it, while `QueryClient<T>` still recovers each read's own input schema and
|
|
@@ -89,8 +109,12 @@ export type QueryMap = Record<string, QueryLike>;
|
|
|
89
109
|
|
|
90
110
|
/** `queries.publicPost({ slug })`, with the input schema and the row type both inferred. */
|
|
91
111
|
export type QueryClient<TQueries extends QueryMap> = {
|
|
92
|
-
readonly [K in keyof TQueries]: TQueries[K] extends Query<
|
|
93
|
-
|
|
112
|
+
readonly [K in keyof TQueries]: TQueries[K] extends Query<
|
|
113
|
+
infer TInput,
|
|
114
|
+
infer TRow,
|
|
115
|
+
infer TSingle extends boolean
|
|
116
|
+
>
|
|
117
|
+
? QueryClientMethodOf<TInput, TRow, TSingle>
|
|
94
118
|
: never;
|
|
95
119
|
};
|
|
96
120
|
|
package/src/errors.ts
CHANGED
|
@@ -27,15 +27,17 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
|
|
|
27
27
|
X_QUERY_SUBSCRIBES_DRIFT: 'a live query subscribes to a relation its own sql never reads',
|
|
28
28
|
X_QUERY_SUBSCRIBES_INVALID: 'a query declares subscribed relations no live read can use',
|
|
29
29
|
X_QUERY_UNREGISTERED: 'a query was used before it was registered',
|
|
30
|
+
X_QUERY_SINGLE_INVALID: 'a query declares single: as something other than a boolean',
|
|
30
31
|
};
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
|
-
* Codes
|
|
34
|
-
* enforcing an input schema —
|
|
35
|
-
*
|
|
34
|
+
* Codes another package owns that this one only throws. `X_INPUT_INVALID` describes an action's
|
|
35
|
+
* job — enforcing an input schema — and `X_NOT_FOUND` is `@ultimat3/entity`'s "no row for that
|
|
36
|
+
* id", which a `single: true` read answers over HTTP. The owner declares the title and query never
|
|
37
|
+
* re-declares it: two copies of a title are two titles, one of which is stale. `X_RPC_FAILED` left this list in
|
|
36
38
|
* 21.0.0: the typed read client's wire failures are `@ultimat3/core`'s `clientTransport`'s now.
|
|
37
39
|
*/
|
|
38
|
-
export const QUERY_BORROWED_ERROR_CODES = ['X_INPUT_INVALID'] as const;
|
|
40
|
+
export const QUERY_BORROWED_ERROR_CODES = ['X_INPUT_INVALID', 'X_NOT_FOUND'] as const;
|
|
39
41
|
|
|
40
42
|
// One unconditional call: a presence guard would turn "another package claims one of these codes"
|
|
41
43
|
// from an X_ERROR_CODE_DUPLICATE at import into whichever module loaded first deciding the title.
|
|
@@ -324,3 +326,28 @@ export class QueryInputInvalidError extends UltimateError {
|
|
|
324
326
|
});
|
|
325
327
|
}
|
|
326
328
|
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* A `single: true` read matched no row. The HTTP route's answer only — every in-process caller of
|
|
332
|
+
* the same read gets `[]`, as it always has. `@ultimat3/entity`'s code, so the status (404) and the
|
|
333
|
+
* title are the ones a repo's `findById` miss already answers, not a second "not found".
|
|
334
|
+
*/
|
|
335
|
+
export class QueryRowNotFoundError extends UltimateError {
|
|
336
|
+
constructor(name: string) {
|
|
337
|
+
super({
|
|
338
|
+
code: 'X_NOT_FOUND',
|
|
339
|
+
cause: `query "${name}" is declared single: true and its sql matched no row for this input`,
|
|
340
|
+
fix: `x queries describe ${name} --json # prints the SQL — a stale id, another tenant's row or a soft-deleted one matches nothing`,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export class QuerySingleInvalidError extends UltimateError {
|
|
346
|
+
constructor(value: unknown) {
|
|
347
|
+
super({
|
|
348
|
+
code: 'X_QUERY_SINGLE_INVALID',
|
|
349
|
+
cause: `a query declares single: as ${typeof value}, and only a boolean says whether it reads one object`,
|
|
350
|
+
fix: 'write single: true for a read of one object, or drop the key for a list read',
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
package/src/facade.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
10
|
+
import type { QueryClientMethodOf } from './client';
|
|
10
11
|
import { queryClientMethodFor } from './client';
|
|
11
12
|
import { toLiveQuery } from './live';
|
|
12
13
|
import { toQueryTool } from './mcp-tool';
|
|
@@ -18,13 +19,19 @@ import { queryName, runQuery } from './read';
|
|
|
18
19
|
* `self` is a thunk on purpose: the façade is attached while the query is still
|
|
19
20
|
* being assembled, so every method resolves the query when it is called, not now.
|
|
20
21
|
*/
|
|
21
|
-
export function facadeFor<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
export function facadeFor<
|
|
23
|
+
TInput extends StandardSchemaV1,
|
|
24
|
+
TRow extends object,
|
|
25
|
+
TSingle extends boolean,
|
|
26
|
+
>(
|
|
27
|
+
def: QueryDef<TInput, TRow, TSingle>,
|
|
28
|
+
self: () => Query<TInput, TRow, TSingle>,
|
|
29
|
+
): QueryFacade<TInput, TRow, TSingle> {
|
|
25
30
|
return {
|
|
26
31
|
input: def.input,
|
|
27
32
|
policy: def.policy,
|
|
33
|
+
// Only when declared, like every lifted field: a list read's surface is unchanged.
|
|
34
|
+
...(def.single === undefined ? {} : { single: def.single }),
|
|
28
35
|
...(def.subscribes === undefined ? {} : { subscribes: def.subscribes }),
|
|
29
36
|
...(def.rows === undefined ? {} : { rows: def.rows }),
|
|
30
37
|
...(def.cache === undefined ? {} : { cache: def.cache }),
|
|
@@ -39,6 +46,13 @@ export function facadeFor<TInput extends StandardSchemaV1, TRow extends object>(
|
|
|
39
46
|
page: (input, args) => paginate(self(), input, args),
|
|
40
47
|
live: (input, options) => toLiveQuery(self(), input, options),
|
|
41
48
|
tool: () => toQueryTool(self()),
|
|
42
|
-
|
|
49
|
+
// One implementation for both wire shapes: the route decides the body, the transport parses
|
|
50
|
+
// it, and only the TYPE differs — a single read's method answers the row, and has no `page`.
|
|
51
|
+
client: (options) =>
|
|
52
|
+
queryClientMethodFor<TInput, TRow>(queryName(self()), options) as QueryClientMethodOf<
|
|
53
|
+
TInput,
|
|
54
|
+
TRow,
|
|
55
|
+
TSingle
|
|
56
|
+
>,
|
|
43
57
|
};
|
|
44
58
|
}
|
package/src/http.ts
CHANGED
|
@@ -11,13 +11,17 @@ import { toBucket } from '@ultimat3/http';
|
|
|
11
11
|
import { coerceQuery } from '@ultimat3/schema';
|
|
12
12
|
import type { Deprecation } from './deprecation';
|
|
13
13
|
import { recordDeprecatedCall, renderDeprecation } from './deprecation';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
QueryDeprecationInvalidError,
|
|
16
|
+
QueryInputInvalidError,
|
|
17
|
+
QueryRowNotFoundError,
|
|
18
|
+
} from './errors';
|
|
15
19
|
import { derivePath } from './naming';
|
|
16
|
-
import { pageControlsOf } from './page-controls';
|
|
20
|
+
import { PAGE_FIRST_KEY, pageControlsOf } from './page-controls';
|
|
17
21
|
import { admitsAnonymous, policyCapability } from './policy-gate';
|
|
18
22
|
import type { AnyQuery } from './query';
|
|
19
23
|
import { queryName, runQuery } from './read';
|
|
20
|
-
import { recordAnswerFor } from './record-answer';
|
|
24
|
+
import { recordAnswerFor, recordRowAnswerFor } from './record-answer';
|
|
21
25
|
|
|
22
26
|
/**
|
|
23
27
|
* `liveFeed` -> `GET /_x/query/live-feed`. Named for the primitive rather than spelled
|
|
@@ -31,6 +35,7 @@ export function toQueryRoute(target: AnyQuery): Route {
|
|
|
31
35
|
// not a surprise on the first read.
|
|
32
36
|
const sunsetting = deprecationHeadersFor(name, target.deprecated);
|
|
33
37
|
const answer = recordAnswerFor(target.rows);
|
|
38
|
+
const answerRow = recordRowAnswerFor(target.rows);
|
|
34
39
|
|
|
35
40
|
const handler = async (request: UltimateRequest): Promise<Response> => {
|
|
36
41
|
if (sunsetting !== undefined) {
|
|
@@ -59,6 +64,21 @@ export function toQueryRoute(target: AnyQuery): Route {
|
|
|
59
64
|
// read's, and a schema that refused unknown keys would otherwise refuse every paged call.
|
|
60
65
|
const { input: values, page } = pageControlsOf(name, request.queryRaw());
|
|
61
66
|
const input = coerceQuery(target.input, values);
|
|
67
|
+
if (target.single === true) {
|
|
68
|
+
// Refused, not ignored: a caller paging a read of one object has the wrong read in mind, and
|
|
69
|
+
// a silently dropped `_first` would answer a shape it did not ask for.
|
|
70
|
+
if (page !== undefined) {
|
|
71
|
+
throw new QueryInputInvalidError(
|
|
72
|
+
name,
|
|
73
|
+
`${PAGE_FIRST_KEY} pages a list, and this read is declared single: true — it answers one row, or 404`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
// The first row — what every in-process `[0]` of the same read already takes. None is the
|
|
77
|
+
// 404 a detail URL means, where a list read answers `200 []`.
|
|
78
|
+
const [row] = await runQuery(target, input, { surface: 'http' });
|
|
79
|
+
if (row === undefined) throw new QueryRowNotFoundError(name);
|
|
80
|
+
return answerRow(row);
|
|
81
|
+
}
|
|
62
82
|
// With a page control the answer is the `Page` envelope `query.page()` answers a server
|
|
63
83
|
// caller with — `{ rows, endCursor, hasNextPage }`, the same names, so a cursor read off the
|
|
64
84
|
// wire and one read off a direct call are the same string in the same field. Without one the
|
package/src/index.ts
CHANGED
|
@@ -57,9 +57,11 @@ export type {
|
|
|
57
57
|
QueryCallOptions,
|
|
58
58
|
QueryClient,
|
|
59
59
|
QueryClientMethod,
|
|
60
|
+
QueryClientMethodOf,
|
|
60
61
|
QueryClientOptions,
|
|
61
62
|
QueryLike,
|
|
62
63
|
QueryMap,
|
|
64
|
+
QuerySingleClientMethod,
|
|
63
65
|
} from './client';
|
|
64
66
|
/** `queryClient` is the map-wide read client; `queryClientMethodFor` is what `.client()` binds. */
|
|
65
67
|
export { queryClient, queryClientMethodFor } from './client';
|
|
@@ -79,6 +81,8 @@ export {
|
|
|
79
81
|
QueryInputUnencodableError,
|
|
80
82
|
QueryNotPageableError,
|
|
81
83
|
QueryPolicyMissingError,
|
|
84
|
+
QueryRowNotFoundError,
|
|
85
|
+
QuerySingleInvalidError,
|
|
82
86
|
QuerySubscribesDriftError,
|
|
83
87
|
QuerySubscribesInvalidError,
|
|
84
88
|
QueryUnregisteredError,
|
package/src/live.ts
CHANGED
|
@@ -108,7 +108,7 @@ export function liveEpoch(): string {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
export async function toLiveQuery<TInput extends StandardSchemaV1, TRow extends object>(
|
|
111
|
-
target: Query<TInput, TRow>,
|
|
111
|
+
target: Query<TInput, TRow, boolean>,
|
|
112
112
|
input: unknown,
|
|
113
113
|
options: ToLiveOptions = {},
|
|
114
114
|
): Promise<LiveQuery> {
|
package/src/openapi.ts
CHANGED
|
@@ -76,6 +76,7 @@ export function queryOpenApiPaths(
|
|
|
76
76
|
*/
|
|
77
77
|
export function toQueryOpenApiOperation(target: AnyQuery): Record<string, unknown> {
|
|
78
78
|
const name = queryName(target);
|
|
79
|
+
if (target.single === true) return singleOperation(target, name);
|
|
79
80
|
return {
|
|
80
81
|
operationId: name,
|
|
81
82
|
tags: [QUERY_TAG],
|
|
@@ -116,6 +117,46 @@ export function toQueryOpenApiOperation(target: AnyQuery): Record<string, unknow
|
|
|
116
117
|
};
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
/**
|
|
121
|
+
* A `single: true` read: one object, a 404 when there is none, and no page controls — the route
|
|
122
|
+
* refuses them, so advertising them would document a guaranteed 400. Everything else — the input
|
|
123
|
+
* parameters, the record envelope, the `x-ultimate` block — is the list operation's, unchanged.
|
|
124
|
+
*/
|
|
125
|
+
function singleOperation(target: AnyQuery, name: string): Record<string, unknown> {
|
|
126
|
+
const records = answersRecords(target.rows);
|
|
127
|
+
return {
|
|
128
|
+
operationId: name,
|
|
129
|
+
tags: [QUERY_TAG],
|
|
130
|
+
summary: target.mcp?.description ?? name,
|
|
131
|
+
...(target.deprecated === undefined ? {} : { deprecated: true }),
|
|
132
|
+
parameters: inputParameters(target.input),
|
|
133
|
+
responses: {
|
|
134
|
+
'200': {
|
|
135
|
+
description: 'ok',
|
|
136
|
+
content: {
|
|
137
|
+
'application/json': {
|
|
138
|
+
schema: records ? recordEnvelopeSchema(SINGLE_ANSWER) : SINGLE_ANSWER,
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
...(records ? { headers: RECORDS_OPENAPI_HEADER } : {}),
|
|
142
|
+
},
|
|
143
|
+
'400': problemResponse('X_INPUT_INVALID'),
|
|
144
|
+
'403': problemResponse('policy denied'),
|
|
145
|
+
'404': problemResponse('X_NOT_FOUND — the read matched no row for this input'),
|
|
146
|
+
},
|
|
147
|
+
'x-ultimate': {
|
|
148
|
+
capability: policyCapability(target.policy),
|
|
149
|
+
live: target.isLive,
|
|
150
|
+
single: true,
|
|
151
|
+
cacheTags: tagKeys(target.cache?.tags ?? []),
|
|
152
|
+
tool: isExposed(target) ? name : null,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** One row. Its members are not published — a query declares no output schema, as `ANSWERS` says. */
|
|
158
|
+
const SINGLE_ANSWER: Readonly<Record<string, unknown>> = { type: 'object' };
|
|
159
|
+
|
|
119
160
|
/** The two controls, spelled from `page-controls.ts` so the document cannot drift from the route. */
|
|
120
161
|
const PAGE_PARAMETERS: readonly Record<string, unknown>[] = [
|
|
121
162
|
{
|
package/src/pagination.ts
CHANGED
|
@@ -36,7 +36,7 @@ export interface PaginateArgs extends SourceOptions {
|
|
|
36
36
|
* sliced after execution and the source is doing more work than it should.
|
|
37
37
|
*/
|
|
38
38
|
export async function paginate<TInput extends StandardSchemaV1, TRow extends object>(
|
|
39
|
-
target: Query<TInput, TRow>,
|
|
39
|
+
target: Query<TInput, TRow, boolean>,
|
|
40
40
|
input: unknown,
|
|
41
41
|
args: PaginateArgs,
|
|
42
42
|
): Promise<Page<TRow>> {
|
package/src/query.ts
CHANGED
|
@@ -12,9 +12,9 @@ import type { Actor, Ctx } from '@ultimat3/core';
|
|
|
12
12
|
import { fingerprint } from '@ultimat3/core';
|
|
13
13
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
14
14
|
import type { QueryCacheScope } from './cache';
|
|
15
|
-
import type {
|
|
15
|
+
import type { QueryClientMethodOf, QueryClientOptions } from './client';
|
|
16
16
|
import type { Deprecation } from './deprecation';
|
|
17
|
-
import { QueryCacheTtlInvalidError } from './errors';
|
|
17
|
+
import { QueryCacheTtlInvalidError, QuerySingleInvalidError } from './errors';
|
|
18
18
|
import { facadeFor } from './facade';
|
|
19
19
|
import { assertEncodableInput } from './input-shape';
|
|
20
20
|
import type { LiveQuery, ToLiveOptions } from './live';
|
|
@@ -86,11 +86,25 @@ export interface QueryRateLimit {
|
|
|
86
86
|
readonly windowMs: number;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
export interface QueryDef<
|
|
89
|
+
export interface QueryDef<
|
|
90
|
+
TInput extends StandardSchemaV1,
|
|
91
|
+
TRow extends object,
|
|
92
|
+
TSingle extends boolean = boolean,
|
|
93
|
+
> {
|
|
90
94
|
readonly input: TInput;
|
|
91
95
|
readonly policy: QueryPolicy;
|
|
92
96
|
/** `true` makes the read subscribable — see `toLiveQuery`. */
|
|
93
97
|
readonly live?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* `true` declares a read of ONE object — a detail page's row by id or slug. Only the WIRE
|
|
100
|
+
* changes: `GET /_x/query/<name>` answers that row itself (the first one `sql` returns), and
|
|
101
|
+
* **404 `X_NOT_FOUND`** when `sql` returns none, where a list read answers `200 []`; the OpenAPI
|
|
102
|
+
* operation documents one object and no `_first`/`_after`, which the route refuses (400). The
|
|
103
|
+
* typed client answers `Promise<TRow>`. Every in-process caller — `read(input)`, `.as()`,
|
|
104
|
+
* `.page()`, `.live()` and the MCP tool — keeps the rows it always had, so opting in breaks no
|
|
105
|
+
* `[0]` a server file already wrote. Omitted is `false`: a list.
|
|
106
|
+
*/
|
|
107
|
+
readonly single?: TSingle;
|
|
94
108
|
/**
|
|
95
109
|
* The relations this live read is patched from — the tables `x db gen` grants
|
|
96
110
|
* `REPLICA IDENTITY FULL`, without which logical replication carries no old row on an UPDATE
|
|
@@ -205,6 +219,7 @@ export interface AnyQueryDef {
|
|
|
205
219
|
readonly input: StandardSchemaV1;
|
|
206
220
|
readonly policy: QueryPolicy;
|
|
207
221
|
readonly live?: boolean;
|
|
222
|
+
readonly single?: boolean;
|
|
208
223
|
readonly subscribes?: readonly string[];
|
|
209
224
|
readonly rows?: StandardSchemaV1;
|
|
210
225
|
sql(input: unknown, ctx: Ctx): SqlSource<object>;
|
|
@@ -219,6 +234,8 @@ export interface AnyQuery {
|
|
|
219
234
|
readonly name: string;
|
|
220
235
|
/** Declared `live: true`. The subscription itself is `live()`. */
|
|
221
236
|
readonly isLive: boolean;
|
|
237
|
+
/** Declared `single: true` — the route answers one object or 404. Absent is a list read. */
|
|
238
|
+
readonly single?: boolean;
|
|
222
239
|
/** The declaration, minus `sql`: readable, and never a way to run it. */
|
|
223
240
|
readonly input: StandardSchemaV1;
|
|
224
241
|
readonly policy: QueryPolicy;
|
|
@@ -245,11 +262,14 @@ export interface AnyQuery {
|
|
|
245
262
|
export interface Query<
|
|
246
263
|
TInput extends StandardSchemaV1 = StandardSchemaV1,
|
|
247
264
|
TRow extends object = Record<string, unknown>,
|
|
265
|
+
TSingle extends boolean = false,
|
|
248
266
|
> extends AnyQuery {
|
|
249
267
|
/** Callable server-side with the same types the client and the MCP tool see. */
|
|
250
268
|
(input: InferInput<TInput>, options?: QueryOptions): Promise<readonly TRow[]>;
|
|
251
269
|
readonly input: TInput;
|
|
252
|
-
|
|
270
|
+
/** The declaration, as a type: what `.client()` and `queryClient` read the wire shape off. */
|
|
271
|
+
readonly single?: TSingle;
|
|
272
|
+
named(name: string): Query<TInput, TRow, TSingle>;
|
|
253
273
|
as(
|
|
254
274
|
actor: Actor | null,
|
|
255
275
|
input: InferInput<TInput>,
|
|
@@ -261,14 +281,19 @@ export interface Query<
|
|
|
261
281
|
* Typed against this query's input and row type, which is the whole point of it —
|
|
262
282
|
* so it lives here and not on the schema-erased `AnyQuery` view.
|
|
263
283
|
*/
|
|
264
|
-
client(options: QueryClientOptions):
|
|
284
|
+
client(options: QueryClientOptions): QueryClientMethodOf<TInput, TRow, TSingle>;
|
|
265
285
|
}
|
|
266
286
|
|
|
267
287
|
/** The fluent half of a query: lifted declaration plus one method per projection. */
|
|
268
|
-
export type QueryFacade<
|
|
269
|
-
|
|
288
|
+
export type QueryFacade<
|
|
289
|
+
TInput extends StandardSchemaV1,
|
|
290
|
+
TRow extends object,
|
|
291
|
+
TSingle extends boolean = false,
|
|
292
|
+
> = Pick<
|
|
293
|
+
Query<TInput, TRow, TSingle>,
|
|
270
294
|
| 'input'
|
|
271
295
|
| 'policy'
|
|
296
|
+
| 'single'
|
|
272
297
|
| 'subscribes'
|
|
273
298
|
| 'rows'
|
|
274
299
|
| 'cache'
|
|
@@ -282,15 +307,22 @@ export type QueryFacade<TInput extends StandardSchemaV1, TRow extends object> =
|
|
|
282
307
|
| 'client'
|
|
283
308
|
>;
|
|
284
309
|
|
|
285
|
-
export function query<
|
|
286
|
-
|
|
287
|
-
|
|
310
|
+
export function query<
|
|
311
|
+
TInput extends StandardSchemaV1,
|
|
312
|
+
TRow extends object,
|
|
313
|
+
TSingle extends boolean = false,
|
|
314
|
+
>(def: QueryDef<TInput, TRow, TSingle>): Query<TInput, TRow, TSingle> {
|
|
288
315
|
// Here and not in `toQueryRoute`: a read is projected to `GET /_x/query/<kebab>` whether or not
|
|
289
316
|
// anyone mounts it, and the typed client derives that same URL — so an input a query string
|
|
290
317
|
// cannot carry is wrong for every call, and the file that declared it is where it is repaired.
|
|
291
318
|
assertEncodableInput(def.input);
|
|
292
319
|
assertCacheTtl(def.cache);
|
|
293
320
|
assertSubscribes(def.subscribes, def.live);
|
|
321
|
+
// A JS caller or generated code can write `single: 'yes'`; truthiness would read it as `true`
|
|
322
|
+
// on the route and `false` in the type, so anything but a boolean is refused where it is written.
|
|
323
|
+
if (def.single !== undefined && typeof def.single !== 'boolean') {
|
|
324
|
+
throw new QuerySingleInvalidError(def.single);
|
|
325
|
+
}
|
|
294
326
|
// Snapshot and freeze BEFORE registration. `build()` stores `def` and `facadeFor()` exposes the
|
|
295
327
|
// same array, so a caller holding the literal it passed could mutate the list AFTER validation
|
|
296
328
|
// — and that list is what the manifest publishes and what `x db gen` grants REPLICA IDENTITY
|
|
@@ -335,20 +367,20 @@ export function nameQuery<Q extends AnyQuery>(target: Q, name: string): Q {
|
|
|
335
367
|
return target;
|
|
336
368
|
}
|
|
337
369
|
|
|
338
|
-
function build<TInput extends StandardSchemaV1, TRow extends object>(
|
|
339
|
-
def: QueryDef<TInput, TRow>,
|
|
370
|
+
function build<TInput extends StandardSchemaV1, TRow extends object, TSingle extends boolean>(
|
|
371
|
+
def: QueryDef<TInput, TRow, TSingle>,
|
|
340
372
|
name: string,
|
|
341
|
-
): Query<TInput, TRow> {
|
|
373
|
+
): Query<TInput, TRow, TSingle> {
|
|
342
374
|
const callable = (
|
|
343
375
|
input: InferInput<TInput>,
|
|
344
376
|
options: QueryOptions = {},
|
|
345
377
|
): Promise<readonly TRow[]> => runQuery(self, input, options);
|
|
346
378
|
|
|
347
|
-
const self: Query<TInput, TRow> = Object.assign(callable, {
|
|
379
|
+
const self: Query<TInput, TRow, TSingle> = Object.assign(callable, {
|
|
348
380
|
kind: 'query' as const,
|
|
349
381
|
isLive: def.live === true,
|
|
350
382
|
describe: (): QueryDescriptor => describeQuery(self),
|
|
351
|
-
named: (next: string): Query<TInput, TRow> => build(def, next),
|
|
383
|
+
named: (next: string): Query<TInput, TRow, TSingle> => build(def, next),
|
|
352
384
|
...facadeFor(def, () => self),
|
|
353
385
|
});
|
|
354
386
|
// `name` on a function is non-writable, so Object.assign cannot set it.
|
package/src/read.ts
CHANGED
|
@@ -65,7 +65,7 @@ export function queryName(target: AnyQuery): string {
|
|
|
65
65
|
|
|
66
66
|
/** Validate, authorize, then read — the same three steps on every surface. */
|
|
67
67
|
export function runQuery<TInput extends StandardSchemaV1, TRow extends object>(
|
|
68
|
-
target: Query<TInput, TRow>,
|
|
68
|
+
target: Query<TInput, TRow, boolean>,
|
|
69
69
|
raw: unknown,
|
|
70
70
|
options?: QueryOptions,
|
|
71
71
|
): Promise<readonly TRow[]>;
|
package/src/record-answer.ts
CHANGED
|
@@ -30,6 +30,17 @@ export function recordAnswerFor(rows: StandardSchemaV1 | undefined): RecordAnswe
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The same decision for a `single: true` read, whose answer is ONE row: the row itself, or the
|
|
35
|
+
* envelope with that row as `data` and its records beside it — the shape an action's record wire
|
|
36
|
+
* answers for a single output, so the transport unwraps both the same way.
|
|
37
|
+
*/
|
|
38
|
+
export function recordRowAnswerFor(rows: StandardSchemaV1 | undefined): (row: object) => Response {
|
|
39
|
+
if (!answersRecords(rows)) return (row) => json(row);
|
|
40
|
+
return (row) =>
|
|
41
|
+
json(encodeRecordEnvelope(row, collect(rows, [row])), { headers: { [RECORDS_HEADER]: '1' } });
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
/** The one predicate the route and the OpenAPI projection both ask: can this read answer records? */
|
|
34
45
|
export function answersRecords(rows: StandardSchemaV1 | undefined): rows is StandardSchemaV1 {
|
|
35
46
|
return rows !== undefined && hasEntityRows(rows);
|
package/src/sql.ts
CHANGED
|
@@ -22,7 +22,7 @@ export interface ExplainResult extends SqlText {
|
|
|
22
22
|
* surfaces that expose it (`/_x`, the CLI) are admin-gated in their own right.
|
|
23
23
|
*/
|
|
24
24
|
export async function explain<TInput extends StandardSchemaV1, TRow extends object>(
|
|
25
|
-
target: Query<TInput, TRow>,
|
|
25
|
+
target: Query<TInput, TRow, boolean>,
|
|
26
26
|
input: unknown,
|
|
27
27
|
ctx?: Ctx,
|
|
28
28
|
): Promise<ExplainResult> {
|