@ultimat3/query 9.0.0 → 10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/query",
3
- "version": "9.0.0",
3
+ "version": "10.0.0",
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",
@@ -31,10 +31,10 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/cache": "9.0.0",
35
- "@ultimat3/core": "9.0.0",
36
- "@ultimat3/http": "9.0.0",
37
- "@ultimat3/policy": "9.0.0",
38
- "@ultimat3/schema": "9.0.0"
34
+ "@ultimat3/cache": "10.0.0",
35
+ "@ultimat3/core": "10.0.0",
36
+ "@ultimat3/http": "10.0.0",
37
+ "@ultimat3/policy": "10.0.0",
38
+ "@ultimat3/schema": "10.0.0"
39
39
  }
40
40
  }
package/src/errors.ts CHANGED
@@ -2,7 +2,12 @@
2
2
  import { assertNever, registerErrorCodes, UltimateError } from '@ultimat3/core';
3
3
  import type { SurfaceDenial } from '@ultimat3/policy';
4
4
 
5
- const docs = (code: string): string => `https://ultimate.dev/errors/${code}`;
5
+ // No `docs:` on the classes below, with one exception noted at `QueryRequestFailedError`.
6
+ // `UltimateError` fills it from `describeErrorCode(code).docs`, which is `@ultimat3/core`'s
7
+ // `ERROR_DOCS_URL` — one page for every code, never one per code, because `wiki/` is the
8
+ // framework's only public documentation surface and a code lives there in a TABLE ROW, which has
9
+ // no anchor. The `https://ultimate.dev/errors/<code>` links this file built until 9.x answered
10
+ // 404, host included, on every read this package has ever refused.
6
11
 
7
12
  /** One class, one code: core owns the cursor codec, so core owns `X_CURSOR_INVALID`. */
8
13
  export { CursorInvalidError } from '@ultimat3/core';
@@ -76,7 +81,6 @@ export class QueryDeniedError extends UltimateError {
76
81
  code,
77
82
  cause: `${query} denied: ${denialReason(denial)}`,
78
83
  fix: `x policy explain ${query} --json # shows which clause decided and why`,
79
- docs: docs(code),
80
84
  });
81
85
  this.denial = denial;
82
86
  }
@@ -89,7 +93,6 @@ export class QueryUnregisteredError extends UltimateError {
89
93
  code: 'X_QUERY_UNREGISTERED',
90
94
  cause: 'a query was used before it was registered, so it has no name',
91
95
  fix: "call registerQueries(await import('./live')) at boot, before serving reads",
92
- docs: docs('X_QUERY_UNREGISTERED'),
93
96
  });
94
97
  }
95
98
  }
@@ -106,7 +109,6 @@ export class QueryForeignError extends UltimateError {
106
109
  code: 'X_QUERY_FOREIGN',
107
110
  cause: `"${name === '' ? 'anonymous' : name}" is not a query built by query()`,
108
111
  fix: "declare it as `export const name = query({ input, policy, sql })` from '@ultimat3/query'",
109
- docs: docs('X_QUERY_FOREIGN'),
110
112
  });
111
113
  }
112
114
  }
@@ -125,7 +127,6 @@ export class QueryInputUnencodableError extends UltimateError {
125
127
  code: 'X_QUERY_INPUT_UNENCODABLE',
126
128
  cause: `${offender}, and a read is served as GET /_x/query/<name> — a query string carries characters, not structures or nulls`,
127
129
  fix: 'flatten the key into scalar arguments (status: t.string, limit: t.number), spell an absent value as `.optional()` rather than `t.nullable(...)`, or declare it as an action() if it really needs a JSON body',
128
- docs: docs('X_QUERY_INPUT_UNENCODABLE'),
129
130
  });
130
131
  }
131
132
  }
@@ -148,7 +149,6 @@ export class QueryCacheTtlInvalidError extends UltimateError {
148
149
  code: 'X_QUERY_CACHE_TTL_INVALID',
149
150
  cause: `a query declares cache.ttlMs as ${ttlMs}, and every cache tier refuses a lease that is not positive and finite`,
150
151
  fix: 'set `cache: { ttlMs: 60_000 }` to a positive whole number of milliseconds, or drop ttlMs to take the read cache default',
151
- docs: docs('X_QUERY_CACHE_TTL_INVALID'),
152
152
  meta: { ttlMs },
153
153
  });
154
154
  }
@@ -160,7 +160,6 @@ export class QueryDuplicateError extends UltimateError {
160
160
  code: 'X_QUERY_DUPLICATE',
161
161
  cause: `two queries are registered under the name "${name}"`,
162
162
  fix: 'rename one export — query names are globally unique: x queries list --json',
163
- docs: docs('X_QUERY_DUPLICATE'),
164
163
  });
165
164
  }
166
165
  }
@@ -179,7 +178,6 @@ export class QueryPolicyMissingError extends UltimateError {
179
178
  code: 'X_QUERY_POLICY_MISSING',
180
179
  cause: `query "${name}" was registered without a policy`,
181
180
  fix: `add \`policy: can('<resource>:<verb>')\` to the query() that exports "${name}" — a permission your definePermissions() call declares, never the query's own name — or \`allow('<resource>:<verb>')\` to state that the read is public`,
182
- docs: docs('X_QUERY_POLICY_MISSING'),
183
181
  });
184
182
  }
185
183
  }
@@ -196,7 +194,6 @@ export class QueryDeprecationInvalidError extends UltimateError {
196
194
  code: 'X_QUERY_DEPRECATION_INVALID',
197
195
  cause: `query "${name}" declares deprecated.${field} as "${value}", which is not a date`,
198
196
  fix: `edit \`deprecated: { ${field}: … }\` on ${name} to an ISO-8601 instant — e.g. '2026-12-31T23:59:59Z'`,
199
- docs: docs('X_QUERY_DEPRECATION_INVALID'),
200
197
  meta: { query: name, field, value },
201
198
  });
202
199
  }
@@ -214,7 +211,6 @@ export class QueryNotPageableError extends UltimateError {
214
211
  code: 'X_QUERY_NOT_PAGEABLE',
215
212
  cause: `a row from ${subject} has no "id", so a cursor cannot name its position`,
216
213
  fix: `return the primary key from the query's sql: db.${entity ?? 'rows'}.select({ id: true, … })`,
217
- docs: docs('X_QUERY_NOT_PAGEABLE'),
218
214
  });
219
215
  }
220
216
  }
@@ -237,7 +233,6 @@ export class CursorValueUnsupportedError extends UltimateError {
237
233
  code: 'X_CURSOR_VALUE_UNSUPPORTED',
238
234
  cause: `a sort key holds ${description}, which no cursor can carry`,
239
235
  fix: 'order by a scalar column — .orderBy("createdAt") or .orderBy("id") — and project the composite value into the row instead',
240
- docs: docs('X_CURSOR_VALUE_UNSUPPORTED'),
241
236
  });
242
237
  }
243
238
  }
@@ -249,7 +244,6 @@ export class MatcherUnsupportedError extends UltimateError {
249
244
  code: 'X_MATCHER_UNSUPPORTED',
250
245
  cause: `live query "${name}" uses ${feature}, which the incremental matcher cannot patch`,
251
246
  fix: `set \`live: false\` and poll, or reshape the query to equality filters + orderBy + limit`,
252
- docs: docs('X_MATCHER_UNSUPPORTED'),
253
247
  });
254
248
  }
255
249
  }
@@ -260,7 +254,6 @@ export class QueryInputInvalidError extends UltimateError {
260
254
  code: 'X_INPUT_INVALID',
261
255
  cause: `input for query "${name}" failed validation: ${detail}`,
262
256
  fix: `x queries describe ${name} --json # prints the expected input schema`,
263
- docs: docs('X_INPUT_INVALID'),
264
257
  });
265
258
  }
266
259
  }
@@ -283,13 +276,18 @@ export interface QueryProblem {
283
276
  export class QueryRequestFailedError extends UltimateError {
284
277
  constructor(name: string, status: number, problem: QueryProblem = {}) {
285
278
  const code = text(problem.code) ?? 'X_RPC_FAILED';
279
+ const served = text(problem.docs);
286
280
  super({
287
281
  code,
288
282
  cause: text(problem.cause) ?? text(problem.detail) ?? `${name} returned HTTP ${status}`,
289
283
  fix:
290
284
  text(problem.fix) ??
291
285
  `check the gateway in front of the app, then: x queries describe ${name} --json`,
292
- docs: text(problem.docs) ?? docs(code),
286
+ // The one place a `docs` is passed: the SERVER's, re-thrown verbatim beside the cause and
287
+ // fix it came with. An app that documents its own codes somewhere else is entitled to say
288
+ // so, and overwriting it with this framework's page would bury the answer. Absent, the
289
+ // constructor resolves `ERROR_DOCS_URL` like every other error here.
290
+ ...(served === undefined ? {} : { docs: served }),
293
291
  });
294
292
  }
295
293
  }
package/src/pagination.ts CHANGED
@@ -15,7 +15,7 @@ import { reviveSortKey, serializeSortValue } from './cursor-value';
15
15
  import type { Query, SourceOptions } from './query';
16
16
  import { queryHash, queryName, sourceFor } from './query';
17
17
  import type { QueryShape, SeekKey } from './shape';
18
- import { seekKeyOf } from './shape';
18
+ import { compareRows, seekKeyOf, totalOrder } from './shape';
19
19
  import type { SqlSource } from './source';
20
20
  import { isAfterKey } from './source';
21
21
 
@@ -70,7 +70,7 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
70
70
  const window = args.first + 1;
71
71
  const source: SqlSource<object> = base.seek === undefined ? base : base.seek(after, window);
72
72
  const executed = await source.execute();
73
- const scoped = base.seek === undefined ? sliceAfter(executed, after, shape) : executed;
73
+ const scoped = base.seek === undefined ? inTotalOrder(executed, after, shape) : executed;
74
74
  // The source came from this query's own `sql()`, so its rows are TRow.
75
75
  const rows = scoped.slice(0, args.first) as unknown as readonly TRow[];
76
76
  const last = rows[rows.length - 1];
@@ -92,12 +92,26 @@ export async function paginate<TInput extends StandardSchemaV1, TRow extends obj
92
92
  * looks equivalent and is not: the row can be gone by the next request, `findIndex` answers -1,
93
93
  * and every row from the top comes back as page two. Under a delete between two pages that is a
94
94
  * silent restart, which is the failure keyset pagination exists to make impossible.
95
+ *
96
+ * **It SORTS first, and that half was missing.** `isAfterKey` breaks a tie on the declared keys by
97
+ * `id` — it has to, or the cut is not a position at all — while a foreign `SqlSource` ordered its
98
+ * rows by the DECLARED keys alone. So a tie group arrived in an order the cut does not describe,
99
+ * and the cut fell in the middle of it: page one served `a(10), d(20)`, the cursor named `(20, d)`,
100
+ * and rows `b(20)` and `c(20)` matched no page in the listing. Rows VANISH — silently, and only
101
+ * where two rows share a sort key.
102
+ *
103
+ * `Builder` has always done this: `execute()` sorts by `servedOrder()`, which appends `id` once a
104
+ * read asked to be seekable. This is that rule applied to the path a `Builder` does not take, and
105
+ * it is the ordering the cursor arithmetic already assumes on both sides. Reachable only for a
106
+ * hand-written `SqlSource` with no `seek()` — the branch this whole function exists for.
95
107
  */
96
- function sliceAfter(
108
+ function inTotalOrder(
97
109
  rows: readonly object[],
98
110
  after: SeekKey | null,
99
111
  shape: QueryShape,
100
112
  ): readonly object[] {
101
- if (after === null) return rows;
102
- return rows.filter((row) => isAfterKey(row, after, shape.orderBy));
113
+ const keys = totalOrder(shape.orderBy);
114
+ const ordered = [...rows].sort((left, right) => compareRows(left, right, keys));
115
+ if (after === null) return ordered;
116
+ return ordered.filter((row) => isAfterKey(row, after, shape.orderBy));
103
117
  }