@event-driven-io/emmett-mongodb 0.43.0-beta.12 → 0.43.0-beta.14

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/README.md ADDED
@@ -0,0 +1,484 @@
1
+ # @event-driven-io/emmett-mongodb
2
+
3
+ MongoDB adapter for the Emmett event sourcing library, providing event store implementation with stream management, inline projections, and flexible storage strategies.
4
+
5
+ ## Purpose
6
+
7
+ This package enables MongoDB as a persistence backend for event-sourced applications built with Emmett. It stores event streams as documents with support for atomic inline projections, multiple storage strategies, and optimistic concurrency control using BigInt stream positions.
8
+
9
+ ## Key Concepts
10
+
11
+ - **Event Stream**: A document containing all events for a single aggregate, stored with metadata and optional projections
12
+ - **Stream Name**: Composite identifier in format `streamType:streamId` (e.g., `shopping_cart:abc-123`)
13
+ - **Inline Projections**: Read models stored alongside events in the same document, updated atomically during append
14
+ - **Storage Strategy**: Configurable collection organization (per stream type, single collection, or custom)
15
+ - **Collection Prefix**: All collections use the `emt:` prefix (e.g., `emt:shopping_cart`)
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install @event-driven-io/emmett-mongodb
21
+ # or
22
+ pnpm add @event-driven-io/emmett-mongodb
23
+ # or
24
+ yarn add @event-driven-io/emmett-mongodb
25
+ ```
26
+
27
+ **Peer dependencies** (must be installed separately):
28
+
29
+ ```bash
30
+ npm install @event-driven-io/emmett mongodb
31
+ ```
32
+
33
+ ## Quick Start
34
+
35
+ ### Basic Event Store Setup
36
+
37
+ ```typescript
38
+ import {
39
+ getMongoDBEventStore,
40
+ toStreamName,
41
+ } from '@event-driven-io/emmett-mongodb';
42
+ import { type Event, STREAM_DOES_NOT_EXIST } from '@event-driven-io/emmett';
43
+
44
+ // Define your events
45
+ type ProductItemAdded = Event<
46
+ 'ProductItemAdded',
47
+ { productItem: { productId: string; quantity: number; price: number } }
48
+ >;
49
+ type DiscountApplied = Event<
50
+ 'DiscountApplied',
51
+ { percent: number; couponId: string }
52
+ >;
53
+ type ShoppingCartEvent = ProductItemAdded | DiscountApplied;
54
+
55
+ // Create event store with connection string
56
+ const eventStore = getMongoDBEventStore({
57
+ connectionString: 'mongodb://localhost:27017/mydb',
58
+ });
59
+
60
+ // Append events to a stream
61
+ const streamName = toStreamName('shopping_cart', 'cart-123');
62
+
63
+ await eventStore.appendToStream<ShoppingCartEvent>(
64
+ streamName,
65
+ [
66
+ {
67
+ type: 'ProductItemAdded',
68
+ data: { productItem: { productId: 'prod-1', quantity: 2, price: 29.99 } },
69
+ },
70
+ ],
71
+ { expectedStreamVersion: STREAM_DOES_NOT_EXIST },
72
+ );
73
+
74
+ // Read events from stream
75
+ const { events, currentStreamVersion } =
76
+ await eventStore.readStream(streamName);
77
+
78
+ // Close the event store when done (only needed when using connection string)
79
+ await eventStore.close();
80
+ ```
81
+
82
+ ### Using an Existing MongoClient
83
+
84
+ ```typescript
85
+ import { MongoClient } from 'mongodb';
86
+ import { getMongoDBEventStore } from '@event-driven-io/emmett-mongodb';
87
+
88
+ const client = new MongoClient('mongodb://localhost:27017/mydb');
89
+ await client.connect();
90
+
91
+ // Client lifecycle is managed externally - no close() method on event store
92
+ const eventStore = getMongoDBEventStore({ client });
93
+ ```
94
+
95
+ ### Aggregating Stream State
96
+
97
+ ```typescript
98
+ type ShoppingCart = {
99
+ productItems: Array<{ productId: string; quantity: number; price: number }>;
100
+ totalAmount: number;
101
+ };
102
+
103
+ const evolve = (
104
+ state: ShoppingCart,
105
+ event: ShoppingCartEvent,
106
+ ): ShoppingCart => {
107
+ switch (event.type) {
108
+ case 'ProductItemAdded':
109
+ return {
110
+ productItems: [...state.productItems, event.data.productItem],
111
+ totalAmount:
112
+ state.totalAmount +
113
+ event.data.productItem.price * event.data.productItem.quantity,
114
+ };
115
+ case 'DiscountApplied':
116
+ return {
117
+ ...state,
118
+ totalAmount: state.totalAmount * (1 - event.data.percent / 100),
119
+ };
120
+ }
121
+ };
122
+
123
+ const { state, currentStreamVersion } = await eventStore.aggregateStream(
124
+ streamName,
125
+ {
126
+ evolve,
127
+ initialState: () => ({ productItems: [], totalAmount: 0 }),
128
+ },
129
+ );
130
+ ```
131
+
132
+ ## How-to Guides
133
+
134
+ ### Configure Storage Strategies
135
+
136
+ The MongoDB event store supports three storage strategies:
137
+
138
+ #### Collection Per Stream Type (Default, Recommended)
139
+
140
+ Each stream type gets its own collection, named `emt:{streamType}`:
141
+
142
+ ```typescript
143
+ const eventStore = getMongoDBEventStore({
144
+ connectionString: 'mongodb://localhost:27017/mydb',
145
+ storage: 'COLLECTION_PER_STREAM_TYPE',
146
+ });
147
+ // shopping_cart streams -> emt:shopping_cart collection
148
+ // order streams -> emt:order collection
149
+ ```
150
+
151
+ #### Single Collection
152
+
153
+ All streams stored in one collection:
154
+
155
+ ```typescript
156
+ const eventStore = getMongoDBEventStore({
157
+ connectionString: 'mongodb://localhost:27017/mydb',
158
+ storage: {
159
+ type: 'SINGLE_COLLECTION',
160
+ collectionName: 'emt:all_events', // optional, defaults to 'emt:streams'
161
+ },
162
+ });
163
+ ```
164
+
165
+ #### Custom Resolution
166
+
167
+ Define your own collection mapping:
168
+
169
+ ```typescript
170
+ const eventStore = getMongoDBEventStore({
171
+ connectionString: 'mongodb://localhost:27017/mydb',
172
+ storage: {
173
+ type: 'CUSTOM',
174
+ collectionFor: (streamType) => ({
175
+ collectionName: `custom_${streamType}`,
176
+ databaseName: 'events_db', // optional
177
+ }),
178
+ },
179
+ });
180
+ ```
181
+
182
+ ### Define Inline Projections
183
+
184
+ Inline projections are stored alongside events and updated atomically:
185
+
186
+ ```typescript
187
+ import {
188
+ mongoDBInlineProjection,
189
+ getMongoDBEventStore,
190
+ } from '@event-driven-io/emmett-mongodb';
191
+ import { projections } from '@event-driven-io/emmett';
192
+
193
+ type ShoppingCartDetails = {
194
+ productItems: Array<{ productId: string; quantity: number; price: number }>;
195
+ totalAmount: number;
196
+ itemCount: number;
197
+ };
198
+
199
+ const shoppingCartDetailsProjection = mongoDBInlineProjection<
200
+ ShoppingCartDetails,
201
+ ShoppingCartEvent
202
+ >({
203
+ name: 'shopping_cart_details', // optional, defaults to '_default'
204
+ schemaVersion: 1,
205
+ canHandle: ['ProductItemAdded', 'DiscountApplied'],
206
+ evolve: (document, event) => {
207
+ const doc = document ?? { productItems: [], totalAmount: 0, itemCount: 0 };
208
+
209
+ switch (event.type) {
210
+ case 'ProductItemAdded':
211
+ return {
212
+ productItems: [...doc.productItems, event.data.productItem],
213
+ totalAmount:
214
+ doc.totalAmount +
215
+ event.data.productItem.price * event.data.productItem.quantity,
216
+ itemCount: doc.itemCount + 1,
217
+ };
218
+ case 'DiscountApplied':
219
+ return {
220
+ ...doc,
221
+ totalAmount: doc.totalAmount * (1 - event.data.percent / 100),
222
+ };
223
+ }
224
+ },
225
+ });
226
+
227
+ const eventStore = getMongoDBEventStore({
228
+ connectionString: 'mongodb://localhost:27017/mydb',
229
+ projections: projections.inline([shoppingCartDetailsProjection]),
230
+ });
231
+ ```
232
+
233
+ With an initial state:
234
+
235
+ ```typescript
236
+ const projectionWithInitialState = mongoDBInlineProjection<
237
+ ShoppingCartDetails,
238
+ ShoppingCartEvent
239
+ >({
240
+ canHandle: ['ProductItemAdded', 'DiscountApplied'],
241
+ initialState: () => ({ productItems: [], totalAmount: 0, itemCount: 0 }),
242
+ evolve: (document, event) => {
243
+ // document is never null here due to initialState
244
+ switch (event.type) {
245
+ case 'ProductItemAdded':
246
+ return {
247
+ productItems: [...document.productItems, event.data.productItem],
248
+ totalAmount:
249
+ document.totalAmount +
250
+ event.data.productItem.price * event.data.productItem.quantity,
251
+ itemCount: document.itemCount + 1,
252
+ };
253
+ case 'DiscountApplied':
254
+ return {
255
+ ...document,
256
+ totalAmount: document.totalAmount * (1 - event.data.percent / 100),
257
+ };
258
+ }
259
+ },
260
+ });
261
+ ```
262
+
263
+ ### Query Inline Projections
264
+
265
+ The event store provides helpers for querying inline projections:
266
+
267
+ ```typescript
268
+ // Find a single projection by stream name
269
+ const details =
270
+ await eventStore.projections.inline.findOne<ShoppingCartDetails>(
271
+ { streamName: 'shopping_cart:cart-123' },
272
+ { totalAmount: { $gt: 100 } }, // optional MongoDB filter
273
+ );
274
+
275
+ // Find by stream type and ID
276
+ const details2 =
277
+ await eventStore.projections.inline.findOne<ShoppingCartDetails>({
278
+ streamType: 'shopping_cart',
279
+ streamId: 'cart-123',
280
+ projectionName: 'shopping_cart_details',
281
+ });
282
+
283
+ // Find multiple projections
284
+ const allCarts = await eventStore.projections.inline.find<ShoppingCartDetails>(
285
+ { streamType: 'shopping_cart' },
286
+ { totalAmount: { $gt: 50 } },
287
+ { skip: 0, limit: 10, sort: { totalAmount: -1 } },
288
+ );
289
+
290
+ // Count projections
291
+ const count = await eventStore.projections.inline.count<ShoppingCartDetails>(
292
+ { streamType: 'shopping_cart' },
293
+ { itemCount: { $gte: 5 } },
294
+ );
295
+ ```
296
+
297
+ ### Access Raw Collections
298
+
299
+ For advanced queries, access the underlying MongoDB collection:
300
+
301
+ ```typescript
302
+ const collection =
303
+ await eventStore.collectionFor<ShoppingCartEvent>('shopping_cart');
304
+
305
+ // Use standard MongoDB operations
306
+ const streams = await collection
307
+ .find({ 'metadata.streamPosition': { $gt: 10n } })
308
+ .toArray();
309
+ ```
310
+
311
+ ### Test Inline Projections
312
+
313
+ Use the BDD-style specification helpers:
314
+
315
+ ```typescript
316
+ import {
317
+ MongoDBInlineProjectionSpec,
318
+ eventInStream,
319
+ expectInlineReadModel,
320
+ } from '@event-driven-io/emmett-mongodb';
321
+
322
+ const given = MongoDBInlineProjectionSpec.for<
323
+ `shopping_cart:${string}`,
324
+ ShoppingCartEvent
325
+ >({
326
+ projection: shoppingCartDetailsProjection,
327
+ connectionString: 'mongodb://localhost:27017/testdb',
328
+ });
329
+
330
+ await given(
331
+ eventInStream('shopping_cart:test-1', {
332
+ type: 'ProductItemAdded',
333
+ data: { productItem: { productId: 'p1', quantity: 1, price: 100 } },
334
+ }),
335
+ )
336
+ .when([
337
+ { type: 'DiscountApplied', data: { percent: 10, couponId: 'SAVE10' } },
338
+ ])
339
+ .then(
340
+ expectInlineReadModel
341
+ .withName('shopping_cart_details')
342
+ .toHave({ totalAmount: 90 }),
343
+ );
344
+ ```
345
+
346
+ ## API Reference
347
+
348
+ ### getMongoDBEventStore
349
+
350
+ Creates a MongoDB event store instance.
351
+
352
+ ```typescript
353
+ function getMongoDBEventStore(
354
+ options: MongoDBEventStoreOptions,
355
+ ): MongoDBEventStore;
356
+ ```
357
+
358
+ **Options:**
359
+
360
+ | Property | Type | Description |
361
+ | ------------------ | --------------------------------- | -------------------------------------------------------------------- |
362
+ | `client` | `MongoClient` | Existing MongoDB client (mutually exclusive with `connectionString`) |
363
+ | `connectionString` | `string` | MongoDB connection URI (mutually exclusive with `client`) |
364
+ | `clientOptions` | `MongoClientOptions` | Options for MongoClient when using connection string |
365
+ | `projections` | `ProjectionRegistration[]` | Array of inline projection definitions |
366
+ | `storage` | `MongoDBEventStoreStorageOptions` | Storage strategy configuration |
367
+
368
+ ### MongoDBEventStore
369
+
370
+ Extended EventStore interface with MongoDB-specific features:
371
+
372
+ | Method | Description |
373
+ | --------------------------------------------------- | ------------------------------------------------------------------- |
374
+ | `readStream(streamName, options?)` | Read events from a stream |
375
+ | `appendToStream(streamName, events, options?)` | Append events to a stream |
376
+ | `aggregateStream(streamName, options)` | Fold events into aggregate state |
377
+ | `collectionFor(streamType)` | Get raw MongoDB collection for a stream type |
378
+ | `projections.inline.findOne(filter, query?)` | Find single inline projection |
379
+ | `projections.inline.find(filter, query?, options?)` | Find multiple inline projections |
380
+ | `projections.inline.count(filter, query?)` | Count inline projections |
381
+ | `close()` | Close the MongoDB client (only when created with connection string) |
382
+
383
+ ### Stream Naming Functions
384
+
385
+ | Function | Description |
386
+ | ------------------------------------------ | ------------------------------------------------- |
387
+ | `toStreamName(streamType, streamId)` | Create stream name: `streamType:streamId` |
388
+ | `fromStreamName(streamName)` | Parse stream name into `{ streamType, streamId }` |
389
+ | `toStreamCollectionName(streamType)` | Create collection name: `emt:streamType` |
390
+ | `fromStreamCollectionName(collectionName)` | Parse collection name into `{ streamType }` |
391
+
392
+ ### mongoDBInlineProjection
393
+
394
+ Creates an inline projection definition.
395
+
396
+ ```typescript
397
+ function mongoDBInlineProjection<Doc, EventType>(
398
+ options: MongoDBInlineProjectionOptions<Doc, EventType>,
399
+ ): MongoDBInlineProjectionDefinition;
400
+ ```
401
+
402
+ **Options:**
403
+
404
+ | Property | Type | Description |
405
+ | --------------- | ----------- | -------------------------------------------- |
406
+ | `name` | `string` | Projection name (default: `_default`) |
407
+ | `schemaVersion` | `number` | Schema version for migrations (default: `1`) |
408
+ | `canHandle` | `string[]` | Event types this projection handles |
409
+ | `evolve` | `Function` | State evolution function |
410
+ | `initialState` | `() => Doc` | Optional initial state factory |
411
+
412
+ ## Architecture
413
+
414
+ ### Document Structure
415
+
416
+ Each event stream is stored as a single MongoDB document:
417
+
418
+ ```typescript
419
+ interface EventStream {
420
+ streamName: string; // e.g., "shopping_cart:abc-123"
421
+ messages: ReadEvent[]; // Array of events with metadata
422
+ metadata: {
423
+ streamId: string;
424
+ streamType: string;
425
+ streamPosition: bigint; // Current version (BigInt)
426
+ createdAt: Date;
427
+ updatedAt: Date;
428
+ };
429
+ projections: {
430
+ // Inline projections
431
+ [projectionName: string]: MongoDBReadModel;
432
+ };
433
+ }
434
+ ```
435
+
436
+ ### Read Model Structure
437
+
438
+ Inline projections include metadata for version tracking:
439
+
440
+ ```typescript
441
+ interface MongoDBReadModel<Doc> {
442
+ ...Doc; // Your projection fields
443
+ _metadata: {
444
+ streamId: string;
445
+ name: string; // Projection name
446
+ schemaVersion: number;
447
+ streamPosition: bigint; // Last processed event position
448
+ };
449
+ }
450
+ ```
451
+
452
+ ### Optimistic Concurrency
453
+
454
+ The event store uses MongoDB's atomic update operations with version checking:
455
+
456
+ ```typescript
457
+ await eventStore.appendToStream(
458
+ streamName,
459
+ events,
460
+ { expectedStreamVersion: 5n }, // Fails if current version !== 5
461
+ );
462
+ ```
463
+
464
+ Special version constants:
465
+
466
+ - `STREAM_DOES_NOT_EXIST` - Expect stream to not exist
467
+ - `STREAM_EXISTS` - Expect stream to exist (any version)
468
+ - `NO_CONCURRENCY_CHECK` - Skip version validation
469
+
470
+ ## Dependencies
471
+
472
+ ### Peer Dependencies
473
+
474
+ | Package | Version | Purpose |
475
+ | ------------------------- | --------- | -------------------------------- |
476
+ | `@event-driven-io/emmett` | `0.38.3` | Core event sourcing abstractions |
477
+ | `mongodb` | `^6.10.0` | MongoDB driver |
478
+
479
+ ### Development Dependencies
480
+
481
+ | Package | Purpose |
482
+ | ---------------------------------------- | ------------------------ |
483
+ | `@event-driven-io/emmett-testcontainers` | Test container utilities |
484
+ | `@testcontainers/mongodb` | MongoDB testcontainer |