@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/handlers.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AnyEntity,
|
|
3
|
+
BivariantFn,
|
|
4
|
+
DbHandle,
|
|
5
|
+
Effect,
|
|
6
|
+
EffectContext,
|
|
7
|
+
EffectHandlers,
|
|
8
|
+
EffectSchedule,
|
|
9
|
+
Need,
|
|
10
|
+
Needed,
|
|
11
|
+
Needs,
|
|
12
|
+
Projection,
|
|
13
|
+
ProjectionHandlers,
|
|
14
|
+
ProjectionOptions,
|
|
15
|
+
Recorded,
|
|
16
|
+
Services,
|
|
17
|
+
Tap,
|
|
18
|
+
TapContext,
|
|
19
|
+
TapHandlers,
|
|
20
|
+
} from './types.js';
|
|
21
|
+
import { JournalError } from './errors.js';
|
|
22
|
+
import { assertStoredName } from './id.js';
|
|
23
|
+
|
|
24
|
+
/** An entity has a `kind`; an array of entities does not. */
|
|
25
|
+
function toList<E extends { readonly kind: string }>(entity: E | readonly E[]): readonly E[] {
|
|
26
|
+
return 'kind' in entity ? [entity] : entity;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Only an own property counts as a handler. A plain object inherits `toString`,
|
|
31
|
+
* `constructor` and friends from `Object.prototype`; an event type with one of
|
|
32
|
+
* those names must not find a handler nobody declared.
|
|
33
|
+
*/
|
|
34
|
+
function ownHandler<H>(handlers: Partial<Record<string, H>>, type: string): H | undefined {
|
|
35
|
+
return Object.hasOwn(handlers, type) ? handlers[type] : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function declaredTypes(handlers: Partial<Record<string, unknown>>): ReadonlySet<string> {
|
|
39
|
+
return new Set(Object.keys(handlers).filter(key => ownHandler(handlers, key) !== undefined));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function defineProjection<E extends AnyEntity<Meta>, Meta, Tx extends DbHandle>(
|
|
43
|
+
name: string,
|
|
44
|
+
entity: E | readonly E[],
|
|
45
|
+
on: NoInfer<ProjectionHandlers<E, Meta, Tx>>,
|
|
46
|
+
opts?: ProjectionOptions,
|
|
47
|
+
): Projection<Meta, Tx> {
|
|
48
|
+
const handlers: Partial<Record<string, BivariantFn<[tx: Tx, event: Recorded<string, unknown, Meta>], unknown>>> = on;
|
|
49
|
+
if (opts?.lane === 'async') assertStoredName('effect name', name);
|
|
50
|
+
return {
|
|
51
|
+
name,
|
|
52
|
+
entities: toList(entity),
|
|
53
|
+
types: declaredTypes(handlers),
|
|
54
|
+
lane: opts?.lane ?? 'inline',
|
|
55
|
+
async run(tx, event) {
|
|
56
|
+
const handler = ownHandler(handlers, event.type);
|
|
57
|
+
if (handler !== undefined) await handler(tx, event);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Every effect handler gets these. A need must not take one of their names. */
|
|
63
|
+
const builtInContext = new Set(['jobId', 'signal', 'db', 'run']);
|
|
64
|
+
|
|
65
|
+
/** Declare one thing an effect needs: `j.effect('order-mail', order, { mailer: need<Mailer>() }, { ... })`. */
|
|
66
|
+
export const need = <T>(): Need<T> => ({});
|
|
67
|
+
|
|
68
|
+
function defineEffect<E extends AnyEntity<Meta>, Meta, AppDb, Run, N extends Needs>(
|
|
69
|
+
name: string,
|
|
70
|
+
entity: E | readonly E[],
|
|
71
|
+
needs: N,
|
|
72
|
+
on: EffectHandlers<E, Meta, AppDb, Run, N>,
|
|
73
|
+
): Effect<Meta, AppDb, Run, N> {
|
|
74
|
+
assertStoredName('effect name', name);
|
|
75
|
+
for (const key of Object.keys(needs)) {
|
|
76
|
+
if (builtInContext.has(key))
|
|
77
|
+
throw new JournalError(
|
|
78
|
+
'BAD_DEFINITION',
|
|
79
|
+
'effect',
|
|
80
|
+
name,
|
|
81
|
+
`journal: effect "${name}" has a need named "${key}", but the handler context already has "jobId", "signal", "db" and "run"; give the need another name`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const handlers: Partial<
|
|
85
|
+
Record<
|
|
86
|
+
string,
|
|
87
|
+
BivariantFn<[event: Recorded<string, unknown, Meta>, ctx: EffectContext<AppDb, Run> & Needed<N>], unknown>
|
|
88
|
+
>
|
|
89
|
+
> = on;
|
|
90
|
+
return withSchedule(
|
|
91
|
+
{
|
|
92
|
+
name,
|
|
93
|
+
entities: toList(entity),
|
|
94
|
+
types: declaredTypes(handlers),
|
|
95
|
+
needs,
|
|
96
|
+
async run(event, ctx) {
|
|
97
|
+
const handler = ownHandler(handlers, event.type);
|
|
98
|
+
// A job was queued for this type. Running nothing would acknowledge it silently.
|
|
99
|
+
if (handler === undefined)
|
|
100
|
+
throw new Error(
|
|
101
|
+
`effect "${name}" has no handler for "${event.type}", but a job was queued for it; add a handler for this event to the effect, or delete the job if the event is no longer handled`,
|
|
102
|
+
);
|
|
103
|
+
await handler(event, ctx);
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{},
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const INT4_MAX = 2_147_483_647;
|
|
111
|
+
|
|
112
|
+
/** Attach a schedule to an effect, checked once: a job row must be able to hold it. */
|
|
113
|
+
export function withSchedule<Meta, AppDb, Run, N extends Needs>(
|
|
114
|
+
effect: Omit<Effect<Meta, AppDb, Run, N>, 'schedule' | 'withSchedule'>,
|
|
115
|
+
schedule: EffectSchedule<Meta>,
|
|
116
|
+
): Effect<Meta, AppDb, Run, N> {
|
|
117
|
+
const { priority = 0, delaySeconds = 0 } = schedule;
|
|
118
|
+
if (!Number.isInteger(priority) || Math.abs(priority) > INT4_MAX)
|
|
119
|
+
throw new JournalError(
|
|
120
|
+
'BAD_DEFINITION',
|
|
121
|
+
'effect',
|
|
122
|
+
effect.name,
|
|
123
|
+
`journal: effect "${effect.name}": priority must be an integer within 32 bits, got ${String(priority)}`,
|
|
124
|
+
);
|
|
125
|
+
if (!Number.isFinite(delaySeconds) || delaySeconds < 0)
|
|
126
|
+
throw new JournalError(
|
|
127
|
+
'BAD_DEFINITION',
|
|
128
|
+
'effect',
|
|
129
|
+
effect.name,
|
|
130
|
+
`journal: effect "${effect.name}": delaySeconds must be a finite number of seconds, zero or more, got ${String(delaySeconds)}`,
|
|
131
|
+
);
|
|
132
|
+
if (schedule.delaySeconds !== undefined && schedule.runAt !== undefined)
|
|
133
|
+
throw new JournalError(
|
|
134
|
+
'BAD_DEFINITION',
|
|
135
|
+
'effect',
|
|
136
|
+
effect.name,
|
|
137
|
+
`journal: effect "${effect.name}": give delaySeconds or runAt, not both`,
|
|
138
|
+
);
|
|
139
|
+
return {
|
|
140
|
+
...effect,
|
|
141
|
+
schedule,
|
|
142
|
+
withSchedule: next => withSchedule(effect, next),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `j.effect` for one journal. Three arguments: the effect needs nothing. Four: the third lists its needs. */
|
|
147
|
+
export function effectFor<Meta, AppDb, Run>() {
|
|
148
|
+
function effect<E extends AnyEntity<Meta>>(
|
|
149
|
+
name: string,
|
|
150
|
+
entity: E | readonly E[],
|
|
151
|
+
on: NoInfer<EffectHandlers<E, Meta, AppDb, Run>>,
|
|
152
|
+
): Effect<Meta, AppDb, Run>;
|
|
153
|
+
function effect<E extends AnyEntity<Meta>, N extends Needs>(
|
|
154
|
+
name: string,
|
|
155
|
+
entity: E | readonly E[],
|
|
156
|
+
needs: N,
|
|
157
|
+
on: NoInfer<EffectHandlers<E, Meta, AppDb, Run, N>>,
|
|
158
|
+
): Effect<Meta, AppDb, Run, N>;
|
|
159
|
+
function effect<E extends AnyEntity<Meta>, N extends Needs>(
|
|
160
|
+
name: string,
|
|
161
|
+
entity: E | readonly E[],
|
|
162
|
+
...rest: [on: EffectHandlers<E, Meta, AppDb, Run>] | [needs: N, on: EffectHandlers<E, Meta, AppDb, Run, N>]
|
|
163
|
+
) {
|
|
164
|
+
return rest.length === 1 ? defineEffect(name, entity, {}, rest[0]) : defineEffect(name, entity, rest[0], rest[1]);
|
|
165
|
+
}
|
|
166
|
+
return effect;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function defineTap<E extends AnyEntity<Meta>, Meta, AppDb, N extends Needs>(
|
|
170
|
+
name: string,
|
|
171
|
+
entity: E | readonly E[],
|
|
172
|
+
needs: N,
|
|
173
|
+
on: TapHandlers<E, Meta, AppDb, N>,
|
|
174
|
+
): Tap<Meta, AppDb, N> {
|
|
175
|
+
assertStoredName('effect name', name);
|
|
176
|
+
if (Object.hasOwn(needs, 'db'))
|
|
177
|
+
throw new JournalError(
|
|
178
|
+
'BAD_DEFINITION',
|
|
179
|
+
'tap',
|
|
180
|
+
name,
|
|
181
|
+
`journal: tap "${name}" has a need named "db", but the handler context already has "db"; give the need another name`,
|
|
182
|
+
);
|
|
183
|
+
const handlers: Partial<
|
|
184
|
+
Record<string, BivariantFn<[event: Recorded<string, unknown, Meta>, ctx: TapContext<AppDb> & Needed<N>], unknown>>
|
|
185
|
+
> = on;
|
|
186
|
+
return {
|
|
187
|
+
name,
|
|
188
|
+
entities: toList(entity),
|
|
189
|
+
types: declaredTypes(handlers),
|
|
190
|
+
needs,
|
|
191
|
+
async run(event, ctx) {
|
|
192
|
+
await ownHandler(handlers, event.type)?.(event, ctx);
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** `j.tap` for one journal. Three arguments: the tap needs nothing. Four: the third lists its needs. */
|
|
198
|
+
export function tapFor<Meta, AppDb>() {
|
|
199
|
+
function tap<E extends AnyEntity<Meta>>(
|
|
200
|
+
name: string,
|
|
201
|
+
entity: E | readonly E[],
|
|
202
|
+
on: NoInfer<TapHandlers<E, Meta, AppDb>>,
|
|
203
|
+
): Tap<Meta, AppDb>;
|
|
204
|
+
function tap<E extends AnyEntity<Meta>, N extends Needs>(
|
|
205
|
+
name: string,
|
|
206
|
+
entity: E | readonly E[],
|
|
207
|
+
needs: N,
|
|
208
|
+
on: NoInfer<TapHandlers<E, Meta, AppDb, N>>,
|
|
209
|
+
): Tap<Meta, AppDb, N>;
|
|
210
|
+
function tap<E extends AnyEntity<Meta>, N extends Needs>(
|
|
211
|
+
name: string,
|
|
212
|
+
entity: E | readonly E[],
|
|
213
|
+
...rest: [on: TapHandlers<E, Meta, AppDb>] | [needs: N, on: TapHandlers<E, Meta, AppDb, N>]
|
|
214
|
+
) {
|
|
215
|
+
return rest.length === 1 ? defineTap(name, entity, {}, rest[0]) : defineTap(name, entity, rest[0], rest[1]);
|
|
216
|
+
}
|
|
217
|
+
return tap;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Give one tap its services, as `bindEffect` does for an effect. */
|
|
221
|
+
export function bindTap<Meta, AppDb>(tap: Tap<Meta, AppDb, Needs>, services: Services): Tap<Meta, AppDb> {
|
|
222
|
+
const given: Record<string, unknown> = {};
|
|
223
|
+
for (const key of Object.keys(tap.needs)) {
|
|
224
|
+
if (!Object.hasOwn(services, key) || services[key] === undefined)
|
|
225
|
+
throw new JournalError(
|
|
226
|
+
'BAD_DEFINITION',
|
|
227
|
+
'tap',
|
|
228
|
+
tap.name,
|
|
229
|
+
`journal: tap "${tap.name}" needs "${key}", but no service with that name was given; pass it as j.compose({ services: { ${key} } }), or in a Dot app add it to the \`needs\` of the feature that lists this tap`,
|
|
230
|
+
);
|
|
231
|
+
given[key] = services[key];
|
|
232
|
+
}
|
|
233
|
+
return { ...tap, needs: {}, run: (event, ctx) => tap.run(event, { ...given, ...ctx }) };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Give one effect its services. The result needs nothing more, so the worker can run it.
|
|
238
|
+
* `services` may hold more than this effect needs: each need is read by its name.
|
|
239
|
+
* This is the only place a need meets its value. `j.compose` calls it with the app's
|
|
240
|
+
* `services`; the Dot bridge calls it with the services of the feature that lists the effect.
|
|
241
|
+
*/
|
|
242
|
+
export function bindEffect<Meta, AppDb, Run>(
|
|
243
|
+
effect: Effect<Meta, AppDb, Run, Needs>,
|
|
244
|
+
services: Services,
|
|
245
|
+
): Effect<Meta, AppDb, Run> {
|
|
246
|
+
const given: Record<string, unknown> = {};
|
|
247
|
+
for (const key of Object.keys(effect.needs)) {
|
|
248
|
+
// Without this check the effect would start, and every job would fail on `undefined`.
|
|
249
|
+
if (!Object.hasOwn(services, key) || services[key] === undefined)
|
|
250
|
+
throw new JournalError(
|
|
251
|
+
'BAD_DEFINITION',
|
|
252
|
+
'effect',
|
|
253
|
+
effect.name,
|
|
254
|
+
`journal: effect "${effect.name}" needs "${key}", but no service with that name was given; pass it as j.compose({ services: { ${key} } }), or in a Dot app add it to the \`needs\` of the feature that lists this effect`,
|
|
255
|
+
);
|
|
256
|
+
given[key] = services[key];
|
|
257
|
+
}
|
|
258
|
+
return withSchedule(
|
|
259
|
+
{ ...effect, needs: {}, run: (event, ctx) => effect.run(event, { ...given, ...ctx }) },
|
|
260
|
+
effect.schedule,
|
|
261
|
+
);
|
|
262
|
+
}
|
package/src/history.ts
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the log: decoding stored events, the log queries, history-completeness
|
|
3
|
+
* checks, and `replay`, `verify`, `repair`, `rerun`.
|
|
4
|
+
* May import `fold`, `storage`, leaf files, and result types from `api`.
|
|
5
|
+
* Must not import `journal`, `write`, `command` or `worker`.
|
|
6
|
+
*/
|
|
7
|
+
import type { z } from 'zod';
|
|
8
|
+
import { and, asc, eq, gt, inArray, max, sql } from 'drizzle-orm';
|
|
9
|
+
|
|
10
|
+
import type { ForgetResult, ReplayResult, VerifyResult } from './api.js';
|
|
11
|
+
import type { JournalTables } from './pg.js';
|
|
12
|
+
import type {
|
|
13
|
+
AnyEventDef,
|
|
14
|
+
DbHandle,
|
|
15
|
+
EntityDef,
|
|
16
|
+
EntityId,
|
|
17
|
+
EntityTable,
|
|
18
|
+
Projection,
|
|
19
|
+
Recorded,
|
|
20
|
+
Row,
|
|
21
|
+
TransactionSource,
|
|
22
|
+
} from './types.js';
|
|
23
|
+
import { debugReplay } from './debug.js';
|
|
24
|
+
import { JournalError } from './errors.js';
|
|
25
|
+
import { findEventDefinition, fold, hasDeclaredEventType } from './fold.js';
|
|
26
|
+
import {
|
|
27
|
+
assertExactId,
|
|
28
|
+
assertStoredId,
|
|
29
|
+
assertStoredValues,
|
|
30
|
+
deepFreeze,
|
|
31
|
+
isRowOf,
|
|
32
|
+
loadRow,
|
|
33
|
+
sameValue,
|
|
34
|
+
saveReplayed,
|
|
35
|
+
toRecord,
|
|
36
|
+
} from './storage.js';
|
|
37
|
+
|
|
38
|
+
/** What the log functions need from the journal: the meta schema and the journal tables. */
|
|
39
|
+
export type HistoryDeps<Meta extends z.ZodObject> = {
|
|
40
|
+
readonly metaSchema: Meta;
|
|
41
|
+
readonly tables: JournalTables;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Every version from 1 to the top must be stored. A hole means an event was lost
|
|
46
|
+
* after it was written. Folding around it would build a row the real history never
|
|
47
|
+
* produced, and `repair` would save it. The rows come ordered by version, and the
|
|
48
|
+
* unique index allows one row per version, so position and version must agree.
|
|
49
|
+
*/
|
|
50
|
+
function assertNoHoles(kind: string, id: string, rows: readonly { readonly version: number }[]): void {
|
|
51
|
+
for (const [index, row] of rows.entries()) {
|
|
52
|
+
if (row.version !== index + 1) {
|
|
53
|
+
throw new JournalError(
|
|
54
|
+
'HISTORY_GAP',
|
|
55
|
+
kind,
|
|
56
|
+
id,
|
|
57
|
+
`version ${index + 1} is missing from the log; the next stored version is ${row.version}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A stored row newer than a log that says the entity is alive lost its last events.
|
|
65
|
+
* Repair would quietly roll the row back, so replay refuses instead. When the log
|
|
66
|
+
* says the entity is gone, a row at any version was placed outside the journal and
|
|
67
|
+
* repair removes it: see `assertLogSaysGone`.
|
|
68
|
+
*/
|
|
69
|
+
function assertRowNotAhead(kind: string, id: string, folded: object | null, top: number, current: object | null): void {
|
|
70
|
+
if (folded === null || current === null) return;
|
|
71
|
+
if ('version' in current && typeof current.version === 'number' && current.version > top) {
|
|
72
|
+
throw new JournalError(
|
|
73
|
+
'HISTORY_GAP',
|
|
74
|
+
kind,
|
|
75
|
+
id,
|
|
76
|
+
`the row is at version ${current.version} but the log ends at version ${top}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createHistory<Meta extends z.ZodObject>(deps: HistoryDeps<Meta>) {
|
|
82
|
+
type MetaOut = z.output<Meta>;
|
|
83
|
+
const { metaSchema } = deps;
|
|
84
|
+
const eventsTable = deps.tables.events;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Turn a stored log row into a recorded event, parsing data and meta with
|
|
88
|
+
* their current schemas. `id` is the row's entity id as the caller types it.
|
|
89
|
+
*/
|
|
90
|
+
/**
|
|
91
|
+
* Lift a row's data from the schema version it was written at to the event's current
|
|
92
|
+
* one, one step at a time. Each step parses with its own `from` schema first, so a bad
|
|
93
|
+
* row names the version it failed at. Data already at the current version is returned as is.
|
|
94
|
+
*/
|
|
95
|
+
const upcast = (row: typeof eventsTable.$inferSelect, def: AnyEventDef, at: string): unknown => {
|
|
96
|
+
const fail = (why: string): never => {
|
|
97
|
+
throw new JournalError('REPLAY_FAILED', row.kind, row.entityId, `${at}: ${why}`);
|
|
98
|
+
};
|
|
99
|
+
if (row.schemaVersion > def.version) {
|
|
100
|
+
return fail(`written at schema version ${row.schemaVersion}, but the event declares version ${def.version}`);
|
|
101
|
+
}
|
|
102
|
+
let value: unknown = row.data;
|
|
103
|
+
for (let version = row.schemaVersion; version < def.version; version += 1) {
|
|
104
|
+
const step = def.upcasters[version - 1];
|
|
105
|
+
if (step === undefined) return fail(`no upcaster for schema version ${version}`);
|
|
106
|
+
const old = step.from.safeParse(value);
|
|
107
|
+
if (!old.success) return fail(`the data does not fit schema version ${version}: ${old.error.message}`);
|
|
108
|
+
try {
|
|
109
|
+
value = step.up(old.data);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return fail(
|
|
112
|
+
`the upcaster from version ${version} threw: ${error instanceof Error ? error.message : String(error)}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return value;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const decodeStoredEvent = <Id extends string>(
|
|
120
|
+
row: typeof eventsTable.$inferSelect,
|
|
121
|
+
def: AnyEventDef | undefined,
|
|
122
|
+
id: Id,
|
|
123
|
+
): Recorded<string, unknown, MetaOut, Id> => {
|
|
124
|
+
const at = `event ${row.id} ("${row.type}" at version ${row.version})`;
|
|
125
|
+
if (def === undefined) {
|
|
126
|
+
throw new JournalError(
|
|
127
|
+
'REPLAY_FAILED',
|
|
128
|
+
row.kind,
|
|
129
|
+
row.entityId,
|
|
130
|
+
`${at}: the entity declares no event of this type`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const data = def.schema.safeParse(upcast(row, def, at));
|
|
134
|
+
if (!data.success) {
|
|
135
|
+
const after = row.schemaVersion < def.version ? ` after upcasting from version ${row.schemaVersion}` : '';
|
|
136
|
+
throw new JournalError(
|
|
137
|
+
'REPLAY_FAILED',
|
|
138
|
+
row.kind,
|
|
139
|
+
row.entityId,
|
|
140
|
+
`${at}: the data does not fit the current schema${after}: ${data.error.message}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const meta = metaSchema.safeParse(row.meta);
|
|
144
|
+
if (!meta.success) {
|
|
145
|
+
throw new JournalError(
|
|
146
|
+
'REPLAY_FAILED',
|
|
147
|
+
row.kind,
|
|
148
|
+
row.entityId,
|
|
149
|
+
`${at}: the meta does not fit the current meta schema: ${meta.error.message}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
// Events are recorded facts: read-only for every reducer, projection, effect and caller.
|
|
153
|
+
return deepFreeze({
|
|
154
|
+
type: row.type,
|
|
155
|
+
data: data.data,
|
|
156
|
+
seq: row.seq,
|
|
157
|
+
id: row.id,
|
|
158
|
+
kind: row.kind,
|
|
159
|
+
entityId: id,
|
|
160
|
+
version: row.version,
|
|
161
|
+
meta: meta.data,
|
|
162
|
+
recordedAt: row.recordedAt,
|
|
163
|
+
commandId: row.commandId,
|
|
164
|
+
causeSeq: row.causeSeq,
|
|
165
|
+
correlationId: row.correlationId,
|
|
166
|
+
erasedAt: row.erasedAt,
|
|
167
|
+
});
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const loadHistory = async (db: DbHandle, kind: string, id: string) =>
|
|
171
|
+
db
|
|
172
|
+
.select()
|
|
173
|
+
.from(eventsTable)
|
|
174
|
+
.where(and(eq(eventsTable.kind, kind), eq(eventsTable.entityId, id)))
|
|
175
|
+
.orderBy(asc(eventsTable.version));
|
|
176
|
+
|
|
177
|
+
const highestVersion = async (db: DbHandle, kind: string, id: string): Promise<number> => {
|
|
178
|
+
const [top] = await db
|
|
179
|
+
.select({ version: max(eventsTable.version) })
|
|
180
|
+
.from(eventsTable)
|
|
181
|
+
.where(and(eq(eventsTable.kind, kind), eq(eventsTable.entityId, id)));
|
|
182
|
+
return top?.version ?? 0;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const replayFrom = async <Tbl extends EntityTable, E extends AnyEventDef, Key extends string>(
|
|
186
|
+
db: DbHandle,
|
|
187
|
+
entity: EntityDef<string, Tbl, E, MetaOut, Key>,
|
|
188
|
+
id: EntityId<Tbl, Key>,
|
|
189
|
+
current: object | null,
|
|
190
|
+
throughVersion?: number,
|
|
191
|
+
) => {
|
|
192
|
+
// The whole log, so `throughVersion` cannot hide an incomplete history behind an empty result.
|
|
193
|
+
const rows = await loadHistory(db, entity.kind, id);
|
|
194
|
+
const first = rows[0];
|
|
195
|
+
if (first === undefined && current !== null) {
|
|
196
|
+
throw new JournalError('NO_HISTORY', entity.kind, id, 'the row exists but the log holds no event for it');
|
|
197
|
+
}
|
|
198
|
+
// Version 1 is only ever written by a create from no row. A history that starts
|
|
199
|
+
// later belongs to a row that existed before the journal; folding it from nothing
|
|
200
|
+
// would pick the wrong reducers.
|
|
201
|
+
if (first !== undefined && first.version !== 1) {
|
|
202
|
+
throw new JournalError('NO_HISTORY', entity.kind, id, `the first stored version is ${first.version}`);
|
|
203
|
+
}
|
|
204
|
+
assertNoHoles(entity.kind, id, rows);
|
|
205
|
+
const all = decodeHistory(entity, id, rows);
|
|
206
|
+
// The whole history decides whether the entity is alive, whatever `throughVersion` says.
|
|
207
|
+
const full = fold(entity, null, id, all, 'replay').row;
|
|
208
|
+
assertRowNotAhead(entity.kind, id, full, rows.at(-1)?.version ?? 0, current);
|
|
209
|
+
const events = throughVersion === undefined ? all : all.filter(event => event.version <= throughVersion);
|
|
210
|
+
debugReplay.debug('{kind} {id}: {eventsLength} events', { kind: entity.kind, id, eventsLength: events.length });
|
|
211
|
+
return { row: throughVersion === undefined ? full : fold(entity, null, id, events, 'replay').row, events };
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/** Decodes every row. The filter only narrows the type and drops nothing: see `hasDeclaredEventType`. */
|
|
215
|
+
const decodeHistory = <Tbl extends EntityTable, E extends AnyEventDef, Key extends string>(
|
|
216
|
+
entity: EntityDef<string, Tbl, E, MetaOut, Key>,
|
|
217
|
+
id: EntityId<Tbl, Key>,
|
|
218
|
+
rows: Awaited<ReturnType<typeof loadHistory>>,
|
|
219
|
+
) =>
|
|
220
|
+
rows
|
|
221
|
+
.map(row => decodeStoredEvent(row, entity.events.get(row.type), id))
|
|
222
|
+
.filter(event => hasDeclaredEventType(entity, event));
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* No row, but a log. Either a create follows a delete, or the row was removed
|
|
226
|
+
* outside the journal. The log decides: a history that starts at 1 is complete,
|
|
227
|
+
* and if it folds to a row, that row should be there. Creating over it would make
|
|
228
|
+
* write and replay pick different reducers for the same event. A history that
|
|
229
|
+
* starts later belongs to a pre-journal row and cannot be replayed, so it passes.
|
|
230
|
+
*/
|
|
231
|
+
const assertLogSaysGone = async <Tbl extends EntityTable, E extends AnyEventDef, Key extends string>(
|
|
232
|
+
db: DbHandle,
|
|
233
|
+
entity: EntityDef<string, Tbl, E, MetaOut, Key>,
|
|
234
|
+
id: EntityId<Tbl, Key>,
|
|
235
|
+
latestLogVersion: number,
|
|
236
|
+
): Promise<void> => {
|
|
237
|
+
const rows = await loadHistory(db, entity.kind, id);
|
|
238
|
+
if (rows[0]?.version !== 1) return;
|
|
239
|
+
if (fold(entity, null, id, decodeHistory(entity, id, rows), 'replay').row !== null) {
|
|
240
|
+
throw new JournalError(
|
|
241
|
+
'CONFLICT',
|
|
242
|
+
entity.kind,
|
|
243
|
+
id,
|
|
244
|
+
`the row is missing but its history says it exists at version ${latestLogVersion}; it was removed outside the journal, and repair restores it`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const replay = async <Tbl extends EntityTable, E extends AnyEventDef, Key extends string>(
|
|
250
|
+
db: DbHandle,
|
|
251
|
+
entity: EntityDef<string, Tbl, E, MetaOut, Key>,
|
|
252
|
+
id: EntityId<Tbl, Key>,
|
|
253
|
+
opts?: { readonly throughVersion?: number },
|
|
254
|
+
): Promise<ReplayResult<Tbl, E, MetaOut, Key>> => {
|
|
255
|
+
assertExactId(entity, id);
|
|
256
|
+
const current = await loadRow(db, entity, id, false);
|
|
257
|
+
return replayFrom(db, entity, id, current, opts?.throughVersion);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const verify = async <Tbl extends EntityTable, E extends AnyEventDef, Key extends string>(
|
|
261
|
+
db: DbHandle,
|
|
262
|
+
entity: EntityDef<string, Tbl, E, MetaOut, Key>,
|
|
263
|
+
id: EntityId<Tbl, Key>,
|
|
264
|
+
): Promise<VerifyResult<Tbl, Key>> => {
|
|
265
|
+
assertExactId(entity, id);
|
|
266
|
+
const loaded = await loadRow(db, entity, id, false);
|
|
267
|
+
assertStoredId(entity, loaded, id);
|
|
268
|
+
if (loaded !== null && !isRowOf(entity.table, loaded)) {
|
|
269
|
+
throw new Error(`journal: ${entity.kind} ${id} row is missing columns`);
|
|
270
|
+
}
|
|
271
|
+
const actual = loaded;
|
|
272
|
+
const { row: expected } = await replayFrom(db, entity, id, actual);
|
|
273
|
+
if (expected === null && actual === null) return { ok: true };
|
|
274
|
+
if (expected === null || actual === null) return { ok: false, expected, actual };
|
|
275
|
+
const want = toRecord(expected);
|
|
276
|
+
const have = toRecord(actual);
|
|
277
|
+
const ok = Object.keys(want).every(key => sameValue(want[key], have[key]));
|
|
278
|
+
return ok ? { ok: true } : { ok: false, expected, actual };
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const repair = async <Tbl extends EntityTable, E extends AnyEventDef, Key extends string>(
|
|
282
|
+
db: DbHandle,
|
|
283
|
+
entity: EntityDef<string, Tbl, E, MetaOut, Key>,
|
|
284
|
+
id: EntityId<Tbl, Key>,
|
|
285
|
+
): Promise<Row<Tbl> | null> => {
|
|
286
|
+
assertExactId(entity, id);
|
|
287
|
+
return db.transaction(async tx => {
|
|
288
|
+
const current = await loadRow(tx, entity, id, true);
|
|
289
|
+
assertStoredId(entity, current, id);
|
|
290
|
+
const { row: expected } = await replayFrom(tx, entity, id, current);
|
|
291
|
+
debugReplay.debug('{kind} {id}: repair -> {value3}', {
|
|
292
|
+
kind: entity.kind,
|
|
293
|
+
id,
|
|
294
|
+
value3: expected === null ? 'delete' : `v${expected.version}`,
|
|
295
|
+
});
|
|
296
|
+
const saved = await saveReplayed(tx, entity, id, current, expected);
|
|
297
|
+
// Throwing rolls the transaction back, so the row stays as it was.
|
|
298
|
+
if (saved !== null && expected !== null) assertStoredValues(entity, id, expected, saved, Object.keys(expected));
|
|
299
|
+
return saved;
|
|
300
|
+
});
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const forget = async <Tbl extends EntityTable, E extends AnyEventDef, Key extends string>(
|
|
304
|
+
db: DbHandle,
|
|
305
|
+
entity: EntityDef<string, Tbl, E, MetaOut, Key>,
|
|
306
|
+
id: EntityId<Tbl, Key>,
|
|
307
|
+
): Promise<ForgetResult<Tbl>> => {
|
|
308
|
+
assertExactId(entity, id);
|
|
309
|
+
return db.transaction(async tx => {
|
|
310
|
+
// The row lock first, so no write lands between the rewrite and the repair. Then every
|
|
311
|
+
// async cursor, so no projection batch in flight commits the data after it was erased.
|
|
312
|
+
await loadRow(tx, entity, id, true);
|
|
313
|
+
await tx.select({ name: deps.tables.cursors.name }).from(deps.tables.cursors).for('update');
|
|
314
|
+
const rows = await loadHistory(tx, entity.kind, id);
|
|
315
|
+
let erased = 0;
|
|
316
|
+
for (const row of rows) {
|
|
317
|
+
const def = entity.events.get(row.type);
|
|
318
|
+
if (def?.erased === undefined) continue;
|
|
319
|
+
// Decode as replay does, so the erased form starts from data the schema accepts.
|
|
320
|
+
const event = decodeStoredEvent(row, def, id);
|
|
321
|
+
const form = def.schema.safeParse(def.erased(event.data));
|
|
322
|
+
if (!form.success) {
|
|
323
|
+
throw new JournalError(
|
|
324
|
+
'BAD_DEFINITION',
|
|
325
|
+
entity.kind,
|
|
326
|
+
id,
|
|
327
|
+
`event "${row.type}": its erased form does not fit its own schema: ${form.error.message}`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
await tx
|
|
331
|
+
.update(eventsTable)
|
|
332
|
+
.set({
|
|
333
|
+
data: form.data,
|
|
334
|
+
schemaVersion: def.version,
|
|
335
|
+
erasedAt: sql`coalesce(${eventsTable.erasedAt}, now())`,
|
|
336
|
+
})
|
|
337
|
+
.where(eq(eventsTable.seq, row.seq));
|
|
338
|
+
erased += 1;
|
|
339
|
+
}
|
|
340
|
+
// The row must match the history it now has. Repair on `tx` is a savepoint.
|
|
341
|
+
const row = await repair(tx, entity, id);
|
|
342
|
+
debugReplay.debug('{kind} {id}: forget -> {erased} events erased', { kind: entity.kind, id, erased });
|
|
343
|
+
return { erased, row };
|
|
344
|
+
});
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const rerun = async <Tx extends TransactionSource<Tx>>(
|
|
348
|
+
db: TransactionSource<Tx>,
|
|
349
|
+
projection: Projection<MetaOut, Tx>,
|
|
350
|
+
opts?: { readonly afterSeq?: number; readonly batchSize?: number },
|
|
351
|
+
): Promise<{ readonly events: number }> => {
|
|
352
|
+
const batchSize = opts?.batchSize ?? 100;
|
|
353
|
+
const kinds = projection.entities.map(entity => entity.kind);
|
|
354
|
+
let cursor = opts?.afterSeq ?? 0;
|
|
355
|
+
let count = 0;
|
|
356
|
+
if (kinds.length === 0 || projection.types.size === 0) return { events: 0 };
|
|
357
|
+
for (;;) {
|
|
358
|
+
const rows = await db
|
|
359
|
+
.select()
|
|
360
|
+
.from(eventsTable)
|
|
361
|
+
.where(
|
|
362
|
+
and(
|
|
363
|
+
gt(eventsTable.seq, cursor),
|
|
364
|
+
inArray(eventsTable.kind, kinds),
|
|
365
|
+
inArray(eventsTable.type, [...projection.types]),
|
|
366
|
+
),
|
|
367
|
+
)
|
|
368
|
+
.orderBy(asc(eventsTable.seq))
|
|
369
|
+
.limit(batchSize);
|
|
370
|
+
if (rows.length === 0) break;
|
|
371
|
+
await db.transaction(async tx => {
|
|
372
|
+
for (const row of rows) {
|
|
373
|
+
await projection.run(
|
|
374
|
+
tx,
|
|
375
|
+
decodeStoredEvent(row, findEventDefinition(projection.entities, row.kind, row.type), row.entityId),
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
count += rows.length;
|
|
380
|
+
cursor = rows.at(-1)?.seq ?? cursor;
|
|
381
|
+
if (rows.length < batchSize) break;
|
|
382
|
+
}
|
|
383
|
+
debugReplay.debug('rerun {projectionName}: {count} events', { projectionName: projection.name, count });
|
|
384
|
+
return { events: count };
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
return { decodeStoredEvent, highestVersion, assertLogSaysGone, replay, verify, repair, forget, rerun };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export type History<Meta extends z.ZodObject> = ReturnType<typeof createHistory<Meta>>;
|