effect-cqrs 0.0.0-stage → 0.1.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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +298 -2
  3. package/dist/Aggregate.d.ts +86 -0
  4. package/dist/Aggregate.d.ts.map +1 -0
  5. package/dist/Aggregate.js +15 -0
  6. package/dist/Aggregate.js.map +1 -0
  7. package/dist/AggregateService.d.ts +82 -0
  8. package/dist/AggregateService.d.ts.map +1 -0
  9. package/dist/AggregateService.js +181 -0
  10. package/dist/AggregateService.js.map +1 -0
  11. package/dist/Checkpoints.d.ts +19 -0
  12. package/dist/Checkpoints.d.ts.map +1 -0
  13. package/dist/Checkpoints.js +18 -0
  14. package/dist/Checkpoints.js.map +1 -0
  15. package/dist/EventStore.d.ts +110 -0
  16. package/dist/EventStore.d.ts.map +1 -0
  17. package/dist/EventStore.js +136 -0
  18. package/dist/EventStore.js.map +1 -0
  19. package/dist/Fixture.d.ts +71 -0
  20. package/dist/Fixture.d.ts.map +1 -0
  21. package/dist/Fixture.js +108 -0
  22. package/dist/Fixture.js.map +1 -0
  23. package/dist/PostgresEventStore.d.ts +30 -0
  24. package/dist/PostgresEventStore.d.ts.map +1 -0
  25. package/dist/PostgresEventStore.js +144 -0
  26. package/dist/PostgresEventStore.js.map +1 -0
  27. package/dist/SqliteEventStore.d.ts +25 -0
  28. package/dist/SqliteEventStore.d.ts.map +1 -0
  29. package/dist/SqliteEventStore.js +123 -0
  30. package/dist/SqliteEventStore.js.map +1 -0
  31. package/dist/ViewModel.d.ts +90 -0
  32. package/dist/ViewModel.d.ts.map +1 -0
  33. package/dist/ViewModel.js +170 -0
  34. package/dist/ViewModel.js.map +1 -0
  35. package/dist/index.d.ts +9 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +9 -0
  38. package/dist/index.js.map +1 -0
  39. package/package.json +87 -3
  40. package/src/Aggregate.ts +133 -0
  41. package/src/AggregateService.ts +342 -0
  42. package/src/Checkpoints.ts +23 -0
  43. package/src/EventStore.ts +233 -0
  44. package/src/Fixture.ts +213 -0
  45. package/src/PostgresEventStore.ts +188 -0
  46. package/src/SqliteEventStore.ts +166 -0
  47. package/src/ViewModel.ts +348 -0
  48. package/src/index.ts +8 -0
package/src/Fixture.ts ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Tests an aggregate in isolation: given these events, when this command runs,
3
+ * what happens? The moleculer-cqrs `CQRSFixture`, as a chain that runs when it
4
+ * is yielded:
5
+ *
6
+ * ```ts
7
+ * yield* Fixture.make(Todo)
8
+ * .givenEvents([created])
9
+ * .when("deleteTodo", { message: "Done" })
10
+ * .expectEvent("TodoDeleted")
11
+ * .inspectState((state) => assert.strictEqual(state.status, "deleted"))
12
+ * ```
13
+ *
14
+ * Commands run through the real {@link AggregateService} on an in-memory store,
15
+ * so payload validation and event encoding are exercised too.
16
+ */
17
+ import { Effect, Effectable, Schema } from "effect"
18
+ import type * as Aggregate from "./Aggregate.ts"
19
+ import * as AggregateService from "./AggregateService.ts"
20
+ import { EventStore, makeMemory } from "./EventStore.ts"
21
+
22
+ /** Raised as a defect, so a command's error channel only holds what the command can do. */
23
+ export class ExpectationFailed extends Schema.TaggedError<ExpectationFailed>()("ExpectationFailed", {
24
+ message: Schema.String,
25
+ expected: Schema.Unknown,
26
+ actual: Schema.Unknown
27
+ }) {}
28
+
29
+ export interface Outcome<A extends Aggregate.Any> {
30
+ readonly events: ReadonlyArray<Aggregate.Event<A>>
31
+ readonly state: Aggregate.State<A>
32
+ readonly version: number
33
+ /** Continues from here, with this outcome's events as the new history. */
34
+ readonly fixture: Fixture<A>
35
+ }
36
+
37
+ /** An event to compare with, or only its tag when the rest doesn't matter. */
38
+ export type ExpectedEvent<A extends Aggregate.Any> = Aggregate.Event<A> | Aggregate.Event<A>["_tag"]
39
+
40
+ /**
41
+ * The steps so far. Nothing runs until it is yielded or run as an Effect, which
42
+ * produces the {@link Outcome} of the last command.
43
+ */
44
+ export interface Scenario<A extends Aggregate.Any, E, R> extends Effect.Effect<Outcome<A>, E, R> {
45
+ /** Runs another command on top of the previous one. */
46
+ when<K extends Aggregate.CommandName<A>>(
47
+ command: K,
48
+ payload: Aggregate.Payload<A, K>
49
+ ): Scenario<A, E | Aggregate.CommandError<A, K> | AggregateService.CommandFailure, R | Aggregate.Context<A>>
50
+ /** Runs a command that is expected to fail and returns the error. Dies if it succeeds. */
51
+ whenFails<K extends Aggregate.CommandName<A>>(
52
+ command: K,
53
+ payload: Aggregate.Payload<A, K>
54
+ ): Effect.Effect<Aggregate.CommandError<A, K> | AggregateService.CommandFailure, E, R | Aggregate.Context<A>>
55
+ /** Expects the last command to have emitted exactly this event. */
56
+ expectEvent(event: ExpectedEvent<A>): Scenario<A, E, R>
57
+ /** Expects the last command to have emitted exactly these events, in order. */
58
+ expectEvents(events: ReadonlyArray<ExpectedEvent<A>>): Scenario<A, E, R>
59
+ /** Passes the current state to a callback, which throws to fail the test. */
60
+ inspectState(check: (state: Aggregate.State<A>) => void): Scenario<A, E, R>
61
+ }
62
+
63
+ export interface Fixture<A extends Aggregate.Any> {
64
+ /** Replaces the history the next command runs against. */
65
+ given(events: ReadonlyArray<Aggregate.Event<A>>): Fixture<A>
66
+ /** 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>
68
+ /** The state after the given events, without running a command. */
69
+ readonly state: Effect.Effect<Aggregate.State<A>, AggregateService.ReadError, Aggregate.Context<A>>
70
+ /** Passes the state after the given events to a callback, which throws to fail the test. */
71
+ inspectState(
72
+ check: (state: Aggregate.State<A>) => void
73
+ ): Scenario<A, AggregateService.ReadError, Aggregate.Context<A>>
74
+ when<K extends Aggregate.CommandName<A>>(
75
+ command: K,
76
+ payload: Aggregate.Payload<A, K>
77
+ ): Scenario<A, Aggregate.CommandError<A, K> | AggregateService.CommandFailure, Aggregate.Context<A>>
78
+ /** Runs a command that is expected to fail and returns the error. Dies if it succeeds. */
79
+ whenFails<K extends Aggregate.CommandName<A>>(
80
+ command: K,
81
+ payload: Aggregate.Payload<A, K>
82
+ ): Effect.Effect<Aggregate.CommandError<A, K> | AggregateService.CommandFailure, never, Aggregate.Context<A>>
83
+ }
84
+
85
+ class ScenarioImpl<A extends Aggregate.Any, E, R> extends Effectable.Class<Outcome<A>, E, R>
86
+ implements Scenario<A, E, R>
87
+ {
88
+ readonly aggregate: A
89
+ readonly run: Effect.Effect<Outcome<A>, E, R>
90
+
91
+ constructor(aggregate: A, run: Effect.Effect<Outcome<A>, E, R>) {
92
+ super()
93
+ this.aggregate = aggregate
94
+ this.run = run
95
+ }
96
+
97
+ asEffect() {
98
+ return this.run
99
+ }
100
+
101
+ when<K extends Aggregate.CommandName<A>>(command: K, payload: Aggregate.Payload<A, K>) {
102
+ return new ScenarioImpl(
103
+ this.aggregate,
104
+ Effect.flatMap(this.run, (outcome) => outcome.fixture.when(command, payload))
105
+ )
106
+ }
107
+
108
+ whenFails<K extends Aggregate.CommandName<A>>(command: K, payload: Aggregate.Payload<A, K>) {
109
+ return Effect.flatMap(this.run, (outcome) => outcome.fixture.whenFails(command, payload))
110
+ }
111
+
112
+ expectEvent(event: ExpectedEvent<A>) {
113
+ return this.expectEvents([event])
114
+ }
115
+
116
+ expectEvents(expected: ReadonlyArray<ExpectedEvent<A>>) {
117
+ const isSameEvent = Schema.toEquivalence<unknown>(this.aggregate.events)
118
+ const matches = (want: ExpectedEvent<A>, event: Aggregate.Event<A>) =>
119
+ typeof want === "string" ? event._tag === want : isSameEvent(want, event)
120
+
121
+ return new ScenarioImpl(
122
+ this.aggregate,
123
+ Effect.tap(this.run, ({ events }) =>
124
+ events.length === expected.length && expected.every((want, index) => matches(want, events[index]))
125
+ ? Effect.void
126
+ : Effect.die(
127
+ new ExpectationFailed({
128
+ message: "The command did not emit the expected events",
129
+ expected,
130
+ actual: events
131
+ })
132
+ ))
133
+ )
134
+ }
135
+
136
+ inspectState(check: (state: Aggregate.State<A>) => void) {
137
+ return new ScenarioImpl(
138
+ this.aggregate,
139
+ Effect.tap(this.run, ({ state }) => Effect.sync(() => check(state)))
140
+ )
141
+ }
142
+ }
143
+
144
+ export const make = <A extends Aggregate.Any>(
145
+ aggregate: A,
146
+ options?: {
147
+ readonly aggregateId?: string
148
+ readonly events?: ReadonlyArray<Aggregate.Event<A>>
149
+ }
150
+ ): Fixture<A> => {
151
+ const aggregateId = options?.aggregateId ?? "fixture-id"
152
+ const history = options?.events ?? []
153
+ const encodeEvent = AggregateService.makeEventEncoder(aggregate)
154
+
155
+ const seeded = Effect.gen(function*() {
156
+ const store = yield* makeMemory
157
+ if (history.length > 0) {
158
+ yield* store.append({
159
+ aggregateName: aggregate.name,
160
+ aggregateId,
161
+ expectedVersion: 0,
162
+ events: yield* Effect.forEach(history, encodeEvent)
163
+ })
164
+ }
165
+ const service = yield* AggregateService.make(aggregate).pipe(Effect.provideService(EventStore, store))
166
+ return service
167
+ }).pipe(
168
+ // A store with nothing but memory behind it can't fail, and a history that
169
+ // doesn't fit a fresh stream is a mistake in the test.
170
+ Effect.orDie
171
+ )
172
+
173
+ const state = Effect.flatMap(seeded, (service) => service.readModel(aggregateId))
174
+
175
+ const when = <K extends Aggregate.CommandName<A>>(command: K, payload: Aggregate.Payload<A, K>) =>
176
+ new ScenarioImpl(
177
+ aggregate,
178
+ Effect.gen(function*() {
179
+ const service = yield* seeded
180
+ const result = yield* service.commands[command](aggregateId, payload)
181
+ return {
182
+ events: result.events,
183
+ state: yield* service.readModel(aggregateId),
184
+ version: result.version,
185
+ fixture: make(aggregate, { aggregateId, events: [...history, ...result.events] })
186
+ } satisfies Outcome<A>
187
+ })
188
+ )
189
+
190
+ const fixture: Fixture<A> = {
191
+ given: (events) => make(aggregate, { aggregateId, events }),
192
+ givenEvents: (events = [], id = aggregateId) => make(aggregate, { aggregateId: id, events }),
193
+ state,
194
+ inspectState: (check) =>
195
+ new ScenarioImpl(
196
+ aggregate,
197
+ Effect.map(state, (current): Outcome<A> => ({
198
+ events: [],
199
+ state: current,
200
+ version: history.length,
201
+ fixture
202
+ }))
203
+ ).inspectState(check),
204
+ when,
205
+ whenFails: (command, payload) =>
206
+ when(command, payload).pipe(
207
+ Effect.flip,
208
+ Effect.catch(() => Effect.die(new Error(`Expected command "${command}" to fail, but it succeeded`)))
209
+ )
210
+ }
211
+
212
+ return fixture
213
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Durable storage on PostgreSQL, through any `effect/sql` PostgreSQL driver.
3
+ *
4
+ * A sequence can't number events here. Transactions commit in a different order from
5
+ * the one they drew their numbers in, so a view model that has read up to 6 would
6
+ * never come back for a 5 that commits later. Positions come from a counter row
7
+ * instead, which an append holds until it commits: writers wait for each other at
8
+ * that one step, and positions follow commit order with no gaps.
9
+ *
10
+ * The tables are created on startup.
11
+ */
12
+ import { Array as Arr, Clock, DateTime, Effect, Layer, Schema } from "effect"
13
+ import { SqlClient, type SqlError } from "effect/sql"
14
+ import { Checkpoints } from "./Checkpoints.ts"
15
+ import * as EventStore from "./EventStore.ts"
16
+
17
+ export interface Options {
18
+ readonly eventsTable?: string
19
+ readonly checkpointsTable?: string
20
+ }
21
+
22
+ const decodeRows = Schema.decodeUnknownEffect(Schema.Array(EventStore.StoredEvent))
23
+
24
+ const storeError = (operation: string) => (cause: unknown) => new EventStore.EventStoreError({ operation, cause })
25
+
26
+ const isUniqueViolation = (error: SqlError.SqlError) => error.reason._tag === "UniqueViolation"
27
+
28
+ // The client binds at most 32,767 parameters to a statement, and each row uses 7.
29
+ const rowsPerInsert = 4_000
30
+
31
+ export const make = Effect.fnUntraced(function*(options?: Options) {
32
+ const sql = yield* SqlClient.SqlClient
33
+ const prefix = options?.eventsTable ?? "cqrs_events"
34
+ const events = sql(prefix)
35
+ const counter = sql(`${prefix}_head`)
36
+
37
+ const initialize = Effect.gen(function*() {
38
+ yield* sql`
39
+ CREATE TABLE IF NOT EXISTS ${events} (
40
+ position BIGINT PRIMARY KEY,
41
+ aggregate_name TEXT NOT NULL,
42
+ aggregate_id TEXT NOT NULL,
43
+ version INTEGER NOT NULL,
44
+ type TEXT NOT NULL,
45
+ payload JSONB NOT NULL,
46
+ "timestamp" BIGINT NOT NULL,
47
+ UNIQUE (aggregate_name, aggregate_id, version)
48
+ )
49
+ `
50
+ yield* sql`CREATE INDEX IF NOT EXISTS ${sql(`${prefix}_type_idx`)} ON ${events} (type, position)`
51
+ yield* sql`
52
+ CREATE TABLE IF NOT EXISTS ${counter} (
53
+ only_row BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (only_row),
54
+ position BIGINT NOT NULL
55
+ )
56
+ `
57
+ yield* sql`INSERT INTO ${counter} (position) VALUES (0) ON CONFLICT DO NOTHING`
58
+ })
59
+ yield* initialize.pipe(Effect.mapError(storeError("initialize")))
60
+
61
+ const columns = sql.literal(`
62
+ position::float8 AS position,
63
+ aggregate_name AS "aggregateName",
64
+ aggregate_id AS "aggregateId",
65
+ version,
66
+ type,
67
+ payload,
68
+ "timestamp"::float8 AS "timestamp"
69
+ `)
70
+
71
+ const append = (input: EventStore.AppendInput) => {
72
+ const conflict = new EventStore.ConcurrencyConflict({
73
+ aggregateName: input.aggregateName,
74
+ aggregateId: input.aggregateId,
75
+ expectedVersion: input.expectedVersion
76
+ })
77
+ return Effect.gen(function*() {
78
+ const [current] = yield* sql<{ readonly version: number }>`
79
+ SELECT COALESCE(MAX(version), 0) AS version FROM ${events}
80
+ WHERE aggregate_name = ${input.aggregateName} AND aggregate_id = ${input.aggregateId}
81
+ `
82
+ if (current?.version !== input.expectedVersion) {
83
+ return yield* conflict
84
+ }
85
+ const timestamp = yield* Clock.currentTimeMillis
86
+ const [head] = yield* sql<{ readonly position: number }>`
87
+ UPDATE ${counter} SET position = position + ${input.events.length} RETURNING position::float8 AS position
88
+ `
89
+ 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
+ }))
99
+ const rows = stored.map((event) => ({
100
+ position: event.position,
101
+ aggregate_name: event.aggregateName,
102
+ aggregate_id: event.aggregateId,
103
+ version: event.version,
104
+ type: event.type,
105
+ payload: JSON.stringify(event.payload),
106
+ timestamp
107
+ }))
108
+ yield* Effect.forEach(
109
+ Arr.chunksOf(rows, rowsPerInsert),
110
+ (chunk) => sql`INSERT INTO ${events} ${sql.insert(chunk)}`,
111
+ { discard: true }
112
+ )
113
+ return stored
114
+ }).pipe(
115
+ sql.withTransaction,
116
+ // The version check can't see a writer that hasn't committed, but the UNIQUE
117
+ // constraint can. Rolling back gives the position back, so that leaves no gap.
118
+ Effect.catchTag("SqlError", (error) => Effect.fail(isUniqueViolation(error) ? conflict : storeError("append")(error)))
119
+ )
120
+ }
121
+
122
+ const readBatch = (filter: EventStore.EventFilter, afterPosition: number, limit: number) => {
123
+ const oneStream = EventStore.isOneStream(filter)
124
+ const clauses = [
125
+ sql`${events}.position > ${afterPosition}`,
126
+ ...(oneStream ? [sql`version > ${filter.afterVersion ?? 0}`] : []),
127
+ ...(filter.aggregateNames === undefined ? [] : [sql.in("aggregate_name", filter.aggregateNames)]),
128
+ ...(filter.aggregateIds === undefined ? [] : [sql.in("aggregate_id", filter.aggregateIds)]),
129
+ ...(filter.eventTypes === undefined ? [] : [sql.in("type", filter.eventTypes)]),
130
+ ...(filter.startTime === undefined ? [] : [sql`"timestamp" >= ${DateTime.toEpochMillis(filter.startTime)}`]),
131
+ ...(filter.finishTime === undefined ? [] : [sql`"timestamp" < ${DateTime.toEpochMillis(filter.finishTime)}`])
132
+ ]
133
+ // Inside a stream versions follow positions, and ordering by them keeps to the
134
+ // stream's index. With `LIMIT` the planner would otherwise walk the primary key,
135
+ // through every later event of the log, looking for a stream with nothing new.
136
+ // `position` alone would mean the converted column above, which has no index.
137
+ const order = oneStream ? sql`version` : sql`${events}.position`
138
+ return sql`SELECT ${columns} FROM ${events} WHERE ${sql.and(clauses)} ORDER BY ${order} LIMIT ${limit}`.pipe(
139
+ Effect.flatMap(decodeRows),
140
+ Effect.mapError(storeError("read"))
141
+ )
142
+ }
143
+
144
+ const head = sql<{ readonly position: number }>`SELECT position::float8 AS position FROM ${counter}`.pipe(
145
+ Effect.map(([row]) => row?.position ?? 0),
146
+ Effect.mapError(storeError("head"))
147
+ )
148
+
149
+ return yield* EventStore.make({ append, readBatch, head })
150
+ })
151
+
152
+ export const layer = (options?: Options): Layer.Layer<EventStore.EventStore, EventStore.EventStoreError, SqlClient.SqlClient> =>
153
+ Layer.effect(EventStore.EventStore, make(options))
154
+
155
+ /** Keeps checkpoints in the same database, for view models that are durable too. */
156
+ export const layerCheckpoints = (options?: Options): Layer.Layer<Checkpoints, EventStore.EventStoreError, SqlClient.SqlClient> =>
157
+ Layer.effect(
158
+ Checkpoints,
159
+ Effect.gen(function*() {
160
+ const sql = yield* SqlClient.SqlClient
161
+ const table = sql(options?.checkpointsTable ?? "cqrs_checkpoints")
162
+
163
+ yield* sql`
164
+ CREATE TABLE IF NOT EXISTS ${table} (
165
+ view_model TEXT PRIMARY KEY,
166
+ position BIGINT NOT NULL
167
+ )
168
+ `.pipe(Effect.mapError(storeError("initialize")))
169
+
170
+ return Checkpoints.of({
171
+ get: (viewModel) =>
172
+ sql<{ readonly position: number }>`
173
+ SELECT position::float8 AS position FROM ${table} WHERE view_model = ${viewModel}
174
+ `.pipe(
175
+ Effect.map(([row]) => row?.position ?? 0),
176
+ Effect.mapError(storeError("checkpoint"))
177
+ ),
178
+ set: (viewModel, position) =>
179
+ sql`
180
+ INSERT INTO ${table} (view_model, position) VALUES (${viewModel}, ${position})
181
+ ON CONFLICT (view_model) DO UPDATE SET position = excluded.position
182
+ `.pipe(
183
+ Effect.asVoid,
184
+ Effect.mapError(storeError("checkpoint"))
185
+ )
186
+ })
187
+ })
188
+ )
@@ -0,0 +1,166 @@
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
+ })
21
+
22
+ const decodeRows = Schema.decodeUnknownEffect(Schema.Array(EventRow))
23
+
24
+ const storeError = (operation: string) => (cause: unknown) => new EventStore.EventStoreError({ operation, cause })
25
+
26
+ const isUniqueViolation = (error: SqlError.SqlError) => error.reason._tag === "UniqueViolation"
27
+
28
+ export const make = Effect.fnUntraced(function*(options?: Options) {
29
+ const sql = yield* SqlClient.SqlClient
30
+ const events = sql(options?.eventsTable ?? "cqrs_events")
31
+ const indexPrefix = options?.eventsTable ?? "cqrs_events"
32
+
33
+ yield* sql`
34
+ CREATE TABLE IF NOT EXISTS ${events} (
35
+ position INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ aggregate_name TEXT NOT NULL,
37
+ aggregate_id TEXT NOT NULL,
38
+ version INTEGER NOT NULL,
39
+ type TEXT NOT NULL,
40
+ payload TEXT NOT NULL,
41
+ timestamp INTEGER NOT NULL,
42
+ UNIQUE (aggregate_name, aggregate_id, version)
43
+ )
44
+ `.pipe(Effect.mapError(storeError("initialize")))
45
+ yield* sql`
46
+ CREATE INDEX IF NOT EXISTS ${sql(`${indexPrefix}_type_idx`)} ON ${events} (type, position)
47
+ `.pipe(Effect.mapError(storeError("initialize")))
48
+
49
+ const columns = sql.literal(`
50
+ position,
51
+ aggregate_name AS "aggregateName",
52
+ aggregate_id AS "aggregateId",
53
+ version,
54
+ type,
55
+ payload,
56
+ timestamp
57
+ `)
58
+
59
+ const append = (input: EventStore.AppendInput) => {
60
+ const conflict = new EventStore.ConcurrencyConflict({
61
+ aggregateName: input.aggregateName,
62
+ aggregateId: input.aggregateId,
63
+ expectedVersion: input.expectedVersion
64
+ })
65
+ return Effect.gen(function*() {
66
+ const [current] = yield* sql<{ readonly version: number }>`
67
+ SELECT COALESCE(MAX(version), 0) AS version FROM ${events}
68
+ WHERE aggregate_name = ${input.aggregateName} AND aggregate_id = ${input.aggregateId}
69
+ `
70
+ if (current?.version !== input.expectedVersion) {
71
+ return yield* conflict
72
+ }
73
+ const timestamp = yield* Clock.currentTimeMillis
74
+ return yield* Effect.forEach(input.events, (event, index) =>
75
+ sql<{ readonly position: number }>`
76
+ INSERT INTO ${events} (aggregate_name, aggregate_id, version, type, payload, timestamp)
77
+ VALUES (
78
+ ${input.aggregateName},
79
+ ${input.aggregateId},
80
+ ${input.expectedVersion + index + 1},
81
+ ${event.type},
82
+ ${JSON.stringify(event.payload)},
83
+ ${timestamp}
84
+ )
85
+ RETURNING position
86
+ `.pipe(
87
+ Effect.map(([row]): EventStore.StoredEvent => ({
88
+ position: row!.position,
89
+ aggregateName: input.aggregateName,
90
+ aggregateId: input.aggregateId,
91
+ version: input.expectedVersion + index + 1,
92
+ type: event.type,
93
+ payload: event.payload,
94
+ timestamp: DateTime.makeUnsafe(timestamp)
95
+ }))
96
+ ))
97
+ }).pipe(
98
+ sql.withTransaction,
99
+ // The version check can't see a writer racing it on a driver that opens
100
+ // deferred transactions, but the UNIQUE constraint can.
101
+ Effect.catchTag("SqlError", (error) => Effect.fail(isUniqueViolation(error) ? conflict : storeError("append")(error)))
102
+ )
103
+ }
104
+
105
+ const readBatch = (filter: EventStore.EventFilter, afterPosition: number, limit: number) => {
106
+ const oneStream = EventStore.isOneStream(filter)
107
+ const clauses = [
108
+ sql`position > ${afterPosition}`,
109
+ ...(oneStream ? [sql`version > ${filter.afterVersion ?? 0}`] : []),
110
+ ...(filter.aggregateNames === undefined ? [] : [sql.in("aggregate_name", filter.aggregateNames)]),
111
+ ...(filter.aggregateIds === undefined ? [] : [sql.in("aggregate_id", filter.aggregateIds)]),
112
+ ...(filter.eventTypes === undefined ? [] : [sql.in("type", filter.eventTypes)]),
113
+ ...(filter.startTime === undefined ? [] : [sql`timestamp >= ${DateTime.toEpochMillis(filter.startTime)}`]),
114
+ ...(filter.finishTime === undefined ? [] : [sql`timestamp < ${DateTime.toEpochMillis(filter.finishTime)}`])
115
+ ]
116
+ // Inside a stream versions follow positions, and only versions are indexed.
117
+ const order = oneStream ? sql`version` : sql`position`
118
+ return sql`SELECT ${columns} FROM ${events} WHERE ${sql.and(clauses)} ORDER BY ${order} LIMIT ${limit}`.pipe(
119
+ Effect.flatMap(decodeRows),
120
+ Effect.mapError(storeError("read"))
121
+ )
122
+ }
123
+
124
+ const head = sql<{ readonly position: number }>`SELECT COALESCE(MAX(position), 0) AS position FROM ${events}`.pipe(
125
+ Effect.map(([row]) => row?.position ?? 0),
126
+ Effect.mapError(storeError("head"))
127
+ )
128
+
129
+ return yield* EventStore.make({ append, readBatch, head })
130
+ })
131
+
132
+ export const layer = (options?: Options): Layer.Layer<EventStore.EventStore, EventStore.EventStoreError, SqlClient.SqlClient> =>
133
+ Layer.effect(EventStore.EventStore, make(options))
134
+
135
+ /** Keeps checkpoints in the same database, for view models that are durable too. */
136
+ export const layerCheckpoints = (options?: Options): Layer.Layer<Checkpoints, EventStore.EventStoreError, SqlClient.SqlClient> =>
137
+ Layer.effect(
138
+ Checkpoints,
139
+ Effect.gen(function*() {
140
+ const sql = yield* SqlClient.SqlClient
141
+ const table = sql(options?.checkpointsTable ?? "cqrs_checkpoints")
142
+
143
+ yield* sql`
144
+ CREATE TABLE IF NOT EXISTS ${table} (
145
+ view_model TEXT PRIMARY KEY,
146
+ position INTEGER NOT NULL
147
+ )
148
+ `.pipe(Effect.mapError(storeError("initialize")))
149
+
150
+ return Checkpoints.of({
151
+ get: (viewModel) =>
152
+ sql<{ readonly position: number }>`SELECT position FROM ${table} WHERE view_model = ${viewModel}`.pipe(
153
+ Effect.map(([row]) => row?.position ?? 0),
154
+ Effect.mapError(storeError("checkpoint"))
155
+ ),
156
+ set: (viewModel, position) =>
157
+ sql`
158
+ INSERT INTO ${table} (view_model, position) VALUES (${viewModel}, ${position})
159
+ ON CONFLICT (view_model) DO UPDATE SET position = excluded.position
160
+ `.pipe(
161
+ Effect.asVoid,
162
+ Effect.mapError(storeError("checkpoint"))
163
+ )
164
+ })
165
+ })
166
+ )