@ultimat3/entity 22.15.0 → 24.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 +72 -51
- package/README.md +145 -21
- package/package.json +6 -5
- package/src/aggregate.ts +31 -22
- package/src/bulk-write.ts +4 -13
- package/src/column.ts +36 -1
- package/src/columns-data.ts +24 -3
- package/src/columns-scalar.ts +13 -6
- package/src/columns.ts +8 -1
- package/src/containment.ts +15 -2
- package/src/database.ts +9 -2
- package/src/entity-error.ts +45 -20
- package/src/entity.ts +81 -18
- package/src/errors.ts +16 -42
- package/src/expr.ts +66 -20
- package/src/index.ts +16 -1
- package/src/instant.ts +84 -5
- package/src/invariants.ts +3 -3
- package/src/judged-write.ts +28 -0
- package/src/like.ts +85 -0
- package/src/memory-match.ts +27 -82
- package/src/memory-repo.ts +16 -2
- package/src/memory-unique.ts +7 -6
- package/src/numeric-compare.ts +57 -0
- package/src/pg-driver.ts +38 -29
- package/src/pg-row.ts +4 -1
- package/src/pg-sql.ts +5 -1
- package/src/plan.ts +9 -3
- package/src/preload-ceiling.ts +75 -0
- package/src/preload.ts +23 -8
- package/src/query.ts +67 -34
- package/src/record-projection.ts +17 -2
- package/src/row-observer.ts +17 -2
- package/src/rows-of.ts +13 -1
- package/src/sealed-column.ts +123 -0
- package/src/sealed-declaration.ts +64 -0
- package/src/sealed-errors.ts +112 -0
- package/src/sealed-repo.ts +235 -0
- package/src/sealed.ts +196 -0
- package/src/seed.ts +97 -73
- package/src/transition.ts +3 -0
- package/src/type-pins.ts +109 -3
- package/src/types.ts +106 -0
- package/src/unique-constraints.ts +44 -0
package/src/expr.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { literal as sqlLiteral } from '@ultimat3/db';
|
|
16
16
|
import { invariantViolated } from './errors';
|
|
17
17
|
import { isNullish } from './is-null';
|
|
18
|
+
import { DECIMAL_TEXT, numericOrder } from './numeric-compare';
|
|
18
19
|
import { unportableConstruct } from './pattern-portability';
|
|
19
20
|
import { refuseInvariant } from './refuse';
|
|
20
21
|
import type { ColumnMap } from './types';
|
|
@@ -49,12 +50,14 @@ export interface Expr {
|
|
|
49
50
|
interface Term {
|
|
50
51
|
readonly path: readonly string[];
|
|
51
52
|
readonly label: string;
|
|
53
|
+
/** The column's row value is decimal TEXT (`bigint()`, `decimal()`), by its declared kind. */
|
|
54
|
+
readonly decimal: boolean;
|
|
52
55
|
sql(resolve: Resolve): string;
|
|
53
56
|
read(row: Row): unknown;
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
export interface ColumnExpr {
|
|
57
|
-
/** `btrim(
|
|
60
|
+
/** `btrim(col)` in SQL and its rule in the app: leading and trailing SPACES (U+0020) only. */
|
|
58
61
|
trimmed(): ColumnExpr;
|
|
59
62
|
minLength(length: number): Expr;
|
|
60
63
|
contains(value: string): Expr;
|
|
@@ -178,6 +181,21 @@ const check = (
|
|
|
178
181
|
holds: (row: Row) => boolean,
|
|
179
182
|
): Expr => ({ kind: 'check', paths, message, toSql: sql, holds });
|
|
180
183
|
|
|
184
|
+
/**
|
|
185
|
+
* A numeric operand a CHECK can spell. `NaN` and the infinities render as `col >= NaN` — a column
|
|
186
|
+
* reference Postgres does not have — so they are refused on the line that wrote them.
|
|
187
|
+
*/
|
|
188
|
+
const finite = <T>(rule: 'atLeast' | 'eq', operand: T): T => {
|
|
189
|
+
if (typeof operand === 'number' && !Number.isFinite(operand)) {
|
|
190
|
+
refuseInvariant(
|
|
191
|
+
rule,
|
|
192
|
+
`${String(operand)} is not a number a CHECK can compare a column against`,
|
|
193
|
+
`pass a finite number — c.total.${rule}(0) — or a bigint for a bound past 2^53: c.total.${rule}(9007199254740993n)`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return operand;
|
|
197
|
+
};
|
|
198
|
+
|
|
181
199
|
const isColumnExpr = (value: unknown): value is ColumnExpr =>
|
|
182
200
|
typeof value === 'object' && value !== null && terms.has(value as ColumnExpr);
|
|
183
201
|
|
|
@@ -192,11 +210,14 @@ const expr = (term: Term): ColumnExpr => {
|
|
|
192
210
|
trimmed: () =>
|
|
193
211
|
expr({
|
|
194
212
|
path: term.path,
|
|
213
|
+
decimal: term.decimal,
|
|
195
214
|
label: `trimmed ${term.label}`,
|
|
196
215
|
sql: (resolve) => `btrim(${term.sql(resolve)})`,
|
|
197
216
|
read: (row) => {
|
|
198
217
|
const value = term.read(row);
|
|
199
|
-
|
|
218
|
+
// U+0020 only — what one-argument `btrim` strips. `.trim()` also removed tabs, newlines
|
|
219
|
+
// and NBSP, so the app approved a row the CHECK then refused as a raw 23514.
|
|
220
|
+
return typeof value === 'string' ? value.replace(/^ +| +$/g, '') : value;
|
|
200
221
|
},
|
|
201
222
|
}),
|
|
202
223
|
|
|
@@ -233,21 +254,32 @@ const expr = (term: Term): ColumnExpr => {
|
|
|
233
254
|
);
|
|
234
255
|
},
|
|
235
256
|
|
|
236
|
-
atLeast: (bound) =>
|
|
237
|
-
|
|
257
|
+
atLeast: (bound) => {
|
|
258
|
+
finite('atLeast', bound);
|
|
259
|
+
return one(
|
|
238
260
|
`${term.label} must be at least ${bound}`,
|
|
239
261
|
(resolve) => `${term.sql(resolve)} >= ${bound}`,
|
|
240
|
-
|
|
241
|
-
|
|
262
|
+
// By the column's KIND, never `typeof value`: a `bigint()`/`decimal()` row is decimal text,
|
|
263
|
+
// so a number test failed every row the CHECK beside it accepts.
|
|
264
|
+
(value) => (numericOrder(term.decimal, value, bound) ?? -1) >= 0,
|
|
265
|
+
);
|
|
266
|
+
},
|
|
242
267
|
|
|
243
|
-
eq: (other) =>
|
|
244
|
-
isColumnExpr(other)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
268
|
+
eq: (other) => {
|
|
269
|
+
if (isColumnExpr(other)) return sameAs(term, other);
|
|
270
|
+
finite('eq', other);
|
|
271
|
+
// `col = 1.5` is numeric equality whatever spelled the operand, and on a decimal column so is
|
|
272
|
+
// `col = '1.5'` — Postgres coerces the literal. Everything else is the value itself.
|
|
273
|
+
const numeric = term.decimal || typeof other === 'number' || typeof other === 'bigint';
|
|
274
|
+
return one(
|
|
275
|
+
`${term.label} must equal ${literal(other)}`,
|
|
276
|
+
(resolve) => `${term.sql(resolve)} = ${literal(other)}`,
|
|
277
|
+
(value) =>
|
|
278
|
+
numeric && typeof other !== 'boolean'
|
|
279
|
+
? numericOrder(term.decimal, value, other) === 0
|
|
280
|
+
: value === other,
|
|
281
|
+
);
|
|
282
|
+
},
|
|
251
283
|
|
|
252
284
|
isTrue: () =>
|
|
253
285
|
one(
|
|
@@ -281,6 +313,8 @@ const expr = (term: Term): ColumnExpr => {
|
|
|
281
313
|
const part = (term: Term, key: string): ColumnExpr =>
|
|
282
314
|
expr({
|
|
283
315
|
path: [...term.path, key],
|
|
316
|
+
// A money part is a `number` (minor) or text (currency) — never decimal text.
|
|
317
|
+
decimal: false,
|
|
284
318
|
label: `${term.label}.${key}`,
|
|
285
319
|
sql: (resolve) => resolve([...term.path, key]),
|
|
286
320
|
read: (row) => walk(row, [...term.path, key]),
|
|
@@ -300,12 +334,18 @@ const sameAs = (left: Term, other: ColumnExpr): Expr => {
|
|
|
300
334
|
[left.path, right.path],
|
|
301
335
|
`${left.label} must equal ${right.label}`,
|
|
302
336
|
(resolve) => `${left.sql(resolve)} = ${right.sql(resolve)}`,
|
|
303
|
-
|
|
337
|
+
// Two numeric columns are equal when their NUMBERS are: `'2' = '2.00'` across a `bigint()` and
|
|
338
|
+
// a `decimal()`, which `===` over the two row strings denied.
|
|
339
|
+
(row) =>
|
|
340
|
+
left.decimal || right.decimal
|
|
341
|
+
? numericOrder(true, left.read(row), right.read(row)) === 0
|
|
342
|
+
: left.read(row) === right.read(row),
|
|
304
343
|
);
|
|
305
344
|
};
|
|
306
345
|
|
|
307
|
-
const columnTerm = (property: string): Term => ({
|
|
346
|
+
const columnTerm = (property: string, decimal: boolean): Term => ({
|
|
308
347
|
path: [property],
|
|
348
|
+
decimal,
|
|
309
349
|
label: property,
|
|
310
350
|
sql: (resolve) => resolve([property]),
|
|
311
351
|
read: (row) => row[property],
|
|
@@ -403,25 +443,31 @@ export const iff = (left: Expr, right: Expr): Expr => {
|
|
|
403
443
|
* catches a typo at compile time: a JS caller, a dynamically built rule and a `satisfies()` column
|
|
404
444
|
* list all reach it untyped, and the thrown message names the columns that do exist rather than
|
|
405
445
|
* failing later as `undefined is not a function`.
|
|
446
|
+
*
|
|
447
|
+
* It takes the column MAP, not a name list: a numeric rule is judged by the column's declared kind
|
|
448
|
+
* (`numeric-compare.ts`), and the map is the only place that kind is written.
|
|
406
449
|
*/
|
|
407
450
|
export const invariantColumns = <C extends ColumnMap>(
|
|
408
451
|
entity: string,
|
|
409
|
-
|
|
452
|
+
columns: C,
|
|
410
453
|
): InvariantColumns<C> => {
|
|
411
|
-
const
|
|
454
|
+
const properties = Object.keys(columns);
|
|
412
455
|
const helpers = { unique, satisfies };
|
|
413
456
|
return new Proxy(helpers, {
|
|
414
457
|
get(target, property) {
|
|
415
458
|
if (property === 'unique' || property === 'satisfies') return target[property];
|
|
416
459
|
if (typeof property !== 'string') return undefined;
|
|
417
|
-
|
|
460
|
+
// `Object.hasOwn`, never `columns[property]`: the name is caller data on the JS path, and a
|
|
461
|
+
// plain read answers an `Object.prototype` member for `constructor`.
|
|
462
|
+
const column = Object.hasOwn(columns, property) ? columns[property] : undefined;
|
|
463
|
+
if (column === undefined) {
|
|
418
464
|
throw invariantViolated(
|
|
419
465
|
entity,
|
|
420
466
|
'invariant',
|
|
421
467
|
`no column "${property}"; declared columns are ${properties.join(', ')}`,
|
|
422
468
|
);
|
|
423
469
|
}
|
|
424
|
-
return expr(columnTerm(property));
|
|
470
|
+
return expr(columnTerm(property, DECIMAL_TEXT.has(column.$meta.kind)));
|
|
425
471
|
},
|
|
426
472
|
}) as unknown as InvariantColumns<C>;
|
|
427
473
|
};
|
package/src/index.ts
CHANGED
|
@@ -45,7 +45,6 @@ export type {
|
|
|
45
45
|
// so a third-party driver has to be able to raise the same refusals the two shipped ones do.
|
|
46
46
|
export {
|
|
47
47
|
crossTenantDenied,
|
|
48
|
-
dbDrift,
|
|
49
48
|
ENTITY_ERROR_CODES,
|
|
50
49
|
ENTITY_ERROR_TITLES,
|
|
51
50
|
EntityError,
|
|
@@ -76,6 +75,10 @@ export {
|
|
|
76
75
|
} from './feature-errors';
|
|
77
76
|
export type { Invariant, InvariantDef, InvariantKind } from './invariants';
|
|
78
77
|
export { assertInvariants, invariant, MAX_ASSERTED_ROWS } from './invariants';
|
|
78
|
+
// How Postgres compares two values of one column, decided by the column's declared KIND. Exported
|
|
79
|
+
// because `@ultimat3/query`'s matcher and in-memory source ask the same question — and a second
|
|
80
|
+
// answer there is how a live window and the database returned one read in two orders.
|
|
81
|
+
export { compareByKind, sameValueOfKind } from './memory-match';
|
|
79
82
|
export { memoryRepo, memoryTransactor } from './memory-repo';
|
|
80
83
|
export type { StatementLoop } from './n-plus-one';
|
|
81
84
|
export { N_PLUS_ONE_THRESHOLD, nPlusOne, preloadsFor } from './n-plus-one';
|
|
@@ -90,6 +93,8 @@ export { postgresTransactor } from './pg-transactor';
|
|
|
90
93
|
// its own `pageSize` input against a hardcoded 10_000 is a second declaration of one number.
|
|
91
94
|
export { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from './plan';
|
|
92
95
|
export type { RelatedTable, RelatedTables } from './preload';
|
|
96
|
+
export type { PreloadOptions } from './preload-ceiling';
|
|
97
|
+
export { MAX_PRELOADED_ROWS } from './preload-ceiling';
|
|
93
98
|
export type { Preloaded, ReadBuilder, Table } from './query';
|
|
94
99
|
export { tableFor } from './query';
|
|
95
100
|
// The client projection of an entity (plan 101): record type, record key, and the brand on
|
|
@@ -132,6 +137,11 @@ export type { RowBulkChange, RowChange, RowChangeOp, RowObserver } from './row-o
|
|
|
132
137
|
export { observedRepo, rowObserver, setRowObserver } from './row-observer';
|
|
133
138
|
export type { RecordsByKey, RecordsByType } from './rows-of';
|
|
134
139
|
export { hasEntityRows, projectionsIn, rowsOf } from './rows-of';
|
|
140
|
+
// Sealed columns. `sealedFields` is what a manifest or `x doctor` reads to say which columns are
|
|
141
|
+
// sealed; `sealedRepo` is the seam a driver written outside this package returns through.
|
|
142
|
+
export type { SealedField } from './sealed';
|
|
143
|
+
export { sealedFields } from './sealed';
|
|
144
|
+
export { sealedRepo } from './sealed-repo';
|
|
135
145
|
// Full-text search. The LANGUAGE set and the weights are values an app reads to build a form;
|
|
136
146
|
// `SEARCH_PROPERTY` is what a `matches` predicate names, which a hand-built `QueryPlan` needs.
|
|
137
147
|
export type { SearchInit, SearchLanguage, SearchSource, SearchVector } from './search';
|
|
@@ -194,10 +204,15 @@ export type {
|
|
|
194
204
|
RowOf,
|
|
195
205
|
RowPatch,
|
|
196
206
|
RowWrite,
|
|
207
|
+
SealedColumn,
|
|
208
|
+
SealedMeta,
|
|
209
|
+
SealedOptions,
|
|
197
210
|
SearchWeight,
|
|
211
|
+
TextColumn,
|
|
198
212
|
TimestampColumn,
|
|
199
213
|
TypeOf,
|
|
200
214
|
UuidColumn,
|
|
215
|
+
WireRow,
|
|
201
216
|
} from './types';
|
|
202
217
|
export { COLUMN_KINDS } from './types';
|
|
203
218
|
// `viewFor` stays internal: a view is reached through the entity, as `posts.$view([...])`.
|
package/src/instant.ts
CHANGED
|
@@ -57,9 +57,88 @@ export const pgInstantMicros = (text: unknown): bigint | undefined => {
|
|
|
57
57
|
};
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
60
|
+
* An instant as TEXT that names its own zone — what `toISOString()` writes and what a URL, a JSON
|
|
61
|
+
* body or a hand-kept keyset position holds: `2026-01-01T00:00:03.000Z`, `…+02:00`, `… 00:00:03+00`.
|
|
62
|
+
*
|
|
63
|
+
* The zone is REQUIRED. Postgres reads a zoneless text in the session's `TimeZone`, which this
|
|
64
|
+
* process cannot know, so such a text is not an instant here — the same rule `timestamp()` holds a
|
|
65
|
+
* written value to.
|
|
66
|
+
*/
|
|
67
|
+
const ISO_INSTANT_TEXT =
|
|
68
|
+
/^(\d{4,})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?\s*(Z|[+-]\d{2}(?::?\d{2})?)$/i;
|
|
69
|
+
|
|
70
|
+
const MICROS_PER_MINUTE = 60_000_000n;
|
|
71
|
+
|
|
72
|
+
const MICROS_PER_DAY = 86_400_000_000;
|
|
73
|
+
|
|
74
|
+
/** Postgres's `MAX_TZDISP_HOUR`: a numeric offset of sixteen hours or more is an error there. */
|
|
75
|
+
const MAX_OFFSET_HOURS = 15;
|
|
76
|
+
|
|
77
|
+
/** `Z`, `+02`, `+02:00`, `-0430` → minutes east of UTC; `undefined` past what Postgres accepts. */
|
|
78
|
+
const offsetMinutes = (zone: string): bigint | undefined => {
|
|
79
|
+
if (zone.toUpperCase() === 'Z') return 0n;
|
|
80
|
+
const digits = zone.slice(1).replace(':', '');
|
|
81
|
+
const hours = Number(digits.slice(0, 2));
|
|
82
|
+
const minutes = Number(digits.slice(2) || '0');
|
|
83
|
+
if (hours > MAX_OFFSET_HOURS || minutes >= 60) return undefined;
|
|
84
|
+
const east = BigInt(hours * 60 + minutes);
|
|
85
|
+
return zone.startsWith('-') ? -east : east;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The fraction as Postgres reads it: `strtod` of the digits, times a million, `rint` — so past six
|
|
90
|
+
* digits it ROUNDS, half to even, on the double (`.0000005` is a hair under the half and goes
|
|
91
|
+
* down), and `.9999996` is a whole second. JavaScript's `Number` is the same IEEE double.
|
|
92
|
+
*/
|
|
93
|
+
const fractionMicros = (digits: string | undefined): number => {
|
|
94
|
+
if (digits === undefined) return 0;
|
|
95
|
+
if (digits.length <= FRACTION_DIGITS) return Number(digits.padEnd(FRACTION_DIGITS, '0'));
|
|
96
|
+
const scaled = Number(`0.${digits}`) * 1_000_000;
|
|
97
|
+
const floor = Math.floor(scaled);
|
|
98
|
+
const above = scaled - floor;
|
|
99
|
+
if (above !== 0.5) return above > 0.5 ? floor + 1 : floor;
|
|
100
|
+
return floor % 2 === 0 ? floor : floor + 1;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The exact microsecond epoch an ISO instant text names, or `undefined` when Postgres would refuse
|
|
105
|
+
* it — no zone, a date the calendar does not have (`2026-13-45` is an error there, never the 14th
|
|
106
|
+
* of February), an offset of sixteen hours, or a time past the end of its day.
|
|
107
|
+
*
|
|
108
|
+
* Postgres's `time_overflows`: hour ≤ 24, minute ≤ 59, second ≤ 60, and the whole time of day at
|
|
109
|
+
* most 24:00:00 — so `24:00:00` is the next midnight and `:60` the next minute, while
|
|
110
|
+
* `24:00:00.000001` and `23:59:60.5` are errors.
|
|
111
|
+
*/
|
|
112
|
+
const isoInstantMicros = (text: string): bigint | undefined => {
|
|
113
|
+
const match = ISO_INSTANT_TEXT.exec(text);
|
|
114
|
+
if (match === null) return undefined;
|
|
115
|
+
const [, year = '', month = '', day = '', hour = '', minute = '', second = '', fraction] = match;
|
|
116
|
+
const [y = 0, m = 0, d = 0] = [year, month, day].map(Number);
|
|
117
|
+
const [h = 0, min = 0, sec = 0] = [hour, minute, second].map(Number);
|
|
118
|
+
const midnight = utcSecondMillis([y, m, d, 0, 0, 0]);
|
|
119
|
+
const built = new Date(midnight);
|
|
120
|
+
// A field the calendar rolled over was never that date: 13 months, 45 days.
|
|
121
|
+
if (built.getUTCMonth() + 1 !== m || built.getUTCDate() !== d) return undefined;
|
|
122
|
+
const fsec = fractionMicros(fraction);
|
|
123
|
+
const ofDay = ((h * 60 + min) * 60 + sec) * 1_000_000 + fsec;
|
|
124
|
+
if (h > 24 || min > 59 || sec > 60 || ofDay > MICROS_PER_DAY) return undefined;
|
|
125
|
+
const offset = offsetMinutes(match[8] ?? 'Z');
|
|
126
|
+
if (offset === undefined) return undefined;
|
|
127
|
+
const local = BigInt(midnight) * MICROS_PER_MILLI + BigInt(ofDay);
|
|
128
|
+
// The text is wall-clock time AT its offset, so the instant is that many minutes earlier.
|
|
129
|
+
return local - offset * MICROS_PER_MINUTE;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The microsecond epoch of whatever a sort key or an operand is holding: a decoded row's `Date`
|
|
134
|
+
* (milliseconds, so the last three digits are zero), a value already counted in microseconds, the
|
|
135
|
+
* decimal a cursor carries, or an ISO instant as text. `undefined` for anything else, so a caller
|
|
136
|
+
* decides rather than guessing at `0`.
|
|
137
|
+
*
|
|
138
|
+
* The ISO text is the half Postgres always had: a `"created_at" < $1` bound to
|
|
139
|
+
* `2026-01-01T00:00:03.000Z` is parsed there as an instant, while the in-memory driver compared it
|
|
140
|
+
* to the stored `Date` by CHARACTERS — so a hand-kept keyset answered an empty page two in memory
|
|
141
|
+
* and the right one in production.
|
|
63
142
|
*/
|
|
64
143
|
export const instantMicros = (value: unknown): bigint | undefined => {
|
|
65
144
|
if (typeof value === 'bigint') return value;
|
|
@@ -67,8 +146,8 @@ export const instantMicros = (value: unknown): bigint | undefined => {
|
|
|
67
146
|
const millis = value.getTime();
|
|
68
147
|
return Number.isNaN(millis) ? undefined : BigInt(millis) * MICROS_PER_MILLI;
|
|
69
148
|
}
|
|
70
|
-
if (typeof value
|
|
71
|
-
return
|
|
149
|
+
if (typeof value !== 'string') return undefined;
|
|
150
|
+
return /^-?\d+$/.test(value) ? BigInt(value) : isoInstantMicros(value);
|
|
72
151
|
};
|
|
73
152
|
|
|
74
153
|
/**
|
package/src/invariants.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// callback the typed column proxy once, so a rule is written against property keys (`c.likeCount`)
|
|
8
8
|
// and `entity()` alone resolves them to physical names. A physical name is never typed twice.
|
|
9
9
|
|
|
10
|
-
import { EntityError, invariantViolated } from './errors';
|
|
10
|
+
import { describeCommand, EntityError, invariantViolated } from './errors';
|
|
11
11
|
import type { Expr, Resolve, Row } from './expr';
|
|
12
12
|
|
|
13
13
|
/** `assert` is a rule only the app can run — a JS predicate with no SQL translation. */
|
|
@@ -110,8 +110,8 @@ export const assertedRowsTooMany = (
|
|
|
110
110
|
): EntityError =>
|
|
111
111
|
new EntityError({
|
|
112
112
|
code: 'X_INVARIANT_VIOLATED',
|
|
113
|
-
cause: `${entityName}.${operation}() matches ${matched} rows and ${entityName} declares an invariant only the app can judge, so every one of them would be read back — past ${MAX_ASSERTED_ROWS} that is the whole table in memory`,
|
|
114
|
-
fix:
|
|
113
|
+
cause: `${entityName}.${operation}() matches ${matched} rows and ${entityName} declares an invariant only the app can judge, so every one of them would be read back — past ${MAX_ASSERTED_ROWS} that is the whole table in memory. Read the same filter with .where(filter).inBatches(1000) and write each page: one page per statement, judged one page at a time`,
|
|
114
|
+
fix: `${describeCommand(entityName)} # shows the invariant with sql: null that forces the read-back; then sweep the filter with inBatches()`,
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
/** Runs on every write. Reports every violation at once so one round trip fixes all. */
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Single responsibility: a write whose RESULT the app still has to judge. A rule with no CHECK
|
|
2
|
+
// (`kind: 'assert'`) is asserted on the rows a statement returned — after it was sent — so outside
|
|
3
|
+
// a transaction the violating row was committed and the refusal arrived too late to matter.
|
|
4
|
+
|
|
5
|
+
import { type DbClient, withTransaction } from '@ultimat3/db';
|
|
6
|
+
import type { EntityCore } from './entity';
|
|
7
|
+
import { hasJsOnlyInvariant } from './invariants';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Runs `work` on the connection its statement and its judgement share. With an app-only rule
|
|
11
|
+
* declared that is one transaction — a SAVEPOINT inside an open one — so the throw takes the write
|
|
12
|
+
* with it. With none, a CHECK already refused the statement and this is `direct` and nothing else:
|
|
13
|
+
* no `BEGIN`, no extra round trip.
|
|
14
|
+
*
|
|
15
|
+
* `direct` is resolved by the CALLER first, so a pinned repository inside a transaction opened on
|
|
16
|
+
* some other client is still refused (`X_REPO_CLIENT_PINNED`) before anything opens. `pinned` is
|
|
17
|
+
* that same client when the repository has one: the transaction is opened on it — a SAVEPOINT when
|
|
18
|
+
* one is already open there — never on the ambient pool.
|
|
19
|
+
*/
|
|
20
|
+
export const judgedWrite = <Row, T>(
|
|
21
|
+
entity: EntityCore<Row>,
|
|
22
|
+
direct: DbClient,
|
|
23
|
+
pinned: DbClient | undefined,
|
|
24
|
+
work: (send: DbClient) => Promise<T>,
|
|
25
|
+
): Promise<T> => {
|
|
26
|
+
if (!hasJsOnlyInvariant(entity.$invariants)) return work(direct);
|
|
27
|
+
return withTransaction((tx) => work(tx), pinned === undefined ? {} : { client: pinned });
|
|
28
|
+
};
|
package/src/like.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Single responsibility: what a SQL `LIKE` pattern matches in the in-memory driver — Postgres'
|
|
2
|
+
// default escape handling, counted in CHARACTERS, in time bounded by pattern × value.
|
|
3
|
+
|
|
4
|
+
import { EntityError } from './errors';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Postgres answers a `LIKE` pattern ending in the escape character with `22025 — LIKE pattern must
|
|
8
|
+
* not end with escape character`, so a pattern that means nothing there means nothing here either.
|
|
9
|
+
* The pattern itself is never echoed: a filter value is app data, and this cause is rendered into
|
|
10
|
+
* a log line.
|
|
11
|
+
*/
|
|
12
|
+
const danglingEscape = (entityName: string): EntityError =>
|
|
13
|
+
new EntityError({
|
|
14
|
+
code: 'X_INVARIANT_VIOLATED',
|
|
15
|
+
cause: `${entityName}: a like pattern ends with a backslash, which is the escape character — Postgres answers that pattern with 22025 (LIKE pattern must not end with escape character)`,
|
|
16
|
+
fix: "double it — 'a\\\\' is the pattern that matches one literal backslash, and 'a\\%b' matches a literal %",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const ONE = Symbol('_');
|
|
20
|
+
const MANY = Symbol('%');
|
|
21
|
+
|
|
22
|
+
/** One literal character, `_` (exactly one character) or `%` (any run, the empty one included). */
|
|
23
|
+
type Part = string | typeof ONE | typeof MANY;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The pattern as its parts: `%` and `_` are the wildcards, a backslash escapes either (or itself),
|
|
27
|
+
* everything else is literal. Read by CODE POINT, which is what Postgres counts — `_` over `😀` is
|
|
28
|
+
* one character there and was two UTF-16 units to a regex `.` without the `u` flag.
|
|
29
|
+
*
|
|
30
|
+
* A run of `%` is one `%`, as Postgres reads it.
|
|
31
|
+
*/
|
|
32
|
+
const partsOf = (entityName: string, pattern: string): readonly Part[] => {
|
|
33
|
+
const chars = [...pattern];
|
|
34
|
+
const parts: Part[] = [];
|
|
35
|
+
for (let at = 0; at < chars.length; at += 1) {
|
|
36
|
+
const char = chars[at] ?? '';
|
|
37
|
+
if (char === '\\') {
|
|
38
|
+
const escaped = chars[at + 1];
|
|
39
|
+
if (escaped === undefined) throw danglingEscape(entityName);
|
|
40
|
+
parts.push(escaped);
|
|
41
|
+
at += 1;
|
|
42
|
+
} else if (char === '%') {
|
|
43
|
+
if (parts.at(-1) !== MANY) parts.push(MANY);
|
|
44
|
+
} else {
|
|
45
|
+
parts.push(char === '_' ? ONE : char);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return parts;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `text like pattern`. NOT a compiled regex: an anchored regex with one `.*` per `%` backtracks
|
|
53
|
+
* combinatorially the moment the wildcards are separated (`%a%a%a%…b`, `%_%_%_…b`) — seconds at
|
|
54
|
+
* seven of them over a 70-character value, and a filter value arrives from a search box. This is
|
|
55
|
+
* the two-pointer walk with ONE backtrack point (the last `%`), so the worst case is pattern
|
|
56
|
+
* length × value length whatever the pattern holds.
|
|
57
|
+
*/
|
|
58
|
+
export const likeMatches = (entityName: string, pattern: string, text: string): boolean => {
|
|
59
|
+
const parts = partsOf(entityName, pattern);
|
|
60
|
+
const chars = [...text];
|
|
61
|
+
let part = 0;
|
|
62
|
+
let char = 0;
|
|
63
|
+
let star = -1;
|
|
64
|
+
let resume = 0;
|
|
65
|
+
while (char < chars.length) {
|
|
66
|
+
const current = parts[part];
|
|
67
|
+
if (current === MANY) {
|
|
68
|
+
star = part;
|
|
69
|
+
resume = char;
|
|
70
|
+
part += 1;
|
|
71
|
+
} else if (current !== undefined && (current === ONE || current === chars[char])) {
|
|
72
|
+
part += 1;
|
|
73
|
+
char += 1;
|
|
74
|
+
} else if (star !== -1) {
|
|
75
|
+
// The last `%` takes one more character and the walk resumes after it.
|
|
76
|
+
part = star + 1;
|
|
77
|
+
resume += 1;
|
|
78
|
+
char = resume;
|
|
79
|
+
} else {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
while (parts[part] === MANY) part += 1;
|
|
84
|
+
return part === parts.length;
|
|
85
|
+
};
|
package/src/memory-match.ts
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
// JS type of whichever value is in hand: the database decides by the column's type, so a driver
|
|
5
5
|
// deciding by `typeof` is answering a different question.
|
|
6
6
|
|
|
7
|
-
import { compareDecimalText } from '@ultimat3/core';
|
|
8
7
|
import { keyOf } from './batch-read';
|
|
9
8
|
import { arrayContains, arrayOverlaps, jsonContains, jsonHasKey } from './containment';
|
|
10
9
|
import { kindOf, valueAt } from './cursor';
|
|
@@ -13,28 +12,11 @@ import { EntityError } from './errors';
|
|
|
13
12
|
import { searchInMemory } from './feature-errors';
|
|
14
13
|
import { instantMicros } from './instant';
|
|
15
14
|
import { isNullish as isNull } from './is-null';
|
|
15
|
+
import { likeMatches } from './like';
|
|
16
|
+
import { DECIMAL_TEXT, numericOrder } from './numeric-compare';
|
|
16
17
|
import type { Predicate } from './tenancy';
|
|
17
18
|
import type { ColumnKind } from './types';
|
|
18
19
|
|
|
19
|
-
/**
|
|
20
|
-
* The kinds whose ROW VALUE is a decimal string. `bigint()` and `decimal()` both hand back digits
|
|
21
|
-
* as text on purpose (`columns-data.ts`): a JS `bigint` is what `JSON.stringify` throws on and a
|
|
22
|
-
* `number` loses digits past 2^53, exactly where a legacy `int8` key lives.
|
|
23
|
-
*
|
|
24
|
-
* Which makes them the kinds no `typeof` branch can catch. `compare` had a `number`/`number` case
|
|
25
|
-
* and a `bigint`/`bigint` case and neither fired for these, so both fell to
|
|
26
|
-
* `String(left) < String(right)`: memory answered `["10","100","2","9"]` where Postgres answers
|
|
27
|
-
* `["2","9","10","100"]`, and a keyset page boundary was cut where the database never cuts one.
|
|
28
|
-
*
|
|
29
|
-
* This SET is the whole of what this package contributes; the comparison itself is
|
|
30
|
-
* `@ultimat3/core`'s `compareDecimalText`. The split is the point — the text arrives in more than
|
|
31
|
-
* one package and the DECLARED KIND does not, so a caller with no column kinds
|
|
32
|
-
* (`@ultimat3/query`, whose `OrderKey` is a name and a direction) deliberately never asks: a
|
|
33
|
-
* `text` column holding `"10"` and `"9"` is ordered lexically by Postgres, and a comparator
|
|
34
|
-
* guessing "both sides look like decimals" would trade this disagreement for that one.
|
|
35
|
-
*/
|
|
36
|
-
const DECIMAL_TEXT: ReadonlySet<ColumnKind> = new Set<ColumnKind>(['bigint', 'numeric']);
|
|
37
|
-
|
|
38
20
|
const sign = <T extends number | bigint | string>(left: T, right: T): number =>
|
|
39
21
|
left < right ? -1 : left > right ? 1 : 0;
|
|
40
22
|
|
|
@@ -67,14 +49,18 @@ export const compareByKind = (
|
|
|
67
49
|
if (before !== undefined && after !== undefined) return sign(before, after);
|
|
68
50
|
}
|
|
69
51
|
if (left instanceof Date && right instanceof Date) return sign(left.getTime(), right.getTime());
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (exact !== undefined) return exact;
|
|
52
|
+
// A `uuid` is ordered as a VALUE — its sixteen bytes, which is the order of its lower-cased
|
|
53
|
+
// text. Compared as written, `…A` sorted before `…a` and the two are one value to Postgres.
|
|
54
|
+
if (kind === 'uuid' && typeof left === 'string' && typeof right === 'string') {
|
|
55
|
+
return sign(keyOf('uuid', left), keyOf('uuid', right));
|
|
75
56
|
}
|
|
76
|
-
|
|
77
|
-
|
|
57
|
+
// ONE numeric comparison, `numericOrder` — the same call an invariant's `gte`/`eq` makes
|
|
58
|
+
// (`expr.ts`), so a row cannot pass a rule and sort as though it had failed it. Decimal kinds are
|
|
59
|
+
// compared by their digits; anything else only when both sides ARE numbers, and a `number`
|
|
60
|
+
// against a `bigint` is one of those: `String(2) < String(10n)` is false and Postgres says true.
|
|
61
|
+
// `undefined` means the pair is not a numeric comparison, and it falls through as text.
|
|
62
|
+
const numeric = numericOrder(kind !== undefined && DECIMAL_TEXT.has(kind), left, right);
|
|
63
|
+
if (numeric !== undefined) return numeric;
|
|
78
64
|
return sign(String(left), String(right));
|
|
79
65
|
};
|
|
80
66
|
|
|
@@ -91,66 +77,25 @@ export const sameValueOfKind = (
|
|
|
91
77
|
right: unknown,
|
|
92
78
|
): boolean => {
|
|
93
79
|
if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime();
|
|
80
|
+
// An instant handed over as ISO text is the instant it names, as Postgres parses the bound
|
|
81
|
+
// parameter: `eq` against a stored `Date` compared an object to a string and matched no row.
|
|
82
|
+
if (kind === 'timestamptz') {
|
|
83
|
+
const stored = instantMicros(left);
|
|
84
|
+
const given = instantMicros(right);
|
|
85
|
+
if (stored !== undefined && given !== undefined) return stored === given;
|
|
86
|
+
}
|
|
94
87
|
if (kind === 'uuid' && typeof left === 'string' && typeof right === 'string') {
|
|
95
88
|
return keyOf('uuid', left) === keyOf('uuid', right);
|
|
96
89
|
}
|
|
90
|
+
// A `bigint()` or `decimal()` row is decimal TEXT and `=` is numeric there: `'2.50' = 2.5` and
|
|
91
|
+
// `'10' = 10` are both true in Postgres, and `===` answered false for each.
|
|
92
|
+
// One driver's `int8` is a JS `bigint` and the literal beside it a `number`: `5n = 5` is true.
|
|
93
|
+
// The same `numericOrder` `compareByKind` asks, so `=` and `order by` cannot disagree on a tie.
|
|
94
|
+
const numeric = numericOrder(kind !== undefined && DECIMAL_TEXT.has(kind), left, right);
|
|
95
|
+
if (numeric !== undefined) return numeric === 0;
|
|
97
96
|
return left === right;
|
|
98
97
|
};
|
|
99
98
|
|
|
100
|
-
const REGEX_SPECIAL = /[.*+?^${}()|[\]\\]/g;
|
|
101
|
-
|
|
102
|
-
const quote = (text: string): string => text.replace(REGEX_SPECIAL, '\\$&');
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Postgres answers a `LIKE` pattern ending in the escape character with `22025 — LIKE pattern must
|
|
106
|
-
* not end with escape character`, so a pattern that means nothing there means nothing here either.
|
|
107
|
-
* The pattern itself is never echoed: a filter value is app data, and this cause is rendered into
|
|
108
|
-
* a log line.
|
|
109
|
-
*/
|
|
110
|
-
const danglingEscape = (entityName: string): EntityError =>
|
|
111
|
-
new EntityError({
|
|
112
|
-
code: 'X_INVARIANT_VIOLATED',
|
|
113
|
-
cause: `${entityName}: a like pattern ends with a backslash, which is the escape character — Postgres answers that pattern with 22025 (LIKE pattern must not end with escape character)`,
|
|
114
|
-
fix: "double it — 'a\\\\' is the pattern that matches one literal backslash, and 'a\\%b' matches a literal %",
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* A SQL `LIKE` pattern as a regex, with Postgres' DEFAULT escape handling: `%` and `_` are the
|
|
119
|
-
* wildcards, a backslash escapes either (or itself), and everything else is literal.
|
|
120
|
-
*
|
|
121
|
-
* The backslash used to be quoted for the regex BEFORE the wildcards were expanded, so `'a\%b'`
|
|
122
|
-
* matched the literal `a%b` in Postgres and `a\<anything>b` here — one pattern, two meanings, and
|
|
123
|
-
* the driver that disagreed was the one every test runs against.
|
|
124
|
-
*
|
|
125
|
-
* A RUN of `%` is still one `.*`, not one each: `%%%…x` compiled to twenty adjacent `.*` groups,
|
|
126
|
-
* and an anchored regex with twenty of them takes exponential time to fail on a long value — a
|
|
127
|
-
* filter value forwarded from a search box is then a CPU stall in the process. Postgres reads a run
|
|
128
|
-
* of `%` as one wildcard too, so this is the two drivers agreeing rather than a defensive
|
|
129
|
-
* narrowing.
|
|
130
|
-
*/
|
|
131
|
-
const likePattern = (entityName: string, pattern: string): RegExp => {
|
|
132
|
-
let source = '';
|
|
133
|
-
let at = 0;
|
|
134
|
-
while (at < pattern.length) {
|
|
135
|
-
const char = pattern[at];
|
|
136
|
-
if (char === '\\') {
|
|
137
|
-
const escaped = pattern[at + 1];
|
|
138
|
-
if (escaped === undefined) throw danglingEscape(entityName);
|
|
139
|
-
source += quote(escaped);
|
|
140
|
-
at += 2;
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
if (char === '%') {
|
|
144
|
-
while (pattern[at] === '%') at += 1;
|
|
145
|
-
source += '.*';
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
source += char === '_' ? '.' : quote(char ?? '');
|
|
149
|
-
at += 1;
|
|
150
|
-
}
|
|
151
|
-
return new RegExp(`^${source}$`, 's');
|
|
152
|
-
};
|
|
153
|
-
|
|
154
99
|
/** One predicate against one stored row, in the meaning the Postgres driver compiles it to. */
|
|
155
100
|
export const matchesPredicate = <Row>(
|
|
156
101
|
entity: EntityCore<Row>,
|
|
@@ -218,7 +163,7 @@ export const matchesPredicate = <Row>(
|
|
|
218
163
|
// Real LIKE semantics, so `'draft%'` means "starts with" here exactly as it does in Postgres.
|
|
219
164
|
// Treating the pattern as a substring would make the two drivers disagree.
|
|
220
165
|
case 'like':
|
|
221
|
-
return !unknown() &&
|
|
166
|
+
return !unknown() && likeMatches(entity.$name, String(predicate.value), String(actual));
|
|
222
167
|
case 'is-null':
|
|
223
168
|
return isNull(actual);
|
|
224
169
|
case 'is-not-null':
|