@arki/journal 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +541 -2
- package/dist/api.d.ts +336 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +2 -0
- package/dist/api.js.map +1 -0
- package/dist/command.d.ts +27 -0
- package/dist/command.d.ts.map +1 -0
- package/dist/command.js +157 -0
- package/dist/command.js.map +1 -0
- package/dist/debug.d.ts +7 -0
- package/dist/debug.d.ts.map +1 -0
- package/dist/debug.js +8 -0
- package/dist/debug.js.map +1 -0
- package/dist/dot.d.ts +102 -0
- package/dist/dot.d.ts.map +1 -0
- package/dist/dot.js +159 -0
- package/dist/dot.js.map +1 -0
- package/dist/entity.d.ts +56 -0
- package/dist/entity.d.ts.map +1 -0
- package/dist/entity.js +174 -0
- package/dist/entity.js.map +1 -0
- package/dist/errors.d.ts +47 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +112 -0
- package/dist/errors.js.map +1 -0
- package/dist/event.d.ts +9 -0
- package/dist/event.d.ts.map +1 -0
- package/dist/event.js +19 -0
- package/dist/event.js.map +1 -0
- package/dist/fold.d.ts +29 -0
- package/dist/fold.d.ts.map +1 -0
- package/dist/fold.js +49 -0
- package/dist/fold.js.map +1 -0
- package/dist/handlers.d.ts +26 -0
- package/dist/handlers.d.ts.map +1 -0
- package/dist/handlers.js +130 -0
- package/dist/handlers.js.map +1 -0
- package/dist/history.d.ts +49 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +259 -0
- package/dist/history.js.map +1 -0
- package/dist/hooks.d.ts +14 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +67 -0
- package/dist/hooks.js.map +1 -0
- package/dist/id.d.ts +18 -0
- package/dist/id.d.ts.map +1 -0
- package/dist/id.js +41 -0
- package/dist/id.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/journal.d.ts +51 -0
- package/dist/journal.d.ts.map +1 -0
- package/dist/journal.js +181 -0
- package/dist/journal.js.map +1 -0
- package/dist/listen.d.ts +37 -0
- package/dist/listen.d.ts.map +1 -0
- package/dist/listen.js +90 -0
- package/dist/listen.js.map +1 -0
- package/dist/orpc.d.ts +21 -0
- package/dist/orpc.d.ts.map +1 -0
- package/dist/orpc.js +57 -0
- package/dist/orpc.js.map +1 -0
- package/dist/pg.d.ts +537 -0
- package/dist/pg.d.ts.map +1 -0
- package/dist/pg.js +80 -0
- package/dist/pg.js.map +1 -0
- package/dist/storage.d.ts +52 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +221 -0
- package/dist/storage.js.map +1 -0
- package/dist/testing.d.ts +19 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +23 -0
- package/dist/testing.js.map +1 -0
- package/dist/transaction-guard.d.ts +2 -0
- package/dist/transaction-guard.d.ts.map +1 -0
- package/dist/transaction-guard.js +39 -0
- package/dist/transaction-guard.js.map +1 -0
- package/dist/types.d.ts +414 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/worker.d.ts +109 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +459 -0
- package/dist/worker.js.map +1 -0
- package/dist/write.d.ts +39 -0
- package/dist/write.d.ts.map +1 -0
- package/dist/write.js +287 -0
- package/dist/write.js.map +1 -0
- package/docs/agent-guide.md +324 -0
- package/llms.txt +40 -0
- package/package.json +99 -4
- package/src/api.ts +465 -0
- package/src/command.ts +242 -0
- package/src/debug.ts +8 -0
- package/src/dot.ts +296 -0
- package/src/entity.ts +377 -0
- package/src/errors.ts +134 -0
- package/src/event.ts +26 -0
- package/src/fold.ts +97 -0
- package/src/handlers.ts +262 -0
- package/src/history.ts +390 -0
- package/src/hooks.ts +85 -0
- package/src/id.ts +54 -0
- package/src/index.ts +76 -0
- package/src/journal.ts +256 -0
- package/src/listen.ts +109 -0
- package/src/orpc.ts +59 -0
- package/src/pg.ts +107 -0
- package/src/storage.ts +277 -0
- package/src/test-support.ts +82 -0
- package/src/testing.ts +23 -0
- package/src/transaction-guard.ts +41 -0
- package/src/types.ts +439 -0
- package/src/worker.ts +610 -0
- package/src/write.ts +434 -0
package/src/write.ts
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The write path: the write algorithm, version ordering of inserted events, and
|
|
3
|
+
* telling an append conflict from other database errors.
|
|
4
|
+
* May import `api`, `fold`, `history`, `storage`, `transaction-guard` and leaf files.
|
|
5
|
+
* Must not import `journal`, `command` or `worker`.
|
|
6
|
+
*/
|
|
7
|
+
import type { z } from 'zod';
|
|
8
|
+
import { and, eq, sql } from 'drizzle-orm';
|
|
9
|
+
|
|
10
|
+
import type { NextEvents, WriteInput, WriteResult } from './api.js';
|
|
11
|
+
import type { History } from './history.js';
|
|
12
|
+
import type { JournalTables } from './pg.js';
|
|
13
|
+
import type {
|
|
14
|
+
AnyEventDef,
|
|
15
|
+
Effect,
|
|
16
|
+
EntityDef,
|
|
17
|
+
EntityId,
|
|
18
|
+
EntityTable,
|
|
19
|
+
Projection,
|
|
20
|
+
Recorded,
|
|
21
|
+
Row,
|
|
22
|
+
Trace,
|
|
23
|
+
TransactionSource,
|
|
24
|
+
} from './types.js';
|
|
25
|
+
import { debugWrite } from './debug.js';
|
|
26
|
+
import { JournalError } from './errors.js';
|
|
27
|
+
import { fold, hasDeclaredEventType } from './fold.js';
|
|
28
|
+
import { correlationId, eventId, jobId } from './id.js';
|
|
29
|
+
import {
|
|
30
|
+
assertExactId,
|
|
31
|
+
assertRoundTrips,
|
|
32
|
+
assertStoredId,
|
|
33
|
+
assertStoredValues,
|
|
34
|
+
isRowOf,
|
|
35
|
+
loadRow,
|
|
36
|
+
sameValue,
|
|
37
|
+
saveReplayed,
|
|
38
|
+
showValue,
|
|
39
|
+
toRecord,
|
|
40
|
+
} from './storage.js';
|
|
41
|
+
import { oneAtATime } from './transaction-guard.js';
|
|
42
|
+
|
|
43
|
+
const PG_UNIQUE_VIOLATION = '23505';
|
|
44
|
+
/**
|
|
45
|
+
* The append lock: `pg_advisory_xact_lock(namespace, key)`, taken at the start of
|
|
46
|
+
* every write and held until the transaction commits. `seq` is a bigserial, assigned
|
|
47
|
+
* at insert, so without it two transactions can commit in the other order: a reader
|
|
48
|
+
* that checkpointed at the later seq never sees the earlier one. With it, seq order is
|
|
49
|
+
* commit order, and `seq > cursor` is a safe cursor. Cost: event appends across the
|
|
50
|
+
* app commit one transaction at a time. The namespace is 'JRNL' as an int (0x4a524e4c).
|
|
51
|
+
*/
|
|
52
|
+
const APPEND_LOCK_NAMESPACE = 1_247_301_196;
|
|
53
|
+
const APPEND_LOCK_KEY = 1;
|
|
54
|
+
|
|
55
|
+
function isUniqueViolation(error: unknown): boolean {
|
|
56
|
+
let current: unknown = error;
|
|
57
|
+
for (let depth = 0; depth < 5 && typeof current === 'object' && current !== null; depth += 1) {
|
|
58
|
+
if ('code' in current && current.code === PG_UNIQUE_VIOLATION) return true;
|
|
59
|
+
if ('message' in current && typeof current.message === 'string' && /duplicate key value/i.test(current.message)) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
current = 'cause' in current ? current.cause : undefined;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* When a job is due, from the effect's schedule. A delay counts from the transaction's
|
|
69
|
+
* `now()`, so a long transaction can use part of it before commit. `runAt` is the app's
|
|
70
|
+
* clock, as the event names it; a date in the past is due at once.
|
|
71
|
+
*/
|
|
72
|
+
function dueAt(
|
|
73
|
+
effect: Pick<Effect, 'name' | 'schedule'>,
|
|
74
|
+
event: Recorded,
|
|
75
|
+
): { readonly availableAt?: Date | ReturnType<typeof sql> } {
|
|
76
|
+
const { delaySeconds } = effect.schedule;
|
|
77
|
+
if (effect.schedule.runAt !== undefined) {
|
|
78
|
+
const at = effect.schedule.runAt(event);
|
|
79
|
+
if (!(at instanceof Date) || Number.isNaN(at.getTime()))
|
|
80
|
+
throw new Error(`journal: effect "${effect.name}": runAt must return a valid Date, got ${String(at)}`);
|
|
81
|
+
return { availableAt: at };
|
|
82
|
+
}
|
|
83
|
+
if (delaySeconds !== undefined && delaySeconds > 0)
|
|
84
|
+
return { availableAt: sql`now() + make_interval(secs => ${delaySeconds})` };
|
|
85
|
+
return {};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Insert order is the version order. `RETURNING` does not promise it, so restore it. */
|
|
89
|
+
export function sortedByVersion<Row extends { readonly version: number }>(rows: readonly Row[]): Row[] {
|
|
90
|
+
return rows.toSorted((a, b) => a.version - b.version);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What a write needs from the journal. */
|
|
94
|
+
export type WriteDeps<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>> = {
|
|
95
|
+
readonly metaSchema: Meta;
|
|
96
|
+
readonly tables: JournalTables;
|
|
97
|
+
/** Inline projections only: they run inside the write. */
|
|
98
|
+
readonly projections: readonly Projection<z.output<Meta>, Tx>[];
|
|
99
|
+
/** Async projections: the write only wakes the worker when a recorded event matches one. */
|
|
100
|
+
readonly asyncProjections: readonly Pick<Projection, 'types' | 'entities'>[];
|
|
101
|
+
/** A write only queues one job per matching effect. It never runs an effect, so the effect's database type does not matter here. */
|
|
102
|
+
readonly effects: readonly Pick<Effect<z.output<Meta>>, 'name' | 'types' | 'entities' | 'schedule'>[];
|
|
103
|
+
readonly history: Pick<History<Meta>, 'decodeStoredEvent' | 'highestVersion' | 'assertLogSaysGone'>;
|
|
104
|
+
/** Notified at commit with the seq range of every nonempty append. */
|
|
105
|
+
readonly wakeChannel: string;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export function createWriter<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>>(deps: WriteDeps<Meta, Tx>) {
|
|
109
|
+
type MetaOut = z.output<Meta>;
|
|
110
|
+
const { metaSchema, projections, asyncProjections, effects } = deps;
|
|
111
|
+
const { events: eventsTable, jobs: jobsTable } = deps.tables;
|
|
112
|
+
const { decodeStoredEvent, highestVersion, assertLogSaysGone } = deps.history;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* `write` with `getValidatedMeta` giving the parsed and checked meta. `journal.write`
|
|
116
|
+
* parses per call; `journal.run` parses once for every write of a command.
|
|
117
|
+
* `getValidatedMeta` runs inside the one-at-a-time guard, so a bad meta on a busy
|
|
118
|
+
* transaction waits for the running operation like any other rejection.
|
|
119
|
+
*/
|
|
120
|
+
const writeWithValidatedMeta = async <Tbl extends EntityTable, E extends AnyEventDef, Key extends string>(
|
|
121
|
+
tx: Tx,
|
|
122
|
+
entity: EntityDef<string, Tbl, E, MetaOut, Key>,
|
|
123
|
+
id: EntityId<Tbl, Key>,
|
|
124
|
+
getValidatedMeta: () => MetaOut,
|
|
125
|
+
events: NextEvents<Tbl, E, Tx>,
|
|
126
|
+
trace: Trace,
|
|
127
|
+
onJobs?: (count: number) => void,
|
|
128
|
+
opts?: { readonly mode?: 'write' | 'adopt'; readonly expectedVersion?: number },
|
|
129
|
+
): Promise<WriteResult<Tbl, E, MetaOut, Key>> => {
|
|
130
|
+
const adopting = opts?.mode === 'adopt';
|
|
131
|
+
const expectedVersion = opts?.expectedVersion;
|
|
132
|
+
if (expectedVersion !== undefined && (!Number.isInteger(expectedVersion) || expectedVersion < 0))
|
|
133
|
+
throw new Error(`journal: expectedVersion must be a whole number, zero or more, got ${String(expectedVersion)}`);
|
|
134
|
+
return oneAtATime(tx, `${adopting ? 'adopt' : 'write to'} ${entity.kind} ${id}`, async () => {
|
|
135
|
+
assertExactId(entity, id);
|
|
136
|
+
const meta = getValidatedMeta();
|
|
137
|
+
return tx.transaction(async sp => {
|
|
138
|
+
// Before the row lock, so every journal transaction takes its locks in the same
|
|
139
|
+
// order (append lock, then rows) and two commands writing two entities cannot deadlock.
|
|
140
|
+
// Re-entrant inside one transaction, so nested commands take it once.
|
|
141
|
+
await sp.execute(sql`select pg_advisory_xact_lock(${APPEND_LOCK_NAMESPACE}::int, ${APPEND_LOCK_KEY}::int)`);
|
|
142
|
+
const erased: EntityTable = entity.table;
|
|
143
|
+
const loaded = await loadRow(sp, entity, id, true);
|
|
144
|
+
if (loaded !== null && !isRowOf(entity.table, loaded)) {
|
|
145
|
+
throw new Error(`journal: ${entity.kind} ${id} row is missing columns`);
|
|
146
|
+
}
|
|
147
|
+
assertStoredId(entity, loaded, id);
|
|
148
|
+
const row = loaded;
|
|
149
|
+
// Before the rules run: a stale caller gets the answer without doing any work.
|
|
150
|
+
if (expectedVersion !== undefined && (row?.version ?? 0) !== expectedVersion) {
|
|
151
|
+
throw new JournalError(
|
|
152
|
+
'CONFLICT',
|
|
153
|
+
entity.kind,
|
|
154
|
+
id,
|
|
155
|
+
`expected version ${expectedVersion} but the row is at version ${row?.version ?? 0}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const pending = await events(row, { tx: sp });
|
|
159
|
+
if (pending.length === 0) {
|
|
160
|
+
debugWrite.debug('{kind} {id}: events returned nothing', { kind: entity.kind, id });
|
|
161
|
+
return { row, events: [] };
|
|
162
|
+
}
|
|
163
|
+
// The version the next event follows. No row: the log's top (0 for a new entity,
|
|
164
|
+
// the delete's version after a delete). A row at version 0 with no history existed
|
|
165
|
+
// before the journal: its history starts at 2, so version 1 stays the mark of a
|
|
166
|
+
// real create and replay can tell the two apart. A row at version 0 whose log is
|
|
167
|
+
// not empty lags the log; the unique index would not catch a start at 2.
|
|
168
|
+
const stored = row?.version ?? 0;
|
|
169
|
+
const latestLogVersion = row === null || stored === 0 ? await highestVersion(sp, entity.kind, id) : 0;
|
|
170
|
+
if (row !== null && stored === 0 && latestLogVersion > 0) {
|
|
171
|
+
throw new JournalError(
|
|
172
|
+
'CONFLICT',
|
|
173
|
+
entity.kind,
|
|
174
|
+
id,
|
|
175
|
+
`the row is at version 0 but its log already holds version ${latestLogVersion}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
if (adopting && (row === null || stored !== 0 || latestLogVersion > 0)) {
|
|
179
|
+
throw new JournalError(
|
|
180
|
+
'CONFLICT',
|
|
181
|
+
entity.kind,
|
|
182
|
+
id,
|
|
183
|
+
`adopt needs a row at version 0 with no history, but found ${row === null ? 'no row' : `the row at version ${stored}`} and a log ending at version ${latestLogVersion}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
if (adopting && pending.length !== 1) {
|
|
187
|
+
throw new Error(`journal: adopt ${entity.kind} ${id} needs exactly one create event, got ${pending.length}`);
|
|
188
|
+
}
|
|
189
|
+
// Adopt records the create as version 1, so the history is complete from then on.
|
|
190
|
+
const baseVersion = adopting ? 0 : row === null ? latestLogVersion : stored === 0 ? 1 : stored;
|
|
191
|
+
if (row === null && latestLogVersion > 0) await assertLogSaysGone(sp, entity, id, latestLogVersion);
|
|
192
|
+
|
|
193
|
+
// Cheap checks before touching the log. The fold below catches the rest
|
|
194
|
+
// (for example a non-create event after a reducer returned null).
|
|
195
|
+
// Data is parsed here too: a hand-built `{ type, data }` skips the constructor,
|
|
196
|
+
// and a schema that changes its own output would replay to a different value.
|
|
197
|
+
const validated = pending.map((event, index) => {
|
|
198
|
+
const def = entity.events.get(event.type);
|
|
199
|
+
if (def === undefined)
|
|
200
|
+
throw new JournalError(
|
|
201
|
+
'UNKNOWN_EVENT',
|
|
202
|
+
entity.kind,
|
|
203
|
+
id,
|
|
204
|
+
`event "${event.type}" at version ${baseVersion + index + 1}`,
|
|
205
|
+
);
|
|
206
|
+
const data: unknown = def.schema.parse(event.data);
|
|
207
|
+
assertRoundTrips(def.schema, data, entity.kind, id, `event "${event.type}"`);
|
|
208
|
+
return { data, schemaVersion: def.version };
|
|
209
|
+
});
|
|
210
|
+
const firstType = pending[0]?.type ?? '';
|
|
211
|
+
const firstAt = `event "${firstType}" at version ${baseVersion + 1}`;
|
|
212
|
+
const first = entity.reducers.get(firstType);
|
|
213
|
+
if (adopting && first?.create === undefined) {
|
|
214
|
+
throw new Error(`journal: adopt ${entity.kind} ${id}: event "${firstType}" has no .create reducer`);
|
|
215
|
+
}
|
|
216
|
+
if (!adopting && first !== undefined && row !== null && first.on === undefined) {
|
|
217
|
+
throw new JournalError('ROW_EXISTS', entity.kind, id, firstAt);
|
|
218
|
+
}
|
|
219
|
+
if (first !== undefined && row === null && first.create === undefined) {
|
|
220
|
+
throw new JournalError('NO_ROW', entity.kind, id, firstAt);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const inserted = await sp
|
|
224
|
+
.insert(eventsTable)
|
|
225
|
+
.values(
|
|
226
|
+
pending.map((event, index) => ({
|
|
227
|
+
id: eventId(),
|
|
228
|
+
kind: entity.kind,
|
|
229
|
+
entityId: id,
|
|
230
|
+
version: baseVersion + index + 1,
|
|
231
|
+
type: event.type,
|
|
232
|
+
data: validated[index]?.data,
|
|
233
|
+
schemaVersion: validated[index]?.schemaVersion,
|
|
234
|
+
meta,
|
|
235
|
+
commandId: trace.commandId,
|
|
236
|
+
causeSeq: trace.causeSeq,
|
|
237
|
+
correlationId: trace.correlationId,
|
|
238
|
+
})),
|
|
239
|
+
)
|
|
240
|
+
.returning()
|
|
241
|
+
.catch((error: unknown) => {
|
|
242
|
+
if (isUniqueViolation(error))
|
|
243
|
+
throw new JournalError(
|
|
244
|
+
'CONFLICT',
|
|
245
|
+
entity.kind,
|
|
246
|
+
id,
|
|
247
|
+
`version ${baseVersion + 1} is already in the log: another writer appended it first, or the row is behind its history`,
|
|
248
|
+
{ cause: error },
|
|
249
|
+
);
|
|
250
|
+
throw error;
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// The log must hold the id as given: a cut or changed `entity_id` would split the history.
|
|
254
|
+
// This throw rolls the savepoint back with the inserted events.
|
|
255
|
+
for (const stored of inserted) {
|
|
256
|
+
if (stored.entityId !== id) {
|
|
257
|
+
throw new JournalError(
|
|
258
|
+
'ID_MISMATCH',
|
|
259
|
+
entity.kind,
|
|
260
|
+
id,
|
|
261
|
+
`the log stored the entity id ${JSON.stringify(stored.entityId)} but the id given is ${JSON.stringify(id)}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Reducers, projections and the caller see the stored events, decoded the same
|
|
267
|
+
// way replay decodes them. A hand-built pending event may carry keys the schema
|
|
268
|
+
// strips; those never reach a reducer, so write and replay fold the same data.
|
|
269
|
+
// The filter only narrows the type and drops nothing: see `hasDeclaredEventType`.
|
|
270
|
+
const recorded = sortedByVersion(inserted)
|
|
271
|
+
.map(stored => decodeStoredEvent(stored, entity.events.get(stored.type), id))
|
|
272
|
+
.filter(event => hasDeclaredEventType(entity, event));
|
|
273
|
+
if (recorded.length !== pending.length) {
|
|
274
|
+
throw new Error(`journal: insert returned ${recorded.length} of ${pending.length} events`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Adopt folds from nothing, as replay will, and must land on the stored row.
|
|
278
|
+
const { row: next, recreated, reducerColumns } = fold(entity, adopting ? null : row, id, recorded, 'write');
|
|
279
|
+
const finalVersion = baseVersion + recorded.length;
|
|
280
|
+
const atBase = and(eq(entity.keyColumn, id), eq(erased.version, stored));
|
|
281
|
+
let saved: Row<typeof entity.table> | null = null;
|
|
282
|
+
if (adopting && row !== null && next !== null) {
|
|
283
|
+
const want = toRecord(next);
|
|
284
|
+
const have = toRecord(row);
|
|
285
|
+
for (const column of reducerColumns) {
|
|
286
|
+
if (column === entity.key || column === 'version' || sameValue(want[column], have[column])) continue;
|
|
287
|
+
throw new JournalError(
|
|
288
|
+
'VALUE_MISMATCH',
|
|
289
|
+
entity.kind,
|
|
290
|
+
id,
|
|
291
|
+
`adopt: the create reducer builds column "${column}" as ${showValue(want[column])} but the row holds ${showValue(have[column])}; the event must rebuild the stored row exactly, or replay would build a different row`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
// Only the version moves. The values are the row's own, checked above.
|
|
295
|
+
const updated = await sp.update(erased).set({ version: finalVersion }).where(atBase).returning();
|
|
296
|
+
const updatedRow = updated[0];
|
|
297
|
+
if (updatedRow === undefined)
|
|
298
|
+
throw new JournalError('VERSION_MISMATCH', entity.kind, id, `expected row version ${stored}`);
|
|
299
|
+
if (!isRowOf(entity.table, updatedRow))
|
|
300
|
+
throw new Error(`journal: ${entity.kind} ${id} update returned a partial row`);
|
|
301
|
+
saved = updatedRow;
|
|
302
|
+
} else if (row === null && next !== null) {
|
|
303
|
+
saved = await saveReplayed(sp, entity, id, null, next);
|
|
304
|
+
} else if (row !== null && next !== null && recreated) {
|
|
305
|
+
// A reducer deleted the row and a create followed in the same write. Delete
|
|
306
|
+
// then insert, so columns the create omits get their defaults, not the old values.
|
|
307
|
+
const deleted = await sp.delete(erased).where(atBase).returning({ version: erased.version });
|
|
308
|
+
if (deleted.length === 0)
|
|
309
|
+
throw new JournalError('VERSION_MISMATCH', entity.kind, id, `expected row version ${stored}`);
|
|
310
|
+
saved = await saveReplayed(sp, entity, id, null, next);
|
|
311
|
+
} else if (row !== null && next !== null) {
|
|
312
|
+
const updated = await sp
|
|
313
|
+
.update(erased)
|
|
314
|
+
.set({ ...next, version: finalVersion })
|
|
315
|
+
.where(atBase)
|
|
316
|
+
.returning();
|
|
317
|
+
const updatedRow = updated[0];
|
|
318
|
+
if (updatedRow === undefined)
|
|
319
|
+
throw new JournalError('VERSION_MISMATCH', entity.kind, id, `expected row version ${stored}`);
|
|
320
|
+
if (!isRowOf(entity.table, updatedRow))
|
|
321
|
+
throw new Error(`journal: ${entity.kind} ${id} update returned a partial row`);
|
|
322
|
+
saved = updatedRow;
|
|
323
|
+
} else if (row !== null && next === null) {
|
|
324
|
+
const deleted = await sp.delete(erased).where(atBase).returning({ version: erased.version });
|
|
325
|
+
if (deleted.length === 0)
|
|
326
|
+
throw new JournalError('VERSION_MISMATCH', entity.kind, id, `expected row version ${stored}`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// A create may come back normalised by the database; the events are already
|
|
330
|
+
// inserted, and this throw rolls the savepoint back with them.
|
|
331
|
+
assertStoredId(entity, saved, id);
|
|
332
|
+
// Before projections run, so none sees a row that is about to be refused. Only columns a
|
|
333
|
+
// reducer set in this write: the others came from the database.
|
|
334
|
+
if (saved !== null && next !== null) {
|
|
335
|
+
const set = [...reducerColumns].filter(column => column !== entity.key && column !== 'version');
|
|
336
|
+
assertStoredValues(entity, id, next, saved, set);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
for (const event of recorded) {
|
|
340
|
+
for (const projection of projections) {
|
|
341
|
+
if (projection.types.has(event.type) && projection.entities.some(e => e.kind === entity.kind)) {
|
|
342
|
+
await projection.run(sp, event);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const jobs = recorded.flatMap(event =>
|
|
348
|
+
effects
|
|
349
|
+
.filter(effect => effect.types.has(event.type) && effect.entities.some(e => e.kind === entity.kind))
|
|
350
|
+
.map(effect => ({
|
|
351
|
+
id: jobId(),
|
|
352
|
+
eventSeq: event.seq,
|
|
353
|
+
effect: effect.name,
|
|
354
|
+
priority: effect.schedule.priority ?? 0,
|
|
355
|
+
...dueAt(effect, event),
|
|
356
|
+
})),
|
|
357
|
+
);
|
|
358
|
+
if (jobs.length > 0) await sp.insert(jobsTable).values(jobs);
|
|
359
|
+
// Postgres delivers NOTIFY at commit, so every listening worker in every process hears
|
|
360
|
+
// exactly the commits that happened, with the seq range to read. Never the events themselves.
|
|
361
|
+
const seqs = recorded.map(event => event.seq);
|
|
362
|
+
await sp.execute(
|
|
363
|
+
sql`select pg_notify(${deps.wakeChannel}, ${JSON.stringify({ from: Math.min(...seqs), to: Math.max(...seqs) })})`,
|
|
364
|
+
);
|
|
365
|
+
// Work for the worker: jobs, plus an async projection that wants one of these events.
|
|
366
|
+
const forAsync = recorded.some(event =>
|
|
367
|
+
asyncProjections.some(p => p.types.has(event.type) && p.entities.some(e => e.kind === entity.kind)),
|
|
368
|
+
);
|
|
369
|
+
if (jobs.length > 0 || forAsync) onJobs?.(jobs.length + (forAsync ? 1 : 0));
|
|
370
|
+
|
|
371
|
+
debugWrite.debug('{kind} {id}: v{baseVersion} -> v{finalVersion}, {recordedLength} events, {jobsLength} jobs', {
|
|
372
|
+
kind: entity.kind,
|
|
373
|
+
id,
|
|
374
|
+
baseVersion,
|
|
375
|
+
finalVersion,
|
|
376
|
+
recordedLength: recorded.length,
|
|
377
|
+
jobsLength: jobs.length,
|
|
378
|
+
});
|
|
379
|
+
return { row: saved, events: recorded };
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
/** `journal.write`: parses and checks `meta` for this one call. */
|
|
385
|
+
const write = <Tbl extends EntityTable, E extends AnyEventDef, Key extends string>(
|
|
386
|
+
tx: Tx,
|
|
387
|
+
entity: EntityDef<string, Tbl, E, MetaOut, Key>,
|
|
388
|
+
id: EntityId<Tbl, Key>,
|
|
389
|
+
input: WriteInput<Tbl, E, Meta, Tx>,
|
|
390
|
+
): Promise<WriteResult<Tbl, E, MetaOut, Key>> =>
|
|
391
|
+
writeWithValidatedMeta(
|
|
392
|
+
tx,
|
|
393
|
+
entity,
|
|
394
|
+
id,
|
|
395
|
+
() => {
|
|
396
|
+
const meta = metaSchema.parse(input.meta);
|
|
397
|
+
assertRoundTrips(metaSchema, meta, entity.kind, id, 'meta');
|
|
398
|
+
return meta;
|
|
399
|
+
},
|
|
400
|
+
input.events,
|
|
401
|
+
// No command and no cause. A fresh chain, so every recorded event has a correlation id.
|
|
402
|
+
{ commandId: null, causeSeq: null, correlationId: correlationId() },
|
|
403
|
+
undefined,
|
|
404
|
+
{ expectedVersion: input.expectedVersion },
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
/** `journal.adopt`: `write` in adopt mode, with `meta` parsed for this one call. */
|
|
408
|
+
const adopt = <Tbl extends EntityTable, E extends AnyEventDef, Key extends string>(
|
|
409
|
+
tx: Tx,
|
|
410
|
+
entity: EntityDef<string, Tbl, E, MetaOut, Key>,
|
|
411
|
+
id: EntityId<Tbl, Key>,
|
|
412
|
+
input: WriteInput<Tbl, E, Meta, Tx>,
|
|
413
|
+
): Promise<WriteResult<Tbl, E, MetaOut, Key>> =>
|
|
414
|
+
writeWithValidatedMeta(
|
|
415
|
+
tx,
|
|
416
|
+
entity,
|
|
417
|
+
id,
|
|
418
|
+
() => {
|
|
419
|
+
const meta = metaSchema.parse(input.meta);
|
|
420
|
+
assertRoundTrips(metaSchema, meta, entity.kind, id, 'meta');
|
|
421
|
+
return meta;
|
|
422
|
+
},
|
|
423
|
+
input.events,
|
|
424
|
+
{ commandId: null, causeSeq: null, correlationId: correlationId() },
|
|
425
|
+
undefined,
|
|
426
|
+
{ mode: 'adopt' },
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
return { write, adopt, writeWithValidatedMeta };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export type Writer<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>> = ReturnType<
|
|
433
|
+
typeof createWriter<Meta, Tx>
|
|
434
|
+
>;
|