effect-cqrs 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +326 -2
  3. package/dist/Aggregate.d.ts +127 -0
  4. package/dist/Aggregate.d.ts.map +1 -0
  5. package/dist/Aggregate.js +59 -0
  6. package/dist/Aggregate.js.map +1 -0
  7. package/dist/AggregateService.d.ts +154 -0
  8. package/dist/AggregateService.d.ts.map +1 -0
  9. package/dist/AggregateService.js +407 -0
  10. package/dist/AggregateService.js.map +1 -0
  11. package/dist/Causation.d.ts +34 -0
  12. package/dist/Causation.d.ts.map +1 -0
  13. package/dist/Causation.js +32 -0
  14. package/dist/Causation.js.map +1 -0
  15. package/dist/Checkpoints.d.ts +19 -0
  16. package/dist/Checkpoints.d.ts.map +1 -0
  17. package/dist/Checkpoints.js +18 -0
  18. package/dist/Checkpoints.js.map +1 -0
  19. package/dist/EventStore.d.ts +132 -0
  20. package/dist/EventStore.d.ts.map +1 -0
  21. package/dist/EventStore.js +156 -0
  22. package/dist/EventStore.js.map +1 -0
  23. package/dist/EventVersions.d.ts +64 -0
  24. package/dist/EventVersions.d.ts.map +1 -0
  25. package/dist/EventVersions.js +55 -0
  26. package/dist/EventVersions.js.map +1 -0
  27. package/dist/Fixture.d.ts +106 -0
  28. package/dist/Fixture.d.ts.map +1 -0
  29. package/dist/Fixture.js +149 -0
  30. package/dist/Fixture.js.map +1 -0
  31. package/dist/Metadata.d.ts +42 -0
  32. package/dist/Metadata.d.ts.map +1 -0
  33. package/dist/Metadata.js +39 -0
  34. package/dist/Metadata.js.map +1 -0
  35. package/dist/PostgresEventStore.d.ts +30 -0
  36. package/dist/PostgresEventStore.d.ts.map +1 -0
  37. package/dist/PostgresEventStore.js +153 -0
  38. package/dist/PostgresEventStore.js.map +1 -0
  39. package/dist/Saga.d.ts +105 -0
  40. package/dist/Saga.d.ts.map +1 -0
  41. package/dist/Saga.js +97 -0
  42. package/dist/Saga.js.map +1 -0
  43. package/dist/SqliteEventStore.d.ts +25 -0
  44. package/dist/SqliteEventStore.d.ts.map +1 -0
  45. package/dist/SqliteEventStore.js +142 -0
  46. package/dist/SqliteEventStore.js.map +1 -0
  47. package/dist/ViewModel.d.ts +90 -0
  48. package/dist/ViewModel.d.ts.map +1 -0
  49. package/dist/ViewModel.js +172 -0
  50. package/dist/ViewModel.js.map +1 -0
  51. package/dist/index.d.ts +13 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +13 -0
  54. package/dist/index.js.map +1 -0
  55. package/package.json +89 -3
  56. package/src/Aggregate.ts +205 -0
  57. package/src/AggregateService.ts +705 -0
  58. package/src/Causation.ts +48 -0
  59. package/src/Checkpoints.ts +23 -0
  60. package/src/EventStore.ts +270 -0
  61. package/src/EventVersions.ts +104 -0
  62. package/src/Fixture.ts +298 -0
  63. package/src/Metadata.ts +52 -0
  64. package/src/PostgresEventStore.ts +199 -0
  65. package/src/Saga.ts +176 -0
  66. package/src/SqliteEventStore.ts +196 -0
  67. package/src/ViewModel.ts +350 -0
  68. package/src/index.ts +12 -0
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
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Durable storage on SQLite, through any `effect/sql` SQLite driver.
3
+ *
4
+ * The tables are created on startup. The schema relies on SQLite's single writer
5
+ * for positions to follow commit order, so it is not portable to other databases.
6
+ */
7
+ import { Clock, DateTime, Effect, Layer, Schema } from "effect"
8
+ import { SqlClient, type SqlError } from "effect/sql"
9
+ import { Checkpoints } from "./Checkpoints.ts"
10
+ import * as EventStore from "./EventStore.ts"
11
+
12
+ export interface Options {
13
+ readonly eventsTable?: string
14
+ readonly checkpointsTable?: string
15
+ }
16
+
17
+ const EventRow = Schema.Struct({
18
+ ...EventStore.StoredEvent.fields,
19
+ payload: Schema.fromJsonString(Schema.Json),
20
+ metadata: Schema.fromJsonString(Schema.JsonObject)
21
+ })
22
+
23
+ const decodeRows = Schema.decodeUnknownEffect(Schema.Array(EventRow))
24
+
25
+ const storeError = (operation: string) => (cause: unknown) => new EventStore.EventStoreError({ operation, cause })
26
+
27
+ const isUniqueViolation = (error: SqlError.SqlError) => error.reason._tag === "UniqueViolation"
28
+
29
+ const addedColumns: ReadonlyArray<readonly [string, string]> = [
30
+ ["schema_version", "INTEGER NOT NULL DEFAULT 1"],
31
+ ["correlation_id", "TEXT"],
32
+ ["causation_id", "TEXT"],
33
+ ["metadata", "TEXT NOT NULL DEFAULT '{}'"]
34
+ ]
35
+
36
+ export const make = Effect.fnUntraced(function*(options?: Options) {
37
+ const sql = yield* SqlClient.SqlClient
38
+ const events = sql(options?.eventsTable ?? "cqrs_events")
39
+ const indexPrefix = options?.eventsTable ?? "cqrs_events"
40
+
41
+ yield* sql`
42
+ CREATE TABLE IF NOT EXISTS ${events} (
43
+ position INTEGER PRIMARY KEY AUTOINCREMENT,
44
+ aggregate_name TEXT NOT NULL,
45
+ aggregate_id TEXT NOT NULL,
46
+ version INTEGER NOT NULL,
47
+ type TEXT NOT NULL,
48
+ schema_version INTEGER NOT NULL DEFAULT 1,
49
+ payload TEXT NOT NULL,
50
+ timestamp INTEGER NOT NULL,
51
+ correlation_id TEXT,
52
+ causation_id TEXT,
53
+ metadata TEXT NOT NULL DEFAULT '{}',
54
+ UNIQUE (aggregate_name, aggregate_id, version)
55
+ )
56
+ `.pipe(Effect.mapError(storeError("initialize")))
57
+ // Tables created before these columns existed get them here. SQLite has no
58
+ // `ADD COLUMN IF NOT EXISTS`, so the existing columns are listed first.
59
+ const existing = yield* sql<{ readonly name: string }>`PRAGMA table_info(${events})`.pipe(
60
+ Effect.map((rows) => new Set(rows.map(({ name }) => name))),
61
+ Effect.mapError(storeError("initialize"))
62
+ )
63
+ yield* Effect.forEach(
64
+ addedColumns.filter(([name]) => !existing.has(name)),
65
+ ([name, definition]) =>
66
+ sql`ALTER TABLE ${events} ADD COLUMN ${sql(name)} ${sql.literal(definition)}`.pipe(
67
+ Effect.mapError(storeError("initialize"))
68
+ ),
69
+ { discard: true }
70
+ )
71
+ yield* sql`
72
+ CREATE INDEX IF NOT EXISTS ${sql(`${indexPrefix}_type_idx`)} ON ${events} (type, position)
73
+ `.pipe(Effect.mapError(storeError("initialize")))
74
+
75
+ const columns = sql.literal(`
76
+ position,
77
+ aggregate_name AS "aggregateName",
78
+ aggregate_id AS "aggregateId",
79
+ version,
80
+ type,
81
+ schema_version AS "schemaVersion",
82
+ payload,
83
+ timestamp,
84
+ correlation_id AS "correlationId",
85
+ causation_id AS "causationId",
86
+ metadata
87
+ `)
88
+
89
+ const append = (input: EventStore.AppendInput) => {
90
+ const conflict = new EventStore.ConcurrencyConflict({
91
+ aggregateName: input.aggregateName,
92
+ aggregateId: input.aggregateId,
93
+ expectedVersion: input.expectedVersion
94
+ })
95
+ return Effect.gen(function*() {
96
+ const [current] = yield* sql<{ readonly version: number }>`
97
+ SELECT COALESCE(MAX(version), 0) AS version FROM ${events}
98
+ WHERE aggregate_name = ${input.aggregateName} AND aggregate_id = ${input.aggregateId}
99
+ `
100
+ if (current?.version !== input.expectedVersion) {
101
+ return yield* conflict
102
+ }
103
+ const timestamp = yield* Clock.currentTimeMillis
104
+ const metadata = JSON.stringify(input.metadata ?? {})
105
+ return yield* Effect.forEach(input.events, (event, index) =>
106
+ sql<{ readonly position: number }>`
107
+ INSERT INTO ${events} (
108
+ aggregate_name, aggregate_id, version, type, schema_version, payload, timestamp, correlation_id, causation_id,
109
+ metadata
110
+ )
111
+ VALUES (
112
+ ${input.aggregateName},
113
+ ${input.aggregateId},
114
+ ${input.expectedVersion + index + 1},
115
+ ${event.type},
116
+ ${event.schemaVersion ?? 1},
117
+ ${JSON.stringify(event.payload)},
118
+ ${timestamp},
119
+ ${input.correlationId ?? null},
120
+ ${input.causationId ?? null},
121
+ ${metadata}
122
+ )
123
+ RETURNING position
124
+ `.pipe(
125
+ Effect.map(([row]) => EventStore.toStored(input, event, index, row!.position, DateTime.makeUnsafe(timestamp)))
126
+ ))
127
+ }).pipe(
128
+ sql.withTransaction,
129
+ // The version check can't see a writer racing it on a driver that opens
130
+ // deferred transactions, but the UNIQUE constraint can.
131
+ Effect.catchTag("SqlError", (error) => Effect.fail(isUniqueViolation(error) ? conflict : storeError("append")(error)))
132
+ )
133
+ }
134
+
135
+ const readBatch = (filter: EventStore.EventFilter, afterPosition: number, limit: number) => {
136
+ const oneStream = EventStore.isOneStream(filter)
137
+ const clauses = [
138
+ sql`position > ${afterPosition}`,
139
+ ...(oneStream ? [sql`version > ${filter.afterVersion ?? 0}`] : []),
140
+ ...(filter.aggregateNames === undefined ? [] : [sql.in("aggregate_name", filter.aggregateNames)]),
141
+ ...(filter.aggregateIds === undefined ? [] : [sql.in("aggregate_id", filter.aggregateIds)]),
142
+ ...(filter.eventTypes === undefined ? [] : [sql.in("type", filter.eventTypes)]),
143
+ ...(filter.startTime === undefined ? [] : [sql`timestamp >= ${DateTime.toEpochMillis(filter.startTime)}`]),
144
+ ...(filter.finishTime === undefined ? [] : [sql`timestamp < ${DateTime.toEpochMillis(filter.finishTime)}`])
145
+ ]
146
+ // Inside a stream versions follow positions, and only versions are indexed.
147
+ const order = oneStream ? sql`version` : sql`position`
148
+ return sql`SELECT ${columns} FROM ${events} WHERE ${sql.and(clauses)} ORDER BY ${order} LIMIT ${limit}`.pipe(
149
+ Effect.flatMap(decodeRows),
150
+ Effect.mapError(storeError("read"))
151
+ )
152
+ }
153
+
154
+ const head = sql<{ readonly position: number }>`SELECT COALESCE(MAX(position), 0) AS position FROM ${events}`.pipe(
155
+ Effect.map(([row]) => row?.position ?? 0),
156
+ Effect.mapError(storeError("head"))
157
+ )
158
+
159
+ return yield* EventStore.make({ append, readBatch, head })
160
+ })
161
+
162
+ export const layer = (options?: Options): Layer.Layer<EventStore.EventStore, EventStore.EventStoreError, SqlClient.SqlClient> =>
163
+ Layer.effect(EventStore.EventStore, make(options))
164
+
165
+ /** Keeps checkpoints in the same database, for view models that are durable too. */
166
+ export const layerCheckpoints = (options?: Options): Layer.Layer<Checkpoints, EventStore.EventStoreError, SqlClient.SqlClient> =>
167
+ Layer.effect(
168
+ Checkpoints,
169
+ Effect.gen(function*() {
170
+ const sql = yield* SqlClient.SqlClient
171
+ const table = sql(options?.checkpointsTable ?? "cqrs_checkpoints")
172
+
173
+ yield* sql`
174
+ CREATE TABLE IF NOT EXISTS ${table} (
175
+ view_model TEXT PRIMARY KEY,
176
+ position INTEGER NOT NULL
177
+ )
178
+ `.pipe(Effect.mapError(storeError("initialize")))
179
+
180
+ return Checkpoints.of({
181
+ get: (viewModel) =>
182
+ sql<{ readonly position: number }>`SELECT position FROM ${table} WHERE view_model = ${viewModel}`.pipe(
183
+ Effect.map(([row]) => row?.position ?? 0),
184
+ Effect.mapError(storeError("checkpoint"))
185
+ ),
186
+ set: (viewModel, position) =>
187
+ sql`
188
+ INSERT INTO ${table} (view_model, position) VALUES (${viewModel}, ${position})
189
+ ON CONFLICT (view_model) DO UPDATE SET position = excluded.position
190
+ `.pipe(
191
+ Effect.asVoid,
192
+ Effect.mapError(storeError("checkpoint"))
193
+ )
194
+ })
195
+ })
196
+ )