@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/CLAUDE.md +49 -31
- package/README.md +76 -20
- package/package.json +6 -6
- package/src/aggregate.ts +31 -22
- package/src/append-only-errors.ts +53 -0
- package/src/append-only.ts +82 -0
- package/src/batch.ts +1 -1
- package/src/bulk-write.ts +4 -13
- package/src/column.ts +36 -1
- package/src/columns-data.ts +24 -3
- package/src/columns.ts +9 -2
- package/src/containment.ts +15 -2
- package/src/database.ts +9 -2
- package/src/describe.ts +5 -0
- package/src/entity-error.ts +31 -23
- package/src/entity.ts +18 -30
- package/src/errors.ts +18 -44
- package/src/expr.ts +66 -20
- package/src/feature-errors.ts +3 -1
- package/src/index-init.ts +29 -0
- package/src/index.ts +12 -3
- package/src/invariants.ts +3 -3
- package/src/judged-write.ts +28 -0
- package/src/like.ts +85 -0
- package/src/memory-match.ts +20 -82
- package/src/memory-repo.ts +14 -9
- package/src/memory-unique.ts +7 -6
- package/src/numeric-compare.ts +57 -0
- package/src/pg-driver.ts +53 -45
- package/src/pg-sql.ts +5 -1
- package/src/plan.ts +9 -3
- package/src/preload-ceiling.ts +75 -0
- package/src/preload.ts +20 -7
- package/src/query.ts +19 -17
- package/src/registry.ts +2 -0
- package/src/repo.ts +5 -6
- package/src/row-observer.ts +2 -2
- package/src/sealed-column.ts +7 -3
- package/src/sealed-repo.ts +2 -1
- package/src/sealed.ts +6 -4
- package/src/seed.ts +113 -74
- package/src/transition.ts +3 -0
- package/src/type-pins.ts +25 -0
- package/src/types.ts +6 -0
- package/src/unique-constraints.ts +44 -0
|
@@ -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
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// being told — which is how `ctx.jobs.enqueue()` lands its outbox row atomically with the write
|
|
8
8
|
// that caused it. `RepoOptions.tx` is the in-memory driver's undo hook and is ignored here.
|
|
9
9
|
|
|
10
|
+
import { pageOf } from '@ultimat3/core';
|
|
10
11
|
import {
|
|
11
12
|
currentTx,
|
|
12
13
|
type DbClient,
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
} from '@ultimat3/db';
|
|
17
18
|
import { aggregateColumnOf, aggregateMinor, assertOneUnit } from './aggregate';
|
|
18
19
|
import { decodeAggregate } from './aggregate-decode';
|
|
20
|
+
import { appendOnlyRepo } from './append-only';
|
|
19
21
|
import {
|
|
20
22
|
conflictKeys,
|
|
21
23
|
insertChunks,
|
|
@@ -34,6 +36,7 @@ import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
|
|
|
34
36
|
import { notFound, repoClientPinned } from './errors';
|
|
35
37
|
import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
|
|
36
38
|
import { forgetPreloaded, tagSiblings } from './jit-preload';
|
|
39
|
+
import { judgedWrite } from './judged-write';
|
|
37
40
|
import { bindValues, decodeRow, type PhysicalRow, physicalName, sortPrecision } from './pg-row';
|
|
38
41
|
import { countStatement, type ReadShape, selectStatement } from './pg-sql';
|
|
39
42
|
import {
|
|
@@ -45,6 +48,7 @@ import {
|
|
|
45
48
|
type GroupRow,
|
|
46
49
|
type MoneyUnitRow,
|
|
47
50
|
} from './pg-sql-aggregate';
|
|
51
|
+
import { postgresTransactor } from './pg-transactor';
|
|
48
52
|
import {
|
|
49
53
|
type ConflictTarget,
|
|
50
54
|
deleteStatement,
|
|
@@ -62,7 +66,7 @@ import { taggedWrite } from './write-tag';
|
|
|
62
66
|
export interface PostgresDriverOptions {
|
|
63
67
|
/**
|
|
64
68
|
* Pin a client. Left out, every call resolves `db()` — the ambient pool, or the open
|
|
65
|
-
* transaction when one is in scope. Tests pass `
|
|
69
|
+
* transaction when one is in scope. Tests pass `recordingClient()` here or install one
|
|
66
70
|
* globally with `setDbClient()`.
|
|
67
71
|
*/
|
|
68
72
|
readonly client?: DbClient | undefined;
|
|
@@ -85,21 +89,25 @@ export const postgresRepo = <Row>(
|
|
|
85
89
|
): Repo<Row> => {
|
|
86
90
|
/**
|
|
87
91
|
* 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
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* `
|
|
92
|
-
*
|
|
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.
|
|
92
|
+
* each method. Unpinned, `db()` answers with the open transaction when there is one. Pinned, a
|
|
93
|
+
* statement sent straight to `config.client` takes another connection out of that pool — so it
|
|
94
|
+
* goes through the open transaction instead when that transaction was opened ON this client
|
|
95
|
+
* (`tx.origin`): one database, one reservation, and the repository joins it. Any other open
|
|
96
|
+
* transaction is a second database in one scope, and is refused rather than written around.
|
|
95
97
|
*/
|
|
96
98
|
const client = (): DbClient => {
|
|
97
99
|
const pinned = config.client;
|
|
98
100
|
if (pinned === undefined) return db();
|
|
99
|
-
|
|
100
|
-
return pinned;
|
|
101
|
+
const tx = currentTx();
|
|
102
|
+
if (tx === undefined) return pinned;
|
|
103
|
+
if (tx.origin === pinned) return tx;
|
|
104
|
+
throw repoClientPinned(entity.$name);
|
|
101
105
|
};
|
|
102
106
|
|
|
107
|
+
/** The statement and its app-side judgement on one connection — see `judged-write.ts`. */
|
|
108
|
+
const judged = <T>(work: (send: DbClient) => Promise<T>): Promise<T> =>
|
|
109
|
+
judgedWrite(entity, client(), config.client, work);
|
|
110
|
+
|
|
103
111
|
/**
|
|
104
112
|
* Every statement a repository call sends carries the entity and the operation that compiled it,
|
|
105
113
|
* which is what turns "50× `select … where "id" = $1`" into "50× `findById` on `members`" in a
|
|
@@ -241,22 +249,21 @@ export const postgresRepo = <Row>(
|
|
|
241
249
|
// statement, not one per row — its `await` already ended the coalescing window.
|
|
242
250
|
if (config.jitPreload !== false) tagSiblings(entity, rows);
|
|
243
251
|
const last = rows.at(-1);
|
|
244
|
-
return
|
|
252
|
+
return pageOf(
|
|
245
253
|
rows,
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
};
|
|
254
|
+
found.length > plan.limit && last !== undefined
|
|
255
|
+
? // Minted from the PHYSICAL row as well as the decoded one: a `timestamptz` decodes
|
|
256
|
+
// to a `Date`, and the microseconds that drops are the difference between a position
|
|
257
|
+
// the `order by` agrees with and one that cuts between two rows.
|
|
258
|
+
cursorFor(
|
|
259
|
+
entity,
|
|
260
|
+
plan,
|
|
261
|
+
last,
|
|
262
|
+
idOf(last),
|
|
263
|
+
sortPrecision(entity, plan.orderBy, page.at(-1)),
|
|
264
|
+
)
|
|
265
|
+
: null,
|
|
266
|
+
);
|
|
260
267
|
},
|
|
261
268
|
|
|
262
269
|
async insert(values) {
|
|
@@ -298,20 +305,17 @@ export const postgresRepo = <Row>(
|
|
|
298
305
|
if (current === null) throw notFound(entity.$name, id);
|
|
299
306
|
return current;
|
|
300
307
|
}
|
|
301
|
-
const
|
|
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);
|
|
308
|
+
const statement = updateStatement(entity, plan, values, shapeOf(options ?? {}), true);
|
|
310
309
|
// SQL-expressible invariants are CHECK constraints, so Postgres already rejected the
|
|
311
|
-
// statement. A JS-only one
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
310
|
+
// statement. A JS-only one can only be judged on the result — which is why `judged` sends
|
|
311
|
+
// the statement inside a transaction the refusal rolls back.
|
|
312
|
+
return judged(async (send) => {
|
|
313
|
+
const written = await attributed(op, () => writing(() => send.one<PhysicalRow>(statement)));
|
|
314
|
+
if (written === null) throw notFound(entity.$name, id);
|
|
315
|
+
const after = decodeRow(entity, written);
|
|
316
|
+
entity.$assert(after);
|
|
317
|
+
return after;
|
|
318
|
+
});
|
|
315
319
|
},
|
|
316
320
|
|
|
317
321
|
async delete(id, options) {
|
|
@@ -368,12 +372,13 @@ export const postgresRepo = <Row>(
|
|
|
368
372
|
const rows = Number(matched?.count ?? 0);
|
|
369
373
|
if (rows > MAX_ASSERTED_ROWS) throw assertedRowsTooMany(entity.$name, op, rows);
|
|
370
374
|
const statement = updateStatement(entity, plan, values, shape, true);
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
375
|
+
return judged(async (send) => {
|
|
376
|
+
const written = await attributed(op, () =>
|
|
377
|
+
writing(() => send.query<PhysicalRow>(statement)),
|
|
378
|
+
);
|
|
379
|
+
for (const row of written) entity.$assert(decodeRow(entity, row));
|
|
380
|
+
return written.length;
|
|
381
|
+
});
|
|
377
382
|
},
|
|
378
383
|
|
|
379
384
|
async count(args = {}) {
|
|
@@ -473,7 +478,8 @@ export const postgresRepo = <Row>(
|
|
|
473
478
|
},
|
|
474
479
|
};
|
|
475
480
|
// The one sealing seam, shared with `memoryRepo()`: sealed on the way in, opened on the way out.
|
|
476
|
-
|
|
481
|
+
// Append-only outermost, as there: a refused rewrite seals nothing and sends no statement.
|
|
482
|
+
return appendOnlyRepo(entity, sealedRepo(entity, repo));
|
|
477
483
|
};
|
|
478
484
|
|
|
479
485
|
/**
|
|
@@ -482,4 +488,6 @@ export const postgresRepo = <Row>(
|
|
|
482
488
|
*/
|
|
483
489
|
export const postgresDriver = (config: PostgresDriverOptions = {}): Driver => ({
|
|
484
490
|
repo: <Row>(entity: EntityCore<Row>) => postgresRepo(entity, config),
|
|
491
|
+
transactor: () =>
|
|
492
|
+
postgresTransactor(config.client === undefined ? {} : { client: config.client }),
|
|
485
493
|
});
|
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,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:
|
|
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:
|
|
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
|
|
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(
|
|
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
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
// concurrent writes an insert before the offset shifts every later page, so a client silently
|
|
5
5
|
// skips and repeats rows.
|
|
6
6
|
|
|
7
|
+
import type { Page } from '@ultimat3/core';
|
|
8
|
+
import { pageOf } from '@ultimat3/core';
|
|
7
9
|
import type { BatchIterator } from './batch';
|
|
8
10
|
import { assertBatchable, batchIterator } from './batch';
|
|
9
11
|
import { entityNow } from './clock';
|
|
10
12
|
import type { EntityCore } from './entity';
|
|
11
13
|
import { searchUndeclared } from './feature-errors';
|
|
12
14
|
import { assertFinitePageSize, DEFAULT_PAGE_SIZE, namedColumns } from './plan';
|
|
13
|
-
import type
|
|
14
|
-
import {
|
|
15
|
-
import type {
|
|
16
|
-
import { relationNamed } from './relations';
|
|
17
|
-
import type { Page, Repo, RepoOptions, UpsertArgs } from './repo';
|
|
15
|
+
import { preloaded, type RelatedTables } from './preload';
|
|
16
|
+
import { type PreloadOptions, type PreloadRelation, preloadsWith } from './preload-ceiling';
|
|
17
|
+
import type { Repo, RepoOptions, UpsertArgs } from './repo';
|
|
18
18
|
import { copyRow, pickRow } from './sealed';
|
|
19
19
|
import { SEARCH_PROPERTY } from './search';
|
|
20
20
|
import type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
|
|
@@ -86,15 +86,18 @@ export interface ReadBuilder<Row, S extends SealedKeySet = NoSealedKeys> {
|
|
|
86
86
|
/**
|
|
87
87
|
* One relation, read for the whole page in one extra `where <key> in (…)` and attached to every
|
|
88
88
|
* 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
|
|
90
|
-
*
|
|
91
|
-
*
|
|
89
|
+
* the line an N+1 warning names. The relation is the `references()` already declared; a name no
|
|
90
|
+
* foreign key produces is `X_PRELOAD_UNKNOWN_RELATION`. `max` is the most related rows it may
|
|
91
|
+
* attach to one page (default `MAX_PRELOADED_ROWS`), refused past it and never truncated.
|
|
92
92
|
*
|
|
93
93
|
* A `belongsTo` attaches the row or `null`, a `hasMany` an array — always present, so "no
|
|
94
94
|
* author" never reads like "nobody preloaded the author". Attached after the projection: a
|
|
95
95
|
* `select()` narrows the columns, never the relations.
|
|
96
96
|
*/
|
|
97
|
-
preload<Name extends string>(
|
|
97
|
+
preload<Name extends string>(
|
|
98
|
+
relation: Name,
|
|
99
|
+
options?: PreloadOptions,
|
|
100
|
+
): ReadBuilder<Row & Preloaded<Name>, S>;
|
|
98
101
|
/**
|
|
99
102
|
* Every row the chain matches, `size` at a time and one statement per batch — the terminal a
|
|
100
103
|
* `for await` consumes instead of holding a whole table in memory. A batch is the page `page()`
|
|
@@ -252,7 +255,7 @@ interface State {
|
|
|
252
255
|
readonly cursor: string | null;
|
|
253
256
|
readonly select: readonly string[] | undefined;
|
|
254
257
|
/** Resolved when `preload()` was called, so an unknown name fails at the chain and not a page later. */
|
|
255
|
-
readonly preload: readonly
|
|
258
|
+
readonly preload: readonly PreloadRelation[];
|
|
256
259
|
}
|
|
257
260
|
|
|
258
261
|
const EMPTY: State = {
|
|
@@ -356,15 +359,14 @@ const builder = <Source, Row>(
|
|
|
356
359
|
);
|
|
357
360
|
},
|
|
358
361
|
|
|
359
|
-
preload<Name extends string>(relation: Name) {
|
|
362
|
+
preload<Name extends string>(relation: Name, options?: PreloadOptions) {
|
|
360
363
|
// Resolved here, so a name no foreign key produces fails on the chain rather than one page
|
|
361
|
-
// later
|
|
362
|
-
const
|
|
363
|
-
const already = state.preload.some((held) => held.name === resolved.name);
|
|
364
|
+
// later; naming one relation twice is one statement, under the ceiling stated last.
|
|
365
|
+
const preload = preloadsWith(state.preload, entity.$name, relation, options);
|
|
364
366
|
return builder<Source, Row & Preloaded<Name>>(
|
|
365
367
|
entity,
|
|
366
368
|
repo,
|
|
367
|
-
{ ...state, preload
|
|
369
|
+
{ ...state, preload },
|
|
368
370
|
// The relation is attached after the projection, so `pick` is unchanged and the row type
|
|
369
371
|
// is the only thing that grows — one cast, where a runtime name becomes a static one.
|
|
370
372
|
pick as (row: Source) => Row & Preloaded<Name>,
|
|
@@ -380,14 +382,14 @@ const builder = <Source, Row>(
|
|
|
380
382
|
// in place of the chain's: one statement per batch, and the same one `page()` sends.
|
|
381
383
|
page: async (cursor) => {
|
|
382
384
|
const result = await repo.findMany({ ...args(), limit: size, cursor });
|
|
383
|
-
return
|
|
385
|
+
return pageOf(await attach(result.rows), result.nextCursor);
|
|
384
386
|
},
|
|
385
387
|
});
|
|
386
388
|
},
|
|
387
389
|
|
|
388
390
|
page: async () => {
|
|
389
391
|
const result = await repo.findMany(args());
|
|
390
|
-
return
|
|
392
|
+
return pageOf(await attach(result.rows), result.nextCursor);
|
|
391
393
|
},
|
|
392
394
|
|
|
393
395
|
all: async () => attach((await repo.findMany(args())).rows),
|
package/src/registry.ts
CHANGED
|
@@ -125,6 +125,8 @@ export interface EntityDescription {
|
|
|
125
125
|
readonly cacheTag: string;
|
|
126
126
|
readonly softDelete: boolean;
|
|
127
127
|
readonly orgScoped: boolean;
|
|
128
|
+
/** `entity({ appendOnly: true })`; absent on every entity that did not say it. `x db gen` reads it. */
|
|
129
|
+
readonly appendOnly?: true;
|
|
128
130
|
}
|
|
129
131
|
|
|
130
132
|
export interface RegistryEntry {
|
package/src/repo.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// table silently skips and repeats rows. A keyset cursor is stable because it names a
|
|
8
8
|
// position in the sort order, not a row count.
|
|
9
9
|
|
|
10
|
+
import type { Page } from '@ultimat3/core';
|
|
10
11
|
import type { AggregateFn } from './aggregate';
|
|
11
12
|
import type { Predicate, SortKey } from './tenancy';
|
|
12
13
|
import type { IdOf, RowPatch, RowWrite } from './types';
|
|
@@ -72,12 +73,6 @@ export interface FindManyArgs extends FindByIdOptions {
|
|
|
72
73
|
readonly select?: readonly string[];
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
export interface Page<T> {
|
|
76
|
-
readonly rows: readonly T[];
|
|
77
|
-
/** Pass back as `cursor`. `null` means this was the last page. */
|
|
78
|
-
readonly nextCursor: string | null;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
76
|
/**
|
|
82
77
|
* `T` defaults to `unknown` so a row-agnostic consumer (the generated admin, the manifest
|
|
83
78
|
* emitter) can name the shape without knowing the entity.
|
|
@@ -93,6 +88,10 @@ export interface Page<T> {
|
|
|
93
88
|
*/
|
|
94
89
|
export interface Repo<T = unknown> {
|
|
95
90
|
findById(id: IdOf<T>, options?: FindByIdOptions): Promise<T | null>;
|
|
91
|
+
/**
|
|
92
|
+
* One page: `@ultimat3/core`'s `Page`, the framework's one page shape. Pass `nextCursor` back as
|
|
93
|
+
* `cursor`; it is `null` exactly when `hasMore` is false — this was the last page.
|
|
94
|
+
*/
|
|
96
95
|
findMany(args?: FindManyArgs): Promise<Page<T>>;
|
|
97
96
|
insert(values: RowWrite<T>, options?: RepoOptions): Promise<T>;
|
|
98
97
|
/**
|
package/src/row-observer.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// so it reports the same changes whether rows live in memory or in Postgres.
|
|
3
3
|
//
|
|
4
4
|
// It exists because a change feed needs a source and only one of the two has one. Production
|
|
5
|
-
// decodes the write-ahead log (`@ultimat3/realtime`'s `
|
|
6
|
-
// walsender and the memory driver has no log at all, so `
|
|
5
|
+
// decodes the write-ahead log (`@ultimat3/realtime`'s `postgresChangeFeed()`); PGlite has no
|
|
6
|
+
// walsender and the memory driver has no log at all, so `memoryChangeFeed()` — which that package
|
|
7
7
|
// calls "the blessed development and test feed" — had nothing upstream of it. That is what left
|
|
8
8
|
// `@ultimat3/testing`'s `subscribe` fixture with no driver: a live query with no changes flowing
|
|
9
9
|
// into it is a snapshot, and a snapshot is not what those tests assert.
|
package/src/sealed-column.ts
CHANGED
|
@@ -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 = (
|
|
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
|
};
|
package/src/sealed-repo.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// it. Both driver factories return through this, which is why the typed handle and a hand-held
|
|
5
5
|
// `postgresRepo()` cannot disagree. An entity with no sealed column gets its repository back as is.
|
|
6
6
|
|
|
7
|
+
import { pageOf } from '@ultimat3/core';
|
|
7
8
|
import type { AggregateFn } from './aggregate';
|
|
8
9
|
import { namedColumns } from './plan';
|
|
9
10
|
import type { FindManyArgs, Repo, UpsertArgs } from './repo';
|
|
@@ -177,7 +178,7 @@ export const sealedRepo = <Row, R extends Repo<Row>>(entity: SealedSource, repo:
|
|
|
177
178
|
async findMany(args) {
|
|
178
179
|
const keys = sealKeysOnce();
|
|
179
180
|
const page = await repo.findMany(await readArgs(entity, fields, args, keys));
|
|
180
|
-
return
|
|
181
|
+
return pageOf(await openRows(fields, page.rows, keys), page.nextCursor);
|
|
181
182
|
},
|
|
182
183
|
async insert(values, options) {
|
|
183
184
|
const keys = sealKeysOnce();
|