effect-cqrs 0.1.0 → 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 (59) hide show
  1. package/README.md +34 -6
  2. package/dist/Aggregate.d.ts +47 -6
  3. package/dist/Aggregate.d.ts.map +1 -1
  4. package/dist/Aggregate.js +45 -1
  5. package/dist/Aggregate.js.map +1 -1
  6. package/dist/AggregateService.d.ts +83 -11
  7. package/dist/AggregateService.d.ts.map +1 -1
  8. package/dist/AggregateService.js +261 -35
  9. package/dist/AggregateService.js.map +1 -1
  10. package/dist/Causation.d.ts +34 -0
  11. package/dist/Causation.d.ts.map +1 -0
  12. package/dist/Causation.js +32 -0
  13. package/dist/Causation.js.map +1 -0
  14. package/dist/EventStore.d.ts +22 -0
  15. package/dist/EventStore.d.ts.map +1 -1
  16. package/dist/EventStore.js +30 -10
  17. package/dist/EventStore.js.map +1 -1
  18. package/dist/EventVersions.d.ts +64 -0
  19. package/dist/EventVersions.d.ts.map +1 -0
  20. package/dist/EventVersions.js +55 -0
  21. package/dist/EventVersions.js.map +1 -0
  22. package/dist/Fixture.d.ts +40 -5
  23. package/dist/Fixture.d.ts.map +1 -1
  24. package/dist/Fixture.js +44 -3
  25. package/dist/Fixture.js.map +1 -1
  26. package/dist/Metadata.d.ts +42 -0
  27. package/dist/Metadata.d.ts.map +1 -0
  28. package/dist/Metadata.js +39 -0
  29. package/dist/Metadata.js.map +1 -0
  30. package/dist/PostgresEventStore.d.ts.map +1 -1
  31. package/dist/PostgresEventStore.js +22 -13
  32. package/dist/PostgresEventStore.js.map +1 -1
  33. package/dist/Saga.d.ts +105 -0
  34. package/dist/Saga.d.ts.map +1 -0
  35. package/dist/Saga.js +97 -0
  36. package/dist/Saga.js.map +1 -0
  37. package/dist/SqliteEventStore.d.ts.map +1 -1
  38. package/dist/SqliteEventStore.js +32 -13
  39. package/dist/SqliteEventStore.js.map +1 -1
  40. package/dist/ViewModel.d.ts.map +1 -1
  41. package/dist/ViewModel.js +3 -1
  42. package/dist/ViewModel.js.map +1 -1
  43. package/dist/index.d.ts +4 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +4 -0
  46. package/dist/index.js.map +1 -1
  47. package/package.json +5 -3
  48. package/src/Aggregate.ts +81 -9
  49. package/src/AggregateService.ts +418 -55
  50. package/src/Causation.ts +48 -0
  51. package/src/EventStore.ts +47 -10
  52. package/src/EventVersions.ts +104 -0
  53. package/src/Fixture.ts +92 -7
  54. package/src/Metadata.ts +52 -0
  55. package/src/PostgresEventStore.ts +24 -13
  56. package/src/Saga.ts +176 -0
  57. package/src/SqliteEventStore.ts +43 -13
  58. package/src/ViewModel.ts +3 -1
  59. package/src/index.ts +4 -0
@@ -2,8 +2,27 @@
2
2
  * Runs an aggregate against the {@link EventStore}: the `command`, `read-model`
3
3
  * and `history` actions of moleculer-cqrs, as a typed service.
4
4
  */
5
- import { Array as Arr, type DateTime, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
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"
6
24
  import * as Aggregate from "./Aggregate.ts"
25
+ import { CurrentCausation } from "./Causation.ts"
7
26
  import {
8
27
  ConcurrencyConflict,
9
28
  type EventFilter,
@@ -11,6 +30,8 @@ import {
11
30
  type EventStoreError,
12
31
  type StoredEvent
13
32
  } from "./EventStore.ts"
33
+ import * as EventVersions from "./EventVersions.ts"
34
+ import { CurrentMetadata, Metadata } from "./Metadata.ts"
14
35
 
15
36
  export class UnknownCommand extends Schema.TaggedError<UnknownCommand>()("UnknownCommand", {
16
37
  aggregateName: Schema.String,
@@ -23,12 +44,16 @@ export class InvalidCommand extends Schema.TaggedError<InvalidCommand>()("Invali
23
44
  message: Schema.String
24
45
  }) {}
25
46
 
26
- /** A stored event no longer matches the schema, which means the data needs a migration. */
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
+ */
27
51
  export class EventDecodeError extends Schema.TaggedError<EventDecodeError>()("EventDecodeError", {
28
52
  aggregateName: Schema.String,
29
53
  aggregateId: Schema.String,
30
54
  version: Schema.Int,
31
55
  type: Schema.String,
56
+ schemaVersion: Schema.Int,
32
57
  message: Schema.String
33
58
  }) {}
34
59
 
@@ -45,12 +70,17 @@ export interface CommandResult<Event> {
45
70
  /** The store position of the last event, to pass to `ViewModels.caughtUp`. */
46
71
  readonly position: number
47
72
  readonly events: ReadonlyArray<Event>
73
+ /** Recorded on the events, see `Causation`. */
74
+ readonly correlationId: string
48
75
  }
49
76
 
50
77
  export interface HistoryEntry<Event> {
51
78
  readonly version: number
52
79
  readonly timestamp: DateTime.Utc
53
80
  readonly type: string
81
+ readonly correlationId: string | null
82
+ readonly causationId: string | null
83
+ readonly metadata: Metadata
54
84
  readonly payload?: Event
55
85
  }
56
86
 
@@ -86,47 +116,197 @@ export interface AggregateService<A extends Aggregate.Any> {
86
116
  const CommandEnvelope = Schema.Struct({
87
117
  aggregateId: Schema.NonEmptyString,
88
118
  type: Schema.String,
89
- payload: Schema.Unknown
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)
90
125
  })
91
126
 
92
- /** Decodes a stored event with the schema of the aggregate it belongs to. */
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
+ */
93
136
  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
- )
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))
106
160
  ) as Effect.Effect<Aggregate.Event<A>, EventDecodeError>
161
+ }
107
162
  }
108
163
 
109
- /** Encodes events into the JSON form the store keeps. */
164
+ /** Encodes events into the JSON form the store keeps, in the current version of their schema. */
110
165
  export const makeEventEncoder = <A extends Aggregate.Any>(aggregate: A) => {
111
166
  const encode = Schema.encodeUnknownEffect(Schema.toCodecJson(aggregate.events))
167
+ const versions = new Map(versionsOf(aggregate).map(([tag, versions]) => [tag, EventVersions.current(versions)]))
112
168
  return (event: Aggregate.Event<A>) =>
113
169
  encode(event).pipe(
114
170
  // Events come from the aggregate's own handlers, so this is a programming error.
115
171
  Effect.orDie,
116
- Effect.map((payload) => ({ type: event._tag, payload: payload as Schema.Json }))
172
+ Effect.map((payload) => ({
173
+ type: event._tag,
174
+ payload: payload as Schema.Json,
175
+ schemaVersion: versions.get(event._tag) ?? 1
176
+ }))
117
177
  )
118
178
  }
119
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
+
120
201
  interface Loaded<State> {
121
202
  readonly state: State
122
203
  readonly version: number
123
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"
124
303
  }
125
304
 
126
305
  /** Remembers recent states, dropping the least recently used. A `Map` iterates in insertion order. */
127
306
  const makeStateCache = <State>(capacity: number) => {
128
307
  const entries = new Map<string, Loaded<State>>()
129
308
  return {
309
+ size: () => entries.size,
130
310
  get: (aggregateId: string) => {
131
311
  const entry = entries.get(aggregateId)
132
312
  if (entry !== undefined) {
@@ -135,11 +315,14 @@ const makeStateCache = <State>(capacity: number) => {
135
315
  }
136
316
  return entry
137
317
  },
138
- set: (aggregateId: string, loaded: Loaded<State>) => {
139
- if (capacity === 0 || (entries.get(aggregateId)?.position ?? 0) > loaded.position) return
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
140
321
  entries.delete(aggregateId)
141
322
  entries.set(aggregateId, loaded)
142
- if (entries.size > capacity) entries.delete(entries.keys().next().value!)
323
+ if (entries.size <= capacity) return false
324
+ entries.delete(entries.keys().next().value!)
325
+ return true
143
326
  }
144
327
  }
145
328
  }
@@ -183,17 +366,64 @@ const makeTimeSlice = () => {
183
366
  // Only a rival outside this service can cause a conflict, so give it room to finish.
184
367
  const conflictBackoff = Schedule.exponential("10 millis").pipe(Schedule.jittered)
185
368
 
186
- export const make = Effect.fnUntraced(function*<A extends Aggregate.Any>(
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>(
187
417
  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
- }
418
+ options?: Options & { readonly snapshots?: SnapshotOptions<Aggregate.State<A>> | undefined }
194
419
  ) {
195
420
  const store = yield* EventStore
196
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 }
197
427
  const maxRetries = options?.maxRetries ?? 5
198
428
  const aggregateName = aggregate.name
199
429
  const decodeEvent = makeEventDecoder(aggregate)
@@ -201,6 +431,10 @@ export const make = Effect.fnUntraced(function*<A extends Aggregate.Any>(
201
431
  const states = makeStateCache<Aggregate.State<A>>(options?.stateCacheSize ?? 10_000)
202
432
  const lockStream = makeKeyedLock()
203
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)
204
438
 
205
439
  const replay = <Acc>(
206
440
  stream: Stream.Stream<StoredEvent, EventStoreError>,
@@ -219,6 +453,17 @@ export const make = Effect.fnUntraced(function*<A extends Aggregate.Any>(
219
453
  range?: Pick<EventFilter, "startTime" | "finishTime" | "afterPosition" | "afterVersion">
220
454
  ) => store.read({ aggregateNames: [aggregateName], aggregateIds: [aggregateId], ...range })
221
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
+
222
467
  const replayFrom = (
223
468
  start: Loaded<Aggregate.State<A>>,
224
469
  aggregateId: string,
@@ -227,56 +472,150 @@ export const make = Effect.fnUntraced(function*<A extends Aggregate.Any>(
227
472
  replay(
228
473
  streamOf(aggregateId, { finishTime, afterPosition: start.position, afterVersion: start.version }),
229
474
  start,
230
- (acc, event, stored) => ({
231
- state: Aggregate.evolve(aggregate, acc.state, event, stored),
232
- version: stored.version,
233
- position: stored.position
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)
234
496
  })
235
497
  )
498
+ }
236
499
 
237
- const initial: Loaded<Aggregate.State<A>> = { state: aggregate.initial as Aggregate.State<A>, version: 0, position: 0 }
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
+ )
238
514
 
239
515
  // Streams only grow, so a remembered state plus what came after it is the current
240
- // state, even if another process wrote those events.
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.
241
518
  const load = (aggregateId: string, finishTime?: DateTime.Utc) =>
242
519
  finishTime === undefined
243
- ? replayFrom(states.get(aggregateId) ?? initial, aggregateId).pipe(
244
- Effect.tap((loaded) => Effect.sync(() => states.set(aggregateId, loaded)))
245
- )
520
+ ? Effect.flatMap(start(aggregateId), (from) =>
521
+ Effect.flatMap(
522
+ replayFrom(from, aggregateId),
523
+ (loaded) => remember(aggregateId, loaded, loaded.version - from.version)
524
+ ))
246
525
  : replayFrom(initial, aggregateId, finishTime)
247
526
 
248
- const decide = Effect.fnUntraced(function*(type: string, aggregateId: string, payload: unknown) {
249
- const { position, state, version } = yield* load(aggregateId)
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
250
560
  const { handler } = aggregate.commands[type]!
251
561
  const events: ReadonlyArray<Aggregate.Event<A>> = Arr.ensure(yield* handler(state, payload, { aggregateId, version }))
562
+ const { correlationId } = causation
252
563
  if (events.length === 0) {
253
- return { aggregateName, aggregateId, version, position, events }
564
+ return { aggregateName, aggregateId, version, position, events, correlationId }
254
565
  }
255
566
  const stored = yield* store.append({
256
567
  aggregateName,
257
568
  aggregateId,
258
569
  expectedVersion: version,
259
- events: yield* Effect.forEach(events, encodeEvent)
570
+ events: yield* Effect.forEach(events, encodeEvent),
571
+ ...causation,
572
+ metadata
260
573
  })
574
+ yield* rememberWritten(aggregateId, loaded, stored)
261
575
  const last = stored[stored.length - 1]!
262
- return { aggregateName, aggregateId, version: last.version, position: last.position, events }
576
+ return { aggregateName, aggregateId, version: last.version, position: last.position, events, correlationId }
263
577
  })
264
578
 
265
579
  // Commands on one stream take turns, so they can't race each other and only a
266
580
  // rival in another process causes a conflict. A handler must not run a command on
267
581
  // 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
- )
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
+ })
280
619
 
281
620
  const decodePayload = (type: string, schema: Aggregate.PayloadSchema, input: unknown) =>
282
621
  Schema.decodeUnknownEffect(schema)(input).pipe(
@@ -308,7 +647,7 @@ export const make = Effect.fnUntraced(function*<A extends Aggregate.Any>(
308
647
  Schema.toCodecJson(aggregate.commands[envelope.type]!.payload),
309
648
  envelope.payload
310
649
  )
311
- return yield* execute(envelope.type, envelope.aggregateId, payload)
650
+ return yield* execute(envelope.type, envelope.aggregateId, payload, envelope)
312
651
  })
313
652
 
314
653
  const readModel: AggregateService<A>["readModel"] = (aggregateId, options) =>
@@ -326,6 +665,9 @@ export const make = Effect.fnUntraced(function*<A extends Aggregate.Any>(
326
665
  version: stored.version,
327
666
  timestamp: stored.timestamp,
328
667
  type: stored.type,
668
+ correlationId: stored.correlationId,
669
+ causationId: stored.causationId,
670
+ metadata: stored.metadata,
329
671
  ...(options?.payload === true ? { payload: event } : {})
330
672
  })
331
673
  return entries
@@ -340,3 +682,24 @@ export const make = Effect.fnUntraced(function*<A extends Aggregate.Any>(
340
682
  history
341
683
  } as unknown as AggregateService<A>
342
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
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Why an event was recorded. Every event carries two ids:
3
+ *
4
+ * - `correlationId` is shared by everything that follows from one original
5
+ * request, across aggregates and view models.
6
+ * - `causationId` names what directly caused the command that recorded it: the
7
+ * request, or the event a view model or saga was reacting to.
8
+ *
9
+ * They travel in the fiber's context rather than in payloads. A command run
10
+ * outside any causation starts a new correlation with a fresh id. Inside a view
11
+ * model handler the event being handled is the cause, so a chain of reactions
12
+ * can be drawn as a tree without passing ids around by hand.
13
+ */
14
+ import { Context, Effect, Function } from "effect"
15
+ import { eventId, type StoredEvent } from "./EventStore.ts"
16
+
17
+ export interface Causation {
18
+ readonly correlationId: string
19
+ readonly causationId: string
20
+ }
21
+
22
+ /** The causation of commands run in this fiber, `undefined` outside any. */
23
+ export const CurrentCausation = Context.Reference<Causation | undefined>("effect-cqrs/Causation", {
24
+ defaultValue: () => undefined
25
+ })
26
+
27
+ /** Runs an effect with this causation, for instance the id of an incoming HTTP request. */
28
+ export const withCausation: {
29
+ (causation: Causation): <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
30
+ <A, E, R>(effect: Effect.Effect<A, E, R>, causation: Causation): Effect.Effect<A, E, R>
31
+ } = Function.dual(
32
+ 2,
33
+ <A, E, R>(effect: Effect.Effect<A, E, R>, causation: Causation) =>
34
+ Effect.provideService(effect, CurrentCausation, causation)
35
+ )
36
+
37
+ /** The causation of commands run in reaction to a stored event. */
38
+ export const fromEvent = (event: StoredEvent): Causation => {
39
+ const id = eventId(event)
40
+ return { correlationId: event.correlationId ?? id, causationId: id }
41
+ }
42
+
43
+ /**
44
+ * Runs an effect in reaction to a stored event, so commands it runs record that
45
+ * event as their cause. View models do this for their handlers already.
46
+ */
47
+ export const causedBy = (event: StoredEvent) => <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
48
+ Effect.provideService(effect, CurrentCausation, fromEvent(event))