@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
409 lines (394 loc) • 14.5 kB
text/typescript
import { SqlClient, SqlResolver } from '@effect/sql';
import { Effect, Layer, ParseResult, Schema, Sink, Stream, identity, pipe } from 'effect';
import {
EventNumber,
EventStreamId,
EventStreamPosition,
type EventStore,
EventStoreError,
eventStoreError,
ConcurrencyConflictError,
} from '@codeforbreakfast/eventsourcing-store';
import { ConnectionManagerLive } from './connectionManager';
import { EventStreamTrackerLive } from './eventStreamTracker';
import {
NotificationListener,
NotificationListenerLive,
type NotificationPayload,
} from './notificationListener';
import {
SubscriptionManager,
SubscriptionManagerLive,
type SubscriptionManagerService,
} from './subscriptionManager';
// Define the EventRowService interface
interface EventRowServiceInterface {
readonly insert: (row: Readonly<EventRow>) => Effect.Effect<EventRow, unknown, never>;
readonly selectAllEventsInStream: (
streamId: EventStreamId
) => Effect.Effect<EventRow[], unknown, never>;
readonly selectAllEvents: (
nullValue: Schema.Schema.Type<typeof Schema.Null>
) => Effect.Effect<EventRow[], unknown, never>;
}
export class EventRowService extends Effect.Tag('EventRowService')<
EventRowService,
EventRowServiceInterface
>() {}
class EventRow extends Schema.Class<EventRow>('EventRow')({
stream_id: EventStreamId,
event_number: EventNumber,
event_payload: Schema.String,
}) {}
export const makeEventRowService: Effect.Effect<
EventRowServiceInterface,
EventStoreError,
SqlClient.SqlClient
> = pipe(
SqlClient.SqlClient,
Effect.flatMap((sql: SqlClient.SqlClient) =>
pipe(
Effect.all({
insertEventRow: SqlResolver.ordered('InsertEventRow', {
Request: EventRow,
Result: EventRow,
execute: (requests) => sql`
INSERT INTO events ${sql.insert(requests)}
RETURNING events.*
`,
}),
selectAllEventsInStream: SqlResolver.grouped('SelectAllEventRowsInStream', {
Request: EventStreamId,
RequestGroupKey: identity,
Result: EventRow,
ResultGroupKey: (row) => row.stream_id,
execute: (ids) => sql`
SELECT * FROM events
WHERE ${sql.in('stream_id', ids)}
ORDER BY event_number
`,
}),
selectAllEvents: SqlResolver.grouped('SelectAllEventRows', {
Request: Schema.Null,
RequestGroupKey: identity,
Result: EventRow,
ResultGroupKey: () => null,
execute: () => sql`
SELECT * FROM events
ORDER BY stream_id, event_number
`,
}),
}),
Effect.map(
({
insertEventRow,
selectAllEventsInStream,
selectAllEvents,
}): EventRowServiceInterface => ({
insert: insertEventRow.execute,
selectAllEventsInStream: selectAllEventsInStream.execute,
selectAllEvents: selectAllEvents.execute,
})
),
Effect.mapError((error) =>
eventStoreError.write(
undefined,
`Failed to initialize event row service: ${String(error)}`,
error
)
)
)
)
);
/**
* Layer that provides EventRowService
*/
export const EventRowServiceLive = Layer.effect(EventRowService, makeEventRowService);
/**
* Event tracking layer - provides event ordering and deduplication services
* Depends on database infrastructure for connection management
*/
export const EventTrackingLive = EventStreamTrackerLive().pipe(
Layer.provide(ConnectionManagerLive)
);
/**
* Notification infrastructure layer - provides PostgreSQL LISTEN/NOTIFY handling
* Depends on database infrastructure for connection management
*/
export const NotificationInfrastructureLive = NotificationListenerLive.pipe(
Layer.provide(ConnectionManagerLive)
);
/**
* Combined layer that provides all the required services for real-time event subscriptions
* Organized into logical groups for better understanding and testability
*/
export const EventSubscriptionServicesLive = Layer.mergeAll(
SubscriptionManagerLive,
EventTrackingLive,
NotificationInfrastructureLive
);
/**
* Create a SQL-based EventStore with subscription support and PostgreSQL LISTEN/NOTIFY
*/
export const makeSqlEventStoreWithSubscriptionManager = (
subscriptionManager: SubscriptionManagerService,
notificationListener: Readonly<{
listen: (streamId: EventStreamId) => Effect.Effect<void, EventStoreError, never>;
unlisten: (streamId: EventStreamId) => Effect.Effect<void, EventStoreError, never>;
notifications: Stream.Stream<
{ streamId: EventStreamId; payload: NotificationPayload },
EventStoreError,
never
>;
start: Effect.Effect<void, EventStoreError, never>;
stop: Effect.Effect<void, EventStoreError, never>;
}>
): Effect.Effect<EventStore<string>, EventStoreError, EventRowService> => {
return pipe(
EventRowService,
Effect.map((eventRowService) => ({
eventRows: eventRowService,
subscriptionManager,
notificationListener,
})),
Effect.tap(({ notificationListener, subscriptionManager }) =>
// Start the notification bridge: consume notifications and publish to subscribers
pipe(
Effect.logInfo(
'Starting notification bridge between PostgreSQL LISTEN/NOTIFY and SubscriptionManager'
),
Effect.flatMap(() =>
pipe(
// Start the notification listener
notificationListener.start,
Effect.flatMap(() =>
pipe(
// Start consuming notifications and bridging to subscription manager
notificationListener.notifications,
Stream.tap(({ streamId, payload }) =>
pipe(
Effect.logDebug(`Bridging notification for stream ${streamId}`, { payload }),
Effect.flatMap(() =>
subscriptionManager.publishEvent(streamId, payload.event_payload)
),
Effect.catchAll((error) =>
Effect.logError(`Failed to bridge notification for stream ${streamId}`, {
error,
})
)
)
),
Stream.runDrain,
Effect.fork, // Run in background
Effect.asVoid
)
)
)
)
)
),
Effect.map(({ eventRows, subscriptionManager, notificationListener }) => {
// Define an EventStore implementation
const eventStore: EventStore<string> = {
append: (to: EventStreamPosition) => {
const sink = Sink.foldEffect(
to,
() => true,
(end, payload: string) =>
pipe(
// Get all events in stream to check position
eventRows.selectAllEventsInStream(end.streamId),
Effect.map((events: readonly EventRow[]) => {
// Find the last event in the stream
if (events.length === 0) {
return -1;
}
const lastEvent = events[events.length - 1];
return lastEvent?.event_number;
}),
Effect.flatMap((last) => {
// Strict check for new streams
// For new streams, eventNumber should be 0 and last should be -1
// For existing streams, eventNumber should be last + 1
return (end.eventNumber === 0 && last === -1) ||
(last !== undefined && last === end.eventNumber - 1)
? Effect.succeed(end)
: Effect.fail(
new ConcurrencyConflictError({
expectedVersion: end.eventNumber,
actualVersion: (last ?? -1) + 1,
streamId: end.streamId,
})
);
}),
Effect.flatMap((end: EventStreamPosition) =>
eventRows.insert({
event_number: end.eventNumber,
stream_id: end.streamId,
event_payload: payload,
})
),
Effect.map((row: Readonly<EventRow>) => ({
streamId: row.stream_id,
eventNumber: row.event_number + 1,
})),
Effect.tap(() =>
// Notify subscribers about the new event
pipe(
subscriptionManager.publishEvent(end.streamId, payload),
Effect.catchAll(() => Effect.succeed(undefined)) // Don't fail if notification fails
)
),
Effect.tapError((error) =>
Effect.logError('Error writing to event store', { error })
),
Effect.mapError((error) => {
// Don't remap ConcurrencyConflictError - it's already the right type
if (error instanceof ConcurrencyConflictError) {
return error;
}
// Map database/other errors to EventStoreError
return eventStoreError.write(
end.streamId,
`Failed to append event: ${String(error)}`,
error
);
}),
Effect.flatMap(Schema.decode(EventStreamPosition))
)
);
return sink as Sink.Sink<
EventStreamPosition,
string,
string,
EventStoreError | ConcurrencyConflictError | ParseResult.ParseError
>;
},
read: (
from: EventStreamPosition
): Effect.Effect<
Stream.Stream<string, ParseResult.ParseError | EventStoreError>,
EventStoreError,
never
> => {
// Read returns only historical events - no live updates
return pipe(
eventRows.selectAllEventsInStream(from.streamId),
Effect.map((events: readonly EventRow[]) => {
const filteredEvents = events
.filter((event: Readonly<EventRow>) => event.event_number >= from.eventNumber)
.map((event: Readonly<EventRow>) => event.event_payload);
return Stream.fromIterable(filteredEvents);
}),
Effect.map((stream) =>
Stream.mapError(stream, (error) =>
eventStoreError.read(
from.streamId,
`Failed to read historical events: ${String(error)}`,
error
)
)
),
Effect.mapError((error) =>
eventStoreError.read(
from.streamId,
`Failed to read historical events: ${String(error)}`,
error
)
)
);
},
subscribe: (
from: EventStreamPosition
): Effect.Effect<
Stream.Stream<string, ParseResult.ParseError | EventStoreError>,
EventStoreError,
never
> => {
// Subscribe returns historical events + live updates
return pipe(
// Start PostgreSQL LISTEN for this stream
notificationListener.listen(from.streamId),
Effect.flatMap(() =>
// Establish live subscription SECOND to receive bridged notifications
subscriptionManager.subscribeToStream(from.streamId)
),
Effect.flatMap((liveStream) =>
pipe(
// Then get historical events
eventRows.selectAllEventsInStream(from.streamId),
Effect.map((events: readonly EventRow[]) => {
const filteredEvents = events
.filter((event: Readonly<EventRow>) => event.event_number >= from.eventNumber)
.map((event: Readonly<EventRow>) => event.event_payload);
return Stream.fromIterable(filteredEvents);
}),
Effect.map((historicalStream) =>
// Combine historical events with live stream (PostgreSQL notifications handled by layer)
pipe(historicalStream, Stream.concat(liveStream))
)
)
),
Effect.map((stream) =>
Stream.mapError(stream, (error) =>
eventStoreError.read(
from.streamId,
`Failed to subscribe to stream: ${String(error)}`,
error
)
)
),
Effect.mapError((error) =>
eventStoreError.read(
from.streamId,
`Failed to subscribe to stream: ${String(error)}`,
error
)
)
);
},
};
return eventStore;
})
);
};
/**
* Layer that provides a SQL EventStore with properly shared SubscriptionManager and NotificationListener
*/
export class SqlEventStore extends Effect.Tag('SqlEventStore')<
SqlEventStore,
EventStore<string>
>() {}
/**
* Main SQL EventStore layer with simplified dependency management
* Uses the logical layer groups defined above for clearer composition
*/
export const SqlEventStoreLive = Layer.effect(
SqlEventStore,
pipe(
Effect.all({
subscriptionManager: SubscriptionManager,
notificationListener: NotificationListener,
}),
Effect.flatMap(({ subscriptionManager, notificationListener }) =>
makeSqlEventStoreWithSubscriptionManager(subscriptionManager, notificationListener)
)
)
).pipe(Layer.provide(Layer.mergeAll(EventSubscriptionServicesLive, EventRowServiceLive)));
/**
* Backward-compatible function - requires SubscriptionManager and NotificationListener in context
*/
export const sqlEventStore = (): Effect.Effect<
EventStore<string>,
EventStoreError,
EventRowService | SubscriptionManager | NotificationListener
> =>
pipe(
Effect.all({
subscriptionManager: SubscriptionManager,
notificationListener: NotificationListener,
}),
Effect.flatMap(({ subscriptionManager, notificationListener }) =>
makeSqlEventStoreWithSubscriptionManager(subscriptionManager, notificationListener)
)
);