@ultimat3/query 1.2.0 → 2.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 +393 -0
- package/README.md +179 -10
- package/package.json +7 -5
- package/src/cache.ts +180 -71
- package/src/client.ts +83 -2
- package/src/cursor-value.ts +65 -0
- package/src/deprecation.ts +81 -0
- package/src/errors.ts +97 -1
- package/src/facade.ts +2 -0
- package/src/http.ts +109 -0
- package/src/index.ts +40 -9
- package/src/input-shape.ts +74 -0
- package/src/live.ts +27 -3
- package/src/matcher.ts +36 -9
- package/src/mcp-tool.ts +11 -5
- package/src/naming.ts +5 -9
- package/src/pagination.ts +25 -3
- package/src/policy-gate.ts +13 -2
- package/src/query.ts +108 -8
- package/src/read.ts +116 -16
- package/src/shape.ts +103 -6
- package/src/source.ts +137 -49
- package/src/sql.ts +2 -2
- package/src/stable.ts +23 -12
- package/src/tags.ts +0 -17
package/src/source.ts
CHANGED
|
@@ -5,9 +5,15 @@
|
|
|
5
5
|
* answer these four questions.
|
|
6
6
|
*/
|
|
7
7
|
import type { Filter, FilterOp, OrderKey, QueryShape, SeekKey } from './shape';
|
|
8
|
-
import { compareRows, compareValues, matchesFilters } from './shape';
|
|
8
|
+
import { compareRows, compareValues, isNull, matchesFilters, totalOrder } from './shape';
|
|
9
9
|
import { columnOf } from './stable';
|
|
10
10
|
|
|
11
|
+
/** Nothing matches. `in ()` is a syntax error in Postgres, so an empty set needs a constant. */
|
|
12
|
+
const NEVER = '1 = 0';
|
|
13
|
+
|
|
14
|
+
/** Binds a value and answers the `$n` that reads it. Nothing here interpolates a value. */
|
|
15
|
+
type Slot = (value: unknown) => string;
|
|
16
|
+
|
|
11
17
|
export interface SqlText {
|
|
12
18
|
readonly sql: string;
|
|
13
19
|
readonly params: readonly unknown[];
|
|
@@ -19,6 +25,12 @@ export interface SqlSource<TRow> {
|
|
|
19
25
|
execute(): Promise<readonly TRow[]>;
|
|
20
26
|
/** Required for `live: true`: the matcher patches from the shape, not from SQL. */
|
|
21
27
|
shape(): QueryShape;
|
|
28
|
+
/**
|
|
29
|
+
* The same read served in `totalOrder` — the declared keys, then `id`. A live read is built
|
|
30
|
+
* through this, because the matcher places a patched row by that order and a reconnect resumes
|
|
31
|
+
* by it. Absent means the source already serves one order it can be resumed in.
|
|
32
|
+
*/
|
|
33
|
+
total?(): SqlSource<TRow>;
|
|
22
34
|
/** Cursor push-down. Absent means pagination slices after execution. */
|
|
23
35
|
seek?(after: SeekKey | null, limit: number): SqlSource<TRow>;
|
|
24
36
|
}
|
|
@@ -42,10 +54,16 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
|
|
|
42
54
|
private readonly rowLimit: number | null,
|
|
43
55
|
private readonly after: SeekKey | null,
|
|
44
56
|
private readonly unsupported: readonly string[],
|
|
45
|
-
/** Set by `seek()
|
|
46
|
-
private readonly
|
|
57
|
+
/** Set by `seek()` and `total()` — the only reads that pay for the id tiebreak. */
|
|
58
|
+
private readonly totalized: boolean = false,
|
|
47
59
|
) {}
|
|
48
60
|
|
|
61
|
+
/**
|
|
62
|
+
* One equality filter per key, and the clauses come out in **lexical key order** — not the order
|
|
63
|
+
* the object literal was typed in. The generated text is something callers compare across runs:
|
|
64
|
+
* `explain()` prints it, `LiveQuery.sqlText` caches it, and tests pin it. Reordering two keys in
|
|
65
|
+
* a call site must not rewrite the statement.
|
|
66
|
+
*/
|
|
49
67
|
where(equals: Readonly<Record<string, unknown>>): Builder<TRow> {
|
|
50
68
|
const added = Object.keys(equals)
|
|
51
69
|
.sort()
|
|
@@ -71,7 +89,15 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
|
|
|
71
89
|
}
|
|
72
90
|
|
|
73
91
|
seek(after: SeekKey | null, limit: number): Builder<TRow> {
|
|
74
|
-
return this.derive({ after, rowLimit: limit,
|
|
92
|
+
return this.derive({ after, rowLimit: limit, totalized: true });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The declared keys plus the `id` tiebreak, with no cursor and no window — page one of the
|
|
97
|
+
* ordering a paged read already serves. `seek()` implies it; a live read asks for it directly.
|
|
98
|
+
*/
|
|
99
|
+
total(): Builder<TRow> {
|
|
100
|
+
return this.derive({ totalized: true });
|
|
75
101
|
}
|
|
76
102
|
|
|
77
103
|
shape(): QueryShape {
|
|
@@ -86,24 +112,12 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
|
|
|
86
112
|
|
|
87
113
|
toSQL(): SqlText {
|
|
88
114
|
const params: unknown[] = [];
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
params.push(item);
|
|
93
|
-
return `$${params.length}`;
|
|
94
|
-
});
|
|
95
|
-
return `"${filter.column}" in (${slots.join(', ')})`;
|
|
96
|
-
}
|
|
97
|
-
params.push(filter.value);
|
|
98
|
-
return `"${filter.column}" ${filter.op} $${params.length}`;
|
|
99
|
-
});
|
|
100
|
-
if (this.after !== null) clauses.push(this.seekClause(this.after, params));
|
|
115
|
+
const slot = slotter(params);
|
|
116
|
+
const clauses = this.filters.map((filter) => filterClause(filter, slot));
|
|
117
|
+
if (this.after !== null) clauses.push(this.seekClause(this.after, slot));
|
|
101
118
|
const where = clauses.length > 0 ? ` where ${clauses.join(' and ')}` : '';
|
|
102
|
-
const keys = this.
|
|
103
|
-
const order =
|
|
104
|
-
keys.length > 0
|
|
105
|
-
? ` order by ${keys.map((key) => `"${key.column}" ${key.direction}`).join(', ')}`
|
|
106
|
-
: '';
|
|
119
|
+
const keys = this.servedOrder();
|
|
120
|
+
const order = keys.length > 0 ? ` order by ${keys.map(orderTerm).join(', ')}` : '';
|
|
107
121
|
const limit = this.rowLimit === null ? '' : ` limit ${this.rowLimit}`;
|
|
108
122
|
return { sql: `select * from "${this.entity}"${where}${order}${limit}`, params };
|
|
109
123
|
}
|
|
@@ -111,7 +125,7 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
|
|
|
111
125
|
async execute(): Promise<readonly TRow[]> {
|
|
112
126
|
const source = typeof this.rows === 'function' ? await this.rows() : this.rows;
|
|
113
127
|
let result = source.filter((row) => matchesFilters(row, this.filters));
|
|
114
|
-
const keys = this.
|
|
128
|
+
const keys = this.servedOrder();
|
|
115
129
|
if (keys.length > 0) {
|
|
116
130
|
result = [...result].sort((a, b) => compareRows(a, b, keys));
|
|
117
131
|
}
|
|
@@ -128,20 +142,14 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
|
|
|
128
142
|
}
|
|
129
143
|
|
|
130
144
|
/**
|
|
131
|
-
* The ordering
|
|
145
|
+
* The ordering this read is actually served in — `totalOrder`, so the SQL, the in-memory sort,
|
|
146
|
+
* `seekClause()` and the matcher all read one list.
|
|
132
147
|
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* so one of the pair comes back twice and the other never does. `execute()` had the same split,
|
|
136
|
-
* sorting by the declared keys and then filtering with the id-aware predicate. One order, read
|
|
137
|
-
* by the SQL, the in-memory sort and the predicate alike, is what closes it.
|
|
138
|
-
*
|
|
139
|
-
* Only a paged read pays for it: an unpaginated `from()` over rows that have no `id` must keep
|
|
140
|
-
* generating exactly the SQL it was asked for.
|
|
148
|
+
* Only a read that asked for it pays: `seek()` for a page, `total()` for a live window. A plain
|
|
149
|
+
* `from()` over rows that have no `id` must keep generating exactly the SQL it was asked for.
|
|
141
150
|
*/
|
|
142
|
-
private
|
|
143
|
-
|
|
144
|
-
return [...this.order, { column: 'id', direction: 'asc' }];
|
|
151
|
+
private servedOrder(): readonly OrderKey[] {
|
|
152
|
+
return this.totalized ? totalOrder(this.order) : this.order;
|
|
145
153
|
}
|
|
146
154
|
|
|
147
155
|
/**
|
|
@@ -152,25 +160,28 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
|
|
|
152
160
|
* a mixed listing repeated and skipped rows while `execute()` did the right
|
|
153
161
|
* thing. Same shape as `@ultimat3/entity`'s `seekSql`: one meaning, two drivers.
|
|
154
162
|
*/
|
|
155
|
-
private seekClause(after: SeekKey,
|
|
156
|
-
|
|
157
|
-
params.push(value);
|
|
158
|
-
return `$${params.length}`;
|
|
159
|
-
};
|
|
160
|
-
// The same `pageOrder()` the ORDER BY is built from, so the predicate can only ever describe
|
|
163
|
+
private seekClause(after: SeekKey, slot: Slot): string {
|
|
164
|
+
// The same `servedOrder()` the ORDER BY is built from, so the predicate can only ever describe
|
|
161
165
|
// the order the rows actually arrive in. The tiebreak is absent when the ordering already
|
|
162
166
|
// named `id`: a second `id` term compares the key to itself, can never be true, and is dead
|
|
163
167
|
// SQL an agent then has to reason about.
|
|
164
|
-
const keys = this.
|
|
168
|
+
const keys = this.servedOrder();
|
|
165
169
|
const values = this.ordersById ? [...after.key] : [...after.key, after.id];
|
|
166
|
-
const terms =
|
|
170
|
+
const terms: string[] = [];
|
|
171
|
+
for (const [index, key] of keys.entries()) {
|
|
172
|
+
const value = values[index];
|
|
173
|
+
// Under `nulls last` nothing sorts after a NULL, so this key's term is dead SQL — dropped
|
|
174
|
+
// rather than emitted, exactly as the doubled id tiebreak is. The remaining keys still
|
|
175
|
+
// carry the page: the equality prefix below reaches them as `"col" is null`.
|
|
176
|
+
if (isNull(value) && key.direction === 'asc') continue;
|
|
167
177
|
const equal = keys
|
|
168
178
|
.slice(0, index)
|
|
169
|
-
.map((earlier, position) =>
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
179
|
+
.map((earlier, position) => equalTerm(earlier.column, values[position], slot));
|
|
180
|
+
terms.push(`(${[...equal, afterTerm(key, value, slot)].join(' and ')})`);
|
|
181
|
+
}
|
|
182
|
+
// Every key null under an ascending order is the very end of the listing. Only reachable
|
|
183
|
+
// from a hand-built cursor — `seekKeyOf` refuses a row with no id — but `()` is a syntax error.
|
|
184
|
+
return terms.length === 0 ? NEVER : `(${terms.join(' or ')})`;
|
|
174
185
|
}
|
|
175
186
|
|
|
176
187
|
private derive(patch: Partial<BuilderState>): Builder<TRow> {
|
|
@@ -182,18 +193,95 @@ export class Builder<TRow extends object> implements SqlSource<TRow> {
|
|
|
182
193
|
patch.rowLimit === undefined ? this.rowLimit : patch.rowLimit,
|
|
183
194
|
patch.after === undefined ? this.after : patch.after,
|
|
184
195
|
patch.unsupported ?? this.unsupported,
|
|
185
|
-
patch.
|
|
196
|
+
patch.totalized ?? this.totalized,
|
|
186
197
|
);
|
|
187
198
|
}
|
|
188
199
|
}
|
|
189
200
|
|
|
201
|
+
const slotter =
|
|
202
|
+
(params: unknown[]): Slot =>
|
|
203
|
+
(value) => {
|
|
204
|
+
params.push(value);
|
|
205
|
+
return `$${params.length}`;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* One filter, in SQL that means what `matchesFilter` means.
|
|
210
|
+
*
|
|
211
|
+
* `= $n` with a NULL parameter is unknown in Postgres and unknown is never true, so
|
|
212
|
+
* `where({ deletedAt: null })` matched every live row in memory and no row at all in the
|
|
213
|
+
* database. `is null` / `is distinct from` is the pair `@ultimat3/entity`'s `predicateSql`
|
|
214
|
+
* already emits — one meaning, two sources. An ordering operator needs no case: `"col" > $n`
|
|
215
|
+
* against a NULL matches nothing, which is what `ordered()` now answers too.
|
|
216
|
+
*/
|
|
217
|
+
function filterClause(filter: Filter, slot: Slot): string {
|
|
218
|
+
const column = `"${filter.column}"`;
|
|
219
|
+
if (filter.op === 'in') {
|
|
220
|
+
// `in` reads a list or nothing. A non-array operand matches no row in memory, so the SQL says
|
|
221
|
+
// the same constant — the fallback below would emit `"col" in $n`, which is a syntax error a
|
|
222
|
+
// driver reports instead of the empty result the two sources agree on.
|
|
223
|
+
if (!Array.isArray(filter.value)) return NEVER;
|
|
224
|
+
const present = filter.value.filter((item) => !isNull(item));
|
|
225
|
+
const list =
|
|
226
|
+
present.length === 0
|
|
227
|
+
? null
|
|
228
|
+
: `${column} in (${present.map((item) => slot(item)).join(', ')})`;
|
|
229
|
+
const nulls = present.length === filter.value.length ? null : `${column} is null`;
|
|
230
|
+
if (list === null) return nulls ?? NEVER;
|
|
231
|
+
return nulls === null ? list : `(${list} or ${nulls})`;
|
|
232
|
+
}
|
|
233
|
+
if (filter.op === '=') return equalTerm(filter.column, filter.value, slot);
|
|
234
|
+
if (filter.op === '!=') {
|
|
235
|
+
return isNull(filter.value)
|
|
236
|
+
? `${column} is not null`
|
|
237
|
+
: `${column} is distinct from ${slot(filter.value)}`;
|
|
238
|
+
}
|
|
239
|
+
return `${column} ${filter.op} ${slot(filter.value)}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* NULL's place in the ordering, written down rather than inherited. Postgres already defaults
|
|
244
|
+
* to `nulls last` under `asc` and `nulls first` under `desc` — that is the rule `compareValues`
|
|
245
|
+
* implements, so the in-memory sort, the live matcher and `seekClause` below can only agree
|
|
246
|
+
* with it. Saying it out loud is what keeps a driver whose default differs from re-opening the
|
|
247
|
+
* divergence, and it is what an agent reads when a nullable sort key surprises it.
|
|
248
|
+
*/
|
|
249
|
+
function orderTerm(key: OrderKey): string {
|
|
250
|
+
const nulls = key.direction === 'desc' ? 'nulls first' : 'nulls last';
|
|
251
|
+
return `"${key.column}" ${key.direction} ${nulls}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** `= $n` never matches a NULL — the same defect as a filter, one page later. */
|
|
255
|
+
function equalTerm(column: string, value: unknown, slot: Slot): string {
|
|
256
|
+
return isNull(value) ? `"${column}" is null` : `"${column}" = ${slot(value)}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Strictly past this key's value, under the ordering `orderTerm` writes.
|
|
261
|
+
*
|
|
262
|
+
* `desc` is `nulls first`: every non-null row follows a NULL cursor, and no NULL row follows a
|
|
263
|
+
* value — which `"col" < $n` already excludes. `asc` is `nulls last`: the NULLs follow every
|
|
264
|
+
* value, so a value cursor has to reach them explicitly or page two ends at the first NULL.
|
|
265
|
+
*/
|
|
266
|
+
function afterTerm(key: OrderKey, value: unknown, slot: Slot): string {
|
|
267
|
+
const column = `"${key.column}"`;
|
|
268
|
+
if (key.direction === 'desc') {
|
|
269
|
+
return isNull(value) ? `${column} is not null` : `${column} < ${slot(value)}`;
|
|
270
|
+
}
|
|
271
|
+
const after = `${column} > ${slot(value)}`;
|
|
272
|
+
// `id` is the tiebreak that makes the order total and `seekKeyOf` refuses a row without one,
|
|
273
|
+
// so `"id" is null` is unsatisfiable: reaching for it would be dead SQL on every paged read,
|
|
274
|
+
// and an `or` the planner has to defeat before it can seek the index.
|
|
275
|
+
return key.column === 'id' ? after : `(${after} or ${column} is null)`;
|
|
276
|
+
}
|
|
277
|
+
|
|
190
278
|
interface BuilderState {
|
|
191
279
|
readonly filters: readonly Filter[];
|
|
192
280
|
readonly order: readonly OrderKey[];
|
|
193
281
|
readonly rowLimit: number | null;
|
|
194
282
|
readonly after: SeekKey | null;
|
|
195
283
|
readonly unsupported: readonly string[];
|
|
196
|
-
readonly
|
|
284
|
+
readonly totalized: boolean;
|
|
197
285
|
}
|
|
198
286
|
|
|
199
287
|
/**
|
package/src/sql.ts
CHANGED
|
@@ -27,7 +27,7 @@ export async function explain<TInput extends StandardSchemaV1, TRow extends obje
|
|
|
27
27
|
ctx?: Ctx,
|
|
28
28
|
): Promise<ExplainResult> {
|
|
29
29
|
const source = await sourceFor(target, input, {
|
|
30
|
-
|
|
30
|
+
unenforced: 'explain returns the statement and never a row, and its surfaces are admin-gated',
|
|
31
31
|
...(ctx === undefined ? {} : { ctx }),
|
|
32
32
|
});
|
|
33
33
|
const text = source.toSQL();
|
|
@@ -65,7 +65,7 @@ export async function describeSql(
|
|
|
65
65
|
continue;
|
|
66
66
|
}
|
|
67
67
|
const source = await sourceFor(target, sample, {
|
|
68
|
-
|
|
68
|
+
unenforced: 'the dashboard listing renders SQL text for a sample input and reads no rows',
|
|
69
69
|
...(ctx === undefined ? {} : { ctx }),
|
|
70
70
|
});
|
|
71
71
|
entries.push({ query: name, live: target.isLive, sql: source.toSQL().sql });
|
package/src/stable.ts
CHANGED
|
@@ -13,8 +13,15 @@ export function stableStringify(value: unknown): string {
|
|
|
13
13
|
switch (typeof value) {
|
|
14
14
|
case 'string':
|
|
15
15
|
return JSON.stringify(value);
|
|
16
|
+
// A bare token, never `'null'` and never a quoted string: this output is only ever hashed, so
|
|
17
|
+
// an unquoted word cannot collide with the `string` branch (which always quotes) while
|
|
18
|
+
// `'null'` collided with JSON `null` itself — `{ n: NaN }`, `{ n: Infinity }` and `{ n: null }`
|
|
19
|
+
// fingerprinted identically and shared one cache entry and one cursor scope. `-0` is spelled
|
|
20
|
+
// out for the same reason: `String(-0)` is `"0"`, so `-0` and `0` were one key too.
|
|
16
21
|
case 'number':
|
|
17
|
-
|
|
22
|
+
if (Number.isNaN(value)) return 'NaN';
|
|
23
|
+
if (!Number.isFinite(value)) return value > 0 ? 'Infinity' : '-Infinity';
|
|
24
|
+
return Object.is(value, -0) ? '-0' : String(value);
|
|
18
25
|
case 'boolean':
|
|
19
26
|
return String(value);
|
|
20
27
|
case 'bigint':
|
|
@@ -35,18 +42,22 @@ export function stableStringify(value: unknown): string {
|
|
|
35
42
|
return `{${entries.join(',')}}`;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
/**
|
|
46
|
+
* SHA-256, first 16 hex characters — the same primitive and the same width `@ultimat3/realtime`'s
|
|
47
|
+
* `stableDigest` and `@ultimat3/entity`'s `planScope` already chose, and for the same reason.
|
|
48
|
+
*
|
|
49
|
+
* A fingerprint is a SHARING key, not a checksum: it decides which read-cache entry two callers
|
|
50
|
+
* are served from and which scope a cursor is bound to, over input a client chooses. FNV-1a/32 —
|
|
51
|
+
* what this was — is 4x10^9 values, brute-forceable offline in seconds, so an attacker could mint
|
|
52
|
+
* an input that lands on another read's entry or another page's scope. It identifies, and here
|
|
53
|
+
* identifying IS the boundary.
|
|
54
|
+
*
|
|
55
|
+
* The canonical form above is unchanged, so the only thing that moved is the hash: a cursor issued
|
|
56
|
+
* before this fails its scope check as `X_CURSOR_INVALID` — cleanly, with "request the first page
|
|
57
|
+
* again" as its fix — and a warm read cache is cold once.
|
|
58
|
+
*/
|
|
48
59
|
export function fingerprint(value: unknown): string {
|
|
49
|
-
return
|
|
60
|
+
return new Bun.CryptoHasher('sha256').update(stableStringify(value)).digest('hex').slice(0, 16);
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
/** Column read that works for interfaces without an index signature. */
|
package/src/tags.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cache tags stay opaque here — they belong to @ultimat3/cache. This package only
|
|
3
|
-
* needs the wire string per tag so a read's key can be found by the invalidation
|
|
4
|
-
* graph an action's `invalidates` drives.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { CacheTag } from '@ultimat3/cache';
|
|
8
|
-
import { serializeTag } from '@ultimat3/cache';
|
|
9
|
-
|
|
10
|
-
export function tagKey(value: CacheTag): string {
|
|
11
|
-
return serializeTag(value);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** Sorted + de-duplicated: descriptor output must not depend on declaration order. */
|
|
15
|
-
export function tagKeys(tags: readonly CacheTag[]): readonly string[] {
|
|
16
|
-
return [...new Set(tags.map(tagKey))].sort();
|
|
17
|
-
}
|