@ultimat3/entity 30.8.0 → 31.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.
Files changed (63) hide show
  1. package/CLAUDE.md +68 -106
  2. package/README.md +180 -8
  3. package/package.json +6 -5
  4. package/src/adapter.ts +65 -0
  5. package/src/batch-read.ts +12 -0
  6. package/src/column.ts +12 -0
  7. package/src/columns.ts +20 -8
  8. package/src/cross-tenant.ts +11 -14
  9. package/src/cursor.ts +3 -0
  10. package/src/database.ts +2 -2
  11. package/src/declared-index.ts +72 -0
  12. package/src/define-driver.ts +156 -0
  13. package/src/describe.ts +5 -0
  14. package/src/driver-delete.ts +62 -0
  15. package/src/driver-errors.ts +80 -0
  16. package/src/driver-kit-types.ts +107 -0
  17. package/src/driver-registry.ts +26 -0
  18. package/src/driver-repo-reads.ts +102 -0
  19. package/src/driver-repo-writes.ts +236 -0
  20. package/src/driver-rows.ts +108 -0
  21. package/src/driver-scope.ts +77 -0
  22. package/src/driver-write-scope.ts +107 -0
  23. package/src/entity-error.ts +10 -0
  24. package/src/entity-parse.ts +3 -2
  25. package/src/entity-patch.ts +3 -1
  26. package/src/entity.ts +40 -54
  27. package/src/enum-column.ts +8 -0
  28. package/src/index.ts +43 -27
  29. package/src/json-null.ts +13 -0
  30. package/src/memory-match.ts +51 -17
  31. package/src/memory-observed.ts +16 -11
  32. package/src/memory-repo.ts +46 -99
  33. package/src/memory-seek.ts +12 -25
  34. package/src/memory-unique.ts +7 -7
  35. package/src/memory-update.ts +7 -2
  36. package/src/memory-upsert.ts +102 -0
  37. package/src/partition.ts +140 -0
  38. package/src/personal-data-kind.ts +15 -0
  39. package/src/personal-data.ts +66 -0
  40. package/src/pg-driver.ts +83 -30
  41. package/src/pg-row.ts +7 -1
  42. package/src/pg-write-sql.ts +39 -4
  43. package/src/plan.ts +2 -0
  44. package/src/predicate-screen.ts +36 -0
  45. package/src/query.ts +13 -4
  46. package/src/registry.ts +3 -0
  47. package/src/row-key.ts +19 -0
  48. package/src/row-observer.ts +38 -90
  49. package/src/search-index-installed.ts +24 -0
  50. package/src/search-index-memory.ts +132 -0
  51. package/src/search-index-postgres.ts +107 -0
  52. package/src/search-index-sync.ts +96 -0
  53. package/src/search-index-version.ts +93 -0
  54. package/src/search-index.ts +168 -0
  55. package/src/search-mirror-batch.ts +133 -0
  56. package/src/search-mirrored-repo.ts +98 -0
  57. package/src/search-repo.ts +91 -0
  58. package/src/search-written-rows.ts +36 -0
  59. package/src/tenant-index.ts +113 -0
  60. package/src/types.ts +12 -0
  61. package/src/view.ts +2 -1
  62. package/src/write-commit.ts +28 -0
  63. package/src/write-tag.ts +6 -2
@@ -1,10 +1,10 @@
1
- // Single responsibility: the memory driver's half of `@ultimat3/db`'s statement seam. Generated
1
+ // Single responsibility: the memory driver's half of the `db.statement` event. Generated
2
2
  // tests run on this driver, so a loop it never reported was a `statements` assertion that could not
3
3
  // fail. One event per repository operation, attributed exactly as `postgresRepo` attributes its
4
4
  // statement; nothing is sent, so `text` names the operation and holds no SQL.
5
5
 
6
- import { tryUseContext } from '@ultimat3/core';
7
- import { expectedQueryLoopReason, statementObserver } from '@ultimat3/db';
6
+ import { callerStack, instrument, subscribed, tryUseContext } from '@ultimat3/core';
7
+ import { expectedQueryLoopReason } from '@ultimat3/db';
8
8
  import type { MemoryRepo } from './repo';
9
9
 
10
10
  /** What one operation reports: the verb `statementKind` reads, and its row count. */
@@ -48,11 +48,13 @@ const report = (
48
48
  op: string,
49
49
  verb: Reported['verb'],
50
50
  rows: number,
51
+ callerStack: string | undefined,
51
52
  error?: unknown,
52
53
  ): void => {
53
- const observer = statementObserver();
54
- if (observer === undefined) return;
55
- observer.onStatement({
54
+ if (!subscribed('db.statement')) return;
55
+ // Emitted here, not through `@ultimat3/db`: nothing is sent, so no funnel sees it — this IS the
56
+ // memory driver's funnel, and a subscriber must not be able to tell the two drivers apart.
57
+ instrument('db.statement', {
56
58
  text: `${verb} ${entity}.${op} (memory driver, no SQL sent)`,
57
59
  values: [],
58
60
  // Nothing crossed a wire, and a duration made up of `Map` reads would only rank as noise.
@@ -61,6 +63,7 @@ const report = (
61
63
  ...(error === undefined ? {} : { error }),
62
64
  attribution: { entity, op },
63
65
  expected: expectedQueryLoopReason(),
66
+ callerStack,
64
67
  });
65
68
  };
66
69
 
@@ -86,7 +89,7 @@ const pointLookups = (): (() => boolean) => {
86
89
 
87
90
  type AnyMethod = (...args: readonly unknown[]) => Promise<unknown>;
88
91
 
89
- /** `repo`, each statement-shaped method reporting once it settles, as the funnels report. */
92
+ /** `repo`, each statement-shaped method emitting once it settles, as the funnels emit. */
90
93
  export function observedMemoryRepo<Row>(entity: string, repo: MemoryRepo<Row>): MemoryRepo<Row> {
91
94
  const wrapped: Record<string, AnyMethod> = {};
92
95
  const firstInMicrotask = pointLookups();
@@ -94,26 +97,28 @@ export function observedMemoryRepo<Row>(entity: string, repo: MemoryRepo<Row>):
94
97
  const method = repo[op] as AnyMethod;
95
98
  const { verb, rows } = OPERATIONS[op];
96
99
  wrapped[op] = async (...args) => {
97
- if (statementObserver() === undefined) return method(...args);
100
+ if (!subscribed('db.statement')) return method(...args);
98
101
  // Decided as the call is made: by the time the row is back the microtask has moved on.
99
102
  const reports = op !== 'findById' || firstInMicrotask();
100
103
  // `postgresRepo` attributes an aggregate to its function, never to `aggregate`.
101
104
  const name = op === 'aggregate' ? String(args[0]) : op;
105
+ // Before the `await`, as the Postgres funnel reads it: nothing unless capture is on.
106
+ const stack = callerStack();
102
107
  let answer: unknown;
103
108
  try {
104
109
  answer = await method(...args);
105
110
  } catch (error) {
106
- if (reports) report(entity, name, verb, 0, error);
111
+ if (reports) report(entity, name, verb, 0, stack, error);
107
112
  throw error;
108
113
  }
109
- if (reports) report(entity, name, verb, rows(answer));
114
+ if (reports) report(entity, name, verb, rows(answer), stack);
110
115
  return answer;
111
116
  };
112
117
  }
113
118
  for (const op of LOCKS) {
114
119
  const method = repo[op] as AnyMethod;
115
120
  wrapped[op] = async (...args) => {
116
- report(entity, op, 'select', 1);
121
+ report(entity, op, 'select', 1, callerStack());
117
122
  return method(...args);
118
123
  };
119
124
  }
@@ -10,10 +10,10 @@ import { pageOf } from '@ultimat3/core';
10
10
  import { refuseAbandoned } from '@ultimat3/db';
11
11
  import { appendOnlyRepo } from './append-only';
12
12
  import { keyOf } from './batch-read';
13
- import { conflictKeyOf, conflictKeys, upsertPlan } from './bulk-write';
14
13
  import { entityNow } from './clock';
15
14
  import { narrowRow } from './columns';
16
15
  import { cursorFor, kindOf, valueAt } from './cursor';
16
+ import { projectRow } from './driver-rows';
17
17
  import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
18
18
  import { invariantViolated, notFound } from './errors';
19
19
  import { assertedRowsTooMany, hasJsOnlyInvariant, MAX_ASSERTED_ROWS } from './invariants';
@@ -29,10 +29,14 @@ import { type MemoryTables, sameTenantJudge } from './memory-same-tenant';
29
29
  import { afterCursor } from './memory-seek';
30
30
  import { StoredRows, uniqueIndex, uniqueViolation } from './memory-unique';
31
31
  import { judgeUpdate, mergedRow } from './memory-update';
32
+ import { memoryUpsertAll } from './memory-upsert';
32
33
  import { deletePlan, idPlan, readPlan, singleKeyOf, updatePlan } from './plan';
33
34
  import type { FindManyArgs, MemoryRepo, RepoOptions } from './repo';
35
+ import { rowKey } from './row-key';
34
36
  import { sealedFields } from './sealed';
35
37
  import { sealedRepo } from './sealed-repo';
38
+ import { deletionStamp } from './search-index-version';
39
+ import { writtenRowsSink } from './search-written-rows';
36
40
  import type { QueryPlan } from './tenancy';
37
41
  import { assertRowTenant } from './tenancy';
38
42
  import type { RowWrite } from './types';
@@ -51,16 +55,8 @@ export const memoryRepo = <Row>(
51
55
  /** The driver's other tables, for a same-tenant reference. Alone, this repository sees itself. */
52
56
  tables: MemoryTables = new Map(),
53
57
  ): MemoryRepo<Row> => {
54
- /**
55
- * A stored row's key, spelled the way `batch-read.ts` spells an id — because Postgres compares a
56
- * `uuid` as a VALUE and prints it lower-cased, so `findById(UPPER)` reads the row there while
57
- * `String(...)` missed it here: `null` from a read and `X_NOT_FOUND` from a write, against a row
58
- * that exists, reachable from a path parameter, a client-supplied id or a legacy import.
59
- */
60
- const storeKey = (row: unknown): string =>
61
- entity.$primaryKey
62
- .map((property) => keyOf(kindOf(entity, property) ?? '', field(row, property)))
63
- .join('');
58
+ /** A stored row's key — `row-key.ts`, the one spelling both drivers use. */
59
+ const storeKey = (row: unknown): string => rowKey(entity, row);
64
60
  /** The same key, from the id a caller named rather than from a row it has in hand. */
65
61
  const idStoreKey = (id: unknown, operation: string): string =>
66
62
  keyOf(kindOf(entity, singleKeyOf(entity, operation)) ?? '', id);
@@ -105,6 +101,9 @@ export const memoryRepo = <Row>(
105
101
  const changed = memoryLifecycle(entity as EntityCore, tables);
106
102
  const judgeReferences = sameTenantJudge(entity, tables);
107
103
  const locks = memoryLocks();
104
+ /** The stamp a soft delete writes: `deletedAt`, and a searchable entity's version advanced. */
105
+ const softDeletion = () =>
106
+ deletionStamp(entity as EntityCore<unknown>, SOFT_DELETE_COLUMN, entityNow());
108
107
  /** One guard for every write, as `postgresRepo`'s `writing()`: async, so a refusal rejects. */
109
108
  const writing =
110
109
  <A extends unknown[], R>(body: (...args: A) => Promise<R>) =>
@@ -113,15 +112,21 @@ export const memoryRepo = <Row>(
113
112
  return body(...args);
114
113
  };
115
114
 
116
- const rowsOf = (plan: QueryPlan, args: FindManyArgs): Row[] => {
117
- const visible = (row: Row): boolean =>
115
+ /** Whether a soft-deleted row is in the read: hidden unless the caller asked to include it. */
116
+ const visibleTo =
117
+ (args: FindManyArgs) =>
118
+ (row: Row): boolean =>
118
119
  !entity.$softDelete ||
119
120
  args.includeDeleted === true ||
120
121
  field(row, SOFT_DELETE_COLUMN) === null ||
121
122
  field(row, SOFT_DELETE_COLUMN) === undefined;
123
+ const matchesPlan = (plan: QueryPlan, row: Row): boolean =>
124
+ plan.where.every((predicate) => matchesPredicate(entity, row, predicate));
125
+
126
+ const rowsOf = (plan: QueryPlan, args: FindManyArgs): Row[] => {
122
127
  return [...rows.values()]
123
- .filter((row) => plan.where.every((predicate) => matchesPredicate(entity, row, predicate)))
124
- .filter(visible)
128
+ .filter((row) => matchesPlan(plan, row))
129
+ .filter(visibleTo(args))
125
130
  .sort((left, right) => {
126
131
  for (const entry of plan.orderBy) {
127
132
  const order = compareByKind(
@@ -226,12 +231,17 @@ export const memoryRepo = <Row>(
226
231
  // synchronously, or half the call sites would need two error paths. Every write asks
227
232
  // `refuseAbandoned()` first, as `postgresRepo`'s `writing()` does (`write-abandon.test.ts`).
228
233
  const repo: MemoryRepo<Row> = {
234
+ // Keyed, never a scan: the plan is still built — it is where tenancy is applied and refused —
235
+ // and the one row under the id must pass every predicate in it and be visible, as `rowsOf`
236
+ // would judge it. Filtering and sorting the table cost 6.49 ms at 20k rows.
229
237
  async findById(id, options) {
230
- const { found } = select(
231
- { ...options, where: [{ column: singleKeyOf(entity, 'findById'), op: 'eq', value: id }] },
232
- 'findById',
233
- );
234
- return found[0] ?? null;
238
+ const args: FindManyArgs = {
239
+ ...options,
240
+ where: [{ column: singleKeyOf(entity, 'findById'), op: 'eq', value: id }],
241
+ };
242
+ const plan = readPlan(entity, args, 'findById');
243
+ const row = rows.get(idStoreKey(id, 'findById'));
244
+ return row !== undefined && visibleTo(args)(row) && matchesPlan(plan, row) ? row : null;
235
245
  },
236
246
 
237
247
  async findMany(args = {}) {
@@ -240,8 +250,10 @@ export const memoryRepo = <Row>(
240
250
  const page = found.slice(start, start + plan.limit);
241
251
  const last = page.at(-1);
242
252
  const more = start + page.length < found.length;
253
+ // Projected as `pg-sql.ts` projects (`driver-rows.ts`): the asked columns, the key and the
254
+ // sort keys — the cursor is minted from the stored row, which holds all of them anyway.
243
255
  return pageOf(
244
- page,
256
+ page.map((row) => projectRow(entity, plan, row)),
245
257
  more && last !== undefined ? cursorFor(entity, plan, last, storeKey(last)) : null,
246
258
  );
247
259
  },
@@ -280,69 +292,9 @@ export const memoryRepo = <Row>(
280
292
  return batch.map((row) => write(row, options, 'insertAll', undefined, true));
281
293
  }),
282
294
 
283
- upsertAll: writing(async (given, args) => {
284
- // The INCOMING rows, judged before any of them is matched: under `onMatch: 'nothing'` a
285
- // colliding row is skipped and never reaches `write()`, so checking only what lands would
286
- // let a row naming another tenant through whenever it happened to collide. Narrowed first
287
- // for the reason `insertAll` above is, and before `conflictKeyOf` reads a target too.
288
- const batch = narrowed(given);
289
- // Plan first, then rows: the order `postgresRepo` refuses in, so one bad call has one code.
290
- const { onConflict, onMatch = 'update', overwrites } = args;
291
- const plan = upsertPlan(entity, batch, onConflict, onMatch, overwrites);
292
- const keys = conflictKeys(entity, plan, batch);
293
- for (const row of batch) {
294
- assertRowTenant(entity.$name, entity.$tenantColumn, 'upsertAll', row);
295
- entity.$assert(row);
296
- }
297
- // The stored rows under the same key, so "does this collide" is the question the unique
298
- // index answers in Postgres and not a scan per row. A soft-deleted row still occupies its
299
- // key here, because the index it would collide with there is not partial either — and a row
300
- // whose target holds a null occupies none, because the index is `NULLS DISTINCT`.
301
- const stored = new Map<string, Row>();
302
- for (const row of rows.values()) {
303
- const key = conflictKeyOf(entity, plan.on, row);
304
- if (key !== undefined) stored.set(key, row);
305
- }
306
- // A row that lands is judged before any of them does: the statement refuses as one. One left
307
- // alone by `do nothing` wrote nothing, so its reference is checked by nobody on either driver.
308
- for (const [position, row] of batch.entries()) {
309
- const key = keys[position];
310
- const collides = key !== undefined && stored.has(key);
311
- if (!collides || plan.set.length > 0) judgeReferences(row, batch);
312
- }
313
- const written: Row[] = [];
314
- // A row colliding on ANOTHER unique takes the rows before it with it, as the statement does.
315
- undoingBatch(args.tx, (tx) => {
316
- for (const [position, row] of batch.entries()) {
317
- const key = keys[position];
318
- const existing = key === undefined ? undefined : stored.get(key);
319
- // `do nothing` writes no row, and `returning *` therefore names none: a skipped row is
320
- // absent from the result rather than present and unchanged.
321
- if (existing !== undefined && plan.set.length === 0) continue;
322
- const merged =
323
- existing === undefined
324
- ? row
325
- : Object.assign(
326
- {},
327
- existing,
328
- Object.fromEntries(plan.set.map((property) => [property, field(row, property)])),
329
- );
330
- // `UpsertArgs extends RepoOptions`, so the args ARE the options — one bag, and a `tx`
331
- // passed to an upsert registers its undo exactly as it does for every other write here.
332
- const result = write(
333
- merged,
334
- { tx },
335
- 'upsertAll',
336
- existing === undefined ? undefined : storeKey(existing),
337
- );
338
- // Filed as it lands, so a later row of the same batch collides with an earlier one exactly
339
- // as it would with a row the request stored a moment before it.
340
- if (key !== undefined) stored.set(key, result);
341
- written.push(result);
342
- }
343
- });
344
- return written;
345
- }),
295
+ upsertAll: writing(async (given, args) =>
296
+ memoryUpsertAll({ entity, rows, write, storeKey, judgeReferences }, narrowed(given), args),
297
+ ),
346
298
 
347
299
  update: writing(async (id, given, options) => {
348
300
  // Here and in `pg-driver.ts`, the one door: a value `insert` refuses, a fragment included.
@@ -359,13 +311,10 @@ export const memoryRepo = <Row>(
359
311
  return locks.turn(`row:${idStoreKey(id, 'delete')}`, options?.tx, () => {
360
312
  const current = addressed(id, options, 'delete');
361
313
  // Soft delete hides the row without losing it; the column's presence is the switch.
314
+ // The deleted row, for the search mirror when it asked (`search-written-rows.ts`).
315
+ writtenRowsSink()?.([current]);
362
316
  if (entity.$softDelete) {
363
- write(
364
- Object.assign({}, current, { [SOFT_DELETE_COLUMN]: entityNow() }),
365
- options,
366
- 'delete',
367
- storeKey(current),
368
- );
317
+ write(mergedRow(entity, current, softDeletion()), options, 'delete', storeKey(current));
369
318
  return;
370
319
  }
371
320
  const key = storeKey(current);
@@ -389,12 +338,7 @@ export const memoryRepo = <Row>(
389
338
  undoingBatch(options?.tx, (tx) => {
390
339
  for (const row of doomed) {
391
340
  if (entity.$softDelete) {
392
- write(
393
- Object.assign({}, row, { [SOFT_DELETE_COLUMN]: entityNow() }),
394
- { tx },
395
- 'deleteWhere',
396
- storeKey(row),
397
- );
341
+ write(mergedRow(entity, row, softDeletion()), { tx }, 'deleteWhere', storeKey(row));
398
342
  continue;
399
343
  }
400
344
  const key = storeKey(row);
@@ -403,6 +347,8 @@ export const memoryRepo = <Row>(
403
347
  changed(row, undefined, tx);
404
348
  }
405
349
  });
350
+ // Past the judged bound the rows are not handed over, as Postgres's `counted` decides.
351
+ if (doomed.length <= MAX_ASSERTED_ROWS) writtenRowsSink()?.(doomed);
406
352
  return doomed.length;
407
353
  }),
408
354
 
@@ -432,9 +378,10 @@ export const memoryRepo = <Row>(
432
378
  // statement back; here nothing was written yet, so nothing has to be undone.
433
379
  const merged = judgeUpdate(entity, found, patch, untouched(found), storeKey);
434
380
  for (const row of merged) judgeReferences(row);
435
- for (const [position, row] of merged.entries()) {
436
- write(row, options, 'updateWhere', storeKey(found[position]));
437
- }
381
+ const written = merged.map((row, at) =>
382
+ write(row, options, 'updateWhere', storeKey(found[at])),
383
+ );
384
+ if (written.length <= MAX_ASSERTED_ROWS) writtenRowsSink()?.(written);
438
385
  return found.length;
439
386
  }),
440
387
 
@@ -1,32 +1,11 @@
1
1
  // Single responsibility: where a cursor's next page starts among the memory driver's sorted rows.
2
2
  // Split from `memory-repo.ts` for its line ceiling.
3
3
 
4
- import { kindOf, seekFrom, valueAt } from './cursor';
4
+ import { seekFrom } from './cursor';
5
+ import { afterSeek } from './driver-rows';
5
6
  import type { EntityCore } from './entity';
6
- import { compareByKind } from './memory-match';
7
7
  import type { QueryPlan } from './tenancy';
8
8
 
9
- /** Lexicographic over the sort keys, direction applied. `> 0` means "after the cursor". */
10
- const compareToSeek = <Row>(
11
- entity: EntityCore<Row>,
12
- plan: QueryPlan,
13
- row: unknown,
14
- seek: readonly unknown[],
15
- ): number => {
16
- for (const [index, entry] of plan.orderBy.entries()) {
17
- // The COLUMN's kind, not the value's: the seek was revived from the same kind (`cursor.ts`),
18
- // so a `bigint` column compares its stored decimal string against a revived `BigInt` as one
19
- // number instead of as two pieces of text.
20
- const order = compareByKind(
21
- kindOf(entity, entry.column),
22
- valueAt(row, entry.column),
23
- seek[index],
24
- );
25
- if (order !== 0) return entry.direction === 'desc' ? -order : order;
26
- }
27
- return 0;
28
- };
29
-
30
9
  /**
31
10
  * Where the next page starts. By sort position, not by the previous row's id: that row may have
32
11
  * been deleted between the two requests, and an id that is no longer there would restart
@@ -39,6 +18,14 @@ export const afterCursor = <Row>(
39
18
  ): number => {
40
19
  const seek = seekFrom(entity, plan);
41
20
  if (seek === undefined) return 0;
42
- const start = found.findIndex((row) => compareToSeek(entity, plan, row, seek) > 0);
43
- return start === -1 ? found.length : start;
21
+ // Binary search: `found` is sorted by the very keys `afterSeek` reads, so "after the cursor"
22
+ // is a suffix. A linear `findIndex` made walking a table page by page quadratic.
23
+ let low = 0;
24
+ let high = found.length;
25
+ while (low < high) {
26
+ const middle = (low + high) >>> 1;
27
+ if (afterSeek(entity, plan, found[middle], seek)) high = middle;
28
+ else low = middle + 1;
29
+ }
30
+ return low;
44
31
  };
@@ -3,21 +3,21 @@
3
3
  // duplicate key and ignored `unique()` entirely, so a signup race that is a 409 in production was
4
4
  // a quiet overwrite under `x dev` and in every app test.
5
5
 
6
- import { driverError } from '@ultimat3/db';
7
6
  import { keyOf } from './batch-read';
8
7
  import { columnName } from './column';
8
+ import { entityUniqueViolation } from './driver-errors';
9
9
  import type { EntityCore } from './entity';
10
10
  import { bindValues } from './pg-row';
11
11
  import type { RowPatch } from './types';
12
12
  import { uniqueConstraints } from './unique-constraints';
13
13
 
14
- /** The refusal Postgres answers `23505` with, in the shape `driverError` gives it there. */
14
+ /**
15
+ * The refusal Postgres answers `23505` with: `X_DB_UNIQUE_VIOLATION`, the same code, through
16
+ * `driver-errors.ts` — the one builder every non-Postgres store raises it with. No SQLSTATE is
17
+ * faked: no server said `23505`, so `sqlState()` answers `undefined` here.
18
+ */
15
19
  export const uniqueViolation = (entity: EntityCore, constraint: string): Error =>
16
- driverError(`memory write into ${entity.$table}`, {
17
- code: '23505',
18
- constraint,
19
- message: `duplicate key value violates unique constraint "${constraint}"`,
20
- });
20
+ entityUniqueViolation(entity, constraint);
21
21
 
22
22
  /** A number's kinds: `'2.5'` and `'2.50'` are one key to Postgres, and so are `7` and `'7'`. */
23
23
  const BY_VALUE: ReadonlySet<string> = new Set(['integer', 'bigint', 'numeric']);
@@ -10,6 +10,7 @@
10
10
  import { narrowRow } from './columns';
11
11
  import type { EntityCore } from './entity';
12
12
  import { uniqueIndex, uniqueViolation } from './memory-unique';
13
+ import { advanceVersion } from './search-index-version';
13
14
  import { assertRowTenant } from './tenancy';
14
15
 
15
16
  /**
@@ -29,8 +30,12 @@ const defined = (patch: object): object =>
29
30
  * same reason (`memory-repo.ts`), and narrowing twice is harmless: the second pass finds nothing
30
31
  * left to change.
31
32
  */
32
- export const mergedRow = <Row>(entity: EntityCore<Row>, row: Row, patch: object): Row =>
33
- narrowRow<Row>(entity.$columns, Object.assign({}, row, defined(patch)));
33
+ export const mergedRow = <Row>(entity: EntityCore<Row>, row: Row, patch: object): Row => {
34
+ const given = defined(patch);
35
+ const merged = narrowRow<Row>(entity.$columns, Object.assign({}, row, given));
36
+ // A searchable entity's version moves past the stored one even inside one millisecond.
37
+ return advanceVersion(entity, row, merged, (property) => Object.hasOwn(given, property));
38
+ };
34
39
 
35
40
  /**
36
41
  * The rows a filtered update would write, judged as ONE batch: the patch merged into every
@@ -0,0 +1,102 @@
1
+ // Single responsibility: the memory driver's `upsertAll` — which incoming row collides with which
2
+ // stored one, and what a collision overwrites — so a test over memory resolves a conflict exactly
3
+ // as `insert … on conflict` does. Split from `memory-repo.ts` for its line ceiling.
4
+
5
+ import { conflictKeyOf, conflictKeys, upsertPlan } from './bulk-write';
6
+ import type { EntityCore } from './entity';
7
+ import { undoingBatch } from './memory-batch';
8
+ import type { RepoOptions, UpsertArgs } from './repo';
9
+ import { advanceVersion } from './search-index-version';
10
+ import { assertRowTenant } from './tenancy';
11
+ import type { RowWrite } from './types';
12
+
13
+ const field = (row: unknown, property: string): unknown =>
14
+ typeof row === 'object' && row !== null ? (row as Record<string, unknown>)[property] : undefined;
15
+
16
+ /** What the upsert needs of the repository it belongs to: its rows and its one write. */
17
+ export interface MemoryUpsertScope<Row> {
18
+ readonly entity: EntityCore<Row>;
19
+ readonly rows: { values(): Iterable<Row> };
20
+ write(
21
+ given: RowWrite<Row>,
22
+ options: RepoOptions | undefined,
23
+ operation: string,
24
+ from?: string,
25
+ ): Row;
26
+ storeKey(row: unknown): string;
27
+ judgeReferences(row: Row, batch?: readonly Row[]): void;
28
+ }
29
+
30
+ /** `batch` is already narrowed: the rows as they would be stored. */
31
+ export const memoryUpsertAll = <Row>(
32
+ { entity, rows, write, storeKey, judgeReferences }: MemoryUpsertScope<Row>,
33
+ batch: readonly Row[],
34
+ args: UpsertArgs<Row>,
35
+ ): readonly Row[] => {
36
+ // The INCOMING rows, judged before any of them is matched: under `onMatch: 'nothing'` a
37
+ // colliding row is skipped and never reaches `write()`, so checking only what lands would
38
+ // let a row naming another tenant through whenever it happened to collide. The caller narrowed
39
+ // them, for the reason its `insertAll` does, and before `conflictKeyOf` reads a target too.
40
+ // Plan first, then rows: the order `postgresRepo` refuses in, so one bad call has one code.
41
+ const { onConflict, onMatch = 'update', overwrites } = args;
42
+ const plan = upsertPlan(entity, batch, onConflict, onMatch, overwrites);
43
+ const keys = conflictKeys(entity, plan, batch);
44
+ for (const row of batch) {
45
+ assertRowTenant(entity.$name, entity.$tenantColumn, 'upsertAll', row);
46
+ entity.$assert(row);
47
+ }
48
+ // The stored rows under the same key, so "does this collide" is the question the unique
49
+ // index answers in Postgres and not a scan per row. A soft-deleted row still occupies its
50
+ // key here, because the index it would collide with there is not partial either — and a row
51
+ // whose target holds a null occupies none, because the index is `NULLS DISTINCT`.
52
+ const stored = new Map<string, Row>();
53
+ for (const row of rows.values()) {
54
+ const key = conflictKeyOf(entity, plan.on, row);
55
+ if (key !== undefined) stored.set(key, row);
56
+ }
57
+ // A row that lands is judged before any of them does: the statement refuses as one. One left
58
+ // alone by `do nothing` wrote nothing, so its reference is checked by nobody on either driver.
59
+ for (const [position, row] of batch.entries()) {
60
+ const key = keys[position];
61
+ const collides = key !== undefined && stored.has(key);
62
+ if (!collides || plan.set.length > 0) judgeReferences(row, batch);
63
+ }
64
+ const written: Row[] = [];
65
+ // A row colliding on ANOTHER unique takes the rows before it with it, as the statement does.
66
+ undoingBatch(args.tx, (tx) => {
67
+ for (const [position, row] of batch.entries()) {
68
+ const key = keys[position];
69
+ const existing = key === undefined ? undefined : stored.get(key);
70
+ // `do nothing` writes no row, and `returning *` therefore names none: a skipped row is
71
+ // absent from the result rather than present and unchanged.
72
+ if (existing !== undefined && plan.set.length === 0) continue;
73
+ // A collision's version moves past the stored one, as an update's does (`advanceVersion`).
74
+ const merged =
75
+ existing === undefined
76
+ ? row
77
+ : advanceVersion(
78
+ entity,
79
+ existing,
80
+ Object.assign(
81
+ {},
82
+ existing,
83
+ Object.fromEntries(plan.set.map((property) => [property, field(row, property)])),
84
+ ),
85
+ (property) => plan.set.includes(property),
86
+ );
87
+ // `UpsertArgs extends RepoOptions`, so the args ARE the options — one bag, and a `tx`
88
+ // passed to an upsert registers its undo exactly as it does for every other write here.
89
+ const result = write(
90
+ merged,
91
+ { tx },
92
+ 'upsertAll',
93
+ existing === undefined ? undefined : storeKey(existing),
94
+ );
95
+ // Filed as it lands, so a later row of the same batch collides with an earlier one exactly
96
+ // as it would with a row the request stored a moment before it.
97
+ if (key !== undefined) stored.set(key, result);
98
+ written.push(result);
99
+ }
100
+ });
101
+ return written;
102
+ };