@ultimat3/query 19.1.3 → 19.3.1
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 -1
- package/package.json +6 -6
- package/src/client.ts +45 -14
- package/src/http.ts +17 -2
- package/src/index.ts +11 -0
- package/src/input-shape.ts +7 -0
- package/src/openapi.ts +156 -0
- package/src/page-controls.ts +112 -0
- package/src/pagination.ts +3 -9
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ Every projection is a method on the query itself. A query has no `.def`.
|
|
|
30
30
|
| `liveFeed.page({ orgId }, { first: 20, after })` | one bounded page plus the signed cursor that continues it. There is no `offset` |
|
|
31
31
|
| `liveFeed.live({ orgId })` | the `LiveQuery` `@ultimat3/realtime` subscribes to, carrying the same policy object |
|
|
32
32
|
| `liveFeed.tool()` | the MCP read tool, named `liveFeed`. `tool().policy === liveFeed.policy`, and it reads fresh |
|
|
33
|
-
| `liveFeed.client({ baseUrl })` | `GET /_x/query/live-feed?orgId=…`, typed both ways |
|
|
33
|
+
| `liveFeed.client({ baseUrl })` | `GET /_x/query/live-feed?orgId=…`, typed both ways. `.page(input, { first, after })` on the same method reads one page over the wire |
|
|
34
34
|
| `liveFeed.describe()` | the manifest row |
|
|
35
35
|
|
|
36
36
|
The route on the other end of that client is `toQueryRoute(liveFeed)`, and — `As of 2026-08` —
|
|
@@ -132,6 +132,8 @@ forgetting the policy, and the reason is what tells the next reader which of the
|
|
|
132
132
|
| `mcp-tool.ts` | the MCP read descriptor |
|
|
133
133
|
| `client.ts` | the typed read client (browser-safe) |
|
|
134
134
|
| `http.ts` | the route projection — `GET /_x/query/<kebab>`, the URL the client derives |
|
|
135
|
+
| `page-controls.ts` | `_first` / `_after` — the two search-string keys that page the route, split out and judged at the wire; `MAX_PAGE_SIZE` |
|
|
136
|
+
| `openapi.ts` | the read half of `openapi.json` — one `GET` path item per registered query |
|
|
135
137
|
| `naming.ts` | export name → wire path. The MCP tool name is the export name verbatim |
|
|
136
138
|
| `live.ts` | the `LiveQuery` descriptor `@ultimat3/realtime` subscribes to |
|
|
137
139
|
| `matcher.ts` | change event → minimal patch (`add` / `update` / `remove` / `refill`) |
|
|
@@ -267,6 +269,35 @@ HMAC-signed, and bound to one query + arguments — a cursor from another query
|
|
|
267
269
|
`query.page(input, { first, after })` is the only way to ask for one — `paginate` backs it and is
|
|
268
270
|
not exported, because a page is the read's own answer rather than an imported helper.
|
|
269
271
|
|
|
272
|
+
### The same page over HTTP — `?_first=` and `?_after=`
|
|
273
|
+
|
|
274
|
+
The route reads two controls off the search string BEFORE the schema sees it, and with either
|
|
275
|
+
present answers the `Page` envelope `.page()` answers a server caller with — `{ rows, endCursor,
|
|
276
|
+
hasNextPage }`, the same names, so a cursor read off the wire and one read off a direct call are
|
|
277
|
+
one string in one field. Without a control the answer is the bare array it has always been, so a
|
|
278
|
+
client written before the controls existed keeps reading rows. `As of 2026-09`; until then the
|
|
279
|
+
route had no envelope, and a query over a paged external source carried its page marker on a row
|
|
280
|
+
(an `olderCursor` column on the oldest message), which is a cursor on the wrong side of the shape.
|
|
281
|
+
|
|
282
|
+
```
|
|
283
|
+
GET /_x/query/live-feed?orgId=…&_first=20 → { rows, endCursor, hasNextPage }
|
|
284
|
+
GET /_x/query/live-feed?orgId=…&_first=20&_after=<c> → the next page
|
|
285
|
+
GET /_x/query/live-feed?orgId=… → [ …rows ] (unchanged)
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
| Rule | Why it is that way |
|
|
289
|
+
|---|---|
|
|
290
|
+
| the keys are `_first` and `_after`, not `first` and `after` | the search string is ALSO the input, and an input member named `first` was legal on every release before this one — a listing's page-size field is the natural name for it. Reserving the bare names would have refused every such declaration at `query()`, in a minor. The underscore is the framework's own mark, as `/_x/` is |
|
|
291
|
+
| a declaration naming either is refused at `query()` | `X_QUERY_INPUT_UNENCODABLE`, in the declaring file: the route strips the key before the schema runs, so the field could never be filled |
|
|
292
|
+
| `_first` outside 1–10,000, not a whole number, or `_after` without `_first` | **400** `X_INPUT_INVALID` with the read's own fix line — judged at the wire (`page-controls.ts`), because `paginate` asserts the same bound as `X_INVARIANT`, which is a 500 blaming the server for a number the caller typed |
|
|
293
|
+
| a cursor that is not this read's | **400** `X_CURSOR_INVALID` — `decodeCursor` is the one judge of a cursor; the route checks only that `_after` is one non-empty string |
|
|
294
|
+
| a control sent twice | refused, never resolved to the last one as a declared scalar is: two page sizes is two pages asked for in one request |
|
|
295
|
+
| the typed client | `feed.client({ baseUrl }).page(input, { first, after })` and `queries.feed.page(…)` append the two controls after the sorted input, so a paged URL is the plain URL plus a suffix |
|
|
296
|
+
| `openapi.json` | every read is a `GET` path item with its input as `in: query` parameters, the two controls after them, and a `200` that is `oneOf` the bare rows and the envelope — `queryOpenApiPaths`, merged into `@ultimat3/action`'s document by the CLI |
|
|
297
|
+
|
|
298
|
+
The MCP read tool does not page: `read()` answers the source's rows, bounded by the read's own
|
|
299
|
+
`limit`, and an agent that wants more asks a narrower question.
|
|
300
|
+
|
|
270
301
|
The codec lives in `@ultimat3/core`, not here: `encodeCursor`, `decodeCursor`,
|
|
271
302
|
`configureCursorSigning` (set the signing secret once at boot; rotating it invalidates every
|
|
272
303
|
open cursor) and `usesDevCursorSecret` are all imported from there. `As of 2026-08`, `x doctor`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/query",
|
|
3
|
-
"version": "19.1
|
|
3
|
+
"version": "19.3.1",
|
|
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",
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"test": "bun test"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@ultimat3/cache": "19.1
|
|
39
|
-
"@ultimat3/core": "19.1
|
|
40
|
-
"@ultimat3/http": "19.1
|
|
41
|
-
"@ultimat3/policy": "19.1
|
|
42
|
-
"@ultimat3/schema": "19.1
|
|
38
|
+
"@ultimat3/cache": "19.3.1",
|
|
39
|
+
"@ultimat3/core": "19.3.1",
|
|
40
|
+
"@ultimat3/http": "19.3.1",
|
|
41
|
+
"@ultimat3/policy": "19.3.1",
|
|
42
|
+
"@ultimat3/schema": "19.3.1"
|
|
43
43
|
}
|
|
44
44
|
}
|
package/src/client.ts
CHANGED
|
@@ -21,6 +21,9 @@ import { problemOf, traceHeaders } from '@ultimat3/core';
|
|
|
21
21
|
import type { InferInput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
22
22
|
import { QueryRequestFailedError } from './errors';
|
|
23
23
|
import { derivePath } from './naming';
|
|
24
|
+
import type { PageControls } from './page-controls';
|
|
25
|
+
import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-controls';
|
|
26
|
+
import type { Page } from './pagination';
|
|
24
27
|
import type { Query } from './query';
|
|
25
28
|
import { isJsonObject } from './stable';
|
|
26
29
|
|
|
@@ -50,11 +53,23 @@ export interface QueryCallOptions {
|
|
|
50
53
|
readonly retry?: ClientRetry;
|
|
51
54
|
}
|
|
52
55
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
/**
|
|
57
|
+
* `feed({ orgId })` with the input schema and the row type both inferred — and `feed.page(...)`,
|
|
58
|
+
* the same read as one bounded page.
|
|
59
|
+
*
|
|
60
|
+
* `page` rides ON the method rather than beside it because the method is what a `queryClient` map
|
|
61
|
+
* hands out per name: `queries.feed.page({ orgId }, { first: 20 })` needs no second map and no
|
|
62
|
+
* second derivation of the URL. Its args are `PaginateArgs` with the server-side options gone —
|
|
63
|
+
* the browser has no `ctx` and no `actor` to hand over; the route decides both.
|
|
64
|
+
*/
|
|
65
|
+
export interface QueryClientMethod<TInput extends StandardSchemaV1, TRow extends object> {
|
|
66
|
+
(input: InferInput<TInput>, options?: QueryCallOptions): Promise<readonly TRow[]>;
|
|
67
|
+
page(
|
|
68
|
+
input: InferInput<TInput>,
|
|
69
|
+
args: PageControls,
|
|
70
|
+
options?: QueryCallOptions,
|
|
71
|
+
): Promise<Page<TRow>>;
|
|
72
|
+
}
|
|
58
73
|
|
|
59
74
|
/**
|
|
60
75
|
* Loose constraint on purpose: a map of concrete `Query<TInput, TRow>` values must be
|
|
@@ -114,8 +129,14 @@ export function queryClientMethodFor<TInput extends StandardSchemaV1, TRow exten
|
|
|
114
129
|
const doFetch: FetchLike = options.fetch ?? ((input, init) => fetch(input, init));
|
|
115
130
|
const base = options.baseUrl.replace(/\/+$/, '');
|
|
116
131
|
// Erased at the wire seam; the row type is this query's by construction.
|
|
117
|
-
|
|
132
|
+
const rows = (input: InferInput<TInput>, callOptions: QueryCallOptions = {}) =>
|
|
118
133
|
read(doFetch, base, options, name, input, callOptions) as Promise<readonly TRow[]>;
|
|
134
|
+
const page = (
|
|
135
|
+
input: InferInput<TInput>,
|
|
136
|
+
args: PageControls,
|
|
137
|
+
callOptions: QueryCallOptions = {},
|
|
138
|
+
) => read(doFetch, base, options, name, input, callOptions, args) as Promise<Page<TRow>>;
|
|
139
|
+
return Object.assign(rows, { page });
|
|
119
140
|
}
|
|
120
141
|
|
|
121
142
|
async function read(
|
|
@@ -125,8 +146,9 @@ async function read(
|
|
|
125
146
|
name: string,
|
|
126
147
|
input: unknown,
|
|
127
148
|
callOptions: QueryCallOptions,
|
|
149
|
+
page?: PageControls,
|
|
128
150
|
): Promise<unknown> {
|
|
129
|
-
const search = searchOf(input);
|
|
151
|
+
const search = searchOf(input, page);
|
|
130
152
|
const url = `${base}${derivePath(name)}${search === '' ? '' : `?${search}`}`;
|
|
131
153
|
const dispatch = (signal: AbortSignal | undefined): Promise<WireAnswer> =>
|
|
132
154
|
fetchOnce(doFetch, url, options, name, signal ?? callOptions.signal);
|
|
@@ -177,16 +199,25 @@ async function fetchOnce(
|
|
|
177
199
|
/**
|
|
178
200
|
* Input as a query string. Keys are sorted so the same input always produces the
|
|
179
201
|
* same URL — a GET is a cache key, and an unstable one caches nothing.
|
|
202
|
+
*
|
|
203
|
+
* The page controls come LAST, after the sorted input and in a fixed order, so a paged URL is the
|
|
204
|
+
* plain URL with a suffix — the same dedup key for the same page, and a log line a reader can
|
|
205
|
+
* split at `_first=` to recover the read underneath.
|
|
180
206
|
*/
|
|
181
|
-
function searchOf(input: unknown): string {
|
|
182
|
-
if (!isJsonObject(input)) return '';
|
|
207
|
+
function searchOf(input: unknown, page?: PageControls): string {
|
|
183
208
|
const params = new URLSearchParams();
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
209
|
+
if (isJsonObject(input)) {
|
|
210
|
+
for (const key of Object.keys(input).sort()) {
|
|
211
|
+
const value = input[key];
|
|
212
|
+
if (value === undefined || value === null) continue;
|
|
213
|
+
for (const item of Array.isArray(value) ? (value as readonly unknown[]) : [value]) {
|
|
214
|
+
params.append(key, typeof item === 'object' ? JSON.stringify(item) : String(item));
|
|
215
|
+
}
|
|
189
216
|
}
|
|
190
217
|
}
|
|
218
|
+
if (page !== undefined) {
|
|
219
|
+
params.append(PAGE_FIRST_KEY, String(page.first));
|
|
220
|
+
if (page.after !== undefined) params.append(PAGE_AFTER_KEY, page.after);
|
|
221
|
+
}
|
|
191
222
|
return params.toString();
|
|
192
223
|
}
|
package/src/http.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { Deprecation } from './deprecation';
|
|
|
14
14
|
import { applyHeaders, recordDeprecatedCall, renderDeprecation } from './deprecation';
|
|
15
15
|
import { QueryDeprecationInvalidError } from './errors';
|
|
16
16
|
import { derivePath } from './naming';
|
|
17
|
+
import { pageControlsOf } from './page-controls';
|
|
17
18
|
import { admitsAnonymous, policyCapability } from './policy-gate';
|
|
18
19
|
import type { AnyQuery } from './query';
|
|
19
20
|
import { queryName, runQuery } from './read';
|
|
@@ -40,8 +41,22 @@ export function toQueryRoute(target: AnyQuery): Route {
|
|
|
40
41
|
// second parser: the same read would answer `X_BODY_INVALID` where every other
|
|
41
42
|
// surface answers `X_INPUT_INVALID` with the line that prints its schema. `runQuery`
|
|
42
43
|
// is the one that decides, exactly as it does for a direct server call.
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
//
|
|
45
|
+
// The two page controls come OUT first (`page-controls.ts`): they are the route's, not the
|
|
46
|
+
// read's, and a schema that refused unknown keys would otherwise refuse every paged call.
|
|
47
|
+
const { input: values, page } = pageControlsOf(name, request.queryRaw());
|
|
48
|
+
const input = coerceQuery(target.input, values);
|
|
49
|
+
// With a page control the answer is the `Page` envelope `query.page()` answers a server
|
|
50
|
+
// caller with — `{ rows, endCursor, hasNextPage }`, the same names, so a cursor read off the
|
|
51
|
+
// wire and one read off a direct call are the same string in the same field. Without one the
|
|
52
|
+
// answer is the bare array it has always been: every client written before the controls
|
|
53
|
+
// existed keeps reading rows, and `hasNextPage` never rides on a row where a page marker
|
|
54
|
+
// has no business being.
|
|
55
|
+
const response = json(
|
|
56
|
+
page === undefined
|
|
57
|
+
? await runQuery(target, input, { surface: 'http' })
|
|
58
|
+
: await target.page(input, { ...page, surface: 'http' }),
|
|
59
|
+
);
|
|
45
60
|
// On the failure path too, below: a client polling a deprecated read that is currently
|
|
46
61
|
// 403ing still has to learn the read is going away.
|
|
47
62
|
if (sunsetting !== undefined) applyHeaders(response, sunsetting);
|
package/src/index.ts
CHANGED
|
@@ -97,6 +97,17 @@ export { isExposed, toQueryTool, toQueryTools } from './mcp-tool';
|
|
|
97
97
|
* verbatim, and an exported derivation would be a second way to spell one tool.
|
|
98
98
|
*/
|
|
99
99
|
export { derivePath, toKebabCase } from './naming';
|
|
100
|
+
/**
|
|
101
|
+
* The read half of `openapi.json`. `@ultimat3/cli` merges these paths into `@ultimat3/action`'s
|
|
102
|
+
* `buildOpenApi` document — the two packages are one tier and cannot compose each other.
|
|
103
|
+
*/
|
|
104
|
+
export { queryOpenApiPaths, toQueryOpenApiOperation } from './openapi';
|
|
105
|
+
/**
|
|
106
|
+
* The two search-string keys that page `GET /_x/query/<name>` — `_first` and `_after` — and the
|
|
107
|
+
* shape the typed client's `.page()` takes. `MAX_PAGE_SIZE` is the bound both ends check.
|
|
108
|
+
*/
|
|
109
|
+
export type { PageControls } from './page-controls';
|
|
110
|
+
export { MAX_PAGE_SIZE, PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-controls';
|
|
100
111
|
/**
|
|
101
112
|
* The shapes `query.page(input, { first, after })` takes and answers with. `paginate` itself is
|
|
102
113
|
* deliberately unexported: a page is the read's own answer, and a second, importable way to ask
|
package/src/input-shape.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { type SchemaNode, tryIntrospect } from '@ultimat3/schema';
|
|
19
19
|
import { QueryInputUnencodableError } from './errors';
|
|
20
|
+
import { PAGE_CONTROL_KEYS } from './page-controls';
|
|
20
21
|
|
|
21
22
|
/** Node kinds whose value is a structure, not characters. `money` is `{ minor, currency }`. */
|
|
22
23
|
const STRUCTURAL = new Set(['object', 'record', 'money']);
|
|
@@ -27,6 +28,12 @@ const STRUCTURAL = new Set(['object', 'record', 'money']);
|
|
|
27
28
|
*/
|
|
28
29
|
function unencodable(node: SchemaNode): string | undefined {
|
|
29
30
|
for (const [key, child] of Object.entries(node.properties ?? {})) {
|
|
31
|
+
// The route reads `_first` and `_after` OFF the search string before the schema sees it, so a
|
|
32
|
+
// declaration naming either is a field no request can ever fill — refused where it is written,
|
|
33
|
+
// exactly like a nested object, and for the same reason: the wire cannot carry it.
|
|
34
|
+
if (PAGE_CONTROL_KEYS.includes(key)) {
|
|
35
|
+
return `${key} is reserved — the route reads it as a page control (see query.page())`;
|
|
36
|
+
}
|
|
30
37
|
// Required AND nullable: `searchOf` sends nothing for a `null`, and absence is what the far
|
|
31
38
|
// side then sees — so the value the caller explicitly chose fails validation on arrival.
|
|
32
39
|
// Optional or defaulted, absence is already the schema's own answer and nothing is lost.
|
package/src/openapi.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projection: a query's route as an OpenAPI 3.1 path item, so `openapi.json` — the file `x verify`
|
|
3
|
+
* diffs and the typed client is generated from — documents the READ half of the API. It documented
|
|
4
|
+
* only the write half until 2026-09: `buildOpenApi` in `@ultimat3/action` walks the action
|
|
5
|
+
* registry, and every `GET /_x/query/<name>` was served by `api-routes.ts` and published nowhere,
|
|
6
|
+
* which is why the page controls this route now reads had no document to be written into.
|
|
7
|
+
*
|
|
8
|
+
* Ported beside `@ultimat3/action`'s rather than composed with it: both packages are tier 3 and
|
|
9
|
+
* tiers never go sideways, so the CLI — the one caller that writes the file — merges the two
|
|
10
|
+
* `paths` maps (`packages/cli/src/app-openapi.ts`). The component names this file references are
|
|
11
|
+
* restated, and pinned in `openapi.test.ts` against what the action side emits.
|
|
12
|
+
*
|
|
13
|
+
* DETERMINISM IS A HARD REQUIREMENT, as it is one package over: `serializeOpenApi` sorts keys at
|
|
14
|
+
* every depth, so only the ARRAYS here need an order — parameters by name, then the two page
|
|
15
|
+
* controls — and nothing reads the clock, the environment or a random source.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { tagKeys } from '@ultimat3/cache';
|
|
19
|
+
import type { SchemaNode } from '@ultimat3/schema';
|
|
20
|
+
import { nodeToJsonSchema, tryIntrospect } from '@ultimat3/schema';
|
|
21
|
+
import { isExposed } from './mcp-tool';
|
|
22
|
+
import { derivePath } from './naming';
|
|
23
|
+
import { MAX_PAGE_SIZE, PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-controls';
|
|
24
|
+
import { policyCapability } from './policy-gate';
|
|
25
|
+
import type { AnyQuery } from './query';
|
|
26
|
+
import { queryName } from './read';
|
|
27
|
+
import { listQueries } from './registry';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `@ultimat3/action`'s `PROBLEM_SCHEMA_NAME`, restated: the CLI merges this file's paths into that
|
|
31
|
+
* package's document, which is what puts the component the `$ref` names into `components`.
|
|
32
|
+
*/
|
|
33
|
+
const PROBLEM_SCHEMA_REF = '#/components/schemas/Problem';
|
|
34
|
+
|
|
35
|
+
/** One tag for every read: an action is tagged by its resource, a read by what it is. */
|
|
36
|
+
const QUERY_TAG = 'query';
|
|
37
|
+
|
|
38
|
+
export type OpenApiPathItem = Record<string, unknown>;
|
|
39
|
+
|
|
40
|
+
/** `{ [path]: { get: operation } }` for every registered read, name-sorted like the actions. */
|
|
41
|
+
export function queryOpenApiPaths(
|
|
42
|
+
queries: readonly AnyQuery[] = listQueries(),
|
|
43
|
+
): Record<string, OpenApiPathItem> {
|
|
44
|
+
const paths: Record<string, OpenApiPathItem> = {};
|
|
45
|
+
for (const target of [...queries].sort(compareByName)) {
|
|
46
|
+
paths[derivePath(queryName(target))] = { get: toQueryOpenApiOperation(target) };
|
|
47
|
+
}
|
|
48
|
+
return paths;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The GET operation for one read. The input's members are `in: query` parameters — a read is
|
|
53
|
+
* served from its search string (`input-shape.ts` is what guarantees every member fits one) — and
|
|
54
|
+
* the two page controls follow them, on every read, because every read pages through the same
|
|
55
|
+
* route code. `required` follows the schema: optional or defaulted is not required.
|
|
56
|
+
*/
|
|
57
|
+
export function toQueryOpenApiOperation(target: AnyQuery): Record<string, unknown> {
|
|
58
|
+
const name = queryName(target);
|
|
59
|
+
return {
|
|
60
|
+
operationId: name,
|
|
61
|
+
tags: [QUERY_TAG],
|
|
62
|
+
summary: target.mcp?.description ?? name,
|
|
63
|
+
...(target.deprecated === undefined ? {} : { deprecated: true }),
|
|
64
|
+
parameters: [...inputParameters(target.input), ...PAGE_PARAMETERS],
|
|
65
|
+
responses: {
|
|
66
|
+
'200': {
|
|
67
|
+
description: 'ok',
|
|
68
|
+
content: {
|
|
69
|
+
'application/json': {
|
|
70
|
+
// Two shapes, decided by the request: the bare rows every client read before the
|
|
71
|
+
// controls existed, and the page envelope when `_first` is sent. `oneOf` and not a
|
|
72
|
+
// second operation — it is one route, one policy evaluation, one URL prefix.
|
|
73
|
+
schema: {
|
|
74
|
+
oneOf: [
|
|
75
|
+
{ type: 'array', items: {} },
|
|
76
|
+
{
|
|
77
|
+
type: 'object',
|
|
78
|
+
required: ['rows', 'endCursor', 'hasNextPage'],
|
|
79
|
+
properties: {
|
|
80
|
+
rows: { type: 'array', items: {} },
|
|
81
|
+
endCursor: { type: ['string', 'null'] },
|
|
82
|
+
hasNextPage: { type: 'boolean' },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
// `X_INPUT_INVALID` is the search string failing the read's own schema, or a page control
|
|
91
|
+
// outside its bound; `X_CURSOR_INVALID` is an `_after` that is not one of this read's. Both
|
|
92
|
+
// are the caller's to repair, which is what 400 says.
|
|
93
|
+
'400': problemResponse('X_INPUT_INVALID or X_CURSOR_INVALID'),
|
|
94
|
+
'403': problemResponse('policy denied'),
|
|
95
|
+
},
|
|
96
|
+
'x-ultimate': {
|
|
97
|
+
capability: policyCapability(target.policy),
|
|
98
|
+
live: target.isLive,
|
|
99
|
+
cacheTags: tagKeys(target.cache?.tags ?? []),
|
|
100
|
+
// The same rule `toMcpTool` and the action side apply: a tool is advertised only where the
|
|
101
|
+
// MCP catalog lists one, under the export name verbatim (`mcp-tool.ts`).
|
|
102
|
+
tool: isExposed(target) ? name : null,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The two controls, spelled from `page-controls.ts` so the document cannot drift from the route. */
|
|
108
|
+
const PAGE_PARAMETERS: readonly Record<string, unknown>[] = [
|
|
109
|
+
{
|
|
110
|
+
name: PAGE_FIRST_KEY,
|
|
111
|
+
in: 'query',
|
|
112
|
+
required: false,
|
|
113
|
+
description: `page size; present, the response is the page envelope rather than the bare rows (1 to ${MAX_PAGE_SIZE})`,
|
|
114
|
+
schema: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE },
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: PAGE_AFTER_KEY,
|
|
118
|
+
in: 'query',
|
|
119
|
+
required: false,
|
|
120
|
+
description: `the endCursor a previous page answered; needs ${PAGE_FIRST_KEY}`,
|
|
121
|
+
schema: { type: 'string' },
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A member per declared property, name-sorted. A schema this package cannot introspect — a
|
|
127
|
+
* foreign Standard Schema, the seam `configureSchemaProvider` exists for — publishes no
|
|
128
|
+
* parameters rather than guessed ones, the same answer `assertEncodableInput` gives it.
|
|
129
|
+
*/
|
|
130
|
+
function inputParameters(input: unknown): readonly Record<string, unknown>[] {
|
|
131
|
+
const node = tryIntrospect(input);
|
|
132
|
+
if (node?.properties === undefined) return [];
|
|
133
|
+
return Object.entries(node.properties)
|
|
134
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
135
|
+
.map(([key, child]) => parameterOf(key, child));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parameterOf(name: string, node: SchemaNode): Record<string, unknown> {
|
|
139
|
+
return {
|
|
140
|
+
name,
|
|
141
|
+
in: 'query',
|
|
142
|
+
required: node.optional !== true && node.hasDefault !== true,
|
|
143
|
+
schema: nodeToJsonSchema(node),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function problemResponse(description: string): Record<string, unknown> {
|
|
148
|
+
return {
|
|
149
|
+
description,
|
|
150
|
+
content: { 'application/problem+json': { schema: { $ref: PROBLEM_SCHEMA_REF } } },
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function compareByName(a: AnyQuery, b: AnyQuery): number {
|
|
155
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
156
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Single responsibility: the two search-string keys that turn `GET /_x/query/<name>` into a page,
|
|
2
|
+
// and what a value under either has to look like. A LEAF on purpose: `client.ts` is browser-safe
|
|
3
|
+
// and `openapi.ts` is a projection, and both spell these names — one file, or the client encodes a
|
|
4
|
+
// key the route does not read.
|
|
5
|
+
//
|
|
6
|
+
// The keys are `_first` and `_after`, not `first` and `after`. `PaginateArgs` spells them bare,
|
|
7
|
+
// and the route could have too — except a search string is ALSO the query's input, and an input
|
|
8
|
+
// field named `first` was legal on every release before this one (`http.test.ts` declares one,
|
|
9
|
+
// and a listing's page-size field is the most natural name for it). Reserving the bare names would
|
|
10
|
+
// have turned every such declaration into a refusal at `query()`, in a minor. The underscore is
|
|
11
|
+
// the framework's own namespace mark — the route lives under `/_x/` for the same reason — and
|
|
12
|
+
// `input-shape.ts` refuses a declaration that reaches for it, so the two names can never collide.
|
|
13
|
+
|
|
14
|
+
import { QueryInputInvalidError } from './errors';
|
|
15
|
+
|
|
16
|
+
/** `?_first=20` — the page size. Present is what makes the route answer a `Page`. */
|
|
17
|
+
export const PAGE_FIRST_KEY = '_first';
|
|
18
|
+
/** `?_after=<cursor>` — the signed cursor the previous page's `endCursor` carried. */
|
|
19
|
+
export const PAGE_AFTER_KEY = '_after';
|
|
20
|
+
export const PAGE_CONTROL_KEYS: readonly string[] = [PAGE_FIRST_KEY, PAGE_AFTER_KEY];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The largest page a read will serve. A TWIN of `@ultimat3/entity`'s `MAX_PAGE_SIZE` — this
|
|
24
|
+
* package holds no dependency on that one, the same compromise `naming.ts` and `deprecation.ts`
|
|
25
|
+
* are ported under — and it exists for the same reason: `first` reaches `paginate` straight from
|
|
26
|
+
* an action's input or a route parameter, so `args.first + 1` bound whatever a client sent and one
|
|
27
|
+
* request could ask for five million rows. Lives here rather than in `pagination.ts` because the
|
|
28
|
+
* route validates against it BEFORE `paginate` runs — see `pageControlsOf`.
|
|
29
|
+
*/
|
|
30
|
+
export const MAX_PAGE_SIZE = 10_000;
|
|
31
|
+
|
|
32
|
+
/** What the route hands `query.page()` — `PaginateArgs` minus the server-side options. */
|
|
33
|
+
export interface PageControls {
|
|
34
|
+
readonly first: number;
|
|
35
|
+
readonly after?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The values a search string decodes to: `@ultimat3/http`'s `QueryValues`, restated as a leaf. */
|
|
39
|
+
export type SearchValues = Readonly<Record<string, string | readonly string[] | undefined>>;
|
|
40
|
+
|
|
41
|
+
export interface SplitSearch {
|
|
42
|
+
/** The search string with both controls removed — what the query's own schema sees. */
|
|
43
|
+
readonly input: Record<string, string | readonly string[] | undefined>;
|
|
44
|
+
/** `undefined` when neither control was sent, so the route answers a bare array as it always has. */
|
|
45
|
+
readonly page: PageControls | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whole digits only — `Number('1e3')` is 1000 and `Number(' 2')` is 2, and a page size that
|
|
50
|
+
* needs a lenient parse is a page size nobody meant.
|
|
51
|
+
*/
|
|
52
|
+
const WHOLE_NUMBER = /^\d+$/;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Splits the two controls out of a search string, judged at the WIRE and never left to `paginate`:
|
|
56
|
+
* that function `assert`s its bound, which is `X_INVARIANT` — a 500, blaming the server for a
|
|
57
|
+
* number the caller typed. Here every refusal is the query's own `X_INPUT_INVALID`, a 400 whose
|
|
58
|
+
* fix line prints the schema, exactly what a bad `orgId` on the same URL already answers.
|
|
59
|
+
*
|
|
60
|
+
* The cursor's VALIDITY is not judged here. `decodeCursor` owns that — `X_CURSOR_INVALID`, also a
|
|
61
|
+
* 400 — and a second reader of the codec's envelope would be a second opinion about what a cursor
|
|
62
|
+
* is. Only its shape as a search value is checked: one string, non-empty.
|
|
63
|
+
*/
|
|
64
|
+
export function pageControlsOf(name: string, values: SearchValues): SplitSearch {
|
|
65
|
+
// `Object.create(null)`, never `{}`, and the same rule `@ultimat3/http`'s `collectFields`
|
|
66
|
+
// follows: a repeated `?__proto__=a&__proto__=b` arrives as an ARRAY, which is exactly what
|
|
67
|
+
// `Object.prototype.__proto__`'s setter accepts — one assignment and this object's prototype IS
|
|
68
|
+
// that array, so `'length' in input` answers true about a field nobody sent. A null prototype
|
|
69
|
+
// has no such accessor, so the key is ordinary data on the way to the schema.
|
|
70
|
+
const input: Record<string, string | readonly string[] | undefined> = Object.create(null);
|
|
71
|
+
for (const [key, value] of Object.entries(values)) {
|
|
72
|
+
if (!PAGE_CONTROL_KEYS.includes(key)) input[key] = value;
|
|
73
|
+
}
|
|
74
|
+
const first = values[PAGE_FIRST_KEY];
|
|
75
|
+
const after = values[PAGE_AFTER_KEY];
|
|
76
|
+
if (first === undefined && after === undefined) return { input, page: undefined };
|
|
77
|
+
if (first === undefined) {
|
|
78
|
+
throw new QueryInputInvalidError(
|
|
79
|
+
name,
|
|
80
|
+
`${PAGE_AFTER_KEY} was sent without ${PAGE_FIRST_KEY} — a cursor continues a page, so the page size is what names one`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const size = single(name, PAGE_FIRST_KEY, first);
|
|
84
|
+
if (!WHOLE_NUMBER.test(size) || Number(size) < 1 || Number(size) > MAX_PAGE_SIZE) {
|
|
85
|
+
throw new QueryInputInvalidError(
|
|
86
|
+
name,
|
|
87
|
+
`${PAGE_FIRST_KEY} must be a whole number of rows between 1 and ${MAX_PAGE_SIZE}, got "${size}"`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (after === undefined) return { input, page: { first: Number(size) } };
|
|
91
|
+
const cursor = single(name, PAGE_AFTER_KEY, after);
|
|
92
|
+
if (cursor.length === 0) {
|
|
93
|
+
throw new QueryInputInvalidError(
|
|
94
|
+
name,
|
|
95
|
+
`${PAGE_AFTER_KEY} is empty — omit it for the first page, or send the endCursor the previous page answered`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return { input, page: { first: Number(size), after: cursor } };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A control sent twice is refused rather than resolved. `coerceQuery` takes the LAST repeat of a
|
|
103
|
+
* declared scalar, but a page size is not a filter: two of them is two different pages asked for
|
|
104
|
+
* in one request, and answering either one silently is the wrong half of a guess.
|
|
105
|
+
*/
|
|
106
|
+
function single(name: string, key: string, value: string | readonly string[]): string {
|
|
107
|
+
if (typeof value === 'string') return value;
|
|
108
|
+
throw new QueryInputInvalidError(
|
|
109
|
+
name,
|
|
110
|
+
`${key} was sent ${value.length} times — a page has one size and one cursor`,
|
|
111
|
+
);
|
|
112
|
+
}
|
package/src/pagination.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { assert, decodeCursor, encodeCursor } from '@ultimat3/core';
|
|
13
13
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
14
14
|
import { reviveSortKey, serializeSortValue } from './cursor-value';
|
|
15
|
+
import { MAX_PAGE_SIZE } from './page-controls';
|
|
15
16
|
import type { Query, SourceOptions } from './query';
|
|
16
17
|
import { queryHash, queryName, sourceFor } from './query';
|
|
17
18
|
import type { QueryShape, SeekKey } from './shape';
|
|
@@ -30,15 +31,6 @@ export interface PaginateArgs extends SourceOptions {
|
|
|
30
31
|
readonly after?: string;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
/**
|
|
34
|
-
* The largest page a read will serve. A TWIN of `@ultimat3/entity`'s `MAX_PAGE_SIZE` — this
|
|
35
|
-
* package holds no dependency on that one, the same compromise `naming.ts` and `deprecation.ts`
|
|
36
|
-
* are ported under — and it exists for the same reason: `first` reaches here straight from an
|
|
37
|
-
* action's input or a route parameter, so `args.first + 1` bound whatever a client sent and one
|
|
38
|
-
* request could ask for five million rows.
|
|
39
|
-
*/
|
|
40
|
-
const MAX_PAGE_SIZE = 10_000;
|
|
41
|
-
|
|
42
34
|
/**
|
|
43
35
|
* One page. Push-down when the source implements `seek()`; otherwise the rows are
|
|
44
36
|
* sliced after execution and the source is doing more work than it should.
|
|
@@ -66,6 +58,8 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
|
|
|
66
58
|
const base = await sourceFor(target, input, args);
|
|
67
59
|
const shape = base.shape();
|
|
68
60
|
|
|
61
|
+
// `MAX_PAGE_SIZE` lives in `page-controls.ts`: the route checks the same bound at the wire, as
|
|
62
|
+
// a 400, before this `assert` — which is a 500 — can see the number.
|
|
69
63
|
// Fetch one extra row: its presence *is* `hasNextPage`, with no count query.
|
|
70
64
|
const window = args.first + 1;
|
|
71
65
|
const source: SqlSource<object> = base.seek === undefined ? base : base.seek(after, window);
|