@arki/event-sourcing 0.1.1 → 0.1.3
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/dist/dot.d.ts +1 -1
- package/dist/dot.js +1 -1
- package/package.json +10 -4
- package/src/builders/command-handler.ts +77 -0
- package/src/builders/command.ts +72 -0
- package/src/builders/decider.ts +288 -0
- package/src/builders/event.ts +68 -0
- package/src/builders/index.ts +40 -0
- package/src/builders/process-manager.ts +153 -0
- package/src/builders/projection.ts +151 -0
- package/src/builders/stateful-process-manager.ts +338 -0
- package/src/bus.ts +3 -0
- package/src/command-flow.ts +491 -0
- package/src/command.ts +52 -0
- package/src/debug.ts +36 -0
- package/src/decide.ts +10 -0
- package/src/dot.ts +161 -0
- package/src/error.ts +1 -0
- package/src/event-sourcing-features.ts +215 -0
- package/src/event.ts +46 -0
- package/src/index.ts +10 -0
- package/src/process-manager.ts +335 -0
- package/src/simple-process-manager.ts +70 -0
- package/src/store.ts +367 -0
- package/src/types.ts +9 -0
|
@@ -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
|
+
}
|