@ultimat3/query 1.1.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/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()`. Only a paged read pays for the id tiebreak — see `pageOrder()`. */
46
- private readonly paged: boolean = false,
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, paged: true });
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 clauses = this.filters.map((filter) => {
90
- if (filter.op === 'in' && Array.isArray(filter.value)) {
91
- const slots = filter.value.map((item) => {
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.pageOrder();
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.pageOrder();
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 a page is actually served in: the declared keys, then `id` to make it total.
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
- * Without the tiebreak the database is free to return two rows with the same sort value in
134
- * either order, while `seekClause()` decides the next page as if they had been ordered by id
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 pageOrder(): readonly OrderKey[] {
143
- if (!this.paged || this.ordersById) return this.order;
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, params: unknown[]): string {
156
- const slot = (value: unknown): string => {
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.pageOrder();
168
+ const keys = this.servedOrder();
165
169
  const values = this.ordersById ? [...after.key] : [...after.key, after.id];
166
- const terms = keys.map((key, index) => {
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) => `"${earlier.column}" = ${slot(values[position])}`);
170
- const compare = `"${key.column}" ${key.direction === 'desc' ? '<' : '>'} ${slot(values[index])}`;
171
- return `(${[...equal, compare].join(' and ')})`;
172
- });
173
- return `(${terms.join(' or ')})`;
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.paged ?? this.paged,
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 paged: boolean;
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
- enforce: false,
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
- enforce: false,
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
- return Number.isFinite(value) ? String(value) : 'null';
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
- /** FNV-1a/32 as hex. Identity of a query shape, never a security boundary. */
39
- export function fnv1a(input: string): string {
40
- let hash = 0x811c9dc5;
41
- for (let i = 0; i < input.length; i += 1) {
42
- hash ^= input.charCodeAt(i);
43
- hash = Math.imul(hash, 0x01000193) >>> 0;
44
- }
45
- return hash.toString(16).padStart(8, '0');
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 fnv1a(stableStringify(value));
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
- }