effect-cqrs 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 +298 -2
- package/dist/Aggregate.d.ts +86 -0
- package/dist/Aggregate.d.ts.map +1 -0
- package/dist/Aggregate.js +15 -0
- package/dist/Aggregate.js.map +1 -0
- package/dist/AggregateService.d.ts +82 -0
- package/dist/AggregateService.d.ts.map +1 -0
- package/dist/AggregateService.js +181 -0
- package/dist/AggregateService.js.map +1 -0
- package/dist/Checkpoints.d.ts +19 -0
- package/dist/Checkpoints.d.ts.map +1 -0
- package/dist/Checkpoints.js +18 -0
- package/dist/Checkpoints.js.map +1 -0
- package/dist/EventStore.d.ts +110 -0
- package/dist/EventStore.d.ts.map +1 -0
- package/dist/EventStore.js +136 -0
- package/dist/EventStore.js.map +1 -0
- package/dist/Fixture.d.ts +71 -0
- package/dist/Fixture.d.ts.map +1 -0
- package/dist/Fixture.js +108 -0
- package/dist/Fixture.js.map +1 -0
- package/dist/PostgresEventStore.d.ts +30 -0
- package/dist/PostgresEventStore.d.ts.map +1 -0
- package/dist/PostgresEventStore.js +144 -0
- package/dist/PostgresEventStore.js.map +1 -0
- package/dist/SqliteEventStore.d.ts +25 -0
- package/dist/SqliteEventStore.d.ts.map +1 -0
- package/dist/SqliteEventStore.js +123 -0
- package/dist/SqliteEventStore.js.map +1 -0
- package/dist/ViewModel.d.ts +90 -0
- package/dist/ViewModel.d.ts.map +1 -0
- package/dist/ViewModel.js +170 -0
- package/dist/ViewModel.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/package.json +87 -3
- package/src/Aggregate.ts +133 -0
- package/src/AggregateService.ts +342 -0
- package/src/Checkpoints.ts +23 -0
- package/src/EventStore.ts +233 -0
- package/src/Fixture.ts +213 -0
- package/src/PostgresEventStore.ts +188 -0
- package/src/SqliteEventStore.ts +166 -0
- package/src/ViewModel.ts +348 -0
- package/src/index.ts +8 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs an aggregate against the {@link EventStore}: the `command`, `read-model`
|
|
3
|
+
* and `history` actions of moleculer-cqrs, as a typed service.
|
|
4
|
+
*/
|
|
5
|
+
import { Array as Arr, type DateTime, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
|
|
6
|
+
import * as Aggregate from "./Aggregate.ts"
|
|
7
|
+
import {
|
|
8
|
+
ConcurrencyConflict,
|
|
9
|
+
type EventFilter,
|
|
10
|
+
EventStore,
|
|
11
|
+
type EventStoreError,
|
|
12
|
+
type StoredEvent
|
|
13
|
+
} from "./EventStore.ts"
|
|
14
|
+
|
|
15
|
+
export class UnknownCommand extends Schema.TaggedError<UnknownCommand>()("UnknownCommand", {
|
|
16
|
+
aggregateName: Schema.String,
|
|
17
|
+
type: Schema.String
|
|
18
|
+
}) {}
|
|
19
|
+
|
|
20
|
+
export class InvalidCommand extends Schema.TaggedError<InvalidCommand>()("InvalidCommand", {
|
|
21
|
+
aggregateName: Schema.String,
|
|
22
|
+
type: Schema.String,
|
|
23
|
+
message: Schema.String
|
|
24
|
+
}) {}
|
|
25
|
+
|
|
26
|
+
/** A stored event no longer matches the schema, which means the data needs a migration. */
|
|
27
|
+
export class EventDecodeError extends Schema.TaggedError<EventDecodeError>()("EventDecodeError", {
|
|
28
|
+
aggregateName: Schema.String,
|
|
29
|
+
aggregateId: Schema.String,
|
|
30
|
+
version: Schema.Int,
|
|
31
|
+
type: Schema.String,
|
|
32
|
+
message: Schema.String
|
|
33
|
+
}) {}
|
|
34
|
+
|
|
35
|
+
export type ReadError = EventStoreError | EventDecodeError
|
|
36
|
+
|
|
37
|
+
/** How a command can fail, on top of the errors its own handler declares. */
|
|
38
|
+
export type CommandFailure = ConcurrencyConflict | InvalidCommand | ReadError
|
|
39
|
+
|
|
40
|
+
export interface CommandResult<Event> {
|
|
41
|
+
readonly aggregateName: string
|
|
42
|
+
readonly aggregateId: string
|
|
43
|
+
/** The stream version after the command. */
|
|
44
|
+
readonly version: number
|
|
45
|
+
/** The store position of the last event, to pass to `ViewModels.caughtUp`. */
|
|
46
|
+
readonly position: number
|
|
47
|
+
readonly events: ReadonlyArray<Event>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface HistoryEntry<Event> {
|
|
51
|
+
readonly version: number
|
|
52
|
+
readonly timestamp: DateTime.Utc
|
|
53
|
+
readonly type: string
|
|
54
|
+
readonly payload?: Event
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AggregateService<A extends Aggregate.Any> {
|
|
58
|
+
readonly aggregate: A
|
|
59
|
+
/** One typed function per command. */
|
|
60
|
+
readonly commands: {
|
|
61
|
+
readonly [K in Aggregate.CommandName<A>]: (
|
|
62
|
+
aggregateId: string,
|
|
63
|
+
payload: Aggregate.Payload<A, K>
|
|
64
|
+
) => Effect.Effect<CommandResult<Aggregate.Event<A>>, Aggregate.CommandError<A, K> | CommandFailure>
|
|
65
|
+
}
|
|
66
|
+
/** Runs a `{ aggregateId, type, payload }` envelope received as JSON from outside the process. */
|
|
67
|
+
readonly dispatch: (
|
|
68
|
+
input: unknown
|
|
69
|
+
) => Effect.Effect<CommandResult<Aggregate.Event<A>>, Aggregate.CommandError<A> | CommandFailure | UnknownCommand>
|
|
70
|
+
/** Materializes the state on the fly, optionally as it was before `finishTime`. */
|
|
71
|
+
readonly readModel: (
|
|
72
|
+
aggregateId: string,
|
|
73
|
+
options?: { readonly finishTime?: DateTime.Utc | undefined }
|
|
74
|
+
) => Effect.Effect<Aggregate.State<A>, ReadError>
|
|
75
|
+
/** Lists the events of one aggregate, with their decoded payload when `payload` is set. */
|
|
76
|
+
readonly history: (
|
|
77
|
+
aggregateId: string,
|
|
78
|
+
options?: {
|
|
79
|
+
readonly payload?: boolean | undefined
|
|
80
|
+
readonly startTime?: DateTime.Utc | undefined
|
|
81
|
+
readonly finishTime?: DateTime.Utc | undefined
|
|
82
|
+
}
|
|
83
|
+
) => Effect.Effect<ReadonlyArray<HistoryEntry<Aggregate.Event<A>>>, ReadError>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const CommandEnvelope = Schema.Struct({
|
|
87
|
+
aggregateId: Schema.NonEmptyString,
|
|
88
|
+
type: Schema.String,
|
|
89
|
+
payload: Schema.Unknown
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
/** Decodes a stored event with the schema of the aggregate it belongs to. */
|
|
93
|
+
export const makeEventDecoder = <A extends Aggregate.Any>(aggregate: A) => {
|
|
94
|
+
const decode = Schema.decodeUnknownEffect(Schema.toCodecJson(aggregate.events))
|
|
95
|
+
return (stored: StoredEvent): Effect.Effect<Aggregate.Event<A>, EventDecodeError> =>
|
|
96
|
+
decode(stored.payload).pipe(
|
|
97
|
+
Effect.mapError((error) =>
|
|
98
|
+
new EventDecodeError({
|
|
99
|
+
aggregateName: stored.aggregateName,
|
|
100
|
+
aggregateId: stored.aggregateId,
|
|
101
|
+
version: stored.version,
|
|
102
|
+
type: stored.type,
|
|
103
|
+
message: error.message
|
|
104
|
+
})
|
|
105
|
+
)
|
|
106
|
+
) as Effect.Effect<Aggregate.Event<A>, EventDecodeError>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Encodes events into the JSON form the store keeps. */
|
|
110
|
+
export const makeEventEncoder = <A extends Aggregate.Any>(aggregate: A) => {
|
|
111
|
+
const encode = Schema.encodeUnknownEffect(Schema.toCodecJson(aggregate.events))
|
|
112
|
+
return (event: Aggregate.Event<A>) =>
|
|
113
|
+
encode(event).pipe(
|
|
114
|
+
// Events come from the aggregate's own handlers, so this is a programming error.
|
|
115
|
+
Effect.orDie,
|
|
116
|
+
Effect.map((payload) => ({ type: event._tag, payload: payload as Schema.Json }))
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface Loaded<State> {
|
|
121
|
+
readonly state: State
|
|
122
|
+
readonly version: number
|
|
123
|
+
readonly position: number
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Remembers recent states, dropping the least recently used. A `Map` iterates in insertion order. */
|
|
127
|
+
const makeStateCache = <State>(capacity: number) => {
|
|
128
|
+
const entries = new Map<string, Loaded<State>>()
|
|
129
|
+
return {
|
|
130
|
+
get: (aggregateId: string) => {
|
|
131
|
+
const entry = entries.get(aggregateId)
|
|
132
|
+
if (entry !== undefined) {
|
|
133
|
+
entries.delete(aggregateId)
|
|
134
|
+
entries.set(aggregateId, entry)
|
|
135
|
+
}
|
|
136
|
+
return entry
|
|
137
|
+
},
|
|
138
|
+
set: (aggregateId: string, loaded: Loaded<State>) => {
|
|
139
|
+
if (capacity === 0 || (entries.get(aggregateId)?.position ?? 0) > loaded.position) return
|
|
140
|
+
entries.delete(aggregateId)
|
|
141
|
+
entries.set(aggregateId, loaded)
|
|
142
|
+
if (entries.size > capacity) entries.delete(entries.keys().next().value!)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Runs effects with the same key one at a time. A key only exists while it is in use. */
|
|
148
|
+
const makeKeyedLock = () => {
|
|
149
|
+
const locks = new Map<string, { readonly semaphore: Semaphore.Semaphore; users: number }>()
|
|
150
|
+
return (key: string) => <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
|
151
|
+
Effect.acquireUseRelease(
|
|
152
|
+
Effect.sync(() => {
|
|
153
|
+
const lock = locks.get(key) ?? { semaphore: Semaphore.makeUnsafe(1), users: 0 }
|
|
154
|
+
lock.users++
|
|
155
|
+
locks.set(key, lock)
|
|
156
|
+
return lock
|
|
157
|
+
}),
|
|
158
|
+
(lock) => lock.semaphore.withPermit(effect),
|
|
159
|
+
(lock) =>
|
|
160
|
+
Effect.sync(() => {
|
|
161
|
+
if (--lock.users === 0) locks.delete(key)
|
|
162
|
+
})
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Gives other fibers a turn once in a while. `Effect.forEach` with `concurrency`
|
|
168
|
+
* carries on as long as items don't suspend, and a store on `node:sqlite` never does,
|
|
169
|
+
* so a bulk run would finish before any view model ran. Yielding per command is
|
|
170
|
+
* expensive, so it only happens when a millisecond has passed, using real time
|
|
171
|
+
* because this is about the CPU. The clock restarts after the yield rather than
|
|
172
|
+
* before it, so commands started meanwhile suspend too and the loop lets go.
|
|
173
|
+
*/
|
|
174
|
+
const makeTimeSlice = () => {
|
|
175
|
+
let started = performance.now()
|
|
176
|
+
return Effect.suspend(() =>
|
|
177
|
+
performance.now() - started < 1
|
|
178
|
+
? Effect.void
|
|
179
|
+
: Effect.andThen(Effect.yieldNow, Effect.sync(() => void (started = performance.now())))
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Only a rival outside this service can cause a conflict, so give it room to finish.
|
|
184
|
+
const conflictBackoff = Schedule.exponential("10 millis").pipe(Schedule.jittered)
|
|
185
|
+
|
|
186
|
+
export const make = Effect.fnUntraced(function*<A extends Aggregate.Any>(
|
|
187
|
+
aggregate: A,
|
|
188
|
+
options?: {
|
|
189
|
+
/** How many times a command is re-decided after losing a race. Defaults to 5. */
|
|
190
|
+
readonly maxRetries?: number
|
|
191
|
+
/** How many aggregates keep their state in memory between commands. Defaults to 10,000. */
|
|
192
|
+
readonly stateCacheSize?: number
|
|
193
|
+
}
|
|
194
|
+
) {
|
|
195
|
+
const store = yield* EventStore
|
|
196
|
+
const context = yield* Effect.context<Aggregate.Context<A>>()
|
|
197
|
+
const maxRetries = options?.maxRetries ?? 5
|
|
198
|
+
const aggregateName = aggregate.name
|
|
199
|
+
const decodeEvent = makeEventDecoder(aggregate)
|
|
200
|
+
const encodeEvent = makeEventEncoder(aggregate)
|
|
201
|
+
const states = makeStateCache<Aggregate.State<A>>(options?.stateCacheSize ?? 10_000)
|
|
202
|
+
const lockStream = makeKeyedLock()
|
|
203
|
+
const timeSlice = makeTimeSlice()
|
|
204
|
+
|
|
205
|
+
const replay = <Acc>(
|
|
206
|
+
stream: Stream.Stream<StoredEvent, EventStoreError>,
|
|
207
|
+
initial: Acc,
|
|
208
|
+
step: (acc: Acc, event: Aggregate.Event<A>, stored: StoredEvent) => Acc
|
|
209
|
+
) =>
|
|
210
|
+
stream.pipe(
|
|
211
|
+
Stream.runFoldEffect(
|
|
212
|
+
() => initial,
|
|
213
|
+
(acc, stored) => Effect.map(decodeEvent(stored), (event) => step(acc, event, stored))
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
const streamOf = (
|
|
218
|
+
aggregateId: string,
|
|
219
|
+
range?: Pick<EventFilter, "startTime" | "finishTime" | "afterPosition" | "afterVersion">
|
|
220
|
+
) => store.read({ aggregateNames: [aggregateName], aggregateIds: [aggregateId], ...range })
|
|
221
|
+
|
|
222
|
+
const replayFrom = (
|
|
223
|
+
start: Loaded<Aggregate.State<A>>,
|
|
224
|
+
aggregateId: string,
|
|
225
|
+
finishTime?: DateTime.Utc
|
|
226
|
+
) =>
|
|
227
|
+
replay(
|
|
228
|
+
streamOf(aggregateId, { finishTime, afterPosition: start.position, afterVersion: start.version }),
|
|
229
|
+
start,
|
|
230
|
+
(acc, event, stored) => ({
|
|
231
|
+
state: Aggregate.evolve(aggregate, acc.state, event, stored),
|
|
232
|
+
version: stored.version,
|
|
233
|
+
position: stored.position
|
|
234
|
+
})
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
const initial: Loaded<Aggregate.State<A>> = { state: aggregate.initial as Aggregate.State<A>, version: 0, position: 0 }
|
|
238
|
+
|
|
239
|
+
// Streams only grow, so a remembered state plus what came after it is the current
|
|
240
|
+
// state, even if another process wrote those events.
|
|
241
|
+
const load = (aggregateId: string, finishTime?: DateTime.Utc) =>
|
|
242
|
+
finishTime === undefined
|
|
243
|
+
? replayFrom(states.get(aggregateId) ?? initial, aggregateId).pipe(
|
|
244
|
+
Effect.tap((loaded) => Effect.sync(() => states.set(aggregateId, loaded)))
|
|
245
|
+
)
|
|
246
|
+
: replayFrom(initial, aggregateId, finishTime)
|
|
247
|
+
|
|
248
|
+
const decide = Effect.fnUntraced(function*(type: string, aggregateId: string, payload: unknown) {
|
|
249
|
+
const { position, state, version } = yield* load(aggregateId)
|
|
250
|
+
const { handler } = aggregate.commands[type]!
|
|
251
|
+
const events: ReadonlyArray<Aggregate.Event<A>> = Arr.ensure(yield* handler(state, payload, { aggregateId, version }))
|
|
252
|
+
if (events.length === 0) {
|
|
253
|
+
return { aggregateName, aggregateId, version, position, events }
|
|
254
|
+
}
|
|
255
|
+
const stored = yield* store.append({
|
|
256
|
+
aggregateName,
|
|
257
|
+
aggregateId,
|
|
258
|
+
expectedVersion: version,
|
|
259
|
+
events: yield* Effect.forEach(events, encodeEvent)
|
|
260
|
+
})
|
|
261
|
+
const last = stored[stored.length - 1]!
|
|
262
|
+
return { aggregateName, aggregateId, version: last.version, position: last.position, events }
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
// Commands on one stream take turns, so they can't race each other and only a
|
|
266
|
+
// rival in another process causes a conflict. A handler must not run a command on
|
|
267
|
+
// its own stream, which would wait for itself.
|
|
268
|
+
const execute = (type: string, aggregateId: string, payload: unknown) =>
|
|
269
|
+
decide(type, aggregateId, payload).pipe(
|
|
270
|
+
Effect.retry({
|
|
271
|
+
times: maxRetries,
|
|
272
|
+
while: (error) => error instanceof ConcurrencyConflict,
|
|
273
|
+
schedule: conflictBackoff
|
|
274
|
+
}),
|
|
275
|
+
Effect.provideContext(context),
|
|
276
|
+
lockStream(aggregateId),
|
|
277
|
+
(decided) => Effect.andThen(timeSlice, decided),
|
|
278
|
+
Effect.withSpan(`${aggregateName}.${type}`, { attributes: { "aggregate.id": aggregateId } })
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
const decodePayload = (type: string, schema: Aggregate.PayloadSchema, input: unknown) =>
|
|
282
|
+
Schema.decodeUnknownEffect(schema)(input).pipe(
|
|
283
|
+
Effect.mapError((error) => new InvalidCommand({ aggregateName, type, message: error.message }))
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
const commands = Object.fromEntries(
|
|
287
|
+
Object.entries(aggregate.commands).map(([type, command]) => [
|
|
288
|
+
type,
|
|
289
|
+
(aggregateId: string, payload: unknown) =>
|
|
290
|
+
decodePayload(type, Schema.toType(command.payload), payload).pipe(
|
|
291
|
+
Effect.flatMap((decoded) => execute(type, aggregateId, decoded))
|
|
292
|
+
)
|
|
293
|
+
])
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
const decodeEnvelope = Schema.decodeUnknownEffect(CommandEnvelope)
|
|
297
|
+
|
|
298
|
+
const dispatch = (input: unknown) =>
|
|
299
|
+
Effect.gen(function*() {
|
|
300
|
+
const envelope = yield* decodeEnvelope(input).pipe(
|
|
301
|
+
Effect.mapError((error) => new InvalidCommand({ aggregateName, type: "unknown", message: error.message }))
|
|
302
|
+
)
|
|
303
|
+
if (!Object.hasOwn(aggregate.commands, envelope.type)) {
|
|
304
|
+
return yield* new UnknownCommand({ aggregateName, type: envelope.type })
|
|
305
|
+
}
|
|
306
|
+
const payload = yield* decodePayload(
|
|
307
|
+
envelope.type,
|
|
308
|
+
Schema.toCodecJson(aggregate.commands[envelope.type]!.payload),
|
|
309
|
+
envelope.payload
|
|
310
|
+
)
|
|
311
|
+
return yield* execute(envelope.type, envelope.aggregateId, payload)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
const readModel: AggregateService<A>["readModel"] = (aggregateId, options) =>
|
|
315
|
+
load(aggregateId, options?.finishTime).pipe(
|
|
316
|
+
Effect.map(({ state }) => state),
|
|
317
|
+
Effect.withSpan(`${aggregateName}.readModel`, { attributes: { "aggregate.id": aggregateId } })
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
const history: AggregateService<A>["history"] = (aggregateId, options) =>
|
|
321
|
+
replay(
|
|
322
|
+
streamOf(aggregateId, { startTime: options?.startTime, finishTime: options?.finishTime }),
|
|
323
|
+
[] as Array<HistoryEntry<Aggregate.Event<A>>>,
|
|
324
|
+
(entries, event, stored) => {
|
|
325
|
+
entries.push({
|
|
326
|
+
version: stored.version,
|
|
327
|
+
timestamp: stored.timestamp,
|
|
328
|
+
type: stored.type,
|
|
329
|
+
...(options?.payload === true ? { payload: event } : {})
|
|
330
|
+
})
|
|
331
|
+
return entries
|
|
332
|
+
}
|
|
333
|
+
).pipe(Effect.withSpan(`${aggregateName}.history`, { attributes: { "aggregate.id": aggregateId } }))
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
aggregate,
|
|
337
|
+
commands,
|
|
338
|
+
dispatch,
|
|
339
|
+
readModel,
|
|
340
|
+
history
|
|
341
|
+
} as unknown as AggregateService<A>
|
|
342
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remembers how far each view model has read.
|
|
3
|
+
*
|
|
4
|
+
* A checkpoint has to live as long as the view model it tracks: pair an
|
|
5
|
+
* in-memory view model with {@link layerMemory} so it rebuilds from the event
|
|
6
|
+
* store on restart, and a durable one with a durable checkpoint store.
|
|
7
|
+
*/
|
|
8
|
+
import { Context, Effect, Layer } from "effect"
|
|
9
|
+
import type { EventStoreError } from "./EventStore.ts"
|
|
10
|
+
|
|
11
|
+
export class Checkpoints extends Context.Service<Checkpoints, {
|
|
12
|
+
/** The last processed position, `0` when the view model never ran. */
|
|
13
|
+
get(viewModel: string): Effect.Effect<number, EventStoreError>
|
|
14
|
+
set(viewModel: string, position: number): Effect.Effect<void, EventStoreError>
|
|
15
|
+
}>()("effect-cqrs/Checkpoints") {}
|
|
16
|
+
|
|
17
|
+
export const layerMemory: Layer.Layer<Checkpoints> = Layer.sync(Checkpoints, () => {
|
|
18
|
+
const positions = new Map<string, number>()
|
|
19
|
+
return Checkpoints.of({
|
|
20
|
+
get: (viewModel) => Effect.sync(() => positions.get(viewModel) ?? 0),
|
|
21
|
+
set: (viewModel, position) => Effect.sync(() => void positions.set(viewModel, position))
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The append-only log every aggregate and view model is built on.
|
|
3
|
+
*
|
|
4
|
+
* Each event belongs to a stream, identified by aggregate name and id, and has
|
|
5
|
+
* two coordinates: a `version` that orders it inside its stream and a `position`
|
|
6
|
+
* that orders it across the whole store. Writers use the version for optimistic
|
|
7
|
+
* concurrency, readers use the position as a durable cursor.
|
|
8
|
+
*/
|
|
9
|
+
import { Context, DateTime, Effect, Layer, Option, Ref, Schema, Stream, SubscriptionRef } from "effect"
|
|
10
|
+
|
|
11
|
+
export class EventStoreError extends Schema.TaggedError<EventStoreError>()("EventStoreError", {
|
|
12
|
+
operation: Schema.String,
|
|
13
|
+
cause: Schema.Defect()
|
|
14
|
+
}) {}
|
|
15
|
+
|
|
16
|
+
export class ConcurrencyConflict extends Schema.TaggedError<ConcurrencyConflict>()("ConcurrencyConflict", {
|
|
17
|
+
aggregateName: Schema.String,
|
|
18
|
+
aggregateId: Schema.String,
|
|
19
|
+
expectedVersion: Schema.Int
|
|
20
|
+
}) {}
|
|
21
|
+
|
|
22
|
+
export const StoredEvent = Schema.Struct({
|
|
23
|
+
position: Schema.Int,
|
|
24
|
+
aggregateName: Schema.String,
|
|
25
|
+
aggregateId: Schema.String,
|
|
26
|
+
version: Schema.Int,
|
|
27
|
+
type: Schema.String,
|
|
28
|
+
payload: Schema.Json,
|
|
29
|
+
timestamp: Schema.DateTimeUtcFromMillis
|
|
30
|
+
})
|
|
31
|
+
export type StoredEvent = typeof StoredEvent.Type
|
|
32
|
+
|
|
33
|
+
export interface NewEvent {
|
|
34
|
+
readonly type: string
|
|
35
|
+
readonly payload: Schema.Json
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AppendInput {
|
|
39
|
+
readonly aggregateName: string
|
|
40
|
+
readonly aggregateId: string
|
|
41
|
+
/** The stream version the writer based its decision on, `0` for a new stream. */
|
|
42
|
+
readonly expectedVersion: number
|
|
43
|
+
readonly events: ReadonlyArray<NewEvent>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Selects events by stream, type and time. Omitted fields match everything.
|
|
48
|
+
* `startTime` is inclusive and `finishTime` is exclusive, so adding one
|
|
49
|
+
* millisecond to a history timestamp selects up to and including that event.
|
|
50
|
+
*/
|
|
51
|
+
export interface EventFilter {
|
|
52
|
+
readonly aggregateNames?: ReadonlyArray<string> | undefined
|
|
53
|
+
readonly aggregateIds?: ReadonlyArray<string> | undefined
|
|
54
|
+
readonly eventTypes?: ReadonlyArray<string> | undefined
|
|
55
|
+
readonly startTime?: DateTime.Utc | undefined
|
|
56
|
+
readonly finishTime?: DateTime.Utc | undefined
|
|
57
|
+
readonly afterPosition?: number | undefined
|
|
58
|
+
/**
|
|
59
|
+
* For a filter on one stream: where to continue in it. Versions follow the stream's
|
|
60
|
+
* own index, so resuming there costs the same however far the log has moved on.
|
|
61
|
+
*/
|
|
62
|
+
readonly afterVersion?: number | undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A filter on exactly one stream, which a store can read through that stream's index. */
|
|
66
|
+
export const isOneStream = ({ aggregateIds, aggregateNames }: EventFilter): boolean =>
|
|
67
|
+
aggregateNames?.length === 1 && aggregateIds?.length === 1
|
|
68
|
+
|
|
69
|
+
export class EventStore extends Context.Service<EventStore, {
|
|
70
|
+
/** Appends atomically, failing if the stream moved past `expectedVersion`. */
|
|
71
|
+
append(input: AppendInput): Effect.Effect<ReadonlyArray<StoredEvent>, ConcurrencyConflict | EventStoreError>
|
|
72
|
+
/** Reads the events stored so far, ordered by position, then completes. */
|
|
73
|
+
read(filter?: EventFilter): Stream.Stream<StoredEvent, EventStoreError>
|
|
74
|
+
/** Reads the events stored so far, then keeps following new ones. Never completes. */
|
|
75
|
+
subscribe(filter?: EventFilter): Stream.Stream<StoredEvent, EventStoreError>
|
|
76
|
+
/** The position of the latest event, `0` when the store is empty. */
|
|
77
|
+
readonly head: Effect.Effect<number, EventStoreError>
|
|
78
|
+
/**
|
|
79
|
+
* Emits the current latest position, then each newly appended one. Consumers
|
|
80
|
+
* can treat an element as "there may be something new": appends from other
|
|
81
|
+
* processes are not observed.
|
|
82
|
+
*/
|
|
83
|
+
readonly changes: Stream.Stream<number>
|
|
84
|
+
}>()("effect-cqrs/EventStore") {}
|
|
85
|
+
|
|
86
|
+
/** What a storage adapter implements. {@link make} builds the rest on top. */
|
|
87
|
+
export interface Backend {
|
|
88
|
+
readonly append: EventStore["Service"]["append"]
|
|
89
|
+
readonly readBatch: (
|
|
90
|
+
filter: EventFilter,
|
|
91
|
+
afterPosition: number,
|
|
92
|
+
limit: number
|
|
93
|
+
) => Effect.Effect<ReadonlyArray<StoredEvent>, EventStoreError>
|
|
94
|
+
readonly head: Effect.Effect<number, EventStoreError>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const make = Effect.fnUntraced(function*(backend: Backend, options?: { readonly batchSize?: number }) {
|
|
98
|
+
const batchSize = options?.batchSize ?? 500
|
|
99
|
+
const latest = yield* SubscriptionRef.make(yield* backend.head)
|
|
100
|
+
|
|
101
|
+
const append = Effect.fn("EventStore.append")((input: AppendInput) =>
|
|
102
|
+
backend.append(input).pipe(
|
|
103
|
+
Effect.tap((stored) => {
|
|
104
|
+
const last = stored.at(-1)
|
|
105
|
+
return last === undefined
|
|
106
|
+
? Effect.void
|
|
107
|
+
: SubscriptionRef.update(latest, (position) => Math.max(position, last.position))
|
|
108
|
+
})
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
const read = (filter: EventFilter = {}) =>
|
|
113
|
+
Stream.paginate(
|
|
114
|
+
{ afterPosition: filter.afterPosition ?? 0, afterVersion: filter.afterVersion ?? 0 },
|
|
115
|
+
({ afterPosition, afterVersion }) =>
|
|
116
|
+
backend.readBatch({ ...filter, afterVersion }, afterPosition, batchSize).pipe(
|
|
117
|
+
Effect.map((events) => {
|
|
118
|
+
const last = events.at(-1)
|
|
119
|
+
return [
|
|
120
|
+
events,
|
|
121
|
+
last === undefined || events.length < batchSize
|
|
122
|
+
? Option.none()
|
|
123
|
+
: Option.some({ afterPosition: last.position, afterVersion: last.version })
|
|
124
|
+
] as const
|
|
125
|
+
})
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const subscribe = (filter: EventFilter = {}) =>
|
|
130
|
+
Stream.unwrap(Effect.gen(function*() {
|
|
131
|
+
const cursor = yield* Ref.make({ position: filter.afterPosition ?? 0, version: filter.afterVersion ?? 0 })
|
|
132
|
+
return SubscriptionRef.changes(latest).pipe(
|
|
133
|
+
Stream.filterEffect((position) => Effect.map(Ref.get(cursor), (seen) => position > seen.position)),
|
|
134
|
+
Stream.flatMap((position) =>
|
|
135
|
+
Stream.unwrap(
|
|
136
|
+
Effect.map(Ref.get(cursor), (seen) =>
|
|
137
|
+
read({ ...filter, afterPosition: seen.position, afterVersion: seen.version }))
|
|
138
|
+
).pipe(
|
|
139
|
+
Stream.tap((event) => Ref.set(cursor, { position: event.position, version: event.version })),
|
|
140
|
+
// Events the filter skipped must not trigger another read.
|
|
141
|
+
Stream.ensuring(
|
|
142
|
+
Ref.update(cursor, (seen) => seen.position >= position ? seen : { ...seen, position })
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
}))
|
|
148
|
+
|
|
149
|
+
return EventStore.of({
|
|
150
|
+
append,
|
|
151
|
+
read,
|
|
152
|
+
subscribe,
|
|
153
|
+
head: backend.head,
|
|
154
|
+
changes: SubscriptionRef.changes(latest)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const matches = (filter: EventFilter, event: StoredEvent): boolean =>
|
|
159
|
+
(filter.aggregateNames?.includes(event.aggregateName) ?? true) &&
|
|
160
|
+
(filter.aggregateIds?.includes(event.aggregateId) ?? true) &&
|
|
161
|
+
(filter.eventTypes?.includes(event.type) ?? true) &&
|
|
162
|
+
(filter.startTime === undefined || !DateTime.isLessThan(event.timestamp, filter.startTime)) &&
|
|
163
|
+
(filter.finishTime === undefined || DateTime.isLessThan(event.timestamp, filter.finishTime))
|
|
164
|
+
|
|
165
|
+
/** The index of the first event past `position`, in events sorted by position. */
|
|
166
|
+
const firstAfter = (events: ReadonlyArray<StoredEvent>, position: number): number => {
|
|
167
|
+
let low = 0
|
|
168
|
+
let high = events.length
|
|
169
|
+
while (low < high) {
|
|
170
|
+
const middle = (low + high) >>> 1
|
|
171
|
+
if (events[middle]!.position <= position) low = middle + 1
|
|
172
|
+
else high = middle
|
|
173
|
+
}
|
|
174
|
+
return low
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const streamKey = (aggregateName: string, aggregateId: string) => JSON.stringify([aggregateName, aggregateId])
|
|
178
|
+
|
|
179
|
+
export const makeMemory: Effect.Effect<EventStore["Service"], EventStoreError> = Effect.gen(function*() {
|
|
180
|
+
const events: Array<StoredEvent> = []
|
|
181
|
+
const streams = new Map<string, Array<StoredEvent>>()
|
|
182
|
+
|
|
183
|
+
// Every command reads one stream, which shouldn't cost a walk through the whole log.
|
|
184
|
+
const candidates = (filter: EventFilter): ReadonlyArray<StoredEvent> =>
|
|
185
|
+
isOneStream(filter)
|
|
186
|
+
? streams.get(streamKey(filter.aggregateNames![0]!, filter.aggregateIds![0]!)) ?? []
|
|
187
|
+
: events
|
|
188
|
+
|
|
189
|
+
return yield* make({
|
|
190
|
+
append: Effect.fnUntraced(function*(input) {
|
|
191
|
+
const timestamp = yield* DateTime.now
|
|
192
|
+
const key = streamKey(input.aggregateName, input.aggregateId)
|
|
193
|
+
const stream = streams.get(key) ?? []
|
|
194
|
+
const version = stream.length
|
|
195
|
+
if (version !== input.expectedVersion) {
|
|
196
|
+
return yield* new ConcurrencyConflict({
|
|
197
|
+
aggregateName: input.aggregateName,
|
|
198
|
+
aggregateId: input.aggregateId,
|
|
199
|
+
expectedVersion: input.expectedVersion
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
const stored = input.events.map((event, index): StoredEvent => ({
|
|
203
|
+
position: events.length + index + 1,
|
|
204
|
+
aggregateName: input.aggregateName,
|
|
205
|
+
aggregateId: input.aggregateId,
|
|
206
|
+
version: version + index + 1,
|
|
207
|
+
type: event.type,
|
|
208
|
+
payload: event.payload,
|
|
209
|
+
timestamp
|
|
210
|
+
}))
|
|
211
|
+
for (const event of stored) {
|
|
212
|
+
events.push(event)
|
|
213
|
+
stream.push(event)
|
|
214
|
+
}
|
|
215
|
+
streams.set(key, stream)
|
|
216
|
+
return stored
|
|
217
|
+
}),
|
|
218
|
+
readBatch: (filter, afterPosition, limit) =>
|
|
219
|
+
Effect.sync(() => {
|
|
220
|
+
const source = candidates(filter)
|
|
221
|
+
const batch: Array<StoredEvent> = []
|
|
222
|
+
for (let index = firstAfter(source, afterPosition); index < source.length && batch.length < limit; index++) {
|
|
223
|
+
const event = source[index]!
|
|
224
|
+
if (matches(filter, event)) batch.push(event)
|
|
225
|
+
}
|
|
226
|
+
return batch
|
|
227
|
+
}),
|
|
228
|
+
head: Effect.sync(() => events.length)
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
/** Keeps events in process memory. They do not survive a restart. */
|
|
233
|
+
export const layerMemory: Layer.Layer<EventStore, EventStoreError> = Layer.effect(EventStore, makeMemory)
|