@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/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
- 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
- },
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
@@ -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
- /** `?_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';
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
  /**
@@ -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 : { key: reviveSortKey(decoded.key), id: decoded.id };
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({ scope: hash, key: seek.key.map(serializeSortValue), id: seek.id }),
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
- /** Where a page resumes: the sort-key values of the last row plus its id tiebreak. */
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: string;
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: typeof id === 'string' ? id : String(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 && typeof normalize(a) === typeof normalize(b);
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');