@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/batch.ts ADDED
@@ -0,0 +1,125 @@
1
+ // Single responsibility: what `inBatches(size)` hands back — the keyset iteration a `for await`
2
+ // consumes, one page per statement, holding its own position and closed by the loop that reads it.
3
+ //
4
+ // Not a second read path. Every batch is the `findMany` the chain would have sent at that
5
+ // position, so filters, tenancy, soft delete, the projection and every `preload()` mean here
6
+ // exactly what they mean in `page()`. What this file owns is the loop, the refusals that belong on
7
+ // the chain rather than one batch later, and what closing means.
8
+
9
+ import { assertSeekable } from './cursor';
10
+ import type { EntityCore } from './entity';
11
+ import { EntityError } from './errors';
12
+ import { MAX_PAGE_SIZE, totalOrder } from './plan';
13
+ import type { Page } from './repo';
14
+ import type { SortKey } from './tenancy';
15
+
16
+ /**
17
+ * A batch size is rows, whole, at least one and at most `MAX_PAGE_SIZE`. `0`, a fraction and a
18
+ * `NaN` from a parsed environment variable all reach the same statement — `limit 0` reads nothing
19
+ * forever — so they are refused where they were written instead of hanging a job. The ceiling is
20
+ * the top of the same range: a batch IS a page (`inBatches` sends the `findMany` the chain would
21
+ * have sent), so `planFor` would refuse it one statement in, in `limit()`'s voice, for an author
22
+ * who never wrote a `limit()`.
23
+ */
24
+ const badBatchSize = (entityName: string, size: number): EntityError =>
25
+ new EntityError({
26
+ code: 'X_INVARIANT_VIOLATED',
27
+ cause: `${entityName}.inBatches(${String(size)}) — a batch is a whole number of rows, at least one and at most ${MAX_PAGE_SIZE}`,
28
+ fix: `${entityName}.inBatches(500) # the rows one statement reads`,
29
+ });
30
+
31
+ /**
32
+ * `limit()` bounds one page; `inBatches()` reads every page. A chain saying both has written one
33
+ * number with two meanings, and neither reading is safe to guess: honouring the limit reads a
34
+ * fraction of a batch, dropping it reads the whole table the caller thought they had bounded.
35
+ */
36
+ const limitedBatches = (entityName: string, limit: number, size: number): EntityError =>
37
+ new EntityError({
38
+ code: 'X_INVARIANT_VIOLATED',
39
+ cause: `${entityName}.limit(${limit}).inBatches(${size}) — limit() bounds one page, inBatches() reads every page`,
40
+ fix: `${entityName}.inBatches(${limit}) # drop the limit(): the batch size is the page size`,
41
+ });
42
+
43
+ /**
44
+ * What the chain has to be before a batch of it exists. Every one of these is the author's own
45
+ * text, so all three are refused when `inBatches()` is called rather than one statement in: an
46
+ * iteration that reads half a table and then fails has already done half the work twice.
47
+ */
48
+ export const assertBatchable = <Row>(
49
+ entity: EntityCore<Row>,
50
+ size: number,
51
+ chain: { readonly limit: number | undefined; readonly orderBy: readonly SortKey[] },
52
+ ): void => {
53
+ if (!Number.isSafeInteger(size) || size < 1 || size > MAX_PAGE_SIZE) {
54
+ throw badBatchSize(entity.$name, size);
55
+ }
56
+ if (chain.limit !== undefined) throw limitedBatches(entity.$name, chain.limit, size);
57
+ // The order the driver will sort by, primary key included: the cursor between two batches is
58
+ // minted from it, and an ordering that cannot carry one fails on the batch *after* the first —
59
+ // where whatever size the caller happened to pass decides whether anyone ever finds out.
60
+ assertSeekable(entity, totalOrder(entity, chain.orderBy));
61
+ };
62
+
63
+ /** Where the batches come from: the chain's own starting position, and one page of it at a time. */
64
+ export interface BatchRead<Row> {
65
+ /** `after(cursor)` on the chain, or `null` for the first batch. */
66
+ readonly from: string | null;
67
+ /** The page this chain reads at `cursor` — relations attached, exactly as `page()` returns it. */
68
+ page(cursor: string | null): Promise<Page<Row>>;
69
+ }
70
+
71
+ /**
72
+ * One iteration, one handle. `for await` consumes it and closes it on the way out — `break`,
73
+ * `return` and a throw all call `return()` on the iterator, which is what stops the next statement
74
+ * from going out; `await using` is the same guarantee for a handle kept in a variable. It is its
75
+ * own iterator, so a second `for await` continues where the first stopped instead of re-reading
76
+ * the table from the top.
77
+ */
78
+ export interface BatchIterator<Row> extends AsyncIterable<readonly Row[]>, AsyncDisposable {
79
+ /**
80
+ * Where the next batch starts, `null` once there is no next one. Persist it and
81
+ * `.after(cursor).inBatches(size)` resumes the iteration — which is what makes stopping early
82
+ * cheap rather than wasted.
83
+ */
84
+ readonly cursor: string | null;
85
+ /** Ends the iteration. Idempotent, and what `await using` calls. */
86
+ close(): Promise<void>;
87
+ }
88
+
89
+ /**
90
+ * The loop itself. Keyset, never OFFSET: each batch resumes from the cursor the previous one ended
91
+ * on, so a row inserted or deleted mid-iteration cannot make the loop skip or repeat one — the
92
+ * whole reason this package has no `offset`.
93
+ */
94
+ export const batchIterator = <Row>(read: BatchRead<Row>): BatchIterator<Row> => {
95
+ let cursor = read.from;
96
+
97
+ async function* batches(): AsyncGenerator<readonly Row[], void, undefined> {
98
+ do {
99
+ const page = await read.page(cursor);
100
+ // Advanced before the yield, so a consumer that breaks reads `.cursor` as the position it
101
+ // stopped at rather than the one it just consumed.
102
+ cursor = page.nextCursor;
103
+ // Never an empty batch: a consumer forced to check `batch.length` is reading around the
104
+ // iterator instead of through it. The last page ends the loop by its cursor, not by
105
+ // yielding nothing.
106
+ if (page.rows.length > 0) yield page.rows;
107
+ } while (cursor !== null);
108
+ }
109
+
110
+ // Created once and never restarted: the generator's own state is what "closed" means, so
111
+ // `close()` is idempotent by construction rather than by a flag two paths could disagree about.
112
+ const iterator = batches();
113
+ const close = async (): Promise<void> => {
114
+ await iterator.return(undefined);
115
+ };
116
+
117
+ return {
118
+ get cursor(): string | null {
119
+ return cursor;
120
+ },
121
+ [Symbol.asyncIterator]: () => iterator,
122
+ close,
123
+ [Symbol.asyncDispose]: close,
124
+ };
125
+ };
@@ -0,0 +1,285 @@
1
+ // Single responsibility: what a many-row write is made of, decided once in PROPERTY space so both
2
+ // drivers write the same thing — the columns one statement carries, which of them a collision
3
+ // overwrites, and the chunking that keeps a statement inside Postgres's bind count. The SQL is
4
+ // `pg-sql.ts`'s; everything here is the decision that has to precede it, and the in-memory driver
5
+ // reads the same answer rather than a second copy of the rule.
6
+
7
+ import { keyOf } from './batch-read';
8
+ import { valueAt } from './cursor';
9
+ import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
10
+ import { EntityError, invariantViolated } from './errors';
11
+ import { columnsOf } from './pg-row';
12
+
13
+ /**
14
+ * Postgres binds at most 65535 parameters in one statement and a multi-row insert spends
15
+ * rows × columns of them, so a wide batch becomes several statements rather than one the server
16
+ * refuses — the same rule `MAX_IDS_PER_STATEMENT` (`batch-read.ts`) applies to a batched read.
17
+ */
18
+ export const MAX_BIND_PARAMETERS = 65535;
19
+
20
+ /** What an `upsertAll` resolves to. Property names, so the two drivers read one answer. */
21
+ export interface UpsertPlan {
22
+ /** The unique constraint a collision is judged against. */
23
+ readonly on: readonly string[];
24
+ /** What a colliding row takes from the incoming one. Empty leaves the stored row alone. */
25
+ readonly set: readonly string[];
26
+ }
27
+
28
+ const owns = (row: unknown, property: string): boolean =>
29
+ typeof row === 'object' && row !== null && Object.hasOwn(row, property);
30
+
31
+ /**
32
+ * The properties a batch writes: every declared column at least one row names, in declaration
33
+ * order. `Object.hasOwn` decides it, exactly as `bindValues` does — a property present and
34
+ * `undefined` is a value the caller wrote, and dropping it here would insert a column the update
35
+ * set then skipped.
36
+ */
37
+ export const namedProperties = <Row>(
38
+ entity: EntityCore<Row>,
39
+ rows: readonly Partial<Row>[],
40
+ ): readonly string[] =>
41
+ Object.keys(entity.$columns).filter((property) => rows.some((row) => owns(row, property)));
42
+
43
+ /** Those properties as physical columns. Money is two columns, so this is not the same list. */
44
+ export const insertColumns = <Row>(
45
+ entity: EntityCore<Row>,
46
+ properties: readonly string[],
47
+ ): readonly string[] =>
48
+ properties.flatMap((property) => {
49
+ const column = entity.$columns[property];
50
+ return column === undefined ? [] : columnsOf(property, column);
51
+ });
52
+
53
+ /**
54
+ * Rows per statement: the bind budget divided by one row's width, never fewer than one. Splitting
55
+ * can only cost round trips; not splitting would fail a write that succeeded one row at a time.
56
+ */
57
+ export const insertChunks = <T>(rows: readonly T[], width: number): readonly (readonly T[])[] => {
58
+ if (rows.length === 0) return [];
59
+ const size = width <= 0 ? rows.length : Math.max(1, Math.floor(MAX_BIND_PARAMETERS / width));
60
+ const chunks: (readonly T[])[] = [];
61
+ for (let from = 0; from < rows.length; from += size) chunks.push(rows.slice(from, from + size));
62
+ return chunks;
63
+ };
64
+
65
+ /**
66
+ * Not `invariantViolated`: its fix opens `x entity explain`, which describes invariants nobody
67
+ * wrote here. What repairs each of these is one edit to the call.
68
+ */
69
+ const noConflictTarget = (entityName: string): EntityError =>
70
+ new EntityError({
71
+ code: 'X_INVARIANT_VIOLATED',
72
+ cause: `${entityName}.upsertAll() named no onConflict columns — a collision is judged against a unique constraint, never against "any"`,
73
+ fix: `${entityName}.upsertAll(rows, { onConflict: ['<column>'] }) # the columns of the unique index a duplicate lands on`,
74
+ });
75
+
76
+ const nothingToSet = (
77
+ entityName: string,
78
+ on: readonly string[],
79
+ spared: readonly string[],
80
+ ): EntityError =>
81
+ new EntityError({
82
+ code: 'X_INVARIANT_VIOLATED',
83
+ cause: `${entityName}.upsertAll() would write nothing on a collision: every column in the batch is one a collision never moves (${spared.join(', ')})`,
84
+ fix: `${entityName}.upsertAll(rows, { onConflict: ['${on.join("', '")}'], onMatch: 'nothing' }) # keep the stored row`,
85
+ });
86
+
87
+ const collidesWithItself = (
88
+ entityName: string,
89
+ on: readonly string[],
90
+ position: number,
91
+ ): EntityError =>
92
+ new EntityError({
93
+ code: 'X_INVARIANT_VIOLATED',
94
+ cause: `${entityName}.upsertAll() row ${position + 1} repeats a value of (${on.join(', ')}) already in the batch — one statement cannot update the same row twice`,
95
+ fix: `${entityName}.upsertAll(rows, { onConflict: ['${on.join("', '")}'], onMatch: 'nothing' }) # or dedupe rows on (${on.join(', ')}) before the call`,
96
+ });
97
+
98
+ const noSuchConstraint = (
99
+ entityName: string,
100
+ on: readonly string[],
101
+ declared: readonly string[],
102
+ ): EntityError =>
103
+ new EntityError({
104
+ code: 'X_INVARIANT_VIOLATED',
105
+ cause: `${entityName} declares no unique constraint on (${on.join(', ')}); Postgres answers a conflict target it cannot match with 42P10${declared.length === 0 ? '' : ` — it has ${declared.join('; ')}`}`,
106
+ fix: `indexes: [{ on: ['${on.join("', '")}'], unique: true }] # in the ${entityName} entity(), then x db gen "unique ${on.join(' ')}"`,
107
+ });
108
+
109
+ const unevenBatch = (entityName: string, position: number, missing: string): EntityError =>
110
+ new EntityError({
111
+ code: 'X_INVARIANT_VIOLATED',
112
+ cause: `${entityName}.upsertAll() row ${position + 1} does not name "${missing}", which other rows of the batch do — under onMatch: 'update' a column one row omits would be overwritten with that column's default, not left alone`,
113
+ fix: `${entityName}.upsertAll(rows.map((row) => ({ ...row, ${missing}: <value> })), args) # every row of an updating batch names the same columns`,
114
+ });
115
+
116
+ /**
117
+ * A cross-tenant upsert is not a batching mistake, it is a write into another tenant's row, so it
118
+ * gets tenancy's own code. `X_TENANCY_UNSCOPED`'s own factory sends the reader to the request
119
+ * context the tenant is derived from, which is not what repairs this: an upsert builds no read
120
+ * plan, so nothing derives anything onto it — the scope has to be part of the constraint the
121
+ * collision is judged against, or the row that comes back was never in scope to begin with.
122
+ */
123
+ const crossTenantUpsert = (
124
+ entityName: string,
125
+ tenant: string,
126
+ on: readonly string[],
127
+ ): EntityError =>
128
+ new EntityError({
129
+ code: 'X_TENANCY_UNSCOPED',
130
+ cause: `${entityName}.upsertAll() resolves a collision on (${on.join(', ')}), which does not include the tenant column "${tenant}" — a row stored by another tenant would match and be overwritten`,
131
+ fix: `${entityName}.upsertAll(rows, { onConflict: ['${tenant}', '${on.join("', '")}'] }) # or onMatch: 'nothing', which writes nothing to a row it does not own`,
132
+ });
133
+
134
+ /**
135
+ * Every conflict target Postgres could infer an index for. Three sources, because this framework
136
+ * has three ways to declare one unique index and a target refused for being declared the "wrong"
137
+ * way would send its author to add a SECOND declaration of a constraint they already wrote:
138
+ * the primary key, `unique()`/`indexes: [{ unique: true }]` (both land in `$indexes`), and
139
+ * `invariant(name, c.unique([…]))`, which emits its `create unique index` out of `$invariants`.
140
+ *
141
+ * A partial unique index is excluded from all three: its predicate would have to be repeated in
142
+ * the `on conflict` clause, which this layer does not spell. `bindInvariant` stamps that `where`
143
+ * on a soft-deleting entity, so the same one rule covers both lists.
144
+ */
145
+ const uniqueTargets = <Row>(entity: EntityCore<Row>): readonly (readonly string[])[] => [
146
+ insertColumns(entity, entity.$primaryKey),
147
+ ...entity.$indexes.filter((i) => i.unique && i.where === undefined).map((i) => i.columns),
148
+ ...entity.$invariants
149
+ .filter((i) => i.kind === 'unique' && i.where === undefined)
150
+ .map((i) => i.columns),
151
+ ];
152
+
153
+ const sameColumns = (left: readonly string[], right: readonly string[]): boolean =>
154
+ left.length === right.length && [...left].sort().join() === [...right].sort().join();
155
+
156
+ /**
157
+ * The conflict target, and what a collision overwrites. `'nothing'` overwrites nothing by
158
+ * definition; `'update'` takes every column the batch writes except three closed sets — the
159
+ * conflict target, which is how the stored row was found, the primary key, which is its address,
160
+ * and the soft-delete stamp, which is whether the row is there at all. An upsert that moved one of
161
+ * the first two would move a row nobody asked to move, and every foreign key already pointing at
162
+ * that id would miss it.
163
+ *
164
+ * The stamp is the third for a reason no caller can work around: a soft-deleted row still occupies
165
+ * its conflict target — the unique index it collides with is not partial — so setting `deleted_at`
166
+ * from `excluded` would clear a stamp the app wrote and hand the row back holding this batch's
167
+ * values. That is the resurrection `update(id, patch)` and `updateWhere` both refuse by carrying
168
+ * `deleted_at is null`, which an `on conflict` clause cannot carry. Excluded rather than refused,
169
+ * because `$parse` fills every declared column before a row reaches here: the `deletedAt: null` in
170
+ * the batch is the framework's, not the caller's, and refusing it would make `'update'` impossible
171
+ * on every soft-deleting entity. `insertAll` is untouched — a row with no stored row to collide
172
+ * with writes the stamp it carries, exactly as `insert` does.
173
+ *
174
+ * Four refusals precede all of that, and each one is a statement Postgres would either reject or,
175
+ * worse, accept: a target no declared unique constraint matches (`42P10`), a target that does not
176
+ * carry the tenant column under `'update'` (another tenant's row, silently rewritten), a batch
177
+ * whose rows name different columns under `'update'` (`excluded.<col>` is the column's default for
178
+ * a row that omitted it, so "leave it alone" is not what happens), and a target that leaves
179
+ * nothing to write.
180
+ */
181
+ export const upsertPlan = <Row>(
182
+ entity: EntityCore<Row>,
183
+ rows: readonly Partial<Row>[],
184
+ onConflict: readonly string[],
185
+ onMatch: 'update' | 'nothing',
186
+ ): UpsertPlan => {
187
+ if (onConflict.length === 0) throw noConflictTarget(entity.$name);
188
+ for (const property of onConflict) {
189
+ if (entity.$columns[property] === undefined) {
190
+ throw invariantViolated(
191
+ entity.$name,
192
+ 'upsertAll',
193
+ `no column "${property}" — pick from: ${Object.keys(entity.$columns).join(', ')}`,
194
+ );
195
+ }
196
+ }
197
+ const targets = uniqueTargets(entity);
198
+ const physical = insertColumns(entity, onConflict);
199
+ if (!targets.some((target) => sameColumns(target, physical))) {
200
+ throw noSuchConstraint(
201
+ entity.$name,
202
+ onConflict,
203
+ targets.map((target) => `(${target.join(', ')})`),
204
+ );
205
+ }
206
+ if (onMatch === 'nothing') return { on: onConflict, set: [] };
207
+ const tenant = entity.$tenantColumn;
208
+ if (tenant !== null && !onConflict.includes(tenant)) {
209
+ throw crossTenantUpsert(entity.$name, tenant, onConflict);
210
+ }
211
+ const properties = namedProperties(entity, rows);
212
+ for (const [position, row] of rows.entries()) {
213
+ const missing = properties.find((property) => !owns(row, property));
214
+ if (missing !== undefined) throw unevenBatch(entity.$name, position, missing);
215
+ }
216
+ const spared = new Set([
217
+ ...onConflict,
218
+ ...entity.$primaryKey,
219
+ ...(entity.$softDelete ? [SOFT_DELETE_COLUMN] : []),
220
+ ]);
221
+ const set = properties.filter((property) => !spared.has(property));
222
+ // An empty batch sends no statement, so there is nothing to refuse — but the target above is
223
+ // checked either way, so a typo in `onConflict` fails on no rows exactly as it does on a page.
224
+ if (set.length === 0 && properties.length > 0) {
225
+ throw nothingToSet(entity.$name, onConflict, [...spared]);
226
+ }
227
+ return { on: onConflict, set };
228
+ };
229
+
230
+ /**
231
+ * One cell of a conflict target. `keyOf` alone is `batch-read.ts`'s spelling of an *id*, and three
232
+ * kinds need more than it: a `Date` stringifies without its milliseconds, an object (money is one)
233
+ * stringifies to `[object Object]`, and — the one that changes an answer — a null is not a value
234
+ * Postgres compares. A default unique index is `NULLS DISTINCT`, so two rows with a null in the
235
+ * target collide with nothing, not with each other.
236
+ */
237
+ const cellKey = (kind: string, value: unknown): string | undefined => {
238
+ if (value === null || value === undefined) return undefined;
239
+ if (value instanceof Date) return `date:${value.getTime()}`;
240
+ if (typeof value === 'object') {
241
+ return `json:${JSON.stringify(value, (_, part) => (typeof part === 'bigint' ? `${part}n` : part))}`;
242
+ }
243
+ return `value:${keyOf(kind, value)}`;
244
+ };
245
+
246
+ /**
247
+ * One row's conflict target as a string, spelled the way a batched read spells a key — a `uuid`
248
+ * handed in upper case is the value Postgres matches, so it must be the value matched here.
249
+ * `undefined` when any cell is null: that row's target is distinct from every other, as it is in
250
+ * the index, so it collides with nothing.
251
+ */
252
+ export const conflictKeyOf = <Row>(
253
+ entity: EntityCore<Row>,
254
+ on: readonly string[],
255
+ row: Partial<Row>,
256
+ ): string | undefined => {
257
+ const cells = on.map((property) =>
258
+ cellKey(entity.$columns[property]?.$meta.kind ?? '', valueAt(row, property)),
259
+ );
260
+ return cells.some((cell) => cell === undefined) ? undefined : JSON.stringify(cells);
261
+ };
262
+
263
+ /**
264
+ * Every row's conflict key, refusing a batch that collides with itself. Postgres answers two rows
265
+ * with one conflict target under `do update` with `ON CONFLICT DO UPDATE command cannot affect row
266
+ * a second time`, so it is refused here — in both drivers — rather than passing in memory and
267
+ * failing in production. Under `do nothing` the server skips the repeat, and so does the memory
268
+ * driver, which is why this only guards the update form.
269
+ */
270
+ export const conflictKeys = <Row>(
271
+ entity: EntityCore<Row>,
272
+ plan: UpsertPlan,
273
+ rows: readonly Partial<Row>[],
274
+ ): readonly (string | undefined)[] => {
275
+ const keys = rows.map((row) => conflictKeyOf(entity, plan.on, row));
276
+ if (plan.set.length === 0) return keys;
277
+ const seen = new Set<string>();
278
+ for (const [position, key] of keys.entries()) {
279
+ // A null in the target is no key at all, so it repeats nothing — `NULLS DISTINCT` again.
280
+ if (key === undefined) continue;
281
+ if (seen.has(key)) throw collidesWithItself(entity.$name, plan.on, position);
282
+ seen.add(key);
283
+ }
284
+ return keys;
285
+ };
@@ -0,0 +1,175 @@
1
+ // Single responsibility: collapse the point lookups one request issues in the same microtask into
2
+ // a single `where id in (…)`, and hand a lookup a page already answered straight to the preload.
3
+ // A list that resolves an author per row is the N+1 this removes, and it removes it without adding
4
+ // a second way to read — `findById` keeps its signature and its meaning, and pays for one round
5
+ // trip instead of one per row.
6
+
7
+ import { type Ctx, tryUseContext } from '@ultimat3/core';
8
+ import type { DbClient } from '@ultimat3/db';
9
+ import {
10
+ type Answer,
11
+ type KeyColumn,
12
+ keyOf,
13
+ type PointRead,
14
+ readByIds,
15
+ scopeKey,
16
+ statementChunks,
17
+ } from './batch-read';
18
+ import type { EntityCore } from './entity';
19
+ import { preloadedFindById } from './jit-preload';
20
+ import { physicalName } from './pg-row';
21
+ import type { ReadShape } from './pg-sql';
22
+ import type { QueryPlan } from './tenancy';
23
+
24
+ /** One caller's lookup: the row it asked for, and the two ends of the promise it is holding. */
25
+ interface Pending {
26
+ readonly id: unknown;
27
+ /** What the answer will be filed under — `keyOf(id)`, not the id itself. */
28
+ readonly key: string;
29
+ readonly row: Promise<unknown>;
30
+ readonly settle: (row: unknown) => void;
31
+ readonly fail: (error: unknown) => void;
32
+ }
33
+
34
+ /** One statement in the making: the ids collected so far, and the read that will send them. */
35
+ interface Batch {
36
+ /** Two lookups share a statement only when they read from the same place. */
37
+ readonly client: DbClient;
38
+ /** Keyed by `String(id)`, so the same id asked for twice is one bind and one row. */
39
+ readonly pending: Map<string, Pending>;
40
+ readonly load: (ids: readonly unknown[]) => Promise<ReadonlyMap<string, Answer>>;
41
+ }
42
+
43
+ /**
44
+ * Per request, keyed by ctx identity, so a batch dies with the request that opened it — the shape
45
+ * `@ultimat3/query`'s request memo has one tier up. `entity` cannot import that one (tier 2 to
46
+ * tier 3 is upward), so it owns this one.
47
+ */
48
+ const requests = new WeakMap<object, Map<string, Batch>>();
49
+
50
+ const batchesFor = (ctx: Ctx): Map<string, Batch> => {
51
+ const key: object = ctx;
52
+ const existing = requests.get(key);
53
+ if (existing !== undefined) return existing;
54
+ const created = new Map<string, Batch>();
55
+ requests.set(key, created);
56
+ return created;
57
+ };
58
+
59
+ const pendingFor = (id: unknown, key: string): Pending => {
60
+ // The executor runs synchronously, so both are assigned before this returns. TypeScript cannot
61
+ // see through the callback, which is all the definite assignments claim.
62
+ let settle!: (row: unknown) => void;
63
+ let fail!: (error: unknown) => void;
64
+ const row = new Promise<unknown>((resolve, reject) => {
65
+ settle = resolve;
66
+ fail = reject;
67
+ });
68
+ return { id, key, row, settle, fail };
69
+ };
70
+
71
+ const openBatch = (
72
+ batches: Map<string, Batch>,
73
+ key: string,
74
+ client: DbClient,
75
+ load: Batch['load'],
76
+ ): Batch => {
77
+ const batch: Batch = { client, pending: new Map(), load };
78
+ batches.set(key, batch);
79
+ // The window is one microtask: every lookup issued before the current synchronous run ends
80
+ // shares this statement. It closes here, before the statement is sent, so a lookup arriving
81
+ // mid-flight opens the next batch instead of joining ids already on the wire.
82
+ queueMicrotask(() => {
83
+ if (batches.get(key) === batch) batches.delete(key);
84
+ void flush(batch);
85
+ });
86
+ return batch;
87
+ };
88
+
89
+ const flush = async (batch: Batch): Promise<void> => {
90
+ const waiting = [...batch.pending.values()];
91
+ batch.pending.clear();
92
+ // One statement at a time: a batch wide enough to split must not take the pool with it.
93
+ for (const chunk of statementChunks(waiting)) {
94
+ try {
95
+ const answers = await batch.load(chunk.map((entry) => entry.id));
96
+ for (const entry of chunk) {
97
+ const answer = answers.get(entry.key);
98
+ // An id the statement did not answer for is a row that is not there — `findById`'s `null`,
99
+ // never a rejection, and never another caller's row.
100
+ if (answer === undefined) entry.settle(null);
101
+ else if ('error' in answer) entry.fail(answer.error);
102
+ else entry.settle(answer.row);
103
+ }
104
+ } catch (error) {
105
+ // The statement failed, so everyone in it gets the failure the single statement would have
106
+ // handed them. Every one of these promises was returned to a caller, so none goes unhandled.
107
+ for (const entry of chunk) entry.fail(error);
108
+ }
109
+ }
110
+ };
111
+
112
+ /**
113
+ * The shared read this lookup joins, or `undefined` when there is none to join: no request in
114
+ * scope, a composite key, a scope this cannot compare, or a client the open batch does not read
115
+ * from. Declining is always correct — the caller sends the one statement it always sent.
116
+ *
117
+ * Two shapes, one seam. A page already read answers first (one statement for a whole sequential
118
+ * loop), and what no page can answer joins the microtask batch.
119
+ */
120
+ export const coalesceFindById = <Row>(
121
+ entity: EntityCore<Row>,
122
+ client: DbClient,
123
+ plan: QueryPlan,
124
+ shape: ReadShape,
125
+ id: unknown,
126
+ ): Promise<Row | null> | undefined => {
127
+ const ctx = tryUseContext();
128
+ const [keyColumn] = entity.$primaryKey;
129
+ const declared = keyColumn === undefined ? undefined : entity.$columns[keyColumn];
130
+ if (
131
+ ctx === undefined ||
132
+ keyColumn === undefined ||
133
+ declared === undefined ||
134
+ entity.$primaryKey.length !== 1
135
+ ) {
136
+ return undefined;
137
+ }
138
+ // A seek positions a page, never a point lookup. If one ever reaches here the statement is not
139
+ // the one this batches.
140
+ if (shape.seek !== undefined) return undefined;
141
+ const at = plan.where.findIndex(
142
+ (predicate) =>
143
+ predicate.column === keyColumn && predicate.op === 'eq' && predicate.value === id,
144
+ );
145
+ if (at === -1) return undefined;
146
+ const scoped: QueryPlan = { ...plan, where: plan.where.filter((_, index) => index !== at) };
147
+ const key = scopeKey(entity, scoped, shape);
148
+ if (key === undefined) return undefined;
149
+
150
+ const keyColumnRef: KeyColumn = {
151
+ property: keyColumn,
152
+ column: physicalName(entity, keyColumn),
153
+ kind: declared.$meta.kind,
154
+ };
155
+ const read: PointRead<Row> = { entity, client, scoped, shape, key: keyColumnRef };
156
+ // A page whose foreign keys this id is one of answers for every row of that page at once, which
157
+ // is the only thing that batches a `for … of` loop: its `await` already ended the microtask.
158
+ const preloaded = preloadedFindById<Row>(ctx, read, key, id);
159
+ if (preloaded !== undefined) return preloaded;
160
+
161
+ const batches = batchesFor(ctx);
162
+ const open = batches.get(key);
163
+ // A pinned client and the ambient pool are two places to read from, and a batch is one
164
+ // statement: a lookup that does not share the open batch's client sends its own.
165
+ if (open !== undefined && open.client !== client) return undefined;
166
+ const batch = open ?? openBatch(batches, key, client, (ids) => readByIds(read, ids));
167
+
168
+ const filedAt = keyOf(keyColumnRef.kind, id);
169
+ const already = batch.pending.get(filedAt);
170
+ const pending = already ?? pendingFor(id, filedAt);
171
+ if (already === undefined) batch.pending.set(filedAt, pending);
172
+ // One batch is one entity — the key fixed that before the batch existed — so this re-attaches
173
+ // the row type the store erased rather than asserting anything new about it.
174
+ return pending.row as Promise<Row | null>;
175
+ };
package/src/column.ts CHANGED
@@ -56,6 +56,30 @@ export const bindColumn = (column: AnyColumn, table: string, property: string):
56
56
 
57
57
  export const bindingOf = (column: AnyColumn): Binding | undefined => bindings.get(column);
58
58
 
59
+ /**
60
+ * A declared foreign key, resolved to where its target actually landed — `null` when the column
61
+ * declares none. The thunk exists because two schema modules import each other in a cycle, so
62
+ * this can only be answered after both have evaluated; it is answered in ONE place so the DDL
63
+ * projection (`describe.ts`) and the relation map (`relations.ts`) can never disagree about what
64
+ * a `references()` points at.
65
+ */
66
+ export const referenceBinding = (
67
+ entityName: string,
68
+ property: string,
69
+ meta: ColumnMeta,
70
+ ): Binding | null => {
71
+ if (meta.references === undefined) return null;
72
+ const target = bindingOf(meta.references());
73
+ if (target === undefined) {
74
+ throw invariantViolated(
75
+ entityName,
76
+ property,
77
+ 'references a column that belongs to no entity — pass a column of an entity() result',
78
+ );
79
+ }
80
+ return target;
81
+ };
82
+
59
83
  const literal = (value: unknown): ColumnDefault => {
60
84
  if (value === null) return { kind: 'value', value: null };
61
85
  if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {