@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,100 @@
1
+ // Single responsibility: the three lifecycle declarations an entity may carry — `retain`, `history`
2
+ // and the `countedIn` counters kept on its columns — resolved once at `entity()` and projected onto its
3
+ // description. Split from `entity.ts`, which was at its ceiling; each rule is its own file's.
4
+
5
+ import type {
6
+ CounterDescriptionLike,
7
+ HistoryDescriptionLike,
8
+ RetainDescriptionLike,
9
+ } from '@ultimat3/db';
10
+ import { columnName } from './column';
11
+ import { countersOf, describeCounter } from './counter';
12
+ import type { EntityCore } from './entity';
13
+ import { invariantViolated } from './errors';
14
+ import { type HistoryEntity, type HistoryInit, historyEntity } from './history';
15
+ import type { Retain } from './retain';
16
+ import type { ColumnMap } from './types';
17
+
18
+ /** What `$describe()` gains, each absent unless declared so no other entity's hash moves. */
19
+ export interface LifecycleDescription {
20
+ readonly history?: HistoryDescriptionLike;
21
+ readonly counts?: readonly CounterDescriptionLike[];
22
+ readonly retain?: RetainDescriptionLike;
23
+ readonly historyOf?: string;
24
+ }
25
+
26
+ export interface LifecycleInput {
27
+ readonly name: string;
28
+ readonly table: string;
29
+ readonly columns: ColumnMap;
30
+ readonly primaryKey: readonly string[];
31
+ readonly tenantColumn: string | null;
32
+ readonly appendOnly: boolean;
33
+ readonly history: HistoryInit | undefined;
34
+ readonly historyOf: string | undefined;
35
+ }
36
+
37
+ /** The history entity, declared beside its parent — `null` when the entity keeps none. */
38
+ export function lifecycleHistory(input: LifecycleInput): HistoryEntity | null {
39
+ if (input.history === undefined) return null;
40
+ if (input.historyOf !== undefined || input.appendOnly) {
41
+ throw invariantViolated(
42
+ input.name,
43
+ 'history',
44
+ input.appendOnly
45
+ ? 'an appendOnly entity changes no row, so it has no history to keep — drop one of the two'
46
+ : 'a history table keeps no history of its own',
47
+ );
48
+ }
49
+ return historyEntity(input, input.history);
50
+ }
51
+
52
+ /**
53
+ * The physical columns whose jsonb spelling is not the row's: an `int8`/`numeric` becomes a JSON
54
+ * number a reader parses into a float, a `timestamptz` the session zone's offset. The trigger
55
+ * stores these as the strings the memory driver keeps. Absent when empty, so no other hash moves.
56
+ */
57
+ function exactColumns(core: EntityCore): Pick<HistoryDescriptionLike, 'text' | 'instants'> {
58
+ const text: string[] = [];
59
+ const instants: string[] = [];
60
+ for (const [property, column] of Object.entries(core.$columns)) {
61
+ const kind = column.$meta.kind;
62
+ if (kind === 'bigint' || kind === 'numeric') text.push(columnName(property, column.$meta));
63
+ if (kind === 'timestamptz') instants.push(columnName(property, column.$meta));
64
+ }
65
+ return {
66
+ ...(text.length === 0 ? {} : { text }),
67
+ ...(instants.length === 0 ? {} : { instants }),
68
+ };
69
+ }
70
+
71
+ const physical = (core: EntityCore, property: string): string => {
72
+ const column = core.$columns[property];
73
+ return column === undefined ? property : columnName(property, column.$meta);
74
+ };
75
+
76
+ /** Read at describe time: a counter's thunk can only be answered once both modules evaluated. */
77
+ export function describeLifecycle(
78
+ core: EntityCore,
79
+ retain: Retain | null,
80
+ historyOf: string | undefined,
81
+ ): LifecycleDescription {
82
+ const counters = countersOf(core);
83
+ const history = core.$history;
84
+ return {
85
+ ...(history === null
86
+ ? {}
87
+ : {
88
+ history: {
89
+ table: history.$table,
90
+ key: physical(core, core.$primaryKey[0] ?? ''),
91
+ tenant: core.$tenantColumn === null ? null : physical(core, core.$tenantColumn),
92
+ ignore: counters.map((counter) => physical(core, counter.column)),
93
+ ...exactColumns(core),
94
+ },
95
+ }),
96
+ ...(counters.length === 0 ? {} : { counts: counters.map(describeCounter) }),
97
+ ...(retain === null ? {} : { retain: { after: retain.after, on: retain.column } }),
98
+ ...(historyOf === undefined ? {} : { historyOf }),
99
+ };
100
+ }
@@ -0,0 +1,27 @@
1
+ // Single responsibility: what `lockKey` locks, in one place for both drivers. The tenant comes from
2
+ // the read path's own plan, so an org's key never blocks another org's and an actor with none is
3
+ // refused exactly as a read is, by the same guard and the same code.
4
+
5
+ import { assert } from '@ultimat3/core';
6
+ import type { EntityCore } from './entity';
7
+ import { readPlan } from './plan';
8
+ import type { RepoOptions } from './repo';
9
+
10
+ /**
11
+ * `<table>:<tenant or ->:<key>`. `-` is an entity with no tenant, or a `crossTenant()` sweep that
12
+ * named none: the sweep then holds its own key and no org's, and naming `orgId` is how it takes one.
13
+ */
14
+ export const lockKeyInput = <Row>(
15
+ entity: EntityCore<Row>,
16
+ key: string,
17
+ options: RepoOptions | undefined,
18
+ ): string => {
19
+ assert(
20
+ key.trim() !== '',
21
+ `${entity.$name}.lockKey() was given a blank key, so every caller would share one lock`,
22
+ `db.${entity.$name}.lockKey('recount:' + id, fn) # name what the lock protects`,
23
+ );
24
+ const plan = readPlan(entity, { ...options }, 'lockKey');
25
+ const named = plan.where.find((predicate) => predicate.column === entity.$tenantColumn)?.value;
26
+ return `${entity.$table}:${typeof named === 'string' ? named : '-'}:${key}`;
27
+ };
@@ -0,0 +1,11 @@
1
+ // The refusal a hand-written `Repo` answers `lock` and `lockKey` with. They are optional on the
2
+ // contract so such a driver still compiles, which is exactly why the typed handle has to say so.
3
+
4
+ import { EntityError } from './entity-error';
5
+
6
+ export const lockUnsupported = (entityName: string, method: 'lock' | 'lockKey'): EntityError =>
7
+ new EntityError({
8
+ code: 'X_LOCK_UNSUPPORTED',
9
+ cause: `${entityName}.${method}() reached a repository that does not implement it: a hand-written driver, which the contract lets omit both`,
10
+ fix: 'database(entities, { driver: postgresDriver() }) # the shipped driver has both; a hand-written Repo implements lock and lockKey',
11
+ });
@@ -0,0 +1,99 @@
1
+ // Single responsibility: the in-memory driver's aggregate reads — `aggregate`, `approximateCount`,
2
+ // `countBy` and `series` — over rows the driver already selected. Split from `memory-repo.ts` at
3
+ // its line ceiling; every rule here is read from the file both drivers share (`aggregate.ts`,
4
+ // `count-by.ts`, `time-bucket.ts`), so this is execution, never a second meaning.
5
+
6
+ import { aggregateColumnOf, assertOneUnit, type MoneyUnit } from './aggregate';
7
+ import { foldAggregate } from './aggregate-fold';
8
+ import { countsFrom, groupColumnOf } from './count-by';
9
+ import { valueAt } from './cursor';
10
+ import type { EntityCore } from './entity';
11
+ import type { FindManyArgs, Repo } from './repo';
12
+ import type { QueryPlan } from './tenancy';
13
+ import { bucketStart, type SeriesPoint, seriesFrom, seriesPlanOf } from './time-bucket';
14
+
15
+ /** `memoryRepo`'s own read: the plan built and guarded, the visible rows it matches. */
16
+ export type MemorySelect<Row> = (
17
+ args: FindManyArgs,
18
+ operation: string,
19
+ ) => { plan: QueryPlan; found: Row[] };
20
+
21
+ type AggregateReads<Row> = Pick<
22
+ Required<Repo<Row>>,
23
+ 'aggregate' | 'approximateCount' | 'countBy' | 'series'
24
+ >;
25
+
26
+ const moneyUnitOf = (value: unknown): MoneyUnit | undefined => {
27
+ if (typeof value !== 'object' || value === null) return undefined;
28
+ const money = value as { readonly currency?: unknown; readonly scale?: unknown };
29
+ if (typeof money.currency !== 'string') return undefined;
30
+ return { currency: money.currency, scale: typeof money.scale === 'number' ? money.scale : null };
31
+ };
32
+
33
+ export const memoryAggregates = <Row>(
34
+ entity: EntityCore<Row>,
35
+ select: MemorySelect<Row>,
36
+ ): AggregateReads<Row> => ({
37
+ async aggregate(fn, column, args = {}) {
38
+ // Refused before a row is read, and by the same function the Postgres driver calls: a column
39
+ // that has no aggregate is that mistake in both drivers or in neither.
40
+ const declared = aggregateColumnOf(entity, fn, column);
41
+ const { found } = select(args, fn);
42
+ return foldAggregate(entity, fn, column, declared.$meta.kind, found);
43
+ },
44
+
45
+ /**
46
+ * Exact here, and that is the honest answer rather than a shortcut: the estimate exists
47
+ * because `count(*)` walks every visible row of a real table, and this driver's rows are
48
+ * already an array whose length is free. Filters are refused in both drivers by `plan.ts`,
49
+ * so the two still answer the same QUESTION.
50
+ */
51
+ async approximateCount(args = {}) {
52
+ return select(args, 'approximateCount').found.length;
53
+ },
54
+
55
+ async countBy(column, args = {}) {
56
+ // Refused before a row is read, and by the same function the Postgres driver calls: a column
57
+ // a map cannot be keyed by is that mistake in both drivers or in neither.
58
+ groupColumnOf(entity, column, 'countBy');
59
+ const { found } = select(args, 'countBy');
60
+ const groups = new Map<unknown, number>();
61
+ for (const row of found) {
62
+ // `?? null`, so a property this row never carried lands in the same group Postgres puts a
63
+ // NULL row in — and `0`, `''` and `false` stay the values they are.
64
+ const value = valueAt(row, column) ?? null;
65
+ groups.set(value, (groups.get(value) ?? 0) + 1);
66
+ }
67
+ return countsFrom(entity, column, 'countBy', [...groups]);
68
+ },
69
+
70
+ async series(spec, args = {}) {
71
+ const plan = seriesPlanOf(entity, spec);
72
+ const { found } = select(args, 'series');
73
+ const sum = plan.sum;
74
+ // One unit across EVERY matched row, before any bucket: the Postgres driver judges the whole
75
+ // predicate in one statement, so a bucket-by-bucket check here would answer where it refuses.
76
+ if (sum?.column.$meta.kind === 'money') {
77
+ const units = found.flatMap((row) => moneyUnitOf(valueAt(row, sum.property)) ?? []);
78
+ assertOneUnit(entity, 'sum', sum.property, units);
79
+ }
80
+ // A row with no instant is in no bucket — Postgres' statement says `is not null`.
81
+ const buckets = new Map<number, Row[]>();
82
+ for (const row of found) {
83
+ const at = valueAt(row, plan.on);
84
+ if (!(at instanceof Date)) continue;
85
+ const start = bucketStart(at, plan.unit, plan.timeZone).getTime();
86
+ const bucket = buckets.get(start);
87
+ if (bucket === undefined) buckets.set(start, [row]);
88
+ else bucket.push(row);
89
+ }
90
+ const points: SeriesPoint[] = [...buckets].map(([start, rows]) => ({
91
+ bucket: new Date(start),
92
+ value:
93
+ sum === null
94
+ ? rows.length
95
+ : foldAggregate(entity, 'sum', sum.property, sum.column.$meta.kind, rows),
96
+ }));
97
+ return seriesFrom(entity, plan, points);
98
+ },
99
+ });
@@ -0,0 +1,69 @@
1
+ // Single responsibility: the memory driver's one-statement promise for a batch judged as it lands.
2
+ // Postgres refuses a statement as one; rows the memory driver can only judge one at a time are
3
+ // undone here when a later one is refused, and handed to the surrounding `tx` when none is.
4
+
5
+ import {
6
+ enrolMemoryScope,
7
+ memoryJournal,
8
+ memoryOutcomeOf,
9
+ memoryTxClosed,
10
+ refuseIfMemoryTxClosed,
11
+ } from './memory-journal';
12
+ import type { Tx } from './repo';
13
+
14
+ /**
15
+ * `work` under a scope collecting its undos: run on a throw or a rejection, handed to `tx` when it
16
+ * succeeds. An effect with nowhere to wait runs at once, what `Tx.onCommit` promises a bare `Tx`.
17
+ * Async work settles the scope when it settles; a lock's savepoint is this scope too.
18
+ */
19
+ export const undoingBatch = <T>(given: Tx | undefined, work: (scope: Tx) => T): T => {
20
+ // No `tx` named: the ambient rollback scope, when one is open, is the surrounding transaction.
21
+ const tx = given ?? memoryJournal();
22
+ // Before a row moves, as `@ultimat3/db` refuses before a statement is sent.
23
+ refuseIfMemoryTxClosed(tx);
24
+ const undos: (() => void)[] = [];
25
+ const undo = (): void => {
26
+ for (const each of undos.reverse()) each();
27
+ undos.length = 0;
28
+ };
29
+ const handUp = (): void => {
30
+ // The scope outlived its transaction, as a RELEASE sent after COMMIT or ROLLBACK is refused.
31
+ // Its rows went where the root went: kept by a commit, undone here after a rollback.
32
+ const ended = tx === undefined ? undefined : memoryOutcomeOf(tx)?.value;
33
+ if (tx !== undefined && ended !== undefined && ended !== 'open') {
34
+ if (ended === 'rolled-back') undo();
35
+ undos.length = 0;
36
+ throw memoryTxClosed(tx.id, ended);
37
+ }
38
+ for (const each of undos) tx?.onRollback(each);
39
+ };
40
+ const scope: Tx = {
41
+ id: tx?.id ?? 'batch',
42
+ onRollback: (each) => {
43
+ refuseIfMemoryTxClosed(scope);
44
+ undos.push(each);
45
+ },
46
+ onCommit: (effect) => (tx?.onCommit === undefined ? effect() : tx.onCommit(effect)),
47
+ };
48
+ enrolMemoryScope(scope, tx);
49
+ try {
50
+ const result = work(scope);
51
+ if (result instanceof Promise) {
52
+ return result.then(
53
+ (value) => {
54
+ handUp();
55
+ return value;
56
+ },
57
+ (error: unknown) => {
58
+ undo();
59
+ throw error;
60
+ },
61
+ ) as T;
62
+ }
63
+ handUp();
64
+ return result;
65
+ } catch (error) {
66
+ undo();
67
+ throw error;
68
+ }
69
+ };
@@ -0,0 +1,68 @@
1
+ // Single responsibility: the memory transaction this async chain is inside, ambient as
2
+ // `@ultimat3/db`'s `withTransaction` is — a write that names no `tx` joins it, so a handler that
3
+ // passes none is still undone when the transaction rolls back — and whether that one has ENDED: the
4
+ // async chain outlives the scope, so the store's presence is not an open transaction.
5
+
6
+ import { asyncContext } from '@ultimat3/core';
7
+ import { DbError } from '@ultimat3/db';
8
+ import type { Tx } from './repo';
9
+
10
+ /** How the ROOT ended, shared by reference across nesting, as `@ultimat3/db`'s `TxOutcome` is. */
11
+ export interface MemoryOutcome {
12
+ value: 'open' | 'committed' | 'rolled-back';
13
+ }
14
+
15
+ /** Each memory `Tx` handed out, its root and the root's outcome: a hand-written `Tx` has none. */
16
+ interface Scope {
17
+ readonly root: Tx;
18
+ readonly outcome: MemoryOutcome;
19
+ }
20
+
21
+ const journal = asyncContext<Tx>('an open memory transaction');
22
+ const scopes = new WeakMap<Tx, Scope>();
23
+
24
+ /** The open memory transaction this async chain writes under, or `undefined` outside one. */
25
+ export const memoryJournal = (): Tx | undefined => journal.get();
26
+
27
+ /** `work` with `tx` as the ambient memory transaction. `memoryTransactor` is the one caller. */
28
+ export const inMemoryJournal = <T>(tx: Tx, work: () => T): T => journal.run(tx, work);
29
+
30
+ /** `work` under no memory transaction: an effect after COMMIT writes as its own unit of work. */
31
+ export const outsideMemoryJournal = <T>(work: () => T): T => journal.exit(work);
32
+
33
+ /** Files `tx` under its root. `memoryTransactor` is the one caller. */
34
+ export const enrolMemoryTx = (tx: Tx, root: Tx, outcome: MemoryOutcome): void => {
35
+ scopes.set(tx, { root, outcome });
36
+ };
37
+
38
+ /** The outcome a memory `tx` shares with its root, or `undefined` for any other `Tx`. */
39
+ export const memoryOutcomeOf = (tx: Tx): MemoryOutcome | undefined => scopes.get(tx)?.outcome;
40
+
41
+ /** `scope` — a batch or a lock's savepoint over `tx` — answers to `tx`'s root, when it has one. */
42
+ export const enrolMemoryScope = (scope: Tx, tx: Tx | undefined): void => {
43
+ const parent = tx === undefined ? undefined : scopes.get(tx);
44
+ if (parent !== undefined) scopes.set(scope, parent);
45
+ };
46
+
47
+ /** The root a memory `tx` belongs to — what a lock is held to — or `tx` itself for any other. */
48
+ export const memoryRoot = (tx: Tx): Tx => scopes.get(tx)?.root ?? tx;
49
+
50
+ /**
51
+ * The same refusal a statement on a finished `DbTx` meets (`X_DB_TRANSACTION_CLOSED`): one mistake,
52
+ * one code on both drivers. The cause names the memory scope, because that is what outlived it.
53
+ */
54
+ export const memoryTxClosed = (id: string, outcome: string): DbError =>
55
+ new DbError({
56
+ code: 'X_DB_TRANSACTION_CLOSED',
57
+ cause: `a write was sent on memory transaction ${id} after its scope ended (${outcome}): an un-awaited promise, or the loser of a Promise.all, outlived memoryTransactor().run, and it was never part of that unit of work`,
58
+ fix: 'await memoryTransactor().run(async () => { await repo.insert(row); }) # await every write inside the body; one meant to outlive the scope goes after run() resolves',
59
+ meta: { tx: id, outcome },
60
+ });
61
+
62
+ /** Refuses a write on a memory `tx` whose root has ended; any other `tx` passes. */
63
+ export const refuseIfMemoryTxClosed = (tx: Tx | undefined): void => {
64
+ const scope = tx === undefined ? undefined : scopes.get(tx);
65
+ if (tx !== undefined && scope !== undefined && scope.outcome.value !== 'open') {
66
+ throw memoryTxClosed(tx.id, scope.outcome.value);
67
+ }
68
+ };
@@ -0,0 +1,106 @@
1
+ // Single responsibility: the memory driver's half of the lifecycle triggers — a history row per row
2
+ // change, a counter moved per child change — so a unit test answers what Postgres answers. Called
3
+ // by `memoryRepo` at each place a stored row changes, with the row before and after.
4
+
5
+ import { tryUseContext } from '@ultimat3/core';
6
+ import { entityNow } from './clock';
7
+ import { columnName, moneyColumns } from './column';
8
+ import { countersOf, countersTargeting } from './counter';
9
+ import type { EntityCore } from './entity';
10
+ import { SOFT_DELETE_COLUMN } from './entity';
11
+ import type { MemoryTables } from './memory-same-tenant';
12
+ import type { Tx } from './repo';
13
+
14
+ const field = (row: unknown, property: string): unknown =>
15
+ typeof row === 'object' && row !== null ? (row as Record<string, unknown>)[property] : undefined;
16
+
17
+ const jsonValue = (value: unknown): unknown =>
18
+ value instanceof Date ? value.toISOString() : value === undefined ? null : value;
19
+
20
+ /** The stored row as `to_jsonb` spells it: physical names, money as its columns. */
21
+ export function imageOf(entity: EntityCore, row: unknown): Record<string, unknown> {
22
+ const image = new Map<string, unknown>();
23
+ for (const [property, column] of Object.entries(entity.$columns)) {
24
+ const value = field(row, property);
25
+ if (column.$meta.kind !== 'money') {
26
+ image.set(columnName(property, column.$meta), jsonValue(value));
27
+ continue;
28
+ }
29
+ const parts = moneyColumns(property, column.$meta);
30
+ image.set(parts.minor, jsonValue(field(value, 'minor')));
31
+ image.set(parts.currency, jsonValue(field(value, 'currency')));
32
+ if (parts.scale !== null) image.set(parts.scale, jsonValue(field(value, 'scale')));
33
+ }
34
+ return Object.fromEntries(image);
35
+ }
36
+
37
+ const live = (entity: EntityCore, row: unknown): boolean =>
38
+ !entity.$softDelete || field(row, SOFT_DELETE_COLUMN) == null;
39
+
40
+ /** What one change does to the tables beside it. `undefined` before is an insert, after a delete. */
41
+ export function memoryLifecycle(
42
+ entity: EntityCore,
43
+ tables: MemoryTables,
44
+ ): (before: unknown, after: unknown, tx: Tx | undefined) => void {
45
+ return (before, after, tx) => {
46
+ const undo = (fn: () => void): void => tx?.onRollback(fn);
47
+ for (const counter of countersTargeting(entity.$name)) {
48
+ const parent = tables.get(counter.parent.$name);
49
+ const key = counter.parent.$primaryKey[0] ?? '';
50
+ const move = (row: unknown, delta: number): void => {
51
+ const id = field(row, counter.references);
52
+ if (row === undefined || id == null || !live(entity, row)) return;
53
+ const stored = parent?.find(key, id);
54
+ if (stored === undefined) return;
55
+ const count = Number(field(stored, counter.column) ?? 0) + delta;
56
+ parent?.raw?.replace(stored, { ...(stored as object), [counter.column]: count }, undo);
57
+ };
58
+ move(before, -1);
59
+ move(after, 1);
60
+ }
61
+ const history = entity.$history;
62
+ const target = history === null ? undefined : tables.get(history.$name);
63
+ if (history === null || target?.raw === undefined) return;
64
+ const prior = before === undefined ? null : imageOf(entity, before);
65
+ const next = after === undefined ? null : imageOf(entity, after);
66
+ const counted = new Set(countingColumns(entity));
67
+ if (prior !== null && next !== null && sameIgnoring(prior, next, counted)) return;
68
+ const source = after ?? before;
69
+ const ctx = tx === undefined ? undefined : tryUseContext();
70
+ const tenant = entity.$tenantColumn;
71
+ target.raw.store(
72
+ {
73
+ id: crypto.randomUUID(),
74
+ ...(tenant === null ? {} : { [tenant]: field(source, tenant) }),
75
+ rowId: String(field(source, entity.$primaryKey[0] ?? '')),
76
+ op: before === undefined ? 'insert' : after === undefined ? 'delete' : 'update',
77
+ at: entityNow(),
78
+ actorId: ctx?.actor.id ?? null,
79
+ requestId: ctx?.requestId ?? null,
80
+ before: prior,
81
+ after: next,
82
+ },
83
+ undo,
84
+ );
85
+ };
86
+ }
87
+
88
+ /** The parent's counting columns: their change alone records no history, as in the trigger. */
89
+ const countingColumns = (entity: EntityCore): readonly string[] =>
90
+ countersOf(entity).map((counter) => {
91
+ const column = entity.$columns[counter.column];
92
+ return column === undefined ? counter.column : columnName(counter.column, column.$meta);
93
+ });
94
+
95
+ function sameIgnoring(
96
+ left: Record<string, unknown>,
97
+ right: Record<string, unknown>,
98
+ ignored: ReadonlySet<string>,
99
+ ): boolean {
100
+ const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
101
+ for (const key of keys) {
102
+ if (ignored.has(key)) continue;
103
+ if (JSON.stringify(left[key]) !== JSON.stringify(right[key])) return false;
104
+ }
105
+ return true;
106
+ }
@@ -0,0 +1,144 @@
1
+ // Single responsibility: the memory driver's lock. A promise queue per key, reentrant within one
2
+ // async chain, and bounded: Postgres has `statement_timeout` for a stuck waiter and deadlock
3
+ // detection for a cycle, and this driver has neither, so a wait that lasts is the refusal instead.
4
+
5
+ import { asyncContext } from '@ultimat3/core';
6
+ import { driverError } from '@ultimat3/db';
7
+ import { undoingBatch } from './memory-batch';
8
+ import { memoryJournal, memoryRoot, refuseIfMemoryTxClosed } from './memory-journal';
9
+ import { memoryTransactor } from './memory-transactor';
10
+ import type { Tx } from './repo';
11
+
12
+ /** Past this a waiter gives up. Generous for a test, and a cycle reaches it every time. */
13
+ export const MEMORY_LOCK_WAIT_MS = 5_000;
14
+
15
+ export interface MemoryLocks {
16
+ /**
17
+ * `work` under `key`, in a transaction: its own, or joined as a savepoint to `outer`, or to the
18
+ * one this async chain's lock already opened. Joined, the key is held to that transaction's end,
19
+ * because a Postgres lock taken in a savepoint lives to the root.
20
+ */
21
+ run<R>(key: string, outer: Tx | undefined, work: (tx: Tx) => Promise<R> | R): Promise<R>;
22
+ /**
23
+ * A plain write's turn at `key`, the wait an `update` does for a row lock on Postgres: waits for a
24
+ * holder, runs `work`, holds nothing after it. No wait when this chain or `outer` holds the key.
25
+ */
26
+ turn<R>(key: string, outer: Tx | undefined, work: () => R): Promise<R>;
27
+ }
28
+
29
+ /** What this async chain holds, and the transaction its innermost lock runs in. */
30
+ interface Chain {
31
+ readonly names: ReadonlySet<string>;
32
+ readonly tx: Tx;
33
+ }
34
+
35
+ const heldByChain = asyncContext<Chain>('the memory locks this chain holds');
36
+
37
+ let instances = 0;
38
+
39
+ /** `57014`, what a Postgres waiter past its `statement_timeout` is: one code for one mistake. */
40
+ const timedOut = (key: string, waitMs: number): Error =>
41
+ driverError(`memory lock "${key}"`, {
42
+ code: '57014',
43
+ message: `canceling statement: waited ${waitMs} ms for a lock another caller still holds, usually two locks taken in opposite order`,
44
+ });
45
+
46
+ export const memoryLocks = (waitMs: number = MEMORY_LOCK_WAIT_MS): MemoryLocks => {
47
+ instances += 1;
48
+ const instance = instances;
49
+ /** The last holder's end, per key: a newcomer waits on it and becomes the new end. */
50
+ const tails = new Map<string, Promise<void>>();
51
+ /** What a joined scope still holds once it returned, per ROOT: same root, same key, no wait. */
52
+ const heldByTx = new WeakMap<Tx, Set<string>>();
53
+
54
+ const inTx = <R>(outer: Tx | undefined, work: (tx: Tx) => Promise<R> | R): Promise<R> =>
55
+ outer === undefined
56
+ ? memoryTransactor().run(async (tx) => work(tx))
57
+ : Promise.resolve(undoingBatch(outer, work));
58
+
59
+ const acquire = async (name: string): Promise<() => void> => {
60
+ const previous = tails.get(name);
61
+ const mine = Promise.withResolvers<void>();
62
+ const tail = (previous ?? Promise.resolve()).then(() => mine.promise);
63
+ tails.set(name, tail);
64
+ // Off the tail, never off `release`: a waiter giving up has released, while the tail it joined
65
+ // still ends with the live holder, and a newcomer has to queue behind that holder.
66
+ void tail.then(() => {
67
+ if (tails.get(name) === tail) tails.delete(name);
68
+ });
69
+ const release = (): void => mine.resolve();
70
+ if (previous === undefined) return release;
71
+ let timer: ReturnType<typeof setTimeout> | undefined;
72
+ const bound = new Promise<never>((_resolve, reject) => {
73
+ timer = setTimeout(() => reject(timedOut(name, waitMs)), waitMs);
74
+ });
75
+ try {
76
+ await Promise.race([previous, bound]);
77
+ return release;
78
+ } catch (error) {
79
+ // Its place in the queue is given up, so the waiter behind it is not held by a ghost.
80
+ release();
81
+ throw error;
82
+ } finally {
83
+ clearTimeout(timer);
84
+ }
85
+ };
86
+
87
+ const holds = (name: string, chain: Chain | undefined, outer: Tx | undefined): boolean =>
88
+ chain?.names.has(name) === true ||
89
+ (outer !== undefined && heldByTx.get(memoryRoot(outer))?.has(name)) === true;
90
+
91
+ return {
92
+ async run(key, given, work) {
93
+ const name = `${instance}:${key}`;
94
+ const chain = heldByChain.get();
95
+ // The ambient memory transaction last: a lock taken in one is a savepoint of it, held to its
96
+ // root's end. Its own transaction instead released at that one's commit, and the root's
97
+ // rollback then overwrote what the next holder had committed.
98
+ const outer = given ?? chain?.tx ?? memoryJournal();
99
+ // Before the wait, as a statement on a finished transaction is refused before it is sent.
100
+ refuseIfMemoryTxClosed(outer);
101
+ // `work` runs knowing its own tx, so a lock nested in it with no options joins this one.
102
+ const body =
103
+ <R>(step: (tx: Tx) => Promise<R> | R) =>
104
+ (tx: Tx) =>
105
+ heldByChain.run({ names: new Set([...(chain?.names ?? []), name]), tx }, () => step(tx));
106
+ if (holds(name, chain, outer)) return inTx(outer, body(work));
107
+ const release = await acquire(name);
108
+ // Held to the root's end when there is one that can say so. A hand-written `Tx` with no
109
+ // `onCommit` cannot, and the scope's own end is the only moment left.
110
+ if (outer?.onCommit !== undefined) {
111
+ // Filed under the root, so any scope of it finds the key; released by `outer`'s end, which
112
+ // for a savepoint that commits is the root's, and for one that rolls back is its own.
113
+ const root = memoryRoot(outer);
114
+ const held = heldByTx.get(root) ?? new Set<string>();
115
+ held.add(name);
116
+ heldByTx.set(root, held);
117
+ const once = (): void => {
118
+ held.delete(name);
119
+ release();
120
+ };
121
+ outer.onCommit(once);
122
+ outer.onRollback(once);
123
+ return inTx(outer, body(work));
124
+ }
125
+ try {
126
+ return await inTx(outer, body(work));
127
+ } finally {
128
+ release();
129
+ }
130
+ },
131
+
132
+ async turn(key, outer, work) {
133
+ const name = `${instance}:${key}`;
134
+ const chain = heldByChain.get();
135
+ if (holds(name, chain, outer ?? chain?.tx ?? memoryJournal())) return work();
136
+ const release = await acquire(name);
137
+ try {
138
+ return work();
139
+ } finally {
140
+ release();
141
+ }
142
+ },
143
+ };
144
+ };