@ultimat3/entity 23.0.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.
@@ -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':
@@ -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;
@@ -0,0 +1,57 @@
1
+ // Single responsibility: how a NUMBER a column holds is compared — by the column's declared kind,
2
+ // never by the JS type in hand. `bigint()` and `decimal()` rows are decimal TEXT, so a `typeof`
3
+ // branch answers a different question from the one Postgres answers for the same column.
4
+
5
+ import { compareDecimalText } from '@ultimat3/core';
6
+ import type { ColumnKind } from './types';
7
+
8
+ /**
9
+ * The kinds whose ROW VALUE is a decimal string (`columns-data.ts`): a JS `bigint` is what
10
+ * `JSON.stringify` throws on and a `number` loses digits past 2^53. This SET is what this package
11
+ * contributes; the comparison is `@ultimat3/core`'s `compareDecimalText`, which a caller with no
12
+ * column kinds (`@ultimat3/query`) deliberately never asks — a `text` column holding `"10"` and
13
+ * `"9"` is ordered lexically by Postgres.
14
+ */
15
+ export const DECIMAL_TEXT: ReadonlySet<ColumnKind> = new Set<ColumnKind>(['bigint', 'numeric']);
16
+
17
+ const isNumber = (value: unknown): value is number | bigint =>
18
+ typeof value === 'bigint' || (typeof value === 'number' && !Number.isNaN(value));
19
+
20
+ /**
21
+ * A finite operand as PLAIN decimal digits. `String(1e21)` is `"1e+21"` and `String(1e-7)` is
22
+ * `"1e-7"`: both are literals Postgres reads as exact numerics and neither is text
23
+ * `compareDecimalText` reads, so the exponent is applied to the digits here.
24
+ */
25
+ export const plainDecimal = (value: number | bigint): string => {
26
+ const text = String(value);
27
+ const parts = /^(-?)(\d+)(?:\.(\d+))?e([+-]\d+)$/.exec(text);
28
+ if (parts === null) return text;
29
+ const [, sign = '', whole = '', fraction = '', exponent = '0'] = parts;
30
+ const shift = Number(exponent);
31
+ const digits = whole + fraction;
32
+ const point = whole.length + shift;
33
+ if (point <= 0) return `${sign}0.${'0'.repeat(-point)}${digits}`;
34
+ if (point >= digits.length) return `${sign}${digits.padEnd(point, '0')}`;
35
+ return `${sign}${digits.slice(0, point)}.${digits.slice(point)}`;
36
+ };
37
+
38
+ /** Either side may be the JS number: a cursor revives one, and one driver returns one. */
39
+ const plain = (value: unknown): unknown => (isNumber(value) ? plainDecimal(value) : value);
40
+
41
+ /**
42
+ * `-1`, `0` or `1` for a column value against an operand — or `undefined` when the pair is not a
43
+ * numeric comparison at all (a NULL, a string in an integer column), which every caller reads as
44
+ * "the rule does not hold".
45
+ *
46
+ * `decimal` is the COLUMN's declared kind, not a guess from the value: a `text` column holding
47
+ * digits never reaches here as a number.
48
+ */
49
+ export const numericOrder = (
50
+ decimal: boolean,
51
+ value: unknown,
52
+ operand: unknown,
53
+ ): number | undefined => {
54
+ if (decimal) return compareDecimalText(plain(value), plain(operand));
55
+ if (!isNumber(value) || !isNumber(operand)) return undefined;
56
+ return value < operand ? -1 : value > operand ? 1 : 0;
57
+ };
package/src/pg-driver.ts CHANGED
@@ -34,6 +34,7 @@ import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
34
34
  import { notFound, repoClientPinned } from './errors';
35
35
  import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
36
36
  import { forgetPreloaded, tagSiblings } from './jit-preload';
37
+ import { judgedWrite } from './judged-write';
37
38
  import { bindValues, decodeRow, type PhysicalRow, physicalName, sortPrecision } from './pg-row';
38
39
  import { countStatement, type ReadShape, selectStatement } from './pg-sql';
39
40
  import {
@@ -45,6 +46,7 @@ import {
45
46
  type GroupRow,
46
47
  type MoneyUnitRow,
47
48
  } from './pg-sql-aggregate';
49
+ import { postgresTransactor } from './pg-transactor';
48
50
  import {
49
51
  type ConflictTarget,
50
52
  deleteStatement,
@@ -85,21 +87,25 @@ export const postgresRepo = <Row>(
85
87
  ): Repo<Row> => {
86
88
  /**
87
89
  * The one place a connection is chosen, which is why the transaction guard is here and not on
88
- * each method. Unpinned, `db()` answers with the open transaction when there is one — that is
89
- * how a repository call inside `withTransaction` joins it without being told. Pinned, it cannot:
90
- * `withTransaction` ran `BEGIN` on a connection IT reserved, and a statement sent straight to
91
- * `config.client` takes a different connection out of the pool, so the write commits whatever
92
- * the transaction decides and the read cannot see what the transaction has written. Refused
93
- * rather than resolved — a `DbTx` does not name the client it was opened on, so this layer
94
- * cannot even tell whether the two are the same database.
90
+ * each method. Unpinned, `db()` answers with the open transaction when there is one. Pinned, a
91
+ * statement sent straight to `config.client` takes another connection out of that pool — so it
92
+ * goes through the open transaction instead when that transaction was opened ON this client
93
+ * (`tx.origin`): one database, one reservation, and the repository joins it. Any other open
94
+ * transaction is a second database in one scope, and is refused rather than written around.
95
95
  */
96
96
  const client = (): DbClient => {
97
97
  const pinned = config.client;
98
98
  if (pinned === undefined) return db();
99
- if (currentTx() !== undefined) throw repoClientPinned(entity.$name);
100
- return pinned;
99
+ const tx = currentTx();
100
+ if (tx === undefined) return pinned;
101
+ if (tx.origin === pinned) return tx;
102
+ throw repoClientPinned(entity.$name);
101
103
  };
102
104
 
105
+ /** The statement and its app-side judgement on one connection — see `judged-write.ts`. */
106
+ const judged = <T>(work: (send: DbClient) => Promise<T>): Promise<T> =>
107
+ judgedWrite(entity, client(), config.client, work);
108
+
103
109
  /**
104
110
  * Every statement a repository call sends carries the entity and the operation that compiled it,
105
111
  * which is what turns "50× `select … where "id" = $1`" into "50× `findById` on `members`" in a
@@ -298,20 +304,17 @@ export const postgresRepo = <Row>(
298
304
  if (current === null) throw notFound(entity.$name, id);
299
305
  return current;
300
306
  }
301
- const written = await attributed(op, () =>
302
- writing(() =>
303
- client().one<PhysicalRow>(
304
- updateStatement(entity, plan, values, shapeOf(options ?? {}), true),
305
- ),
306
- ),
307
- );
308
- if (written === null) throw notFound(entity.$name, id);
309
- const after = decodeRow(entity, written);
307
+ const statement = updateStatement(entity, plan, values, shapeOf(options ?? {}), true);
310
308
  // SQL-expressible invariants are CHECK constraints, so Postgres already rejected the
311
- // statement. A JS-only one (`kind: 'assert'`, `sql: null`) can only be judged on the
312
- // result — inside `withTransaction` the throw takes the row with it.
313
- entity.$assert(after);
314
- return after;
309
+ // statement. A JS-only one can only be judged on the result — which is why `judged` sends
310
+ // the statement inside a transaction the refusal rolls back.
311
+ return judged(async (send) => {
312
+ const written = await attributed(op, () => writing(() => send.one<PhysicalRow>(statement)));
313
+ if (written === null) throw notFound(entity.$name, id);
314
+ const after = decodeRow(entity, written);
315
+ entity.$assert(after);
316
+ return after;
317
+ });
315
318
  },
316
319
 
317
320
  async delete(id, options) {
@@ -368,12 +371,13 @@ export const postgresRepo = <Row>(
368
371
  const rows = Number(matched?.count ?? 0);
369
372
  if (rows > MAX_ASSERTED_ROWS) throw assertedRowsTooMany(entity.$name, op, rows);
370
373
  const statement = updateStatement(entity, plan, values, shape, true);
371
- // Inside `withTransaction` a failed assert takes the whole statement with it.
372
- const written = await attributed(op, () =>
373
- writing(() => client().query<PhysicalRow>(statement)),
374
- );
375
- for (const row of written) entity.$assert(decodeRow(entity, row));
376
- return written.length;
374
+ return judged(async (send) => {
375
+ const written = await attributed(op, () =>
376
+ writing(() => send.query<PhysicalRow>(statement)),
377
+ );
378
+ for (const row of written) entity.$assert(decodeRow(entity, row));
379
+ return written.length;
380
+ });
377
381
  },
378
382
 
379
383
  async count(args = {}) {
@@ -482,4 +486,6 @@ export const postgresRepo = <Row>(
482
486
  */
483
487
  export const postgresDriver = (config: PostgresDriverOptions = {}): Driver => ({
484
488
  repo: <Row>(entity: EntityCore<Row>) => postgresRepo(entity, config),
489
+ transactor: () =>
490
+ postgresTransactor(config.client === undefined ? {} : { client: config.client }),
485
491
  });
package/src/pg-sql.ts CHANGED
@@ -123,8 +123,12 @@ const predicateSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFr
123
123
  // (measured), and the qualified spelling is immune to any client that reads `?` as a
124
124
  // placeholder and to a search_path that shadows the operator. Postgres matches the index
125
125
  // against it identically — verified in the same EXPLAIN run.
126
+ //
127
+ // A key is a STRING or the test matches no row — `jsonHasKey`'s rule (`containment.ts`).
128
+ // `String(value)` bound `"null"` for a null operand and `"1"` for the number 1, so Postgres
129
+ // answered rows the memory driver never did.
126
130
  case 'has-key':
127
- return sql`${column} operator(pg_catalog.?) ${String(predicate.value)}`;
131
+ return typeof value === 'string' ? sql`${column} operator(pg_catalog.?) ${value}` : NEVER;
128
132
  }
129
133
  };
130
134
 
package/src/plan.ts CHANGED
@@ -5,7 +5,13 @@
5
5
 
6
6
  import { assertSeekable } from './cursor';
7
7
  import type { EntityCore } from './entity';
8
- import { EntityError, invariantViolated, patchEmpty, writeUnfiltered } from './errors';
8
+ import {
9
+ describeCommand,
10
+ EntityError,
11
+ invariantViolated,
12
+ patchEmpty,
13
+ writeUnfiltered,
14
+ } from './errors';
9
15
  import type { FindManyArgs, RepoOptions } from './repo';
10
16
  import type { Predicate, QueryPlan, SortKey } from './tenancy';
11
17
  import { scopedPlan } from './tenancy';
@@ -92,8 +98,8 @@ export const assertFinitePageSize = (entityName: string, rows: number): number =
92
98
  if (Number.isSafeInteger(rows) && rows >= 1 && rows <= MAX_PAGE_SIZE) return rows;
93
99
  throw new EntityError({
94
100
  code: 'X_INVARIANT_VIOLATED',
95
- cause: `${entityName}.limit(${String(rows)}) — a page is a whole number of rows, at least one and at most ${MAX_PAGE_SIZE}`,
96
- fix: `${entityName}.limit(${DEFAULT_PAGE_SIZE}) # or, to visit every row the filter matches, ${entityName}.inBatches(1000) — one page per statement, and never a table in memory`,
101
+ cause: `${entityName}.limit(${String(rows)}) — a page is a whole number of rows, at least one and at most ${MAX_PAGE_SIZE}. Write .limit(${DEFAULT_PAGE_SIZE}) on that chain, or .inBatches(1000) to visit every row the filter matches — one page per statement, and never a table in memory`,
102
+ fix: `${describeCommand(entityName)} # the entity whose chain names the page: correct its limit(), or read it with inBatches()`,
97
103
  });
98
104
  };
99
105
 
@@ -0,0 +1,75 @@
1
+ // Single responsibility: the bound on a preload — the ceiling a chain declares (or the default),
2
+ // resolved where `preload()` is called, and the refusal past it. `preload.ts` reads under it.
3
+
4
+ import { describeValue } from '@ultimat3/schema';
5
+ import { describeCommand, EntityError } from './errors';
6
+ import { MAX_PAGE_SIZE } from './plan';
7
+ import type { Relation } from './relations';
8
+ import { relationNamed } from './relations';
9
+
10
+ /**
11
+ * How many related rows ONE relation may attach to one page when the chain declares no ceiling of
12
+ * its own: the largest page a read may ask for. A preload holds every related row in memory, and
13
+ * a `hasMany` paged until the relation was exhausted — a parent with a million children was a
14
+ * million rows from a chain that named no number.
15
+ */
16
+ export const MAX_PRELOADED_ROWS = MAX_PAGE_SIZE;
17
+
18
+ export interface PreloadOptions {
19
+ /** The ceiling for this relation on this chain. Past it the read is refused, never truncated. */
20
+ readonly max?: number;
21
+ }
22
+
23
+ /** A relation as a chain named it: the edge, and the ceiling it is read under. */
24
+ export type PreloadRelation = Relation & { readonly max: number };
25
+
26
+ /**
27
+ * The relation a `preload()` call names, resolved on the chain — an unknown name and a ceiling
28
+ * that is not a count both fail where they were written, not one page later.
29
+ */
30
+ export const preloadOf = (
31
+ entityName: string,
32
+ name: string,
33
+ options: PreloadOptions | undefined,
34
+ ): PreloadRelation => {
35
+ const relation = relationNamed(entityName, name);
36
+ const max = options?.max === undefined ? MAX_PRELOADED_ROWS : options.max;
37
+ if (!Number.isSafeInteger(max) || max < 1) {
38
+ throw new EntityError({
39
+ code: 'X_INVARIANT_VIOLATED',
40
+ cause: `${entityName}.preload('${relation.name}', { max }) — the ceiling is a whole number of related rows, at least one; got ${describeValue(max)}. Write .preload('${relation.name}', { max: ${MAX_PRELOADED_ROWS} }) on that chain — the default — or the most rows one request may hold for this relation`,
41
+ fix: `${describeCommand(entityName)} # the entity whose chain preloads '${relation.name}': correct the { max } on that call`,
42
+ });
43
+ }
44
+ return { ...relation, max };
45
+ };
46
+
47
+ /**
48
+ * The chain's preloads after one more `preload()` call. A relation named twice is ONE statement,
49
+ * and its ceiling is one setting: a later call that STATES `max` replaces the earlier one, exactly
50
+ * as a second `.limit()` replaces the first; a later call that states none leaves it. Name-only
51
+ * dedup kept the first and silently dropped a stricter ceiling written after it.
52
+ */
53
+ export const preloadsWith = (
54
+ held: readonly PreloadRelation[],
55
+ entityName: string,
56
+ name: string,
57
+ options: PreloadOptions | undefined,
58
+ ): readonly PreloadRelation[] => {
59
+ const resolved = preloadOf(entityName, name, options);
60
+ if (!held.some((one) => one.name === resolved.name)) return [...held, resolved];
61
+ if (options?.max === undefined) return held;
62
+ return held.map((one) => (one.name === resolved.name ? resolved : one));
63
+ };
64
+
65
+ /**
66
+ * Refused, never truncated: an array one row short reads as "these are the children", and nothing
67
+ * at the call site could tell. The cause names both ways out — the doubled ceiling to write on the
68
+ * chain, or the read that holds one page at a time.
69
+ */
70
+ export const preloadTooMany = (entityName: string, relation: PreloadRelation): EntityError =>
71
+ new EntityError({
72
+ code: 'X_INVARIANT_VIOLATED',
73
+ cause: `${entityName}.preload('${relation.name}') matched more than ${relation.max} ${relation.to} rows for one page — a preload holds every related row in memory, and that is past its ceiling. Raise it on that chain with .preload('${relation.name}', { max: ${relation.max * 2} }), or read ${relation.to} a page at a time, filtered on ${relation.remoteKey}, with inBatches(1000)`,
74
+ fix: `${describeCommand(entityName)} # the entity whose chain preloads '${relation.name}': raise its { max }, or page ${relation.to} with inBatches()`,
75
+ });
package/src/preload.ts CHANGED
@@ -11,6 +11,7 @@ import { keyOf, MAX_IDS_PER_STATEMENT, statementChunks } from './batch-read';
11
11
  import { valueAt } from './cursor';
12
12
  import type { EntityCore } from './entity';
13
13
  import { EntityError } from './errors';
14
+ import { type PreloadRelation, preloadTooMany } from './preload-ceiling';
14
15
  import type { Relation } from './relations';
15
16
  import type { Repo } from './repo';
16
17
  import { copyRow } from './sealed';
@@ -73,11 +74,14 @@ const tenantScope = (
73
74
  * genuinely full. A `belongsTo` over a page of 50 is exactly one statement.
74
75
  *
75
76
  * The page loop is what keeps a `hasMany` honest: a relation with more rows than one page holds
76
- * costs another statement rather than silently returning the first page of them.
77
+ * costs another statement rather than silently returning the first page of them. And the ceiling
78
+ * is what keeps the loop finite: it asks for at most ONE row past `relation.max`, which is the
79
+ * row that proves the refusal, and reads nothing after it.
77
80
  */
78
81
  const relatedRows = async (
82
+ source: string,
79
83
  target: RelatedTable,
80
- relation: Relation,
84
+ relation: PreloadRelation,
81
85
  values: readonly unknown[],
82
86
  scope: readonly Predicate[],
83
87
  ): Promise<readonly unknown[]> => {
@@ -87,10 +91,11 @@ const relatedRows = async (
87
91
  do {
88
92
  const page = await target.repo.findMany({
89
93
  where: [{ column: relation.remoteKey, op: 'in', value: chunk }, ...scope],
90
- limit: MAX_IDS_PER_STATEMENT,
94
+ limit: Math.min(MAX_IDS_PER_STATEMENT, relation.max + 1 - rows.length),
91
95
  cursor,
92
96
  });
93
97
  rows.push(...page.rows);
98
+ if (rows.length > relation.max) throw preloadTooMany(source, relation);
94
99
  cursor = page.nextCursor;
95
100
  } while (cursor !== null);
96
101
  }
@@ -99,15 +104,16 @@ const relatedRows = async (
99
104
 
100
105
  /** Related rows filed under the key they attach to — in the order the read returned them. */
101
106
  const indexed = async (
107
+ source: string,
102
108
  target: RelatedTable,
103
- relation: Relation,
109
+ relation: PreloadRelation,
104
110
  kind: string,
105
111
  values: readonly unknown[],
106
112
  scope: readonly Predicate[],
107
113
  ): Promise<ReadonlyMap<string, readonly unknown[]>> => {
108
114
  const index = new Map<string, unknown[]>();
109
115
  if (values.length === 0) return index;
110
- const found = await relatedRows(target, relation, values, scope);
116
+ const found = await relatedRows(source, target, relation, values, scope);
111
117
  for (const row of found) {
112
118
  const at = keyOf(kind, valueAt(row, relation.remoteKey));
113
119
  const bucket = index.get(at);
@@ -135,7 +141,7 @@ export interface PreloadRead<Source> {
135
141
  readonly entity: EntityCore<Source>;
136
142
  /** `undefined` for a table built by hand — `tableFor(entity, repo)` reaches no other table. */
137
143
  readonly related: RelatedTables | undefined;
138
- readonly relations: readonly Relation[];
144
+ readonly relations: readonly PreloadRelation[];
139
145
  /** The page's own predicates: what the related read inherits its tenant scope from. */
140
146
  readonly where: readonly Predicate[];
141
147
  }
@@ -165,7 +171,14 @@ export const preloaded = async <Source, Row>(
165
171
  const kind = read.entity.$columns[relation.localKey]?.$meta.kind ?? '';
166
172
  const keys = source.map((row) => valueAt(row, relation.localKey));
167
173
  const scope = tenantScope(read.entity, target.entity, read.where);
168
- const index = await indexed(target, relation, kind, distinctKeys(kind, keys), scope);
174
+ const index = await indexed(
175
+ read.entity.$name,
176
+ target,
177
+ relation,
178
+ kind,
179
+ distinctKeys(kind, keys),
180
+ scope,
181
+ );
169
182
  return { relation, kind, keys, index };
170
183
  }),
171
184
  );
package/src/query.ts CHANGED
@@ -10,10 +10,8 @@ import { entityNow } from './clock';
10
10
  import type { EntityCore } from './entity';
11
11
  import { searchUndeclared } from './feature-errors';
12
12
  import { assertFinitePageSize, DEFAULT_PAGE_SIZE, namedColumns } from './plan';
13
- import type { RelatedTables } from './preload';
14
- import { preloaded } from './preload';
15
- import type { Relation } from './relations';
16
- import { relationNamed } from './relations';
13
+ import { preloaded, type RelatedTables } from './preload';
14
+ import { type PreloadOptions, type PreloadRelation, preloadsWith } from './preload-ceiling';
17
15
  import type { Page, Repo, RepoOptions, UpsertArgs } from './repo';
18
16
  import { copyRow, pickRow } from './sealed';
19
17
  import { SEARCH_PROPERTY } from './search';
@@ -86,15 +84,18 @@ export interface ReadBuilder<Row, S extends SealedKeySet = NoSealedKeys> {
86
84
  /**
87
85
  * One relation, read for the whole page in one extra `where <key> in (…)` and attached to every
88
86
  * row under its own name — the eager form of the batching a point lookup does for itself, and
89
- * the line an N+1 warning names. The relation is the `references()` already declared, so there
90
- * is nothing to declare here; a name no foreign key produces is `X_PRELOAD_UNKNOWN_RELATION`,
91
- * listing the ones that exist.
87
+ * the line an N+1 warning names. The relation is the `references()` already declared; a name no
88
+ * foreign key produces is `X_PRELOAD_UNKNOWN_RELATION`. `max` is the most related rows it may
89
+ * attach to one page (default `MAX_PRELOADED_ROWS`), refused past it and never truncated.
92
90
  *
93
91
  * A `belongsTo` attaches the row or `null`, a `hasMany` an array — always present, so "no
94
92
  * author" never reads like "nobody preloaded the author". Attached after the projection: a
95
93
  * `select()` narrows the columns, never the relations.
96
94
  */
97
- preload<Name extends string>(relation: Name): ReadBuilder<Row & Preloaded<Name>, S>;
95
+ preload<Name extends string>(
96
+ relation: Name,
97
+ options?: PreloadOptions,
98
+ ): ReadBuilder<Row & Preloaded<Name>, S>;
98
99
  /**
99
100
  * Every row the chain matches, `size` at a time and one statement per batch — the terminal a
100
101
  * `for await` consumes instead of holding a whole table in memory. A batch is the page `page()`
@@ -252,7 +253,7 @@ interface State {
252
253
  readonly cursor: string | null;
253
254
  readonly select: readonly string[] | undefined;
254
255
  /** Resolved when `preload()` was called, so an unknown name fails at the chain and not a page later. */
255
- readonly preload: readonly Relation[];
256
+ readonly preload: readonly PreloadRelation[];
256
257
  }
257
258
 
258
259
  const EMPTY: State = {
@@ -356,15 +357,14 @@ const builder = <Source, Row>(
356
357
  );
357
358
  },
358
359
 
359
- preload<Name extends string>(relation: Name) {
360
+ preload<Name extends string>(relation: Name, options?: PreloadOptions) {
360
361
  // Resolved here, so a name no foreign key produces fails on the chain rather than one page
361
- // later — and naming one relation twice is one statement, not two identical ones.
362
- const resolved = relationNamed(entity.$name, relation);
363
- const already = state.preload.some((held) => held.name === resolved.name);
362
+ // later; naming one relation twice is one statement, under the ceiling stated last.
363
+ const preload = preloadsWith(state.preload, entity.$name, relation, options);
364
364
  return builder<Source, Row & Preloaded<Name>>(
365
365
  entity,
366
366
  repo,
367
- { ...state, preload: already ? state.preload : [...state.preload, resolved] },
367
+ { ...state, preload },
368
368
  // The relation is attached after the projection, so `pick` is unchanged and the row type
369
369
  // is the only thing that grows — one cast, where a runtime name becomes a static one.
370
370
  pick as (row: Source) => Row & Preloaded<Name>,
@@ -74,7 +74,11 @@ export const makeSealedColumn = <T extends Text, Optional extends boolean, Looku
74
74
  };
75
75
 
76
76
  /** What `.sealed(options)` turns the declared meta into. */
77
- const sealedMeta = (meta: ColumnMeta, options: SealedOptions): ColumnMeta => {
77
+ const sealedMeta = (
78
+ meta: ColumnMeta,
79
+ options: SealedOptions,
80
+ plaintext: (value: unknown) => unknown,
81
+ ): ColumnMeta => {
78
82
  if (meta.default !== undefined) {
79
83
  refuseSealed(
80
84
  'given a default',
@@ -96,7 +100,7 @@ const sealedMeta = (meta: ColumnMeta, options: SealedOptions): ColumnMeta => {
96
100
  );
97
101
  }
98
102
  const { check: _check, length: _length, ...rest } = meta;
99
- return { ...rest, sealed: { lookup } };
103
+ return { ...rest, sealed: { lookup, plaintext } };
100
104
  };
101
105
 
102
106
  export const makeTextColumn = <T extends Text, Optional extends boolean>(
@@ -114,6 +118,6 @@ export const makeTextColumn = <T extends Text, Optional extends boolean>(
114
118
  default: (value) => again(base.default(value)),
115
119
  column: (name) => again(base.column(name)),
116
120
  sealed: (options = {}) =>
117
- makeSealedColumn(sealedMeta(meta, options), sealedParse(parse), optional),
121
+ makeSealedColumn(sealedMeta(meta, options, parse), sealedParse(parse), optional),
118
122
  };
119
123
  };