effect-cqrs 0.0.0-stage → 0.2.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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +326 -2
  3. package/dist/Aggregate.d.ts +127 -0
  4. package/dist/Aggregate.d.ts.map +1 -0
  5. package/dist/Aggregate.js +59 -0
  6. package/dist/Aggregate.js.map +1 -0
  7. package/dist/AggregateService.d.ts +154 -0
  8. package/dist/AggregateService.d.ts.map +1 -0
  9. package/dist/AggregateService.js +407 -0
  10. package/dist/AggregateService.js.map +1 -0
  11. package/dist/Causation.d.ts +34 -0
  12. package/dist/Causation.d.ts.map +1 -0
  13. package/dist/Causation.js +32 -0
  14. package/dist/Causation.js.map +1 -0
  15. package/dist/Checkpoints.d.ts +19 -0
  16. package/dist/Checkpoints.d.ts.map +1 -0
  17. package/dist/Checkpoints.js +18 -0
  18. package/dist/Checkpoints.js.map +1 -0
  19. package/dist/EventStore.d.ts +132 -0
  20. package/dist/EventStore.d.ts.map +1 -0
  21. package/dist/EventStore.js +156 -0
  22. package/dist/EventStore.js.map +1 -0
  23. package/dist/EventVersions.d.ts +64 -0
  24. package/dist/EventVersions.d.ts.map +1 -0
  25. package/dist/EventVersions.js +55 -0
  26. package/dist/EventVersions.js.map +1 -0
  27. package/dist/Fixture.d.ts +106 -0
  28. package/dist/Fixture.d.ts.map +1 -0
  29. package/dist/Fixture.js +149 -0
  30. package/dist/Fixture.js.map +1 -0
  31. package/dist/Metadata.d.ts +42 -0
  32. package/dist/Metadata.d.ts.map +1 -0
  33. package/dist/Metadata.js +39 -0
  34. package/dist/Metadata.js.map +1 -0
  35. package/dist/PostgresEventStore.d.ts +30 -0
  36. package/dist/PostgresEventStore.d.ts.map +1 -0
  37. package/dist/PostgresEventStore.js +153 -0
  38. package/dist/PostgresEventStore.js.map +1 -0
  39. package/dist/Saga.d.ts +105 -0
  40. package/dist/Saga.d.ts.map +1 -0
  41. package/dist/Saga.js +97 -0
  42. package/dist/Saga.js.map +1 -0
  43. package/dist/SqliteEventStore.d.ts +25 -0
  44. package/dist/SqliteEventStore.d.ts.map +1 -0
  45. package/dist/SqliteEventStore.js +142 -0
  46. package/dist/SqliteEventStore.js.map +1 -0
  47. package/dist/ViewModel.d.ts +90 -0
  48. package/dist/ViewModel.d.ts.map +1 -0
  49. package/dist/ViewModel.js +172 -0
  50. package/dist/ViewModel.js.map +1 -0
  51. package/dist/index.d.ts +13 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +13 -0
  54. package/dist/index.js.map +1 -0
  55. package/package.json +89 -3
  56. package/src/Aggregate.ts +205 -0
  57. package/src/AggregateService.ts +705 -0
  58. package/src/Causation.ts +48 -0
  59. package/src/Checkpoints.ts +23 -0
  60. package/src/EventStore.ts +270 -0
  61. package/src/EventVersions.ts +104 -0
  62. package/src/Fixture.ts +298 -0
  63. package/src/Metadata.ts +52 -0
  64. package/src/PostgresEventStore.ts +199 -0
  65. package/src/Saga.ts +176 -0
  66. package/src/SqliteEventStore.ts +196 -0
  67. package/src/ViewModel.ts +350 -0
  68. package/src/index.ts +12 -0
@@ -0,0 +1,705 @@
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 {
6
+ Array as Arr,
7
+ Cause,
8
+ Context,
9
+ type DateTime,
10
+ Duration,
11
+ Effect,
12
+ Exit,
13
+ FiberSet,
14
+ Metric,
15
+ Option,
16
+ Predicate,
17
+ Schedule,
18
+ Schema,
19
+ type Scope,
20
+ Semaphore,
21
+ Stream
22
+ } from "effect"
23
+ import { KeyValueStore } from "effect/persistence"
24
+ import * as Aggregate from "./Aggregate.ts"
25
+ import { CurrentCausation } from "./Causation.ts"
26
+ import {
27
+ ConcurrencyConflict,
28
+ type EventFilter,
29
+ EventStore,
30
+ type EventStoreError,
31
+ type StoredEvent
32
+ } from "./EventStore.ts"
33
+ import * as EventVersions from "./EventVersions.ts"
34
+ import { CurrentMetadata, Metadata } from "./Metadata.ts"
35
+
36
+ export class UnknownCommand extends Schema.TaggedError<UnknownCommand>()("UnknownCommand", {
37
+ aggregateName: Schema.String,
38
+ type: Schema.String
39
+ }) {}
40
+
41
+ export class InvalidCommand extends Schema.TaggedError<InvalidCommand>()("InvalidCommand", {
42
+ aggregateName: Schema.String,
43
+ type: Schema.String,
44
+ message: Schema.String
45
+ }) {}
46
+
47
+ /**
48
+ * A stored event doesn't match the schema of its version, or was written in a
49
+ * version the aggregate doesn't declare. Old shapes are declared with `EventVersions`.
50
+ */
51
+ export class EventDecodeError extends Schema.TaggedError<EventDecodeError>()("EventDecodeError", {
52
+ aggregateName: Schema.String,
53
+ aggregateId: Schema.String,
54
+ version: Schema.Int,
55
+ type: Schema.String,
56
+ schemaVersion: Schema.Int,
57
+ message: Schema.String
58
+ }) {}
59
+
60
+ export type ReadError = EventStoreError | EventDecodeError
61
+
62
+ /** How a command can fail, on top of the errors its own handler declares. */
63
+ export type CommandFailure = ConcurrencyConflict | InvalidCommand | ReadError
64
+
65
+ export interface CommandResult<Event> {
66
+ readonly aggregateName: string
67
+ readonly aggregateId: string
68
+ /** The stream version after the command. */
69
+ readonly version: number
70
+ /** The store position of the last event, to pass to `ViewModels.caughtUp`. */
71
+ readonly position: number
72
+ readonly events: ReadonlyArray<Event>
73
+ /** Recorded on the events, see `Causation`. */
74
+ readonly correlationId: string
75
+ }
76
+
77
+ export interface HistoryEntry<Event> {
78
+ readonly version: number
79
+ readonly timestamp: DateTime.Utc
80
+ readonly type: string
81
+ readonly correlationId: string | null
82
+ readonly causationId: string | null
83
+ readonly metadata: Metadata
84
+ readonly payload?: Event
85
+ }
86
+
87
+ export interface AggregateService<A extends Aggregate.Any> {
88
+ readonly aggregate: A
89
+ /** One typed function per command. */
90
+ readonly commands: {
91
+ readonly [K in Aggregate.CommandName<A>]: (
92
+ aggregateId: string,
93
+ payload: Aggregate.Payload<A, K>
94
+ ) => Effect.Effect<CommandResult<Aggregate.Event<A>>, Aggregate.CommandError<A, K> | CommandFailure>
95
+ }
96
+ /** Runs a `{ aggregateId, type, payload }` envelope received as JSON from outside the process. */
97
+ readonly dispatch: (
98
+ input: unknown
99
+ ) => Effect.Effect<CommandResult<Aggregate.Event<A>>, Aggregate.CommandError<A> | CommandFailure | UnknownCommand>
100
+ /** Materializes the state on the fly, optionally as it was before `finishTime`. */
101
+ readonly readModel: (
102
+ aggregateId: string,
103
+ options?: { readonly finishTime?: DateTime.Utc | undefined }
104
+ ) => Effect.Effect<Aggregate.State<A>, ReadError>
105
+ /** Lists the events of one aggregate, with their decoded payload when `payload` is set. */
106
+ readonly history: (
107
+ aggregateId: string,
108
+ options?: {
109
+ readonly payload?: boolean | undefined
110
+ readonly startTime?: DateTime.Utc | undefined
111
+ readonly finishTime?: DateTime.Utc | undefined
112
+ }
113
+ ) => Effect.Effect<ReadonlyArray<HistoryEntry<Aggregate.Event<A>>>, ReadError>
114
+ }
115
+
116
+ const CommandEnvelope = Schema.Struct({
117
+ aggregateId: Schema.NonEmptyString,
118
+ type: Schema.String,
119
+ payload: Schema.Unknown,
120
+ /** Continues a correlation started elsewhere, see `Causation`. */
121
+ correlationId: Schema.optional(Schema.String),
122
+ causationId: Schema.optional(Schema.String),
123
+ /** Recorded on top of the current metadata, see `Metadata`. */
124
+ metadata: Schema.optional(Metadata)
125
+ })
126
+
127
+ const versionsOf = (aggregate: Aggregate.Any) =>
128
+ Object.entries(aggregate.versions as Record<string, EventVersions.Any | undefined>).flatMap(([tag, versions]) =>
129
+ versions === undefined ? [] : [[tag, versions] as const]
130
+ )
131
+
132
+ /**
133
+ * Decodes a stored event with the schema of the aggregate it belongs to. An
134
+ * event stored in an older version is upcast to the current one on the way.
135
+ */
136
+ export const makeEventDecoder = <A extends Aggregate.Any>(aggregate: A) => {
137
+ const decodeCurrent = Schema.decodeUnknownEffect(Schema.toCodecJson(aggregate.events))
138
+ const upcasters = new Map(versionsOf(aggregate).map(([tag, versions]) => [tag, {
139
+ current: EventVersions.current(versions),
140
+ decoders: EventVersions.decoders(tag, versions).map((decoder) => Schema.decodeUnknownEffect(decoder))
141
+ }]))
142
+ return (stored: StoredEvent): Effect.Effect<Aggregate.Event<A>, EventDecodeError> => {
143
+ const fail = (message: string) =>
144
+ new EventDecodeError({
145
+ aggregateName: stored.aggregateName,
146
+ aggregateId: stored.aggregateId,
147
+ version: stored.version,
148
+ type: stored.type,
149
+ schemaVersion: stored.schemaVersion,
150
+ message
151
+ })
152
+ const upcaster = upcasters.get(stored.type)
153
+ const current = upcaster?.current ?? 1
154
+ const decode = stored.schemaVersion === current ? decodeCurrent : upcaster?.decoders[stored.schemaVersion - 1]
155
+ if (decode === undefined) {
156
+ return Effect.fail(fail(`${aggregate.name} knows ${stored.type} up to schema version ${current}`))
157
+ }
158
+ return decode(stored.payload).pipe(
159
+ Effect.mapError((error) => fail(error.message))
160
+ ) as Effect.Effect<Aggregate.Event<A>, EventDecodeError>
161
+ }
162
+ }
163
+
164
+ /** Encodes events into the JSON form the store keeps, in the current version of their schema. */
165
+ export const makeEventEncoder = <A extends Aggregate.Any>(aggregate: A) => {
166
+ const encode = Schema.encodeUnknownEffect(Schema.toCodecJson(aggregate.events))
167
+ const versions = new Map(versionsOf(aggregate).map(([tag, versions]) => [tag, EventVersions.current(versions)]))
168
+ return (event: Aggregate.Event<A>) =>
169
+ encode(event).pipe(
170
+ // Events come from the aggregate's own handlers, so this is a programming error.
171
+ Effect.orDie,
172
+ Effect.map((payload) => ({
173
+ type: event._tag,
174
+ payload: payload as Schema.Json,
175
+ schemaVersion: versions.get(event._tag) ?? 1
176
+ }))
177
+ )
178
+ }
179
+
180
+ /** Saves the state of each aggregate every so often, so a cold start replays only what came after. */
181
+ export interface SnapshotOptions<State> {
182
+ /** How many events to fold after a snapshot before saving the next one. */
183
+ readonly every: number
184
+ /** How the state is stored. It is encoded as JSON. */
185
+ readonly schema: Schema.Codec<State, any, never, never>
186
+ /**
187
+ * Part of every key. Change it whenever the state or the projection changes,
188
+ * so that snapshots of the old state are ignored and rebuilt from the log.
189
+ * Defaults to `1`.
190
+ */
191
+ readonly version?: string | number | undefined
192
+ }
193
+
194
+ export interface Options {
195
+ /** How many times a command is re-decided after losing a race. Defaults to 5. */
196
+ readonly maxRetries?: number | undefined
197
+ /** How many aggregates keep their state in memory between commands. Defaults to 10,000. */
198
+ readonly stateCacheSize?: number | undefined
199
+ }
200
+
201
+ interface Loaded<State> {
202
+ readonly state: State
203
+ readonly version: number
204
+ readonly position: number
205
+ /** The version of the last snapshot saved or loaded, `0` when there is none. */
206
+ readonly snapshotVersion: number
207
+ }
208
+
209
+ /**
210
+ * The names of the metrics every aggregate service records through Effect's
211
+ * `Metric` module. Each series carries an `aggregate` attribute with the
212
+ * aggregate's name. Read them with `Metric.snapshot`, or send them on with an
213
+ * exporter such as `OtlpMetrics` from `effect/observability`.
214
+ */
215
+ export const MetricNames = {
216
+ /**
217
+ * Timer: how long a command took, including its wait for an earlier command on
218
+ * the same stream, by `command` and `outcome`. The outcome is `success`, the tag
219
+ * of the error, `defect` or `interrupted`. A payload that doesn't decode, or a
220
+ * type the aggregate doesn't know, fails before this point and isn't recorded.
221
+ */
222
+ commandDuration: "cqrs_command_duration",
223
+ /**
224
+ * Counter: where loading the current state found a state to start from, by
225
+ * `source`: `memory`, `snapshot`, or `replay` when it starts from the first event.
226
+ */
227
+ stateLoads: "cqrs_state_loads_total",
228
+ /** Histogram: how many events a load read from the store on top of that state. */
229
+ eventsReplayed: "cqrs_events_replayed",
230
+ /** Gauge: how many states are remembered in memory. */
231
+ stateCacheSize: "cqrs_state_cache_size",
232
+ /** Counter: states dropped from memory to make room for others, see `stateCacheSize`. */
233
+ stateCacheEvictions: "cqrs_state_cache_evictions_total",
234
+ /** Counter: snapshots saved, by `result`: `success` or `failure`. */
235
+ snapshotSaves: "cqrs_snapshot_saves_total",
236
+ /** Counter: snapshots that couldn't be read, so the stream was replayed instead. */
237
+ snapshotReadFailures: "cqrs_snapshot_read_failures_total",
238
+ /** Counter: appends that lost a race with a writer in another process. Each is retried until `maxRetries` runs out. */
239
+ concurrencyConflicts: "cqrs_concurrency_conflicts_total"
240
+ } as const
241
+
242
+ /**
243
+ * Builds each series once with its attributes: a metric's key is cached, while
244
+ * `Metric.withAttributes` would compute it again on every update.
245
+ */
246
+ const makeMetrics = (aggregateName: string) => {
247
+ const attributes = { aggregate: aggregateName }
248
+ const counter = (name: string, description: string, extra?: Record<string, string>) =>
249
+ Metric.counter(name, { description, incremental: true, attributes: { ...attributes, ...extra } })
250
+ const loads = (source: "memory" | "snapshot" | "replay") =>
251
+ counter(MetricNames.stateLoads, "Where loading an aggregate found a state to start from", { source })
252
+ const saves = (result: "success" | "failure") =>
253
+ counter(MetricNames.snapshotSaves, "Snapshots saved", { result })
254
+ const durations = new Map<string, Map<string, Metric.Metric<Duration.Duration, unknown>>>()
255
+ return {
256
+ loadedFromMemory: loads("memory"),
257
+ loadedFromSnapshot: loads("snapshot"),
258
+ loadedFromReplay: loads("replay"),
259
+ eventsReplayed: Metric.histogram(MetricNames.eventsReplayed, {
260
+ description: "Events read from the store to bring a state up to date",
261
+ attributes,
262
+ boundaries: [0, 1, 5, 10, 50, 100, 500, 1000, 5000, 10000]
263
+ }),
264
+ cacheSize: Metric.gauge(MetricNames.stateCacheSize, { description: "States remembered in memory", attributes }),
265
+ evictions: counter(MetricNames.stateCacheEvictions, "States dropped from memory to make room"),
266
+ snapshotSaved: saves("success"),
267
+ snapshotFailed: saves("failure"),
268
+ snapshotReadFailures: counter(MetricNames.snapshotReadFailures, "Snapshots that couldn't be read"),
269
+ conflicts: counter(MetricNames.concurrencyConflicts, "Appends that lost a race with another writer"),
270
+ duration: (command: string, outcome: string) => {
271
+ let byOutcome = durations.get(command)
272
+ if (byOutcome === undefined) durations.set(command, byOutcome = new Map())
273
+ let timer = byOutcome.get(outcome)
274
+ if (timer === undefined) {
275
+ timer = Metric.timer(MetricNames.commandDuration, {
276
+ description: "How long commands take",
277
+ attributes: { ...attributes, command, outcome }
278
+ })
279
+ byOutcome.set(outcome, timer)
280
+ }
281
+ return timer
282
+ }
283
+ }
284
+ }
285
+
286
+ type Metrics = ReturnType<typeof makeMetrics>
287
+
288
+ /** Counts one occurrence and returns `value`, in a single step rather than an effect per update. */
289
+ const recording = <X>(counter: Metric.Counter<number>, value: X): Effect.Effect<X> =>
290
+ Effect.contextWith((context: Context.Context<never>) => {
291
+ counter.updateUnsafe(1, context)
292
+ return Effect.succeed(value)
293
+ })
294
+
295
+ const outcomeOf = (exit: Exit.Exit<unknown, unknown>): string => {
296
+ if (Exit.isSuccess(exit)) return "success"
297
+ const error = Cause.findErrorOption(exit.cause)
298
+ if (Option.isSome(error)) {
299
+ const value = error.value
300
+ return Predicate.hasProperty(value, "_tag") && Predicate.isString(value._tag) ? value._tag : "error"
301
+ }
302
+ return Cause.hasInterruptsOnly(exit.cause) ? "interrupted" : "defect"
303
+ }
304
+
305
+ /** Remembers recent states, dropping the least recently used. A `Map` iterates in insertion order. */
306
+ const makeStateCache = <State>(capacity: number) => {
307
+ const entries = new Map<string, Loaded<State>>()
308
+ return {
309
+ size: () => entries.size,
310
+ get: (aggregateId: string) => {
311
+ const entry = entries.get(aggregateId)
312
+ if (entry !== undefined) {
313
+ entries.delete(aggregateId)
314
+ entries.set(aggregateId, entry)
315
+ }
316
+ return entry
317
+ },
318
+ /** Returns whether another state was dropped to make room. */
319
+ set: (aggregateId: string, loaded: Loaded<State>): boolean => {
320
+ if (capacity === 0 || (entries.get(aggregateId)?.position ?? 0) > loaded.position) return false
321
+ entries.delete(aggregateId)
322
+ entries.set(aggregateId, loaded)
323
+ if (entries.size <= capacity) return false
324
+ entries.delete(entries.keys().next().value!)
325
+ return true
326
+ }
327
+ }
328
+ }
329
+
330
+ /** Runs effects with the same key one at a time. A key only exists while it is in use. */
331
+ const makeKeyedLock = () => {
332
+ const locks = new Map<string, { readonly semaphore: Semaphore.Semaphore; users: number }>()
333
+ return (key: string) => <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
334
+ Effect.acquireUseRelease(
335
+ Effect.sync(() => {
336
+ const lock = locks.get(key) ?? { semaphore: Semaphore.makeUnsafe(1), users: 0 }
337
+ lock.users++
338
+ locks.set(key, lock)
339
+ return lock
340
+ }),
341
+ (lock) => lock.semaphore.withPermit(effect),
342
+ (lock) =>
343
+ Effect.sync(() => {
344
+ if (--lock.users === 0) locks.delete(key)
345
+ })
346
+ )
347
+ }
348
+
349
+ /**
350
+ * Gives other fibers a turn once in a while. `Effect.forEach` with `concurrency`
351
+ * carries on as long as items don't suspend, and a store on `node:sqlite` never does,
352
+ * so a bulk run would finish before any view model ran. Yielding per command is
353
+ * expensive, so it only happens when a millisecond has passed, using real time
354
+ * because this is about the CPU. The clock restarts after the yield rather than
355
+ * before it, so commands started meanwhile suspend too and the loop lets go.
356
+ */
357
+ const makeTimeSlice = () => {
358
+ let started = performance.now()
359
+ return Effect.suspend(() =>
360
+ performance.now() - started < 1
361
+ ? Effect.void
362
+ : Effect.andThen(Effect.yieldNow, Effect.sync(() => void (started = performance.now())))
363
+ )
364
+ }
365
+
366
+ // Only a rival outside this service can cause a conflict, so give it room to finish.
367
+ const conflictBackoff = Schedule.exponential("10 millis").pipe(Schedule.jittered)
368
+
369
+ const makeSnapshots = <State>(aggregateName: string, options: SnapshotOptions<State>, metrics: Metrics) =>
370
+ Effect.gen(function*() {
371
+ const store = KeyValueStore.toSchemaStore(
372
+ yield* KeyValueStore.KeyValueStore,
373
+ Schema.Struct({ version: Schema.Int, position: Schema.Int, state: options.schema })
374
+ )
375
+ const key = (aggregateId: string) =>
376
+ `effect-cqrs/snapshot/${JSON.stringify([aggregateName, String(options.version ?? 1), aggregateId])}`
377
+
378
+ // A snapshot is only a shortcut, so a failure to read or save one falls back to the log.
379
+ const get = (aggregateId: string) =>
380
+ store.get(key(aggregateId)).pipe(
381
+ Effect.map(Option.map((snapshot): Loaded<State> => ({ ...snapshot, snapshotVersion: snapshot.version }))),
382
+ Effect.catch((error) =>
383
+ Metric.update(metrics.snapshotReadFailures, 1).pipe(
384
+ Effect.andThen(Effect.logWarning(`Ignoring the ${aggregateName} snapshot of ${aggregateId}`, error)),
385
+ Effect.as(Option.none<Loaded<State>>())
386
+ )
387
+ )
388
+ )
389
+
390
+ // Saves run beside the commands rather than in their way. Closing the service
391
+ // waits for the ones still running, so a clean shutdown keeps its last snapshots.
392
+ const saving = yield* FiberSet.make<void>()
393
+ yield* Effect.addFinalizer(() => FiberSet.awaitEmpty(saving))
394
+
395
+ // Starts a save once `every` events have been folded since the last snapshot, and
396
+ // returns the state to remember. A failed save is not retried until `every` more.
397
+ const save = (aggregateId: string, loaded: Loaded<State>): Effect.Effect<Loaded<State>> =>
398
+ loaded.version - loaded.snapshotVersion < options.every
399
+ ? Effect.succeed(loaded)
400
+ : FiberSet.run(
401
+ saving,
402
+ store.set(key(aggregateId), { version: loaded.version, position: loaded.position, state: loaded.state }).pipe(
403
+ Effect.andThen(Metric.update(metrics.snapshotSaved, 1)),
404
+ Effect.catch((error) =>
405
+ Effect.andThen(
406
+ Metric.update(metrics.snapshotFailed, 1),
407
+ Effect.logWarning(`Could not save the ${aggregateName} snapshot of ${aggregateId}`, error)
408
+ )
409
+ )
410
+ )
411
+ ).pipe(Effect.as({ ...loaded, snapshotVersion: loaded.version }))
412
+
413
+ return { get, save }
414
+ })
415
+
416
+ const makeService = Effect.fnUntraced(function*<A extends Aggregate.Any>(
417
+ aggregate: A,
418
+ options?: Options & { readonly snapshots?: SnapshotOptions<Aggregate.State<A>> | undefined }
419
+ ) {
420
+ const store = yield* EventStore
421
+ const context = yield* Effect.context<Aggregate.Context<A>>()
422
+ // Metadata provided where the service was built, such as the software version,
423
+ // reaches commands run where that layer isn't visible.
424
+ const builtWith = Context.get(context as Context.Context<never>, CurrentMetadata)
425
+ const mergeMetadata = (current: Metadata, given: Metadata | undefined): Metadata =>
426
+ current === builtWith && given === undefined ? builtWith : { ...builtWith, ...current, ...given }
427
+ const maxRetries = options?.maxRetries ?? 5
428
+ const aggregateName = aggregate.name
429
+ const decodeEvent = makeEventDecoder(aggregate)
430
+ const encodeEvent = makeEventEncoder(aggregate)
431
+ const states = makeStateCache<Aggregate.State<A>>(options?.stateCacheSize ?? 10_000)
432
+ const lockStream = makeKeyedLock()
433
+ const timeSlice = makeTimeSlice()
434
+ const metrics = makeMetrics(aggregateName)
435
+ const snapshots = options?.snapshots === undefined
436
+ ? undefined
437
+ : yield* makeSnapshots<Aggregate.State<A>>(aggregateName, options.snapshots, metrics)
438
+
439
+ const replay = <Acc>(
440
+ stream: Stream.Stream<StoredEvent, EventStoreError>,
441
+ initial: Acc,
442
+ step: (acc: Acc, event: Aggregate.Event<A>, stored: StoredEvent) => Acc
443
+ ) =>
444
+ stream.pipe(
445
+ Stream.runFoldEffect(
446
+ () => initial,
447
+ (acc, stored) => Effect.map(decodeEvent(stored), (event) => step(acc, event, stored))
448
+ )
449
+ )
450
+
451
+ const streamOf = (
452
+ aggregateId: string,
453
+ range?: Pick<EventFilter, "startTime" | "finishTime" | "afterPosition" | "afterVersion">
454
+ ) => store.read({ aggregateNames: [aggregateName], aggregateIds: [aggregateId], ...range })
455
+
456
+ const fold = (
457
+ acc: Loaded<Aggregate.State<A>>,
458
+ event: Aggregate.Event<A>,
459
+ stored: StoredEvent
460
+ ): Loaded<Aggregate.State<A>> => ({
461
+ state: Aggregate.evolve(aggregate, acc.state, event, stored),
462
+ version: stored.version,
463
+ position: stored.position,
464
+ snapshotVersion: acc.snapshotVersion
465
+ })
466
+
467
+ const replayFrom = (
468
+ start: Loaded<Aggregate.State<A>>,
469
+ aggregateId: string,
470
+ finishTime?: DateTime.Utc
471
+ ) =>
472
+ replay(
473
+ streamOf(aggregateId, { finishTime, afterPosition: start.position, afterVersion: start.version }),
474
+ start,
475
+ fold
476
+ )
477
+
478
+ const initial: Loaded<Aggregate.State<A>> = {
479
+ state: aggregate.initial as Aggregate.State<A>,
480
+ version: 0,
481
+ position: 0,
482
+ snapshotVersion: 0
483
+ }
484
+
485
+ // Remembered states come first, as they are the most recent. Without one the
486
+ // last snapshot saves replaying the start of the stream.
487
+ const start = (aggregateId: string): Effect.Effect<Loaded<Aggregate.State<A>>> => {
488
+ const remembered = states.get(aggregateId)
489
+ if (remembered !== undefined) return recording(metrics.loadedFromMemory, remembered)
490
+ if (snapshots === undefined) return recording(metrics.loadedFromReplay, initial)
491
+ return Effect.flatMap(
492
+ snapshots.get(aggregateId),
493
+ Option.match({
494
+ onNone: () => recording(metrics.loadedFromReplay, initial),
495
+ onSome: (snapshot) => recording(metrics.loadedFromSnapshot, snapshot)
496
+ })
497
+ )
498
+ }
499
+
500
+ // Returns the state as remembered, with the version of a snapshot it just started.
501
+ // `read` is how many events the load read from the store, when one did.
502
+ const remember = (aggregateId: string, loaded: Loaded<Aggregate.State<A>>, read?: number) =>
503
+ (snapshots === undefined ? Effect.succeed(loaded) : snapshots.save(aggregateId, loaded)).pipe(
504
+ Effect.tap((remembered) =>
505
+ Effect.contextWith((context: Context.Context<never>) => {
506
+ const evicted = states.set(aggregateId, remembered)
507
+ if (read !== undefined) metrics.eventsReplayed.updateUnsafe(read, context)
508
+ metrics.cacheSize.updateUnsafe(states.size(), context)
509
+ if (evicted) metrics.evictions.updateUnsafe(1, context)
510
+ return Effect.void
511
+ })
512
+ )
513
+ )
514
+
515
+ // Streams only grow, so a remembered state plus what came after it is the current
516
+ // state, even if another process wrote those events. Versions have no gaps, so the
517
+ // difference between two is the number of events read.
518
+ const load = (aggregateId: string, finishTime?: DateTime.Utc) =>
519
+ finishTime === undefined
520
+ ? Effect.flatMap(start(aggregateId), (from) =>
521
+ Effect.flatMap(
522
+ replayFrom(from, aggregateId),
523
+ (loaded) => remember(aggregateId, loaded, loaded.version - from.version)
524
+ ))
525
+ : replayFrom(initial, aggregateId, finishTime)
526
+
527
+ // Measures from the moment the command asks for its turn on the stream.
528
+ const timed = (type: string) => <X, E, R>(effect: Effect.Effect<X, E, R>): Effect.Effect<X, E, R> =>
529
+ Effect.clockWith((clock) => {
530
+ const started = clock.currentTimeNanosUnsafe()
531
+ return Effect.onExit(effect, (exit) =>
532
+ Effect.contextWith((context: Context.Context<never>) => {
533
+ const elapsed = Duration.nanos(clock.currentTimeNanosUnsafe() - started)
534
+ metrics.duration(type, outcomeOf(exit)).updateUnsafe(elapsed, context)
535
+ return Effect.void
536
+ }))
537
+ })
538
+
539
+ // Folds what a command just wrote into the remembered state, so the next command
540
+ // finds nothing new to read. The events are decoded from what was stored, as a
541
+ // replay would, so the state is the one a restart would rebuild. If they don't
542
+ // decode, the state isn't remembered and the next load reports the error.
543
+ const rememberWritten = (aggregateId: string, before: Loaded<Aggregate.State<A>>, written: ReadonlyArray<StoredEvent>) =>
544
+ Effect.forEach(written, decodeEvent).pipe(
545
+ Effect.flatMap((events) =>
546
+ remember(aggregateId, events.reduce((acc, event, index) => fold(acc, event, written[index]!), before))
547
+ ),
548
+ Effect.ignore
549
+ )
550
+
551
+ const decide = Effect.fnUntraced(function*(
552
+ type: string,
553
+ aggregateId: string,
554
+ payload: unknown,
555
+ causation: { readonly correlationId: string; readonly causationId: string },
556
+ metadata: Metadata
557
+ ) {
558
+ const loaded = yield* load(aggregateId)
559
+ const { position, state, version } = loaded
560
+ const { handler } = aggregate.commands[type]!
561
+ const events: ReadonlyArray<Aggregate.Event<A>> = Arr.ensure(yield* handler(state, payload, { aggregateId, version }))
562
+ const { correlationId } = causation
563
+ if (events.length === 0) {
564
+ return { aggregateName, aggregateId, version, position, events, correlationId }
565
+ }
566
+ const stored = yield* store.append({
567
+ aggregateName,
568
+ aggregateId,
569
+ expectedVersion: version,
570
+ events: yield* Effect.forEach(events, encodeEvent),
571
+ ...causation,
572
+ metadata
573
+ })
574
+ yield* rememberWritten(aggregateId, loaded, stored)
575
+ const last = stored[stored.length - 1]!
576
+ return { aggregateName, aggregateId, version: last.version, position: last.position, events, correlationId }
577
+ })
578
+
579
+ // Commands on one stream take turns, so they can't race each other and only a
580
+ // rival in another process causes a conflict. A handler must not run a command on
581
+ // its own stream, which would wait for itself.
582
+ const execute = (
583
+ type: string,
584
+ aggregateId: string,
585
+ payload: unknown,
586
+ given?: {
587
+ readonly correlationId?: string | undefined
588
+ readonly causationId?: string | undefined
589
+ readonly metadata?: Metadata | undefined
590
+ }
591
+ ) =>
592
+ // Read outside the service's own context, which would hide the caller's.
593
+ Effect.contextWith((callerContext: Context.Context<never>) => {
594
+ const current = Context.get(callerContext, CurrentCausation)
595
+ const correlationId = given?.correlationId ?? current?.correlationId
596
+ const causationId = given?.causationId ?? current?.causationId
597
+ // Without a cause the command starts its own correlation.
598
+ const id = correlationId ?? causationId ?? crypto.randomUUID()
599
+ const causation = { correlationId: correlationId ?? id, causationId: causationId ?? id }
600
+ const metadata = mergeMetadata(Context.get(callerContext, CurrentMetadata), given?.metadata)
601
+ return decide(type, aggregateId, payload, causation, metadata).pipe(
602
+ Effect.tapError((error) =>
603
+ error instanceof ConcurrencyConflict ? Metric.update(metrics.conflicts, 1) : Effect.void
604
+ ),
605
+ Effect.retry({
606
+ times: maxRetries,
607
+ while: (error) => error instanceof ConcurrencyConflict,
608
+ schedule: conflictBackoff
609
+ }),
610
+ Effect.provideContext(context),
611
+ lockStream(aggregateId),
612
+ (decided) => Effect.andThen(timeSlice, decided),
613
+ timed(type),
614
+ Effect.withSpan(`${aggregateName}.${type}`, {
615
+ attributes: { "aggregate.id": aggregateId, "cqrs.correlation_id": causation.correlationId }
616
+ })
617
+ )
618
+ })
619
+
620
+ const decodePayload = (type: string, schema: Aggregate.PayloadSchema, input: unknown) =>
621
+ Schema.decodeUnknownEffect(schema)(input).pipe(
622
+ Effect.mapError((error) => new InvalidCommand({ aggregateName, type, message: error.message }))
623
+ )
624
+
625
+ const commands = Object.fromEntries(
626
+ Object.entries(aggregate.commands).map(([type, command]) => [
627
+ type,
628
+ (aggregateId: string, payload: unknown) =>
629
+ decodePayload(type, Schema.toType(command.payload), payload).pipe(
630
+ Effect.flatMap((decoded) => execute(type, aggregateId, decoded))
631
+ )
632
+ ])
633
+ )
634
+
635
+ const decodeEnvelope = Schema.decodeUnknownEffect(CommandEnvelope)
636
+
637
+ const dispatch = (input: unknown) =>
638
+ Effect.gen(function*() {
639
+ const envelope = yield* decodeEnvelope(input).pipe(
640
+ Effect.mapError((error) => new InvalidCommand({ aggregateName, type: "unknown", message: error.message }))
641
+ )
642
+ if (!Object.hasOwn(aggregate.commands, envelope.type)) {
643
+ return yield* new UnknownCommand({ aggregateName, type: envelope.type })
644
+ }
645
+ const payload = yield* decodePayload(
646
+ envelope.type,
647
+ Schema.toCodecJson(aggregate.commands[envelope.type]!.payload),
648
+ envelope.payload
649
+ )
650
+ return yield* execute(envelope.type, envelope.aggregateId, payload, envelope)
651
+ })
652
+
653
+ const readModel: AggregateService<A>["readModel"] = (aggregateId, options) =>
654
+ load(aggregateId, options?.finishTime).pipe(
655
+ Effect.map(({ state }) => state),
656
+ Effect.withSpan(`${aggregateName}.readModel`, { attributes: { "aggregate.id": aggregateId } })
657
+ )
658
+
659
+ const history: AggregateService<A>["history"] = (aggregateId, options) =>
660
+ replay(
661
+ streamOf(aggregateId, { startTime: options?.startTime, finishTime: options?.finishTime }),
662
+ [] as Array<HistoryEntry<Aggregate.Event<A>>>,
663
+ (entries, event, stored) => {
664
+ entries.push({
665
+ version: stored.version,
666
+ timestamp: stored.timestamp,
667
+ type: stored.type,
668
+ correlationId: stored.correlationId,
669
+ causationId: stored.causationId,
670
+ metadata: stored.metadata,
671
+ ...(options?.payload === true ? { payload: event } : {})
672
+ })
673
+ return entries
674
+ }
675
+ ).pipe(Effect.withSpan(`${aggregateName}.history`, { attributes: { "aggregate.id": aggregateId } }))
676
+
677
+ return {
678
+ aggregate,
679
+ commands,
680
+ dispatch,
681
+ readModel,
682
+ history
683
+ } as unknown as AggregateService<A>
684
+ })
685
+
686
+ /**
687
+ * Builds the service for one aggregate. With `snapshots` it saves states through
688
+ * a `KeyValueStore` from `effect/persistence`, which has layers for memory, files,
689
+ * SQL and the browser's `localStorage`. Saves run in the background, and closing
690
+ * the scope waits for the ones still running.
691
+ */
692
+ export const make: {
693
+ <A extends Aggregate.Any>(
694
+ aggregate: A,
695
+ options: Options & { readonly snapshots: SnapshotOptions<Aggregate.State<A>> }
696
+ ): Effect.Effect<
697
+ AggregateService<A>,
698
+ never,
699
+ EventStore | KeyValueStore.KeyValueStore | Scope.Scope | Aggregate.Context<A>
700
+ >
701
+ <A extends Aggregate.Any>(
702
+ aggregate: A,
703
+ options?: Options
704
+ ): Effect.Effect<AggregateService<A>, never, EventStore | Aggregate.Context<A>>
705
+ } = makeService as any