@ultimat3/entity 1.1.0 → 2.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 +614 -0
- package/README.md +391 -11
- package/package.json +5 -4
- package/src/batch-read.ts +134 -0
- package/src/batch.ts +125 -0
- package/src/bulk-write.ts +285 -0
- package/src/coalesce.ts +175 -0
- package/src/column.ts +24 -0
- package/src/columns.ts +183 -35
- package/src/count-by.ts +148 -0
- package/src/cross-tenant.ts +76 -0
- package/src/cursor.ts +17 -3
- package/src/database.ts +35 -2
- package/src/describe.ts +82 -37
- package/src/entity.ts +41 -12
- package/src/errors.ts +346 -5
- package/src/expr.ts +65 -15
- package/src/index.ts +49 -4
- package/src/invariants.ts +56 -14
- package/src/jit-preload.ts +216 -0
- package/src/n-plus-one.ts +122 -0
- package/src/pg-driver.ts +281 -33
- package/src/pg-row.ts +32 -7
- package/src/pg-sql.ts +117 -8
- package/src/plan.ts +130 -20
- package/src/preload.ts +184 -0
- package/src/query.ts +231 -27
- package/src/registry.ts +63 -5
- package/src/relations.ts +212 -0
- package/src/repo.ts +226 -12
- package/src/tenancy.ts +194 -12
- package/src/type-pins.ts +311 -0
- package/src/types.ts +64 -12
- package/src/view.ts +8 -2
package/src/invariants.ts
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
// never disagree with the code — a bulk import, a psql session or a second service all hit the
|
|
4
4
|
// same rule.
|
|
5
5
|
//
|
|
6
|
-
// `invariant()`
|
|
7
|
-
//
|
|
6
|
+
// `invariant()` names an already-built expression: `entity()` hands the whole `invariants:`
|
|
7
|
+
// callback the typed column proxy once, so a rule is written against property keys (`c.likeCount`)
|
|
8
|
+
// and `entity()` alone resolves them to physical names. A physical name is never typed twice.
|
|
8
9
|
|
|
9
|
-
import { invariantViolated } from './errors';
|
|
10
|
-
import type { Expr,
|
|
10
|
+
import { EntityError, invariantViolated } from './errors';
|
|
11
|
+
import type { Expr, Resolve, Row } from './expr';
|
|
11
12
|
|
|
12
13
|
/** `assert` is a rule only the app can run — a JS predicate with no SQL translation. */
|
|
13
14
|
export type InvariantKind = 'check' | 'unique' | 'assert';
|
|
@@ -24,20 +25,25 @@ export interface Invariant<T> {
|
|
|
24
25
|
readonly columns: readonly string[];
|
|
25
26
|
/** Partial-constraint predicate, e.g. `deleted_at is null`. */
|
|
26
27
|
readonly where?: string;
|
|
27
|
-
|
|
28
|
+
/**
|
|
29
|
+
* A method, not a `readonly holds: (row: T) => boolean` property, and the difference is
|
|
30
|
+
* load-bearing: a function-typed property is checked contravariantly, which made `Invariant<Post>`
|
|
31
|
+
* unassignable to `Invariant<unknown>` and so made every real entity fail `EntitySet`. Every
|
|
32
|
+
* `database({ posts, … })` call then degraded to `Table<unknown>` and cascaded — 275 errors in
|
|
33
|
+
* the reference app from this one position. Method syntax is bivariant, which is what
|
|
34
|
+
* `EntityCore.$assert` beside it already relies on. Pinned by `database.variance.test.ts`.
|
|
35
|
+
*/
|
|
36
|
+
holds(row: T): boolean;
|
|
28
37
|
}
|
|
29
38
|
|
|
30
|
-
/** What `invariant()` returns: a rule that does not yet know its physical column names. */
|
|
39
|
+
/** What `invariant()` returns: a named rule that does not yet know its physical column names. */
|
|
31
40
|
export interface InvariantDef {
|
|
32
41
|
readonly name: string;
|
|
33
|
-
readonly
|
|
42
|
+
readonly expr: Expr;
|
|
34
43
|
}
|
|
35
44
|
|
|
36
|
-
/** `invariant('post_like_count_non_negative',
|
|
37
|
-
export const invariant = (
|
|
38
|
-
name: string,
|
|
39
|
-
build: (columns: InvariantColumns) => Expr,
|
|
40
|
-
): InvariantDef => ({ name, build });
|
|
45
|
+
/** `invariants: (c) => [invariant('post_like_count_non_negative', c.likeCount.atLeast(0))]` */
|
|
46
|
+
export const invariant = (name: string, expr: Expr): InvariantDef => ({ name, expr });
|
|
41
47
|
|
|
42
48
|
const asRow = (value: unknown): Row =>
|
|
43
49
|
typeof value === 'object' && value !== null ? (value as Row) : {};
|
|
@@ -45,11 +51,10 @@ const asRow = (value: unknown): Row =>
|
|
|
45
51
|
/** Called by `entity()`: resolves property paths to physical names and freezes the rule. */
|
|
46
52
|
export const bindInvariant = <T>(
|
|
47
53
|
def: InvariantDef,
|
|
48
|
-
columns: InvariantColumns,
|
|
49
54
|
resolve: Resolve,
|
|
50
55
|
partialWhere: string | undefined,
|
|
51
56
|
): Invariant<T> => {
|
|
52
|
-
const expr = def.
|
|
57
|
+
const expr = def.expr;
|
|
53
58
|
const sql = expr.toSql(resolve);
|
|
54
59
|
const kind: InvariantKind = expr.kind === 'unique' ? 'unique' : sql === null ? 'assert' : 'check';
|
|
55
60
|
return {
|
|
@@ -87,6 +92,43 @@ export const invariantsToSql = <T>(table: string, invariants: readonly Invariant
|
|
|
87
92
|
.filter((statement): statement is string => statement !== null)
|
|
88
93
|
.join('\n');
|
|
89
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Whether any rule here can only be judged in the app. This is what decides whether a FILTERED
|
|
97
|
+
* write has to read back the rows it wrote: a `check` or a `unique` is a constraint Postgres
|
|
98
|
+
* already enforced on the statement, so nothing has to come back to prove it, while an `assert`
|
|
99
|
+
* (`sql: null`) has no CHECK and can only be judged on the result. Read from `$invariants` and
|
|
100
|
+
* never from a flag, exactly as `uniqueTargets` (`bulk-write.ts`) reads the same list to classify
|
|
101
|
+
* a conflict target — one declaration, two questions.
|
|
102
|
+
*/
|
|
103
|
+
export const hasJsOnlyInvariant = <T>(invariants: readonly Invariant<T>[]): boolean =>
|
|
104
|
+
invariants.some((inv) => inv.kind === 'assert' && inv.sql === null);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* How many rows a filtered write may bring back to be judged. Only the case above needs any: with
|
|
108
|
+
* every rule expressible as a constraint the answer is a count, and `returning *` there is a whole
|
|
109
|
+
* table streamed into a process sized for one request — a tenant-wide
|
|
110
|
+
* `updateWhere({ orgId }, { marketingOptIn: false })` over twelve million rows is the shape of it.
|
|
111
|
+
* Past this the call is refused rather than answered, because the cheap form of the same sweep is
|
|
112
|
+
* already in the vocabulary: `inBatches(size)` reads one page per statement and judges one page at
|
|
113
|
+
* a time.
|
|
114
|
+
*/
|
|
115
|
+
export const MAX_ASSERTED_ROWS = 50_000;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Not `invariantViolated`: its fix opens `x entity explain`, which describes a rule the author did
|
|
119
|
+
* not break — the rule is fine, the blast radius is not. What repairs this is one edit to the call.
|
|
120
|
+
*/
|
|
121
|
+
export const assertedRowsTooMany = (
|
|
122
|
+
entityName: string,
|
|
123
|
+
operation: string,
|
|
124
|
+
matched: number,
|
|
125
|
+
): EntityError =>
|
|
126
|
+
new EntityError({
|
|
127
|
+
code: 'X_INVARIANT_VIOLATED',
|
|
128
|
+
cause: `${entityName}.${operation}() matches ${matched} rows and ${entityName} declares an invariant only the app can judge, so every one of them would be read back — past ${MAX_ASSERTED_ROWS} that is the whole table in memory`,
|
|
129
|
+
fix: `for await (const rows of ${entityName}.where(filter).inBatches(1000)) { … } # one page per statement, judged one page at a time`,
|
|
130
|
+
});
|
|
131
|
+
|
|
90
132
|
/** Runs on every write. Reports every violation at once so one round trip fixes all. */
|
|
91
133
|
export const assertInvariants = <T>(
|
|
92
134
|
entityName: string,
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Single responsibility: the sibling-aware preload. A point lookup whose id is a foreign key on a
|
|
2
|
+
// page already read resolves that key for EVERY row of the page in one statement, and the rest of
|
|
3
|
+
// the loop is served from memory. A sequential `for … of` loop awaits between iterations, so its
|
|
4
|
+
// lookups never share a microtask and the coalescer cannot see them; this is what batches them.
|
|
5
|
+
//
|
|
6
|
+
// The trigger carries an id, not a row, so what a page leaves behind is an index of its foreign
|
|
7
|
+
// key VALUES rather than a map keyed by row identity: an id is a thing that can be looked up in
|
|
8
|
+
// it, and it holds values, so it pins no rows for the request's lifetime.
|
|
9
|
+
//
|
|
10
|
+
// The scope guard is a security boundary, not a tuning knob. A preloaded row is served only to a
|
|
11
|
+
// lookup with the same scope key, the same client and no write since — anything else reads the
|
|
12
|
+
// statement it always read.
|
|
13
|
+
|
|
14
|
+
import type { Ctx } from '@ultimat3/core';
|
|
15
|
+
import { tryUseContext } from '@ultimat3/core';
|
|
16
|
+
import type { DbClient } from '@ultimat3/db';
|
|
17
|
+
import { type Answer, keyOf, type PointRead, readByIds, statementChunks } from './batch-read';
|
|
18
|
+
import type { EntityCore } from './entity';
|
|
19
|
+
|
|
20
|
+
/** The rows one page's worth of foreign keys resolved to, under one scope. */
|
|
21
|
+
interface Bucket {
|
|
22
|
+
/** The entity the rows belong to — what a write invalidates. */
|
|
23
|
+
readonly entity: string;
|
|
24
|
+
/** Where the rows were read from. A pinned client and the ambient pool are two places. */
|
|
25
|
+
readonly client: DbClient;
|
|
26
|
+
/** Writes to `entity` when the bucket was opened. A later write makes every row of it stale. */
|
|
27
|
+
readonly generation: number;
|
|
28
|
+
/** Never rejects: a failure is an `Answer`, so an id nobody asks for cannot go unhandled. */
|
|
29
|
+
readonly rows: Map<string, Promise<Answer>>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Store {
|
|
33
|
+
/** `[targetEntity, targetProperty]` -> id key -> every id the page carried for that key. */
|
|
34
|
+
readonly siblings: Map<string, Map<string, readonly unknown[]>>;
|
|
35
|
+
/** Scope key -> the rows preloaded under it. */
|
|
36
|
+
readonly preloaded: Map<string, Bucket>;
|
|
37
|
+
/** Entity name -> writes this request has issued against it. */
|
|
38
|
+
readonly writes: Map<string, number>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Per request, keyed by ctx identity, so a page's siblings and everything preloaded from them die
|
|
43
|
+
* with the request that read them — the shape the microtask coalescer's store has, one file over.
|
|
44
|
+
*/
|
|
45
|
+
const requests = new WeakMap<object, Store>();
|
|
46
|
+
|
|
47
|
+
const storeFor = (ctx: Ctx): Store => {
|
|
48
|
+
const key: object = ctx;
|
|
49
|
+
const existing = requests.get(key);
|
|
50
|
+
if (existing !== undefined) return existing;
|
|
51
|
+
const created: Store = { siblings: new Map(), preloaded: new Map(), writes: new Map() };
|
|
52
|
+
requests.set(key, created);
|
|
53
|
+
return created;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Both ends of the edge: a key pointing at another column of the same entity is another edge. */
|
|
57
|
+
const siblingKey = (targetEntity: string, targetProperty: string): string =>
|
|
58
|
+
JSON.stringify([targetEntity, targetProperty]);
|
|
59
|
+
|
|
60
|
+
const writesTo = (store: Store, entity: string): number => store.writes.get(entity) ?? 0;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What a page of rows leaves behind: for each foreign key it declares, the distinct values that
|
|
64
|
+
* page carried, filed under every one of them. A later `findById` for any of those values is a
|
|
65
|
+
* lookup this page can answer for all of them.
|
|
66
|
+
*
|
|
67
|
+
* Values only. Rows are not held, so a page read early in a long request costs its keys and not
|
|
68
|
+
* its rows, and a group of one still counts — a hundred posts by one author is one statement for
|
|
69
|
+
* the whole loop rather than a hundred.
|
|
70
|
+
*/
|
|
71
|
+
export const tagSiblings = <Row>(entity: EntityCore<Row>, rows: readonly Row[]): void => {
|
|
72
|
+
if (rows.length === 0) return;
|
|
73
|
+
const ctx = tryUseContext();
|
|
74
|
+
// No request, no store: a job or a script reads the statement it always read. Asked first —
|
|
75
|
+
// resolving the foreign keys is a pass over the columns, and nothing would read the answer.
|
|
76
|
+
if (ctx === undefined) return;
|
|
77
|
+
const references = entity.$references();
|
|
78
|
+
if (references.length === 0) return;
|
|
79
|
+
const store = storeFor(ctx);
|
|
80
|
+
for (const reference of references) {
|
|
81
|
+
// The declaring column's own kind: a foreign key mirrors the key it points at, and a value is
|
|
82
|
+
// filed here exactly as `findById` will spell it when it comes looking.
|
|
83
|
+
const kind = entity.$columns[reference.property]?.$meta.kind;
|
|
84
|
+
if (kind === undefined) continue;
|
|
85
|
+
const ids: unknown[] = [];
|
|
86
|
+
const keys = new Set<string>();
|
|
87
|
+
for (const row of rows) {
|
|
88
|
+
const value = (row as Record<string, unknown>)[reference.property];
|
|
89
|
+
// A nullable key that resolved to nothing is data, not a row to go looking for.
|
|
90
|
+
if (value === null || value === undefined) continue;
|
|
91
|
+
const key = keyOf(kind, value);
|
|
92
|
+
if (keys.has(key)) continue;
|
|
93
|
+
keys.add(key);
|
|
94
|
+
ids.push(value);
|
|
95
|
+
}
|
|
96
|
+
if (ids.length === 0) continue;
|
|
97
|
+
const at = siblingKey(reference.targetEntity, reference.targetProperty);
|
|
98
|
+
const index = store.siblings.get(at) ?? new Map<string, readonly unknown[]>();
|
|
99
|
+
for (const key of keys) index.set(key, ids);
|
|
100
|
+
store.siblings.set(at, index);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A write makes every preloaded row of that entity stale, so the bucket holding them is not read
|
|
106
|
+
* again. Called before the statement goes out: a row read back afterwards is the row the write
|
|
107
|
+
* left, and one read concurrently with it was concurrent either way.
|
|
108
|
+
*/
|
|
109
|
+
export const forgetPreloaded = (entityName: string): void => {
|
|
110
|
+
const ctx = tryUseContext();
|
|
111
|
+
if (ctx === undefined) return;
|
|
112
|
+
const store = requests.get(ctx);
|
|
113
|
+
if (store === undefined) return;
|
|
114
|
+
store.writes.set(entityName, writesTo(store, entityName) + 1);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** The bucket this lookup may be served from, or `undefined` — a stale one is dropped, not read. */
|
|
118
|
+
const usableBucket = <Row>(
|
|
119
|
+
store: Store,
|
|
120
|
+
read: PointRead<Row>,
|
|
121
|
+
scope: string,
|
|
122
|
+
): Bucket | undefined => {
|
|
123
|
+
const open = store.preloaded.get(scope);
|
|
124
|
+
if (open === undefined) return undefined;
|
|
125
|
+
if (open.client === read.client && open.generation === writesTo(store, open.entity)) return open;
|
|
126
|
+
store.preloaded.delete(scope);
|
|
127
|
+
return undefined;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
type Settlers = ReadonlyMap<string, (answer: Answer) => void>;
|
|
131
|
+
|
|
132
|
+
/** One statement at a time: a page wide enough to split must not take the pool with it. */
|
|
133
|
+
const fill = async <Row>(
|
|
134
|
+
read: PointRead<Row>,
|
|
135
|
+
ids: readonly unknown[],
|
|
136
|
+
settlers: Settlers,
|
|
137
|
+
): Promise<void> => {
|
|
138
|
+
for (const chunk of statementChunks(ids)) {
|
|
139
|
+
let answers: ReadonlyMap<string, Answer>;
|
|
140
|
+
try {
|
|
141
|
+
answers = await readByIds(read, chunk);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
// The statement failed, so it fails for everyone it was widened to cover — which is what
|
|
144
|
+
// the single statement each of them would have sent would have done.
|
|
145
|
+
for (const id of chunk) settlers.get(keyOf(read.key.kind, id))?.({ error });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
for (const id of chunk) {
|
|
149
|
+
const at = keyOf(read.key.kind, id);
|
|
150
|
+
// An id the statement did not answer for is a row that is not there — `findById`'s null.
|
|
151
|
+
settlers.get(at)?.(answers.get(at) ?? { row: null });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The statement, and one promise per id it will answer for. Only the ids the bucket does not
|
|
158
|
+
* already hold are read: a second lookup into a page it already resolved is memory, not a wire.
|
|
159
|
+
*/
|
|
160
|
+
const preload = <Row>(read: PointRead<Row>, bucket: Bucket, ids: readonly unknown[]): void => {
|
|
161
|
+
const settlers = new Map<string, (answer: Answer) => void>();
|
|
162
|
+
const wanted: unknown[] = [];
|
|
163
|
+
for (const id of ids) {
|
|
164
|
+
const at = keyOf(read.key.kind, id);
|
|
165
|
+
if (bucket.rows.has(at)) continue;
|
|
166
|
+
// The executor runs synchronously, so `settle` is assigned before the promise is stored.
|
|
167
|
+
let settle!: (answer: Answer) => void;
|
|
168
|
+
bucket.rows.set(
|
|
169
|
+
at,
|
|
170
|
+
new Promise<Answer>((resolve) => {
|
|
171
|
+
settle = resolve;
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
settlers.set(at, settle);
|
|
175
|
+
wanted.push(id);
|
|
176
|
+
}
|
|
177
|
+
// Nothing awaits this: an id nobody asks for still settles, and it settles with an `Answer`
|
|
178
|
+
// rather than a rejection, so a failed statement cannot go unhandled.
|
|
179
|
+
void fill(read, wanted, settlers);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const answered = <Row>(answer: Promise<Answer>): Promise<Row | null> =>
|
|
183
|
+
answer.then((settled) =>
|
|
184
|
+
'error' in settled ? Promise.reject(settled.error) : (settled.row as Row | null),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The row this lookup is served from a page already read, or `undefined` when no page can answer
|
|
189
|
+
* it: nothing read in this request, an id that is no page's foreign key, a write since, or
|
|
190
|
+
* another client. Declining is always correct — the caller reads the statement it always read.
|
|
191
|
+
*/
|
|
192
|
+
export const preloadedFindById = <Row>(
|
|
193
|
+
ctx: Ctx,
|
|
194
|
+
read: PointRead<Row>,
|
|
195
|
+
scope: string,
|
|
196
|
+
id: unknown,
|
|
197
|
+
): Promise<Row | null> | undefined => {
|
|
198
|
+
const store = requests.get(ctx);
|
|
199
|
+
if (store === undefined) return undefined;
|
|
200
|
+
const filedAt = keyOf(read.key.kind, id);
|
|
201
|
+
const bucket = usableBucket(store, read, scope);
|
|
202
|
+
const already = bucket?.rows.get(filedAt);
|
|
203
|
+
if (already !== undefined) return answered<Row>(already);
|
|
204
|
+
const ids = store.siblings.get(siblingKey(read.entity.$name, read.key.property))?.get(filedAt);
|
|
205
|
+
if (ids === undefined) return undefined;
|
|
206
|
+
const target = bucket ?? {
|
|
207
|
+
entity: read.entity.$name,
|
|
208
|
+
client: read.client,
|
|
209
|
+
generation: writesTo(store, read.entity.$name),
|
|
210
|
+
rows: new Map<string, Promise<Answer>>(),
|
|
211
|
+
};
|
|
212
|
+
store.preloaded.set(scope, target);
|
|
213
|
+
preload(read, target, ids);
|
|
214
|
+
const answer = target.rows.get(filedAt);
|
|
215
|
+
return answer === undefined ? undefined : answered<Row>(answer);
|
|
216
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Which fix a repeated statement has earned. The relations the schema already declared decide it:
|
|
2
|
+
// a loop over a page reads what one `preload()` would have carried, so the map that names the
|
|
3
|
+
// relation is the map that writes the fix line — never a name this file invents.
|
|
4
|
+
//
|
|
5
|
+
// Nothing here counts anything. A ledger elsewhere decides that a shape repeated; this turns that
|
|
6
|
+
// verdict into the one error the surfaces render.
|
|
7
|
+
|
|
8
|
+
import type { EntityError, PreloadCandidate } from './errors';
|
|
9
|
+
import { nPlusOneQuery, nPlusOneWrite } from './errors';
|
|
10
|
+
import type { RelationKind } from './relations';
|
|
11
|
+
import { relationMap } from './relations';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Statements of one shape, in one unit of work, before the loop is worth reporting. Five, because a
|
|
15
|
+
* page that reads the same shape four times is a page with four reads and one that reads it fifty
|
|
16
|
+
* times is a loop over rows — and the threshold has to sit far enough above the first number that a
|
|
17
|
+
* fixed-arity render never trips it.
|
|
18
|
+
*
|
|
19
|
+
* It lives here, with the codes and the fix, because it is the number that decides a *verdict*: the
|
|
20
|
+
* dev ledger (`x dev`) and the strict test fixture (`@ultimat3/testing`) are two detectors, and two
|
|
21
|
+
* numbers would mean a loop that fails a test and a loop that warns in dev are different loops.
|
|
22
|
+
* What a unit of work *is* stays each detector's — a request there, one test here.
|
|
23
|
+
*/
|
|
24
|
+
export const N_PLUS_ONE_THRESHOLD = 5;
|
|
25
|
+
|
|
26
|
+
/** One statement shape, repeated inside one request — a ledger's verdict, as this layer reads it. */
|
|
27
|
+
export interface StatementLoop {
|
|
28
|
+
/** Which of the two codes this is: decided from the statement, upstream, never re-derived here. */
|
|
29
|
+
readonly kind: 'read' | 'write';
|
|
30
|
+
/** What repeated: `members.findById` when a repository sent it, else the statement's own text. */
|
|
31
|
+
readonly subject: string;
|
|
32
|
+
/** Statements of this shape in the request. Reported, not judged — the threshold is the ledger's. */
|
|
33
|
+
readonly count: number;
|
|
34
|
+
/** The entity the repository call named. Absent for hand-written SQL, which names no chain. */
|
|
35
|
+
readonly entity?: string | undefined;
|
|
36
|
+
/** The repository operation — `findById`, `insert`. Absent for the same reason. */
|
|
37
|
+
readonly op?: string | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Which relation kind would have carried the rows this operation read one at a time.
|
|
42
|
+
*
|
|
43
|
+
* A point lookup per row is the `belongsTo` side — fifty `members.findById` are one page's authors,
|
|
44
|
+
* and `posts.preload('author')` is one statement for all of them. A filtered read per row is the
|
|
45
|
+
* `hasMany` side — fifty `comments.findMany({ postId })` are one page's comment lists, and
|
|
46
|
+
* `posts.preload('comments')` is the same one statement. Every other operation is left to the `in`
|
|
47
|
+
* form: a repeated `count` or `countBy` is answered by `countBy`, and a preload attaches rows to a
|
|
48
|
+
* page that a count never read.
|
|
49
|
+
*/
|
|
50
|
+
const PRELOADABLE_BY_OP: Readonly<Record<string, RelationKind>> = {
|
|
51
|
+
findById: 'belongsTo',
|
|
52
|
+
findMany: 'hasMany',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every page that could have preloaded these rows, in the map's own sorted order.
|
|
57
|
+
*
|
|
58
|
+
* The edges are read by their `to` end, because that is the entity the loop repeated on: a lookup
|
|
59
|
+
* of `members` is fixed on whatever holds a `references()` to it, and this diagnostic only ever saw
|
|
60
|
+
* the statement — never the `for … of` above it — so it cannot know which of those pages was being
|
|
61
|
+
* iterated. Naming them all is what `preloadUnknownRelation` already does with relation names.
|
|
62
|
+
*/
|
|
63
|
+
export const preloadsFor = (
|
|
64
|
+
entityName: string,
|
|
65
|
+
op: string | undefined,
|
|
66
|
+
): readonly PreloadCandidate[] => {
|
|
67
|
+
const kind = op === undefined ? undefined : PRELOADABLE_BY_OP[op];
|
|
68
|
+
if (kind === undefined) return [];
|
|
69
|
+
const candidates: PreloadCandidate[] = [];
|
|
70
|
+
for (const relations of Object.values(relationMap())) {
|
|
71
|
+
for (const relation of Object.values(relations)) {
|
|
72
|
+
if (relation.kind !== kind || relation.to !== entityName) continue;
|
|
73
|
+
candidates.push({ from: relation.from, relation: relation.name });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return candidates;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A schema whose relations cannot be named answers with the `in` form rather than with its own
|
|
81
|
+
* complaint: `relationMap()` throws `X_INVARIANT_VIOLATED` on two foreign keys it cannot tell
|
|
82
|
+
* apart, and a diagnostic that replaced the loop it was reporting with a schema error would hide
|
|
83
|
+
* the N+1 behind a fault the loop did not cause — in a dev process, as an uncaught throw.
|
|
84
|
+
*/
|
|
85
|
+
const preloadsOrNone = (
|
|
86
|
+
entityName: string,
|
|
87
|
+
op: string | undefined,
|
|
88
|
+
): readonly PreloadCandidate[] => {
|
|
89
|
+
try {
|
|
90
|
+
return preloadsFor(entityName, op);
|
|
91
|
+
} catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** The read half: the preload the schema names, else the `in` form of the statement it repeated. */
|
|
97
|
+
const readLoop = (loop: StatementLoop): EntityError => {
|
|
98
|
+
if (loop.entity === undefined) return nPlusOneQuery(loop.subject, loop.count, { form: 'sql' });
|
|
99
|
+
// The destructure is what proves the fix has something to name — an empty candidate list is an
|
|
100
|
+
// empty `preload` fix, which is the one thing the error contract refuses outright.
|
|
101
|
+
const [first, ...rest] = preloadsOrNone(loop.entity, loop.op);
|
|
102
|
+
if (first === undefined) {
|
|
103
|
+
return nPlusOneQuery(loop.subject, loop.count, { form: 'in', entity: loop.entity });
|
|
104
|
+
}
|
|
105
|
+
return nPlusOneQuery(loop.subject, loop.count, { form: 'preload', candidates: [first, ...rest] });
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* One repeated shape, as the error every surface renders. One entry point and not two, because the
|
|
110
|
+
* caller already knows which of the two codes it holds and a second decision here is a second place
|
|
111
|
+
* for the read and the write halves to disagree about what a loop is.
|
|
112
|
+
*/
|
|
113
|
+
export const nPlusOne = (loop: StatementLoop): EntityError =>
|
|
114
|
+
loop.kind === 'read'
|
|
115
|
+
? readLoop(loop)
|
|
116
|
+
: nPlusOneWrite(
|
|
117
|
+
loop.subject,
|
|
118
|
+
loop.count,
|
|
119
|
+
loop.entity === undefined
|
|
120
|
+
? { form: 'sql' }
|
|
121
|
+
: { form: 'bulk', entity: loop.entity, op: loop.op },
|
|
122
|
+
);
|