@ultimat3/entity 11.3.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.
- package/CLAUDE.md +190 -20
- package/README.md +120 -1
- package/package.json +5 -5
- package/src/aggregate-decode.ts +35 -0
- package/src/aggregate-fold.ts +91 -0
- package/src/aggregate.ts +232 -0
- package/src/batch.ts +2 -1
- package/src/column.ts +15 -1
- package/src/containment.ts +94 -0
- package/src/cursor.ts +100 -14
- package/src/database.ts +1 -1
- package/src/describe.ts +3 -0
- package/src/entity.ts +153 -7
- package/src/errors.ts +6 -0
- package/src/index.ts +3 -1
- package/src/instant.ts +93 -0
- package/src/memory-match.ts +67 -0
- package/src/memory-repo.ts +357 -0
- package/src/pg-driver.ts +94 -7
- package/src/pg-row.ts +38 -1
- package/src/pg-sql.ts +279 -133
- package/src/pg-write-sql.ts +129 -0
- package/src/plan.ts +71 -10
- package/src/query.ts +59 -3
- package/src/registry.ts +7 -0
- package/src/repo.ts +16 -328
- package/src/tenancy.ts +18 -2
- package/src/types.ts +10 -0
package/src/aggregate.ts
ADDED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
93
|
-
|
|
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
|
-
|
|
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
|
|
129
|
-
* a
|
|
130
|
-
*
|
|
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
|
|
145
|
-
|
|
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
|
-
/**
|
|
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) =>
|
|
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
|
-
|
|
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,
|