@ultimat3/query 20.2.1 → 22.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 +158 -544
- package/README.md +65 -8
- package/package.json +9 -7
- package/src/client.ts +39 -63
- package/src/deprecation.ts +0 -5
- package/src/errors.ts +25 -49
- package/src/facade.ts +1 -0
- package/src/http.ts +40 -39
- package/src/index.ts +5 -7
- package/src/naming.ts +10 -22
- package/src/openapi.ts +26 -14
- package/src/page-controls.ts +2 -4
- package/src/page-keys.ts +9 -0
- package/src/pagination.ts +23 -4
- package/src/query.ts +13 -0
- package/src/record-answer.ts +68 -0
- package/src/shape.ts +15 -4
- package/src/source.ts +15 -0
package/src/openapi.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { tagKeys } from '@ultimat3/cache';
|
|
19
|
+
import { RECORDS_OPENAPI_HEADER, recordEnvelopeSchema } from '@ultimat3/core';
|
|
19
20
|
import type { SchemaNode } from '@ultimat3/schema';
|
|
20
21
|
import { nodeToJsonSchema, tryIntrospect } from '@ultimat3/schema';
|
|
21
22
|
import { isExposed } from './mcp-tool';
|
|
@@ -24,6 +25,7 @@ import { MAX_PAGE_SIZE, PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-controls';
|
|
|
24
25
|
import { policyCapability } from './policy-gate';
|
|
25
26
|
import type { AnyQuery } from './query';
|
|
26
27
|
import { queryName } from './read';
|
|
28
|
+
import { answersRecords } from './record-answer';
|
|
27
29
|
import { listQueries } from './registry';
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -37,6 +39,24 @@ const QUERY_TAG = 'query';
|
|
|
37
39
|
|
|
38
40
|
export type OpenApiPathItem = Record<string, unknown>;
|
|
39
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Two shapes, decided by the request: the bare rows every client read before the controls
|
|
44
|
+
* existed, and the page envelope when `_first` is sent. `oneOf` and not a second operation — it is
|
|
45
|
+
* one route, one policy evaluation, one URL prefix.
|
|
46
|
+
*/
|
|
47
|
+
const ANSWERS: readonly Record<string, unknown>[] = [
|
|
48
|
+
{ type: 'array', items: {} },
|
|
49
|
+
{
|
|
50
|
+
type: 'object',
|
|
51
|
+
required: ['rows', 'endCursor', 'hasNextPage'],
|
|
52
|
+
properties: {
|
|
53
|
+
rows: { type: 'array', items: {} },
|
|
54
|
+
endCursor: { type: ['string', 'null'] },
|
|
55
|
+
hasNextPage: { type: 'boolean' },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
40
60
|
/** `{ [path]: { get: operation } }` for every registered read, name-sorted like the actions. */
|
|
41
61
|
export function queryOpenApiPaths(
|
|
42
62
|
queries: readonly AnyQuery[] = listQueries(),
|
|
@@ -70,22 +90,14 @@ export function toQueryOpenApiOperation(target: AnyQuery): Record<string, unknow
|
|
|
70
90
|
// Two shapes, decided by the request: the bare rows every client read before the
|
|
71
91
|
// controls existed, and the page envelope when `_first` is sent. `oneOf` and not a
|
|
72
92
|
// second operation — it is one route, one policy evaluation, one URL prefix.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
},
|
|
93
|
+
// A read declaring an entity's `rows:` ALWAYS answers the envelope, with the same two
|
|
94
|
+
// shapes under `data` — core's one description, shared with the action projection.
|
|
95
|
+
schema: answersRecords(target.rows)
|
|
96
|
+
? recordEnvelopeSchema({ oneOf: ANSWERS })
|
|
97
|
+
: { oneOf: ANSWERS },
|
|
87
98
|
},
|
|
88
99
|
},
|
|
100
|
+
...(answersRecords(target.rows) ? { headers: RECORDS_OPENAPI_HEADER } : {}),
|
|
89
101
|
},
|
|
90
102
|
// `X_INPUT_INVALID` is the search string failing the read's own schema, or a page control
|
|
91
103
|
// outside its bound; `X_CURSOR_INVALID` is an `_after` that is not one of this read's. Both
|
package/src/page-controls.ts
CHANGED
|
@@ -12,11 +12,9 @@
|
|
|
12
12
|
// `input-shape.ts` refuses a declaration that reaches for it, so the two names can never collide.
|
|
13
13
|
|
|
14
14
|
import { QueryInputInvalidError } from './errors';
|
|
15
|
+
import { PAGE_AFTER_KEY, PAGE_FIRST_KEY } from './page-keys';
|
|
15
16
|
|
|
16
|
-
|
|
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';
|
|
17
|
+
export { PAGE_AFTER_KEY, PAGE_FIRST_KEY };
|
|
20
18
|
export const PAGE_CONTROL_KEYS: readonly string[] = [PAGE_FIRST_KEY, PAGE_AFTER_KEY];
|
|
21
19
|
|
|
22
20
|
/**
|
package/src/page-keys.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// The two search-string keys that turn `GET /_x/query/<name>` into a page — and nothing else. A
|
|
2
|
+
// leaf apart from `page-controls.ts` because that module refuses a bad value, which means it imports
|
|
3
|
+
// this package's `errors.ts`, which registers the whole code table at import: the browser client
|
|
4
|
+
// that only SPELLS the keys must not carry it. `page-controls.ts` re-exports both names.
|
|
5
|
+
|
|
6
|
+
/** `?_first=20` — the page size. Present is what makes the route answer a `Page`. */
|
|
7
|
+
export const PAGE_FIRST_KEY = '_first';
|
|
8
|
+
/** `?_after=<cursor>` — the signed cursor the previous page's `endCursor` carried. */
|
|
9
|
+
export const PAGE_AFTER_KEY = '_after';
|
package/src/pagination.ts
CHANGED
|
@@ -50,13 +50,13 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
|
|
|
50
50
|
// The scope is this read plus these arguments: a cursor from anywhere else is
|
|
51
51
|
// already `X_CURSOR_INVALID` by the time it gets here.
|
|
52
52
|
const decoded = args.after === undefined ? null : decodeCursor(args.after, hash);
|
|
53
|
+
const base = await sourceFor(target, input, args);
|
|
54
|
+
const shape = base.shape();
|
|
53
55
|
// Revived to the types the columns hold, never left as the strings JSON handed back: a `Date`
|
|
54
56
|
// key decoded as an ISO string reaches `compareValues` as text and is compared against the
|
|
55
57
|
// row's own millisecond number, so page two matched nothing at all. See `cursor-value.ts`.
|
|
56
58
|
const after: SeekKey | null =
|
|
57
|
-
decoded === null ? null :
|
|
58
|
-
const base = await sourceFor(target, input, args);
|
|
59
|
-
const shape = base.shape();
|
|
59
|
+
decoded === null ? null : seekFromCursor(decoded, shape.orderBy.length);
|
|
60
60
|
|
|
61
61
|
// `MAX_PAGE_SIZE` lives in `page-controls.ts`: the route checks the same bound at the wire, as
|
|
62
62
|
// a 400, before this `assert` — which is a 500 — can see the number.
|
|
@@ -75,7 +75,13 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
|
|
|
75
75
|
endCursor:
|
|
76
76
|
seek === null
|
|
77
77
|
? null
|
|
78
|
-
: encodeCursor({
|
|
78
|
+
: encodeCursor({
|
|
79
|
+
scope: hash,
|
|
80
|
+
// The id rides TYPED at the tail of the key — core's `id` slot is a string — so a
|
|
81
|
+
// numeric or bigint tiebreak compares as a number on the next page, not lexically.
|
|
82
|
+
key: [...seek.key, seek.id].map(serializeSortValue),
|
|
83
|
+
id: String(seek.id),
|
|
84
|
+
}),
|
|
79
85
|
hasNextPage: scoped.length > args.first,
|
|
80
86
|
};
|
|
81
87
|
}
|
|
@@ -109,3 +115,16 @@ function inTotalOrder(
|
|
|
109
115
|
if (after === null) return ordered;
|
|
110
116
|
return ordered.filter((row) => isAfterKey(row, after, shape.orderBy));
|
|
111
117
|
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The seek a decoded cursor names. A cursor carrying one more key than the ordering has its typed
|
|
121
|
+
* id at the tail; an older cursor, minted before the id rode there, falls back to the string slot.
|
|
122
|
+
*/
|
|
123
|
+
function seekFromCursor(
|
|
124
|
+
decoded: { readonly key: readonly unknown[]; readonly id: string },
|
|
125
|
+
width: number,
|
|
126
|
+
): SeekKey {
|
|
127
|
+
const key = reviveSortKey(decoded.key);
|
|
128
|
+
if (key.length !== width + 1) return { key, id: decoded.id };
|
|
129
|
+
return { key: key.slice(0, width), id: key[width] };
|
|
130
|
+
}
|
package/src/query.ts
CHANGED
|
@@ -89,6 +89,15 @@ export interface QueryDef<TInput extends StandardSchemaV1, TRow extends object>
|
|
|
89
89
|
* is legal — a join reads relations the shape's single `entity` cannot name.
|
|
90
90
|
*/
|
|
91
91
|
readonly subscribes?: readonly string[];
|
|
92
|
+
/**
|
|
93
|
+
* The row schema, when every row IS an entity's row — `rows: Post.$schema`. Typed against the
|
|
94
|
+
* row `sql:` answers, so a projection (`from<PostSummary>`) cannot claim a full entity's schema.
|
|
95
|
+
* It is what the route reads to answer the record envelope (`x-ultimate-records: 1`) so the
|
|
96
|
+
* page's one store adopts the rows; absent, or unbranded, the wire is the bare rows it has
|
|
97
|
+
* always been. `sql:` names a table as a STRING, so nothing else carries the schema to derive
|
|
98
|
+
* from — this key is that carrier, never a `records:` switch.
|
|
99
|
+
*/
|
|
100
|
+
readonly rows?: StandardSchemaV1<unknown, TRow>;
|
|
92
101
|
sql(input: InferOutput<TInput>, ctx: Ctx): SqlSource<TRow>;
|
|
93
102
|
readonly cache?: QueryCache;
|
|
94
103
|
readonly mcp?: QueryMcp;
|
|
@@ -179,6 +188,7 @@ export interface AnyQueryDef {
|
|
|
179
188
|
readonly policy: QueryPolicy;
|
|
180
189
|
readonly live?: boolean;
|
|
181
190
|
readonly subscribes?: readonly string[];
|
|
191
|
+
readonly rows?: StandardSchemaV1;
|
|
182
192
|
sql(input: unknown, ctx: Ctx): SqlSource<object>;
|
|
183
193
|
readonly cache?: QueryCache;
|
|
184
194
|
readonly mcp?: QueryMcp;
|
|
@@ -196,6 +206,8 @@ export interface AnyQuery {
|
|
|
196
206
|
readonly policy: QueryPolicy;
|
|
197
207
|
/** Lifted for the same reason `rateLimit` is: `toLiveQuery` cross-checks it without `defOf`. */
|
|
198
208
|
readonly subscribes?: readonly string[];
|
|
209
|
+
/** Lifted for the route: whether an answer is a record envelope is decided at projection. */
|
|
210
|
+
readonly rows?: StandardSchemaV1;
|
|
199
211
|
readonly cache?: QueryCache;
|
|
200
212
|
readonly mcp?: QueryMcp;
|
|
201
213
|
/** Lifted so `toQueryRoute` reads the declaration without reaching through `defOf`. */
|
|
@@ -240,6 +252,7 @@ export type QueryFacade<TInput extends StandardSchemaV1, TRow extends object> =
|
|
|
240
252
|
| 'input'
|
|
241
253
|
| 'policy'
|
|
242
254
|
| 'subscribes'
|
|
255
|
+
| 'rows'
|
|
243
256
|
| 'cache'
|
|
244
257
|
| 'mcp'
|
|
245
258
|
| 'rateLimit'
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A read's HTTP answer. Decided ONCE per query, at projection, from its `rows:` schema — the rule
|
|
3
|
+
* `@ultimat3/action`'s `record-wire.ts` already follows: a query whose `rows:` is an entity row
|
|
4
|
+
* schema ALWAYS answers `{ data, records }` behind `x-ultimate-records: 1` (records may be `{}`),
|
|
5
|
+
* and every other query answers the bare rows (or `Page`) it always did. One body shape per
|
|
6
|
+
* operation — until 21.0.0 a read sent the envelope only when a row came back, so its OpenAPI was a
|
|
7
|
+
* `oneOf` over "sometimes" and a generated client had to branch on the data.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { RecordRows, Row } from '@ultimat3/core';
|
|
11
|
+
import { encodeRecordEnvelope, RECORDS_HEADER } from '@ultimat3/core';
|
|
12
|
+
import { hasEntityRows, rowsOf } from '@ultimat3/entity';
|
|
13
|
+
import { json } from '@ultimat3/http';
|
|
14
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
15
|
+
import type { Page } from './pagination';
|
|
16
|
+
|
|
17
|
+
/** Turns one read's answer into its response. Built once per route, never per request. */
|
|
18
|
+
export type RecordAnswer = (answer: readonly object[] | Page<object>) => Response;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `undefined` or an unbranded schema answers the plain `json()` it always did. The rows are
|
|
22
|
+
* collected one at a time against the ROW schema, since the answer's own shape — an array or a
|
|
23
|
+
* `Page` — is the route's, not the declaration's.
|
|
24
|
+
*/
|
|
25
|
+
export function recordAnswerFor(rows: StandardSchemaV1 | undefined): RecordAnswer {
|
|
26
|
+
if (!answersRecords(rows)) return (answer) => json(answer);
|
|
27
|
+
return (answer) => {
|
|
28
|
+
const records = collect(rows, isPage(answer) ? answer.rows : answer);
|
|
29
|
+
return json(encodeRecordEnvelope(answer, records), { headers: { [RECORDS_HEADER]: '1' } });
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The one predicate the route and the OpenAPI projection both ask: can this read answer records? */
|
|
34
|
+
export function answersRecords(rows: StandardSchemaV1 | undefined): rows is StandardSchemaV1 {
|
|
35
|
+
return rows !== undefined && hasEntityRows(rows);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `Array.isArray` does not narrow a `readonly` array out of a union, so this says it once. */
|
|
39
|
+
function isPage(answer: readonly object[] | Page<object>): answer is Page<object> {
|
|
40
|
+
return !Array.isArray(answer);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Every entity row across the answer's rows: record type -> record key -> row. First sighting wins,
|
|
45
|
+
* `rowsOf`'s own rule — one record shown twice is one record. Built in `Map`s and handed out as
|
|
46
|
+
* null-prototype objects: a record type and a record key are data, and `__proto__` must stay a key.
|
|
47
|
+
*/
|
|
48
|
+
function collect(
|
|
49
|
+
schema: StandardSchemaV1,
|
|
50
|
+
list: readonly object[],
|
|
51
|
+
): Readonly<Record<string, RecordRows>> {
|
|
52
|
+
const out = new Map<string, Map<string, Row>>();
|
|
53
|
+
for (const row of list) {
|
|
54
|
+
for (const [type, byKey] of Object.entries(rowsOf(schema, row))) {
|
|
55
|
+
const bucket = out.get(type) ?? new Map<string, Row>();
|
|
56
|
+
out.set(type, bucket);
|
|
57
|
+
for (const [key, found] of Object.entries(byKey))
|
|
58
|
+
if (!bucket.has(key)) bucket.set(key, found);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return nullProto([...out].map(([type, bucket]) => [type, nullProto(bucket)] as const));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function nullProto<V>(entries: Iterable<readonly [string, V]>): Readonly<Record<string, V>> {
|
|
65
|
+
const target = Object.create(null) as Record<string, V>;
|
|
66
|
+
for (const [key, value] of entries) target[key] = value;
|
|
67
|
+
return target;
|
|
68
|
+
}
|
package/src/shape.ts
CHANGED
|
@@ -31,10 +31,14 @@ export interface QueryShape {
|
|
|
31
31
|
readonly unsupported: readonly string[];
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Where a page resumes: the sort-key values of the last row plus its id tiebreak. `id` keeps the
|
|
36
|
+
* row's own TYPE: a stringified numeric id compared lexically against the next page's rows, so
|
|
37
|
+
* `"10" < "7"` and a row tied on the sort key after id 7 was never served.
|
|
38
|
+
*/
|
|
35
39
|
export interface SeekKey {
|
|
36
40
|
readonly key: readonly unknown[];
|
|
37
|
-
readonly id:
|
|
41
|
+
readonly id: unknown;
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
/**
|
|
@@ -53,7 +57,7 @@ export function seekKeyOf(
|
|
|
53
57
|
if (id === undefined || id === null) throw new QueryNotPageableError(shape.entity);
|
|
54
58
|
return {
|
|
55
59
|
key: shape.orderBy.map((order) => columnOf(row, order.column)),
|
|
56
|
-
id
|
|
60
|
+
id,
|
|
57
61
|
};
|
|
58
62
|
}
|
|
59
63
|
|
|
@@ -193,11 +197,18 @@ function normalize(value: unknown): unknown {
|
|
|
193
197
|
return value instanceof Date ? value.getTime() : value;
|
|
194
198
|
}
|
|
195
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Equality as Postgres answers it. A number and a bigint are ONE family — an `int8` arrives as a
|
|
202
|
+
* `bigint` from one driver while a filter value is usually a `number` literal, and `5n = 5` is
|
|
203
|
+
* true there — so the type check that keeps `'5'` from equalling `5` treats them as one.
|
|
204
|
+
*/
|
|
196
205
|
function same(a: unknown, b: unknown): boolean {
|
|
197
206
|
if (isNull(a) || isNull(b)) return isNull(a) && isNull(b);
|
|
198
|
-
return compareValues(a, b) === 0 &&
|
|
207
|
+
return compareValues(a, b) === 0 && family(normalize(a)) === family(normalize(b));
|
|
199
208
|
}
|
|
200
209
|
|
|
210
|
+
const family = (value: unknown): string => (isNumeric(value) ? 'numeric' : typeof value);
|
|
211
|
+
|
|
201
212
|
/** Row ordering under an `orderBy` list. Stable, and total when an id key is last. */
|
|
202
213
|
export function compareRows(a: object, b: object, orderBy: readonly OrderKey[]): number {
|
|
203
214
|
for (const key of orderBy) {
|
package/src/source.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* app's `sql:` returns `from()` over an `@ultimat3/entity` repo, and a source only has to
|
|
5
5
|
* answer these four questions.
|
|
6
6
|
*/
|
|
7
|
+
import { QueryColumnUnselectedError } from './errors';
|
|
7
8
|
import type { Filter, FilterOp, OrderKey, QueryShape, SeekKey } from './shape';
|
|
8
9
|
import { compareRows, compareValues, isNull, matchesFilters, totalOrder } from './shape';
|
|
9
10
|
import { columnOf } from './stable';
|
|
@@ -132,6 +133,7 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
|
|
|
132
133
|
|
|
133
134
|
async execute(): Promise<readonly TRow[]> {
|
|
134
135
|
const source = typeof this.rows === 'function' ? await this.rows() : this.rows;
|
|
136
|
+
this.assertCarried(source);
|
|
135
137
|
let result = source.filter((row) => matchesFilters(row, this.filters));
|
|
136
138
|
const keys = this.servedOrder();
|
|
137
139
|
if (keys.length > 0) {
|
|
@@ -144,6 +146,19 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
|
|
|
144
146
|
return this.rowLimit === null ? result : result.slice(0, this.rowLimit);
|
|
145
147
|
}
|
|
146
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Every column a filter or the declared ordering reads must be ON the rows. Only the declared
|
|
151
|
+
* keys: the `id` tiebreak `servedOrder()` adds is `seek()`'s concern, refused there by name.
|
|
152
|
+
*/
|
|
153
|
+
private assertCarried(rows: readonly TRow[]): void {
|
|
154
|
+
const read = new Set([...this.filters, ...this.order].map((entry) => entry.column));
|
|
155
|
+
for (const row of rows) {
|
|
156
|
+
for (const column of read) {
|
|
157
|
+
if (!(column in row)) throw new QueryColumnUnselectedError(this.entity, column);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
147
162
|
/** True when the ordering already names `id`, so the tiebreak is neither added nor doubled. */
|
|
148
163
|
private get ordersById(): boolean {
|
|
149
164
|
return this.order.some((key) => key.column === 'id');
|