@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/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(...)` in SQL, `.trim()` in the app — the same rule, both sides. */
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
- return typeof value === 'string' ? value.trim() : value;
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
- one(
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
- (value) => (typeof value === 'number' || typeof value === 'bigint') && value >= bound,
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
- ? sameAs(term, other)
246
- : one(
247
- `${term.label} must equal ${literal(other)}`,
248
- (resolve) => `${term.sql(resolve)} = ${literal(other)}`,
249
- (value) => value === other,
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
- (row) => left.read(row) === right.read(row),
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
- properties: readonly string[],
452
+ columns: C,
410
453
  ): InvariantColumns<C> => {
411
- const known = new Set(properties);
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
- if (!known.has(property)) {
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
- * The microsecond epoch of whatever a sort key is holding: a decoded row's `Date` (milliseconds,
61
- * so the last three digits are zero), a value already counted in microseconds, or the decimal a
62
- * cursor carries. `undefined` for anything else, so a caller decides rather than guessing at `0`.
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 === 'string' && /^-?\d+$/.test(value)) return BigInt(value);
71
- return undefined;
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: `for await (const rows of ${entityName}.where(filter).inBatches(1000)) { … } # one page per statement, judged one page at a time`,
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
+ };
@@ -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
- if (kind !== undefined && DECIMAL_TEXT.has(kind)) {
71
- // `undefined` when either side is not a plain decimal — that pair is not a numeric comparison,
72
- // so it falls through to the branches below rather than being guessed at.
73
- const exact = compareDecimalText(left, right);
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
- if (typeof left === 'number' && typeof right === 'number') return sign(left, right);
77
- if (typeof left === 'bigint' && typeof right === 'bigint') return sign(left, right);
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() && likePattern(entity.$name, String(predicate.value)).test(String(actual));
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':