@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/src/pg-driver.ts CHANGED
@@ -7,24 +7,48 @@
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 { type DbClient, db, type TransactionOptions, withTransaction } from '@ultimat3/db';
10
+ import { systemClock } from '@ultimat3/core';
11
+ import {
12
+ currentTx,
13
+ type DbClient,
14
+ db,
15
+ type SqlFragment,
16
+ type TransactionOptions,
17
+ withStatementAttribution,
18
+ withTransaction,
19
+ } from '@ultimat3/db';
20
+ import {
21
+ conflictKeys,
22
+ insertChunks,
23
+ insertColumns,
24
+ namedProperties,
25
+ upsertPlan,
26
+ } from './bulk-write';
27
+ import { coalesceFindById } from './coalesce';
11
28
  import { snake } from './column';
29
+ import { countsFrom, groupColumnOf, groupValue, MAX_GROUPS } from './count-by';
12
30
  import { cursorFor, seekFrom, valueAt } from './cursor';
13
31
  import type { Driver } from './database';
14
32
  import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
15
- import { notFound } from './errors';
33
+ import { notFound, repoClientPinned } from './errors';
34
+ import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
35
+ import { forgetPreloaded, tagSiblings } from './jit-preload';
16
36
  import { bindValues, decodeRow, type PhysicalRow } from './pg-row';
17
37
  import {
38
+ type ConflictTarget,
39
+ countByStatement,
18
40
  countStatement,
19
41
  deleteStatement,
42
+ type GroupRow,
20
43
  insertStatement,
21
44
  type ReadShape,
22
45
  selectStatement,
23
46
  updateStatement,
24
47
  } from './pg-sql';
25
- import { idPlan, readPlan } from './plan';
26
- import type { FindManyArgs, Repo, Transactor } from './repo';
48
+ import { deletePlan, idPlan, readPlan, updatePlan } from './plan';
49
+ import type { FindManyArgs, Repo, Transactor, UpsertArgs } from './repo';
27
50
  import type { QueryPlan } from './tenancy';
51
+ import { assertRowTenant } from './tenancy';
28
52
 
29
53
  export interface PostgresDriverOptions {
30
54
  /**
@@ -33,6 +57,12 @@ export interface PostgresDriverOptions {
33
57
  * globally with `setDbClient()`.
34
58
  */
35
59
  readonly client?: DbClient | undefined;
60
+ /**
61
+ * Preload foreign keys resolved by a page into a request-scoped cache, so the first
62
+ * `findById` for any one of them resolves that key for the whole page in one statement.
63
+ * Default: true.
64
+ */
65
+ readonly jitPreload?: boolean | undefined;
36
66
  }
37
67
 
38
68
  const shapeOf = (args: FindManyArgs, seek?: readonly unknown[]): ReadShape => ({
@@ -44,7 +74,41 @@ export const postgresRepo = <Row>(
44
74
  entity: EntityCore<Row>,
45
75
  config: PostgresDriverOptions = {},
46
76
  ): Repo<Row> => {
47
- const client = (): DbClient => config.client ?? db();
77
+ /**
78
+ * The one place a connection is chosen, which is why the transaction guard is here and not on
79
+ * each method. Unpinned, `db()` answers with the open transaction when there is one — that is
80
+ * how a repository call inside `withTransaction` joins it without being told. Pinned, it cannot:
81
+ * `withTransaction` ran `BEGIN` on a connection IT reserved, and a statement sent straight to
82
+ * `config.client` takes a different connection out of the pool, so the write commits whatever
83
+ * the transaction decides and the read cannot see what the transaction has written. Refused
84
+ * rather than resolved — a `DbTx` does not name the client it was opened on, so this layer
85
+ * cannot even tell whether the two are the same database.
86
+ */
87
+ const client = (): DbClient => {
88
+ const pinned = config.client;
89
+ if (pinned === undefined) return db();
90
+ if (currentTx() !== undefined) throw repoClientPinned(entity.$name);
91
+ return pinned;
92
+ };
93
+
94
+ /**
95
+ * Every statement a repository call sends carries the entity and the operation that compiled it,
96
+ * which is what turns "50× `select … where "id" = $1`" into "50× `findById` on `members`" in a
97
+ * diagnostic. This is the last frame that knows both: below it there is only SQL.
98
+ *
99
+ * It wraps the whole call rather than the `client()` handle because the statement is often sent
100
+ * well below — inside the coalescer's microtask flush, inside a chunked write, inside the
101
+ * preload's `readByIds` — and threading a parameter through all of them is the same fact written
102
+ * five times. The scope is an `AsyncLocalStorage` in `@ultimat3/db` (tier 1, downward), so it
103
+ * survives every one of those awaits, and with no observer installed it is not entered at all:
104
+ * one property read and one branch on the production path (axiom 6).
105
+ *
106
+ * `op` is the string the plan builder was already given, named once per method so the operation
107
+ * a refusal reports and the operation a diagnostic reports cannot drift.
108
+ */
109
+ const attributed = <T>(op: string, send: () => Promise<T>): Promise<T> =>
110
+ withStatementAttribution(entity.$name, op, send);
111
+
48
112
  const idOf = (row: Row): string =>
49
113
  entity.$primaryKey.map((property) => String(valueAt(row, property))).join('');
50
114
 
@@ -55,20 +119,112 @@ export const postgresRepo = <Row>(
55
119
  return found === undefined ? null : decodeRow(entity, found);
56
120
  };
57
121
 
122
+ /**
123
+ * Hard or soft, decided once so `delete(id)` and `deleteWhere(filter)` cannot drift apart. Soft
124
+ * delete hides the row without losing it; the column's presence is the switch, and `shapeOf({})`
125
+ * keeps the `deleted_at is null` clause so a second call cannot move an existing stamp forward.
126
+ *
127
+ * No `returning`: both callers send this through `execute()` and read a count, so the rows would
128
+ * be a whole tenant's table crossing the wire for nobody — the same cost `updateWhere` used to
129
+ * pay unconditionally, on the sibling path that looked like the one doing it right.
130
+ */
131
+ const removal = (plan: QueryPlan): SqlFragment =>
132
+ entity.$softDelete
133
+ ? updateStatement(
134
+ entity,
135
+ plan,
136
+ new Map([[snake(SOFT_DELETE_COLUMN), systemClock.now()]]),
137
+ shapeOf({}),
138
+ false,
139
+ )
140
+ : deleteStatement(entity, plan);
141
+
142
+ /**
143
+ * Every write goes out through here, which makes it the ONE place the request's preloaded rows
144
+ * are dropped: a row this statement changes must not be served afterwards from a page read
145
+ * before it. Before the statement, not after — a row read back afterwards is the row this write
146
+ * left, and one read concurrently with it was concurrent either way.
147
+ */
148
+ const writing = <T>(send: () => Promise<T>): Promise<T> => {
149
+ forgetPreloaded(entity.$name);
150
+ return send();
151
+ };
152
+
153
+ /**
154
+ * The one insert path, for one row or ten thousand: the batch's own column list, split into as
155
+ * many statements as Postgres's bind count allows, and the rows the server stored. `insert(row)`
156
+ * comes through here too, so there is no second builder for the two to drift apart in — and a
157
+ * batch wide enough to split is several statements, which is why an all-or-nothing caller wraps
158
+ * the call in `withTransaction` rather than trusting one statement's atomicity.
159
+ */
160
+ const writeRows = async (
161
+ op: string,
162
+ batch: readonly Row[],
163
+ conflict: ConflictTarget | undefined,
164
+ ): Promise<readonly Row[]> => {
165
+ if (batch.length === 0) return [];
166
+ // The whole batch is judged before any statement exists, tenant and invariants together, in
167
+ // the same loop and for the same reason: Postgres refuses one statement as one, so a row this
168
+ // actor may not write must stop the rows beside it too. `memoryRepo`'s `write` runs the same
169
+ // pair — an insert builds no read plan, so this is the only place the tenant is checked.
170
+ for (const row of batch) {
171
+ assertRowTenant(entity.$name, entity.$tenantColumn, op, row);
172
+ entity.$assert(row);
173
+ }
174
+ const columns = insertColumns(entity, namedProperties(entity, batch));
175
+ const bound = batch.map((row) => bindValues(entity, row));
176
+ const shape = { columns, ...(conflict === undefined ? {} : { conflict }) };
177
+ const written: Row[] = [];
178
+ // Attributed here rather than in each of the three callers: a batch wide enough to split is
179
+ // several statements sent inside this loop, and every one of them belongs to the call that
180
+ // asked for it — `insert`, `insertAll` or `upsertAll`, which is why the op is a parameter.
181
+ await attributed(op, () =>
182
+ writing(async () => {
183
+ for (const chunk of insertChunks(bound, columns.length)) {
184
+ for (const row of await client().query<PhysicalRow>(
185
+ insertStatement(entity, chunk, shape),
186
+ )) {
187
+ written.push(decodeRow(entity, row));
188
+ }
189
+ }
190
+ }),
191
+ );
192
+ return written;
193
+ };
194
+
58
195
  return {
59
196
  async findById(id, options) {
60
- const plan = idPlan(entity, id, options, 'findById');
61
- return one(plan, options ?? {});
197
+ const op = 'findById';
198
+ const plan = idPlan(entity, id, options, op);
199
+ const args = options ?? {};
200
+ // Every point lookup a request issues in one microtask is one statement: a page that
201
+ // resolves an author per row pays for one round trip, not one per row. The statement is the
202
+ // one this call would have sent — same scope, same soft-delete filter, `in` instead of `=` —
203
+ // and with no request in scope (a job, a script) it is exactly that statement, alone.
204
+ //
205
+ // The batch is flushed from a microtask scheduled inside this scope, so the statement one
206
+ // lookup sends for fifty carries the pair every one of those fifty would have carried.
207
+ return attributed(
208
+ op,
209
+ () => coalesceFindById(entity, client(), plan, shapeOf(args), id) ?? one(plan, args),
210
+ );
62
211
  },
63
212
 
64
213
  async findMany(args = {}) {
65
- const plan = readPlan(entity, args, 'findMany');
214
+ const op = 'findMany';
215
+ const plan = readPlan(entity, args, op);
66
216
  // One row past the page: the presence of that row is what says there is a next cursor,
67
217
  // and it costs one row instead of a second `count(*)` over the same predicate.
68
- const found = await client().query<PhysicalRow>(
69
- selectStatement(entity, plan, shapeOf(args, seekFrom(entity, plan)), plan.limit + 1),
218
+ const found = await attributed(op, () =>
219
+ client().query<PhysicalRow>(
220
+ selectStatement(entity, plan, shapeOf(args, seekFrom(entity, plan)), plan.limit + 1),
221
+ ),
70
222
  );
71
223
  const rows = found.slice(0, plan.limit).map((row) => decodeRow(entity, row));
224
+ // What the page leaves behind: its foreign key values, so the first `findById` for any one
225
+ // of them resolves the key for the whole page. A `for … of` loop over these rows costs one
226
+ // statement, not one per row — its `await` already ended the coalescing window.
227
+ if (config.jitPreload !== false) tagSiblings(entity, rows);
72
228
  const last = rows.at(-1);
73
229
  return {
74
230
  rows,
@@ -80,24 +236,47 @@ export const postgresRepo = <Row>(
80
236
  },
81
237
 
82
238
  async insert(values) {
83
- entity.$assert(values);
84
- const written = await client().one<PhysicalRow>(
85
- insertStatement(entity, bindValues(entity, values)),
86
- );
239
+ const [written] = await writeRows('insert', [values], undefined);
87
240
  // `returning *` is the row Postgres actually stored, defaults included.
88
- return written === null ? values : decodeRow(entity, written);
241
+ return written ?? values;
242
+ },
243
+
244
+ async insertAll(batch) {
245
+ return writeRows('insertAll', batch, undefined);
246
+ },
247
+
248
+ async upsertAll(batch, args: UpsertArgs<Row>) {
249
+ const plan = upsertPlan(entity, batch, args.onConflict, args.onMatch ?? 'update');
250
+ // Refused here, not by the server: a batch that repeats a conflict target is `21000` in
251
+ // Postgres and a silent overwrite in memory, and the two drivers have to mean one thing.
252
+ conflictKeys(entity, plan, batch);
253
+ return writeRows('upsertAll', batch, {
254
+ columns: insertColumns(entity, plan.on),
255
+ set: insertColumns(entity, plan.set),
256
+ });
89
257
  },
90
258
 
91
259
  async update(id, patch, options) {
92
- const plan = idPlan(entity, id, options, 'update');
260
+ const op = 'update';
261
+ const plan = idPlan(entity, id, options, op);
262
+ // The plan bounds WHICH row is written; the patch decides what it becomes, and a patch
263
+ // naming another tenant would hand this row away — a leak out of the actor's tenant that no
264
+ // predicate can refuse.
265
+ assertRowTenant(entity.$name, entity.$tenantColumn, op, patch);
93
266
  const values = bindValues(entity, patch);
267
+ // The read an empty patch degrades to is still this call: attributed to `update`, because
268
+ // that is the line an author would go and change.
94
269
  if (values.size === 0) {
95
- const current = await one(plan, options ?? {});
270
+ const current = await attributed(op, () => one(plan, options ?? {}));
96
271
  if (current === null) throw notFound(entity.$name, id);
97
272
  return current;
98
273
  }
99
- const written = await client().one<PhysicalRow>(
100
- updateStatement(entity, plan, values, shapeOf(options ?? {})),
274
+ const written = await attributed(op, () =>
275
+ writing(() =>
276
+ client().one<PhysicalRow>(
277
+ updateStatement(entity, plan, values, shapeOf(options ?? {}), true),
278
+ ),
279
+ ),
101
280
  );
102
281
  if (written === null) throw notFound(entity.$name, id);
103
282
  const after = decodeRow(entity, written);
@@ -109,26 +288,95 @@ export const postgresRepo = <Row>(
109
288
  },
110
289
 
111
290
  async delete(id, options) {
112
- const plan = idPlan(entity, id, options, 'delete');
113
- // Soft delete hides the row without losing it; the column's presence is the switch.
114
- const statement = entity.$softDelete
115
- ? updateStatement(
116
- entity,
117
- plan,
118
- new Map([[snake(SOFT_DELETE_COLUMN), new Date()]]),
119
- shapeOf({}),
120
- )
121
- : deleteStatement(entity, plan);
122
- if ((await client().execute(statement)) === 0) throw notFound(entity.$name, id);
291
+ const op = 'delete';
292
+ const plan = idPlan(entity, id, options, op);
293
+ if ((await attributed(op, () => writing(() => client().execute(removal(plan))))) === 0) {
294
+ throw notFound(entity.$name, id);
295
+ }
296
+ },
297
+
298
+ // No `X_NOT_FOUND` here: a filter that matches nothing is a fact the caller asked for, not a
299
+ // failed address. The count is the answer, which is why it is a `number` and not `void`.
300
+ //
301
+ // `deleteWhere`/`updateWhere` are the bulk forms of `delete(id)`/`update(id, patch)` — same
302
+ // role `insertAll`/`upsertAll` play for a per-row insert loop, one statement instead of one
303
+ // per row. They are also the *only* filtered writes a composite-key entity has: `likes`,
304
+ // `blocks`, `participants`, any join table has no single-column id for `delete`/`update` to
305
+ // address, so without this pair such an entity would be create-only.
306
+ async deleteWhere(filter, options) {
307
+ const op = 'deleteWhere';
308
+ // The plan is built before the write is announced: a refused filter never happened.
309
+ const plan = deletePlan(entity, filter, options, op);
310
+ return attributed(op, () => writing(() => client().execute(removal(plan))));
311
+ },
312
+
313
+ async updateWhere(filter, patch, options) {
314
+ const op = 'updateWhere';
315
+ const plan = updatePlan(entity, filter, patch, options, op);
316
+ // The same move, filtered: one statement can hand away every row it matches.
317
+ assertRowTenant(entity.$name, entity.$tenantColumn, op, patch);
318
+ const values = bindValues(entity, patch);
319
+ // `shapeOf({})` keeps the `deleted_at is null` clause `update(id, patch)` already carries,
320
+ // so a soft-deleted row is not silently patched back into shape.
321
+ const shape = shapeOf({});
322
+ // The rows come back only when something here can still refuse them. A `check` or a `unique`
323
+ // invariant is a constraint Postgres enforced on the statement itself, so the answer is
324
+ // already a count; only a JS-only rule (`kind: 'assert'`, `sql: null`) has to be judged on
325
+ // the result. Unconditional, this was `returning *` on every filtered write in the framework
326
+ // — a GDPR sweep patching twelve million rows streamed all twelve million into the process,
327
+ // decoded each one, asserted nothing about any of them, and answered with a number.
328
+ // `deleteWhere` was the tell: same predicate, same table, `execute()` and a count.
329
+ if (!hasJsOnlyInvariant(entity.$invariants)) {
330
+ const statement = updateStatement(entity, plan, values, shape, false);
331
+ return attributed(op, () => writing(() => client().execute(statement)));
332
+ }
333
+ // Rows ARE needed, so how many is a memory bound rather than a detail — this is the one
334
+ // statement in the driver whose result size is the caller's filter and not a page. Counted
335
+ // BEFORE the write, because a refusal after `returning *` has already allocated the thing it
336
+ // is refusing; the count can drift by whatever a concurrent writer adds between the two,
337
+ // which moves a bound, never a decision.
338
+ const matched = await attributed(op, () =>
339
+ client().one<{ count: unknown }>(countStatement(entity, plan, shape)),
340
+ );
341
+ const rows = Number(matched?.count ?? 0);
342
+ if (rows > MAX_ASSERTED_ROWS) throw assertedRowsTooMany(entity.$name, op, rows);
343
+ const statement = updateStatement(entity, plan, values, shape, true);
344
+ // Inside `withTransaction` a failed assert takes the whole statement with it.
345
+ const written = await attributed(op, () =>
346
+ writing(() => client().query<PhysicalRow>(statement)),
347
+ );
348
+ for (const row of written) entity.$assert(decodeRow(entity, row));
349
+ return written.length;
123
350
  },
124
351
 
125
352
  async count(args = {}) {
126
- const plan = readPlan(entity, args, 'count');
127
- const row = await client().one<{ count: unknown }>(
128
- countStatement(entity, plan, shapeOf(args)),
353
+ const op = 'count';
354
+ const plan = readPlan(entity, args, op);
355
+ const row = await attributed(op, () =>
356
+ client().one<{ count: unknown }>(countStatement(entity, plan, shapeOf(args))),
129
357
  );
130
358
  return Number(row?.count ?? 0);
131
359
  },
360
+
361
+ async countBy(column, args = {}) {
362
+ const op = 'countBy';
363
+ const grouped = groupColumnOf(entity, column, op);
364
+ const plan = readPlan(entity, args, op);
365
+ // One group past the bound, for the same reason a page reads one row past its limit: the
366
+ // presence of that group is what says the answer was never going to fit, and `countsFrom`
367
+ // refuses it rather than hand back a breakdown missing its tail.
368
+ const rows = await attributed(op, () =>
369
+ client().query<GroupRow>(
370
+ countByStatement(entity, plan, shapeOf(args), column, MAX_GROUPS + 1),
371
+ ),
372
+ );
373
+ return countsFrom(
374
+ entity,
375
+ column,
376
+ op,
377
+ rows.map((row) => [groupValue(grouped, row.group_value), Number(row.group_count)] as const),
378
+ );
379
+ },
132
380
  };
133
381
  };
134
382
 
package/src/pg-row.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  // as a string, timestamptz may arrive as one, and a silent `NaN` is worse than a loud throw.
6
6
 
7
7
  import { snake } from './column';
8
+ import { narrowMoney } from './columns';
8
9
  import type { EntityCore } from './entity';
9
10
  import { invariantViolated } from './errors';
10
11
  import type { AnyColumn, MoneyValue } from './types';
@@ -13,10 +14,18 @@ export type PhysicalRow = Readonly<Record<string, unknown>>;
13
14
 
14
15
  const MONEY_PARTS = new Set(['minor', 'currency']);
15
16
 
16
- /** `price` -> `price_minor`, `price_currency`. Everything else is one snake_case column. */
17
+ /**
18
+ * `price` -> `price_minor`, `price_currency`, `price_scale`. Everything else is one snake_case
19
+ * column.
20
+ *
21
+ * The scale column is nullable and is the only one of the three an amount may omit: `null` means
22
+ * "the currency's own minor unit", which is every row written before the column existed. It is NOT
23
+ * addressable as a predicate or a sort key (`MONEY_PARTS` below, and `cursor.ts`'s copy) — a scale
24
+ * says which units `minor` counts, so ordering or filtering by it compares two different questions.
25
+ */
17
26
  export const columnsOf = (property: string, column: AnyColumn): readonly string[] =>
18
27
  column.$meta.kind === 'money'
19
- ? [`${snake(property)}_minor`, `${snake(property)}_currency`]
28
+ ? [`${snake(property)}_minor`, `${snake(property)}_currency`, `${snake(property)}_scale`]
20
29
  : [snake(property)];
21
30
 
22
31
  /**
@@ -61,7 +70,9 @@ export const bindValues = <Row>(
61
70
  values: Partial<Row>,
62
71
  ): ReadonlyMap<string, unknown> => {
63
72
  const bound = new Map<string, unknown>();
64
- const record = values as Readonly<Record<string, unknown>>;
73
+ // `MoneyInput` lets a writer hand a `bigint`; the row type is `MoneyValue`. `memoryRepo` calls
74
+ // the same narrowing before it stores, so what the two drivers write is one value.
75
+ const record = narrowMoney(entity.$columns, values) as Readonly<Record<string, unknown>>;
65
76
  for (const [property, column] of Object.entries(entity.$columns)) {
66
77
  if (!Object.hasOwn(record, property)) continue;
67
78
  const value = record[property];
@@ -72,14 +83,25 @@ export const bindValues = <Row>(
72
83
  const money = value as MoneyValue | null | undefined;
73
84
  bound.set(`${snake(property)}_minor`, money?.minor ?? null);
74
85
  bound.set(`${snake(property)}_currency`, money?.currency ?? null);
86
+ // `?? null` and not `!== undefined`: an amount at the currency's own scale carries no key at
87
+ // all, and that absence is what the nullable column stores. A `0` written here for it would
88
+ // claim whole units — a 100x reinterpretation of every ordinary price.
89
+ bound.set(`${snake(property)}_scale`, money?.scale ?? null);
75
90
  }
76
91
  return bound;
77
92
  };
78
93
 
79
- const moneyOf = (source: PhysicalRow, minor: string, currency: string): unknown => {
94
+ const moneyOf = (source: PhysicalRow, minor: string, currency: string, scale: string): unknown => {
80
95
  const amount = source[minor];
81
96
  if (amount === null || amount === undefined) return null;
82
- return { minor: amount, currency: String(source[currency] ?? '').trim() };
97
+ // A column the projection left out is absent, not null and absent must read as "no scale"
98
+ // exactly as a stored NULL does, so both take the same branch.
99
+ const declared = source[scale];
100
+ return {
101
+ minor: amount,
102
+ currency: String(source[currency] ?? '').trim(),
103
+ ...(declared === null || declared === undefined ? {} : { scale: declared }),
104
+ };
83
105
  };
84
106
 
85
107
  /**
@@ -89,9 +111,12 @@ const moneyOf = (source: PhysicalRow, minor: string, currency: string): unknown
89
111
  export const decodeRow = <Row>(entity: EntityCore<Row>, source: PhysicalRow): Row => {
90
112
  const row: Record<string, unknown> = {};
91
113
  for (const [property, column] of Object.entries(entity.$columns)) {
92
- const [head, tail] = columnsOf(property, column);
114
+ const [head, currency, scale] = columnsOf(property, column);
93
115
  if (head === undefined || !(head in source)) continue;
94
- const value = tail === undefined ? source[head] : moneyOf(source, head, tail);
116
+ const value =
117
+ currency === undefined || scale === undefined
118
+ ? source[head]
119
+ : moneyOf(source, head, currency, scale);
95
120
  if (value !== null && value !== undefined) {
96
121
  row[property] = column.$parse(value);
97
122
  continue;
package/src/pg-sql.ts CHANGED
@@ -55,6 +55,34 @@ const predicateSql = <Row>(entity: EntityCore<Row>, predicate: Predicate): SqlFr
55
55
  }
56
56
  };
57
57
 
58
+ /**
59
+ * A cursor's timestamp is the row's own value FLOORED: a `Date` holds milliseconds and a
60
+ * `timestamptz` column holds microseconds, so `created_at > '…123'` is satisfied by the very row
61
+ * at `…123456` the cursor was minted from — the same row, returned again, on every page boundary.
62
+ * Under `desc` the same gap does the opposite and silently drops every row inside that
63
+ * millisecond, which no `id` tiebreak can recover because the first `or` term never matched.
64
+ *
65
+ * So a timestamp seek compares against the millisecond WINDOW its value stands for — what
66
+ * `date_trunc('milliseconds', …)` would say, spelled as a half-open range so the column stays
67
+ * bare and an index can still range-scan it. `timestamptz` is the only sort kind revived as a
68
+ * `Date` (`cursor.ts`), so the type test IS the kind test.
69
+ */
70
+ const nextMillisecond = (value: Date): Date => new Date(value.getTime() + 1);
71
+
72
+ /** Strictly past the cursor's position in this key's direction. */
73
+ const seekAfter = (column: SqlFragment, direction: string, value: unknown): SqlFragment => {
74
+ if (direction === 'desc') return sql`${column} < ${value}`;
75
+ // `>= v + 1ms` is `trunc(col) > v`; `< v` already is `trunc(col) < v`, so only asc moves.
76
+ if (value instanceof Date) return sql`${column} >= ${nextMillisecond(value)}`;
77
+ return sql`${column} > ${value}`;
78
+ };
79
+
80
+ /** At the cursor's position for this key — the prefix a later key's tiebreak hangs off. */
81
+ const seekEqual = (column: SqlFragment, value: unknown): SqlFragment =>
82
+ value instanceof Date
83
+ ? sql`(${column} >= ${value} and ${column} < ${nextMillisecond(value)})`
84
+ : sql`${column} = ${value}`;
85
+
58
86
  /**
59
87
  * The keyset seek, spelled out rather than as a row comparison: `(a, b) > (x, y)` requires every
60
88
  * key to sort the same way, and a listing that is `published_at desc, id asc` does not.
@@ -67,10 +95,9 @@ const seekSql = <Row>(
67
95
  const terms = orderBy.map((entry, index) => {
68
96
  const equal = orderBy
69
97
  .slice(0, index)
70
- .map((earlier, position) => sql`${columnRef(entity, earlier.column)} = ${seek[position]}`);
71
- const after = raw(entry.direction === 'desc' ? '<' : '>');
98
+ .map((earlier, position) => seekEqual(columnRef(entity, earlier.column), seek[position]));
72
99
  return sql`(${join(
73
- [...equal, sql`${columnRef(entity, entry.column)} ${after} ${seek[index]}`],
100
+ [...equal, seekAfter(columnRef(entity, entry.column), entry.direction, seek[index])],
74
101
  ' and ',
75
102
  )})`;
76
103
  });
@@ -135,25 +162,107 @@ export const countStatement = <Row>(
135
162
  ): SqlFragment =>
136
163
  sql`select count(*) as count from ${identifier(entity.$table)} where ${conditions(entity, plan, shape)}`;
137
164
 
165
+ /** What a grouped count comes back as. Both names are fixed, so neither can be a column's. */
166
+ export interface GroupRow {
167
+ readonly group_value: unknown;
168
+ readonly group_count: unknown;
169
+ }
170
+
171
+ /**
172
+ * The grouped count: one row per distinct value of one column, over exactly the rows
173
+ * `countStatement` would have counted — the same predicates, the same soft-delete filter, one
174
+ * `group by` more. `limit` bounds the groups, not the rows, which is what turns a whole-table
175
+ * breakdown into a refusal instead of a result set nobody sized.
176
+ *
177
+ * Both output names are aliases and fixed, so they cannot collide with each other whatever the
178
+ * table declares: an entity is free to have a column called `count`, and the un-aliased form would
179
+ * then return two outputs of one name.
180
+ */
181
+ export const countByStatement = <Row>(
182
+ entity: EntityCore<Row>,
183
+ plan: QueryPlan,
184
+ shape: ReadShape,
185
+ column: string,
186
+ limit: number,
187
+ ): SqlFragment => {
188
+ const grouped = columnRef(entity, column);
189
+ return sql`select ${grouped} as group_value, count(*) as group_count from ${identifier(
190
+ entity.$table,
191
+ )} where ${conditions(entity, plan, shape)} group by ${grouped} limit ${limit}`;
192
+ };
193
+
194
+ /** `on conflict (…) do update set …`, or `do nothing` when there is nothing to overwrite. */
195
+ export interface ConflictTarget {
196
+ /** Physical columns of the unique index a collision is judged against. */
197
+ readonly columns: readonly string[];
198
+ /** Physical columns a colliding row takes from the incoming one. Empty is `do nothing`. */
199
+ readonly set: readonly string[];
200
+ }
201
+
202
+ export interface InsertShape {
203
+ /** Every physical column written — one list, shared by every row of the statement. */
204
+ readonly columns: readonly string[];
205
+ /** How a collision resolves. Absent, it is the caller's error, exactly as it is for one row. */
206
+ readonly conflict?: ConflictTarget | undefined;
207
+ }
208
+
209
+ /**
210
+ * The cell of a row that did not name this column. `default` is the second and last `raw()` in
211
+ * this file and, like `asc|desc` above it, a closed set of one word: it is what makes a row inside
212
+ * a many-row `insert` mean what the same row means on its own, where an unnamed column is simply
213
+ * left out. The seek operator used to be a third — it is chosen in TypeScript now
214
+ * (`seekAfter`/`seekEqual`), because a timestamp seek is not one operator.
215
+ */
216
+ const DEFAULT_CELL = raw('default');
217
+
218
+ const conflictSql = (conflict: ConflictTarget): SqlFragment => {
219
+ const target = join(conflict.columns.map(identifier));
220
+ return conflict.set.length === 0
221
+ ? sql` on conflict (${target}) do nothing`
222
+ : sql` on conflict (${target}) do update set ${join(
223
+ conflict.set.map((column) => sql`${identifier(column)} = excluded.${identifier(column)}`),
224
+ )}`;
225
+ };
226
+
227
+ /**
228
+ * One statement for any number of rows. A single row compiles to exactly the text it always did,
229
+ * which is the point: `insertAll([row])` and `insert(row)` are one code path, so there is no
230
+ * second insert builder for the two to drift apart in.
231
+ */
138
232
  export const insertStatement = <Row>(
139
233
  entity: EntityCore<Row>,
140
- values: ReadonlyMap<string, unknown>,
234
+ rows: readonly ReadonlyMap<string, unknown>[],
235
+ shape: InsertShape,
141
236
  ): SqlFragment => {
142
- const entries = [...values];
237
+ const tuples = rows.map(
238
+ (row) =>
239
+ sql`(${join(
240
+ shape.columns.map((column) => (row.has(column) ? sql`${row.get(column)}` : DEFAULT_CELL)),
241
+ )})`,
242
+ );
243
+ const conflict = shape.conflict === undefined ? sql`` : conflictSql(shape.conflict);
143
244
  return sql`insert into ${identifier(entity.$table)} (${join(
144
- entries.map(([column]) => identifier(column)),
145
- )}) values (${join(entries.map(([, value]) => sql`${value}`))}) returning *`;
245
+ shape.columns.map(identifier),
246
+ )}) values ${join(tuples)}${conflict} returning *`;
146
247
  };
147
248
 
249
+ /**
250
+ * `returning` is a parameter and has no default, because the three callers want three different
251
+ * answers and the wrong one is not visible in the result: `update(id, patch)` needs the stored row,
252
+ * a soft delete and a filtered write need a count, and `returning *` on a filtered write over a
253
+ * whole tenant streams every matched row into the process for nobody to read. A default would make
254
+ * that the quiet case.
255
+ */
148
256
  export const updateStatement = <Row>(
149
257
  entity: EntityCore<Row>,
150
258
  plan: QueryPlan,
151
259
  values: ReadonlyMap<string, unknown>,
152
260
  shape: ReadShape,
261
+ returning: boolean,
153
262
  ): SqlFragment =>
154
263
  sql`update ${identifier(entity.$table)} set ${join(
155
264
  [...values].map(([column, value]) => sql`${identifier(column)} = ${value}`),
156
- )} where ${conditions(entity, plan, shape)} returning *`;
265
+ )} where ${conditions(entity, plan, shape)}${returning ? sql` returning *` : sql``}`;
157
266
 
158
267
  /** Only reached when the entity has no soft-delete column, so there is no filter to apply. */
159
268
  export const deleteStatement = <Row>(entity: EntityCore<Row>, plan: QueryPlan): SqlFragment =>