@ultimat3/query 9.0.0 → 11.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 +6 -6
- package/src/errors.ts +12 -14
- package/src/pagination.ts +19 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/query",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "11.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": "
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/http": "
|
|
37
|
-
"@ultimat3/policy": "
|
|
38
|
-
"@ultimat3/schema": "
|
|
34
|
+
"@ultimat3/cache": "11.0.0",
|
|
35
|
+
"@ultimat3/core": "11.0.0",
|
|
36
|
+
"@ultimat3/http": "11.0.0",
|
|
37
|
+
"@ultimat3/policy": "11.0.0",
|
|
38
|
+
"@ultimat3/schema": "11.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
|
-
|
|
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:
|
|
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 ?
|
|
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
|
|
108
|
+
function inTotalOrder(
|
|
97
109
|
rows: readonly object[],
|
|
98
110
|
after: SeekKey | null,
|
|
99
111
|
shape: QueryShape,
|
|
100
112
|
): readonly object[] {
|
|
101
|
-
|
|
102
|
-
|
|
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
|
}
|