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,348 @@
1
+ /**
2
+ * The read side: projections of events into whatever storage a query needs.
3
+ *
4
+ * Moleculer-cqrs pushes events to view models over the broker, so an event sent
5
+ * while a service is down is lost until someone replays. Here each view model
6
+ * pulls from the store from its own checkpoint, so it can't miss anything, and
7
+ * delivery is at least once: a handler can see an event again after a crash and
8
+ * should be idempotent.
9
+ */
10
+ import {
11
+ Context,
12
+ Duration,
13
+ Effect,
14
+ Layer,
15
+ Option,
16
+ Ref,
17
+ Schedule,
18
+ Schema,
19
+ Semaphore,
20
+ Stream,
21
+ SubscriptionRef
22
+ } from "effect"
23
+ import type { DateTime } from "effect"
24
+ import type * as Aggregate from "./Aggregate.ts"
25
+ import { makeEventDecoder } from "./AggregateService.ts"
26
+ import { Checkpoints } from "./Checkpoints.ts"
27
+ import { type EventFilter, EventStore, type EventStoreError, type StoredEvent } from "./EventStore.ts"
28
+
29
+ export class UnknownViewModel extends Schema.TaggedError<UnknownViewModel>()("UnknownViewModel", {
30
+ name: Schema.String
31
+ }) {}
32
+
33
+ export class ViewModelError extends Schema.TaggedError<ViewModelError>()("ViewModelError", {
34
+ viewModel: Schema.String,
35
+ /** The event being processed, `0` when the failure happened outside any event. */
36
+ position: Schema.Int,
37
+ cause: Schema.Defect()
38
+ }) {}
39
+
40
+ export type Handlers<Event extends Aggregate.Tagged> = {
41
+ readonly [Tag in Event["_tag"]]?: (
42
+ event: Extract<Event, { readonly _tag: Tag }>,
43
+ meta: Aggregate.EventMeta
44
+ ) => Effect.Effect<unknown, any, any>
45
+ }
46
+
47
+ type HandlerError<H> = {
48
+ readonly [K in keyof H]-?: NonNullable<H[K]> extends (...args: any) => Effect.Effect<any, infer E, any> ? E : never
49
+ }[keyof H]
50
+
51
+ type HandlerContext<H> = {
52
+ readonly [K in keyof H]-?: NonNullable<H[K]> extends (...args: any) => Effect.Effect<any, any, infer R> ? R : never
53
+ }[keyof H]
54
+
55
+ export interface ViewModel<out Name extends string, out E, out R> {
56
+ readonly name: Name
57
+ readonly aggregates: ReadonlyArray<Aggregate.Any>
58
+ readonly handlers: Record<string, (event: never, meta: Aggregate.EventMeta) => Effect.Effect<unknown, E, R>>
59
+ /** Clears everything the view model stored, before a replay rebuilds it. */
60
+ readonly dispose: Effect.Effect<void, E, R>
61
+ }
62
+
63
+ export type Any = ViewModel<string, any, any>
64
+
65
+ export type Context<V extends Any> = V extends ViewModel<any, any, infer R> ? R : never
66
+
67
+ /** Handlers are keyed by event tag, and events without one are skipped. */
68
+ export const make = <
69
+ const Name extends string,
70
+ const Aggregates extends ReadonlyArray<Aggregate.Any>,
71
+ const H extends Handlers<Aggregate.Event<Aggregates[number]>>,
72
+ E = never,
73
+ R = never
74
+ >(options: {
75
+ readonly name: Name
76
+ readonly aggregates: Aggregates
77
+ readonly handlers: H
78
+ readonly dispose: Effect.Effect<void, E, R>
79
+ }): ViewModel<Name, E | HandlerError<H>, R | HandlerContext<H>> => options as any
80
+
81
+ export interface ReplayOptions {
82
+ readonly viewModels: ReadonlyArray<string>
83
+ readonly startTime?: DateTime.Utc | undefined
84
+ readonly finishTime?: DateTime.Utc | undefined
85
+ }
86
+
87
+ export interface ReplayResult {
88
+ readonly viewModels: ReadonlyArray<string>
89
+ readonly eventCount: number
90
+ }
91
+
92
+ export interface Status {
93
+ readonly name: string
94
+ readonly position: number
95
+ /** The failure the view model is retrying, while it is stalled on one. */
96
+ readonly lastError: Option.Option<ViewModelError>
97
+ }
98
+
99
+ export class ViewModels extends Context.Service<ViewModels, {
100
+ /**
101
+ * Disposes the named view models and rebuilds them from their events. A time
102
+ * window limits which events are replayed: the view model then resumes from
103
+ * the present and events outside the window stay out of it.
104
+ */
105
+ replay(options: ReplayOptions): Effect.Effect<ReplayResult, UnknownViewModel | ViewModelError>
106
+ /**
107
+ * Waits until every view model has processed `position`, defaulting to the
108
+ * latest event. Pair it with `CommandResult.position` to read your own writes.
109
+ * A view model stalled on a failure doesn't catch up, so consider a timeout.
110
+ */
111
+ caughtUp(position?: number): Effect.Effect<void, EventStoreError>
112
+ readonly status: Effect.Effect<ReadonlyArray<Status>>
113
+ }>()("effect-cqrs/ViewModels") {}
114
+
115
+ // Stalled view models keep their place so events are never processed out of order.
116
+ const retryDelay = (attempt: number) => Duration.millis(Math.min(100 * 2 ** attempt, 30_000))
117
+
118
+ // Following the log moves a few events at a time, and a checkpoint for each of those
119
+ // moves is a commit per view model per event. Saving less often only means that a
120
+ // crash repeats more, which handlers already have to cope with.
121
+ const checkpointInterval = 500
122
+
123
+ // How far behind a view model can be and still read the tail everyone shares. It is
124
+ // one page of the store, so a shared read never has to page.
125
+ const tailSize = 500
126
+
127
+ /**
128
+ * Reads the end of the log once for all view models. When they are caught up they
129
+ * wake on the same append and want the same few events, so each reading them for
130
+ * itself is most of what a view model costs.
131
+ */
132
+ const makeTail = (store: EventStore["Service"], viewModels: ReadonlyArray<Any>) => {
133
+ const filter: EventFilter = {
134
+ aggregateNames: Array.from(new Set(viewModels.flatMap((viewModel) => viewModel.aggregates.map(({ name }) => name)))),
135
+ eventTypes: Array.from(new Set(viewModels.flatMap((viewModel) => Object.keys(viewModel.handlers))))
136
+ }
137
+ const decoded = new WeakMap<StoredEvent, unknown>()
138
+ let last: { readonly afterPosition: number; readonly latest: number; readonly events: ReadonlyArray<StoredEvent> } | undefined
139
+
140
+ return {
141
+ read: (afterPosition: number, latest: number) =>
142
+ Effect.suspend(() =>
143
+ last?.afterPosition === afterPosition && last.latest === latest
144
+ ? Effect.succeed(last.events)
145
+ : Stream.runCollect(store.read({ ...filter, afterPosition })).pipe(
146
+ Effect.tap((events) => Effect.sync(() => void (last = { afterPosition, latest, events })))
147
+ )
148
+ ),
149
+ decoded
150
+ }
151
+ }
152
+
153
+ type Tail = ReturnType<typeof makeTail>
154
+
155
+ const makeRunner = Effect.fnUntraced(function*<V extends Any>(viewModel: V, tail: Tail) {
156
+ const store = yield* EventStore
157
+ const checkpoints = yield* Checkpoints
158
+ const context = yield* Effect.context<Context<V>>()
159
+ const provide = <A, E>(effect: Effect.Effect<A, E, any>) => Effect.provideContext(effect, context) as Effect.Effect<A, E>
160
+ const semaphore = yield* Semaphore.make(1)
161
+ const name = viewModel.name
162
+
163
+ const fail = (position: number) => (cause: unknown) => new ViewModelError({ viewModel: name, position, cause })
164
+
165
+ const checkpoint = yield* checkpoints.get(name)
166
+ const position = yield* SubscriptionRef.make(checkpoint)
167
+ const saved = yield* Ref.make(checkpoint)
168
+ const lastError = yield* Ref.make(Option.none<ViewModelError>())
169
+ const stall = yield* Ref.make({ position: -1, attempts: 0 })
170
+
171
+ const decoders = new Map(viewModel.aggregates.map((aggregate) => [aggregate.name, makeEventDecoder(aggregate)]))
172
+ const filter: EventFilter = {
173
+ aggregateNames: Array.from(decoders.keys()),
174
+ eventTypes: Object.keys(viewModel.handlers)
175
+ }
176
+ const wants = (stored: StoredEvent) =>
177
+ decoders.has(stored.aggregateName) && Object.hasOwn(viewModel.handlers, stored.type)
178
+
179
+ const decode = (stored: StoredEvent) => decoders.get(stored.aggregateName)!(stored)
180
+
181
+ // Events from the tail are the same objects for everyone, so the first to decode one
182
+ // does it for the rest.
183
+ const decodeShared = (stored: StoredEvent) =>
184
+ tail.decoded.has(stored)
185
+ ? Effect.succeed(tail.decoded.get(stored))
186
+ : decode(stored).pipe(Effect.tap((event) => Effect.sync(() => void tail.decoded.set(stored, event))))
187
+
188
+ const handle = (decodeEvent: (stored: StoredEvent) => Effect.Effect<unknown, unknown>) => (stored: StoredEvent) =>
189
+ Effect.gen(function*() {
190
+ const event = yield* decodeEvent(stored)
191
+ yield* viewModel.handlers[stored.type]!(event as never, stored)
192
+ }).pipe(
193
+ Effect.mapError(fail(stored.position)),
194
+ Effect.catchDefect((defect) => Effect.fail(fail(stored.position)(defect)))
195
+ )
196
+
197
+ const save = (to: number) =>
198
+ checkpoints.set(name, to).pipe(
199
+ Effect.mapError(fail(0)),
200
+ Effect.andThen(Ref.set(saved, to))
201
+ )
202
+
203
+ const advance = (to: number) =>
204
+ Ref.get(saved).pipe(
205
+ Effect.flatMap((last) => to < last || to - last >= checkpointInterval ? save(to) : Effect.void),
206
+ Effect.andThen(SubscriptionRef.set(position, to))
207
+ )
208
+
209
+ // Finalizers run last to first, so this one sees the fiber below already stopped.
210
+ yield* Effect.addFinalizer(() => SubscriptionRef.get(position).pipe(Effect.flatMap(save), Effect.ignore))
211
+
212
+ // A crash repeats at most what came after the last saved checkpoint: two batches.
213
+ const processBatch = (batch: ReadonlyArray<StoredEvent>, decodeEvent: typeof decode | typeof decodeShared) =>
214
+ Effect.forEach(batch, handle(decodeEvent), { discard: true }).pipe(
215
+ Effect.andThen(advance(batch[batch.length - 1]!.position))
216
+ )
217
+
218
+ const consume = (eventFilter: EventFilter, onBatch: (size: number) => Effect.Effect<void>) =>
219
+ store.read(eventFilter).pipe(
220
+ Stream.mapError(fail(0)),
221
+ Stream.runForEachArray((batch) => processBatch(batch, decode).pipe(Effect.andThen(onBatch(batch.length))))
222
+ )
223
+
224
+ const follow = (afterPosition: number, latest: number) =>
225
+ tail.read(afterPosition, latest).pipe(
226
+ Effect.mapError(fail(0)),
227
+ Effect.map((events) => events.filter(wants)),
228
+ Effect.flatMap((events) => events.length === 0 ? Effect.void : processBatch(events, decodeShared))
229
+ )
230
+
231
+ // Moves past the end of the log even when the last events were not for this view model.
232
+ const settle = (head: number) =>
233
+ SubscriptionRef.get(position).pipe(
234
+ Effect.flatMap((current) => current < head ? advance(head) : Effect.void)
235
+ )
236
+
237
+ const catchUp = (latest: number) =>
238
+ Effect.gen(function*() {
239
+ const afterPosition = yield* SubscriptionRef.get(position)
240
+ if (latest <= afterPosition) return
241
+ if (latest - afterPosition <= tailSize) yield* follow(afterPosition, latest)
242
+ else yield* consume({ ...filter, afterPosition }, () => Effect.void)
243
+ yield* settle(latest)
244
+ }).pipe(
245
+ semaphore.withPermit,
246
+ provide
247
+ )
248
+
249
+ // Failing again at the same place doubles the delay. Having moved on means that
250
+ // stall is over, so the next one starts from the shortest delay.
251
+ const backOff = (error: ViewModelError) =>
252
+ Effect.gen(function*() {
253
+ const current = yield* SubscriptionRef.get(position)
254
+ const attempt = yield* Ref.modify(stall, (last) => {
255
+ const attempts = last.position === current ? last.attempts + 1 : 0
256
+ return [attempts, { position: current, attempts }] as const
257
+ })
258
+ yield* Ref.set(lastError, Option.some(error))
259
+ yield* Effect.logError(`View model ${name} failed, retrying`, error)
260
+ yield* Effect.sleep(retryDelay(attempt))
261
+ })
262
+
263
+ yield* store.changes.pipe(
264
+ Stream.filterEffect((latest) => Effect.map(SubscriptionRef.get(position), (current) => latest > current)),
265
+ Stream.runForEach((latest) =>
266
+ catchUp(latest).pipe(
267
+ Effect.tapError(backOff),
268
+ Effect.retry(Schedule.forever),
269
+ Effect.andThen(Ref.set(lastError, Option.none()))
270
+ )
271
+ ),
272
+ Effect.forkScoped
273
+ )
274
+
275
+ const replay = (range: Pick<ReplayOptions, "startTime" | "finishTime">) =>
276
+ Effect.gen(function*() {
277
+ // The checkpoint is cleared first: a crash in between then replays over
278
+ // old data, which is safe for idempotent handlers, instead of leaving a
279
+ // disposed view model that resumes from a stale position.
280
+ yield* advance(0)
281
+ yield* viewModel.dispose.pipe(Effect.mapError(fail(0)))
282
+ const head = yield* store.head.pipe(Effect.mapError(fail(0)))
283
+ const count = yield* Ref.make(0)
284
+ yield* consume({ ...filter, ...range }, (size) => Ref.update(count, (n) => n + size))
285
+ yield* settle(head)
286
+ return yield* Ref.get(count)
287
+ }).pipe(semaphore.withPermit, provide)
288
+
289
+ const caughtUp = (target: number) =>
290
+ SubscriptionRef.changes(position).pipe(
291
+ Stream.filter((current) => current >= target),
292
+ Stream.runHead,
293
+ Effect.asVoid
294
+ )
295
+
296
+ const status = Effect.gen(function*() {
297
+ return {
298
+ name,
299
+ position: yield* SubscriptionRef.get(position),
300
+ lastError: yield* Ref.get(lastError)
301
+ } satisfies Status
302
+ })
303
+
304
+ return { name, replay, caughtUp, status }
305
+ })
306
+
307
+ /** Starts the view models in the background for as long as the layer lives. */
308
+ export const layer = <const VMs extends ReadonlyArray<Any>>(
309
+ viewModels: VMs
310
+ ): Layer.Layer<ViewModels, EventStoreError, EventStore | Checkpoints | Context<VMs[number]>> =>
311
+ Layer.effect(
312
+ ViewModels,
313
+ Effect.gen(function*() {
314
+ const store = yield* EventStore
315
+ const tail = makeTail(store, viewModels)
316
+ const runners = yield* Effect.forEach(viewModels, (viewModel) => makeRunner(viewModel, tail))
317
+
318
+ const replay = Effect.fn("ViewModels.replay")(function*(options: ReplayOptions) {
319
+ const selected = yield* Effect.forEach(options.viewModels, (name) => {
320
+ const runner = runners.find((runner) => runner.name === name)
321
+ return runner === undefined ? new UnknownViewModel({ name }) : Effect.succeed(runner)
322
+ })
323
+ const counts = yield* Effect.forEach(
324
+ selected,
325
+ (runner) => runner.replay({ startTime: options.startTime, finishTime: options.finishTime }),
326
+ { concurrency: "unbounded" }
327
+ )
328
+ return {
329
+ viewModels: options.viewModels,
330
+ eventCount: counts.reduce((total, count) => total + count, 0)
331
+ }
332
+ })
333
+
334
+ const caughtUp = Effect.fn("ViewModels.caughtUp")(function*(position?: number) {
335
+ const target = position ?? (yield* store.head)
336
+ yield* Effect.forEach(runners, (runner) => runner.caughtUp(target), {
337
+ concurrency: "unbounded",
338
+ discard: true
339
+ })
340
+ })
341
+
342
+ return ViewModels.of({
343
+ replay,
344
+ caughtUp,
345
+ status: Effect.forEach(runners, (runner) => runner.status)
346
+ })
347
+ })
348
+ )
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * as Aggregate from "./Aggregate.ts"
2
+ export * as AggregateService from "./AggregateService.ts"
3
+ export * as Checkpoints from "./Checkpoints.ts"
4
+ export * as EventStore from "./EventStore.ts"
5
+ export * as Fixture from "./Fixture.ts"
6
+ export * as PostgresEventStore from "./PostgresEventStore.ts"
7
+ export * as SqliteEventStore from "./SqliteEventStore.ts"
8
+ export * as ViewModel from "./ViewModel.ts"