@ultimat3/entity 29.3.2 → 30.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +66 -96
- package/README.md +125 -8
- package/package.json +15 -5
- package/src/append-only.ts +7 -0
- package/src/batch-read.ts +12 -4
- package/src/bulk-write-send.ts +49 -0
- package/src/bulk-write.ts +48 -11
- package/src/column.ts +29 -4
- package/src/counter.ts +109 -0
- package/src/cross-tenant.ts +13 -0
- package/src/cursor.ts +17 -1
- package/src/database.ts +16 -10
- package/src/describe-tenancy.ts +25 -0
- package/src/describe.ts +146 -3
- package/src/entity-error.ts +33 -0
- package/src/entity-parse.ts +89 -0
- package/src/entity-patch.ts +42 -0
- package/src/entity.ts +95 -98
- package/src/errors.ts +8 -0
- package/src/history-actor.ts +22 -0
- package/src/history.ts +86 -0
- package/src/index-identity.ts +46 -0
- package/src/index-init.ts +12 -0
- package/src/index.ts +20 -1
- package/src/jit-preload.ts +35 -25
- package/src/lifecycle.ts +100 -0
- package/src/lock-key.ts +27 -0
- package/src/lock-unsupported.ts +11 -0
- package/src/memory-aggregates.ts +99 -0
- package/src/memory-batch.ts +69 -0
- package/src/memory-journal.ts +68 -0
- package/src/memory-lifecycle.ts +106 -0
- package/src/memory-lock.ts +144 -0
- package/src/memory-observed.ts +121 -0
- package/src/memory-repo.ts +199 -186
- package/src/memory-same-tenant.ts +83 -0
- package/src/memory-seek.ts +44 -0
- package/src/memory-transactor.ts +94 -0
- package/src/memory-unique.ts +19 -4
- package/src/n-plus-one.ts +28 -10
- package/src/numeric-compare.ts +43 -0
- package/src/pg-aggregates.ts +185 -0
- package/src/pg-driver.ts +79 -166
- package/src/pg-lock.ts +50 -0
- package/src/pg-row.ts +29 -2
- package/src/pg-sql-aggregate.ts +31 -0
- package/src/pg-sql.ts +17 -1
- package/src/query.ts +26 -105
- package/src/read-aggregates.ts +138 -0
- package/src/read-capture.ts +22 -0
- package/src/registry.ts +49 -1
- package/src/repo.ts +34 -0
- package/src/retain.ts +71 -0
- package/src/row-observer.ts +22 -4
- package/src/row-security-settings.ts +20 -0
- package/src/sealed-repo.ts +15 -0
- package/src/seed.ts +43 -7
- package/src/series-unsupported.ts +11 -0
- package/src/table-locks.ts +41 -0
- package/src/test-driver.ts +90 -0
- package/src/time-bucket.ts +147 -0
- package/src/touch.ts +33 -0
- package/src/types.ts +22 -0
- package/src/view-entity.ts +78 -0
- package/src/view-issue.ts +47 -0
- package/src/view-repo.ts +38 -0
- package/src/view.ts +41 -27
|
@@ -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
|
+
};
|
|
@@ -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
|
+
}
|