@ultimat3/query 1.2.0 → 3.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/read.ts CHANGED
@@ -8,7 +8,9 @@
8
8
  import type { Ctx } from '@ultimat3/core';
9
9
  import {
10
10
  anonymousActor,
11
+ assert,
11
12
  createContext,
13
+ logger,
12
14
  runWithContext,
13
15
  tryUseContext,
14
16
  useContext,
@@ -17,7 +19,14 @@ import {
17
19
  } from '@ultimat3/core';
18
20
  import type { StandardSchemaV1 } from '@ultimat3/schema';
19
21
  import { formatPath, validateAsync } from '@ultimat3/schema';
20
- import { cacheKeyFor, readThrough } from './cache';
22
+ import {
23
+ cacheKeyFor,
24
+ DEFAULT_READ_CACHE_TTL_MS,
25
+ readAuthority,
26
+ readFresh,
27
+ readOnce,
28
+ readThrough,
29
+ } from './cache';
21
30
  import { QueryForeignError, QueryInputInvalidError, QueryUnregisteredError } from './errors';
22
31
  import { actorOf, guard } from './policy-gate';
23
32
  import type { AnyQuery, AnyQueryDef, Query, QueryOptions, SourceOptions } from './query';
@@ -57,8 +66,23 @@ export function queryName(target: AnyQuery): string {
57
66
  export function runQuery<TInput extends StandardSchemaV1, TRow extends object>(
58
67
  target: Query<TInput, TRow>,
59
68
  raw: unknown,
69
+ options?: QueryOptions,
70
+ ): Promise<readonly TRow[]>;
71
+ /**
72
+ * The same read from a schema-erased handle. The route projection maps `listQueries()`,
73
+ * which knows only `AnyQuery` — an overload rather than a second function, so what is
74
+ * gone is the row TYPE and never the parse, the policy or the memo.
75
+ */
76
+ export function runQuery(
77
+ target: AnyQuery,
78
+ raw: unknown,
79
+ options?: QueryOptions,
80
+ ): Promise<readonly object[]>;
81
+ export function runQuery(
82
+ target: AnyQuery,
83
+ raw: unknown,
60
84
  options: QueryOptions = {},
61
- ): Promise<readonly TRow[]> {
85
+ ): Promise<readonly object[]> {
62
86
  return asActor(options, (ctx) => readRows(target, raw, ctx, options));
63
87
  }
64
88
 
@@ -81,7 +105,14 @@ export function sourceFor(
81
105
  * core models it as the anonymous actor. Omitting `actor` touches no context at all.
82
106
  */
83
107
  function asActor<T>(options: QueryOptions, run: (ctx: Ctx) => Promise<T>): Promise<T> {
84
- if (options.actor === undefined) return run(options.ctx ?? useContext());
108
+ if (options.actor === undefined) {
109
+ // INSTALLED, never only handed over — the same fix `@ultimat3/action`'s `invoke` carries, for
110
+ // the same reason: `@ultimat3/entity`'s tenant guard derives from `tryUseContext()`, so a read
111
+ // built under an explicit `ctx` evaluated its policy against that actor and its row tenancy
112
+ // against nobody. Absent a `ctx` this reinstalls the ambient one, which changes nothing.
113
+ const ctx = options.ctx ?? useContext();
114
+ return runWithContext(ctx, () => run(ctx));
115
+ }
85
116
  const patch = { actor: options.actor ?? anonymousActor() };
86
117
  const inChild = (): Promise<T> => run(useContext());
87
118
  const base = options.ctx ?? tryUseContext();
@@ -90,23 +121,72 @@ function asActor<T>(options: QueryOptions, run: (ctx: Ctx) => Promise<T>): Promi
90
121
  : runWithContext(base, () => withChildContext(patch, inChild));
91
122
  }
92
123
 
93
- async function readRows<TInput extends StandardSchemaV1, TRow extends object>(
94
- target: Query<TInput, TRow>,
124
+ /**
125
+ * The span covers the WHOLE read, not just `execute()`. Wrapping the execution alone left the
126
+ * input parse, the policy evaluation and `sql()`'s own construction outside every span, so a read
127
+ * whose cost was in building the source reported milliseconds while its parent HTTP span reported
128
+ * seconds — a gap with no name, which reads as framework overhead and gets hand-instrumented.
129
+ *
130
+ * Attributes are bounded: surface, actor KIND, booleans, and a row count. Never the input, never
131
+ * an actor id — a read is keyed per tenant and per cursor, and either would be unbounded.
132
+ */
133
+ function readRows(
134
+ target: AnyQuery,
135
+ raw: unknown,
136
+ ctx: Ctx,
137
+ options: QueryOptions,
138
+ ): Promise<readonly object[]> {
139
+ const name = queryName(target);
140
+ return withSpan(`query.${name}`, async (span) => {
141
+ span.setAttributes({
142
+ 'ultimate.primitive': 'query',
143
+ 'ultimate.query': name,
144
+ 'ultimate.surface': options.surface ?? 'server',
145
+ 'ultimate.actor.kind': ctx.actor.kind,
146
+ 'ultimate.live': target.isLive,
147
+ 'ultimate.fresh': options.fresh === true,
148
+ // Whether this read goes through the tier at all. Every read is memoized; only a `cache:`
149
+ // read is filled — and which of the two a slow read took is the first thing to ask.
150
+ 'ultimate.cached': defOf(target).cache !== undefined,
151
+ });
152
+ const rows = await readRowsIn(target, raw, ctx, options);
153
+ span.setAttribute('ultimate.rows', rows.length);
154
+ return rows;
155
+ });
156
+ }
157
+
158
+ async function readRowsIn(
159
+ target: AnyQuery,
95
160
  raw: unknown,
96
161
  ctx: Ctx,
97
162
  options: QueryOptions,
98
- ): Promise<readonly TRow[]> {
163
+ ): Promise<readonly object[]> {
99
164
  const def = defOf(target);
100
165
  const name = queryName(target);
101
166
  const source = await buildSource(target, raw, ctx, options);
102
- const read = (): Promise<readonly object[]> => withSpan(`query.${name}`, () => source.execute());
103
- // The source came from this query's own `sql()`, so its rows are TRow.
104
- if (options.fresh === true || def.cache === undefined) {
105
- return (await read()) as readonly TRow[];
106
- }
107
- const key = cacheKeyFor(name, raw, def.cache.tags);
108
- const rows = await readThrough(ctx, key, def.cache.ttlMs ?? null, read);
109
- return rows as readonly TRow[];
167
+ const read = (): Promise<readonly object[]> => source.execute();
168
+ // The source came from this query's own `sql()`, so its rows are TRow throughout —
169
+ // which is what the typed overload above states, and this body never has to assert.
170
+ const tags = def.cache?.tags ?? [];
171
+ // The authority is part of the key on EVERY read, cached or not, because there is one key
172
+ // function and a second one beside it is what this package's own rule forbids. It costs the memo
173
+ // nothing a memo is already per-ctx and it is the whole of what the tier was missing: keyed
174
+ // on the name, the input and the tags alone, the process-wide tier handed one org's rows to the
175
+ // next org that asked for them. `actor` when the declaration named no scope, always.
176
+ const key = cacheKeyFor(name, raw, tags, readAuthority(ctx.actor, def.cache?.scope ?? 'actor'));
177
+ // `fresh` is the caller saying no cache may answer this one — the memo included, a memo being
178
+ // a cache whose lifetime is the request. It still *publishes* into the memo: this read is the
179
+ // newest answer the request has, so the next plain read of the key joins it rather than the
180
+ // entry a write earlier in the request already moved past.
181
+ if (options.fresh === true) return await readFresh(ctx, key, read);
182
+ // `cache:` buys the tier, never the memo: a read asked twice in one request is one execution
183
+ // whether or not its author opted into caching.
184
+ // A declared `cache:` with no `ttlMs` gets one anyway. Tags are the primary eviction, but a
185
+ // read whose tags never fire would otherwise hold one entry per distinct input for the life of
186
+ // the process — a paginated feed over 10k tenants is 10k immortal entries.
187
+ return def.cache === undefined
188
+ ? await readOnce(ctx, key, read)
189
+ : await readThrough(ctx, key, def.cache.ttlMs ?? DEFAULT_READ_CACHE_TTL_MS, read, tags);
110
190
  }
111
191
 
112
192
  async function buildSource(
@@ -118,14 +198,34 @@ async function buildSource(
118
198
  const def = defOf(target);
119
199
  const name = queryName(target);
120
200
  const input = await validate(def.input, raw, name);
121
- if (options.enforce !== false) {
201
+ const unenforced = options.unenforced;
202
+ if (unenforced === undefined) {
122
203
  guard(
123
204
  def.policy,
124
205
  { actor: actorOf(ctx), input, ctx, query: name },
125
206
  options.surface ?? 'server',
126
207
  );
208
+ } else {
209
+ // The reason IS the mechanism, exactly as it is for `crossTenant`: a blank one leaves the
210
+ // escape with no argument, and the next reader cannot tell a considered skip from a forgotten
211
+ // policy. Refused before the source is built, so nothing is read on a blank justification.
212
+ assert(
213
+ unenforced.trim() !== '',
214
+ `query "${name}" was built with a blank unenforced reason, so the policy it skips carries no argument`,
215
+ `pass why this read needs no subject: sourceFor(target, input, { unenforced: 'explain returns no rows' })`,
216
+ );
217
+ // The audit half. `debug`, because the two shipped callers are dev tooling and a sync node's
218
+ // once-per-query-id window — never a per-request path — and core's logger costs one level
219
+ // comparison when nothing is listening.
220
+ logger.debug('query.policy.unenforced', { query: name, reason: unenforced });
127
221
  }
128
- return def.sql(input, ctx);
222
+ const source = def.sql(input, ctx);
223
+ // A live window is served in the order its patches are placed in. The matcher breaks a tie on
224
+ // the declared keys with `id` (`totalOrder`) and so does the keyset re-read a reconnect resumes
225
+ // with, so an initial window served in the declared keys alone puts tied rows where neither of
226
+ // them would: the client renders one order and the next read answers another. A source that
227
+ // cannot say (`total` absent) already serves one order it can be resumed in.
228
+ return options.surface === 'live' && source.total !== undefined ? source.total() : source;
129
229
  }
130
230
 
131
231
  async function validate(schema: StandardSchemaV1, raw: unknown, name: string): Promise<unknown> {
package/src/shape.ts CHANGED
@@ -57,6 +57,32 @@ export function seekKeyOf(
57
57
  };
58
58
  }
59
59
 
60
+ /** Appended by `totalOrder`. Ascending whatever the declared keys do — so is the seek predicate. */
61
+ const ID_TIEBREAK: OrderKey = { column: 'id', direction: 'asc' };
62
+
63
+ /**
64
+ * The ordering a read is actually served in: the declared keys, then `id` to make it total.
65
+ *
66
+ * Two rows sharing every declared sort value have no order at all without it — the database
67
+ * returns them either way round while `isAfterKey` decides the next page as though `id` had
68
+ * settled it. `Builder.seek()` compiles this list, `paginate()` sorts by it, and the matcher
69
+ * places a row by it: a row inserted at the end of a tie group is a row the next re-read finds
70
+ * somewhere else. An ordering that already names `id` is total, and adding a second `id` term
71
+ * would compare the key to itself.
72
+ */
73
+ export function totalOrder(orderBy: readonly OrderKey[]): readonly OrderKey[] {
74
+ return orderBy.some((key) => key.column === 'id') ? orderBy : [...orderBy, ID_TIEBREAK];
75
+ }
76
+
77
+ /**
78
+ * SQL NULL, as a row spells it. A column the row simply omits reads `undefined` here and NULL
79
+ * in Postgres, so both are the same absence — otherwise a fixture row without `deletedAt` and
80
+ * the same row round-tripped through a driver answer `where({ deletedAt: null })` differently.
81
+ */
82
+ export function isNull(value: unknown): boolean {
83
+ return value === null || value === undefined;
84
+ }
85
+
60
86
  export function matchesFilters(row: object, filters: readonly Filter[]): boolean {
61
87
  return filters.every((filter) => matchesFilter(row, filter));
62
88
  }
@@ -71,33 +97,104 @@ export function matchesFilter(row: object, filter: Filter): boolean {
71
97
  case 'in':
72
98
  return Array.isArray(filter.value) && filter.value.some((item) => same(actual, item));
73
99
  case '>':
74
- return compareValues(actual, filter.value) > 0;
75
100
  case '>=':
76
- return compareValues(actual, filter.value) >= 0;
77
101
  case '<':
78
- return compareValues(actual, filter.value) < 0;
79
102
  case '<=':
80
- return compareValues(actual, filter.value) <= 0;
103
+ return ordered(filter.op, actual, filter.value);
81
104
  default:
82
105
  return false;
83
106
  }
84
107
  }
85
108
 
86
- /** Dates compare by instant, everything else by value. No coercion across types. */
109
+ /**
110
+ * `col > NULL` is unknown in SQL and unknown is not a match, so a NULL on either side of an
111
+ * ordering operator matches nothing here either. Only `=`, `!=` and `in` read NULL as a value —
112
+ * and those are exactly the three `Builder.toSQL()` compiles to `is null` / `is distinct from`.
113
+ */
114
+ function ordered(op: '>' | '>=' | '<' | '<=', actual: unknown, value: unknown): boolean {
115
+ if (isNull(actual) || isNull(value)) return false;
116
+ const result = compareValues(actual, value);
117
+ switch (op) {
118
+ case '>':
119
+ return result > 0;
120
+ case '>=':
121
+ return result >= 0;
122
+ case '<':
123
+ return result < 0;
124
+ case '<=':
125
+ return result <= 0;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Dates compare by instant, everything else by value. No coercion across types.
131
+ *
132
+ * NULL is greater than every value and equal to itself — Postgres' own sort rule, which is what
133
+ * lets `Builder.toSQL()` write it down as `asc nulls last` / `desc nulls first` and mean this
134
+ * function. Sorting only: a comparison *filter* against NULL matches nothing (`ordered`). Before
135
+ * this, `null` sorted as the string `"null"`, so it landed between `"m"` and `"o"` in memory and
136
+ * at the end in the database — the same page read two ways.
137
+ */
87
138
  export function compareValues(a: unknown, b: unknown): number {
139
+ if (isNull(a) || isNull(b)) return isNull(a) ? (isNull(b) ? 0 : 1) : -1;
88
140
  const left = normalize(a);
89
141
  const right = normalize(b);
90
- if (typeof left === 'number' && typeof right === 'number') return left - right;
142
+ if (isNumeric(left) && isNumeric(right)) return compareNumeric(left, right);
91
143
  const l = String(left);
92
144
  const r = String(right);
93
145
  return l < r ? -1 : l > r ? 1 : 0;
94
146
  }
95
147
 
148
+ function isNumeric(value: unknown): value is number | bigint {
149
+ return typeof value === 'number' || typeof value === 'bigint';
150
+ }
151
+
152
+ /**
153
+ * Numbers and bigints, in one order, because **Postgres orders them in one order**.
154
+ *
155
+ * `bigint` is a first-class `ColumnKind` — the physical type of every `<p>_minor` column — and
156
+ * `@ultimat3/entity`'s `count-by.ts` lists it as groupable, so these values do reach the
157
+ * comparator. The old numeric fast path was `typeof left === 'number' && typeof right ===
158
+ * 'number'` alone, so a bigint fell through to `String(left) < String(right)`:
159
+ * `compareValues(9n, 10n)` answered `1` and a sort came out `["10", "100", "9"]`, which means the
160
+ * in-memory source, the live matcher and the seek fallback all disagreed with the database on any
161
+ * bigint-ordered read — including page two of one.
162
+ *
163
+ * A bigint pair never subtracts: the difference is exact but the return type is a `number`. A
164
+ * mixed pair goes through `BigInt` when the number is whole, so a value past 2^53 keeps its exact
165
+ * place; a fractional number cannot equal a bigint, so comparing it as a float is enough to place
166
+ * it. `Number.isInteger` is false for `NaN` and `±Infinity`, which is what keeps them out of the
167
+ * `BigInt()` call that would throw on them.
168
+ */
169
+ function compareNumeric(left: number | bigint, right: number | bigint): number {
170
+ if (typeof left === 'number' && typeof right === 'number') return left - right;
171
+ if (typeof left === 'bigint' && typeof right === 'bigint') return sign(left, right);
172
+ // Widened rather than negated: `-sign(a, b)` answers `-0` for a tie, and `-0` is a different
173
+ // value from `0` to `Object.is` and to a caller writing `=== 0`.
174
+ return typeof left === 'bigint'
175
+ ? mixed(left, right as number)
176
+ : -mixed(right as bigint, left) || 0;
177
+ }
178
+
179
+ /** A bigint against a number, in that order. Whole numbers go through `BigInt` so a value past
180
+ * 2^53 keeps its exact place; a fractional number can never equal a bigint, so comparing it as a
181
+ * float is enough to place it. `Number.isInteger` is false for `NaN` and `±Infinity`, which is
182
+ * what keeps them out of the `BigInt()` call that would throw on them. */
183
+ function mixed(big: bigint, other: number): number {
184
+ return Number.isInteger(other) ? sign(big, BigInt(other)) : sign(Number(big), other);
185
+ }
186
+
187
+ function sign<T extends number | bigint>(left: T, right: T): number {
188
+ return left < right ? -1 : left > right ? 1 : 0;
189
+ }
190
+
191
+ /** A `Date` compares by instant, so two of them order by time and never by their ISO text. */
96
192
  function normalize(value: unknown): unknown {
97
193
  return value instanceof Date ? value.getTime() : value;
98
194
  }
99
195
 
100
196
  function same(a: unknown, b: unknown): boolean {
197
+ if (isNull(a) || isNull(b)) return isNull(a) && isNull(b);
101
198
  return compareValues(a, b) === 0 && typeof normalize(a) === typeof normalize(b);
102
199
  }
103
200
 
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 });