@ultimat3/entity 23.0.0 → 25.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
  };
@@ -2,6 +2,7 @@
2
2
  // machine. Split from `errors.ts` at the 500-line ceiling; the codes and their titles stay there,
3
3
  // because a registry with two homes is a registry that disagrees with itself.
4
4
 
5
+ import { renderFixShellArg } from '@ultimat3/core';
5
6
  import { EntityError } from './errors';
6
7
 
7
8
  /**
@@ -15,7 +16,8 @@ export const searchUndeclared = (entityName: string): EntityError =>
15
16
  new EntityError({
16
17
  code: 'X_SEARCH_UNDECLARED',
17
18
  cause: `${entityName} has no searchable column, so there is no tsvector to match against`,
18
- fix: `add .searchable() to a text() column of ${entityName}, then: x db gen "search ${entityName}"`,
19
+ // Screened where it enters the command: the line is pasted, and `$(…)` runs inside "…".
20
+ fix: `add .searchable() to a text() column of ${entityName}, then: x db gen "search ${renderFixShellArg(entityName, '<entity>')}"`,
19
21
  });
20
22
 
21
23
  /**
@@ -0,0 +1,29 @@
1
+ // What one `indexes: [...]` entry of `entity(name, { indexes })` may say. Its own file so the
2
+ // declaration's shape is readable apart from `entity.ts`, which turns it into an `IndexDef`.
3
+
4
+ import type { IndexMethod } from '@ultimat3/db';
5
+ import type { Expr, InvariantColumns } from './expr';
6
+ import type { ColumnMap } from './types';
7
+
8
+ export interface IndexInit<C extends ColumnMap> {
9
+ readonly on: readonly (keyof C & string)[];
10
+ readonly order?: 'asc' | 'desc';
11
+ readonly unique?: boolean;
12
+ /** Partial index predicate, written in the same language as an invariant. */
13
+ readonly where?: (columns: InvariantColumns<C>) => Expr;
14
+ /**
15
+ * The access method. Omitted is `btree`, which is Postgres' own default and what every index
16
+ * declared before this existed is — so an entity that names none emits the statement it always
17
+ * emitted and nothing regenerates.
18
+ *
19
+ * `'gin'` is the one with a caller, and it is the whole point of the containment operators:
20
+ * measured on Postgres 16 over 20,000 rows, `tags @> …`, `tags <@ …`, `tags && …` and
21
+ * `data @> …` are each a Bitmap Index Scan with one and a Seq Scan without. The set is
22
+ * `@ultimat3/db`'s `INDEX_METHODS`, imported rather than restated — one declaration of one fact.
23
+ *
24
+ * Two Postgres rules ride with it and both are refused HERE, where the author is, rather than at
25
+ * `x db gen` or inside `ROLE=migrate` as the server's own syntax error: a GIN index cannot be
26
+ * unique and cannot order its keys.
27
+ */
28
+ readonly using?: IndexMethod;
29
+ }
package/src/index.ts CHANGED
@@ -5,6 +5,10 @@ export type { Infer } from '@ultimat3/schema';
5
5
  export { t } from '@ultimat3/schema';
6
6
  export type { AggregateFn } from './aggregate';
7
7
  export { AVG_SCALE } from './aggregate';
8
+ // Append-only entities: the same kind of seam, wrapped OUTSIDE `sealedRepo` by both drivers — a
9
+ // driver written outside this package returns through `appendOnlyRepo(entity, sealedRepo(…))`.
10
+ export type { AppendOnlySource } from './append-only';
11
+ export { appendOnlyRepo } from './append-only';
8
12
  export type { BatchIterator } from './batch';
9
13
  export type { MoneyColumns } from './column';
10
14
  export { columnName, moneyColumns, snake } from './column';
@@ -30,7 +34,7 @@ export { CROSS_TENANT_SCOPE, crossTenant } from './cross-tenant';
30
34
  export type { Database, DatabaseOptions, Driver, EntitySet } from './database';
31
35
  export { database, defaultDriver, memoryDriver } from './database';
32
36
  export type { DescribeInput } from './describe';
33
- export type { Entity, EntityCore, EntityInit, IndexInit } from './entity';
37
+ export type { Entity, EntityCore, EntityInit } from './entity';
34
38
  export { entity, SOFT_DELETE_COLUMN } from './entity';
35
39
  // The vocabulary an EXISTING schema needs. Separate from the blessed builders on purpose: those
36
40
  // are decisions this framework made for a table it was going to create, and these are the shapes
@@ -45,7 +49,6 @@ export type {
45
49
  // so a third-party driver has to be able to raise the same refusals the two shipped ones do.
46
50
  export {
47
51
  crossTenantDenied,
48
- dbDrift,
49
52
  ENTITY_ERROR_CODES,
50
53
  ENTITY_ERROR_TITLES,
51
54
  EntityError,
@@ -74,8 +77,13 @@ export {
74
77
  stateTransitionIllegal,
75
78
  stateUndeclared,
76
79
  } from './feature-errors';
80
+ export type { IndexInit } from './index-init';
77
81
  export type { Invariant, InvariantDef, InvariantKind } from './invariants';
78
82
  export { assertInvariants, invariant, MAX_ASSERTED_ROWS } from './invariants';
83
+ // How Postgres compares two values of one column, decided by the column's declared KIND. Exported
84
+ // because `@ultimat3/query`'s matcher and in-memory source ask the same question — and a second
85
+ // answer there is how a live window and the database returned one read in two orders.
86
+ export { compareByKind, sameValueOfKind } from './memory-match';
79
87
  export { memoryRepo, memoryTransactor } from './memory-repo';
80
88
  export type { StatementLoop } from './n-plus-one';
81
89
  export { N_PLUS_ONE_THRESHOLD, nPlusOne, preloadsFor } from './n-plus-one';
@@ -90,6 +98,8 @@ export { postgresTransactor } from './pg-transactor';
90
98
  // its own `pageSize` input against a hardcoded 10_000 is a second declaration of one number.
91
99
  export { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from './plan';
92
100
  export type { RelatedTable, RelatedTables } from './preload';
101
+ export type { PreloadOptions } from './preload-ceiling';
102
+ export { MAX_PRELOADED_ROWS } from './preload-ceiling';
93
103
  export type { Preloaded, ReadBuilder, Table } from './query';
94
104
  export { tableFor } from './query';
95
105
  // The client projection of an entity (plan 101): record type, record key, and the brand on
@@ -121,7 +131,6 @@ export type {
121
131
  FindByIdOptions,
122
132
  FindManyArgs,
123
133
  MemoryRepo,
124
- Page,
125
134
  Repo,
126
135
  RepoOptions,
127
136
  Transactor,
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
 
@@ -101,63 +87,15 @@ export const sameValueOfKind = (
101
87
  if (kind === 'uuid' && typeof left === 'string' && typeof right === 'string') {
102
88
  return keyOf('uuid', left) === keyOf('uuid', right);
103
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;
104
96
  return left === right;
105
97
  };
106
98
 
107
- const REGEX_SPECIAL = /[.*+?^${}()|[\]\\]/g;
108
-
109
- const quote = (text: string): string => text.replace(REGEX_SPECIAL, '\\$&');
110
-
111
- /**
112
- * Postgres answers a `LIKE` pattern ending in the escape character with `22025 — LIKE pattern must
113
- * not end with escape character`, so a pattern that means nothing there means nothing here either.
114
- * The pattern itself is never echoed: a filter value is app data, and this cause is rendered into
115
- * a log line.
116
- */
117
- const danglingEscape = (entityName: string): EntityError =>
118
- new EntityError({
119
- code: 'X_INVARIANT_VIOLATED',
120
- 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)`,
121
- fix: "double it — 'a\\\\' is the pattern that matches one literal backslash, and 'a\\%b' matches a literal %",
122
- });
123
-
124
- /**
125
- * A SQL `LIKE` pattern as a regex, with Postgres' DEFAULT escape handling: `%` and `_` are the
126
- * wildcards, a backslash escapes either (or itself), and everything else is literal.
127
- *
128
- * The backslash used to be quoted for the regex BEFORE the wildcards were expanded, so `'a\%b'`
129
- * matched the literal `a%b` in Postgres and `a\<anything>b` here — one pattern, two meanings, and
130
- * the driver that disagreed was the one every test runs against.
131
- *
132
- * A RUN of `%` is still one `.*`, not one each: `%%%…x` compiled to twenty adjacent `.*` groups,
133
- * and an anchored regex with twenty of them takes exponential time to fail on a long value — a
134
- * filter value forwarded from a search box is then a CPU stall in the process. Postgres reads a run
135
- * of `%` as one wildcard too, so this is the two drivers agreeing rather than a defensive
136
- * narrowing.
137
- */
138
- const likePattern = (entityName: string, pattern: string): RegExp => {
139
- let source = '';
140
- let at = 0;
141
- while (at < pattern.length) {
142
- const char = pattern[at];
143
- if (char === '\\') {
144
- const escaped = pattern[at + 1];
145
- if (escaped === undefined) throw danglingEscape(entityName);
146
- source += quote(escaped);
147
- at += 2;
148
- continue;
149
- }
150
- if (char === '%') {
151
- while (pattern[at] === '%') at += 1;
152
- source += '.*';
153
- continue;
154
- }
155
- source += char === '_' ? '.' : quote(char ?? '');
156
- at += 1;
157
- }
158
- return new RegExp(`^${source}$`, 's');
159
- };
160
-
161
99
  /** One predicate against one stored row, in the meaning the Postgres driver compiles it to. */
162
100
  export const matchesPredicate = <Row>(
163
101
  entity: EntityCore<Row>,
@@ -225,7 +163,7 @@ export const matchesPredicate = <Row>(
225
163
  // Real LIKE semantics, so `'draft%'` means "starts with" here exactly as it does in Postgres.
226
164
  // Treating the pattern as a substring would make the two drivers disagree.
227
165
  case 'like':
228
- return !unknown() && likePattern(entity.$name, String(predicate.value)).test(String(actual));
166
+ return !unknown() && likeMatches(entity.$name, String(predicate.value), String(actual));
229
167
  case 'is-null':
230
168
  return isNull(actual);
231
169
  case 'is-not-null':
@@ -3,11 +3,13 @@
3
3
  // that passes here means something about Postgres.
4
4
  //
5
5
  // Split from `repo.ts` when that file passed the 500-line ceiling. What stays there is the
6
- // CONTRACT — `Repo`, `Page`, `FindManyArgs`, `Transactor` — which `postgresRepo` implements too
7
- // and which nothing about storing rows in a `Map` belongs in.
6
+ // CONTRACT — `Repo`, `FindManyArgs`, `Transactor` (the page is core's `Page`) — which `postgresRepo`
7
+ // implements too and which nothing about storing rows in a `Map` belongs in.
8
8
 
9
+ import { pageOf } from '@ultimat3/core';
9
10
  import { aggregateColumnOf } from './aggregate';
10
11
  import { foldAggregate } from './aggregate-fold';
12
+ import { appendOnlyRepo } from './append-only';
11
13
  import { keyOf } from './batch-read';
12
14
  import { conflictKeyOf, conflictKeys, upsertPlan } from './bulk-write';
13
15
  import { entityNow } from './clock';
@@ -219,11 +221,10 @@ export const memoryRepo = <Row>(
219
221
  const page = found.slice(start, start + plan.limit);
220
222
  const last = page.at(-1);
221
223
  const more = start + page.length < found.length;
222
- return {
223
- rows: page,
224
- nextCursor:
225
- more && last !== undefined ? cursorFor(entity, plan, last, storeKey(last)) : null,
226
- };
224
+ return pageOf(
225
+ page,
226
+ more && last !== undefined ? cursorFor(entity, plan, last, storeKey(last)) : null,
227
+ );
227
228
  },
228
229
 
229
230
  async insert(values, options) {
@@ -426,8 +427,12 @@ export const memoryRepo = <Row>(
426
427
  },
427
428
  };
428
429
  // The memory driver seals too: it stores what Postgres would, so a test that passes here is not
429
- // passing over plaintext production never holds.
430
- return sealedRepo<Row, MemoryRepo<Row>>(entity, repo);
430
+ // passing over plaintext production never holds. Append-only outermost, in both drivers: a
431
+ // refused rewrite seals nothing and sends nothing.
432
+ return appendOnlyRepo<Row, MemoryRepo<Row>>(
433
+ entity,
434
+ sealedRepo<Row, MemoryRepo<Row>>(entity, repo),
435
+ );
431
436
  };
432
437
 
433
438
  let txCounter = 0;
@@ -1,5 +1,5 @@
1
1
  // Single responsibility: the uniqueness Postgres enforces, enforced by the in-memory driver too —
2
- // the primary key and every non-partial `unique` index. Memory silently REPLACED a row on a
2
+ // the primary key and every non-partial unique, an index or an invariant (`unique-constraints.ts`). Memory silently REPLACED a row on a
3
3
  // duplicate key and ignored `unique()` entirely, so a signup race that is a 409 in production was
4
4
  // a quiet overwrite under `x dev` and in every app test.
5
5
 
@@ -7,6 +7,7 @@ import { driverError } from '@ultimat3/db';
7
7
  import type { EntityCore } from './entity';
8
8
  import { bindValues } from './pg-row';
9
9
  import type { RowPatch } from './types';
10
+ import { uniqueConstraints } from './unique-constraints';
10
11
 
11
12
  /** The refusal Postgres answers `23505` with, in the shape `driverError` gives it there. */
12
13
  export const uniqueViolation = (entity: EntityCore, constraint: string): Error =>
@@ -25,16 +26,16 @@ const cellOf = (value: unknown): string | undefined => {
25
26
  };
26
27
 
27
28
  /**
28
- * The first unique index `candidate` collides with among `others`, or `undefined`. Partial indexes
29
- * are skipped: their predicate is SQL this driver cannot evaluate, and a guess would refuse rows
30
- * Postgres accepts — the one direction that must never happen.
29
+ * The first unique constraint `candidate` collides with among `others`, or `undefined`. Partial
30
+ * ones are skipped: their predicate is SQL this driver cannot evaluate, and a guess would refuse
31
+ * rows Postgres accepts — the one direction that must never happen.
31
32
  */
32
33
  export const uniqueClash = <Row>(
33
34
  entity: EntityCore<Row>,
34
35
  candidate: Row,
35
36
  others: Iterable<Row>,
36
37
  ): string | undefined => {
37
- const unique = entity.$indexes.filter((index) => index.unique && index.where === undefined);
38
+ const unique = uniqueConstraints(entity);
38
39
  if (unique.length === 0) return undefined;
39
40
  const bound = (row: Row) => bindValues(entity, row as unknown as RowPatch<Row>);
40
41
  const incoming = bound(candidate);
@@ -44,7 +45,7 @@ export const uniqueClash = <Row>(
44
45
  for (const other of others) {
45
46
  const stored = bound(other);
46
47
  if (index.columns.every((name, at) => cellOf(stored.get(name)) === key[at]))
47
- return index.name;
48
+ return index.name();
48
49
  }
49
50
  }
50
51
  return undefined;