event-storage 1.0.0 → 1.1.0

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 CHANGED
@@ -62,6 +62,8 @@ eventstore.on('ready', () => {
62
62
  | **Optimistic concurrency** | Pass `expectedVersion` to `commit()` to guarantee conflict-free writes. |
63
63
  | **Flexible stream reading** | Range queries, reverse iteration, and a fluent builder API. |
64
64
  | **Derived streams** | Filter or combine events into new read-only streams. |
65
+ | **Multi-value matchers** | Object matchers support array values (OR semantics) and still benefit from O(1) discriminant routing on writes. |
66
+ | **DCB / `typeAccessor`** | Configure `typeAccessor` to have per-type stream indexes maintained automatically, and use `query()` / `Condition` for fine-grained, query-scoped optimistic concurrency (Dynamic Consistency Boundaries). |
65
67
  | **Stream categories** | Name streams `<category>-<id>` and query the whole category at once. |
66
68
  | **Durable consumers** | At-least-once (and exactly-once with `setState`) event delivery with automatic position tracking. |
67
69
  | **Consistency guards** | Build aggregates that enforce business invariants with built-in snapshotting. |
@@ -79,6 +81,7 @@ The full documentation is hosted at **<https://node-event-storage.readthedocs.io
79
81
 
80
82
  - [Getting Started](https://node-event-storage.readthedocs.io/en/latest/getting-started/) — installation, constructor options, basic usage.
81
83
  - [Event Streams](https://node-event-storage.readthedocs.io/en/latest/streams/) — writing, reading, optimistic concurrency, fluent API, joining streams, categories, and event metadata.
84
+ - [Dynamic Consistency Boundaries (DCB)](https://node-event-storage.readthedocs.io/en/latest/dcb/) — `typeAccessor`, multi-value matchers, consistency tokens, and the full DCB workflow.
82
85
  - [Consumers](https://node-event-storage.readthedocs.io/en/latest/consumers/) — at-least-once and exactly-once delivery, consumer state, consistency guards, and read-only mode.
83
86
  - [Advanced Topics](https://node-event-storage.readthedocs.io/en/latest/advanced/) — ACID properties, reliability and crash-safety guarantees, storage configuration, partitioning, custom serialization, compression, security, and access control hooks.
84
87
 
package/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { default as EventStore, default, ExpectedVersion, OptimisticConcurrencyError, LOCK_THROW, LOCK_RECLAIM } from './src/EventStore.js';
1
+ export { default as EventStore, default, ExpectedVersion, OptimisticConcurrencyError, CommitCondition, LOCK_THROW, LOCK_RECLAIM } from './src/EventStore.js';
2
2
  export { default as EventStream } from './src/EventStream.js';
3
3
  export { default as Storage, StorageLockedError } from './src/Storage.js';
4
4
  export { default as Index } from './src/Index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "event-storage",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "An optimized embedded event store for node.js",
6
6
  "keywords": [
@@ -44,6 +44,7 @@
44
44
  "src/Storage/*.js",
45
45
  "src/WatchesFile.js",
46
46
  "src/util.js",
47
+ "src/fsUtil.js",
47
48
  "src/metadataUtil.js",
48
49
  "index.js"
49
50
  ],
package/src/Consumer.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import stream from 'stream';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
- import { assert, ensureDirectory } from './util.js';
4
+ import { assert } from './util.js';
5
+ import { ensureDirectory } from './fsUtil.js';
5
6
  import Storage from './Storage/ReadableStorage.js';
6
7
  const MAX_CATCHUP_BATCH = 10;
7
8
 
package/src/EventStore.js CHANGED
@@ -6,15 +6,47 @@ import events from 'events';
6
6
  import Storage, { ReadOnly as ReadOnlyStorage, LOCK_THROW, LOCK_RECLAIM } from './Storage.js';
7
7
  import Index from './Index.js';
8
8
  import Consumer from './Consumer.js';
9
- import { assert, scanForFiles } from './util.js';
9
+ import { assert, getPropertyAtPath } from './util.js';
10
+ import { ensureDirectory, scanForFiles } from './fsUtil.js';
11
+ import { buildTypeMatcherFn } from './metadataUtil.js';
10
12
 
11
13
  const ExpectedVersion = {
12
14
  Any: -1,
13
15
  EmptyStream: 0
14
16
  };
15
17
 
18
+ /**
19
+ * Default matcher property paths mirroring the Storage default, used for index optimization.
20
+ */
21
+ const DEFAULT_MATCHER_PROPERTIES = ['stream', 'payload.type'];
22
+
16
23
  class OptimisticConcurrencyError extends Error {}
17
24
 
25
+ /**
26
+ * An accept condition that captures the global event-log position at the time a {@link EventStore#query}
27
+ * call was made. Pass it as the `expectedVersion` argument to {@link EventStore#commit} to enforce
28
+ * DCB-style (Dynamic Consistency Boundary) optimistic concurrency: the commit is rejected only when
29
+ * one or more events that match the original query (types + optional matcher) have been appended to
30
+ * the store between the `query` call and the `commit` call.
31
+ *
32
+ * @property {string[]} types The event types included in the query.
33
+ * @property {function(object, object): boolean|null} matcher An optional function `(payload, metadata) => boolean`
34
+ * used to narrow the conflict check. When `null`, any new event of a listed type causes a conflict.
35
+ * @property {number} noneMatchAfter The global store length (total event count) at the time the query was made.
36
+ */
37
+ class CommitCondition {
38
+ /**
39
+ * @param {string[]} types
40
+ * @param {function(object, object): boolean|null} [matcher]
41
+ * @param {number} noneMatchAfter
42
+ */
43
+ constructor(types, matcher = null, noneMatchAfter) {
44
+ this.types = types;
45
+ this.matcher = matcher;
46
+ this.noneMatchAfter = noneMatchAfter;
47
+ }
48
+ }
49
+
18
50
  /**
19
51
  * An event store optimized for working with many streams.
20
52
  * An event stream is implemented as an iterator over an index on the storage, therefore indexes need to be lightweight
@@ -29,6 +61,9 @@ class EventStore extends events.EventEmitter {
29
61
  * @param {string} [config.streamsDirectory] The directory where the streams should be stored. Default '{storageDirectory}/streams'.
30
62
  * @param {object} [config.storageConfig] Additional config options given to the storage backend. See `Storage`.
31
63
  * @param {boolean} [config.readOnly] If the storage should be mounted in read-only mode.
64
+ * @param {string|function(object): string} [config.typeAccessor] Dot-notation path (e.g. `'type'`) or
65
+ * function `(event) => string` identifying the event type. Enables type-based queries via
66
+ * {@link EventStore#query} and ensures proper index routing for those queries.
32
67
  * @param {object|function(string): object} [config.streamMetadata] A metadata object or a function `(streamName) => object`
33
68
  * that is called whenever a new stream partition is created. The returned object is stored once in the partition
34
69
  * file header and surfaced to `preCommit` / `preRead` hooks. Takes precedence only when
@@ -41,6 +76,15 @@ class EventStore extends events.EventEmitter {
41
76
  storeName = 'eventstore';
42
77
  }
43
78
 
79
+ if (typeof config.typeAccessor === 'string' && config.typeAccessor) {
80
+ const accessorPath = config.typeAccessor;
81
+ this.typeAccessor = (event) => getPropertyAtPath(event, accessorPath);
82
+ this.typeMatcherFn = buildTypeMatcherFn(accessorPath);
83
+ } else {
84
+ this.typeAccessor = typeof config.typeAccessor === 'function' ? config.typeAccessor : null;
85
+ this.typeMatcherFn = null;
86
+ }
87
+
44
88
  this.storageDirectory = path.resolve(config.storageDirectory || /* istanbul ignore next */ './data');
45
89
  let defaults = {
46
90
  dataDirectory: this.storageDirectory,
@@ -50,6 +94,17 @@ class EventStore extends events.EventEmitter {
50
94
  };
51
95
  const storageConfig = Object.assign(defaults, config.storageConfig);
52
96
 
97
+ // When typeAccessor is a string path, ensure the corresponding full document path
98
+ // (payload.<path>) is present in matcherProperties so the IndexMatcher discriminant
99
+ // table can route type-stream lookups in O(1) on every write.
100
+ if (this.typeMatcherFn) {
101
+ const fullPath = `payload.${config.typeAccessor}`;
102
+ const currentProps = storageConfig.matcherProperties || DEFAULT_MATCHER_PROPERTIES;
103
+ if (!currentProps.includes(fullPath)) {
104
+ storageConfig.matcherProperties = [...currentProps, fullPath];
105
+ }
106
+ }
107
+
53
108
  // Translate the high-level streamMetadata option into the storage-level metadata function,
54
109
  // but only when the caller has not already provided a lower-level storageConfig.metadata.
55
110
  if (config.streamMetadata !== undefined && storageConfig.metadata === undefined) {
@@ -75,18 +130,17 @@ class EventStore extends events.EventEmitter {
75
130
  this.storage = (storageConfig.readOnly === true) ?
76
131
  new ReadOnlyStorage(storeName, storageConfig)
77
132
  : new Storage(storeName, storageConfig);
78
- this.storage.open();
79
133
  this.streams = Object.create(null);
80
134
  this.streams._all = { index: this.storage.index };
81
135
 
82
- this.scanStreams((err) => {
83
- if (err) {
84
- this.storage.close();
85
- throw err;
86
- }
136
+ this.storage.on('index-created', this.registerStream.bind(this));
137
+
138
+ this.storage.on('opened', () => {
87
139
  this.checkUnfinishedCommits();
88
140
  this.emit('ready');
89
141
  });
142
+
143
+ this.storage.open();
90
144
  }
91
145
 
92
146
  /**
@@ -121,22 +175,6 @@ class EventStore extends events.EventEmitter {
121
175
  }
122
176
  }
123
177
 
124
- /**
125
- * Scan the streams directory for existing streams so they are ready for `getEventStream()`.
126
- *
127
- * @private
128
- * @param {function} callback A callback that will be called when all existing streams are found.
129
- */
130
- scanStreams(callback) {
131
- /* istanbul ignore if */
132
- if (typeof callback !== 'function') {
133
- callback = () => {};
134
- }
135
- // Find existing streams by scanning dir for filenames starting with 'stream-'
136
- scanForFiles(this.streamsDirectory, /(stream-.*)\.index$/, this.registerStream.bind(this), callback);
137
- this.storage.on('index-created', this.registerStream.bind(this));
138
- }
139
-
140
178
  /**
141
179
  * @private
142
180
  * @param {string} name The full stream name, including the `stream-` prefix (and optional `.closed` suffix).
@@ -299,16 +337,16 @@ class EventStore extends events.EventEmitter {
299
337
  *
300
338
  * @private
301
339
  * @param {Array<object>|object} events
302
- * @param {number} [expectedVersion]
340
+ * @param {number|CommitCondition} [expectedVersion]
303
341
  * @param {object|function} [metadata]
304
342
  * @param {function} [callback]
305
- * @returns {{events: Array<object>, metadata: object, callback: function, expectedVersion: number}}
343
+ * @returns {{events: Array<object>, metadata: object, callback: function, expectedVersion: number|CommitCondition}}
306
344
  */
307
345
  static fixArgumentTypes(events, expectedVersion, metadata, callback) {
308
346
  if (!(events instanceof Array)) {
309
347
  events = [events];
310
348
  }
311
- if (typeof expectedVersion !== 'number') {
349
+ if (typeof expectedVersion !== 'number' && !(expectedVersion instanceof CommitCondition)) {
312
350
  callback = metadata;
313
351
  metadata = expectedVersion;
314
352
  expectedVersion = ExpectedVersion.Any;
@@ -323,6 +361,58 @@ class EventStore extends events.EventEmitter {
323
361
  return { events, expectedVersion, metadata, callback };
324
362
  }
325
363
 
364
+ /**
365
+ * Check a {@link CommitCondition} against the current state of the store.
366
+ * Iterates a join stream over all condition type streams starting from
367
+ * `condition.noneMatchAfter` (the global position captured at query time), and throws an
368
+ * {@link OptimisticConcurrencyError} when a new event of a listed type satisfies
369
+ * `condition.matcher(payload, metadata)` (or any such event when no matcher is provided).
370
+ *
371
+ * @param {CommitCondition} condition
372
+ * @throws {OptimisticConcurrencyError}
373
+ */
374
+ checkCondition(condition) {
375
+ if (this.storage.length <= condition.noneMatchAfter) return; // no new events since condition was obtained
376
+
377
+ const existingTypes = condition.types.filter(t => t in this.streams);
378
+ if (existingTypes.length === 0) return;
379
+
380
+ // Only events after condition.noneMatchAfter can be conflicts.
381
+ const stream = this.fromStreams(
382
+ '_check_' + condition.types.join('_'),
383
+ existingTypes,
384
+ condition.noneMatchAfter + 1
385
+ );
386
+
387
+ let next;
388
+ while ((next = stream.next()) !== false) {
389
+ if (!condition.matcher || condition.matcher(next.payload, next.metadata)) {
390
+ throw new OptimisticConcurrencyError(
391
+ `Optimistic Concurrency error. A conflicting event was committed since the condition was obtained.`
392
+ );
393
+ }
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Ensure a dedicated type stream exists for each event's type, creating it if needed.
399
+ * Must be called before the entity stream is created to guarantee correct index routing.
400
+ *
401
+ * @param {Array<object>} events The events to process.
402
+ */
403
+ ensureTypeStreams(events) {
404
+ if (!this.typeAccessor) return;
405
+ for (const event of events) {
406
+ const type = this.typeAccessor(event);
407
+ if (type && !(type in this.streams)) {
408
+ const matcher = this.typeMatcherFn
409
+ ? this.typeMatcherFn(type)
410
+ : (doc) => this.typeAccessor(doc.payload) === type;
411
+ this.createEventStream(type, matcher, false);
412
+ }
413
+ }
414
+ }
415
+
326
416
  /**
327
417
  * Commit a list of events for the given stream name, which is expected to be at the given version.
328
418
  * Note that the events committed may still appear in other streams too - the given stream name is only
@@ -331,10 +421,12 @@ class EventStore extends events.EventEmitter {
331
421
  * @api
332
422
  * @param {string} streamName The name of the stream to commit the events to.
333
423
  * @param {Array<object>|object} events The events to commit or a single event.
334
- * @param {number} [expectedVersion] One of ExpectedVersion constants or a positive version number that the stream is supposed to be at before commit.
424
+ * @param {number|CommitCondition} [expectedVersion] One of the `ExpectedVersion` constants, a positive
425
+ * stream version number, or a {@link CommitCondition} obtained from {@link EventStore#query}.
335
426
  * @param {object} [metadata] The commit metadata to use as base. Useful for replication and adding storage metadata.
336
427
  * @param {function} [callback] A function that will be executed when all events have been committed.
337
- * @throws {OptimisticConcurrencyError} if the stream is not at the expected version.
428
+ * @throws {OptimisticConcurrencyError} if the stream is not at the expected version, or if a
429
+ * {@link CommitCondition} was provided and conflicting events have been committed since it was obtained.
338
430
  */
339
431
  commit(streamName, events, expectedVersion = ExpectedVersion.Any, metadata = {}, callback = null) {
340
432
  assert(!(this.storage instanceof ReadOnlyStorage), 'The storage was opened in read-only mode. Can not commit to it.');
@@ -343,8 +435,18 @@ class EventStore extends events.EventEmitter {
343
435
 
344
436
  ({ events, expectedVersion, metadata, callback } = EventStore.fixArgumentTypes(events, expectedVersion, metadata, callback));
345
437
 
438
+ // Perform DCB-style concurrency check when a CommitCondition is provided.
439
+ if (expectedVersion instanceof CommitCondition) {
440
+ this.checkCondition(expectedVersion);
441
+ expectedVersion = ExpectedVersion.Any;
442
+ }
443
+
444
+ // When typeAccessor is configured, ensure a dedicated type stream exists for each event
445
+ // before the entity stream write so the type stream index is never incomplete.
446
+ this.ensureTypeStreams(events);
447
+
346
448
  if (!(streamName in this.streams)) {
347
- this.createEventStream(streamName, { stream: streamName });
449
+ this.createEventStream(streamName, { stream: streamName }, false);
348
450
  }
349
451
  assert(!this.streams[streamName].closed, `Stream "${streamName}" is closed and cannot be written to.`);
350
452
  let streamVersion = this.streams[streamName].index.length;
@@ -394,6 +496,56 @@ class EventStore extends events.EventEmitter {
394
496
  return this.streams[streamName].index.length;
395
497
  }
396
498
 
499
+ /**
500
+ * Query the event store for events matching a set of event types and an optional filter function.
501
+ * Returns a pre-filtered event stream and a {@link CommitCondition} that can be passed to
502
+ * {@link EventStore#commit} to enforce optimistic concurrency.
503
+ *
504
+ * A conflict occurs when at least one event appended between the `query` call and the `commit` call
505
+ * belongs to one of the listed types and (when `matcher` is provided) also satisfies
506
+ * `matcher(payload, metadata)`. Events written before the `query` call are never treated as conflicts.
507
+ *
508
+ * **Behaviour when a type stream does not exist:**
509
+ * - Without `typeAccessor` configured: throws an error, because the store cannot guarantee that no
510
+ * events of that type exist (the stream was never created). Create the stream explicitly first,
511
+ * or configure `typeAccessor` to have streams created automatically on commit.
512
+ * - With `typeAccessor` configured: treats the missing stream as empty (0-length). The stream will
513
+ * be created automatically the first time an event of that type is committed.
514
+ *
515
+ * @api
516
+ * @param {string[]} types A non-empty array of event-type names to query.
517
+ * @param {function(object, object): boolean|null} [matcher] An optional filter function `(payload, metadata) => boolean`
518
+ * passed to the returned {@link CommitCondition}.
519
+ * @param {number} [minRevision=1] The 1-based minimum global revision to include in the returned stream (inclusive).
520
+ * @returns {{ condition: CommitCondition, stream: EventStream }} An object with:
521
+ * - `condition` — the {@link CommitCondition} to pass to {@link EventStore#commit}.
522
+ * - `stream` — a read-only event stream containing all matching events.
523
+ * @throws {Error} if `types` is not a non-empty array.
524
+ * @throws {Error} if `typeAccessor` is not configured and any of the listed type streams do not exist.
525
+ */
526
+ query(types, matcher = null, minRevision = 1) {
527
+ assert(Array.isArray(types) && types.length > 0, 'Must specify a non-empty array of event types for query.');
528
+
529
+ const queryTypes = [];
530
+ for (const type of types) {
531
+ if (!(type in this.streams)) {
532
+ if (this.typeAccessor) {
533
+ // typeAccessor is configured: type streams are created on commit, so a missing
534
+ // stream simply means no event of this type has been committed yet — treat as empty.
535
+ continue;
536
+ }
537
+ // No typeAccessor: the stream was never created; we cannot know whether events of
538
+ // this type exist in the store, so throw to avoid an unintentional full-store scan.
539
+ throw new Error(`Type stream "${type}" does not exist. Create it with createEventStream() first, or configure typeAccessor to have type streams created automatically on commit.`);
540
+ }
541
+ queryTypes.push(type);
542
+ }
543
+
544
+ const condition = new CommitCondition(types, matcher, this.storage.length);
545
+ const stream = this.fromStreams('_query_' + types.join('_'), queryTypes, minRevision, -1, matcher);
546
+ return { stream, condition };
547
+ }
548
+
397
549
  /**
398
550
  * Get an event stream for the given stream name within the revision boundaries.
399
551
  *
@@ -430,25 +582,39 @@ class EventStore extends events.EventEmitter {
430
582
  * @param {Array<string>} streamNames An array of the stream names to join.
431
583
  * @param {number} [minRevision] The 1-based minimum revision to include in the events (inclusive).
432
584
  * @param {number} [maxRevision] The 1-based maximum revision to include in the events (inclusive).
585
+ * @param {function(object, object): boolean|null} [predicate] An optional filter predicate
586
+ * `(payload, metadata) => boolean`. Only events for which this returns truthy are yielded.
433
587
  * @returns {EventStream} The joined event stream.
434
588
  * @throws {Error} if any of the streams doesn't exist.
435
589
  */
436
- fromStreams(streamName, streamNames, minRevision = 1, maxRevision = -1) {
590
+ fromStreams(streamName, streamNames, minRevision = 1, maxRevision = -1, predicate = null) {
437
591
  assert(streamNames instanceof Array, 'Must specify an array of stream names.');
438
592
 
593
+ if (streamNames.length === 0) {
594
+ return new EventStream(streamName, this);
595
+ }
596
+
439
597
  for (let stream of streamNames) {
440
598
  assert(stream in this.streams, `Stream "${stream}" does not exist.`);
441
599
  }
442
- return new JoinEventStream(streamName, streamNames, this, minRevision, maxRevision);
600
+
601
+ if (streamNames.length === 1) {
602
+ const stream = new EventStream(streamNames[0], this, minRevision, maxRevision, predicate);
603
+ stream.name = streamName;
604
+ return stream;
605
+ }
606
+
607
+ return new JoinEventStream(streamName, streamNames, this, minRevision, maxRevision, predicate);
443
608
  }
444
609
 
445
610
  /**
446
611
  * Get a stream for a category of streams. This will effectively return a joined stream of all streams that start
447
- * with the given `categoryName` followed by a dash.
612
+ * with the given `categoryName` followed by a dash (flat layout, e.g. `users-123`) or a slash (hierarchical
613
+ * layout, e.g. `users/123`).
448
614
  * If you frequently use this for a category consisting of a lot of streams (e.g. `users`), consider creating a
449
615
  * dedicated physical stream for the category:
450
616
  *
451
- * `eventstore.createEventStream('users', e => e.stream.startsWith('users-'))`
617
+ * `eventstore.createEventStream('users', e => e.stream.startsWith('users-') || e.stream.startsWith('users/'))`
452
618
  *
453
619
  * @api
454
620
  * @param {string} categoryName The name of the category to get a stream for. A category is a stream name prefix.
@@ -461,7 +627,10 @@ class EventStore extends events.EventEmitter {
461
627
  if (categoryName in this.streams) {
462
628
  return this.getEventStream(categoryName, minRevision, maxRevision);
463
629
  }
464
- const categoryStreams = Object.keys(this.streams).filter(streamName => streamName.startsWith(categoryName + '-'));
630
+ const categoryStreams = Object.keys(this.streams).filter(streamName =>
631
+ streamName.startsWith(categoryName + '-') ||
632
+ streamName.startsWith(categoryName + '/')
633
+ );
465
634
 
466
635
  if (categoryStreams.length === 0) {
467
636
  throw new Error(`No streams for category '${categoryName}' exist.`);
@@ -475,16 +644,21 @@ class EventStore extends events.EventEmitter {
475
644
  * @api
476
645
  * @param {string} streamName The name of the stream to create.
477
646
  * @param {object|function(event)} matcher A matcher object, denoting the properties that need to match on an event a function that takes the event and returns true if the event should be added.
647
+ * @param {boolean} [reindex=true] Whether to scan existing documents and populate the new index. Set to false when it is known that no existing documents can match the matcher (e.g. when creating a brand-new write stream).
478
648
  * @returns {EventStream} The EventStream with all existing events matching the matcher.
479
649
  * @throws {Error} If a stream with that name already exists.
480
650
  * @throws {Error} If the stream could not be created.
481
651
  */
482
- createEventStream(streamName, matcher) {
652
+ createEventStream(streamName, matcher, reindex = true) {
483
653
  assert(!(this.storage instanceof ReadOnlyStorage), 'The storage was opened in read-only mode. Can not create new stream on it.');
484
654
  assert(!(streamName in this.streams), 'Can not recreate stream!');
485
655
 
486
656
  const streamIndexName = 'stream-' + streamName;
487
- const index = this.storage.ensureIndex(streamIndexName, matcher);
657
+ if (streamName.includes('/')) {
658
+ const subDir = path.join(this.streamsDirectory, this.storeName + '.stream-' + path.dirname(streamName));
659
+ ensureDirectory(subDir);
660
+ }
661
+ const index = this.storage.ensureIndex(streamIndexName, matcher, reindex);
488
662
  assert(index !== null, `Error creating stream index ${streamName}.`);
489
663
 
490
664
  // deepcode ignore PrototypePollutionFunctionParams: streams is a Map
@@ -543,7 +717,7 @@ class EventStore extends events.EventEmitter {
543
717
  fs.renameSync(index.fileName, closedFileName);
544
718
 
545
719
  // Remove from secondary indexes so that new writes are no longer indexed into this stream
546
- delete this.storage.secondaryIndexes[indexName];
720
+ this.storage.removeSecondaryIndex(indexName);
547
721
 
548
722
  // Reopen the renamed index for read access, outside the secondary indexes write path
549
723
  const closedIndexName = indexName + '.closed';
@@ -594,4 +768,4 @@ EventStore.Storage = Storage;
594
768
  EventStore.Index = Index;
595
769
 
596
770
  export default EventStore;
597
- export { ExpectedVersion, OptimisticConcurrencyError, LOCK_THROW, LOCK_RECLAIM };
771
+ export { ExpectedVersion, OptimisticConcurrencyError, CommitCondition, LOCK_THROW, LOCK_RECLAIM };
@@ -33,13 +33,16 @@ class EventStream extends stream.Readable {
33
33
  * @param {EventStore} eventStore The event store to get the stream from.
34
34
  * @param {number} [minRevision] The minimum revision to include in the events (inclusive).
35
35
  * @param {number} [maxRevision] The maximum revision to include in the events (inclusive).
36
+ * @param {function(object, object): boolean|null} [predicate] An optional filter function
37
+ * `(payload, metadata) => boolean`. Only events for which this returns truthy are yielded.
36
38
  */
37
- constructor(name, eventStore, minRevision = 1, maxRevision = -1) {
39
+ constructor(name, eventStore, minRevision = 1, maxRevision = -1, predicate = null) {
38
40
  super({ objectMode: true });
39
41
  assert(typeof name === 'string' && name !== '', 'Need to specify a stream name.');
40
42
  assert(typeof eventStore === 'object' && eventStore !== null, `Need to provide EventStore instance to create EventStream ${name}.`);
41
43
 
42
44
  this.name = name;
45
+ this.predicate = predicate || null;
43
46
  if (eventStore.streams[name]) {
44
47
  this.streamIndex = eventStore.streams[name].index;
45
48
  this.minRevision = normalizeVersion(minRevision, this.streamIndex.length);
@@ -244,6 +247,23 @@ class EventStream extends stream.Readable {
244
247
  return this;
245
248
  }
246
249
 
250
+ /**
251
+ * Apply a filter predicate to this stream. Only events for which `predicate(payload, metadata)`
252
+ * returns a truthy value will be yielded. The predicate is stored as a first-class property
253
+ * of the stream and applied in {@link EventStream#next}.
254
+ *
255
+ * @api
256
+ * @param {function(object, object): boolean} predicate A function receiving `(payload, metadata)`.
257
+ * Events for which the predicate returns falsy are skipped.
258
+ * @returns {EventStream} `this`
259
+ */
260
+ filter(predicate) {
261
+ this.predicate = predicate || null;
262
+ this._iterator = null;
263
+ this._events = null;
264
+ return this;
265
+ }
266
+
247
267
  /**
248
268
  * @returns {object|boolean} The next event or false if no more events in the stream.
249
269
  */
@@ -251,13 +271,17 @@ class EventStream extends stream.Readable {
251
271
  if (!this._iterator) {
252
272
  this._iterator = this.fetch();
253
273
  }
254
- let next;
255
274
  try {
256
- next = this._iterator.next();
275
+ while (true) {
276
+ const result = this._iterator.next();
277
+ if (result.done) return false;
278
+ if (!this.predicate || this.predicate(result.value.payload, result.value.metadata)) {
279
+ return result.value;
280
+ }
281
+ }
257
282
  } catch(e) {
258
283
  return false;
259
284
  }
260
- return next.done ? false : next.value;
261
285
  }
262
286
 
263
287
  // noinspection JSUnusedGlobalSymbols
@@ -1,6 +1,8 @@
1
1
  import fs from 'fs';
2
2
  import ReadableIndex, { Entry, CorruptedIndexError, HEADER_MAGIC } from './ReadableIndex.js';
3
- import { assertEqual, buildMetadataHeader, ensureDirectory } from '../util.js';
3
+ import { assertEqual } from '../util.js';
4
+ import { buildMetadataHeader } from '../metadataUtil.js';
5
+ import { ensureDirectory } from '../fsUtil.js';
4
6
 
5
7
  /**
6
8
  * An index is a simple append-only file that stores an ordered list of entry elements pointing to the actual file position