@ultimat3/entity 29.4.0 → 30.1.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.
Files changed (65) hide show
  1. package/CLAUDE.md +66 -96
  2. package/README.md +118 -8
  3. package/package.json +15 -5
  4. package/src/append-only.ts +7 -0
  5. package/src/batch-read.ts +12 -4
  6. package/src/bulk-write-send.ts +49 -0
  7. package/src/bulk-write.ts +48 -11
  8. package/src/column.ts +29 -4
  9. package/src/counter.ts +109 -0
  10. package/src/cross-tenant.ts +13 -0
  11. package/src/cursor.ts +17 -1
  12. package/src/database.ts +16 -10
  13. package/src/describe-tenancy.ts +25 -0
  14. package/src/describe.ts +144 -3
  15. package/src/entity-error.ts +33 -0
  16. package/src/entity-parse.ts +89 -0
  17. package/src/entity-patch.ts +42 -0
  18. package/src/entity.ts +90 -76
  19. package/src/errors.ts +8 -0
  20. package/src/history-actor.ts +22 -0
  21. package/src/history.ts +86 -0
  22. package/src/index.ts +20 -1
  23. package/src/jit-preload.ts +35 -25
  24. package/src/lifecycle.ts +100 -0
  25. package/src/lock-key.ts +27 -0
  26. package/src/lock-unsupported.ts +11 -0
  27. package/src/memory-aggregates.ts +99 -0
  28. package/src/memory-batch.ts +69 -0
  29. package/src/memory-journal.ts +68 -0
  30. package/src/memory-lifecycle.ts +106 -0
  31. package/src/memory-lock.ts +144 -0
  32. package/src/memory-observed.ts +121 -0
  33. package/src/memory-repo.ts +199 -186
  34. package/src/memory-same-tenant.ts +83 -0
  35. package/src/memory-seek.ts +44 -0
  36. package/src/memory-transactor.ts +94 -0
  37. package/src/memory-unique.ts +19 -4
  38. package/src/n-plus-one.ts +28 -10
  39. package/src/numeric-compare.ts +43 -0
  40. package/src/pg-aggregates.ts +185 -0
  41. package/src/pg-driver.ts +79 -166
  42. package/src/pg-lock.ts +50 -0
  43. package/src/pg-row.ts +29 -2
  44. package/src/pg-sql-aggregate.ts +31 -0
  45. package/src/pg-sql.ts +17 -1
  46. package/src/query.ts +26 -105
  47. package/src/read-aggregates.ts +138 -0
  48. package/src/read-capture.ts +22 -0
  49. package/src/registry.ts +44 -1
  50. package/src/repo.ts +34 -0
  51. package/src/retain.ts +71 -0
  52. package/src/row-observer.ts +22 -4
  53. package/src/row-security-settings.ts +20 -0
  54. package/src/sealed-repo.ts +15 -0
  55. package/src/seed.ts +43 -7
  56. package/src/series-unsupported.ts +11 -0
  57. package/src/table-locks.ts +41 -0
  58. package/src/test-driver.ts +90 -0
  59. package/src/time-bucket.ts +147 -0
  60. package/src/touch.ts +33 -0
  61. package/src/types.ts +20 -0
  62. package/src/view-entity.ts +78 -0
  63. package/src/view-issue.ts +47 -0
  64. package/src/view-repo.ts +38 -0
  65. package/src/view.ts +41 -27
@@ -0,0 +1,121 @@
1
+ // Single responsibility: the memory driver's half of `@ultimat3/db`'s statement seam. Generated
2
+ // tests run on this driver, so a loop it never reported was a `statements` assertion that could not
3
+ // fail. One event per repository operation, attributed exactly as `postgresRepo` attributes its
4
+ // statement; nothing is sent, so `text` names the operation and holds no SQL.
5
+
6
+ import { tryUseContext } from '@ultimat3/core';
7
+ import { expectedQueryLoopReason, statementObserver } from '@ultimat3/db';
8
+ import type { MemoryRepo } from './repo';
9
+
10
+ /** What one operation reports: the verb `statementKind` reads, and its row count. */
11
+ interface Reported {
12
+ readonly verb: 'select' | 'insert' | 'update' | 'delete';
13
+ readonly rows: (answer: unknown) => number;
14
+ }
15
+
16
+ const none = (): number => 0;
17
+ const one = (answer: unknown): number => (answer === null || answer === undefined ? 0 : 1);
18
+ const counted = (answer: unknown): number => (typeof answer === 'number' ? answer : 0);
19
+ const listed = (answer: unknown): number => (Array.isArray(answer) ? answer.length : 0);
20
+ const paged = (answer: unknown): number =>
21
+ typeof answer === 'object' && answer !== null && 'rows' in answer ? listed(answer.rows) : 0;
22
+
23
+ /** Every operation that would be a statement. `reset` is a test seam and reaches no database. */
24
+ const OPERATIONS = {
25
+ findById: { verb: 'select', rows: one },
26
+ findMany: { verb: 'select', rows: paged },
27
+ count: { verb: 'select', rows: one },
28
+ countBy: { verb: 'select', rows: none },
29
+ aggregate: { verb: 'select', rows: one },
30
+ series: { verb: 'select', rows: listed },
31
+ approximateCount: { verb: 'select', rows: one },
32
+ insert: { verb: 'insert', rows: one },
33
+ insertAll: { verb: 'insert', rows: listed },
34
+ upsertAll: { verb: 'insert', rows: listed },
35
+ update: { verb: 'update', rows: one },
36
+ updateWhere: { verb: 'update', rows: counted },
37
+ delete: { verb: 'delete', rows: () => 1 },
38
+ deleteWhere: { verb: 'delete', rows: counted },
39
+ } as const satisfies Record<string, Reported>;
40
+
41
+ type Operation = keyof typeof OPERATIONS;
42
+
43
+ /** Taken before the body runs, as Postgres answers the lock before the body it guards. */
44
+ const LOCKS = ['lock', 'lockKey'] as const;
45
+
46
+ const report = (
47
+ entity: string,
48
+ op: string,
49
+ verb: Reported['verb'],
50
+ rows: number,
51
+ error?: unknown,
52
+ ): void => {
53
+ const observer = statementObserver();
54
+ if (observer === undefined) return;
55
+ observer.onStatement({
56
+ text: `${verb} ${entity}.${op} (memory driver, no SQL sent)`,
57
+ values: [],
58
+ // Nothing crossed a wire, and a duration made up of `Map` reads would only rank as noise.
59
+ durationMs: 0,
60
+ rows,
61
+ ...(error === undefined ? {} : { error }),
62
+ attribution: { entity, op },
63
+ expected: expectedQueryLoopReason(),
64
+ });
65
+ };
66
+
67
+ /**
68
+ * `postgresRepo`'s `findById` joins every lookup of one request's microtask into one `in` statement
69
+ * (`coalesce.ts`) and declines with no request in scope. Mirrored, or `Promise.all(ids.map(byId))`
70
+ * fails a test here that Postgres passes with one statement. The rows are still read one by one:
71
+ * this decides only what the seam is told.
72
+ */
73
+ const pointLookups = (): (() => boolean) => {
74
+ let open: object | undefined;
75
+ return () => {
76
+ const ctx: object | undefined = tryUseContext();
77
+ if (ctx === undefined) return true;
78
+ if (open === ctx) return false;
79
+ open = ctx;
80
+ queueMicrotask(() => {
81
+ if (open === ctx) open = undefined;
82
+ });
83
+ return true;
84
+ };
85
+ };
86
+
87
+ type AnyMethod = (...args: readonly unknown[]) => Promise<unknown>;
88
+
89
+ /** `repo`, each statement-shaped method reporting once it settles, as the funnels report. */
90
+ export function observedMemoryRepo<Row>(entity: string, repo: MemoryRepo<Row>): MemoryRepo<Row> {
91
+ const wrapped: Record<string, AnyMethod> = {};
92
+ const firstInMicrotask = pointLookups();
93
+ for (const op of Object.keys(OPERATIONS) as Operation[]) {
94
+ const method = repo[op] as AnyMethod;
95
+ const { verb, rows } = OPERATIONS[op];
96
+ wrapped[op] = async (...args) => {
97
+ if (statementObserver() === undefined) return method(...args);
98
+ // Decided as the call is made: by the time the row is back the microtask has moved on.
99
+ const reports = op !== 'findById' || firstInMicrotask();
100
+ // `postgresRepo` attributes an aggregate to its function, never to `aggregate`.
101
+ const name = op === 'aggregate' ? String(args[0]) : op;
102
+ let answer: unknown;
103
+ try {
104
+ answer = await method(...args);
105
+ } catch (error) {
106
+ if (reports) report(entity, name, verb, 0, error);
107
+ throw error;
108
+ }
109
+ if (reports) report(entity, name, verb, rows(answer));
110
+ return answer;
111
+ };
112
+ }
113
+ for (const op of LOCKS) {
114
+ const method = repo[op] as AnyMethod;
115
+ wrapped[op] = async (...args) => {
116
+ report(entity, op, 'select', 1);
117
+ return method(...args);
118
+ };
119
+ }
120
+ return Object.assign({}, repo, wrapped);
121
+ }
@@ -7,23 +7,30 @@
7
7
  // implements too and which nothing about storing rows in a `Map` belongs in.
8
8
 
9
9
  import { pageOf } from '@ultimat3/core';
10
- import { aggregateColumnOf } from './aggregate';
11
- import { foldAggregate } from './aggregate-fold';
10
+ import { refuseAbandoned } from '@ultimat3/db';
12
11
  import { appendOnlyRepo } from './append-only';
13
12
  import { keyOf } from './batch-read';
14
13
  import { conflictKeyOf, conflictKeys, upsertPlan } from './bulk-write';
15
14
  import { entityNow } from './clock';
16
15
  import { narrowRow } from './columns';
17
- import { countsFrom, groupColumnOf } from './count-by';
18
- import { cursorFor, kindOf, seekFrom, valueAt } from './cursor';
16
+ import { cursorFor, kindOf, valueAt } from './cursor';
19
17
  import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
20
18
  import { invariantViolated, notFound } from './errors';
21
19
  import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
20
+ import { lockKeyInput } from './lock-key';
21
+ import { memoryAggregates } from './memory-aggregates';
22
+ import { undoingBatch } from './memory-batch';
23
+ import { memoryJournal } from './memory-journal';
24
+ import { memoryLifecycle } from './memory-lifecycle';
25
+ import { memoryLocks } from './memory-lock';
22
26
  import { compareByKind, matchesPredicate } from './memory-match';
27
+ import { observedMemoryRepo } from './memory-observed';
28
+ import { type MemoryTables, sameTenantJudge } from './memory-same-tenant';
29
+ import { afterCursor } from './memory-seek';
23
30
  import { uniqueClash, uniqueViolation } from './memory-unique';
24
31
  import { judgeUpdate, mergedRow } from './memory-update';
25
32
  import { deletePlan, idPlan, readPlan, singleKeyOf, updatePlan } from './plan';
26
- import type { FindManyArgs, MemoryRepo, RepoOptions, Transactor, Tx } from './repo';
33
+ import type { FindManyArgs, MemoryRepo, RepoOptions } from './repo';
27
34
  import { sealedFields } from './sealed';
28
35
  import { sealedRepo } from './sealed-repo';
29
36
  import type { QueryPlan } from './tenancy';
@@ -33,43 +40,6 @@ import type { RowWrite } from './types';
33
40
  const field = (row: unknown, property: string): unknown =>
34
41
  typeof row === 'object' && row !== null ? (row as Record<string, unknown>)[property] : undefined;
35
42
 
36
- /** Lexicographic over the sort keys, direction applied. `> 0` means "after the cursor". */
37
- const compareToSeek = <Row>(
38
- entity: EntityCore<Row>,
39
- plan: QueryPlan,
40
- row: unknown,
41
- seek: readonly unknown[],
42
- ): number => {
43
- for (const [index, entry] of plan.orderBy.entries()) {
44
- // The COLUMN's kind, not the value's: the seek was revived from the same kind (`cursor.ts`),
45
- // so a `bigint` column compares its stored decimal string against a revived `BigInt` as one
46
- // number instead of as two pieces of text.
47
- const order = compareByKind(
48
- kindOf(entity, entry.column),
49
- valueAt(row, entry.column),
50
- seek[index],
51
- );
52
- if (order !== 0) return entry.direction === 'desc' ? -order : order;
53
- }
54
- return 0;
55
- };
56
-
57
- /**
58
- * Where the next page starts. By sort position, not by the previous row's id: that row may have
59
- * been deleted between the two requests, and an id that is no longer there would restart
60
- * pagination at the top instead of continuing it.
61
- */
62
- const afterCursor = <Row>(
63
- entity: EntityCore<Row>,
64
- plan: QueryPlan,
65
- found: readonly Row[],
66
- ): number => {
67
- const seek = seekFrom(entity, plan);
68
- if (seek === undefined) return 0;
69
- const start = found.findIndex((row) => compareToSeek(entity, plan, row, seek) > 0);
70
- return start === -1 ? found.length : start;
71
- };
72
-
73
43
  /**
74
44
  * The default driver: correct semantics, no database. `x dev` uses it before the first
75
45
  * migration and tests use it everywhere. Postgres is the production driver and implements
@@ -78,6 +48,8 @@ const afterCursor = <Row>(
78
48
  export const memoryRepo = <Row>(
79
49
  entity: EntityCore<Row>,
80
50
  seed: readonly Row[] = [],
51
+ /** The driver's other tables, for a same-tenant reference. Alone, this repository sees itself. */
52
+ tables: MemoryTables = new Map(),
81
53
  ): MemoryRepo<Row> => {
82
54
  /**
83
55
  * A stored row's key, spelled the way `batch-read.ts` spells an id — because Postgres compares a
@@ -102,6 +74,42 @@ export const memoryRepo = <Row>(
102
74
  );
103
75
  }
104
76
  const rows = new Map<string, Row>(seed.map((row) => [storeKey(row), row]));
77
+ tables.set(entity.$name, {
78
+ entity: entity as EntityCore,
79
+ // By the primary key when that is what is asked, which is nearly every reference.
80
+ find: (property, value) => {
81
+ const kind = kindOf(entity, property) ?? '';
82
+ const soleColumn = entity.$primaryKey.length === 1 ? entity.$primaryKey[0] : undefined;
83
+ if (soleColumn === property) return rows.get(keyOf(kind, value));
84
+ for (const row of rows.values()) {
85
+ if (keyOf(kind, field(row, property)) === keyOf(kind, value)) return row;
86
+ }
87
+ return undefined;
88
+ },
89
+ raw: {
90
+ store: (row, undo) => {
91
+ const key = storeKey(row);
92
+ rows.set(key, row as Row);
93
+ undo(() => rows.delete(key));
94
+ },
95
+ replace: (before, after, undo) => {
96
+ const key = storeKey(before);
97
+ rows.set(key, after as Row);
98
+ undo(() => rows.set(key, before as Row));
99
+ },
100
+ },
101
+ });
102
+ /** The trigger half: history and counters, after every stored change (`memory-lifecycle.ts`). */
103
+ const changed = memoryLifecycle(entity as EntityCore, tables);
104
+ const judgeReferences = sameTenantJudge(entity, tables);
105
+ const locks = memoryLocks();
106
+ /** One guard for every write, as `postgresRepo`'s `writing()`: async, so a refusal rejects. */
107
+ const writing =
108
+ <A extends unknown[], R>(body: (...args: A) => Promise<R>) =>
109
+ async (...args: A): Promise<R> => {
110
+ refuseAbandoned();
111
+ return body(...args);
112
+ };
105
113
 
106
114
  const rowsOf = (plan: QueryPlan, args: FindManyArgs): Row[] => {
107
115
  const visible = (row: Row): boolean =>
@@ -134,6 +142,16 @@ export const memoryRepo = <Row>(
134
142
  const narrowed = (batch: readonly RowWrite<Row>[]): readonly Row[] =>
135
143
  batch.map((row) => narrowRow<Row>(entity.$columns, row));
136
144
 
145
+ /**
146
+ * Lazy: a batch asks this once a row, and an entity with no unique never iterates it. Re-iterable
147
+ * and not a generator: `uniqueClash` walks it once per constraint.
148
+ */
149
+ const storedExcept = (from: string | undefined): Iterable<Row> => ({
150
+ *[Symbol.iterator]() {
151
+ for (const [stored, other] of rows) if (stored !== from) yield other;
152
+ },
153
+ });
154
+
137
155
  /**
138
156
  * `from` is the key the row is stored under NOW, or `undefined` for a new row. A new row may not
139
157
  * land on a stored key, and a moved one may not land on another row's — Postgres answers both
@@ -155,23 +173,22 @@ export const memoryRepo = <Row>(
155
173
  // out of this tenant is refused by the same call that refuses an insert into another one.
156
174
  assertRowTenant(entity.$name, entity.$tenantColumn, operation, row);
157
175
  entity.$assert(row);
176
+ judgeReferences(row);
158
177
  const key = storeKey(row);
159
178
  if (key !== from && rows.has(key)) throw uniqueViolation(entity, `${entity.$table}_pkey`);
160
- const clash = uniqueClash(
161
- entity,
162
- row,
163
- [...rows.entries()].filter(([stored]) => stored !== from).map(([, other]) => other),
164
- );
179
+ const clash = uniqueClash(entity, row, storedExcept(from));
165
180
  if (clash !== undefined) throw uniqueViolation(entity, clash);
166
181
  const moved = from !== undefined && from !== key ? rows.get(from) : undefined;
167
182
  const previous = rows.get(key);
168
- options?.tx?.onRollback(() => {
183
+ const prior = from === undefined ? previous : (moved ?? previous);
184
+ (options?.tx ?? memoryJournal())?.onRollback(() => {
169
185
  if (previous === undefined) rows.delete(key);
170
186
  else rows.set(key, previous);
171
187
  if (from !== undefined && moved !== undefined) rows.set(from, moved);
172
188
  });
173
189
  if (moved !== undefined && from !== undefined) rows.delete(from);
174
190
  rows.set(key, row);
191
+ changed(prior, row, options?.tx ?? memoryJournal());
175
192
  return row;
176
193
  };
177
194
 
@@ -207,8 +224,13 @@ export const memoryRepo = <Row>(
207
224
  return current;
208
225
  };
209
226
 
227
+ const assertedBound = (rows: number, operation: string): void => {
228
+ if (rows > MAX_ASSERTED_ROWS) throw assertedRowsTooMany(entity.$name, operation, rows);
229
+ };
230
+
210
231
  // Every method is async: a repository call that fails must reject, never throw
211
- // synchronously, or half the call sites would need two error paths.
232
+ // synchronously, or half the call sites would need two error paths. Every write asks
233
+ // `refuseAbandoned()` first, as `postgresRepo`'s `writing()` does (`write-abandon.test.ts`).
212
234
  const repo: MemoryRepo<Row> = {
213
235
  async findById(id, options) {
214
236
  const { found } = select(
@@ -230,11 +252,11 @@ export const memoryRepo = <Row>(
230
252
  );
231
253
  },
232
254
 
233
- async insert(values, options) {
255
+ insert: writing(async (values, options) => {
234
256
  return write(values, options, 'insert');
235
- },
257
+ }),
236
258
 
237
- async insertAll(given, options) {
259
+ insertAll: writing(async (given, options) => {
238
260
  // The whole batch is judged before any of it lands: Postgres refuses the statement as one,
239
261
  // so a row an invariant rejects — or one naming a tenant this actor may not write — must not
240
262
  // leave the rows before it stored here either. `write` re-checks both per row; this loop is
@@ -243,34 +265,43 @@ export const memoryRepo = <Row>(
243
265
  // Narrowed FIRST, so what this loop judges is what `write` will store: `$assert` was handed
244
266
  // the caller's `bigint` minor unit here and the narrowed `number` one call later.
245
267
  const batch = narrowed(given);
268
+ const earlier = (position: number): Iterable<Row> => ({
269
+ *[Symbol.iterator]() {
270
+ yield* rows.values();
271
+ for (let at = 0; at < position; at += 1) yield batch[at] as Row;
272
+ },
273
+ });
246
274
  const seen = new Set<string>();
247
275
  for (const [position, row] of batch.entries()) {
248
276
  assertRowTenant(entity.$name, entity.$tenantColumn, 'insertAll', row);
249
277
  entity.$assert(row);
278
+ judgeReferences(row, batch);
250
279
  // Keys too, before any row lands: one duplicate refuses the whole statement in Postgres.
251
280
  const key = storeKey(row);
252
281
  if (seen.has(key) || rows.has(key)) {
253
282
  throw uniqueViolation(entity, `${entity.$table}_pkey`);
254
283
  }
255
284
  seen.add(key);
256
- const clash = uniqueClash(entity, row, [...rows.values(), ...batch.slice(0, position)]);
285
+ const clash = uniqueClash(entity, row, earlier(position));
257
286
  if (clash !== undefined) throw uniqueViolation(entity, clash);
258
287
  }
259
288
  return batch.map((row) => write(row, options, 'insertAll'));
260
- },
289
+ }),
261
290
 
262
- async upsertAll(given, args) {
291
+ upsertAll: writing(async (given, args) => {
263
292
  // The INCOMING rows, judged before any of them is matched: under `onMatch: 'nothing'` a
264
293
  // colliding row is skipped and never reaches `write()`, so checking only what lands would
265
294
  // let a row naming another tenant through whenever it happened to collide. Narrowed first
266
295
  // for the reason `insertAll` above is, and before `conflictKeyOf` reads a target too.
267
296
  const batch = narrowed(given);
297
+ // Plan first, then rows: the order `postgresRepo` refuses in, so one bad call has one code.
298
+ const { onConflict, onMatch = 'update', overwrites } = args;
299
+ const plan = upsertPlan(entity, batch, onConflict, onMatch, overwrites);
300
+ const keys = conflictKeys(entity, plan, batch);
268
301
  for (const row of batch) {
269
302
  assertRowTenant(entity.$name, entity.$tenantColumn, 'upsertAll', row);
270
303
  entity.$assert(row);
271
304
  }
272
- const plan = upsertPlan(entity, batch, args.onConflict, args.onMatch ?? 'update');
273
- const keys = conflictKeys(entity, plan, batch);
274
305
  // The stored rows under the same key, so "does this collide" is the question the unique
275
306
  // index answers in Postgres and not a scan per row. A soft-deleted row still occupies its
276
307
  // key here, because the index it would collide with there is not partial either — and a row
@@ -280,83 +311,111 @@ export const memoryRepo = <Row>(
280
311
  const key = conflictKeyOf(entity, plan.on, row);
281
312
  if (key !== undefined) stored.set(key, row);
282
313
  }
283
- const written: Row[] = [];
314
+ // A row that lands is judged before any of them does: the statement refuses as one. One left
315
+ // alone by `do nothing` wrote nothing, so its reference is checked by nobody on either driver.
284
316
  for (const [position, row] of batch.entries()) {
285
317
  const key = keys[position];
286
- const existing = key === undefined ? undefined : stored.get(key);
287
- // `do nothing` writes no row, and `returning *` therefore names none: a skipped row is
288
- // absent from the result rather than present and unchanged.
289
- if (existing !== undefined && plan.set.length === 0) continue;
290
- const merged =
291
- existing === undefined
292
- ? row
293
- : Object.assign(
294
- {},
295
- existing,
296
- Object.fromEntries(plan.set.map((property) => [property, field(row, property)])),
297
- );
298
- // `UpsertArgs extends RepoOptions`, so the args ARE the options — one bag, and a `tx`
299
- // passed to an upsert registers its undo exactly as it does for every other write here.
300
- const result = write(
301
- merged,
302
- args,
303
- 'upsertAll',
304
- existing === undefined ? undefined : storeKey(existing),
305
- );
306
- // Filed as it lands, so a later row of the same batch collides with an earlier one exactly
307
- // as it would with a row the request stored a moment before it.
308
- if (key !== undefined) stored.set(key, result);
309
- written.push(result);
318
+ const collides = key !== undefined && stored.has(key);
319
+ if (!collides || plan.set.length > 0) judgeReferences(row, batch);
310
320
  }
321
+ const written: Row[] = [];
322
+ // A row colliding on ANOTHER unique takes the rows before it with it, as the statement does.
323
+ undoingBatch(args.tx, (tx) => {
324
+ for (const [position, row] of batch.entries()) {
325
+ const key = keys[position];
326
+ const existing = key === undefined ? undefined : stored.get(key);
327
+ // `do nothing` writes no row, and `returning *` therefore names none: a skipped row is
328
+ // absent from the result rather than present and unchanged.
329
+ if (existing !== undefined && plan.set.length === 0) continue;
330
+ const merged =
331
+ existing === undefined
332
+ ? row
333
+ : Object.assign(
334
+ {},
335
+ existing,
336
+ Object.fromEntries(plan.set.map((property) => [property, field(row, property)])),
337
+ );
338
+ // `UpsertArgs extends RepoOptions`, so the args ARE the options — one bag, and a `tx`
339
+ // passed to an upsert registers its undo exactly as it does for every other write here.
340
+ const result = write(
341
+ merged,
342
+ { tx },
343
+ 'upsertAll',
344
+ existing === undefined ? undefined : storeKey(existing),
345
+ );
346
+ // Filed as it lands, so a later row of the same batch collides with an earlier one exactly
347
+ // as it would with a row the request stored a moment before it.
348
+ if (key !== undefined) stored.set(key, result);
349
+ written.push(result);
350
+ }
351
+ });
311
352
  return written;
312
- },
353
+ }),
313
354
 
314
- async update(id, patch, options) {
315
- const current = addressed(id, options, 'update');
316
- return write(mergedRow(entity, current, patch), options, 'update', storeKey(current));
317
- },
318
-
319
- async delete(id, options) {
320
- const current = addressed(id, options, 'delete');
321
- // Soft delete hides the row without losing it; the column's presence is the switch.
322
- if (entity.$softDelete) {
323
- write(
324
- Object.assign({}, current, { [SOFT_DELETE_COLUMN]: entityNow() }),
325
- options,
326
- 'delete',
327
- storeKey(current),
328
- );
329
- return;
330
- }
331
- const key = storeKey(current);
332
- options?.tx?.onRollback(() => rows.set(key, current));
333
- rows.delete(key);
334
- },
355
+ update: writing(async (id, given, options) => {
356
+ // Here and in `pg-driver.ts`, the one door: a value `insert` refuses, a fragment included.
357
+ const patch = entity.$parsePatch(given);
358
+ addressed(id, options, 'update');
359
+ return locks.turn(`row:${idStoreKey(id, 'update')}`, options?.tx, () => {
360
+ const current = addressed(id, options, 'update');
361
+ return write(mergedRow(entity, current, patch), options, 'update', storeKey(current));
362
+ });
363
+ }),
335
364
 
336
- async deleteWhere(filter, options) {
337
- // `rowsOf` is the read path: the same predicates, the same tenant scoping, and the same
338
- // soft-delete visibility. A row already stamped is not matched, so a second call cannot
339
- // move `deletedAt` forward — which is what the Postgres driver's `deleted_at is null`
340
- // clause says there.
341
- const doomed = rowsOf(deletePlan(entity, filter, options, 'deleteWhere'), {});
342
- for (const row of doomed) {
365
+ delete: writing(async (id, options) => {
366
+ addressed(id, options, 'delete');
367
+ return locks.turn(`row:${idStoreKey(id, 'delete')}`, options?.tx, () => {
368
+ const current = addressed(id, options, 'delete');
369
+ // Soft delete hides the row without losing it; the column's presence is the switch.
343
370
  if (entity.$softDelete) {
344
371
  write(
345
- Object.assign({}, row, { [SOFT_DELETE_COLUMN]: entityNow() }),
372
+ Object.assign({}, current, { [SOFT_DELETE_COLUMN]: entityNow() }),
346
373
  options,
347
- 'deleteWhere',
348
- storeKey(row),
374
+ 'delete',
375
+ storeKey(current),
349
376
  );
350
- continue;
377
+ return;
351
378
  }
352
- const key = storeKey(row);
353
- options?.tx?.onRollback(() => rows.set(key, row));
379
+ const key = storeKey(current);
380
+ (options?.tx ?? memoryJournal())?.onRollback(() => rows.set(key, current));
354
381
  rows.delete(key);
382
+ changed(current, undefined, options?.tx ?? memoryJournal());
383
+ });
384
+ }),
385
+
386
+ deleteWhere: writing(async (filter, options) => {
387
+ // `rowsOf` is the read path: the same predicates, the same tenant scoping, and the same
388
+ // soft-delete visibility. A row already stamped is not matched, so a second call cannot
389
+ // move `deletedAt` forward — which is what the Postgres driver's `deleted_at is null`
390
+ // clause says there.
391
+ const doomed = rowsOf(deletePlan(entity, filter, options, 'deleteWhere'), {});
392
+ // A soft delete is an update, so an app-only rule judges it: bounded as `updateWhere` is, and
393
+ // one row refused takes the rows before it with it, as the statement does in Postgres.
394
+ if (entity.$softDelete && hasJsOnlyInvariant(entity.$invariants)) {
395
+ assertedBound(doomed.length, 'deleteWhere');
355
396
  }
397
+ undoingBatch(options?.tx, (tx) => {
398
+ for (const row of doomed) {
399
+ if (entity.$softDelete) {
400
+ write(
401
+ Object.assign({}, row, { [SOFT_DELETE_COLUMN]: entityNow() }),
402
+ { tx },
403
+ 'deleteWhere',
404
+ storeKey(row),
405
+ );
406
+ continue;
407
+ }
408
+ const key = storeKey(row);
409
+ tx.onRollback(() => rows.set(key, row));
410
+ rows.delete(key);
411
+ changed(row, undefined, tx);
412
+ }
413
+ });
356
414
  return doomed.length;
357
- },
415
+ }),
358
416
 
359
- async updateWhere(filter, patch, options) {
417
+ updateWhere: writing(async (filter, given, options) => {
418
+ const patch = entity.$parsePatch(given);
360
419
  const plan = updatePlan(entity, filter, patch, options, 'updateWhere');
361
420
  // The PATCH, judged whole and before the rows are read — the same call `postgresRepo` makes
362
421
  // before its statement exists. Inside the loop below it is judged only where a row was
@@ -374,56 +433,39 @@ export const memoryRepo = <Row>(
374
433
  // sweep of twelve million rows was green here and `X_INVARIANT_VIOLATED` there, from one
375
434
  // call — a test proving a call production refuses. Before the loop, so a refusal never
376
435
  // writes the rows it declined to judge.
377
- if (hasJsOnlyInvariant(entity.$invariants) && found.length > MAX_ASSERTED_ROWS) {
378
- throw assertedRowsTooMany(entity.$name, 'updateWhere', found.length);
379
- }
436
+ if (hasJsOnlyInvariant(entity.$invariants)) assertedBound(found.length, 'updateWhere');
380
437
  // The WHOLE batch is judged before any row of it lands — the same statement Postgres sends,
381
438
  // which refuses as one. A row this batch refuses used to leave the rows before it stored:
382
439
  // the write was a loop, and the loop stopped half way through. Postgres rolls that
383
440
  // statement back; here nothing was written yet, so nothing has to be undone.
384
441
  const merged = judgeUpdate(entity, found, patch, untouched(found), storeKey);
442
+ for (const row of merged) judgeReferences(row);
385
443
  for (const [position, row] of merged.entries()) {
386
444
  write(row, options, 'updateWhere', storeKey(found[position]));
387
445
  }
388
446
  return found.length;
389
- },
447
+ }),
390
448
 
391
449
  async count(args = {}) {
392
450
  return select(args, 'count').found.length;
393
451
  },
394
452
 
395
- async aggregate(fn, column, args = {}) {
396
- // Refused before a row is read, and by the same function the Postgres driver calls: a column
397
- // that has no aggregate is that mistake in both drivers or in neither.
398
- const declared = aggregateColumnOf(entity, fn, column);
399
- const { found } = select(args, fn);
400
- return foldAggregate(entity, fn, column, declared.$meta.kind, found);
401
- },
453
+ ...memoryAggregates(entity, select),
402
454
 
403
- /**
404
- * Exact here, and that is the honest answer rather than a shortcut: the estimate exists
405
- * because `count(*)` walks every visible row of a real table, and this driver's rows are
406
- * already an array whose length is free. Filters are refused in both drivers by `plan.ts`,
407
- * so the two still answer the same QUESTION.
408
- */
409
- async approximateCount(args = {}) {
410
- return select(args, 'approximateCount').found.length;
411
- },
455
+ // Asked before the wait, so another tenant's id never queues behind that tenant's holder, and
456
+ // again after it, because the row may be gone by then: Postgres re-reads a row it waited for.
457
+ lock: (id, fn, options) =>
458
+ writing(async () => {
459
+ addressed(id, options, 'lock');
460
+ return locks.run(`row:${idStoreKey(id, 'lock')}`, options?.tx, (tx) =>
461
+ fn(addressed(id, options, 'lock'), tx),
462
+ );
463
+ })(),
412
464
 
413
- async countBy(column, args = {}) {
414
- // Refused before a row is read, and by the same function the Postgres driver calls: a column
415
- // a map cannot be keyed by is that mistake in both drivers or in neither.
416
- groupColumnOf(entity, column, 'countBy');
417
- const { found } = select(args, 'countBy');
418
- const groups = new Map<unknown, number>();
419
- for (const row of found) {
420
- // `?? null`, so a property this row never carried lands in the same group Postgres puts a
421
- // NULL row in — and `0`, `''` and `false` stay the values they are.
422
- const value = field(row, column) ?? null;
423
- groups.set(value, (groups.get(value) ?? 0) + 1);
424
- }
425
- return countsFrom(entity, column, 'countBy', [...groups]);
426
- },
465
+ lockKey: (key, fn, options) =>
466
+ writing(async () =>
467
+ locks.run(`key:${lockKeyInput(entity, key, options)}`, options?.tx, fn),
468
+ )(),
427
469
 
428
470
  reset() {
429
471
  rows.clear();
@@ -432,41 +474,12 @@ export const memoryRepo = <Row>(
432
474
  };
433
475
  // The memory driver seals too: it stores what Postgres would, so a test that passes here is not
434
476
  // passing over plaintext production never holds. Append-only outermost, in both drivers: a
435
- // refused rewrite seals nothing and sends nothing.
477
+ // refused rewrite seals nothing and sends nothing, and so reports nothing.
436
478
  return appendOnlyRepo<Row, MemoryRepo<Row>>(
437
479
  entity,
438
- sealedRepo<Row, MemoryRepo<Row>>(entity, repo),
480
+ sealedRepo<Row, MemoryRepo<Row>>(entity, observedMemoryRepo(entity.$name, repo)),
439
481
  );
440
482
  };
441
483
 
442
- let txCounter = 0;
443
-
444
- /** In-memory transactor: undo closures registered by drivers run on failure. */
445
- export const memoryTransactor = (): Transactor => ({
446
- async run(work) {
447
- const undos: (() => void)[] = [];
448
- const commits: (() => void)[] = [];
449
- txCounter += 1;
450
- const tx: Tx = {
451
- id: `tx-${txCounter}`,
452
- onRollback: (undo) => undos.push(undo),
453
- onCommit: (effect) => commits.push(effect),
454
- };
455
- let result: Awaited<ReturnType<typeof work>>;
456
- try {
457
- result = await work(tx);
458
- } catch (error) {
459
- for (const undo of undos.reverse()) undo();
460
- throw error;
461
- }
462
- // After the work succeeded — the memory "commit" — and best-effort, as `@ultimat3/db` runs them.
463
- for (const effect of commits) {
464
- try {
465
- effect();
466
- } catch {
467
- // an effect is a report about a durable write; it may not fail the write
468
- }
469
- }
470
- return result;
471
- },
472
- });
484
+ /** Moved beside the lock that needs it, and still answered from here for every existing importer. */
485
+ export { memoryTransactor } from './memory-transactor';