@ultimat3/entity 22.15.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +72 -51
- package/README.md +145 -21
- package/package.json +6 -5
- package/src/aggregate.ts +31 -22
- package/src/bulk-write.ts +4 -13
- package/src/column.ts +36 -1
- package/src/columns-data.ts +24 -3
- package/src/columns-scalar.ts +13 -6
- package/src/columns.ts +8 -1
- package/src/containment.ts +15 -2
- package/src/database.ts +9 -2
- package/src/entity-error.ts +45 -20
- package/src/entity.ts +81 -18
- package/src/errors.ts +16 -42
- package/src/expr.ts +66 -20
- package/src/index.ts +16 -1
- package/src/instant.ts +84 -5
- package/src/invariants.ts +3 -3
- package/src/judged-write.ts +28 -0
- package/src/like.ts +85 -0
- package/src/memory-match.ts +27 -82
- package/src/memory-repo.ts +16 -2
- package/src/memory-unique.ts +7 -6
- package/src/numeric-compare.ts +57 -0
- package/src/pg-driver.ts +38 -29
- package/src/pg-row.ts +4 -1
- package/src/pg-sql.ts +5 -1
- package/src/plan.ts +9 -3
- package/src/preload-ceiling.ts +75 -0
- package/src/preload.ts +23 -8
- package/src/query.ts +67 -34
- package/src/record-projection.ts +17 -2
- package/src/row-observer.ts +17 -2
- package/src/rows-of.ts +13 -1
- package/src/sealed-column.ts +123 -0
- package/src/sealed-declaration.ts +64 -0
- package/src/sealed-errors.ts +112 -0
- package/src/sealed-repo.ts +235 -0
- package/src/sealed.ts +196 -0
- package/src/seed.ts +97 -73
- package/src/transition.ts +3 -0
- package/src/type-pins.ts +109 -3
- package/src/types.ts +106 -0
- package/src/unique-constraints.ts +44 -0
package/src/memory-repo.ts
CHANGED
|
@@ -15,12 +15,14 @@ import { narrowRow } from './columns';
|
|
|
15
15
|
import { countsFrom, groupColumnOf } from './count-by';
|
|
16
16
|
import { cursorFor, kindOf, seekFrom, valueAt } from './cursor';
|
|
17
17
|
import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
18
|
-
import { notFound } from './errors';
|
|
18
|
+
import { invariantViolated, notFound } from './errors';
|
|
19
19
|
import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
|
|
20
20
|
import { compareByKind, matchesPredicate } from './memory-match';
|
|
21
21
|
import { uniqueClash, uniqueViolation } from './memory-unique';
|
|
22
22
|
import { deletePlan, idPlan, readPlan, singleKeyOf, updatePlan } from './plan';
|
|
23
23
|
import type { FindManyArgs, MemoryRepo, RepoOptions, Transactor, Tx } from './repo';
|
|
24
|
+
import { sealedFields } from './sealed';
|
|
25
|
+
import { sealedRepo } from './sealed-repo';
|
|
24
26
|
import type { QueryPlan } from './tenancy';
|
|
25
27
|
import { assertRowTenant } from './tenancy';
|
|
26
28
|
import type { RowWrite } from './types';
|
|
@@ -95,6 +97,15 @@ export const memoryRepo = <Row>(
|
|
|
95
97
|
/** The same key, from the id a caller named rather than from a row it has in hand. */
|
|
96
98
|
const idStoreKey = (id: unknown, operation: string): string =>
|
|
97
99
|
keyOf(kindOf(entity, singleKeyOf(entity, operation)) ?? '', id);
|
|
100
|
+
// Seed rows are stored as given, and sealing is asynchronous: a seeded sealed entity would hold
|
|
101
|
+
// plaintext every read then refuses to open. Refused here, with the call that seals.
|
|
102
|
+
if (seed.length > 0 && sealedFields(entity).length > 0) {
|
|
103
|
+
throw invariantViolated(
|
|
104
|
+
entity.$name,
|
|
105
|
+
'seed',
|
|
106
|
+
'declares a sealed column, and memoryRepo(entity, seed) stores seed rows unsealed — pass no seed and write them with insertAll(rows)',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
98
109
|
const rows = new Map<string, Row>(seed.map((row) => [storeKey(row), row]));
|
|
99
110
|
|
|
100
111
|
const rowsOf = (plan: QueryPlan, args: FindManyArgs): Row[] => {
|
|
@@ -193,7 +204,7 @@ export const memoryRepo = <Row>(
|
|
|
193
204
|
|
|
194
205
|
// Every method is async: a repository call that fails must reject, never throw
|
|
195
206
|
// synchronously, or half the call sites would need two error paths.
|
|
196
|
-
|
|
207
|
+
const repo: MemoryRepo<Row> = {
|
|
197
208
|
async findById(id, options) {
|
|
198
209
|
const { found } = select(
|
|
199
210
|
{ ...options, where: [{ column: singleKeyOf(entity, 'findById'), op: 'eq', value: id }] },
|
|
@@ -414,6 +425,9 @@ export const memoryRepo = <Row>(
|
|
|
414
425
|
for (const row of seed) rows.set(storeKey(row), row);
|
|
415
426
|
},
|
|
416
427
|
};
|
|
428
|
+
// 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);
|
|
417
431
|
};
|
|
418
432
|
|
|
419
433
|
let txCounter = 0;
|
package/src/memory-unique.ts
CHANGED
|
@@ -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
|
|
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
|
|
29
|
-
* are skipped: their predicate is SQL this driver cannot evaluate, and a guess would refuse
|
|
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
|
|
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,
|
|
@@ -53,6 +55,7 @@ import {
|
|
|
53
55
|
} from './pg-write-sql';
|
|
54
56
|
import { deletePlan, idPlan, readPlan, updatePlan } from './plan';
|
|
55
57
|
import type { FindManyArgs, Repo, UpsertArgs } from './repo';
|
|
58
|
+
import { sealedRepo } from './sealed-repo';
|
|
56
59
|
import type { QueryPlan } from './tenancy';
|
|
57
60
|
import { assertRowTenant } from './tenancy';
|
|
58
61
|
import type { RowWrite } from './types';
|
|
@@ -84,21 +87,25 @@ export const postgresRepo = <Row>(
|
|
|
84
87
|
): Repo<Row> => {
|
|
85
88
|
/**
|
|
86
89
|
* The one place a connection is chosen, which is why the transaction guard is here and not on
|
|
87
|
-
* each method. Unpinned, `db()` answers with the open transaction when there is one
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* `
|
|
91
|
-
*
|
|
92
|
-
* rather than resolved — a `DbTx` does not name the client it was opened on, so this layer
|
|
93
|
-
* 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.
|
|
94
95
|
*/
|
|
95
96
|
const client = (): DbClient => {
|
|
96
97
|
const pinned = config.client;
|
|
97
98
|
if (pinned === undefined) return db();
|
|
98
|
-
|
|
99
|
-
return pinned;
|
|
99
|
+
const tx = currentTx();
|
|
100
|
+
if (tx === undefined) return pinned;
|
|
101
|
+
if (tx.origin === pinned) return tx;
|
|
102
|
+
throw repoClientPinned(entity.$name);
|
|
100
103
|
};
|
|
101
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
|
+
|
|
102
109
|
/**
|
|
103
110
|
* Every statement a repository call sends carries the entity and the operation that compiled it,
|
|
104
111
|
* which is what turns "50× `select … where "id" = $1`" into "50× `findById` on `members`" in a
|
|
@@ -205,7 +212,7 @@ export const postgresRepo = <Row>(
|
|
|
205
212
|
return written;
|
|
206
213
|
};
|
|
207
214
|
|
|
208
|
-
|
|
215
|
+
const repo: Repo<Row> = {
|
|
209
216
|
async findById(id, options) {
|
|
210
217
|
const op = 'findById';
|
|
211
218
|
const plan = idPlan(entity, id, options, op);
|
|
@@ -297,20 +304,17 @@ export const postgresRepo = <Row>(
|
|
|
297
304
|
if (current === null) throw notFound(entity.$name, id);
|
|
298
305
|
return current;
|
|
299
306
|
}
|
|
300
|
-
const
|
|
301
|
-
writing(() =>
|
|
302
|
-
client().one<PhysicalRow>(
|
|
303
|
-
updateStatement(entity, plan, values, shapeOf(options ?? {}), true),
|
|
304
|
-
),
|
|
305
|
-
),
|
|
306
|
-
);
|
|
307
|
-
if (written === null) throw notFound(entity.$name, id);
|
|
308
|
-
const after = decodeRow(entity, written);
|
|
307
|
+
const statement = updateStatement(entity, plan, values, shapeOf(options ?? {}), true);
|
|
309
308
|
// SQL-expressible invariants are CHECK constraints, so Postgres already rejected the
|
|
310
|
-
// statement. A JS-only one
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
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
|
+
});
|
|
314
318
|
},
|
|
315
319
|
|
|
316
320
|
async delete(id, options) {
|
|
@@ -367,12 +371,13 @@ export const postgresRepo = <Row>(
|
|
|
367
371
|
const rows = Number(matched?.count ?? 0);
|
|
368
372
|
if (rows > MAX_ASSERTED_ROWS) throw assertedRowsTooMany(entity.$name, op, rows);
|
|
369
373
|
const statement = updateStatement(entity, plan, values, shape, true);
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
+
});
|
|
376
381
|
},
|
|
377
382
|
|
|
378
383
|
async count(args = {}) {
|
|
@@ -471,6 +476,8 @@ export const postgresRepo = <Row>(
|
|
|
471
476
|
return Number.isFinite(estimate) && estimate >= 0 ? estimate : null;
|
|
472
477
|
},
|
|
473
478
|
};
|
|
479
|
+
// The one sealing seam, shared with `memoryRepo()`: sealed on the way in, opened on the way out.
|
|
480
|
+
return sealedRepo(entity, repo);
|
|
474
481
|
};
|
|
475
482
|
|
|
476
483
|
/**
|
|
@@ -479,4 +486,6 @@ export const postgresRepo = <Row>(
|
|
|
479
486
|
*/
|
|
480
487
|
export const postgresDriver = (config: PostgresDriverOptions = {}): Driver => ({
|
|
481
488
|
repo: <Row>(entity: EntityCore<Row>) => postgresRepo(entity, config),
|
|
489
|
+
transactor: () =>
|
|
490
|
+
postgresTransactor(config.client === undefined ? {} : { client: config.client }),
|
|
482
491
|
});
|
package/src/pg-row.ts
CHANGED
|
@@ -185,7 +185,10 @@ export const decodeRow = <Row>(entity: EntityCore<Row>, source: PhysicalRow): Ro
|
|
|
185
185
|
? moneyOf(source, head, currency, scale)
|
|
186
186
|
: source[head];
|
|
187
187
|
if (value !== null && value !== undefined) {
|
|
188
|
-
|
|
188
|
+
// A sealed column's stored value is not the author's to parse: it goes up as it is, and the
|
|
189
|
+
// sealing seam opens it — so a row somebody left in the clear is `X_SEAL_INVALID` there
|
|
190
|
+
// rather than whatever the plaintext parser thinks of its length here.
|
|
191
|
+
row[property] = column.$meta.sealed === undefined ? column.$parse(value) : value;
|
|
189
192
|
continue;
|
|
190
193
|
}
|
|
191
194
|
if (column.$meta.notNull) {
|
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.?) ${
|
|
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 {
|
|
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}
|
|
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,8 +11,10 @@ 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';
|
|
17
|
+
import { copyRow } from './sealed';
|
|
16
18
|
import type { Predicate } from './tenancy';
|
|
17
19
|
|
|
18
20
|
/** The other side of a relation: the entity, and where its rows live. */
|
|
@@ -72,11 +74,14 @@ const tenantScope = (
|
|
|
72
74
|
* genuinely full. A `belongsTo` over a page of 50 is exactly one statement.
|
|
73
75
|
*
|
|
74
76
|
* The page loop is what keeps a `hasMany` honest: a relation with more rows than one page holds
|
|
75
|
-
* 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.
|
|
76
80
|
*/
|
|
77
81
|
const relatedRows = async (
|
|
82
|
+
source: string,
|
|
78
83
|
target: RelatedTable,
|
|
79
|
-
relation:
|
|
84
|
+
relation: PreloadRelation,
|
|
80
85
|
values: readonly unknown[],
|
|
81
86
|
scope: readonly Predicate[],
|
|
82
87
|
): Promise<readonly unknown[]> => {
|
|
@@ -86,10 +91,11 @@ const relatedRows = async (
|
|
|
86
91
|
do {
|
|
87
92
|
const page = await target.repo.findMany({
|
|
88
93
|
where: [{ column: relation.remoteKey, op: 'in', value: chunk }, ...scope],
|
|
89
|
-
limit: MAX_IDS_PER_STATEMENT,
|
|
94
|
+
limit: Math.min(MAX_IDS_PER_STATEMENT, relation.max + 1 - rows.length),
|
|
90
95
|
cursor,
|
|
91
96
|
});
|
|
92
97
|
rows.push(...page.rows);
|
|
98
|
+
if (rows.length > relation.max) throw preloadTooMany(source, relation);
|
|
93
99
|
cursor = page.nextCursor;
|
|
94
100
|
} while (cursor !== null);
|
|
95
101
|
}
|
|
@@ -98,15 +104,16 @@ const relatedRows = async (
|
|
|
98
104
|
|
|
99
105
|
/** Related rows filed under the key they attach to — in the order the read returned them. */
|
|
100
106
|
const indexed = async (
|
|
107
|
+
source: string,
|
|
101
108
|
target: RelatedTable,
|
|
102
|
-
relation:
|
|
109
|
+
relation: PreloadRelation,
|
|
103
110
|
kind: string,
|
|
104
111
|
values: readonly unknown[],
|
|
105
112
|
scope: readonly Predicate[],
|
|
106
113
|
): Promise<ReadonlyMap<string, readonly unknown[]>> => {
|
|
107
114
|
const index = new Map<string, unknown[]>();
|
|
108
115
|
if (values.length === 0) return index;
|
|
109
|
-
const found = await relatedRows(target, relation, values, scope);
|
|
116
|
+
const found = await relatedRows(source, target, relation, values, scope);
|
|
110
117
|
for (const row of found) {
|
|
111
118
|
const at = keyOf(kind, valueAt(row, relation.remoteKey));
|
|
112
119
|
const bucket = index.get(at);
|
|
@@ -134,7 +141,7 @@ export interface PreloadRead<Source> {
|
|
|
134
141
|
readonly entity: EntityCore<Source>;
|
|
135
142
|
/** `undefined` for a table built by hand — `tableFor(entity, repo)` reaches no other table. */
|
|
136
143
|
readonly related: RelatedTables | undefined;
|
|
137
|
-
readonly relations: readonly
|
|
144
|
+
readonly relations: readonly PreloadRelation[];
|
|
138
145
|
/** The page's own predicates: what the related read inherits its tenant scope from. */
|
|
139
146
|
readonly where: readonly Predicate[];
|
|
140
147
|
}
|
|
@@ -164,13 +171,21 @@ export const preloaded = async <Source, Row>(
|
|
|
164
171
|
const kind = read.entity.$columns[relation.localKey]?.$meta.kind ?? '';
|
|
165
172
|
const keys = source.map((row) => valueAt(row, relation.localKey));
|
|
166
173
|
const scope = tenantScope(read.entity, target.entity, read.where);
|
|
167
|
-
const index = await indexed(
|
|
174
|
+
const index = await indexed(
|
|
175
|
+
read.entity.$name,
|
|
176
|
+
target,
|
|
177
|
+
relation,
|
|
178
|
+
kind,
|
|
179
|
+
distinctKeys(kind, keys),
|
|
180
|
+
scope,
|
|
181
|
+
);
|
|
168
182
|
return { relation, kind, keys, index };
|
|
169
183
|
}),
|
|
170
184
|
);
|
|
171
185
|
// A copy per row: the in-memory driver hands back the row it stores, and attaching to that one
|
|
172
186
|
// would write a relation into the table itself.
|
|
173
|
-
|
|
187
|
+
// `copyRow`, never a spread: a spread drops every server-only (sealed) property of the row.
|
|
188
|
+
const attached = rows.map((row) => copyRow(row as object) as Record<string, unknown>);
|
|
174
189
|
for (const { relation, kind, keys, index } of resolved) {
|
|
175
190
|
for (const [position, row] of attached.entries()) {
|
|
176
191
|
const key = keys[position];
|