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
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Runs an aggregate against the {@link EventStore}: the `command`, `read-model`
3
+ * and `history` actions of moleculer-cqrs, as a typed service.
4
+ */
5
+ import { Array as Arr, type DateTime, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
6
+ import * as Aggregate from "./Aggregate.ts"
7
+ import {
8
+ ConcurrencyConflict,
9
+ type EventFilter,
10
+ EventStore,
11
+ type EventStoreError,
12
+ type StoredEvent
13
+ } from "./EventStore.ts"
14
+
15
+ export class UnknownCommand extends Schema.TaggedError<UnknownCommand>()("UnknownCommand", {
16
+ aggregateName: Schema.String,
17
+ type: Schema.String
18
+ }) {}
19
+
20
+ export class InvalidCommand extends Schema.TaggedError<InvalidCommand>()("InvalidCommand", {
21
+ aggregateName: Schema.String,
22
+ type: Schema.String,
23
+ message: Schema.String
24
+ }) {}
25
+
26
+ /** A stored event no longer matches the schema, which means the data needs a migration. */
27
+ export class EventDecodeError extends Schema.TaggedError<EventDecodeError>()("EventDecodeError", {
28
+ aggregateName: Schema.String,
29
+ aggregateId: Schema.String,
30
+ version: Schema.Int,
31
+ type: Schema.String,
32
+ message: Schema.String
33
+ }) {}
34
+
35
+ export type ReadError = EventStoreError | EventDecodeError
36
+
37
+ /** How a command can fail, on top of the errors its own handler declares. */
38
+ export type CommandFailure = ConcurrencyConflict | InvalidCommand | ReadError
39
+
40
+ export interface CommandResult<Event> {
41
+ readonly aggregateName: string
42
+ readonly aggregateId: string
43
+ /** The stream version after the command. */
44
+ readonly version: number
45
+ /** The store position of the last event, to pass to `ViewModels.caughtUp`. */
46
+ readonly position: number
47
+ readonly events: ReadonlyArray<Event>
48
+ }
49
+
50
+ export interface HistoryEntry<Event> {
51
+ readonly version: number
52
+ readonly timestamp: DateTime.Utc
53
+ readonly type: string
54
+ readonly payload?: Event
55
+ }
56
+
57
+ export interface AggregateService<A extends Aggregate.Any> {
58
+ readonly aggregate: A
59
+ /** One typed function per command. */
60
+ readonly commands: {
61
+ readonly [K in Aggregate.CommandName<A>]: (
62
+ aggregateId: string,
63
+ payload: Aggregate.Payload<A, K>
64
+ ) => Effect.Effect<CommandResult<Aggregate.Event<A>>, Aggregate.CommandError<A, K> | CommandFailure>
65
+ }
66
+ /** Runs a `{ aggregateId, type, payload }` envelope received as JSON from outside the process. */
67
+ readonly dispatch: (
68
+ input: unknown
69
+ ) => Effect.Effect<CommandResult<Aggregate.Event<A>>, Aggregate.CommandError<A> | CommandFailure | UnknownCommand>
70
+ /** Materializes the state on the fly, optionally as it was before `finishTime`. */
71
+ readonly readModel: (
72
+ aggregateId: string,
73
+ options?: { readonly finishTime?: DateTime.Utc | undefined }
74
+ ) => Effect.Effect<Aggregate.State<A>, ReadError>
75
+ /** Lists the events of one aggregate, with their decoded payload when `payload` is set. */
76
+ readonly history: (
77
+ aggregateId: string,
78
+ options?: {
79
+ readonly payload?: boolean | undefined
80
+ readonly startTime?: DateTime.Utc | undefined
81
+ readonly finishTime?: DateTime.Utc | undefined
82
+ }
83
+ ) => Effect.Effect<ReadonlyArray<HistoryEntry<Aggregate.Event<A>>>, ReadError>
84
+ }
85
+
86
+ const CommandEnvelope = Schema.Struct({
87
+ aggregateId: Schema.NonEmptyString,
88
+ type: Schema.String,
89
+ payload: Schema.Unknown
90
+ })
91
+
92
+ /** Decodes a stored event with the schema of the aggregate it belongs to. */
93
+ export const makeEventDecoder = <A extends Aggregate.Any>(aggregate: A) => {
94
+ const decode = Schema.decodeUnknownEffect(Schema.toCodecJson(aggregate.events))
95
+ return (stored: StoredEvent): Effect.Effect<Aggregate.Event<A>, EventDecodeError> =>
96
+ decode(stored.payload).pipe(
97
+ Effect.mapError((error) =>
98
+ new EventDecodeError({
99
+ aggregateName: stored.aggregateName,
100
+ aggregateId: stored.aggregateId,
101
+ version: stored.version,
102
+ type: stored.type,
103
+ message: error.message
104
+ })
105
+ )
106
+ ) as Effect.Effect<Aggregate.Event<A>, EventDecodeError>
107
+ }
108
+
109
+ /** Encodes events into the JSON form the store keeps. */
110
+ export const makeEventEncoder = <A extends Aggregate.Any>(aggregate: A) => {
111
+ const encode = Schema.encodeUnknownEffect(Schema.toCodecJson(aggregate.events))
112
+ return (event: Aggregate.Event<A>) =>
113
+ encode(event).pipe(
114
+ // Events come from the aggregate's own handlers, so this is a programming error.
115
+ Effect.orDie,
116
+ Effect.map((payload) => ({ type: event._tag, payload: payload as Schema.Json }))
117
+ )
118
+ }
119
+
120
+ interface Loaded<State> {
121
+ readonly state: State
122
+ readonly version: number
123
+ readonly position: number
124
+ }
125
+
126
+ /** Remembers recent states, dropping the least recently used. A `Map` iterates in insertion order. */
127
+ const makeStateCache = <State>(capacity: number) => {
128
+ const entries = new Map<string, Loaded<State>>()
129
+ return {
130
+ get: (aggregateId: string) => {
131
+ const entry = entries.get(aggregateId)
132
+ if (entry !== undefined) {
133
+ entries.delete(aggregateId)
134
+ entries.set(aggregateId, entry)
135
+ }
136
+ return entry
137
+ },
138
+ set: (aggregateId: string, loaded: Loaded<State>) => {
139
+ if (capacity === 0 || (entries.get(aggregateId)?.position ?? 0) > loaded.position) return
140
+ entries.delete(aggregateId)
141
+ entries.set(aggregateId, loaded)
142
+ if (entries.size > capacity) entries.delete(entries.keys().next().value!)
143
+ }
144
+ }
145
+ }
146
+
147
+ /** Runs effects with the same key one at a time. A key only exists while it is in use. */
148
+ const makeKeyedLock = () => {
149
+ const locks = new Map<string, { readonly semaphore: Semaphore.Semaphore; users: number }>()
150
+ return (key: string) => <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
151
+ Effect.acquireUseRelease(
152
+ Effect.sync(() => {
153
+ const lock = locks.get(key) ?? { semaphore: Semaphore.makeUnsafe(1), users: 0 }
154
+ lock.users++
155
+ locks.set(key, lock)
156
+ return lock
157
+ }),
158
+ (lock) => lock.semaphore.withPermit(effect),
159
+ (lock) =>
160
+ Effect.sync(() => {
161
+ if (--lock.users === 0) locks.delete(key)
162
+ })
163
+ )
164
+ }
165
+
166
+ /**
167
+ * Gives other fibers a turn once in a while. `Effect.forEach` with `concurrency`
168
+ * carries on as long as items don't suspend, and a store on `node:sqlite` never does,
169
+ * so a bulk run would finish before any view model ran. Yielding per command is
170
+ * expensive, so it only happens when a millisecond has passed, using real time
171
+ * because this is about the CPU. The clock restarts after the yield rather than
172
+ * before it, so commands started meanwhile suspend too and the loop lets go.
173
+ */
174
+ const makeTimeSlice = () => {
175
+ let started = performance.now()
176
+ return Effect.suspend(() =>
177
+ performance.now() - started < 1
178
+ ? Effect.void
179
+ : Effect.andThen(Effect.yieldNow, Effect.sync(() => void (started = performance.now())))
180
+ )
181
+ }
182
+
183
+ // Only a rival outside this service can cause a conflict, so give it room to finish.
184
+ const conflictBackoff = Schedule.exponential("10 millis").pipe(Schedule.jittered)
185
+
186
+ export const make = Effect.fnUntraced(function*<A extends Aggregate.Any>(
187
+ aggregate: A,
188
+ options?: {
189
+ /** How many times a command is re-decided after losing a race. Defaults to 5. */
190
+ readonly maxRetries?: number
191
+ /** How many aggregates keep their state in memory between commands. Defaults to 10,000. */
192
+ readonly stateCacheSize?: number
193
+ }
194
+ ) {
195
+ const store = yield* EventStore
196
+ const context = yield* Effect.context<Aggregate.Context<A>>()
197
+ const maxRetries = options?.maxRetries ?? 5
198
+ const aggregateName = aggregate.name
199
+ const decodeEvent = makeEventDecoder(aggregate)
200
+ const encodeEvent = makeEventEncoder(aggregate)
201
+ const states = makeStateCache<Aggregate.State<A>>(options?.stateCacheSize ?? 10_000)
202
+ const lockStream = makeKeyedLock()
203
+ const timeSlice = makeTimeSlice()
204
+
205
+ const replay = <Acc>(
206
+ stream: Stream.Stream<StoredEvent, EventStoreError>,
207
+ initial: Acc,
208
+ step: (acc: Acc, event: Aggregate.Event<A>, stored: StoredEvent) => Acc
209
+ ) =>
210
+ stream.pipe(
211
+ Stream.runFoldEffect(
212
+ () => initial,
213
+ (acc, stored) => Effect.map(decodeEvent(stored), (event) => step(acc, event, stored))
214
+ )
215
+ )
216
+
217
+ const streamOf = (
218
+ aggregateId: string,
219
+ range?: Pick<EventFilter, "startTime" | "finishTime" | "afterPosition" | "afterVersion">
220
+ ) => store.read({ aggregateNames: [aggregateName], aggregateIds: [aggregateId], ...range })
221
+
222
+ const replayFrom = (
223
+ start: Loaded<Aggregate.State<A>>,
224
+ aggregateId: string,
225
+ finishTime?: DateTime.Utc
226
+ ) =>
227
+ replay(
228
+ streamOf(aggregateId, { finishTime, afterPosition: start.position, afterVersion: start.version }),
229
+ start,
230
+ (acc, event, stored) => ({
231
+ state: Aggregate.evolve(aggregate, acc.state, event, stored),
232
+ version: stored.version,
233
+ position: stored.position
234
+ })
235
+ )
236
+
237
+ const initial: Loaded<Aggregate.State<A>> = { state: aggregate.initial as Aggregate.State<A>, version: 0, position: 0 }
238
+
239
+ // Streams only grow, so a remembered state plus what came after it is the current
240
+ // state, even if another process wrote those events.
241
+ const load = (aggregateId: string, finishTime?: DateTime.Utc) =>
242
+ finishTime === undefined
243
+ ? replayFrom(states.get(aggregateId) ?? initial, aggregateId).pipe(
244
+ Effect.tap((loaded) => Effect.sync(() => states.set(aggregateId, loaded)))
245
+ )
246
+ : replayFrom(initial, aggregateId, finishTime)
247
+
248
+ const decide = Effect.fnUntraced(function*(type: string, aggregateId: string, payload: unknown) {
249
+ const { position, state, version } = yield* load(aggregateId)
250
+ const { handler } = aggregate.commands[type]!
251
+ const events: ReadonlyArray<Aggregate.Event<A>> = Arr.ensure(yield* handler(state, payload, { aggregateId, version }))
252
+ if (events.length === 0) {
253
+ return { aggregateName, aggregateId, version, position, events }
254
+ }
255
+ const stored = yield* store.append({
256
+ aggregateName,
257
+ aggregateId,
258
+ expectedVersion: version,
259
+ events: yield* Effect.forEach(events, encodeEvent)
260
+ })
261
+ const last = stored[stored.length - 1]!
262
+ return { aggregateName, aggregateId, version: last.version, position: last.position, events }
263
+ })
264
+
265
+ // Commands on one stream take turns, so they can't race each other and only a
266
+ // rival in another process causes a conflict. A handler must not run a command on
267
+ // its own stream, which would wait for itself.
268
+ const execute = (type: string, aggregateId: string, payload: unknown) =>
269
+ decide(type, aggregateId, payload).pipe(
270
+ Effect.retry({
271
+ times: maxRetries,
272
+ while: (error) => error instanceof ConcurrencyConflict,
273
+ schedule: conflictBackoff
274
+ }),
275
+ Effect.provideContext(context),
276
+ lockStream(aggregateId),
277
+ (decided) => Effect.andThen(timeSlice, decided),
278
+ Effect.withSpan(`${aggregateName}.${type}`, { attributes: { "aggregate.id": aggregateId } })
279
+ )
280
+
281
+ const decodePayload = (type: string, schema: Aggregate.PayloadSchema, input: unknown) =>
282
+ Schema.decodeUnknownEffect(schema)(input).pipe(
283
+ Effect.mapError((error) => new InvalidCommand({ aggregateName, type, message: error.message }))
284
+ )
285
+
286
+ const commands = Object.fromEntries(
287
+ Object.entries(aggregate.commands).map(([type, command]) => [
288
+ type,
289
+ (aggregateId: string, payload: unknown) =>
290
+ decodePayload(type, Schema.toType(command.payload), payload).pipe(
291
+ Effect.flatMap((decoded) => execute(type, aggregateId, decoded))
292
+ )
293
+ ])
294
+ )
295
+
296
+ const decodeEnvelope = Schema.decodeUnknownEffect(CommandEnvelope)
297
+
298
+ const dispatch = (input: unknown) =>
299
+ Effect.gen(function*() {
300
+ const envelope = yield* decodeEnvelope(input).pipe(
301
+ Effect.mapError((error) => new InvalidCommand({ aggregateName, type: "unknown", message: error.message }))
302
+ )
303
+ if (!Object.hasOwn(aggregate.commands, envelope.type)) {
304
+ return yield* new UnknownCommand({ aggregateName, type: envelope.type })
305
+ }
306
+ const payload = yield* decodePayload(
307
+ envelope.type,
308
+ Schema.toCodecJson(aggregate.commands[envelope.type]!.payload),
309
+ envelope.payload
310
+ )
311
+ return yield* execute(envelope.type, envelope.aggregateId, payload)
312
+ })
313
+
314
+ const readModel: AggregateService<A>["readModel"] = (aggregateId, options) =>
315
+ load(aggregateId, options?.finishTime).pipe(
316
+ Effect.map(({ state }) => state),
317
+ Effect.withSpan(`${aggregateName}.readModel`, { attributes: { "aggregate.id": aggregateId } })
318
+ )
319
+
320
+ const history: AggregateService<A>["history"] = (aggregateId, options) =>
321
+ replay(
322
+ streamOf(aggregateId, { startTime: options?.startTime, finishTime: options?.finishTime }),
323
+ [] as Array<HistoryEntry<Aggregate.Event<A>>>,
324
+ (entries, event, stored) => {
325
+ entries.push({
326
+ version: stored.version,
327
+ timestamp: stored.timestamp,
328
+ type: stored.type,
329
+ ...(options?.payload === true ? { payload: event } : {})
330
+ })
331
+ return entries
332
+ }
333
+ ).pipe(Effect.withSpan(`${aggregateName}.history`, { attributes: { "aggregate.id": aggregateId } }))
334
+
335
+ return {
336
+ aggregate,
337
+ commands,
338
+ dispatch,
339
+ readModel,
340
+ history
341
+ } as unknown as AggregateService<A>
342
+ })
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Remembers how far each view model has read.
3
+ *
4
+ * A checkpoint has to live as long as the view model it tracks: pair an
5
+ * in-memory view model with {@link layerMemory} so it rebuilds from the event
6
+ * store on restart, and a durable one with a durable checkpoint store.
7
+ */
8
+ import { Context, Effect, Layer } from "effect"
9
+ import type { EventStoreError } from "./EventStore.ts"
10
+
11
+ export class Checkpoints extends Context.Service<Checkpoints, {
12
+ /** The last processed position, `0` when the view model never ran. */
13
+ get(viewModel: string): Effect.Effect<number, EventStoreError>
14
+ set(viewModel: string, position: number): Effect.Effect<void, EventStoreError>
15
+ }>()("effect-cqrs/Checkpoints") {}
16
+
17
+ export const layerMemory: Layer.Layer<Checkpoints> = Layer.sync(Checkpoints, () => {
18
+ const positions = new Map<string, number>()
19
+ return Checkpoints.of({
20
+ get: (viewModel) => Effect.sync(() => positions.get(viewModel) ?? 0),
21
+ set: (viewModel, position) => Effect.sync(() => void positions.set(viewModel, position))
22
+ })
23
+ })
@@ -0,0 +1,233 @@
1
+ /**
2
+ * The append-only log every aggregate and view model is built on.
3
+ *
4
+ * Each event belongs to a stream, identified by aggregate name and id, and has
5
+ * two coordinates: a `version` that orders it inside its stream and a `position`
6
+ * that orders it across the whole store. Writers use the version for optimistic
7
+ * concurrency, readers use the position as a durable cursor.
8
+ */
9
+ import { Context, DateTime, Effect, Layer, Option, Ref, Schema, Stream, SubscriptionRef } from "effect"
10
+
11
+ export class EventStoreError extends Schema.TaggedError<EventStoreError>()("EventStoreError", {
12
+ operation: Schema.String,
13
+ cause: Schema.Defect()
14
+ }) {}
15
+
16
+ export class ConcurrencyConflict extends Schema.TaggedError<ConcurrencyConflict>()("ConcurrencyConflict", {
17
+ aggregateName: Schema.String,
18
+ aggregateId: Schema.String,
19
+ expectedVersion: Schema.Int
20
+ }) {}
21
+
22
+ export const StoredEvent = Schema.Struct({
23
+ position: Schema.Int,
24
+ aggregateName: Schema.String,
25
+ aggregateId: Schema.String,
26
+ version: Schema.Int,
27
+ type: Schema.String,
28
+ payload: Schema.Json,
29
+ timestamp: Schema.DateTimeUtcFromMillis
30
+ })
31
+ export type StoredEvent = typeof StoredEvent.Type
32
+
33
+ export interface NewEvent {
34
+ readonly type: string
35
+ readonly payload: Schema.Json
36
+ }
37
+
38
+ export interface AppendInput {
39
+ readonly aggregateName: string
40
+ readonly aggregateId: string
41
+ /** The stream version the writer based its decision on, `0` for a new stream. */
42
+ readonly expectedVersion: number
43
+ readonly events: ReadonlyArray<NewEvent>
44
+ }
45
+
46
+ /**
47
+ * Selects events by stream, type and time. Omitted fields match everything.
48
+ * `startTime` is inclusive and `finishTime` is exclusive, so adding one
49
+ * millisecond to a history timestamp selects up to and including that event.
50
+ */
51
+ export interface EventFilter {
52
+ readonly aggregateNames?: ReadonlyArray<string> | undefined
53
+ readonly aggregateIds?: ReadonlyArray<string> | undefined
54
+ readonly eventTypes?: ReadonlyArray<string> | undefined
55
+ readonly startTime?: DateTime.Utc | undefined
56
+ readonly finishTime?: DateTime.Utc | undefined
57
+ readonly afterPosition?: number | undefined
58
+ /**
59
+ * For a filter on one stream: where to continue in it. Versions follow the stream's
60
+ * own index, so resuming there costs the same however far the log has moved on.
61
+ */
62
+ readonly afterVersion?: number | undefined
63
+ }
64
+
65
+ /** A filter on exactly one stream, which a store can read through that stream's index. */
66
+ export const isOneStream = ({ aggregateIds, aggregateNames }: EventFilter): boolean =>
67
+ aggregateNames?.length === 1 && aggregateIds?.length === 1
68
+
69
+ export class EventStore extends Context.Service<EventStore, {
70
+ /** Appends atomically, failing if the stream moved past `expectedVersion`. */
71
+ append(input: AppendInput): Effect.Effect<ReadonlyArray<StoredEvent>, ConcurrencyConflict | EventStoreError>
72
+ /** Reads the events stored so far, ordered by position, then completes. */
73
+ read(filter?: EventFilter): Stream.Stream<StoredEvent, EventStoreError>
74
+ /** Reads the events stored so far, then keeps following new ones. Never completes. */
75
+ subscribe(filter?: EventFilter): Stream.Stream<StoredEvent, EventStoreError>
76
+ /** The position of the latest event, `0` when the store is empty. */
77
+ readonly head: Effect.Effect<number, EventStoreError>
78
+ /**
79
+ * Emits the current latest position, then each newly appended one. Consumers
80
+ * can treat an element as "there may be something new": appends from other
81
+ * processes are not observed.
82
+ */
83
+ readonly changes: Stream.Stream<number>
84
+ }>()("effect-cqrs/EventStore") {}
85
+
86
+ /** What a storage adapter implements. {@link make} builds the rest on top. */
87
+ export interface Backend {
88
+ readonly append: EventStore["Service"]["append"]
89
+ readonly readBatch: (
90
+ filter: EventFilter,
91
+ afterPosition: number,
92
+ limit: number
93
+ ) => Effect.Effect<ReadonlyArray<StoredEvent>, EventStoreError>
94
+ readonly head: Effect.Effect<number, EventStoreError>
95
+ }
96
+
97
+ export const make = Effect.fnUntraced(function*(backend: Backend, options?: { readonly batchSize?: number }) {
98
+ const batchSize = options?.batchSize ?? 500
99
+ const latest = yield* SubscriptionRef.make(yield* backend.head)
100
+
101
+ const append = Effect.fn("EventStore.append")((input: AppendInput) =>
102
+ backend.append(input).pipe(
103
+ Effect.tap((stored) => {
104
+ const last = stored.at(-1)
105
+ return last === undefined
106
+ ? Effect.void
107
+ : SubscriptionRef.update(latest, (position) => Math.max(position, last.position))
108
+ })
109
+ )
110
+ )
111
+
112
+ const read = (filter: EventFilter = {}) =>
113
+ Stream.paginate(
114
+ { afterPosition: filter.afterPosition ?? 0, afterVersion: filter.afterVersion ?? 0 },
115
+ ({ afterPosition, afterVersion }) =>
116
+ backend.readBatch({ ...filter, afterVersion }, afterPosition, batchSize).pipe(
117
+ Effect.map((events) => {
118
+ const last = events.at(-1)
119
+ return [
120
+ events,
121
+ last === undefined || events.length < batchSize
122
+ ? Option.none()
123
+ : Option.some({ afterPosition: last.position, afterVersion: last.version })
124
+ ] as const
125
+ })
126
+ )
127
+ )
128
+
129
+ const subscribe = (filter: EventFilter = {}) =>
130
+ Stream.unwrap(Effect.gen(function*() {
131
+ const cursor = yield* Ref.make({ position: filter.afterPosition ?? 0, version: filter.afterVersion ?? 0 })
132
+ return SubscriptionRef.changes(latest).pipe(
133
+ Stream.filterEffect((position) => Effect.map(Ref.get(cursor), (seen) => position > seen.position)),
134
+ Stream.flatMap((position) =>
135
+ Stream.unwrap(
136
+ Effect.map(Ref.get(cursor), (seen) =>
137
+ read({ ...filter, afterPosition: seen.position, afterVersion: seen.version }))
138
+ ).pipe(
139
+ Stream.tap((event) => Ref.set(cursor, { position: event.position, version: event.version })),
140
+ // Events the filter skipped must not trigger another read.
141
+ Stream.ensuring(
142
+ Ref.update(cursor, (seen) => seen.position >= position ? seen : { ...seen, position })
143
+ )
144
+ )
145
+ )
146
+ )
147
+ }))
148
+
149
+ return EventStore.of({
150
+ append,
151
+ read,
152
+ subscribe,
153
+ head: backend.head,
154
+ changes: SubscriptionRef.changes(latest)
155
+ })
156
+ })
157
+
158
+ const matches = (filter: EventFilter, event: StoredEvent): boolean =>
159
+ (filter.aggregateNames?.includes(event.aggregateName) ?? true) &&
160
+ (filter.aggregateIds?.includes(event.aggregateId) ?? true) &&
161
+ (filter.eventTypes?.includes(event.type) ?? true) &&
162
+ (filter.startTime === undefined || !DateTime.isLessThan(event.timestamp, filter.startTime)) &&
163
+ (filter.finishTime === undefined || DateTime.isLessThan(event.timestamp, filter.finishTime))
164
+
165
+ /** The index of the first event past `position`, in events sorted by position. */
166
+ const firstAfter = (events: ReadonlyArray<StoredEvent>, position: number): number => {
167
+ let low = 0
168
+ let high = events.length
169
+ while (low < high) {
170
+ const middle = (low + high) >>> 1
171
+ if (events[middle]!.position <= position) low = middle + 1
172
+ else high = middle
173
+ }
174
+ return low
175
+ }
176
+
177
+ const streamKey = (aggregateName: string, aggregateId: string) => JSON.stringify([aggregateName, aggregateId])
178
+
179
+ export const makeMemory: Effect.Effect<EventStore["Service"], EventStoreError> = Effect.gen(function*() {
180
+ const events: Array<StoredEvent> = []
181
+ const streams = new Map<string, Array<StoredEvent>>()
182
+
183
+ // Every command reads one stream, which shouldn't cost a walk through the whole log.
184
+ const candidates = (filter: EventFilter): ReadonlyArray<StoredEvent> =>
185
+ isOneStream(filter)
186
+ ? streams.get(streamKey(filter.aggregateNames![0]!, filter.aggregateIds![0]!)) ?? []
187
+ : events
188
+
189
+ return yield* make({
190
+ append: Effect.fnUntraced(function*(input) {
191
+ const timestamp = yield* DateTime.now
192
+ const key = streamKey(input.aggregateName, input.aggregateId)
193
+ const stream = streams.get(key) ?? []
194
+ const version = stream.length
195
+ if (version !== input.expectedVersion) {
196
+ return yield* new ConcurrencyConflict({
197
+ aggregateName: input.aggregateName,
198
+ aggregateId: input.aggregateId,
199
+ expectedVersion: input.expectedVersion
200
+ })
201
+ }
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
+ }))
211
+ for (const event of stored) {
212
+ events.push(event)
213
+ stream.push(event)
214
+ }
215
+ streams.set(key, stream)
216
+ return stored
217
+ }),
218
+ readBatch: (filter, afterPosition, limit) =>
219
+ Effect.sync(() => {
220
+ const source = candidates(filter)
221
+ const batch: Array<StoredEvent> = []
222
+ for (let index = firstAfter(source, afterPosition); index < source.length && batch.length < limit; index++) {
223
+ const event = source[index]!
224
+ if (matches(filter, event)) batch.push(event)
225
+ }
226
+ return batch
227
+ }),
228
+ head: Effect.sync(() => events.length)
229
+ })
230
+ })
231
+
232
+ /** Keeps events in process memory. They do not survive a restart. */
233
+ export const layerMemory: Layer.Layer<EventStore, EventStoreError> = Layer.effect(EventStore, makeMemory)