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/Fixture.ts ADDED
@@ -0,0 +1,298 @@
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
+ * 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.
20
+ */
21
+ import { Arbitrary, Effect, Effectable, Schema } from "effect"
22
+ import * as Aggregate from "./Aggregate.ts"
23
+ import * as AggregateService from "./AggregateService.ts"
24
+ import { EventStore, makeMemory } from "./EventStore.ts"
25
+
26
+ /** Raised as a defect, so a command's error channel only holds what the command can do. */
27
+ export class ExpectationFailed extends Schema.TaggedError<ExpectationFailed>()("ExpectationFailed", {
28
+ message: Schema.String,
29
+ expected: Schema.Unknown,
30
+ actual: Schema.Unknown
31
+ }) {}
32
+
33
+ export interface Outcome<A extends Aggregate.Any> {
34
+ readonly events: ReadonlyArray<Aggregate.Event<A>>
35
+ readonly state: Aggregate.State<A>
36
+ readonly version: number
37
+ /** Continues from here, with this outcome's events as the new history. */
38
+ readonly fixture: Fixture<A>
39
+ }
40
+
41
+ /** An event to compare with, or only its tag when the rest doesn't matter. */
42
+ export type ExpectedEvent<A extends Aggregate.Any> = Aggregate.Event<A> | Aggregate.Event<A>["_tag"]
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
+
68
+ /**
69
+ * The steps so far. Nothing runs until it is yielded or run as an Effect, which
70
+ * produces the {@link Outcome} of the last command.
71
+ */
72
+ export interface Scenario<A extends Aggregate.Any, E, R> extends Effect.Effect<Outcome<A>, E, R> {
73
+ /** Runs another command on top of the previous one. */
74
+ when<K extends Aggregate.CommandName<A>>(
75
+ command: K,
76
+ payload: Aggregate.Payload<A, K>
77
+ ): Scenario<A, E | Aggregate.CommandError<A, K> | AggregateService.CommandFailure, R | 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, E, R | Aggregate.Context<A>>
83
+ /** Expects the last command to have emitted exactly this event. */
84
+ expectEvent(event: ExpectedEvent<A>): Scenario<A, E, R>
85
+ /** Expects the last command to have emitted exactly these events, in order. */
86
+ expectEvents(events: ReadonlyArray<ExpectedEvent<A>>): Scenario<A, E, R>
87
+ /** Passes the current state to a callback, which throws to fail the test. */
88
+ inspectState(check: (state: Aggregate.State<A>) => void): Scenario<A, E, R>
89
+ }
90
+
91
+ export interface Fixture<A extends Aggregate.Any> {
92
+ /** Replaces the history the next command runs against. */
93
+ given(events: ReadonlyArray<GivenEvent<A>>): Fixture<A>
94
+ /** Like `given`, starting from an empty history by default and optionally switching aggregate id. */
95
+ givenEvents(events?: ReadonlyArray<GivenEvent<A>>, aggregateId?: string): Fixture<A>
96
+ /** The state after the given events, without running a command. */
97
+ readonly state: Effect.Effect<Aggregate.State<A>, AggregateService.ReadError, Aggregate.Context<A>>
98
+ /** Passes the state after the given events to a callback, which throws to fail the test. */
99
+ inspectState(
100
+ check: (state: Aggregate.State<A>) => void
101
+ ): Scenario<A, AggregateService.ReadError, Aggregate.Context<A>>
102
+ when<K extends Aggregate.CommandName<A>>(
103
+ command: K,
104
+ payload: Aggregate.Payload<A, K>
105
+ ): Scenario<A, Aggregate.CommandError<A, K> | AggregateService.CommandFailure, Aggregate.Context<A>>
106
+ /** Runs a command that is expected to fail and returns the error. Dies if it succeeds. */
107
+ whenFails<K extends Aggregate.CommandName<A>>(
108
+ command: K,
109
+ payload: Aggregate.Payload<A, K>
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>>
118
+ }
119
+
120
+ class ScenarioImpl<A extends Aggregate.Any, E, R> extends Effectable.Class<Outcome<A>, E, R>
121
+ implements Scenario<A, E, R>
122
+ {
123
+ readonly aggregate: A
124
+ readonly run: Effect.Effect<Outcome<A>, E, R>
125
+
126
+ constructor(aggregate: A, run: Effect.Effect<Outcome<A>, E, R>) {
127
+ super()
128
+ this.aggregate = aggregate
129
+ this.run = run
130
+ }
131
+
132
+ asEffect() {
133
+ return this.run
134
+ }
135
+
136
+ when<K extends Aggregate.CommandName<A>>(command: K, payload: Aggregate.Payload<A, K>) {
137
+ return new ScenarioImpl(
138
+ this.aggregate,
139
+ Effect.flatMap(this.run, (outcome) => outcome.fixture.when(command, payload))
140
+ )
141
+ }
142
+
143
+ whenFails<K extends Aggregate.CommandName<A>>(command: K, payload: Aggregate.Payload<A, K>) {
144
+ return Effect.flatMap(this.run, (outcome) => outcome.fixture.whenFails(command, payload))
145
+ }
146
+
147
+ expectEvent(event: ExpectedEvent<A>) {
148
+ return this.expectEvents([event])
149
+ }
150
+
151
+ expectEvents(expected: ReadonlyArray<ExpectedEvent<A>>) {
152
+ const isSameEvent = Schema.toEquivalence<unknown>(this.aggregate.events)
153
+ const matches = (want: ExpectedEvent<A>, event: Aggregate.Event<A>) =>
154
+ typeof want === "string" ? event._tag === want : isSameEvent(want, event)
155
+
156
+ return new ScenarioImpl(
157
+ this.aggregate,
158
+ Effect.tap(this.run, ({ events }) =>
159
+ events.length === expected.length && expected.every((want, index) => matches(want, events[index]))
160
+ ? Effect.void
161
+ : Effect.die(
162
+ new ExpectationFailed({
163
+ message: "The command did not emit the expected events",
164
+ expected,
165
+ actual: events
166
+ })
167
+ ))
168
+ )
169
+ }
170
+
171
+ inspectState(check: (state: Aggregate.State<A>) => void) {
172
+ return new ScenarioImpl(
173
+ this.aggregate,
174
+ Effect.tap(this.run, ({ state }) => Effect.sync(() => check(state)))
175
+ )
176
+ }
177
+ }
178
+
179
+ export const make = <A extends Aggregate.Any>(
180
+ aggregate: A,
181
+ options?: {
182
+ readonly aggregateId?: string
183
+ readonly events?: ReadonlyArray<GivenEvent<A>>
184
+ }
185
+ ): Fixture<A> => {
186
+ const aggregateId = options?.aggregateId ?? "fixture-id"
187
+ const history = options?.events ?? []
188
+ const encodeEvent = AggregateService.makeEventEncoder(aggregate)
189
+ const encodeGiven = (event: GivenEvent<A>) =>
190
+ event instanceof Aggregate.Historical ? Effect.succeed(event) : encodeEvent(event)
191
+
192
+ const seeded = Effect.gen(function*() {
193
+ const store = yield* makeMemory
194
+ if (history.length > 0) {
195
+ yield* store.append({
196
+ aggregateName: aggregate.name,
197
+ aggregateId,
198
+ expectedVersion: 0,
199
+ events: yield* Effect.forEach(history, encodeGiven)
200
+ })
201
+ }
202
+ const service = yield* AggregateService.make(aggregate).pipe(Effect.provideService(EventStore, store))
203
+ return service
204
+ }).pipe(
205
+ // A store with nothing but memory behind it can't fail, and a history that
206
+ // doesn't fit a fresh stream is a mistake in the test.
207
+ Effect.orDie
208
+ )
209
+
210
+ const state = Effect.flatMap(seeded, (service) => service.readModel(aggregateId))
211
+
212
+ const when = <K extends Aggregate.CommandName<A>>(command: K, payload: Aggregate.Payload<A, K>) =>
213
+ new ScenarioImpl(
214
+ aggregate,
215
+ Effect.gen(function*() {
216
+ const service = yield* seeded
217
+ const result = yield* service.commands[command](aggregateId, payload)
218
+ return {
219
+ events: result.events,
220
+ state: yield* service.readModel(aggregateId),
221
+ version: result.version,
222
+ fixture: make(aggregate, { aggregateId, events: [...history, ...result.events] })
223
+ } satisfies Outcome<A>
224
+ })
225
+ )
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
+
274
+ const fixture: Fixture<A> = {
275
+ given: (events) => make(aggregate, { aggregateId, events }),
276
+ givenEvents: (events = [], id = aggregateId) => make(aggregate, { aggregateId: id, events }),
277
+ state,
278
+ inspectState: (check) =>
279
+ new ScenarioImpl(
280
+ aggregate,
281
+ Effect.map(state, (current): Outcome<A> => ({
282
+ events: [],
283
+ state: current,
284
+ version: history.length,
285
+ fixture
286
+ }))
287
+ ).inspectState(check),
288
+ when,
289
+ whenFails: (command, payload) =>
290
+ when(command, payload).pipe(
291
+ Effect.flip,
292
+ Effect.catch(() => Effect.die(new Error(`Expected command "${command}" to fail, but it succeeded`)))
293
+ ),
294
+ checkInvariants
295
+ }
296
+
297
+ return fixture
298
+ }
@@ -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)
@@ -0,0 +1,199 @@
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 11.
29
+ const rowsPerInsert = 2_900
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
+ schema_version INTEGER NOT NULL DEFAULT 1,
46
+ payload JSONB NOT NULL,
47
+ "timestamp" BIGINT NOT NULL,
48
+ correlation_id TEXT,
49
+ causation_id TEXT,
50
+ metadata JSONB NOT NULL DEFAULT '{}',
51
+ UNIQUE (aggregate_name, aggregate_id, version)
52
+ )
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 '{}'`
59
+ yield* sql`CREATE INDEX IF NOT EXISTS ${sql(`${prefix}_type_idx`)} ON ${events} (type, position)`
60
+ yield* sql`
61
+ CREATE TABLE IF NOT EXISTS ${counter} (
62
+ only_row BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (only_row),
63
+ position BIGINT NOT NULL
64
+ )
65
+ `
66
+ yield* sql`INSERT INTO ${counter} (position) VALUES (0) ON CONFLICT DO NOTHING`
67
+ })
68
+ yield* initialize.pipe(Effect.mapError(storeError("initialize")))
69
+
70
+ const columns = sql.literal(`
71
+ position::float8 AS position,
72
+ aggregate_name AS "aggregateName",
73
+ aggregate_id AS "aggregateId",
74
+ version,
75
+ type,
76
+ schema_version AS "schemaVersion",
77
+ payload,
78
+ "timestamp"::float8 AS "timestamp",
79
+ correlation_id AS "correlationId",
80
+ causation_id AS "causationId",
81
+ metadata
82
+ `)
83
+
84
+ const append = (input: EventStore.AppendInput) => {
85
+ const conflict = new EventStore.ConcurrencyConflict({
86
+ aggregateName: input.aggregateName,
87
+ aggregateId: input.aggregateId,
88
+ expectedVersion: input.expectedVersion
89
+ })
90
+ return Effect.gen(function*() {
91
+ const [current] = yield* sql<{ readonly version: number }>`
92
+ SELECT COALESCE(MAX(version), 0) AS version FROM ${events}
93
+ WHERE aggregate_name = ${input.aggregateName} AND aggregate_id = ${input.aggregateId}
94
+ `
95
+ if (current?.version !== input.expectedVersion) {
96
+ return yield* conflict
97
+ }
98
+ const timestamp = yield* Clock.currentTimeMillis
99
+ const [head] = yield* sql<{ readonly position: number }>`
100
+ UPDATE ${counter} SET position = position + ${input.events.length} RETURNING position::float8 AS position
101
+ `
102
+ const first = head!.position - input.events.length + 1
103
+ const stored = input.events.map((event, index) =>
104
+ EventStore.toStored(input, event, index, first + index, DateTime.makeUnsafe(timestamp))
105
+ )
106
+ const rows = stored.map((event) => ({
107
+ position: event.position,
108
+ aggregate_name: event.aggregateName,
109
+ aggregate_id: event.aggregateId,
110
+ version: event.version,
111
+ type: event.type,
112
+ schema_version: event.schemaVersion,
113
+ payload: JSON.stringify(event.payload),
114
+ timestamp,
115
+ correlation_id: event.correlationId,
116
+ causation_id: event.causationId,
117
+ metadata: JSON.stringify(event.metadata)
118
+ }))
119
+ yield* Effect.forEach(
120
+ Arr.chunksOf(rows, rowsPerInsert),
121
+ (chunk) => sql`INSERT INTO ${events} ${sql.insert(chunk)}`,
122
+ { discard: true }
123
+ )
124
+ return stored
125
+ }).pipe(
126
+ sql.withTransaction,
127
+ // The version check can't see a writer that hasn't committed, but the UNIQUE
128
+ // constraint can. Rolling back gives the position back, so that leaves no gap.
129
+ Effect.catchTag("SqlError", (error) => Effect.fail(isUniqueViolation(error) ? conflict : storeError("append")(error)))
130
+ )
131
+ }
132
+
133
+ const readBatch = (filter: EventStore.EventFilter, afterPosition: number, limit: number) => {
134
+ const oneStream = EventStore.isOneStream(filter)
135
+ const clauses = [
136
+ sql`${events}.position > ${afterPosition}`,
137
+ ...(oneStream ? [sql`version > ${filter.afterVersion ?? 0}`] : []),
138
+ ...(filter.aggregateNames === undefined ? [] : [sql.in("aggregate_name", filter.aggregateNames)]),
139
+ ...(filter.aggregateIds === undefined ? [] : [sql.in("aggregate_id", filter.aggregateIds)]),
140
+ ...(filter.eventTypes === undefined ? [] : [sql.in("type", filter.eventTypes)]),
141
+ ...(filter.startTime === undefined ? [] : [sql`"timestamp" >= ${DateTime.toEpochMillis(filter.startTime)}`]),
142
+ ...(filter.finishTime === undefined ? [] : [sql`"timestamp" < ${DateTime.toEpochMillis(filter.finishTime)}`])
143
+ ]
144
+ // Inside a stream versions follow positions, and ordering by them keeps to the
145
+ // stream's index. With `LIMIT` the planner would otherwise walk the primary key,
146
+ // through every later event of the log, looking for a stream with nothing new.
147
+ // `position` alone would mean the converted column above, which has no index.
148
+ const order = oneStream ? sql`version` : sql`${events}.position`
149
+ return sql`SELECT ${columns} FROM ${events} WHERE ${sql.and(clauses)} ORDER BY ${order} LIMIT ${limit}`.pipe(
150
+ Effect.flatMap(decodeRows),
151
+ Effect.mapError(storeError("read"))
152
+ )
153
+ }
154
+
155
+ const head = sql<{ readonly position: number }>`SELECT position::float8 AS position FROM ${counter}`.pipe(
156
+ Effect.map(([row]) => row?.position ?? 0),
157
+ Effect.mapError(storeError("head"))
158
+ )
159
+
160
+ return yield* EventStore.make({ append, readBatch, head })
161
+ })
162
+
163
+ export const layer = (options?: Options): Layer.Layer<EventStore.EventStore, EventStore.EventStoreError, SqlClient.SqlClient> =>
164
+ Layer.effect(EventStore.EventStore, make(options))
165
+
166
+ /** Keeps checkpoints in the same database, for view models that are durable too. */
167
+ export const layerCheckpoints = (options?: Options): Layer.Layer<Checkpoints, EventStore.EventStoreError, SqlClient.SqlClient> =>
168
+ Layer.effect(
169
+ Checkpoints,
170
+ Effect.gen(function*() {
171
+ const sql = yield* SqlClient.SqlClient
172
+ const table = sql(options?.checkpointsTable ?? "cqrs_checkpoints")
173
+
174
+ yield* sql`
175
+ CREATE TABLE IF NOT EXISTS ${table} (
176
+ view_model TEXT PRIMARY KEY,
177
+ position BIGINT NOT NULL
178
+ )
179
+ `.pipe(Effect.mapError(storeError("initialize")))
180
+
181
+ return Checkpoints.of({
182
+ get: (viewModel) =>
183
+ sql<{ readonly position: number }>`
184
+ SELECT position::float8 AS position FROM ${table} WHERE view_model = ${viewModel}
185
+ `.pipe(
186
+ Effect.map(([row]) => row?.position ?? 0),
187
+ Effect.mapError(storeError("checkpoint"))
188
+ ),
189
+ set: (viewModel, position) =>
190
+ sql`
191
+ INSERT INTO ${table} (view_model, position) VALUES (${viewModel}, ${position})
192
+ ON CONFLICT (view_model) DO UPDATE SET position = excluded.position
193
+ `.pipe(
194
+ Effect.asVoid,
195
+ Effect.mapError(storeError("checkpoint"))
196
+ )
197
+ })
198
+ })
199
+ )