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.
- package/README.md +34 -6
- package/dist/Aggregate.d.ts +47 -6
- package/dist/Aggregate.d.ts.map +1 -1
- package/dist/Aggregate.js +45 -1
- package/dist/Aggregate.js.map +1 -1
- package/dist/AggregateService.d.ts +83 -11
- package/dist/AggregateService.d.ts.map +1 -1
- package/dist/AggregateService.js +261 -35
- package/dist/AggregateService.js.map +1 -1
- package/dist/Causation.d.ts +34 -0
- package/dist/Causation.d.ts.map +1 -0
- package/dist/Causation.js +32 -0
- package/dist/Causation.js.map +1 -0
- package/dist/EventStore.d.ts +22 -0
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +30 -10
- package/dist/EventStore.js.map +1 -1
- package/dist/EventVersions.d.ts +64 -0
- package/dist/EventVersions.d.ts.map +1 -0
- package/dist/EventVersions.js +55 -0
- package/dist/EventVersions.js.map +1 -0
- package/dist/Fixture.d.ts +40 -5
- package/dist/Fixture.d.ts.map +1 -1
- package/dist/Fixture.js +44 -3
- package/dist/Fixture.js.map +1 -1
- package/dist/Metadata.d.ts +42 -0
- package/dist/Metadata.d.ts.map +1 -0
- package/dist/Metadata.js +39 -0
- package/dist/Metadata.js.map +1 -0
- package/dist/PostgresEventStore.d.ts.map +1 -1
- package/dist/PostgresEventStore.js +22 -13
- package/dist/PostgresEventStore.js.map +1 -1
- package/dist/Saga.d.ts +105 -0
- package/dist/Saga.d.ts.map +1 -0
- package/dist/Saga.js +97 -0
- package/dist/Saga.js.map +1 -0
- package/dist/SqliteEventStore.d.ts.map +1 -1
- package/dist/SqliteEventStore.js +32 -13
- package/dist/SqliteEventStore.js.map +1 -1
- package/dist/ViewModel.d.ts.map +1 -1
- package/dist/ViewModel.js +3 -1
- package/dist/ViewModel.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
- package/src/Aggregate.ts +81 -9
- package/src/AggregateService.ts +418 -55
- package/src/Causation.ts +48 -0
- package/src/EventStore.ts +47 -10
- package/src/EventVersions.ts +104 -0
- package/src/Fixture.ts +92 -7
- package/src/Metadata.ts +52 -0
- package/src/PostgresEventStore.ts +24 -13
- package/src/Saga.ts +176 -0
- package/src/SqliteEventStore.ts +43 -13
- package/src/ViewModel.ts +3 -1
- package/src/index.ts +4 -0
package/src/AggregateService.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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) => ({
|
|
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
|
-
|
|
139
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
244
|
-
Effect.
|
|
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
|
-
|
|
249
|
-
|
|
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 = (
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
package/src/Causation.ts
ADDED
|
@@ -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))
|