@ultimat3/entity 1.2.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/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()` is unbound on purpose: it is written next to the columns, and `entity()` binds
7
- // it to their physical names. That is what keeps a physical name from being typed twice.
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, InvariantColumns, Resolve, Row } from './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
- readonly holds: (row: T) => boolean;
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 build: (columns: InvariantColumns) => Expr;
42
+ readonly expr: Expr;
34
43
  }
35
44
 
36
- /** `invariant('post_like_count_non_negative', (c) => c.likeCount.atLeast(0))` */
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.build(columns);
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
+ );