@ultimat3/entity 11.2.0 → 12.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.
@@ -0,0 +1,232 @@
1
+ // Single responsibility: what an aggregate MEANS — which column kinds each function may be applied
2
+ // to, what its answer is shaped like, and the exact arithmetic. Both drivers read it from here, so
3
+ // a `sum` against memory means what a `sum` against Postgres means; a rule added to one driver
4
+ // alone is the drift the two-driver split exists to prevent.
5
+ //
6
+ // Nothing here is a float. `sum` and `avg` answer decimal TEXT, money answers `MoneyValue` — the
7
+ // same reason `bigint()` and `decimal()` hand back strings: a `number` loses digits past 2^53 and
8
+ // a binary float loses cents.
9
+
10
+ import { columnFor } from './column';
11
+ import type { EntityCore } from './entity';
12
+ import { EntityError } from './errors';
13
+ import type { AnyColumn, ColumnKind } from './types';
14
+
15
+ /** The four, closed. A fifth is a new member here and a new case in both drivers, never one. */
16
+ export type AggregateFn = 'sum' | 'avg' | 'min' | 'max';
17
+
18
+ /**
19
+ * Which kinds each function may be applied to. Closed sets rather than "whatever Postgres accepts",
20
+ * because the bar is what BOTH drivers can answer identically.
21
+ *
22
+ * `text` and `char` are deliberately absent from `min`/`max` even though Postgres has them: text
23
+ * ordering there is the database's COLLATION and here it is JS's UTF-16 code-unit order, and the
24
+ * two disagree on ordinary data (`'a' < 'B'` under `en_US`, `'B' < 'a'` by code unit). A comparison
25
+ * this package cannot make agree is refused rather than answered twice differently — the same
26
+ * decision `memory-match.ts` records for decimal text, in the other direction.
27
+ *
28
+ * `boolean`, `uuid`, `jsonb`, `array` and `bytea` are absent everywhere: none of them has an
29
+ * ordering or a sum a caller would mean.
30
+ */
31
+ const NUMERIC: readonly ColumnKind[] = ['integer', 'bigint', 'numeric'];
32
+ const ORDERED: readonly ColumnKind[] = ['integer', 'bigint', 'numeric', 'timestamptz', 'date'];
33
+
34
+ const ALLOWED = new Map<AggregateFn, ReadonlySet<ColumnKind>>([
35
+ ['sum', new Set<ColumnKind>([...NUMERIC, 'money'])],
36
+ ['avg', new Set<ColumnKind>(NUMERIC)],
37
+ ['min', new Set<ColumnKind>([...ORDERED, 'money'])],
38
+ ['max', new Set<ColumnKind>([...ORDERED, 'money'])],
39
+ ]);
40
+
41
+ export const aggregatable = (fn: AggregateFn, kind: ColumnKind): boolean =>
42
+ ALLOWED.get(fn)?.has(kind) === true;
43
+
44
+ /**
45
+ * `avg` over money is refused rather than rounded. The average of 1, 1 and 2 minor units is 4/3 of
46
+ * a unit, and every representable answer is a rounding — which is the defect `MoneyValue.scale`
47
+ * exists to prevent, so inventing one at the aggregate would reopen it one layer up. The caller
48
+ * decides the rounding, out of two exact numbers.
49
+ */
50
+ export const notAggregatable = (
51
+ entityName: string,
52
+ fn: AggregateFn,
53
+ property: string,
54
+ kind: ColumnKind,
55
+ candidates: readonly string[],
56
+ ): EntityError =>
57
+ new EntityError({
58
+ code: 'X_AGGREGATE_UNSUPPORTED',
59
+ cause:
60
+ fn === 'avg' && kind === 'money'
61
+ ? `${entityName}.avg('${property}') — the mean of an integer number of minor units is not one, and every answer would be a silent rounding`
62
+ : `${entityName}.${fn}('${property}') — a ${kind} column has no ${fn} both drivers can answer the same way`,
63
+ fix:
64
+ fn === 'avg' && kind === 'money'
65
+ ? `${entityName}.sum('${property}') and ${entityName}.count() — divide at the call site, where the rounding is a decision somebody made`
66
+ : candidates.length === 0
67
+ ? `x entities describe ${entityName} --json # this entity declares no column ${fn} can be applied to`
68
+ : `${entityName}.${fn}('${candidates[0]}') # ${fn} takes one of: ${candidates.join(', ')}`,
69
+ });
70
+
71
+ /**
72
+ * Money crossing currencies has no sum, no minimum and no maximum: 100 JPY and 100 EUR are not
73
+ * comparable and adding them answers a number in no currency at all. Both drivers count the
74
+ * distinct currencies of the rows they are about to aggregate and refuse past one, rather than
75
+ * silently answering in whichever currency happened to come first.
76
+ */
77
+ export const mixedCurrency = (
78
+ entityName: string,
79
+ fn: AggregateFn,
80
+ property: string,
81
+ currencies: readonly string[],
82
+ ): EntityError =>
83
+ new EntityError({
84
+ code: 'X_AGGREGATE_MIXED_CURRENCY',
85
+ cause: `${entityName}.${fn}('${property}') covers ${currencies.length} currencies (${[...currencies].sort().join(', ')}) — they have no common unit`,
86
+ fix: `${entityName}.andWhere('${property}.currency', 'eq', '${[...currencies].sort()[0]}').${fn}('${property}') # one currency per call, or countBy('${property}.currency') first`,
87
+ });
88
+
89
+ /** Digits only, optionally signed, optionally with a fraction. What `decimal()` hands back. */
90
+ const DECIMAL_TEXT = /^-?\d+(\.\d+)?$/;
91
+
92
+ /** The scale `avg` answers at, in both drivers. Fixed, so the two cannot round to different places. */
93
+ export const AVG_SCALE = 6;
94
+
95
+ interface Decimal {
96
+ readonly units: bigint;
97
+ readonly scale: number;
98
+ }
99
+
100
+ const parseDecimal = (text: string): Decimal | undefined => {
101
+ if (!DECIMAL_TEXT.test(text)) return undefined;
102
+ const [whole = '0', fraction = ''] = text.split('.');
103
+ return { units: BigInt(`${whole}${fraction}`), scale: fraction.length };
104
+ };
105
+
106
+ const rescale = (value: Decimal, scale: number): bigint =>
107
+ value.units * 10n ** BigInt(scale - value.scale);
108
+
109
+ const render = (units: bigint, scale: number): string => {
110
+ if (scale === 0) return units.toString();
111
+ const negative = units < 0n;
112
+ const digits = (negative ? -units : units).toString().padStart(scale + 1, '0');
113
+ const whole = digits.slice(0, digits.length - scale);
114
+ return `${negative ? '-' : ''}${whole}.${digits.slice(digits.length - scale)}`;
115
+ };
116
+
117
+ /**
118
+ * The exact sum of decimal text, at the widest scale any term carries — which is what Postgres'
119
+ * `sum(numeric)` answers, and what no `Number()` can: `0.1 + 0.2` is `0.30000000000000004` in a
120
+ * binary float and `0.3` here, and an `int8` past 2^53 keeps every digit.
121
+ *
122
+ * `null` for an empty set, exactly as `sum` over no rows is NULL in SQL — never `0`, which would
123
+ * claim rows were counted.
124
+ */
125
+ export const sumDecimalText = (values: readonly string[]): string | null => {
126
+ if (values.length === 0) return null;
127
+ const parsed = values.map(parseDecimal);
128
+ if (parsed.some((value) => value === undefined)) return null;
129
+ const decimals = parsed as readonly Decimal[];
130
+ const scale = decimals.reduce((widest, value) => Math.max(widest, value.scale), 0);
131
+ return render(
132
+ decimals.reduce((total, value) => total + rescale(value, scale), 0n),
133
+ scale,
134
+ );
135
+ };
136
+
137
+ /**
138
+ * `sum / count`, rounded half away from zero at `AVG_SCALE` — which is what Postgres' `round()`
139
+ * does to a `numeric`, and the reason the statement asks for `round(avg(...), 6)` rather than the
140
+ * server's own default scale: two drivers rounding at different places answer two numbers.
141
+ */
142
+ export const averageDecimalText = (total: string | null, count: number): string | null => {
143
+ if (total === null || count === 0) return null;
144
+ const parsed = parseDecimal(total);
145
+ if (parsed === undefined) return null;
146
+ // The EXACT rational, rounded once. Dividing first and rounding after would truncate digits the
147
+ // rounding decision depends on — and rescaling to a fixed number of digits rather than to
148
+ // AVG_SCALE is what made this answer 11000.000000 where Postgres said 1.100000: `rescale` takes
149
+ // an absolute target scale, and the first draft handed it a relative one.
150
+ const digits = Math.max(parsed.scale, AVG_SCALE);
151
+ const numerator = rescale(parsed, digits);
152
+ const denominator = BigInt(count) * 10n ** BigInt(digits - AVG_SCALE);
153
+ const negative = numerator < 0n;
154
+ const magnitude = negative ? -numerator : numerator;
155
+ // `(2m + d) / 2d` is `floor(m/d + 1/2)` in integers — half AWAY FROM ZERO once the sign is put
156
+ // back, which is what Postgres' `round(numeric)` does. No float, no intermediate truncation.
157
+ const rounded = (2n * magnitude + denominator) / (2n * denominator);
158
+ return render(negative ? -rounded : rounded, AVG_SCALE);
159
+ };
160
+
161
+ /**
162
+ * The column an aggregate runs over, resolved and judged before either driver builds anything —
163
+ * so `sum('title')` is refused with the same words and the same code whichever driver is attached.
164
+ */
165
+ export const aggregateColumnOf = <Row>(
166
+ entity: EntityCore<Row>,
167
+ fn: AggregateFn,
168
+ property: string,
169
+ ): AnyColumn => {
170
+ const candidates = Object.entries(entity.$columns)
171
+ .filter(([, each]) => aggregatable(fn, each.$meta.kind))
172
+ .map(([name]) => name);
173
+ const column = columnFor(entity.$columns, property);
174
+ if (column === undefined) {
175
+ throw notAggregatable(entity.$name, fn, property, 'text', candidates);
176
+ }
177
+ if (!aggregatable(fn, column.$meta.kind)) {
178
+ throw notAggregatable(entity.$name, fn, property, column.$meta.kind, candidates);
179
+ }
180
+ return column;
181
+ };
182
+
183
+ /** One amount is one currency at one scale. Two of either have no common unit. */
184
+ export interface MoneyUnit {
185
+ readonly currency: string;
186
+ readonly scale: number | null;
187
+ }
188
+
189
+ export const assertOneUnit = <Row>(
190
+ entity: EntityCore<Row>,
191
+ fn: AggregateFn,
192
+ property: string,
193
+ units: readonly MoneyUnit[],
194
+ ): MoneyUnit | undefined => {
195
+ const seen = new Map<string, MoneyUnit>();
196
+ for (const unit of units) seen.set(`${unit.currency}/${unit.scale ?? ''}`, unit);
197
+ if (seen.size > 1) {
198
+ throw mixedCurrency(entity.$name, fn, property, [...seen.values()].map(unitLabel));
199
+ }
200
+ return [...seen.values()][0];
201
+ };
202
+
203
+ /**
204
+ * What the refusal names. The scale rides along because it is half of what makes two amounts
205
+ * incomparable: `{ minor: 5, currency: 'USD' }` is five cents and `{ minor: 5, currency: 'USD',
206
+ * scale: 6 }` is five millionths of a dollar, and adding them is a 10,000x error with no symptom.
207
+ */
208
+ const unitLabel = (unit: MoneyUnit): string =>
209
+ unit.scale === null ? unit.currency : `${unit.currency}@${unit.scale}`;
210
+
211
+ /**
212
+ * The minor unit an aggregate answers with, narrowed exactly where every other reader of that
213
+ * column narrows it. The column is `bigint` and `MoneyValue.minor` is a `number`, so a total past
214
+ * ±2^53 is a REFUSAL and never a rounded amount — the same bound `parseMinor` applies to one row,
215
+ * applied to the sum of many, where it is far easier to reach.
216
+ */
217
+ export const aggregateMinor = <Row>(
218
+ entity: EntityCore<Row>,
219
+ fn: AggregateFn,
220
+ property: string,
221
+ total: string,
222
+ ): number => {
223
+ const units = BigInt(total);
224
+ if (units <= BigInt(Number.MAX_SAFE_INTEGER) && units >= BigInt(-Number.MAX_SAFE_INTEGER)) {
225
+ return Number(units);
226
+ }
227
+ throw new EntityError({
228
+ code: 'X_AGGREGATE_UNSUPPORTED',
229
+ cause: `${entity.$name}.${fn}('${property}') is ${total} minor units, past ±2^53 — no JS number holds it and MoneyValue.minor is one`,
230
+ fix: `${entity.$name}.andWhere(…).${fn}('${property}') # narrow the rows, or read the total as text with a hand-written statement`,
231
+ });
232
+ };
package/src/batch.ts CHANGED
@@ -56,7 +56,8 @@ export const assertBatchable = <Row>(
56
56
  if (chain.limit !== undefined) throw limitedBatches(entity.$name, chain.limit, size);
57
57
  // The order the driver will sort by, primary key included: the cursor between two batches is
58
58
  // minted from it, and an ordering that cannot carry one fails on the batch *after* the first —
59
- // where whatever size the caller happened to pass decides whether anyone ever finds out.
59
+ // where whatever size the caller happened to pass decides whether anyone ever finds out. A
60
+ // nullable column is NOT such an ordering `As of 2026-08-24`; a nullable primary-key column is.
60
61
  assertSeekable(entity, totalOrder(entity, chain.orderBy));
61
62
  };
62
63
 
package/src/column.ts CHANGED
@@ -105,7 +105,12 @@ export const bindColumn = (column: AnyColumn, table: string, property: string):
105
105
  'build a new column instead of sharing one between entities',
106
106
  );
107
107
  }
108
- const binding: Binding = { table, property, name: columnName(property, column.$meta) };
108
+ // The DERIVED name too, not only a declared one: `snake(property)` lower-cases and nothing else.
109
+ const binding: Binding = {
110
+ table,
111
+ property,
112
+ name: assertColumnName(columnName(property, column.$meta)),
113
+ };
109
114
  bindings.set(column, binding);
110
115
  return binding;
111
116
  };
@@ -203,6 +208,15 @@ export const makeColumn = <T, Optional extends boolean>(
203
208
  * identifier. `[a-z_][a-z0-9_$]*`, which is what an unquoted Postgres identifier may be, and the
204
209
  * bound is the same 63 bytes the server truncates at — a longer one silently addresses a
205
210
  * different column.
211
+ *
212
+ * **Every physical name, not only a declared one — `As of 2026-08-24`.** `columnName` is
213
+ * `meta.name ?? snake(property)` and for three majors only the first branch reached here, so a
214
+ * PROPERTY name went into the DDL untouched: `snake()` lower-cases and does nothing else, and a
215
+ * column named `n" , "x" text); drop table t; --` produced a `create table` carrying a real
216
+ * `drop table` (measured through `generateMigration`). Quoting is not a defence against a value
217
+ * that can close the quote, which is what the paragraph above already said. `bindColumn` is where
218
+ * the derived name is checked, because that runs once per column at `entity()` rather than on
219
+ * every statement.
206
220
  */
207
221
  export const assertColumnName = (name: string): string => {
208
222
  if (!/^[a-z_][a-z0-9_$]*$/.test(name) || name.length > 63) {
@@ -0,0 +1,94 @@
1
+ // Single responsibility: what `@>`, `<@`, `&&` and a JSON key test MEAN, written once so the
2
+ // in-memory driver answers what Postgres answers. A `jsonb` or an `arrayOf()` column was declared
3
+ // and then unfilterable — the ten-operator vocabulary had nothing that could look inside one — so
4
+ // an app with either had to leave the query language for hand-written SQL, which is the one path
5
+ // in this framework with no tenancy guard on it.
6
+ //
7
+ // Every rule here is Postgres', reproduced rather than approximated. Where the two could not be
8
+ // made to agree the operator is refused instead (`memory-match.ts`), never guessed at.
9
+
10
+ /** Absent and NULL are one thing, exactly as they are to every other predicate. */
11
+ const isNull = (value: unknown): boolean => value === null || value === undefined;
12
+
13
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
14
+ typeof value === 'object' && value !== null && !Array.isArray(value);
15
+
16
+ /** Neither an object nor an array — which is exactly the set the top-level exception below covers. */
17
+ const isScalar = (value: unknown): boolean => !isRecord(value) && !Array.isArray(value);
18
+
19
+ /**
20
+ * Two ELEMENTS, equal. `===` plus the one case it gets wrong here: an `arrayOf(timestamp())` row
21
+ * holds `Date` objects, and two Dates for the same instant are two references — the same trap
22
+ * `sameValueOfKind` closes for a predicate, one operator along.
23
+ *
24
+ * Nothing deeper, and that is a property rather than an omission: `arrayOf()` refuses `jsonb`,
25
+ * `bytea`, `money` and a nested array at declaration, so an element is always a scalar or a Date.
26
+ */
27
+ const sameElement = (left: unknown, right: unknown): boolean => {
28
+ if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime();
29
+ return left === right;
30
+ };
31
+
32
+ /**
33
+ * `left @> right` for `jsonb`, to the letter of Postgres' definition — each clause below measured
34
+ * on Postgres 16 rather than read off a summary, because three of them are easy to state wrongly:
35
+ *
36
+ * - two objects: every key of `right` is present in `left` and its value is contained by the one
37
+ * there — recursively, which is what makes `data @> '{"a":{"b":1}}'` a NESTED match and why this
38
+ * package ships no second path-expression language beside it (axiom 1).
39
+ * - two arrays: every element of `right` is contained by SOME element of `left`, which is why
40
+ * `[1,2,3] @> [3,1]` holds and order never matters.
41
+ * - an array on the left and a PRIMITIVE on the right, **at the top level only**: `'[1,2]' @> '2'`
42
+ * is true. Both halves of that sentence are load-bearing and both were wrong here first —
43
+ * `'{"list":[1,2,3]}' @> '{"list":2}'` is FALSE (the exception does not recurse) and
44
+ * `'[{"a":1}]' @> '{"a":1}'` is FALSE (it does not extend to composites).
45
+ * - anything else: element equality, which for a jsonb scalar is what it sounds like.
46
+ */
47
+ export const jsonContains = (left: unknown, right: unknown): boolean => contains(left, right, true);
48
+
49
+ const contains = (left: unknown, right: unknown, top: boolean): boolean => {
50
+ if (Array.isArray(left)) {
51
+ if (Array.isArray(right)) {
52
+ return right.every((item) => left.some((candidate) => contains(candidate, item, false)));
53
+ }
54
+ return top && isScalar(right) && left.some((candidate) => sameElement(candidate, right));
55
+ }
56
+ if (isRecord(left) && isRecord(right)) {
57
+ return Object.keys(right).every(
58
+ (key) => Object.hasOwn(left, key) && contains(left[key], right[key], false),
59
+ );
60
+ }
61
+ return isScalar(left) && isScalar(right) && sameElement(left, right);
62
+ };
63
+
64
+ /**
65
+ * `left @> right` for a SQL array, which is a different operator with a different rule: element
66
+ * containment is plain equality, never the recursive one above, because an array's elements are
67
+ * scalars of one declared type rather than arbitrary JSON. An empty right-hand side is contained
68
+ * by every array, which is what Postgres answers.
69
+ */
70
+ export const arrayContains = (left: readonly unknown[], right: readonly unknown[]): boolean =>
71
+ right.every((item) => left.some((candidate) => sameElement(candidate, item)));
72
+
73
+ /**
74
+ * `left && right`: they share at least one element. Arrays only — `jsonb` has no `&&` — and the
75
+ * bound is the opposite way round from `@>`: an EMPTY operand overlaps nothing, where it is
76
+ * contained by everything.
77
+ */
78
+ export const arrayOverlaps = (left: readonly unknown[], right: readonly unknown[]): boolean =>
79
+ right.some((item) => left.some((candidate) => sameElement(candidate, item)));
80
+
81
+ /**
82
+ * `jsonb_exists(value, key)` — the function form of the `?` operator, which is what the SQL side
83
+ * emits so a literal `?` can never be read as a parameter placeholder by anything on the way.
84
+ *
85
+ * Three shapes, all of them Postgres': a top-level key of an object, a string ELEMENT of an array,
86
+ * and a string value equal to the key. A number never matches — `jsonb_exists('[1]', '1')` is
87
+ * false there, and a `String(item) === key` here would have made it true.
88
+ */
89
+ export const jsonHasKey = (value: unknown, key: unknown): boolean => {
90
+ if (typeof key !== 'string' || isNull(value)) return false;
91
+ if (Array.isArray(value)) return value.some((item) => item === key);
92
+ if (isRecord(value)) return Object.hasOwn(value, key);
93
+ return value === key;
94
+ };
package/src/cursor.ts CHANGED
@@ -10,6 +10,7 @@ import { CursorInvalidError, decodeCursor, encodeCursor } from '@ultimat3/core';
10
10
  import { columnFor } from './column';
11
11
  import type { EntityCore } from './entity';
12
12
  import { invariantViolated } from './errors';
13
+ import { instantMicros } from './instant';
13
14
  import type { QueryPlan } from './tenancy';
14
15
  import type { AnyColumn, ColumnKind } from './types';
15
16
 
@@ -89,8 +90,36 @@ export const valueAt = (row: unknown, path: string): unknown => {
89
90
  : undefined;
90
91
  };
91
92
 
92
- /** Stringified so the cursor is JSON; `revive` restores the type from the column's kind. */
93
- const serializeSortValue = (value: unknown): string => {
93
+ /**
94
+ * Stringified so the cursor is JSON; `revive` restores the type from the column's kind — and the
95
+ * KIND decides how, never the JS type in hand, because those are two different questions on
96
+ * exactly the column that made this file wrong.
97
+ *
98
+ * A `timestamptz` is carried as MICROSECONDS since the epoch, not as `toISOString()`. The column
99
+ * holds microseconds and a `Date` holds milliseconds, so an ISO rendition of a decoded row is the
100
+ * row's own position FLOORED — and a seek built from a floored position ranks rows differently
101
+ * from the `order by` that produced them, which silently drops every row inside the boundary
102
+ * millisecond. Proven against a real server: `pg-cursor-precision.live.test.ts`.
103
+ */
104
+ const ABSENT_MARK = '~';
105
+ const PRESENT_MARK = '!';
106
+
107
+ /**
108
+ * A sort value's place in the cursor is TAGGED, so absence can be told from the text that spells
109
+ * it: `~` alone is NULL, `!` prefixes a present value. Positional, therefore total — a `text`
110
+ * column holding the four characters `null` encodes as `!null` and can never be read as an absent
111
+ * one, which is the collision a bare sentinel value would reopen.
112
+ *
113
+ * The tag exists because a nullable sort key is legal `As of 2026-08-24` (`asc nulls last` /
114
+ * `desc nulls first`, `@ultimat3/query`'s spelling), and a keyset position over one has to be able
115
+ * to say "the boundary row had none".
116
+ */
117
+ const tagged = (text: string): string => `${PRESENT_MARK}${text}`;
118
+
119
+ const serializeSortValue = (kind: ColumnKind, value: unknown): string | undefined => {
120
+ // `undefined`, never `'0'`: a position nothing could read would decode to the epoch, which is
121
+ // "start from the top" wearing a signature — the one thing a cursor must never mean.
122
+ if (kind === 'timestamptz') return instantMicros(value)?.toString();
94
123
  if (value instanceof Date) return value.toISOString();
95
124
  if (typeof value === 'bigint') return value.toString();
96
125
  return String(value);
@@ -108,8 +137,16 @@ const serializeSortValue = (value: unknown): string => {
108
137
  // constant three lines above it.
109
138
  const reviveSortValue = (kind: ColumnKind, text: string): unknown => {
110
139
  switch (kind) {
111
- case 'timestamptz':
112
- return new Date(text);
140
+ case 'timestamptz': {
141
+ // Microseconds since the epoch — the precision the COLUMN keeps, which a `Date` cannot.
142
+ // A cursor minted before that decision carries an ISO string, so this is where it is
143
+ // refused: `BigInt('2026-…')` is a bare `SyntaxError` with no code and no fix.
144
+ const micros = instantMicros(text);
145
+ if (micros === undefined) {
146
+ throw new CursorInvalidError('its position is not a microsecond instant');
147
+ }
148
+ return micros;
149
+ }
113
150
  case 'bigint':
114
151
  return BigInt(text);
115
152
  case 'integer':
@@ -125,9 +162,12 @@ const reviveSortValue = (kind: ColumnKind, text: string): unknown => {
125
162
  * A keyset seek only has a total order when every sort column is present on every row —
126
163
  * `null > 'x'` is unknown in SQL and would drop rows from the middle of a listing.
127
164
  *
128
- * Checked when a cursor is minted as well as when one is decoded: an ordering that cannot carry
129
- * a position is the author's mistake, and reporting it on the *second* page hides it behind
130
- * whatever page size the caller happened to use.
165
+ * Checked where the PLAN is built (`planFor`), which is every read either driver sends, as well as
166
+ * when a cursor is minted and when one is decoded. The plan is the load-bearing one: `cursorFor`
167
+ * runs only when a page found a row past its limit, so the refusal used to depend on how many rows
168
+ * the table happened to hold — green on fifteen seeded rows, `X_INVARIANT_VIOLATED` on the first
169
+ * read past a page of twenty in production. An ordering that cannot carry a position is the
170
+ * author's mistake at any row count.
131
171
  */
132
172
  export const assertSeekable = <Row>(
133
173
  entity: EntityCore<Row>,
@@ -138,15 +178,28 @@ export const assertSeekable = <Row>(
138
178
  // money property named without its part — both mint a cursor nothing can decode.
139
179
  kindAt(entity, key.column);
140
180
  if (columnAt(entity, key.column).$meta.notNull) continue;
181
+ // An ORDINARY nullable key is orderable, `As of 2026-08-24`: NULL has a declared place
182
+ // (`asc nulls last` / `desc nulls first`), the cursor carries that place, and the seek reaches
183
+ // it. What is left is the TIEBREAK — `totalOrder` appends the primary key precisely so two
184
+ // rows sharing a sort value cannot straddle a page boundary, and a nullable primary-key column
185
+ // cannot do that job: `null = null` is unknown, so two such rows are indistinguishable to the
186
+ // seek and one of them is served twice or never. Reachable only through `primaryKey: [...]`,
187
+ // which takes the columns as declared.
188
+ if (!entity.$primaryKey.includes(key.column)) continue;
141
189
  throw invariantViolated(
142
190
  entity.$name,
143
191
  'cursor',
144
- `${key.column} is nullable and cannot carry a cursor order by a not-null column ` +
145
- `(add .orderBy('${entity.$primaryKey[0] ?? 'id'}') or make ${key.column} not null)`,
192
+ `${key.column} is part of the primary key and is nullable, so no ordering can be total ` +
193
+ 'an ordinary nullable column orders fine (nulls last ascending, nulls first descending), ' +
194
+ `but the tiebreak cannot: drop .nullable() from ${key.column}`,
146
195
  );
147
196
  }
148
197
  };
149
198
 
199
+ /** Whether a sort key may hold NULL — what decides the seek's SHAPE, not only its values. */
200
+ export const isNullableKey = <Row>(entity: EntityCore<Row>, path: string): boolean =>
201
+ !columnAt(entity, path).$meta.notNull;
202
+
150
203
  /** Deterministic, and total over the value shapes a predicate can hold. */
151
204
  const renderValue = (value: unknown): string => {
152
205
  if (value === null || value === undefined) return 'null';
@@ -182,17 +235,37 @@ export const planScope = (plan: QueryPlan): string => {
182
235
  .slice(0, 16);
183
236
  };
184
237
 
185
- /** The cursor that continues this plan after `row`. Signed by core, scoped by the plan. */
238
+ /**
239
+ * The cursor that continues this plan after `row`. Signed by core, scoped by the plan.
240
+ *
241
+ * `exact` is how a driver hands over a value the DECODED row cannot hold: a `timestamptz` comes
242
+ * back as a `Date`, which is milliseconds, and the microseconds it dropped are the difference
243
+ * between a position the `order by` agrees with and one it does not. Optional because the
244
+ * in-memory driver stores millisecond `Date`s and therefore has nothing finer to give.
245
+ */
186
246
  export const cursorFor = <Row>(
187
247
  entity: EntityCore<Row>,
188
248
  plan: QueryPlan,
189
249
  row: unknown,
190
250
  id: string,
251
+ exact?: ReadonlyMap<string, unknown>,
191
252
  ): string => {
192
253
  assertSeekable(entity, plan.orderBy);
193
254
  return encodeCursor({
194
255
  scope: planScope(plan),
195
- key: plan.orderBy.map((entry) => serializeSortValue(valueAt(row, entry.column))),
256
+ key: plan.orderBy.map((entry) => {
257
+ const value = exact?.get(entry.column) ?? valueAt(row, entry.column);
258
+ // A column the row never named and a stored NULL are one absence everywhere else in this
259
+ // package (`isNull`), and they are one position here too.
260
+ if (value === null || value === undefined) return ABSENT_MARK;
261
+ const text = serializeSortValue(kindAt(entity, entry.column), value);
262
+ if (text !== undefined) return tagged(text);
263
+ throw invariantViolated(
264
+ entity.$name,
265
+ 'cursor',
266
+ `${entry.column} on the last row of the page holds no instant a cursor can carry`,
267
+ );
268
+ }),
196
269
  id,
197
270
  });
198
271
  };
@@ -216,7 +289,20 @@ export const seekFrom = <Row>(
216
289
  `it carries ${key.length} sort values, this order needs ${plan.orderBy.length}`,
217
290
  );
218
291
  }
219
- return plan.orderBy.map((entry, index) =>
220
- reviveSortValue(kindAt(entity, entry.column), String(key[index])),
221
- );
292
+ return plan.orderBy.map((entry, index) => {
293
+ // `segment` and `ABSENT_MARK`, never `token` and `NULL_KEY`: both names said CREDENTIAL to
294
+ // `bun run secret-compare`, whose rule is that a `===` on one leaks it a byte at a time. What
295
+ // this compares is a page POSITION against a one-character tag, where the repair the guard
296
+ // names — `timingSafeEqual` — would be constant-time nonsense. The guard reads names because a
297
+ // unit test cannot assert timing, so the name is the thing that has to be right.
298
+ const segment = String(key[index]);
299
+ if (segment === ABSENT_MARK) return null;
300
+ if (!segment.startsWith(PRESENT_MARK)) {
301
+ // Every cursor this package mints carries a tag. An untagged one was forged past the
302
+ // signature or minted before nullable sort keys existed; either way the alternative is a
303
+ // silent restart at the top, which is the one thing a cursor may never mean.
304
+ throw new CursorInvalidError('a sort value carries no null-or-value tag');
305
+ }
306
+ return reviveSortValue(kindAt(entity, entry.column), segment.slice(PRESENT_MARK.length));
307
+ });
222
308
  };
package/src/database.ts CHANGED
@@ -2,11 +2,11 @@
2
2
  // writes a repository class per entity, and nobody can reach a table that is not in the set.
3
3
 
4
4
  import type { EntityCore } from './entity';
5
+ import { memoryRepo } from './memory-repo';
5
6
  import type { RelatedTables } from './preload';
6
7
  import type { Table } from './query';
7
8
  import { tableFor } from './query';
8
9
  import type { Repo } from './repo';
9
- import { memoryRepo } from './repo';
10
10
  import { observedRepo } from './row-observer';
11
11
 
12
12
  export type EntitySet = Readonly<Record<string, EntityCore>>;
package/src/describe.ts CHANGED
@@ -188,6 +188,9 @@ export const describeEntity = <Row>(input: DescribeInput<Row>): EntityDescriptio
188
188
  unique: index.unique,
189
189
  where: index.where ?? null,
190
190
  order: index.order ?? null,
191
+ // Spread, never `?? null`: absent is what `@ultimat3/db` reads as the btree it always was,
192
+ // and a written-out `null` would be a field no existing snapshot carries.
193
+ ...(index.using === undefined ? {} : { using: index.using }),
191
194
  })),
192
195
  tags: input.tags,
193
196
  cacheTag: input.cacheTag,