@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.
@@ -0,0 +1,335 @@
1
+ import type {
2
+ AnyEvent,
3
+ DefaultRecord,
4
+ Event,
5
+ EventTypeOf,
6
+ GlobalPositionTypeOfRecordedMessageMetadata,
7
+ MessageBus,
8
+ MessageProcessorStartFrom,
9
+ ReadEventMetadataWithGlobalPosition,
10
+ } from '@event-driven-io/emmett';
11
+ import { reactor } from '@event-driven-io/emmett';
12
+
13
+ import { debugProcess } from './debug.js';
14
+
15
+ /**
16
+ * Process Manager (Saga) handler context
17
+ * This context is passed to the process manager handler function
18
+ */
19
+ export type ProcessManagerContext = DefaultRecord & {
20
+ /** Message bus for sending commands in response to events */
21
+ messageBus: MessageBus;
22
+ };
23
+
24
+ /**
25
+ * Process Manager handler function
26
+ */
27
+ export type ProcessManagerHandler<
28
+ EventType extends Event = AnyEvent,
29
+ Context extends ProcessManagerContext = ProcessManagerContext,
30
+ > = (events: EventType[], context: Context) => Promise<void>;
31
+
32
+ /**
33
+ * Process Manager configuration options
34
+ */
35
+ export type ProcessManagerOptions<
36
+ EventType extends Event = AnyEvent,
37
+ Context extends ProcessManagerContext = ProcessManagerContext,
38
+ CheckpointType = GlobalPositionTypeOfRecordedMessageMetadata<ReadEventMetadataWithGlobalPosition>,
39
+ > = {
40
+ /** Unique identifier for the process manager */
41
+ name: string;
42
+
43
+ /** Event types the process manager will handle */
44
+ eventTypes: EventTypeOf<EventType>[];
45
+
46
+ /** Handler function that processes events */
47
+ handler: ProcessManagerHandler<EventType, Context>;
48
+
49
+ /**
50
+ * Optional hooks for process manager lifecycle
51
+ */
52
+ hooks?: {
53
+ /** Called when the process manager starts */
54
+ onStart?: (context: Context) => Promise<void>;
55
+
56
+ /** Called when the process manager closes */
57
+ onClose?: () => Promise<void>;
58
+ };
59
+
60
+ /**
61
+ * Optional configuration for resuming from specific checkpoint
62
+ * Useful for long-running process managers
63
+ */
64
+ startFrom?: MessageProcessorStartFrom<CheckpointType>;
65
+ };
66
+
67
+ /**
68
+ * Creates a process manager (saga) that subscribes to events and orchestrates responses
69
+ * This is built on top of Emmett's reactor functionality
70
+ */
71
+ export function createProcessManager<
72
+ EventType extends Event = AnyEvent,
73
+ Context extends ProcessManagerContext = ProcessManagerContext,
74
+ CheckpointType = GlobalPositionTypeOfRecordedMessageMetadata<ReadEventMetadataWithGlobalPosition>,
75
+ >(options: ProcessManagerOptions<EventType, Context, CheckpointType>) {
76
+ const { name, eventTypes, handler, hooks, startFrom } = options;
77
+
78
+ debugProcess('[%s] Creating process manager for event types: %o', name, eventTypes);
79
+
80
+ return reactor<EventType, ReadEventMetadataWithGlobalPosition, Context, CheckpointType>({
81
+ processorId: name,
82
+ canHandle: eventTypes,
83
+ startFrom,
84
+
85
+ // Process events in batch mode
86
+ eachBatch: async (events, context) => {
87
+ debugProcess('[%s] Processing batch of %d events', name, events.length);
88
+ await handler(events, context);
89
+ debugProcess('[%s] Batch processed successfully', name);
90
+ },
91
+
92
+ // Pass through lifecycle hooks
93
+ hooks: {
94
+ onStart: async context => {
95
+ debugProcess('[%s] Process manager starting', name);
96
+ if (hooks?.onStart) {
97
+ await hooks.onStart(context);
98
+ }
99
+ debugProcess('[%s] Process manager started', name);
100
+ },
101
+ onClose: async () => {
102
+ debugProcess('[%s] Process manager closing', name);
103
+ if (hooks?.onClose) {
104
+ await hooks.onClose();
105
+ }
106
+ debugProcess('[%s] Process manager closed', name);
107
+ },
108
+ },
109
+ });
110
+ }
111
+
112
+ /**
113
+ * State that can be maintained by a stateful process manager
114
+ */
115
+ export type ProcessManagerState = DefaultRecord;
116
+
117
+ /**
118
+ * Handler function for a stateful process manager
119
+ */
120
+ export type StatefulProcessManagerHandler<
121
+ EventType extends Event = AnyEvent,
122
+ StateType extends ProcessManagerState = ProcessManagerState,
123
+ Context extends ProcessManagerContext = ProcessManagerContext,
124
+ > = (events: EventType[], state: StateType, context: Context) => Promise<StateType>;
125
+
126
+ /**
127
+ * Options for creating a stateful process manager
128
+ */
129
+ export type StatefulProcessManagerOptions<
130
+ EventType extends Event = AnyEvent,
131
+ StateType extends ProcessManagerState = ProcessManagerState,
132
+ Context extends ProcessManagerContext = ProcessManagerContext,
133
+ CheckpointType = GlobalPositionTypeOfRecordedMessageMetadata<ReadEventMetadataWithGlobalPosition>,
134
+ > = Omit<ProcessManagerOptions<EventType, Context, CheckpointType>, 'handler'> & {
135
+ /** Initial state for the process manager */
136
+ initialState: StateType;
137
+
138
+ /** Handler function that processes events and updates state */
139
+ handler: StatefulProcessManagerHandler<EventType, StateType, Context>;
140
+
141
+ /**
142
+ * Store function that persists process manager state
143
+ * Called after each successful event processing
144
+ */
145
+ storeState?: (state: StateType, processorId: string) => Promise<void>;
146
+
147
+ /**
148
+ * Load function that retrieves process manager state
149
+ * Called during initialization
150
+ */
151
+ loadState?: (processorId: string) => Promise<StateType | null>;
152
+ };
153
+
154
+ /**
155
+ * Creates a stateful process manager that maintains its own state
156
+ * This is useful for tracking saga state across event processing
157
+ */
158
+ export function createStatefulProcessManager<
159
+ EventType extends Event = AnyEvent,
160
+ StateType extends ProcessManagerState = ProcessManagerState,
161
+ Context extends ProcessManagerContext = ProcessManagerContext,
162
+ CheckpointType = GlobalPositionTypeOfRecordedMessageMetadata<ReadEventMetadataWithGlobalPosition>,
163
+ >(options: StatefulProcessManagerOptions<EventType, StateType, Context, CheckpointType>) {
164
+ const { name, eventTypes, initialState, handler, hooks, startFrom, storeState, loadState } = options;
165
+
166
+ debugProcess('[%s] Creating stateful process manager for event types: %o', name, eventTypes);
167
+
168
+ // Initialize state (will be updated during processing)
169
+ let state = initialState;
170
+
171
+ return reactor<EventType, ReadEventMetadataWithGlobalPosition, Context, CheckpointType>({
172
+ processorId: name,
173
+ canHandle: eventTypes,
174
+ startFrom,
175
+
176
+ hooks: {
177
+ // Load state during startup
178
+ onStart: async context => {
179
+ debugProcess('[%s] Stateful process manager starting', name);
180
+
181
+ if (loadState) {
182
+ debugProcess('[%s] Loading state from storage', name);
183
+ const loadedState = await loadState(name);
184
+ if (loadedState) {
185
+ state = loadedState;
186
+ debugProcess('[%s] State loaded successfully', name);
187
+ } else {
188
+ debugProcess('[%s] No stored state found, using initial state', name);
189
+ }
190
+ }
191
+
192
+ // Call user-provided onStart hook if specified
193
+ if (hooks?.onStart) {
194
+ await hooks.onStart(context);
195
+ }
196
+
197
+ debugProcess('[%s] Stateful process manager started', name);
198
+ },
199
+
200
+ // Call user-provided onClose hook if specified
201
+ onClose: async () => {
202
+ debugProcess('[%s] Stateful process manager closing', name);
203
+ if (hooks?.onClose) {
204
+ await hooks.onClose();
205
+ }
206
+ debugProcess('[%s] Stateful process manager closed', name);
207
+ },
208
+ },
209
+
210
+ // Process events and update state
211
+ eachBatch: async (events, context) => {
212
+ debugProcess('[%s] Processing batch of %d events (stateful)', name, events.length);
213
+
214
+ // Update state based on events
215
+ state = await handler(events, state, context);
216
+
217
+ debugProcess('[%s] State updated after processing events', name);
218
+
219
+ // Persist updated state if storage function provided
220
+ if (storeState) {
221
+ debugProcess('[%s] Storing updated state', name);
222
+ await storeState(state, name);
223
+ debugProcess('[%s] State stored successfully', name);
224
+ }
225
+ },
226
+ });
227
+ }
228
+
229
+ /**
230
+ * Function to generate a unique idempotency key for an event
231
+ * Used to ensure that a process manager only processes an event once
232
+ *
233
+ * @param event The event to generate an idempotency key for
234
+ * @param processManagerId Optional identifier for the process manager
235
+ * @returns A string that can be used as an idempotency key
236
+ */
237
+ export function generateIdempotencyKey(
238
+ event: Event<any, any, ReadEventMetadataWithGlobalPosition>,
239
+ processManagerId?: string,
240
+ ): string {
241
+ // Get the event metadata
242
+ const metadata = event.metadata;
243
+
244
+ // Create an idempotency key using stream name, position, and optionally process manager ID
245
+ const baseKey = `${metadata.streamName}:${metadata.streamPosition}:${event.type}`;
246
+
247
+ // Add process manager ID if provided
248
+ return processManagerId ? `${processManagerId}:${baseKey}` : baseKey;
249
+ }
250
+
251
+ /**
252
+ * Interface for a function to check if an event has already been processed
253
+ */
254
+ export type IdempotencyCheck = (key: string) => Promise<boolean>;
255
+
256
+ /**
257
+ * Interface for a function to mark an event as processed
258
+ */
259
+ export type MarkProcessed = (key: string) => Promise<void>;
260
+
261
+ /**
262
+ * Options for idempotent event processing
263
+ */
264
+ export type IdempotentProcessingOptions = {
265
+ /** Function to check if an event has already been processed */
266
+ checkProcessed: IdempotencyCheck;
267
+
268
+ /** Function to mark an event as processed */
269
+ markProcessed: MarkProcessed;
270
+
271
+ /** Optional process manager ID to include in the idempotency key */
272
+ processManagerId?: string;
273
+ };
274
+
275
+ /**
276
+ * Higher-order function that wraps a process manager handler to provide idempotent processing
277
+ * This ensures that each event is only processed once, even if it's received multiple times
278
+ *
279
+ * @param handler The original process manager handler
280
+ * @param options Options for idempotent processing
281
+ * @returns A wrapped handler that implements idempotent processing
282
+ */
283
+ export function withIdempotentProcessing<
284
+ EventType extends Event<any, any, ReadEventMetadataWithGlobalPosition> = Event<
285
+ any,
286
+ any,
287
+ ReadEventMetadataWithGlobalPosition
288
+ >,
289
+ Context extends ProcessManagerContext = ProcessManagerContext,
290
+ >(
291
+ handler: ProcessManagerHandler<EventType, Context>,
292
+ options: IdempotentProcessingOptions,
293
+ ): ProcessManagerHandler<EventType, Context> {
294
+ const { checkProcessed, markProcessed, processManagerId } = options;
295
+
296
+ debugProcess('[%s] Wrapping handler with idempotent processing', processManagerId ?? 'unknown');
297
+
298
+ return async (events: EventType[], context: Context) => {
299
+ debugProcess('[%s] Checking idempotency for %d events', processManagerId ?? 'unknown', events.length);
300
+
301
+ // Filter out events that have already been processed
302
+ const unprocessedEvents: EventType[] = [];
303
+ const processedKeys: string[] = [];
304
+
305
+ // Check each event
306
+ for (const event of events) {
307
+ const idempotencyKey = generateIdempotencyKey(event, processManagerId);
308
+ const alreadyProcessed = await checkProcessed(idempotencyKey);
309
+
310
+ if (alreadyProcessed) {
311
+ debugProcess('[%s] Event already processed, skipping: %s', processManagerId ?? 'unknown', idempotencyKey);
312
+ } else {
313
+ unprocessedEvents.push(event);
314
+ processedKeys.push(idempotencyKey);
315
+ }
316
+ }
317
+
318
+ // If no events need processing, return early
319
+ if (unprocessedEvents.length === 0) {
320
+ debugProcess('[%s] All events already processed, skipping batch', processManagerId ?? 'unknown');
321
+ return;
322
+ }
323
+
324
+ debugProcess('[%s] Processing %d unprocessed events', processManagerId ?? 'unknown', unprocessedEvents.length);
325
+
326
+ // Process the unprocessed events
327
+ await handler(unprocessedEvents, context);
328
+
329
+ // Mark all events as processed
330
+ debugProcess('[%s] Marking %d events as processed', processManagerId ?? 'unknown', processedKeys.length);
331
+ await Promise.all(processedKeys.map(key => markProcessed(key)));
332
+
333
+ debugProcess('[%s] Idempotent processing completed', processManagerId ?? 'unknown');
334
+ };
335
+ }
@@ -0,0 +1,70 @@
1
+ import type { Event } from '@event-driven-io/emmett';
2
+ import { reactor } from '@event-driven-io/emmett';
3
+
4
+ import { debugProcess } from './debug.js';
5
+
6
+ /**
7
+ * Creates a simple process manager that reacts to events one at a time,
8
+ * with a pre-bound context closed over at construction time.
9
+ *
10
+ * This is a thin wrapper around Emmett's `reactor` that exposes a positional
11
+ * API with per-message processing semantics. It is intended for the most
12
+ * common process manager use case where:
13
+ *
14
+ * - The context (repositories, KV, message bus, etc.) is fixed at construction time.
15
+ * - The handler operates on one event at a time rather than a batch.
16
+ * - Errors propagate to the consumer so the underlying subscription can
17
+ * apply retry/back-off policies.
18
+ *
19
+ * For richer use cases (per-batch processing, lifecycle hooks, stateful
20
+ * sagas, idempotency) prefer {@link createProcessManager} or
21
+ * {@link createStatefulProcessManager} from this package.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * const trackingNotifications = createSimpleProcessManager(
26
+ * { repo, kv },
27
+ * 'tracking-notifications',
28
+ * ['MovieWatchLogged', 'TvShowRated'],
29
+ * async (event, context) => {
30
+ * // ... handle one event using context
31
+ * },
32
+ * );
33
+ * ```
34
+ *
35
+ * @param context Fixed context closed over by the process manager handler.
36
+ * @param name Unique identifier for the process manager (used as processor id).
37
+ * @param eventTypes Event types this process manager reacts to.
38
+ * @param handlerFn Async function invoked once per matching event.
39
+ */
40
+ export function createSimpleProcessManager<TEvent extends Event, TContext extends object>(
41
+ context: TContext,
42
+ name: string,
43
+ eventTypes: TEvent['type'][],
44
+ handlerFn: (event: TEvent, context: TContext) => Promise<void>,
45
+ ) {
46
+ debugProcess(
47
+ '[createSimpleProcessManager] Creating process manager: %s for event types: %s',
48
+ name,
49
+ eventTypes.join(', '),
50
+ );
51
+ return reactor<TEvent>({
52
+ canHandle: [...eventTypes],
53
+ eachMessage: async message => {
54
+ debugProcess('[createSimpleProcessManager:%s] Processing event: %s', name, message.type);
55
+ try {
56
+ await handlerFn(message, context);
57
+ debugProcess('[createSimpleProcessManager:%s] Successfully processed event: %s', name, message.type);
58
+ } catch (error) {
59
+ debugProcess(
60
+ '[createSimpleProcessManager:%s] Error processing event %s: %s',
61
+ name,
62
+ message.type,
63
+ error instanceof Error ? error.message : String(error),
64
+ );
65
+ throw error;
66
+ }
67
+ },
68
+ processorId: `process-manager-${name}`,
69
+ });
70
+ }