@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.
- package/CLAUDE.md +66 -96
- package/README.md +118 -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 +144 -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 +90 -76
- package/src/errors.ts +8 -0
- package/src/history-actor.ts +22 -0
- package/src/history.ts +86 -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 +44 -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 +20 -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,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
|
+
}
|
package/src/memory-repo.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
255
|
+
insert: writing(async (values, options) => {
|
|
234
256
|
return write(values, options, 'insert');
|
|
235
|
-
},
|
|
257
|
+
}),
|
|
236
258
|
|
|
237
|
-
async
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
287
|
-
|
|
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
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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({},
|
|
372
|
+
Object.assign({}, current, { [SOFT_DELETE_COLUMN]: entityNow() }),
|
|
346
373
|
options,
|
|
347
|
-
'
|
|
348
|
-
storeKey(
|
|
374
|
+
'delete',
|
|
375
|
+
storeKey(current),
|
|
349
376
|
);
|
|
350
|
-
|
|
377
|
+
return;
|
|
351
378
|
}
|
|
352
|
-
const key = storeKey(
|
|
353
|
-
options?.tx?.onRollback(() => rows.set(key,
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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';
|