@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/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 NOT by the `sideEffects` array: Bun reads any array as `false` and drops
10
- // the module regardless (oven-sh/bun#40650). `registerPrimitiveRegistrar('query', …)` here is read
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 `createClientFlight` pays nothing for the fence,
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
- * Re-exported rather than re-declared, so every name is importable from this package exactly as
25
- * before. `isSuperseded` is core's too: reading a fenced answer is the point of installing a
26
- * flight, and it should not cost a second import.
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
- /** The compat window a retirement gets. Versioning is two deployments, not a router feature. */
69
- export type { Deprecation, DeprecationField, DeprecationRender } from './deprecation';
70
- export { recordDeprecatedCall, renderDeprecation } from './deprecation';
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 two search-string keys that page `GET /_x/query/<name>` — `_first` and `_after` — and the
110
- * shape the typed client's `.page()` takes. `MAX_PAGE_SIZE` is the bound both ends check.
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
- * `policyCapability` is the display label; `policyPermissions` is what a report MATCHES on.
124
- * `admitsAnonymous` is `@ultimat3/policy`'s, re-exported here beside them: it is what
125
- * `toQueryRoute` derives `meta.auth` from, so a plain `route` sets that field from the same walk
126
- * rather than re-reading the root combinator.
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
- resetRegistry,
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
- export type { RowProvider, SqlSource, SqlText } from './source';
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 { Builder, from, isAfterKey } from './source';
157
+ export { from, isAfterKey } from './source';
182
158
  export type { ExplainResult, QuerySqlInfo } from './sql';
183
- export { describeSql, explain } from './sql';
159
+ export { describeSql, explainQuery } from './sql';
@@ -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 { guard } from './policy-gate';
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
- guard(policy, subject, 'live');
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
- const belongs = event.op !== 'delete' && matchesFilters(event.row, shape.filters);
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 { RECORDS_OPENAPI_HEADER, recordEnvelopeSchema } from '@ultimat3/core';
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 { isExposed } from './mcp-tool';
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
- // `nextCursor`/`hasMore` are the preferred names; the other two are the same values, kept as
52
- // aliases — documented as such, not deprecated — so every client written against them reads on.
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 that continues this listing — send it as ${PAGE_AFTER_KEY}; null on an empty page`,
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[derivePath(queryName(target))] = { get: toQueryOpenApiOperation(target) };
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
- // The same rule `toMcpTool` and the action side apply: a tool is advertised only where the
121
- // MCP catalog lists one, under the export name verbatim (`mcp-tool.ts`).
122
- tool: isExposed(target) ? name : null,
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: isExposed(target) ? name : null,
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 (alias endCursor) a previous page answered; needs ${PAGE_FIRST_KEY}`,
187
+ description: `the nextCursor a previous page answered; needs ${PAGE_FIRST_KEY}`,
181
188
  schema: { type: 'string' },
182
189
  },
183
190
  ];
@@ -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
- * The largest page a read will serve. A TWIN of `@ultimat3/entity`'s `MAX_PAGE_SIZE` — this
22
- * package holds no dependency on that one, the same compromise `naming.ts` and `deprecation.ts`
23
- * are ported under — and it exists for the same reason: `first` reaches `paginate` straight from
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 `endCursor` carried. */
8
+ /** `?_after=<cursor>` — the signed cursor the previous page's `nextCursor` carried. */
9
9
  export const PAGE_AFTER_KEY = '_after';