@ultimat3/query 23.0.0 → 25.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 +75 -37
- package/README.md +147 -32
- package/package.json +8 -8
- package/src/audit-errors.ts +43 -0
- package/src/audit-gate.ts +177 -0
- package/src/cache.ts +4 -4
- package/src/client.ts +19 -6
- package/src/column-kinds.ts +28 -0
- package/src/cursor-value.ts +3 -3
- package/src/errors.ts +6 -3
- package/src/facade.ts +6 -7
- package/src/http.ts +43 -33
- package/src/index.ts +37 -61
- package/src/input-shape.ts +21 -1
- package/src/live.ts +19 -3
- package/src/matcher.ts +14 -7
- package/src/openapi.ts +24 -17
- package/src/page-controls.ts +5 -9
- package/src/page-keys.ts +1 -1
- package/src/pagination.ts +88 -46
- package/src/policy-gate.ts +17 -49
- package/src/query.ts +22 -56
- package/src/rate-limit-gate.ts +71 -0
- package/src/read.ts +97 -14
- package/src/record-answer.ts +9 -5
- package/src/registry.ts +1 -1
- package/src/search.ts +53 -35
- package/src/shape.ts +52 -85
- package/src/single-answer.ts +22 -0
- package/src/source.ts +26 -11
- package/src/sql.ts +3 -3
- package/src/stable.ts +0 -3
- package/src/subscribes.ts +3 -3
- package/src/type-pins.ts +62 -0
- package/src/deprecation.ts +0 -76
- package/src/mcp-tool.ts +0 -81
- package/src/naming.ts +0 -22
package/src/index.ts
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
* authorize or execute on its own. One authz system, structurally.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
// Anchored on purpose, and
|
|
10
|
-
// the module regardless (oven-sh/bun#40650)
|
|
9
|
+
// Anchored on purpose, and not by the `sideEffects` array alone: Bun before 1.4.1 read any array as
|
|
10
|
+
// `false` and dropped the module regardless (oven-sh/bun#40650), and a bare import holds on every
|
|
11
|
+
// bundler. The array still lists it, because a bundler that honours the array drops a bare import
|
|
12
|
+
// of a module it does not list. `registerPrimitiveRegistrar('query', …)` here is read
|
|
11
13
|
// by @ultimat3/core's registrar table on behalf of `x` and the manifest, and nothing that registers
|
|
12
14
|
// a query imports this module for a binding. `SIDE_EFFECTS_ANCHORS` carries the argument and
|
|
13
15
|
// `bun run side-effects` enforces it.
|
|
@@ -15,33 +17,25 @@ import './registry';
|
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Flight control for the typed client, and OPT-IN by construction: `client.ts` names `ClientFlight`
|
|
18
|
-
* as a TYPE only, so a caller that never mentions `
|
|
20
|
+
* as a TYPE only, so a caller that never mentions `clientFlight` pays nothing for the fence,
|
|
19
21
|
* the dedup map or the retry loop. Every mechanism underneath is `@ultimat3/core`'s — one fence,
|
|
20
22
|
* one flight map, one gate, one backoff curve for the whole framework — and so is the pipeline
|
|
21
23
|
* itself: it shipped as a byte-identical copy here and in `@ultimat3/action`, and
|
|
22
24
|
* two tier-3 packages may not import each other, so the one copy lives at tier 0.
|
|
23
25
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
26
|
+
* `ClientFlight` and `ClientRetry` are re-exported as TYPES because this barrel's options name
|
|
27
|
+
* them; every value (`clientFlight`, `isSuperseded`, …) is imported from `@ultimat3/core`,
|
|
28
|
+
* its one home — a re-export is a second import path (`X_HELPER_COPY`, `bun run flight-copies`).
|
|
27
29
|
*/
|
|
28
|
-
export type {
|
|
29
|
-
ClientFlight,
|
|
30
|
-
ClientFlightOptions,
|
|
31
|
-
ClientRetry,
|
|
32
|
-
FlightKeyOptions,
|
|
33
|
-
FlightPlan,
|
|
34
|
-
WireAnswer,
|
|
35
|
-
} from '@ultimat3/core';
|
|
36
|
-
export {
|
|
37
|
-
createClientFlight,
|
|
38
|
-
DEFAULT_CLIENT_RETRY,
|
|
39
|
-
isSuperseded,
|
|
40
|
-
isTransientFailure,
|
|
41
|
-
} from '@ultimat3/core';
|
|
30
|
+
export type { ClientFlight, ClientRetry } from '@ultimat3/core';
|
|
42
31
|
/** Re-exported so a `query` file needs one import, not two. Same object as schema's. */
|
|
43
32
|
export type { Infer } from '@ultimat3/schema';
|
|
44
33
|
export { t } from '@ultimat3/schema';
|
|
34
|
+
/**
|
|
35
|
+
* The two refusals `query({ audit: true })` adds. The record and the sink are `@ultimat3/core`'s
|
|
36
|
+
* (`setAuditSink`) — one sink for every audited primitive, so this package re-exports neither.
|
|
37
|
+
*/
|
|
38
|
+
export { QueryAuditSinkFailedError, QueryAuditSinkMissingError } from './audit-errors';
|
|
45
39
|
export type { QueryCacheScope } from './cache';
|
|
46
40
|
/** `readAuthority` is the ONLY producer of `cacheKeyFor`'s authority — never spell one by hand. */
|
|
47
41
|
export {
|
|
@@ -65,11 +59,15 @@ export type {
|
|
|
65
59
|
} from './client';
|
|
66
60
|
/** `queryClient` is the map-wide read client; `queryClientMethodFor` is what `.client()` binds. */
|
|
67
61
|
export { queryClient, queryClientMethodFor } from './client';
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
62
|
+
/**
|
|
63
|
+
* `isNull` is the one definition of SQL NULL a custom `SqlSource` has to agree with, and
|
|
64
|
+
* `totalOrder` is the one definition of the order it must serve a page in. `compareRows`,
|
|
65
|
+
* `matchesFilter` and `isAfterKey` take the relation's declared kinds — `kindsOf(shape.entity)` —
|
|
66
|
+
* because how two values compare is `@ultimat3/entity`'s answer for the column's kind.
|
|
67
|
+
*/
|
|
68
|
+
export type { KindOf } from './column-kinds';
|
|
69
|
+
export { kindsOf } from './column-kinds';
|
|
71
70
|
export {
|
|
72
|
-
CursorInvalidError,
|
|
73
71
|
CursorValueUnsupportedError,
|
|
74
72
|
MatcherUnsupportedError,
|
|
75
73
|
QueryColumnUnselectedError,
|
|
@@ -90,27 +88,19 @@ export {
|
|
|
90
88
|
/** The HTTP projection: `GET /_x/query/<kebab>`, the URL `client()` derives. */
|
|
91
89
|
export { toQueryRoute } from './http';
|
|
92
90
|
export type { LiveCursor, LiveQuery, ResumeMode, ResumePlan, ToLiveOptions } from './live';
|
|
93
|
-
export { planResume, seekOf, toLiveQuery } from './live';
|
|
91
|
+
export { planResume, seekOf, spendQueryLimit, toLiveQuery } from './live';
|
|
94
92
|
export type { ChangeEvent, ChangeOp, Patch } from './matcher';
|
|
95
93
|
export { assertMatchable, match, positionFor } from './matcher';
|
|
96
|
-
export type { QueryToolDescriptor, QueryToolReadOptions } from './mcp-tool';
|
|
97
|
-
export { isExposed, toQueryTool, toQueryTools } from './mcp-tool';
|
|
98
|
-
/**
|
|
99
|
-
* Path derivation only. There is no `toToolName`: an MCP tool is served under the export name
|
|
100
|
-
* verbatim, and an exported derivation would be a second way to spell one tool.
|
|
101
|
-
*/
|
|
102
|
-
export { derivePath } from './naming';
|
|
103
94
|
/**
|
|
104
95
|
* The read half of `openapi.json`. `@ultimat3/cli` merges these paths into `@ultimat3/action`'s
|
|
105
96
|
* `buildOpenApi` document — the two packages are one tier and cannot compose each other.
|
|
106
97
|
*/
|
|
107
98
|
export { queryOpenApiPaths, toQueryOpenApiOperation } from './openapi';
|
|
108
99
|
/**
|
|
109
|
-
* The
|
|
110
|
-
*
|
|
100
|
+
* The shape the typed client's `.page()` takes. The bound both ends check is `@ultimat3/entity`'s
|
|
101
|
+
* `MAX_PAGE_SIZE` — imported from there, never re-published here.
|
|
111
102
|
*/
|
|
112
103
|
export type { PageControls } from './page-controls';
|
|
113
|
-
export { MAX_PAGE_SIZE } from './page-controls';
|
|
114
104
|
/**
|
|
115
105
|
* The shapes `query.page(input, { first, after })` takes and answers with. `paginate` itself is
|
|
116
106
|
* deliberately unexported: a page is the read's own answer, and a second, importable way to ask
|
|
@@ -120,19 +110,12 @@ export { MAX_PAGE_SIZE } from './page-controls';
|
|
|
120
110
|
export type { Page, PaginateArgs } from './pagination';
|
|
121
111
|
export type { QueryPolicy, QuerySubject, QuerySurface } from './policy-gate';
|
|
122
112
|
/**
|
|
123
|
-
*
|
|
124
|
-
* `
|
|
125
|
-
*
|
|
126
|
-
*
|
|
113
|
+
* The one authz gate for reads, named after what it guards. The display label
|
|
114
|
+
* (`policyCapability`), what a report MATCHES on (`policyPermissions`) and what `toQueryRoute`
|
|
115
|
+
* derives `meta.auth` from (`admitsAnonymous`) are `@ultimat3/policy`'s, and the anonymous →
|
|
116
|
+
* `null` mapping (`actorOf`) is `@ultimat3/core`'s — imported from there, never re-published.
|
|
127
117
|
*/
|
|
128
|
-
export {
|
|
129
|
-
actorOf,
|
|
130
|
-
admitsAnonymous,
|
|
131
|
-
guard,
|
|
132
|
-
guardBeforeInput,
|
|
133
|
-
policyCapability,
|
|
134
|
-
policyPermissions,
|
|
135
|
-
} from './policy-gate';
|
|
118
|
+
export { guardQuery, guardQueryBeforeInput } from './policy-gate';
|
|
136
119
|
export type {
|
|
137
120
|
AnyQuery,
|
|
138
121
|
Query,
|
|
@@ -140,10 +123,6 @@ export type {
|
|
|
140
123
|
QueryDef,
|
|
141
124
|
QueryDescriptor,
|
|
142
125
|
QueryFacade,
|
|
143
|
-
QueryListFilterOp,
|
|
144
|
-
QueryListParams,
|
|
145
|
-
QueryMcp,
|
|
146
|
-
QueryMcpAnnotations,
|
|
147
126
|
QueryOptions,
|
|
148
127
|
QueryRateLimit,
|
|
149
128
|
SourceOptions,
|
|
@@ -151,33 +130,30 @@ export type {
|
|
|
151
130
|
export { describeQuery, isQuery, query, queryHash } from './query';
|
|
152
131
|
/** The one read path. `defOf` stays unexported — that is the enforcement. */
|
|
153
132
|
export { queryName, runQuery, sourceFor } from './read';
|
|
154
|
-
|
|
155
133
|
export {
|
|
156
134
|
describeQueries,
|
|
157
135
|
getQuery,
|
|
158
136
|
listQueries,
|
|
159
137
|
registerQueries,
|
|
160
138
|
registerQuery,
|
|
161
|
-
|
|
139
|
+
resetQueries,
|
|
162
140
|
} from './registry';
|
|
163
141
|
/** The query FACTORY over an entity's searchable columns — a `query`, never a ninth primitive. */
|
|
164
142
|
export type { SearchChain, SearchDef, SearchInput, SearchPage } from './search';
|
|
165
143
|
export { search } from './search';
|
|
166
144
|
export type { Filter, FilterOp, OrderKey, QueryShape, SeekKey } from './shape';
|
|
167
|
-
/**
|
|
168
|
-
* `isNull` is the one definition of SQL NULL a custom `SqlSource` has to agree with, and
|
|
169
|
-
* `totalOrder` is the one definition of the order it must serve a page in.
|
|
170
|
-
*/
|
|
171
145
|
export {
|
|
172
146
|
compareRows,
|
|
173
|
-
compareValues,
|
|
174
147
|
isNull,
|
|
175
148
|
matchesFilter,
|
|
176
149
|
seekKeyOf,
|
|
177
150
|
totalOrder,
|
|
178
151
|
} from './shape';
|
|
179
|
-
|
|
152
|
+
/** What a `single: true` read answers — one rule, also `@ultimat3/mcp`'s served tool's. */
|
|
153
|
+
export { readAnswer } from './single-answer';
|
|
154
|
+
/** `Builder` is a type only: `from()` is the one way to build one, so there is no `new Builder()`. */
|
|
155
|
+
export type { Builder, RowProvider, SqlSource, SqlText } from './source';
|
|
180
156
|
/** `isAfterKey` is the one definition of "after this position" — both seek paths use it. */
|
|
181
|
-
export {
|
|
157
|
+
export { from, isAfterKey } from './source';
|
|
182
158
|
export type { ExplainResult, QuerySqlInfo } from './sql';
|
|
183
|
-
export { describeSql,
|
|
159
|
+
export { describeSql, explainQuery } from './sql';
|
package/src/input-shape.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { QueryInputUnencodableError } from './errors';
|
|
|
20
20
|
import { PAGE_CONTROL_KEYS } from './page-controls';
|
|
21
21
|
|
|
22
22
|
/** Node kinds whose value is a structure, not characters. `money` is `{ minor, currency }`. */
|
|
23
|
-
const STRUCTURAL = new Set(['object', 'record', 'money']);
|
|
23
|
+
const STRUCTURAL = new Set(['object', 'record', 'money', 'json']);
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* The first key this input could not put on a wire, or `undefined`. One key, not a list: a fix
|
|
@@ -79,3 +79,23 @@ export function assertEncodableInput(input: unknown): void {
|
|
|
79
79
|
const offender = unencodable(node);
|
|
80
80
|
if (offender !== undefined) throw new QueryInputUnencodableError(offender);
|
|
81
81
|
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The route's half of an empty array. `searchOf` sends NOTHING for `[]` — a query string has no
|
|
85
|
+
* spelling for "this key, zero times" — so a REQUIRED array nobody sent can only be the empty one:
|
|
86
|
+
* left absent it fails validation, on a call the typed client accepted.
|
|
87
|
+
*
|
|
88
|
+
* Required only. An optional or defaulted array already has a meaning for absence, and it is the
|
|
89
|
+
* schema's: `[]` there would overwrite a declared default with a value the caller never wrote.
|
|
90
|
+
*/
|
|
91
|
+
export function absentArraysOf(
|
|
92
|
+
schema: unknown,
|
|
93
|
+
input: Readonly<Record<string, unknown>>,
|
|
94
|
+
): Record<string, unknown> {
|
|
95
|
+
const out: Record<string, unknown> = { ...input };
|
|
96
|
+
for (const [key, child] of Object.entries(tryIntrospect(schema)?.properties ?? {})) {
|
|
97
|
+
if (child.kind !== 'array' || child.optional === true || child.hasDefault === true) continue;
|
|
98
|
+
if (!Object.hasOwn(input, key)) out[key] = [];
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
package/src/live.ts
CHANGED
|
@@ -12,9 +12,11 @@ import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
|
12
12
|
import type { Patch } from './matcher';
|
|
13
13
|
import { assertMatchable } from './matcher';
|
|
14
14
|
import type { QueryPolicy, QuerySubject } from './policy-gate';
|
|
15
|
-
import {
|
|
16
|
-
import type { Query } from './query';
|
|
15
|
+
import { guardQuery } from './policy-gate';
|
|
16
|
+
import type { AnyQuery, Query } from './query';
|
|
17
17
|
import { queryHash } from './query';
|
|
18
|
+
import type { QueryLimitCaller } from './rate-limit-gate';
|
|
19
|
+
import { spendReadLimit } from './rate-limit-gate';
|
|
18
20
|
import { queryName, sourceFor } from './read';
|
|
19
21
|
import { assertNoSealedKey } from './sealed-shape';
|
|
20
22
|
import type { QueryShape, SeekKey } from './shape';
|
|
@@ -103,6 +105,20 @@ export interface ToLiveOptions {
|
|
|
103
105
|
const SHARED_WINDOW_REASON =
|
|
104
106
|
'the shared subject-less window has no subscriber to decide about; authorize() runs per subscriber';
|
|
105
107
|
|
|
108
|
+
/**
|
|
109
|
+
* A live SUBSCRIBE's spend of the read's declared `rateLimit:` — the bucket the HTTP route, a paged
|
|
110
|
+
* read and the MCP tool spend. Its own export because the subscribe is realtime's: the sync node
|
|
111
|
+
* builds one shared window per `(query, input)` with no subscriber (`enforce: false`, which spends
|
|
112
|
+
* nothing) and calls this once per subscriber, beside `authorize`. Never on a re-authorization:
|
|
113
|
+
* the subscription it already holds was paid for.
|
|
114
|
+
*/
|
|
115
|
+
export function spendQueryLimit(
|
|
116
|
+
target: AnyQuery,
|
|
117
|
+
caller: Omit<QueryLimitCaller, 'surface'>,
|
|
118
|
+
): Promise<void> {
|
|
119
|
+
return spendReadLimit(queryName(target), target.rateLimit, { ...caller, surface: 'live' });
|
|
120
|
+
}
|
|
121
|
+
|
|
106
122
|
/** Changing the build changes the epoch, which forces reconnects to refetch. */
|
|
107
123
|
export function liveEpoch(): string {
|
|
108
124
|
return Bun.env['X_BUILD_ID'] ?? 'dev';
|
|
@@ -148,7 +164,7 @@ export async function toLiveQuery<TInput extends StandardSchemaV1, TRow extends
|
|
|
148
164
|
limit: shape.limit,
|
|
149
165
|
execute: () => source.execute(),
|
|
150
166
|
authorize: async (subject) => {
|
|
151
|
-
|
|
167
|
+
guardQuery(policy, subject, 'live');
|
|
152
168
|
},
|
|
153
169
|
initialCursor: (rows) => ({
|
|
154
170
|
epoch,
|
package/src/matcher.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* filters + orderBy + limit; anything else throws X_MATCHER_UNSUPPORTED, because
|
|
5
5
|
* an honest refusal beats a silently wrong result set.
|
|
6
6
|
*/
|
|
7
|
+
import type { KindOf } from './column-kinds';
|
|
8
|
+
import { kindsOf } from './column-kinds';
|
|
7
9
|
import { MatcherUnsupportedError, QueryNotPageableError } from './errors';
|
|
8
10
|
import type { QueryShape } from './shape';
|
|
9
11
|
import { compareRows, matchesFilters, totalOrder } from './shape';
|
|
@@ -58,7 +60,10 @@ export function match<TRow extends object>(
|
|
|
58
60
|
const id = idOf(event.row, shape.entity);
|
|
59
61
|
const index = rows.findIndex((row) => idOf(row, shape.entity) === id);
|
|
60
62
|
const inSet = index >= 0;
|
|
61
|
-
|
|
63
|
+
// The declared kinds of the relation's columns, resolved once for this event: every comparison
|
|
64
|
+
// below is `@ultimat3/entity`'s, asked under the kind Postgres would decide by.
|
|
65
|
+
const kindOf = kindsOf(shape.entity);
|
|
66
|
+
const belongs = event.op !== 'delete' && matchesFilters(event.row, shape.filters, kindOf);
|
|
62
67
|
|
|
63
68
|
if (event.op === 'delete' || (inSet && !belongs)) {
|
|
64
69
|
return inSet ? removeAt(shape, index, id, true, rows.length) : [];
|
|
@@ -81,14 +86,14 @@ export function match<TRow extends object>(
|
|
|
81
86
|
return [{ kind: 'refill', from: 0 }];
|
|
82
87
|
}
|
|
83
88
|
|
|
84
|
-
if (!inSet) return insert(shape, rows, event.row);
|
|
89
|
+
if (!inSet) return insert(shape, rows, event.row, kindOf);
|
|
85
90
|
|
|
86
91
|
// Present and still matching: a change to an ordering column is a move, not an update.
|
|
87
92
|
const current = rows[index];
|
|
88
93
|
const moved =
|
|
89
94
|
shape.orderBy.length > 0 &&
|
|
90
95
|
current !== undefined &&
|
|
91
|
-
compareRows(event.row, current, shape.orderBy) !== 0;
|
|
96
|
+
compareRows(event.row, current, shape.orderBy, kindOf) !== 0;
|
|
92
97
|
if (!moved) return [{ kind: 'update', position: index, row: event.row }];
|
|
93
98
|
|
|
94
99
|
const without = [...rows.slice(0, index), ...rows.slice(index + 1)];
|
|
@@ -100,12 +105,12 @@ export function match<TRow extends object>(
|
|
|
100
105
|
// window is `[b, c, d]`. Whether the moved row is still in the window is the server's answer
|
|
101
106
|
// too, so the refill covers both.
|
|
102
107
|
const wasFull = shape.limit !== null && rows.length >= shape.limit;
|
|
103
|
-
if (wasFull && positionFor(shape, without, event.row) >= without.length) {
|
|
108
|
+
if (wasFull && positionFor(shape, without, event.row, kindOf) >= without.length) {
|
|
104
109
|
return removeAt<TRow>(shape, index, id, true, rows.length);
|
|
105
110
|
}
|
|
106
111
|
return [
|
|
107
112
|
...removeAt<TRow>(shape, index, id, false, rows.length),
|
|
108
|
-
...insert(shape, without, event.row),
|
|
113
|
+
...insert(shape, without, event.row, kindOf),
|
|
109
114
|
];
|
|
110
115
|
}
|
|
111
116
|
|
|
@@ -113,8 +118,9 @@ function insert<TRow extends object>(
|
|
|
113
118
|
shape: QueryShape,
|
|
114
119
|
rows: readonly TRow[],
|
|
115
120
|
row: TRow,
|
|
121
|
+
kindOf: KindOf,
|
|
116
122
|
): readonly Patch<TRow>[] {
|
|
117
|
-
const position = positionFor(shape, rows, row);
|
|
123
|
+
const position = positionFor(shape, rows, row, kindOf);
|
|
118
124
|
// Sorted past the end of a full window: the row exists but nobody sees it.
|
|
119
125
|
if (shape.limit !== null && position >= shape.limit) return [];
|
|
120
126
|
const patches: Patch<TRow>[] = [{ kind: 'add', position, row }];
|
|
@@ -186,10 +192,11 @@ export function positionFor<TRow extends object>(
|
|
|
186
192
|
shape: QueryShape,
|
|
187
193
|
rows: readonly TRow[],
|
|
188
194
|
row: TRow,
|
|
195
|
+
kindOf: KindOf = kindsOf(shape.entity),
|
|
189
196
|
): number {
|
|
190
197
|
if (shape.orderBy.length === 0) return rows.length;
|
|
191
198
|
const order = totalOrder(shape.orderBy);
|
|
192
|
-
const found = rows.findIndex((current) => compareRows(row, current, order) < 0);
|
|
199
|
+
const found = rows.findIndex((current) => compareRows(row, current, order, kindOf) < 0);
|
|
193
200
|
return found === -1 ? rows.length : found;
|
|
194
201
|
}
|
|
195
202
|
|
package/src/openapi.ts
CHANGED
|
@@ -16,13 +16,17 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { tagKeys } from '@ultimat3/cache';
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
isMcpExposed,
|
|
21
|
+
queryPath,
|
|
22
|
+
RECORDS_OPENAPI_HEADER,
|
|
23
|
+
recordEnvelopeSchema,
|
|
24
|
+
} from '@ultimat3/core';
|
|
25
|
+
import { MAX_PAGE_SIZE } from '@ultimat3/entity';
|
|
26
|
+
import { policyCapability } from '@ultimat3/policy';
|
|
20
27
|
import type { SchemaNode } from '@ultimat3/schema';
|
|
21
28
|
import { nodeToJsonSchema, tryIntrospect } from '@ultimat3/schema';
|
|
22
|
-
import {
|
|
23
|
-
import { derivePath } from './naming';
|
|
24
|
-
import { MAX_PAGE_SIZE, PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-controls';
|
|
25
|
-
import { policyCapability } from './policy-gate';
|
|
29
|
+
import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-controls';
|
|
26
30
|
import type { AnyQuery } from './query';
|
|
27
31
|
import { queryName } from './read';
|
|
28
32
|
import { answersRecords } from './record-answer';
|
|
@@ -48,19 +52,22 @@ const ANSWERS: readonly Record<string, unknown>[] = [
|
|
|
48
52
|
{ type: 'array', items: {} },
|
|
49
53
|
{
|
|
50
54
|
type: 'object',
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
required: ['rows', 'nextCursor', 'hasMore', 'endCursor', 'hasNextPage'],
|
|
55
|
+
// The one page shape: the `endCursor`/`hasNextPage` alias pair left in 25.0.0 (O-12).
|
|
56
|
+
required: ['rows', 'nextCursor', 'hasMore'],
|
|
54
57
|
properties: {
|
|
55
58
|
rows: { type: 'array', items: {} },
|
|
56
59
|
nextCursor: {
|
|
57
60
|
type: ['string', 'null'],
|
|
58
|
-
description: `the signed cursor
|
|
61
|
+
description: `the signed cursor for the next page — send it as ${PAGE_AFTER_KEY}; null exactly when hasMore is false`,
|
|
59
62
|
},
|
|
60
63
|
hasMore: { type: 'boolean', description: 'whether another page follows' },
|
|
61
|
-
endCursor: { type: ['string', 'null'], description: 'alias of nextCursor — the same value' },
|
|
62
|
-
hasNextPage: { type: 'boolean', description: 'alias of hasMore — the same value' },
|
|
63
64
|
},
|
|
65
|
+
// `@ultimat3/core`'s `Page` is a union, and so is its wire shape: the two facts cannot
|
|
66
|
+
// disagree in a body this document validates (25.0.0 — the last page used to keep a cursor).
|
|
67
|
+
oneOf: [
|
|
68
|
+
{ properties: { nextCursor: { type: 'string' }, hasMore: { const: true } } },
|
|
69
|
+
{ properties: { nextCursor: { type: 'null' }, hasMore: { const: false } } },
|
|
70
|
+
],
|
|
64
71
|
},
|
|
65
72
|
];
|
|
66
73
|
|
|
@@ -70,7 +77,7 @@ export function queryOpenApiPaths(
|
|
|
70
77
|
): Record<string, OpenApiPathItem> {
|
|
71
78
|
const paths: Record<string, OpenApiPathItem> = {};
|
|
72
79
|
for (const target of [...queries].sort(compareByName)) {
|
|
73
|
-
paths[
|
|
80
|
+
paths[queryPath(queryName(target))] = { get: toQueryOpenApiOperation(target) };
|
|
74
81
|
}
|
|
75
82
|
return paths;
|
|
76
83
|
}
|
|
@@ -117,9 +124,9 @@ export function toQueryOpenApiOperation(target: AnyQuery): Record<string, unknow
|
|
|
117
124
|
capability: policyCapability(target.policy),
|
|
118
125
|
live: target.isLive,
|
|
119
126
|
cacheTags: tagKeys(target.cache?.tags ?? []),
|
|
120
|
-
//
|
|
121
|
-
// MCP catalog lists one, under the export name verbatim
|
|
122
|
-
tool:
|
|
127
|
+
// Core's one predicate, the one `@ultimat3/mcp`'s catalog and the action side ask: a tool is
|
|
128
|
+
// advertised only where the MCP catalog lists one, under the export name verbatim.
|
|
129
|
+
tool: isMcpExposed(target.mcp) ? name : null,
|
|
123
130
|
},
|
|
124
131
|
};
|
|
125
132
|
}
|
|
@@ -156,7 +163,7 @@ function singleOperation(target: AnyQuery, name: string): Record<string, unknown
|
|
|
156
163
|
live: target.isLive,
|
|
157
164
|
single: true,
|
|
158
165
|
cacheTags: tagKeys(target.cache?.tags ?? []),
|
|
159
|
-
tool:
|
|
166
|
+
tool: isMcpExposed(target.mcp) ? name : null,
|
|
160
167
|
},
|
|
161
168
|
};
|
|
162
169
|
}
|
|
@@ -177,7 +184,7 @@ const PAGE_PARAMETERS: readonly Record<string, unknown>[] = [
|
|
|
177
184
|
name: PAGE_AFTER_KEY,
|
|
178
185
|
in: 'query',
|
|
179
186
|
required: false,
|
|
180
|
-
description: `the nextCursor
|
|
187
|
+
description: `the nextCursor a previous page answered; needs ${PAGE_FIRST_KEY}`,
|
|
181
188
|
schema: { type: 'string' },
|
|
182
189
|
},
|
|
183
190
|
];
|
package/src/page-controls.ts
CHANGED
|
@@ -11,21 +11,17 @@
|
|
|
11
11
|
// the framework's own namespace mark — the route lives under `/_x/` for the same reason — and
|
|
12
12
|
// `input-shape.ts` refuses a declaration that reaches for it, so the two names can never collide.
|
|
13
13
|
|
|
14
|
+
import { MAX_PAGE_SIZE } from '@ultimat3/entity';
|
|
14
15
|
import { QueryInputInvalidError } from './errors';
|
|
15
16
|
import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-keys';
|
|
16
17
|
|
|
17
18
|
export { PAGE_AFTER_KEY, PAGE_FIRST_KEY };
|
|
18
19
|
export const PAGE_CONTROL_KEYS: readonly string[] = [PAGE_FIRST_KEY, PAGE_AFTER_KEY];
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
* an action's input or a route parameter, so `args.first + 1` bound whatever a client sent and one
|
|
25
|
-
* request could ask for five million rows. Lives here rather than in `pagination.ts` because the
|
|
26
|
-
* route validates against it BEFORE `paginate` runs — see `pageControlsOf`.
|
|
27
|
-
*/
|
|
28
|
-
export const MAX_PAGE_SIZE = 10_000;
|
|
21
|
+
// The largest page a read will serve is `@ultimat3/entity`'s `MAX_PAGE_SIZE`, imported — a batch
|
|
22
|
+
// and a page are one memory bound, and this package already depends on entity. `first` reaches
|
|
23
|
+
// `paginate` straight from a route parameter, so the route judges it against that bound BEFORE
|
|
24
|
+
// `paginate` runs (`pageControlsOf`), and one request can never ask for five million rows.
|
|
29
25
|
|
|
30
26
|
/** What the route hands `query.page()` — `PaginateArgs` minus the server-side options. */
|
|
31
27
|
export interface PageControls {
|
package/src/page-keys.ts
CHANGED
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
|
|
6
6
|
/** `?_first=20` — the page size. Present is what makes the route answer a `Page`. */
|
|
7
7
|
export const PAGE_FIRST_KEY = '_first';
|
|
8
|
-
/** `?_after=<cursor>` — the signed cursor the previous page's `
|
|
8
|
+
/** `?_after=<cursor>` — the signed cursor the previous page's `nextCursor` carried. */
|
|
9
9
|
export const PAGE_AFTER_KEY = '_after';
|