@codeforbreakfast/eventsourcing-store-postgres
Version:
Production-ready PostgreSQL event store with Effect integration - Scalable, ACID-compliant event persistence with type-safe database operations and streaming
217 lines (204 loc) • 5.88 kB
text/typescript
import {
Duration,
Effect,
HashMap,
Layer,
Option,
PubSub,
Schedule,
Stream,
SynchronizedRef,
pipe,
} from 'effect';
import type { ReadonlyDeep } from 'type-fest';
import {
EventStreamId,
EventStoreError,
eventStoreError,
} from '@codeforbreakfast/eventsourcing-store';
/**
* Container for subscription data for a specific stream
*/
interface SubscriptionData<T> {
readonly pubsub: PubSub.PubSub<T>;
}
/**
* SubscriptionManager service for managing subscriptions to event streams
*/
export interface SubscriptionManagerService {
/**
* Subscribe to a specific event stream and return a Stream of events
*/
readonly subscribeToStream: (
streamId: EventStreamId
) => Effect.Effect<Stream.Stream<string, never>, EventStoreError, never>;
/**
* Unsubscribe from a specific event stream
*/
readonly unsubscribeFromStream: (
streamId: EventStreamId
) => Effect.Effect<void, EventStoreError, never>;
/**
* Publish an event to all subscribers of a stream
*/
readonly publishEvent: (
streamId: EventStreamId,
event: string
) => Effect.Effect<void, EventStoreError, never>;
}
export class SubscriptionManager extends Effect.Tag('SubscriptionManager')<
SubscriptionManager,
SubscriptionManagerService
>() {}
/**
* Get or create a PubSub for a stream ID
*/
const getOrCreatePubSub = <T>(
ref: ReadonlyDeep<
SynchronizedRef.SynchronizedRef<HashMap.HashMap<EventStreamId, SubscriptionData<T>>>
>,
streamId: EventStreamId
): Effect.Effect<SubscriptionData<T>, never, never> =>
pipe(
SynchronizedRef.updateAndGet(
ref,
(subs: HashMap.HashMap<EventStreamId, SubscriptionData<T>>) => {
const streamIdOption = HashMap.get(streamId)(subs);
return pipe(
streamIdOption,
Option.match({
onNone: () => {
const createPubSub = PubSub.bounded<T>(256);
return pipe(
subs,
(s) =>
Effect.map(createPubSub, (pubsub) => {
const data = { pubsub };
return HashMap.set(streamId, data)(s);
}),
Effect.runSync
);
},
onSome: () => subs,
})
);
}
),
Effect.flatMap((subscriptions: HashMap.HashMap<EventStreamId, SubscriptionData<T>>) =>
pipe(
HashMap.get(streamId)(subscriptions),
Option.match({
onNone: () => Effect.die("Subscription should exist but doesn't"),
onSome: (data: ReadonlyDeep<SubscriptionData<T>>) => Effect.succeed(data),
})
)
)
);
/**
* Remove a subscription for a stream ID
*/
const removeSubscription = <T>(
ref: ReadonlyDeep<
SynchronizedRef.SynchronizedRef<HashMap.HashMap<EventStreamId, SubscriptionData<T>>>
>,
streamId: EventStreamId
): Effect.Effect<void, never, never> =>
pipe(
SynchronizedRef.update(ref, (subscriptions) => {
return pipe(subscriptions, HashMap.remove(streamId));
}),
Effect.as(undefined)
);
/**
* Publish an event to subscribers of a stream
*/
const publishToStream = <T>(
ref: ReadonlyDeep<
SynchronizedRef.SynchronizedRef<HashMap.HashMap<EventStreamId, SubscriptionData<T>>>
>,
streamId: EventStreamId,
event: T
): Effect.Effect<void, never, never> =>
pipe(
SynchronizedRef.get(ref),
Effect.flatMap((subscriptions) =>
pipe(
HashMap.get(streamId)(subscriptions),
Option.match({
onNone: () => Effect.succeed(undefined),
onSome: (subData: ReadonlyDeep<SubscriptionData<T>>) =>
pipe(
PubSub.publish(event)(subData.pubsub),
Effect.tapError((error) =>
Effect.logError('Failed to publish event to subscribers', {
error,
streamId,
})
)
),
})
)
)
);
/**
* Implementation of SubscriptionManager service
*/
export const SubscriptionManagerLive = Layer.effect(
SubscriptionManager,
pipe(
SynchronizedRef.make<HashMap.HashMap<EventStreamId, SubscriptionData<string>>>(HashMap.empty()),
Effect.map((ref) => ({
subscribeToStream: (
streamId: EventStreamId
): Effect.Effect<Stream.Stream<string, never>, EventStoreError, never> =>
pipe(
getOrCreatePubSub(ref, streamId),
Effect.map((pubsub: ReadonlyDeep<SubscriptionData<string>>) =>
pipe(
Stream.fromPubSub(pubsub.pubsub),
Stream.retry(
pipe(
Schedule.exponential(Duration.millis(100), 1.5),
Schedule.whileOutput((d) => Duration.toMillis(d) < 30000)
)
)
)
),
Effect.mapError((error) =>
eventStoreError.subscribe(
streamId,
`Failed to subscribe to stream: ${String(error)}`,
error
)
)
),
unsubscribeFromStream: (
streamId: EventStreamId
): Effect.Effect<void, EventStoreError, never> =>
pipe(
removeSubscription(ref, streamId),
Effect.mapError((error) =>
eventStoreError.subscribe(
streamId,
`Failed to unsubscribe from stream: ${String(error)}`,
error
)
)
),
publishEvent: (
streamId: EventStreamId,
event: string
): Effect.Effect<void, EventStoreError, never> =>
pipe(
publishToStream(ref, streamId, event),
Effect.mapError((error) =>
eventStoreError.write(
streamId,
`Failed to publish event to subscribers: ${String(error)}`,
error
)
)
),
}))
)
);