@arki/event-sourcing 0.1.0 → 0.1.2

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.
package/src/dot.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * DOT adapter for `@arki/event-sourcing`.
3
+ *
4
+ * Wraps `eventSourcingFeatures.initEventSourcing` + `initMessageBus` as a
5
+ * single `DotPip`. The pip opens the PostgreSQL event store in
6
+ * `boot`, attaches command handlers to an in-memory message bus, publishes
7
+ * both as `services.eventStore` and `services.messageBus`, and closes the
8
+ * event store pool in `dispose` (reverse-topological order).
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { defineApp } from '@arki/dot';
13
+ * import { eventSourcing } from '@arki/event-sourcing/dot';
14
+ *
15
+ * const app = await defineApp('my-app')
16
+ * .use(eventSourcing({
17
+ * projections: [orderProjection, invoiceProjection],
18
+ * commandHandlers: [placeOrderHandler, cancelOrderHandler],
19
+ * }))
20
+ * .boot();
21
+ *
22
+ * await app.services.messageBus.send({ type: 'PlaceOrder', payload: { ... } });
23
+ * await app.dispose();
24
+ * ```
25
+ *
26
+ * The connection URL is resolved in this order:
27
+ * 1. `options.dbUrl` if provided
28
+ * 2. `process.env.EVENT_STORE_URL`
29
+ * 3. `process.env.EVENTSTORE_URL`
30
+ * 4. `process.env.EVENT_DB_URL`
31
+ *
32
+ * If none are set, `boot` throws with a message naming every accepted env
33
+ * var — same contract as `eventSourcingFeatures.initEventSourcing`.
34
+ *
35
+ * The `@arki/dot` package is an OPTIONAL peer of `@arki/event-sourcing`.
36
+ * Importing this adapter without `@arki/dot` installed will fail at module
37
+ * load — that is intentional: the adapter only makes sense in a DOT app.
38
+ */
39
+
40
+ import type { EventStore, MessageBus } from '@event-driven-io/emmett';
41
+
42
+ import type { EmptyShape, Pip } from '@arki/dot/pip';
43
+ import { pip, DotPipError } from '@arki/dot/pip';
44
+
45
+ import type { CommandHandlerRegistration } from './command.js';
46
+ import type { PostgreSQLProjectionInput } from './event-sourcing-features.js';
47
+ import { EVENT_STORE_URL_VARIANTS, eventSourcingFeatures } from './event-sourcing-features.js';
48
+
49
+ /**
50
+ * Stable error codes thrown by the event-sourcing pip. Exported so consumers
51
+ * and coding agents can match against them — never parse the message.
52
+ *
53
+ * @see packages/dot/docs/principles.md — principle 1.3 ("errors are part
54
+ * of the API") and principle 4 ("agent-discoverable everywhere").
55
+ */
56
+ export const EVENT_SOURCING_PIP_ERROR_CODES = {
57
+ /** boot was called without a configured event-store URL. */
58
+ dbUrlNotConfigured: 'EVENT_SOURCING_PIP_E001',
59
+ } as const;
60
+
61
+ /**
62
+ * Options for the event-sourcing DOT adapter.
63
+ */
64
+ export type EventSourcingDotOptions = {
65
+ /**
66
+ * PostgreSQL projection definitions registered inline on the event store.
67
+ * Accepts the three projection builder patterns documented on
68
+ * {@link PostgreSQLProjectionInput}.
69
+ */
70
+ readonly projections: readonly PostgreSQLProjectionInput[];
71
+ /**
72
+ * Command handlers wired into the in-memory message bus. Each handler's
73
+ * `getStreamName` callback maps an incoming command to its target stream.
74
+ *
75
+ * Defaults to `[]` — useful when an app only does projections / event
76
+ * appends without command-side dispatch.
77
+ */
78
+ readonly commandHandlers?: readonly CommandHandlerRegistration[];
79
+ /**
80
+ * Connection URL for the event store. When omitted, the pip reads
81
+ * `EVENT_STORE_URL`, `EVENTSTORE_URL`, or `EVENT_DB_URL` from
82
+ * `process.env`. If none are set, `boot` throws.
83
+ */
84
+ readonly dbUrl?: string;
85
+ };
86
+
87
+ /** Services published by the event-sourcing adapter. */
88
+ export type EventSourcingServices = {
89
+ /** The Emmett event store handle, ready to load/append streams. */
90
+ readonly eventStore: EventStore;
91
+ /**
92
+ * The in-memory message bus. Already has the configured command handlers
93
+ * attached — call `messageBus.send(command)` to dispatch.
94
+ */
95
+ readonly messageBus: MessageBus;
96
+ };
97
+
98
+ /**
99
+ * Resolve the event-store connection URL from explicit options first, then
100
+ * from the recognised env vars in priority order. Returns `undefined` when
101
+ * none are set so the underlying factory can throw the canonical error.
102
+ */
103
+ function resolveDbUrl(explicit: string | undefined): string | undefined {
104
+ if (explicit !== undefined && explicit !== '') return explicit;
105
+ for (const name of EVENT_STORE_URL_VARIANTS) {
106
+ const value = process.env[name];
107
+ if (value !== undefined && value !== '') return value;
108
+ }
109
+ return undefined;
110
+ }
111
+
112
+ /**
113
+ * Build a DOT pip that opens the event store, wires command handlers
114
+ * into an in-memory message bus, and publishes both as services. The
115
+ * kernel calls `dispose` in reverse declaration order to release the
116
+ * underlying PG pool.
117
+ */
118
+ export function eventSourcing(options: EventSourcingDotOptions): Pip<EmptyShape, EventSourcingServices> {
119
+ const commandHandlers = options.commandHandlers ?? [];
120
+
121
+ // Captured at boot so dispose can call it without re-reading services
122
+ // (dispose is allowed to run even when services failed to publish).
123
+ let closeStore: (() => Promise<void>) | undefined;
124
+
125
+ return pip({
126
+ name: 'event-sourcing',
127
+ version: '0.1.0',
128
+ configure(ctx) {
129
+ ctx.registerService('eventStore', 'event-store');
130
+ ctx.registerService('messageBus', 'message-bus');
131
+ ctx.declareProvides('event-store', 'message-bus');
132
+ },
133
+ boot(): EventSourcingServices {
134
+ // Validate at the pip boundary so the DOT lifecycle gets a coded
135
+ // error. `eventSourcingFeatures.initEventSourcing` still throws raw
136
+ // `Error` for non-DOT consumers (its public contract is unchanged);
137
+ // the check here makes sure we never reach it without a URL.
138
+ const dbUrl = resolveDbUrl(options.dbUrl);
139
+ if (dbUrl === undefined) {
140
+ throw new DotPipError({
141
+ code: EVENT_SOURCING_PIP_ERROR_CODES.dbUrlNotConfigured,
142
+ message: '[event-sourcing] Event Store database URL is not configured.',
143
+ remediation: `Pass options.dbUrl to eventSourcing(...) or set one of ${EVENT_STORE_URL_VARIANTS.join(
144
+ ', ',
145
+ )} in the environment before booting the app.`,
146
+ docsUrl: 'https://arki.dev/dot/errors/event-sourcing-pip-e001',
147
+ });
148
+ }
149
+ const { eventStore, close } = eventSourcingFeatures.initEventSourcing(options.projections, dbUrl);
150
+ closeStore = close;
151
+ const messageBus = eventSourcingFeatures.initMessageBus(eventStore, [...commandHandlers]);
152
+ return { eventStore, messageBus };
153
+ },
154
+ async dispose() {
155
+ if (closeStore !== undefined) {
156
+ await closeStore();
157
+ closeStore = undefined;
158
+ }
159
+ },
160
+ });
161
+ }
package/src/error.ts ADDED
@@ -0,0 +1 @@
1
+ export { EmmettError, IllegalStateError } from '@event-driven-io/emmett';
@@ -0,0 +1,215 @@
1
+ import type {
2
+ Event,
3
+ EventStore,
4
+ MessageBus,
5
+ MessageProcessor,
6
+ ProjectionDefinition,
7
+ } from '@event-driven-io/emmett';
8
+ import { getInMemoryMessageBus } from '@event-driven-io/emmett';
9
+
10
+ import type { CommandHandler } from './builders/command-handler.js';
11
+ import type { CommandHandlerRegistration } from './command.js';
12
+ import { debugCommand, debugStore } from './debug.js';
13
+ import type {
14
+ PostgresEventStore,
15
+ PostgreSQLProjectionHandlerContext,
16
+ PostgresReadEventMetadata,
17
+ } from './store.js';
18
+ import { getEventStore, projections } from './store.js';
19
+
20
+ /**
21
+ * Structural interface for projection definitions accepted by
22
+ * {@link initEventSourcing}.
23
+ *
24
+ * Uses method syntax for `handle` to enable TypeScript's bivariant parameter
25
+ * checking, which allows heterogeneous projection arrays where each projection
26
+ * is parametrised on a different event type.
27
+ *
28
+ * Accepts projections produced by three different builder patterns:
29
+ * 1. Emmett's `postgreSQLProjection` / `PostgreSQLProjectionDefinition<E>`.
30
+ * 2. The `defineProjection` builder from this package.
31
+ * 3. The `@arki/db` builder `projection.named().on().handle()`.
32
+ */
33
+ export type PostgreSQLProjectionInput = {
34
+ name?: string;
35
+ canHandle: string[];
36
+ handle(events: Event[], context: object): void;
37
+ };
38
+
39
+ /** Alternative names for the Event Store connection URL environment variable. */
40
+ export const EVENT_STORE_URL_VARIANTS = ['EVENT_STORE_URL', 'EVENTSTORE_URL', 'EVENT_DB_URL'];
41
+
42
+ /**
43
+ * Event sourcing wiring helpers.
44
+ *
45
+ * Bundles the three most common bootstrap operations into a single namespace:
46
+ *
47
+ * - {@link eventSourcingFeatures.initEventSourcing} — open the PostgreSQL
48
+ * event store with a set of inline projections, returning a `close()`
49
+ * function for graceful shutdown.
50
+ * - {@link eventSourcingFeatures.initMessageBus} — create an in-memory
51
+ * message bus and register a list of command handler descriptors against it.
52
+ * - {@link eventSourcingFeatures.setupProcessManagers} — attach process
53
+ * manager consumers to the event store, start them, and wire SIGTERM-driven
54
+ * graceful shutdown.
55
+ *
56
+ * The helpers are intentionally framework-agnostic: any composition root
57
+ * (DI container, plugin, plain `bus.ts` factory function) can call them.
58
+ */
59
+ export const eventSourcingFeatures = {
60
+ /**
61
+ * Initialises the PostgreSQL event store with the given inline projections.
62
+ *
63
+ * @param p Projections to register inline on the event store.
64
+ * @param dbUrl Connection string for the event store. When omitted, an
65
+ * error is thrown that names the recognised environment variables.
66
+ * @returns The event store handle plus a `close()` function that releases
67
+ * the underlying connection pool.
68
+ */
69
+ initEventSourcing(p: readonly PostgreSQLProjectionInput[], dbUrl?: string) {
70
+ if (!dbUrl) {
71
+ debugStore(
72
+ '[eventSourcingFeatures] Event Store URL not found. Required environment variables: %s',
73
+ EVENT_STORE_URL_VARIANTS.join(', '),
74
+ );
75
+ throw new Error(
76
+ `Event Store database URL is not defined. Please set one of the following environment variables: ${EVENT_STORE_URL_VARIANTS.join(
77
+ ', ',
78
+ )}`,
79
+ );
80
+ }
81
+ debugStore('[eventSourcingFeatures] Initializing Event Store with %d projection(s)', p.length);
82
+ const { eventStore, close } = getEventStore(dbUrl, {
83
+ projections: projections.inline(
84
+ p as ProjectionDefinition<Event, PostgresReadEventMetadata, PostgreSQLProjectionHandlerContext>[],
85
+ ),
86
+ });
87
+ debugStore('[eventSourcingFeatures] Event Store initialized successfully');
88
+ return { eventStore, close };
89
+ },
90
+
91
+ /**
92
+ * Creates an in-memory message bus and registers a list of command handlers.
93
+ *
94
+ * Each registration is bound to the event store so command handlers can
95
+ * load and append to streams. If the event store is missing, a warning is
96
+ * logged and an empty bus is returned.
97
+ *
98
+ * @param eventStore Event store used to load and append streams.
99
+ * @param commandHandlers Command handler descriptors to register.
100
+ * @returns A message bus with handlers attached.
101
+ */
102
+ initMessageBus(eventStore: EventStore | undefined, commandHandlers: CommandHandlerRegistration[]): MessageBus {
103
+ debugCommand(
104
+ '[eventSourcingFeatures] Initializing Message Bus with %d command handler(s)',
105
+ commandHandlers.length,
106
+ );
107
+ const messageBus = getInMemoryMessageBus();
108
+
109
+ if (!eventStore) {
110
+ if (commandHandlers.length > 0) {
111
+ debugCommand(
112
+ '[eventSourcingFeatures] Warning: %d command handler(s) configured but Event Store not initialized',
113
+ commandHandlers.length,
114
+ );
115
+ console.warn('Command handlers were configured, but the Event Store is not initialized. Commands may fail.');
116
+ } else {
117
+ debugCommand(
118
+ '[eventSourcingFeatures] No Event Store and no command handlers - returning empty Message Bus',
119
+ );
120
+ }
121
+ return messageBus;
122
+ }
123
+
124
+ if (commandHandlers.length > 0) {
125
+ const createCommandHandlerWrapper = <C extends { type: string }, State, StreamEvent extends Event>(
126
+ handler: CommandHandler<State, C, StreamEvent>,
127
+ getStreamNameFn: (cmd: C) => string,
128
+ ) => {
129
+ return async (command: C) => {
130
+ const streamName = getStreamNameFn(command);
131
+ debugCommand('[MessageBus] Executing command handler for %s on stream: %s', command.type, streamName);
132
+ try {
133
+ if (typeof handler === 'function') {
134
+ await handler(eventStore, streamName, command);
135
+ debugCommand('[MessageBus] Command handler for %s completed successfully', command.type);
136
+ } else {
137
+ debugCommand('[MessageBus] Invalid handler type for command: %s', command.type);
138
+ console.error(`Invalid handler type encountered for command: ${command.type}`);
139
+ }
140
+ } catch (error) {
141
+ debugCommand(
142
+ '[MessageBus] Error executing command handler for %s on stream %s: %s',
143
+ command.type,
144
+ streamName,
145
+ error instanceof Error ? error.message : String(error),
146
+ );
147
+ console.error(`Error executing command handler for ${command.type} on stream ${streamName}:`, error);
148
+ throw error;
149
+ }
150
+ };
151
+ };
152
+
153
+ for (const config of commandHandlers) {
154
+ debugCommand('[eventSourcingFeatures] Registering command handler for type: %s', config.commandType);
155
+ // Type erasure boundary: registrations are stored with C=never so
156
+ // arrays of differently-typed handlers compose. We cast back to
157
+ // callable types for the runtime dispatch wrapper.
158
+ type Cmd = { type: string };
159
+ const handler = config.handler as CommandHandler<unknown, Cmd, Event>;
160
+ const getStreamName = config.getStreamName as (cmd: Cmd) => string;
161
+ messageBus.handle(createCommandHandlerWrapper(handler, getStreamName), config.commandType);
162
+ }
163
+
164
+ debugCommand(
165
+ '[eventSourcingFeatures] Message Bus initialized with %d command handler(s)',
166
+ commandHandlers.length,
167
+ );
168
+ } else {
169
+ debugCommand('[eventSourcingFeatures] No command handlers to register');
170
+ }
171
+
172
+ return messageBus;
173
+ },
174
+
175
+ /**
176
+ * Attaches process managers as event store consumers, starts them, and
177
+ * wires SIGTERM-driven shutdown of the resulting consumers.
178
+ *
179
+ * @param eventStore Event store the process managers consume from.
180
+ * @param processManagers Process manager `MessageProcessor` instances to
181
+ * register, typically produced by {@link createProcessManager},
182
+ * {@link createStatefulProcessManager}, or
183
+ * `createSimpleProcessManager`.
184
+ * @returns The started consumer handles. Useful for tests that need to
185
+ * stop them explicitly.
186
+ */
187
+ async setupProcessManagers(eventStore: PostgresEventStore, processManagers: MessageProcessor[]) {
188
+ debugStore('[eventSourcingFeatures] Setting up %d process manager(s)', processManagers.length);
189
+ const processManagerConsumers = processManagers.map(pm => {
190
+ debugStore('[eventSourcingFeatures] Creating consumer for process manager: %s', pm.id);
191
+ return eventStore.consumer({
192
+ consumerId: pm.id,
193
+ processors: [pm],
194
+ });
195
+ });
196
+
197
+ debugStore(
198
+ '[eventSourcingFeatures] Starting %d process manager consumer(s)',
199
+ processManagerConsumers.length,
200
+ );
201
+ await Promise.all(processManagerConsumers.map(consumer => consumer.start()));
202
+ debugStore('[eventSourcingFeatures] All process manager consumers started successfully');
203
+
204
+ process.on('SIGTERM', async () => {
205
+ debugStore('[eventSourcingFeatures] SIGTERM received, stopping process managers');
206
+ await Promise.all(processManagerConsumers.map(consumer => consumer.stop()));
207
+ debugStore('[eventSourcingFeatures] All process managers stopped, closing consumers');
208
+ await Promise.all(processManagerConsumers.map(consumer => consumer.close()));
209
+ debugStore('[eventSourcingFeatures] All consumers closed, exiting');
210
+ process.exit(0);
211
+ });
212
+
213
+ return processManagerConsumers;
214
+ },
215
+ };
package/src/event.ts ADDED
@@ -0,0 +1,46 @@
1
+ import type { DefaultRecord } from './types.js';
2
+
3
+ export type {
4
+ EventsPublisher,
5
+ EventStore,
6
+ ReadEvent,
7
+ ReadEventMetadataWithGlobalPosition,
8
+ } from '@event-driven-io/emmett';
9
+
10
+ export type Event<
11
+ EventType extends string = string,
12
+ EventData extends DefaultRecord = DefaultRecord,
13
+ EventMetaData extends DefaultRecord | undefined = undefined,
14
+ > = Readonly<{
15
+ type: EventType;
16
+ data: EventData;
17
+ metadata?: EventMetaData;
18
+ kind?: 'Event';
19
+ }>;
20
+
21
+ export type AnyEvent = Event<any, any, any>;
22
+ export type EventTypeOf<T extends Event> = T['type'];
23
+ export type EventDataOf<T extends Event> = T['data'];
24
+ export type EventMetaDataOf<T extends Event> = T extends {
25
+ metadata: infer M;
26
+ }
27
+ ? M
28
+ : undefined;
29
+ export type CreateEventType<
30
+ EventType extends string,
31
+ EventData extends DefaultRecord,
32
+ EventMetaData extends DefaultRecord | undefined = undefined,
33
+ > = Readonly<
34
+ EventMetaData extends undefined
35
+ ? {
36
+ type: EventType;
37
+ data: EventData;
38
+ }
39
+ : {
40
+ type: EventType;
41
+ data: EventData;
42
+ metadata: EventMetaData;
43
+ }
44
+ > & {
45
+ readonly kind?: 'Event';
46
+ };
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export * from './command.js';
2
+ export * from './decide.js';
3
+ export * from './event.js';
4
+ export { createSimpleProcessManager } from './simple-process-manager.js';
5
+ export {
6
+ EVENT_STORE_URL_VARIANTS,
7
+ eventSourcingFeatures,
8
+ type PostgreSQLProjectionInput,
9
+ } from './event-sourcing-features.js';
10
+ export type { DefaultRecord } from './types.js';