@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
443 lines (399 loc) • 12.8 kB
text/typescript
import { PgClient } from '@effect/sql-pg';
import { Effect, Layer, Stream, Ref, Queue, Schema, HashSet, pipe } from 'effect';
import {
EventStreamId,
EventStoreError,
eventStoreError,
} from '@codeforbreakfast/eventsourcing-store';
/**
* Interface for handling notification events
*/
export interface NotificationPayload {
readonly stream_id: string;
readonly event_number: number;
readonly event_payload: string;
}
/**
* Creates a notification channel name for a stream ID
*/
export const makeChannelName = (streamId: EventStreamId): string => `eventstore_events_${streamId}`;
/**
* Global channel name for all events (used by subscribeAll)
*/
export const ALL_EVENTS_CHANNEL = 'eventstore_events_all';
/**
* Parse notification payload from PostgreSQL trigger JSON
*/
const decodeNotificationSchema = Schema.decodeUnknown(
Schema.Struct({
stream_id: Schema.String,
event_number: Schema.Number,
event_payload: Schema.String,
})
);
const parseNotificationPayload = (
jsonString: string
): Effect.Effect<NotificationPayload, EventStoreError, never> =>
pipe(
jsonString,
(str) =>
Effect.try({
try: () => JSON.parse(str) as NotificationPayload,
catch: eventStoreError.read(undefined, 'Failed to parse notification payload'),
}),
Effect.flatMap(decodeNotificationSchema),
Effect.mapError(
eventStoreError.read(undefined, 'Failed to validate notification payload schema')
)
);
/**
* 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
*/
readonly listen: (streamId: EventStreamId) => Effect.Effect<void, EventStoreError, never>;
/**
* Stop listening for notifications on a specific stream's channel
*/
readonly unlisten: (streamId: EventStreamId) => Effect.Effect<void, EventStoreError, never>;
/**
* Listen for notifications on the global all-events channel
*/
readonly listenAll: Effect.Effect<void, EventStoreError, never>;
/**
* Stop listening to the global all-events channel
*/
readonly unlistenAll: Effect.Effect<void, EventStoreError, never>;
/**
* Get a stream of notifications for all channels we're listening to
*/
readonly notifications: Stream.Stream<
{
readonly streamId: EventStreamId;
readonly payload: NotificationPayload;
readonly isAllEvents: boolean;
},
EventStoreError,
never
>;
/**
* Start the notification listener background process
*/
readonly start: Effect.Effect<void, EventStoreError, never>;
/**
* Stop the notification listener and cleanup
*/
readonly stop: Effect.Effect<void, EventStoreError, never>;
}>
>() {}
/**
* Full PostgreSQL LISTEN/NOTIFY implementation using @effect/sql-pg
*/
const parseStreamIdAndQueueNotification =
(
notificationQueue: Queue.Queue<{
readonly streamId: EventStreamId;
readonly payload: NotificationPayload;
readonly isAllEvents: boolean;
}>,
isAllEvents: boolean
) =>
(payload: NotificationPayload) =>
pipe(
payload.stream_id,
Schema.decode(EventStreamId),
Effect.flatMap((parsedStreamId) =>
Queue.offer(notificationQueue, {
streamId: parsedStreamId,
payload,
isAllEvents,
})
),
Effect.mapError(
eventStoreError.read(undefined, 'Failed to parse stream_id from notification')
)
);
const parseAndQueueStreamNotification = (
rawPayload: string,
notificationQueue: Queue.Queue<{
readonly streamId: EventStreamId;
readonly payload: NotificationPayload;
readonly isAllEvents: boolean;
}>,
isAllEvents: boolean
) =>
pipe(
rawPayload,
parseNotificationPayload,
Effect.flatMap(parseStreamIdAndQueueNotification(notificationQueue, isAllEvents))
);
const processRawNotification =
(
notificationQueue: Queue.Queue<{
readonly streamId: EventStreamId;
readonly payload: NotificationPayload;
readonly isAllEvents: boolean;
}>,
channelName: string,
isAllEvents: boolean
) =>
(rawPayload: string) =>
pipe(
parseAndQueueStreamNotification(rawPayload, notificationQueue, isAllEvents),
Effect.catchAll((error) =>
Effect.logError(`Failed to process notification for ${channelName}`, {
error,
})
)
);
const startListeningOnChannel = (
client: PgClient.PgClient,
notificationQueue: Queue.Queue<{
readonly streamId: EventStreamId;
readonly payload: NotificationPayload;
readonly isAllEvents: boolean;
}>,
channelName: string
) =>
pipe(
channelName,
client.listen,
Stream.tap(processRawNotification(notificationQueue, channelName, false)),
Stream.runDrain,
Effect.fork,
Effect.asVoid
);
const activateChannelAndStartListening = (
activeChannels: Ref.Ref<HashSet.HashSet<string>>,
client: PgClient.PgClient,
notificationQueue: Queue.Queue<{
readonly streamId: EventStreamId;
readonly payload: NotificationPayload;
readonly isAllEvents: boolean;
}>,
channelName: string
) =>
pipe(
Ref.update(activeChannels, HashSet.add(channelName)),
Effect.tap(() => startListeningOnChannel(client, notificationQueue, channelName))
);
const activateAndListen = (
activeChannels: Ref.Ref<HashSet.HashSet<string>>,
client: PgClient.PgClient,
notificationQueue: Queue.Queue<{
readonly streamId: EventStreamId;
readonly payload: NotificationPayload;
readonly isAllEvents: boolean;
}>,
streamId: EventStreamId
) => {
const channelName = makeChannelName(streamId);
return activateChannelAndStartListening(activeChannels, client, notificationQueue, channelName);
};
const startListenForStream =
(
activeChannels: Ref.Ref<HashSet.HashSet<string>>,
client: PgClient.PgClient,
notificationQueue: Queue.Queue<{
readonly streamId: EventStreamId;
readonly payload: NotificationPayload;
readonly isAllEvents: boolean;
}>
) =>
(streamId: EventStreamId) =>
pipe(
activateAndListen(activeChannels, client, notificationQueue, streamId),
Effect.mapError(eventStoreError.subscribe(streamId, 'Failed to listen to stream'))
);
const removeChannelFromActive = (
activeChannels: Ref.Ref<HashSet.HashSet<string>>,
channelName: string
) => pipe(activeChannels, Ref.update(HashSet.remove(channelName)), Effect.asVoid);
const removeChannelForStream = (
activeChannels: Ref.Ref<HashSet.HashSet<string>>,
streamId: EventStreamId
) => {
const channelName = makeChannelName(streamId);
return removeChannelFromActive(activeChannels, channelName);
};
const stopListenForStream =
(activeChannels: Ref.Ref<HashSet.HashSet<string>>) => (streamId: EventStreamId) =>
pipe(
removeChannelForStream(activeChannels, streamId),
Effect.mapError(eventStoreError.subscribe(streamId, 'Failed to unlisten from stream'))
);
const createNotificationsStream = (
notificationQueue: Queue.Queue<{
readonly streamId: EventStreamId;
readonly payload: NotificationPayload;
readonly isAllEvents: boolean;
}>
) =>
pipe(
notificationQueue,
Queue.take,
Stream.repeatEffect,
Stream.mapError(eventStoreError.read(undefined, 'Failed to read notification queue'))
);
const startListenerService = pipe(
'PostgreSQL notification listener started with LISTEN/NOTIFY support',
Effect.logInfo,
Effect.asVoid
);
const clearActiveChannels = Ref.set(HashSet.empty<string>());
const stopListenerService = (activeChannels: Ref.Ref<HashSet.HashSet<string>>) =>
pipe(
'PostgreSQL notification listener stopped',
Effect.logInfo,
Effect.andThen(clearActiveChannels(activeChannels)),
Effect.asVoid
);
const parseStreamIdFromPayload = (payload: NotificationPayload) =>
pipe(
payload.stream_id,
Schema.decode(EventStreamId),
Effect.mapError(eventStoreError.read(undefined, 'Failed to parse stream_id from notification'))
);
const queueParsedNotification = (
notificationQueue: Queue.Queue<{
readonly streamId: EventStreamId;
readonly payload: NotificationPayload;
readonly isAllEvents: boolean;
}>,
payload: NotificationPayload,
streamId: EventStreamId,
isAllEvents: boolean
) =>
Queue.offer(notificationQueue, {
streamId,
payload,
isAllEvents,
});
const parseAndQueue =
(
notificationQueue: Queue.Queue<{
readonly streamId: EventStreamId;
readonly payload: NotificationPayload;
readonly isAllEvents: boolean;
}>,
isAllEvents: boolean
) =>
(payload: NotificationPayload) =>
pipe(
payload,
parseStreamIdFromPayload,
Effect.flatMap((streamId) =>
queueParsedNotification(notificationQueue, payload, streamId, isAllEvents)
)
);
const processAllEventsNotification =
(
notificationQueue: Queue.Queue<{
readonly streamId: EventStreamId;
readonly payload: NotificationPayload;
readonly isAllEvents: boolean;
}>
) =>
(rawPayload: string) =>
pipe(
rawPayload,
parseNotificationPayload,
Effect.flatMap(parseAndQueue(notificationQueue, true)),
Effect.catchAll((error) =>
Effect.logError(`Failed to process all-events notification`, { error })
)
);
const startListeningOnAllEventsChannel = (
client: PgClient.PgClient,
notificationQueue: Queue.Queue<{
readonly streamId: EventStreamId;
readonly payload: NotificationPayload;
readonly isAllEvents: boolean;
}>
) =>
pipe(
ALL_EVENTS_CHANNEL,
client.listen,
Stream.tap(processAllEventsNotification(notificationQueue)),
Stream.runDrain,
Effect.fork,
Effect.asVoid
);
const activateAllEventsChannel = (
activeChannels: Ref.Ref<HashSet.HashSet<string>>,
client: PgClient.PgClient,
notificationQueue: Queue.Queue<{
readonly streamId: EventStreamId;
readonly payload: NotificationPayload;
readonly isAllEvents: boolean;
}>
) =>
pipe(
Ref.update(activeChannels, HashSet.add(ALL_EVENTS_CHANNEL)),
Effect.tap(() => startListeningOnAllEventsChannel(client, notificationQueue))
);
const listenAllEvents = (
activeChannels: Ref.Ref<HashSet.HashSet<string>>,
client: PgClient.PgClient,
notificationQueue: Queue.Queue<{
readonly streamId: EventStreamId;
readonly payload: NotificationPayload;
readonly isAllEvents: boolean;
}>
) =>
pipe(
activeChannels,
Ref.get,
Effect.flatMap((channels) =>
Effect.if(HashSet.has(channels, ALL_EVENTS_CHANNEL), {
onTrue: () => Effect.succeed(undefined),
onFalse: () => activateAllEventsChannel(activeChannels, client, notificationQueue),
})
),
Effect.mapError(eventStoreError.subscribe('*', 'Failed to listen to all events'))
);
const unlistenAllEvents = (activeChannels: Ref.Ref<HashSet.HashSet<string>>) =>
pipe(
removeChannelFromActive(activeChannels, ALL_EVENTS_CHANNEL),
Effect.mapError(eventStoreError.subscribe('*', 'Failed to unlisten from all events'))
);
const buildNotificationListener = ({
client,
activeChannels,
notificationQueue,
}: {
readonly client: PgClient.PgClient;
readonly activeChannels: Ref.Ref<HashSet.HashSet<string>>;
readonly notificationQueue: Queue.Queue<{
readonly streamId: EventStreamId;
readonly payload: NotificationPayload;
readonly isAllEvents: boolean;
}>;
}) => ({
listen: startListenForStream(activeChannels, client, notificationQueue),
unlisten: stopListenForStream(activeChannels),
listenAll: listenAllEvents(activeChannels, client, notificationQueue),
unlistenAll: unlistenAllEvents(activeChannels),
notifications: createNotificationsStream(notificationQueue),
// eslint-disable-next-line effect/no-intermediate-effect-variables -- Module-level Effect used once as service property
start: startListenerService,
stop: stopListenerService(activeChannels),
});
const createNotificationListenerDependencies = {
client: PgClient.PgClient,
activeChannels: Ref.make(HashSet.empty<string>()),
notificationQueue: Queue.unbounded<{
readonly streamId: EventStreamId;
readonly payload: NotificationPayload;
readonly isAllEvents: boolean;
}>(),
};
export const NotificationListenerLive = Layer.effect(
NotificationListener,
// eslint-disable-next-line effect/no-pipe-first-arg-call -- Effect.all needs an object argument, cannot be piped differently
pipe(Effect.all(createNotificationListenerDependencies), Effect.map(buildNotificationListener))
);