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
package/src/EventStore.ts CHANGED
@@ -25,14 +25,24 @@ export const StoredEvent = Schema.Struct({
25
25
  aggregateId: Schema.String,
26
26
  version: Schema.Int,
27
27
  type: Schema.String,
28
+ /** The version of the event's schema the payload was written with. Events from before versions existed are `1`. */
29
+ schemaVersion: Schema.Int,
28
30
  payload: Schema.Json,
29
- timestamp: Schema.DateTimeUtcFromMillis
31
+ timestamp: Schema.DateTimeUtcFromMillis,
32
+ /** Shared by everything that follows from one original request. */
33
+ correlationId: Schema.NullOr(Schema.String),
34
+ /** What directly caused the command that recorded the event: a request, a command or another event. */
35
+ causationId: Schema.NullOr(Schema.String),
36
+ /** What the command carried beside its payload, see `Metadata`. Events from before metadata existed have `{}`. */
37
+ metadata: Schema.JsonObject
30
38
  })
31
39
  export type StoredEvent = typeof StoredEvent.Type
32
40
 
33
41
  export interface NewEvent {
34
42
  readonly type: string
35
43
  readonly payload: Schema.Json
44
+ /** Defaults to `1`. */
45
+ readonly schemaVersion?: number | undefined
36
46
  }
37
47
 
38
48
  export interface AppendInput {
@@ -41,8 +51,41 @@ export interface AppendInput {
41
51
  /** The stream version the writer based its decision on, `0` for a new stream. */
42
52
  readonly expectedVersion: number
43
53
  readonly events: ReadonlyArray<NewEvent>
54
+ readonly correlationId?: string | undefined
55
+ readonly causationId?: string | undefined
56
+ /** Recorded on every event of the append. Defaults to `{}`. */
57
+ readonly metadata?: Schema.JsonObject | undefined
44
58
  }
45
59
 
60
+ /**
61
+ * Identifies an event wherever it is stored, as the `causationId` of what it causes:
62
+ * `account/alice@4`. Name and id are URI-encoded, so a `/` or `@` inside one can't
63
+ * make two events look alike.
64
+ */
65
+ export const eventId = (event: Pick<StoredEvent, "aggregateName" | "aggregateId" | "version">): string =>
66
+ `${encodeURIComponent(event.aggregateName)}/${encodeURIComponent(event.aggregateId)}@${event.version}`
67
+
68
+ /** The columns every adapter stores for a new event, with the defaults filled in. */
69
+ export const toStored = (
70
+ input: AppendInput,
71
+ event: NewEvent,
72
+ index: number,
73
+ position: number,
74
+ timestamp: DateTime.Utc
75
+ ): StoredEvent => ({
76
+ position,
77
+ aggregateName: input.aggregateName,
78
+ aggregateId: input.aggregateId,
79
+ version: input.expectedVersion + index + 1,
80
+ type: event.type,
81
+ schemaVersion: event.schemaVersion ?? 1,
82
+ payload: event.payload,
83
+ timestamp,
84
+ correlationId: input.correlationId ?? null,
85
+ causationId: input.causationId ?? null,
86
+ metadata: input.metadata ?? {}
87
+ })
88
+
46
89
  /**
47
90
  * Selects events by stream, type and time. Omitted fields match everything.
48
91
  * `startTime` is inclusive and `finishTime` is exclusive, so adding one
@@ -199,15 +242,9 @@ export const makeMemory: Effect.Effect<EventStore["Service"], EventStoreError> =
199
242
  expectedVersion: input.expectedVersion
200
243
  })
201
244
  }
202
- const stored = input.events.map((event, index): StoredEvent => ({
203
- position: events.length + index + 1,
204
- aggregateName: input.aggregateName,
205
- aggregateId: input.aggregateId,
206
- version: version + index + 1,
207
- type: event.type,
208
- payload: event.payload,
209
- timestamp
210
- }))
245
+ const stored = input.events.map((event, index) =>
246
+ toStored(input, event, index, events.length + index + 1, timestamp)
247
+ )
211
248
  for (const event of stored) {
212
249
  events.push(event)
213
250
  stream.push(event)
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The older shapes of one event, so that events stored long ago still reach
3
+ * projections and view models in today's shape. The log is never rewritten:
4
+ * old payloads are upcast as they are read.
5
+ *
6
+ * ```ts
7
+ * const AccountOpenedV1 = Schema.Struct({ owner: Schema.String })
8
+ *
9
+ * const Account = Aggregate.make<State>()({
10
+ * name: "account",
11
+ * events: AccountEvent,
12
+ * initial,
13
+ * projection,
14
+ * versions: {
15
+ * AccountOpened: EventVersions.from(AccountOpenedV1).next(
16
+ * AccountEvent.cases.AccountOpened,
17
+ * ({ owner }) => ({ owner, currency: "CHF" })
18
+ * )
19
+ * }
20
+ * })
21
+ * ```
22
+ *
23
+ * The first schema is version 1, the shape events had before the history was
24
+ * declared, and every `next` adds a version. The last one must be the event's
25
+ * current schema, which is what new events are written with.
26
+ */
27
+ import { Schema, SchemaGetter } from "effect"
28
+
29
+ /** A schema that decodes and encodes without services, as stored payloads need. */
30
+ export type VersionSchema = Schema.Top & {
31
+ readonly DecodingServices: never
32
+ readonly EncodingServices: never
33
+ }
34
+
35
+ /** What an upcast returns: the next version, without the `_tag` the event's name already gives. */
36
+ export type Untagged<T> = T extends { readonly _tag: string } ? Omit<T, "_tag"> : T
37
+
38
+ type Last<Schemas extends ReadonlyArray<VersionSchema>> = Schemas extends readonly [...any, infer L extends VersionSchema]
39
+ ? L
40
+ : VersionSchema
41
+
42
+ export interface EventVersions<Schemas extends ReadonlyArray<VersionSchema>, Latest> {
43
+ /** Oldest first: `schemas[0]` is version 1. */
44
+ readonly schemas: Schemas
45
+ /** `upcasts[i]` turns version `i + 1` into version `i + 2`. */
46
+ readonly upcasts: ReadonlyArray<(previous: any) => unknown>
47
+ /** The type of the last version, which an aggregate checks against its event. */
48
+ readonly "~latest"?: Latest
49
+ /** Adds a version, with the function that turns the previous one into it. */
50
+ next<Next extends VersionSchema>(
51
+ schema: Next,
52
+ upcast: (previous: Latest) => Untagged<Next["Type"]>
53
+ ): EventVersions<readonly [...Schemas, Next], Next["Type"]>
54
+ }
55
+
56
+ /** Any history whose last version is `Latest`. */
57
+ export type Any<Latest = any> = EventVersions<ReadonlyArray<VersionSchema>, Latest>
58
+
59
+ /** The schema of one version, or `never` when the history has no such version. */
60
+ export type SchemaAt<H extends Any, V extends number> = H extends EventVersions<infer Schemas, any>
61
+ ? [never, ...Schemas][V] extends infer S extends VersionSchema ? S : never
62
+ : never
63
+
64
+ const build = <Schemas extends ReadonlyArray<VersionSchema>>(
65
+ schemas: Schemas,
66
+ upcasts: ReadonlyArray<(previous: any) => unknown>
67
+ ): EventVersions<Schemas, Last<Schemas>["Type"]> => ({
68
+ schemas,
69
+ upcasts,
70
+ next: (schema, upcast) => build([...schemas, schema] as const, [...upcasts, upcast]) as any
71
+ })
72
+
73
+ /** Starts a history at version 1. */
74
+ export const from = <V1 extends VersionSchema>(schema: V1): EventVersions<readonly [V1], V1["Type"]> =>
75
+ build([schema] as const, [])
76
+
77
+ /** The version new events of this kind are written with. */
78
+ export const current = (versions: Any | undefined): number => versions?.schemas.length ?? 1
79
+
80
+ /**
81
+ * Builds one decoder per old version, each going all the way to the current one.
82
+ * The chains are composed once with `Schema.decodeTo`, so reading an old event
83
+ * costs one decode however many versions it is behind.
84
+ *
85
+ * `decoders[v - 1]` decodes a JSON payload stored at version `v`. The current
86
+ * version has none, as the aggregate's own schema reads it.
87
+ */
88
+ export const decoders = (tag: string, versions: Any): ReadonlyArray<Schema.Decoder<unknown>> =>
89
+ versions.schemas.slice(0, -1).map((schema, index) =>
90
+ versions.schemas.slice(index + 1).reduce<Schema.Top>(
91
+ (chain, next, step) =>
92
+ chain.pipe(Schema.decodeTo(Schema.toType(next), {
93
+ // Adding the tag lets the last step decode into the tagged event, and a
94
+ // schema in between without one drops it again.
95
+ decode: SchemaGetter.transform((previous) => ({
96
+ ...(versions.upcasts[index + step]!(previous) as object),
97
+ _tag: tag
98
+ })),
99
+ // A payload is only ever written in its current version.
100
+ encode: SchemaGetter.forbidden(() => `${tag} can only be written in its current version`)
101
+ })),
102
+ Schema.toCodecJson(schema)
103
+ ) as Schema.Decoder<unknown>
104
+ )
package/src/Fixture.ts CHANGED
@@ -13,9 +13,13 @@
13
13
  *
14
14
  * Commands run through the real {@link AggregateService} on an in-memory store,
15
15
  * so payload validation and event encoding are exercised too.
16
+ *
17
+ * The history can include events stored in older versions of their schema, made
18
+ * with `Aggregate.historical`, which then go through the same upcasting as events
19
+ * read from a real store.
16
20
  */
17
- import { Effect, Effectable, Schema } from "effect"
18
- import type * as Aggregate from "./Aggregate.ts"
21
+ import { Arbitrary, Effect, Effectable, Schema } from "effect"
22
+ import * as Aggregate from "./Aggregate.ts"
19
23
  import * as AggregateService from "./AggregateService.ts"
20
24
  import { EventStore, makeMemory } from "./EventStore.ts"
21
25
 
@@ -37,6 +41,30 @@ export interface Outcome<A extends Aggregate.Any> {
37
41
  /** An event to compare with, or only its tag when the rest doesn't matter. */
38
42
  export type ExpectedEvent<A extends Aggregate.Any> = Aggregate.Event<A> | Aggregate.Event<A>["_tag"]
39
43
 
44
+ /** An event in today's shape, or one stored in an older version of its schema. */
45
+ export type GivenEvent<A extends Aggregate.Any> = Aggregate.Event<A> | Aggregate.Historical
46
+
47
+ /** One generated step of {@link Fixture.checkInvariants}. */
48
+ export type Step<A extends Aggregate.Any> = {
49
+ readonly [K in Aggregate.CommandName<A>]: { readonly command: K; readonly payload: Aggregate.Payload<A, K> }
50
+ }[Aggregate.CommandName<A>]
51
+
52
+ export interface InvariantOptions<A extends Aggregate.Any> {
53
+ /** Must hold for the given state and after every command that succeeds. */
54
+ readonly invariant: (state: Aggregate.State<A>) => boolean
55
+ /**
56
+ * How to generate each command's payload. By default it comes from the payload
57
+ * schema. `false` leaves a command out.
58
+ */
59
+ readonly commands?: {
60
+ readonly [K in Aggregate.CommandName<A>]?: Arbitrary.Arbitrary<Aggregate.Payload<A, K>> | false
61
+ } | undefined
62
+ /** The longest sequence of commands to try. Defaults to 20. */
63
+ readonly maxCommands?: number | undefined
64
+ /** Passed on to `Arbitrary.checkEffect`, for the number of runs or a seed. */
65
+ readonly check?: Arbitrary.CheckOptions | undefined
66
+ }
67
+
40
68
  /**
41
69
  * The steps so far. Nothing runs until it is yielded or run as an Effect, which
42
70
  * produces the {@link Outcome} of the last command.
@@ -62,9 +90,9 @@ export interface Scenario<A extends Aggregate.Any, E, R> extends Effect.Effect<O
62
90
 
63
91
  export interface Fixture<A extends Aggregate.Any> {
64
92
  /** Replaces the history the next command runs against. */
65
- given(events: ReadonlyArray<Aggregate.Event<A>>): Fixture<A>
93
+ given(events: ReadonlyArray<GivenEvent<A>>): Fixture<A>
66
94
  /** Like `given`, starting from an empty history by default and optionally switching aggregate id. */
67
- givenEvents(events?: ReadonlyArray<Aggregate.Event<A>>, aggregateId?: string): Fixture<A>
95
+ givenEvents(events?: ReadonlyArray<GivenEvent<A>>, aggregateId?: string): Fixture<A>
68
96
  /** The state after the given events, without running a command. */
69
97
  readonly state: Effect.Effect<Aggregate.State<A>, AggregateService.ReadError, Aggregate.Context<A>>
70
98
  /** Passes the state after the given events to a callback, which throws to fail the test. */
@@ -80,6 +108,13 @@ export interface Fixture<A extends Aggregate.Any> {
80
108
  command: K,
81
109
  payload: Aggregate.Payload<A, K>
82
110
  ): Effect.Effect<Aggregate.CommandError<A, K> | AggregateService.CommandFailure, never, Aggregate.Context<A>>
111
+ /**
112
+ * Runs random sequences of commands from the given history and checks that the
113
+ * invariant holds after each one that succeeds. Commands that fail are part of
114
+ * the game and are skipped. A violation dies with {@link ExpectationFailed},
115
+ * holding the shortest sequence found that breaks it.
116
+ */
117
+ checkInvariants(options: InvariantOptions<A>): Effect.Effect<void, never, Aggregate.Context<A>>
83
118
  }
84
119
 
85
120
  class ScenarioImpl<A extends Aggregate.Any, E, R> extends Effectable.Class<Outcome<A>, E, R>
@@ -145,12 +180,14 @@ export const make = <A extends Aggregate.Any>(
145
180
  aggregate: A,
146
181
  options?: {
147
182
  readonly aggregateId?: string
148
- readonly events?: ReadonlyArray<Aggregate.Event<A>>
183
+ readonly events?: ReadonlyArray<GivenEvent<A>>
149
184
  }
150
185
  ): Fixture<A> => {
151
186
  const aggregateId = options?.aggregateId ?? "fixture-id"
152
187
  const history = options?.events ?? []
153
188
  const encodeEvent = AggregateService.makeEventEncoder(aggregate)
189
+ const encodeGiven = (event: GivenEvent<A>) =>
190
+ event instanceof Aggregate.Historical ? Effect.succeed(event) : encodeEvent(event)
154
191
 
155
192
  const seeded = Effect.gen(function*() {
156
193
  const store = yield* makeMemory
@@ -159,7 +196,7 @@ export const make = <A extends Aggregate.Any>(
159
196
  aggregateName: aggregate.name,
160
197
  aggregateId,
161
198
  expectedVersion: 0,
162
- events: yield* Effect.forEach(history, encodeEvent)
199
+ events: yield* Effect.forEach(history, encodeGiven)
163
200
  })
164
201
  }
165
202
  const service = yield* AggregateService.make(aggregate).pipe(Effect.provideService(EventStore, store))
@@ -187,6 +224,53 @@ export const make = <A extends Aggregate.Any>(
187
224
  })
188
225
  )
189
226
 
227
+ const checkInvariants = (options: InvariantOptions<A>) =>
228
+ Effect.gen(function*() {
229
+ const commands = Object.entries(aggregate.commands).flatMap(([name, command]) => {
230
+ const given = options.commands?.[name as Aggregate.CommandName<A>] as
231
+ | Arbitrary.Arbitrary<unknown>
232
+ | false
233
+ | undefined
234
+ if (given === false) return []
235
+ const payload = given ?? Arbitrary.schema(Schema.toType(command.payload))
236
+ return [[name, Arbitrary.map(payload, (payload: unknown) => ({ command: name, payload }))] as const]
237
+ })
238
+ if (commands.length === 0) {
239
+ return yield* Effect.die(new Error("checkInvariants needs at least one command to run"))
240
+ }
241
+ const byName = new Map(commands)
242
+ const steps = Arbitrary.array(
243
+ Arbitrary.flatMap(Arbitrary.schema(Schema.Literals(commands.map(([name]) => name))), (name) => byName.get(name)!),
244
+ { maxLength: options.maxCommands ?? 20 }
245
+ ) as Arbitrary.Arbitrary<ReadonlyArray<Step<A>>>
246
+
247
+ // Each run starts from a fresh store with the given history.
248
+ const holds = (sequence: ReadonlyArray<Step<A>>) =>
249
+ Effect.gen(function*() {
250
+ const service = yield* seeded
251
+ if (!options.invariant(yield* service.readModel(aggregateId))) return false
252
+ for (const step of sequence) {
253
+ const succeeded = yield* service.commands[step.command](aggregateId, step.payload).pipe(
254
+ Effect.as(true),
255
+ Effect.catch(() => Effect.succeed(false))
256
+ )
257
+ if (succeeded && !options.invariant(yield* service.readModel(aggregateId))) return false
258
+ }
259
+ return true
260
+ }).pipe(Effect.orDie)
261
+
262
+ const result = yield* Arbitrary.checkEffect(steps, holds, options.check)
263
+ if (result._tag !== "Passed") {
264
+ return yield* Effect.die(
265
+ new ExpectationFailed({
266
+ message: Arbitrary.formatCheckFailure(result) ?? "The invariant does not hold",
267
+ expected: "the invariant to hold after every command",
268
+ actual: result._tag === "Falsified" ? result.shrunkInput : result
269
+ })
270
+ )
271
+ }
272
+ })
273
+
190
274
  const fixture: Fixture<A> = {
191
275
  given: (events) => make(aggregate, { aggregateId, events }),
192
276
  givenEvents: (events = [], id = aggregateId) => make(aggregate, { aggregateId: id, events }),
@@ -206,7 +290,8 @@ export const make = <A extends Aggregate.Any>(
206
290
  when(command, payload).pipe(
207
291
  Effect.flip,
208
292
  Effect.catch(() => Effect.die(new Error(`Expected command "${command}" to fail, but it succeeded`)))
209
- )
293
+ ),
294
+ checkInvariants
210
295
  }
211
296
 
212
297
  return fixture
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Metadata: what a command carries beside its payload that the domain doesn't
3
+ * decide on, such as the version of the software that ran it, the user who sent
4
+ * it or the address it came from. It is a JSON object recorded on every event
5
+ * the command writes, and projections, view models and `history` can read it.
6
+ *
7
+ * Like `Causation`, it travels in the fiber's context rather than in payloads.
8
+ * A command records, key by key, from the lowest precedence:
9
+ *
10
+ * 1. the metadata where its aggregate service was built, such as the software
11
+ * version provided once for the whole application with {@link layer};
12
+ * 2. the metadata where it runs, added with {@link withMetadata};
13
+ * 3. the `metadata` of a `dispatch` envelope.
14
+ *
15
+ * A view model or saga reacting to an event doesn't inherit that event's
16
+ * metadata: its commands record their own. The `causationId` leads back to the
17
+ * event, and from there to its metadata.
18
+ */
19
+ import { Context, Effect, Function, Layer, Schema } from "effect"
20
+
21
+ /** A JSON object. */
22
+ export type Metadata = Schema.JsonObject
23
+
24
+ export const Metadata: Schema.Codec<Metadata> = Schema.JsonObject
25
+
26
+ /** The metadata commands run in this fiber record, `{}` outside any. */
27
+ export const CurrentMetadata = Context.Reference<Metadata>("effect-cqrs/Metadata", {
28
+ defaultValue: () => ({})
29
+ })
30
+
31
+ /** Runs an effect whose commands record this metadata on top of the current one. */
32
+ export const withMetadata: {
33
+ (metadata: Metadata): <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
34
+ <A, E, R>(effect: Effect.Effect<A, E, R>, metadata: Metadata): Effect.Effect<A, E, R>
35
+ } = Function.dual(
36
+ 2,
37
+ <A, E, R>(effect: Effect.Effect<A, E, R>, metadata: Metadata) =>
38
+ CurrentMetadata.use((current) => Effect.provideService(effect, CurrentMetadata, { ...current, ...metadata }))
39
+ )
40
+
41
+ /**
42
+ * Provides metadata to everything built on top of this layer, for values that
43
+ * hold for the whole process such as the software version:
44
+ *
45
+ * ```ts
46
+ * AppLayer.pipe(Layer.provide(Metadata.layer({ appVersion: "1.4.0" })))
47
+ * ```
48
+ *
49
+ * Aggregate services remember it, so it is recorded even by commands run where
50
+ * the layer isn't visible.
51
+ */
52
+ export const layer = (metadata: Metadata): Layer.Layer<never> => Layer.succeed(CurrentMetadata, metadata)
@@ -25,8 +25,8 @@ const storeError = (operation: string) => (cause: unknown) => new EventStore.Eve
25
25
 
26
26
  const isUniqueViolation = (error: SqlError.SqlError) => error.reason._tag === "UniqueViolation"
27
27
 
28
- // The client binds at most 32,767 parameters to a statement, and each row uses 7.
29
- const rowsPerInsert = 4_000
28
+ // The client binds at most 32,767 parameters to a statement, and each row uses 11.
29
+ const rowsPerInsert = 2_900
30
30
 
31
31
  export const make = Effect.fnUntraced(function*(options?: Options) {
32
32
  const sql = yield* SqlClient.SqlClient
@@ -42,11 +42,20 @@ export const make = Effect.fnUntraced(function*(options?: Options) {
42
42
  aggregate_id TEXT NOT NULL,
43
43
  version INTEGER NOT NULL,
44
44
  type TEXT NOT NULL,
45
+ schema_version INTEGER NOT NULL DEFAULT 1,
45
46
  payload JSONB NOT NULL,
46
47
  "timestamp" BIGINT NOT NULL,
48
+ correlation_id TEXT,
49
+ causation_id TEXT,
50
+ metadata JSONB NOT NULL DEFAULT '{}',
47
51
  UNIQUE (aggregate_name, aggregate_id, version)
48
52
  )
49
53
  `
54
+ // Tables created before these columns existed get them here.
55
+ yield* sql`ALTER TABLE ${events} ADD COLUMN IF NOT EXISTS schema_version INTEGER NOT NULL DEFAULT 1`
56
+ yield* sql`ALTER TABLE ${events} ADD COLUMN IF NOT EXISTS correlation_id TEXT`
57
+ yield* sql`ALTER TABLE ${events} ADD COLUMN IF NOT EXISTS causation_id TEXT`
58
+ yield* sql`ALTER TABLE ${events} ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'`
50
59
  yield* sql`CREATE INDEX IF NOT EXISTS ${sql(`${prefix}_type_idx`)} ON ${events} (type, position)`
51
60
  yield* sql`
52
61
  CREATE TABLE IF NOT EXISTS ${counter} (
@@ -64,8 +73,12 @@ export const make = Effect.fnUntraced(function*(options?: Options) {
64
73
  aggregate_id AS "aggregateId",
65
74
  version,
66
75
  type,
76
+ schema_version AS "schemaVersion",
67
77
  payload,
68
- "timestamp"::float8 AS "timestamp"
78
+ "timestamp"::float8 AS "timestamp",
79
+ correlation_id AS "correlationId",
80
+ causation_id AS "causationId",
81
+ metadata
69
82
  `)
70
83
 
71
84
  const append = (input: EventStore.AppendInput) => {
@@ -87,23 +100,21 @@ export const make = Effect.fnUntraced(function*(options?: Options) {
87
100
  UPDATE ${counter} SET position = position + ${input.events.length} RETURNING position::float8 AS position
88
101
  `
89
102
  const first = head!.position - input.events.length + 1
90
- const stored = input.events.map((event, index): EventStore.StoredEvent => ({
91
- position: first + index,
92
- aggregateName: input.aggregateName,
93
- aggregateId: input.aggregateId,
94
- version: input.expectedVersion + index + 1,
95
- type: event.type,
96
- payload: event.payload,
97
- timestamp: DateTime.makeUnsafe(timestamp)
98
- }))
103
+ const stored = input.events.map((event, index) =>
104
+ EventStore.toStored(input, event, index, first + index, DateTime.makeUnsafe(timestamp))
105
+ )
99
106
  const rows = stored.map((event) => ({
100
107
  position: event.position,
101
108
  aggregate_name: event.aggregateName,
102
109
  aggregate_id: event.aggregateId,
103
110
  version: event.version,
104
111
  type: event.type,
112
+ schema_version: event.schemaVersion,
105
113
  payload: JSON.stringify(event.payload),
106
- timestamp
114
+ timestamp,
115
+ correlation_id: event.correlationId,
116
+ causation_id: event.causationId,
117
+ metadata: JSON.stringify(event.metadata)
107
118
  }))
108
119
  yield* Effect.forEach(
109
120
  Arr.chunksOf(rows, rowsPerInsert),
package/src/Saga.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Sagas: reactions that span several aggregates and outlive one command, run as
3
+ * durable workflows from `effect/workflow`.
4
+ *
5
+ * A saga has two halves. Its trigger is a view model that follows the log from a
6
+ * checkpoint and starts a workflow for each event it cares about. The workflow is
7
+ * the process itself: each command it runs is an `Activity`, so when the engine
8
+ * replays the workflow after a restart it skips the commands that already
9
+ * finished, and `withCompensation` undoes earlier steps when a later one fails.
10
+ *
11
+ * Triggers see an event at least once, and the workflow's idempotency key turns
12
+ * a second start into a join of the first execution. That only holds while the
13
+ * engine remembers the execution, so pair `WorkflowEngine.layerMemory` with
14
+ * in-memory checkpoints, as for view models, and a durable engine such as
15
+ * `ClusterWorkflowEngine` from `effect/cluster` with durable ones.
16
+ *
17
+ * An activity that is cut off before its result is saved runs again, so the
18
+ * commands a saga runs should be idempotent, for instance by recording the id of
19
+ * the transfer they belong to.
20
+ */
21
+ import { Effect, Layer, Schedule, Schema } from "effect"
22
+ import { Activity, Workflow } from "effect/workflow"
23
+ import type { WorkflowEngine } from "effect/workflow"
24
+ import type * as Aggregate from "./Aggregate.ts"
25
+ import type { CommandResult } from "./AggregateService.ts"
26
+ import * as Causation from "./Causation.ts"
27
+ import type { Checkpoints } from "./Checkpoints.ts"
28
+ import type { EventStore, EventStoreError } from "./EventStore.ts"
29
+ import * as ViewModel from "./ViewModel.ts"
30
+
31
+ /**
32
+ * The causation a workflow carries in its payload. A workflow body runs in the
33
+ * engine's fibers, not in the trigger's, so this is how commands it runs learn
34
+ * which event caused them.
35
+ */
36
+ export const CausationSchema = Schema.Struct({
37
+ correlationId: Schema.String,
38
+ causationId: Schema.String
39
+ })
40
+
41
+ /** A workflow whose payload carries the causation of the event that started it. */
42
+ export type SagaWorkflow<
43
+ Tag extends string = string,
44
+ Fields extends Schema.Struct.Fields = Schema.Struct.Fields,
45
+ Success extends Schema.Top = Schema.Top,
46
+ Error extends Schema.Top = Schema.Top
47
+ > = Workflow.Workflow<Tag, Schema.Struct<Fields & { readonly causation: typeof CausationSchema }>, Success, Error>
48
+
49
+ /**
50
+ * Defines the workflow of a saga. It is `Workflow.make` with a `causation` field
51
+ * added to the payload, which {@link start} fills in and {@link toLayer} applies.
52
+ */
53
+ export const workflow = <
54
+ const Tag extends string,
55
+ const Fields extends Schema.Struct.Fields,
56
+ Success extends Schema.Top = Schema.Void,
57
+ Error extends Schema.Top = Schema.Never
58
+ >(tag: Tag, options: {
59
+ readonly payload: Fields
60
+ /** Identifies one run of the saga, such as a transfer id. Starting it again joins that run. */
61
+ readonly idempotencyKey: (payload: Schema.Struct.Type<Fields>) => string
62
+ readonly success?: Success
63
+ readonly error?: Error
64
+ readonly suspendedRetrySchedule?: Schedule.Schedule<any, unknown> | undefined
65
+ }): SagaWorkflow<Tag, Fields, Success, Error> =>
66
+ Workflow.make(tag, {
67
+ ...options,
68
+ payload: { ...options.payload, causation: CausationSchema }
69
+ }) as any
70
+
71
+ /**
72
+ * Registers the body of a saga's workflow with the engine. Commands the body
73
+ * runs record the event that started the saga as their cause.
74
+ */
75
+ export const toLayer = <
76
+ Tag extends string,
77
+ Fields extends Schema.Struct.Fields,
78
+ Success extends Schema.Top,
79
+ Error extends Schema.Top,
80
+ R
81
+ >(
82
+ self: SagaWorkflow<Tag, Fields, Success, Error>,
83
+ execute: (
84
+ payload: Schema.Struct.Type<Fields>,
85
+ executionId: string
86
+ ) => Effect.Effect<Success["Type"], Error["Type"], R>
87
+ ) =>
88
+ self.toLayer((payload, executionId) =>
89
+ Causation.withCausation(
90
+ execute(payload as Schema.Struct.Type<Fields>, executionId),
91
+ (payload as { readonly causation: Causation.Causation }).causation
92
+ )
93
+ )
94
+
95
+ /**
96
+ * Starts a saga's workflow without waiting for it, and returns its execution id.
97
+ * Inside a trigger the event being handled becomes the saga's cause; elsewhere
98
+ * the current causation does, or a new correlation when there is none.
99
+ */
100
+ export const start = <Tag extends string, Fields extends Schema.Struct.Fields, Success extends Schema.Top, Error extends Schema.Top>(
101
+ self: SagaWorkflow<Tag, Fields, Success, Error>,
102
+ payload: Schema.Struct.MakeIn<Fields>
103
+ ): Effect.Effect<string, never, WorkflowEngine.WorkflowEngine> =>
104
+ Causation.CurrentCausation.use((current) => {
105
+ const id = crypto.randomUUID()
106
+ const causation = current ?? { correlationId: id, causationId: id }
107
+ return self.execute({ ...payload, causation } as never, { discard: true })
108
+ }) as any
109
+
110
+ /**
111
+ * The trigger of a saga: a view model whose handlers start workflows with
112
+ * {@link start}, so it keeps a checkpoint and retries like view models do. Run it
113
+ * with {@link layer}, or with `ViewModel.layer` next to the other view models,
114
+ * where replaying it starts every saga again and joins the executions the engine
115
+ * still remembers.
116
+ */
117
+ export const make = <
118
+ const Name extends string,
119
+ const Aggregates extends ReadonlyArray<Aggregate.Any>,
120
+ const H extends ViewModel.Handlers<Aggregate.Event<Aggregates[number]>>
121
+ >(options: {
122
+ readonly name: Name
123
+ readonly aggregates: Aggregates
124
+ readonly handlers: H
125
+ }) => ViewModel.make({ ...options, dispose: Effect.void })
126
+
127
+ /**
128
+ * Runs triggers in the background for as long as the layer lives, apart from
129
+ * the view models. Workflows usually run commands, so they sit on top of the
130
+ * command side, which view models are often provided to.
131
+ */
132
+ export const layer = <const Sagas extends ReadonlyArray<ViewModel.Any>>(
133
+ sagas: Sagas
134
+ ): Layer.Layer<never, EventStoreError, EventStore | Checkpoints | ViewModel.Context<Sagas[number]>> =>
135
+ Layer.provide(Layer.empty, ViewModel.layer(sagas))
136
+
137
+ /** What a saga keeps of a command it ran: enough to wait for view models to see it. */
138
+ export const CommandOutcome = Schema.Struct({
139
+ aggregateId: Schema.String,
140
+ version: Schema.Int,
141
+ position: Schema.Int
142
+ })
143
+
144
+ // Losing a race or a store error usually passes, so try again before giving up.
145
+ const transient = (error: unknown) =>
146
+ Schema.is(Schema.Struct({ _tag: Schema.Literals(["ConcurrencyConflict", "EventStoreError"]) }))(error)
147
+
148
+ const transientRetry = Schedule.exponential("50 millis").pipe(Schedule.jittered)
149
+
150
+ /**
151
+ * Runs a command as an activity of a saga's workflow, so a replay of the
152
+ * workflow returns the saved outcome instead of running it again.
153
+ *
154
+ * Failures matching `error`, such as a domain error the saga reacts to, are
155
+ * saved like a success. A lost race or a store error is retried a few times.
156
+ * Anything else ends the workflow as a defect, which runs its compensations.
157
+ */
158
+ export const command = <Err extends Schema.Top = typeof Schema.Never, E = never, R = never>(
159
+ name: string,
160
+ options: {
161
+ readonly execute: Effect.Effect<CommandResult<unknown>, E, R>
162
+ readonly error?: Err | undefined
163
+ }
164
+ ): Activity.Activity<typeof CommandOutcome, Err, R> => {
165
+ const expected = options.error === undefined ? () => false : Schema.is(Schema.toType(options.error))
166
+ return Activity.make({
167
+ name,
168
+ success: CommandOutcome,
169
+ error: options.error,
170
+ execute: options.execute.pipe(
171
+ Effect.retry({ while: transient, schedule: transientRetry, times: 5 }),
172
+ Effect.map(({ aggregateId, position, version }) => ({ aggregateId, version, position })),
173
+ Effect.catch((error) => expected(error) ? Effect.fail(error as Err["Type"]) : Effect.die(error))
174
+ )
175
+ }) as any
176
+ }