UNPKG

@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

220 lines (206 loc) 6.96 kB
import { PgClient } from '@effect/sql-pg'; import { Effect, Layer, pipe, Stream, Ref, Queue, Schema } from 'effect'; import { EventStreamId, EventStoreError, eventStoreError, } from '@codeforbreakfast/eventsourcing-store'; /** * Interface for handling notification events */ export interface NotificationPayload { stream_id: string; event_number: number; event_payload: string; } /** * Creates a notification channel name for a stream ID */ export const makeChannelName = (streamId: EventStreamId): string => `eventstore_events_${streamId}`; /** * Parse notification payload from PostgreSQL trigger JSON */ const parseNotificationPayload = ( jsonString: string ): Effect.Effect<NotificationPayload, EventStoreError, never> => pipe( Effect.try({ try: () => JSON.parse(jsonString) as NotificationPayload, catch: (error) => eventStoreError.read( undefined, `Failed to parse notification payload: ${String(error)}`, error ), }), Effect.flatMap((payload) => Schema.decodeUnknown( Schema.Struct({ stream_id: Schema.String, event_number: Schema.Number, event_payload: Schema.String, }) )(payload) ), Effect.mapError((error) => eventStoreError.read( undefined, `Failed to validate notification payload schema: ${String(error)}`, error ) ) ); /** * NotificationListener service for managing PostgreSQL LISTEN/NOTIFY operations */ export class NotificationListener extends Effect.Tag('NotificationListener')< NotificationListener, Readonly<{ /** * Listen for notifications on a specific stream's channel */ listen: (streamId: EventStreamId) => Effect.Effect<void, EventStoreError, never>; /** * Stop listening for notifications on a specific stream's channel */ unlisten: (streamId: EventStreamId) => Effect.Effect<void, EventStoreError, never>; /** * Get a stream of notifications for all channels we're listening to */ notifications: Stream.Stream< { streamId: EventStreamId; payload: NotificationPayload }, EventStoreError, never >; /** * Start the notification listener background process */ start: Effect.Effect<void, EventStoreError, never>; /** * Stop the notification listener and cleanup */ stop: Effect.Effect<void, EventStoreError, never>; }> >() {} /** * Full PostgreSQL LISTEN/NOTIFY implementation using @effect/sql-pg */ export const NotificationListenerLive = Layer.effect( NotificationListener, pipe( Effect.all({ client: PgClient.PgClient, activeChannels: Ref.make(new Set<string>()), notificationQueue: Queue.unbounded<{ streamId: EventStreamId; payload: NotificationPayload; }>(), }), Effect.map(({ client, activeChannels, notificationQueue }) => ({ listen: (streamId: EventStreamId): Effect.Effect<void, EventStoreError, never> => pipe( Effect.logDebug(`Starting LISTEN for stream: ${streamId}`), Effect.flatMap(() => { const channelName = makeChannelName(streamId); return pipe( // Add channel to active set Ref.update(activeChannels, (channels) => new Set([...channels, channelName])), Effect.flatMap(() => pipe( Effect.logDebug(`Successfully started listening on channel: ${channelName}`), // Start the stream processing in background Effect.tap(() => pipe( client.listen(channelName), Stream.tap((rawPayload) => pipe( Effect.logDebug( `Received raw notification on ${channelName}: ${rawPayload}` ), Effect.flatMap(() => parseNotificationPayload(rawPayload)), Effect.flatMap((payload) => pipe( // Store in notification queue for processing Queue.offer(notificationQueue, { streamId, payload, }), Effect.tap(() => Effect.logDebug(`Queued notification for stream ${streamId}`) ) ) ), Effect.catchAll((error) => Effect.logError(`Failed to process notification for ${channelName}`, { error, }) ) ) ), Stream.runDrain, Effect.fork, Effect.asVoid ) ) ) ) ); }), Effect.mapError((error) => eventStoreError.subscribe( streamId, `Failed to listen to stream: ${String(error)}`, error ) ) ), unlisten: (streamId: EventStreamId): Effect.Effect<void, EventStoreError, never> => pipe( Effect.logDebug(`Stopping LISTEN for stream: ${streamId}`), Effect.flatMap(() => { const channelName = makeChannelName(streamId); return pipe( Ref.update( activeChannels, (channels) => new Set([...channels].filter((ch) => ch !== channelName)) ), Effect.asVoid ); }), Effect.mapError((error) => eventStoreError.subscribe( streamId, `Failed to unlisten from stream: ${String(error)}`, error ) ) ), notifications: pipe( Queue.take(notificationQueue), Stream.repeatEffect, Stream.mapError((error) => eventStoreError.read( undefined, `Failed to read notification queue: ${String(error)}`, error ) ) ), start: pipe( Effect.logInfo('PostgreSQL notification listener started with LISTEN/NOTIFY support'), Effect.asVoid ), stop: pipe( Effect.logInfo('PostgreSQL notification listener stopped'), Effect.flatMap(() => Ref.get(activeChannels)), Effect.flatMap((channels) => Effect.forEach(Array.from(channels), (channelName) => Effect.logDebug(`Cleaning up channel: ${channelName}`) ) ), Effect.flatMap(() => Ref.set(activeChannels, new Set())), Effect.asVoid ), })) ) );