• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

albe / node-event-storage / 27492865541

14 Jun 2026 08:10AM UTC coverage: 98.686% (-0.1%) from 98.787%
27492865541

Pull #322

github

web-flow
Merge 128175f25 into 388cb5d1f
Pull Request #322: fix: resolve race condition crashing read-only live-watch reader

1355 of 1394 branches covered (97.2%)

Branch coverage included in aggregate %.

132 of 139 new or added lines in 2 files covered. (94.96%)

29 existing lines in 5 files now uncovered.

6684 of 6752 relevant lines covered (98.99%)

799.85 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.05
/src/EventStore.js
1
import EventStream from './EventStream.js';
4✔
2
import JoinEventStream from './JoinEventStream.js';
4✔
3
import fs from 'fs';
4✔
4
import path from 'path';
4✔
5
import events from 'events';
4✔
6
import Storage, { ReadOnly as ReadOnlyStorage, LOCK_THROW, LOCK_RECLAIM } from './Storage.js';
4✔
7
import Index from './Index.js';
4✔
8
import Consumer from './Consumer.js';
4✔
9
import { assert, getPropertyAtPath } from './utils/util.js';
4✔
10
import { ensureDirectory, scanForFiles } from './utils/fsUtil.js';
4✔
11
import { buildTypeMatcherFn } from './utils/metadataUtil.js';
4✔
12
import { fixCommitArgumentTypes, parseStreamFromIndexName, normalizePredicateRaw } from './utils/apiHelpers.js';
4✔
13

4✔
14
const ExpectedVersion = {
4✔
15
    Any: -1,
4✔
16
    EmptyStream: 0
4✔
17
};
4✔
18

4✔
19
/**
4✔
20
 * Default matcher property paths mirroring the Storage default, used for index optimization.
4✔
21
 */
4✔
22
const DEFAULT_MATCHER_PROPERTIES = ['stream', 'payload.type'];
4✔
23
const STREAM_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_]*(?:[\/:@~+=\-#.][A-Za-z0-9_]+)*$/;
4✔
24
const STORAGE_HOOK_EVENTS = new Set(['preCommit', 'preRead']);
4✔
25

4✔
26
class OptimisticConcurrencyError extends Error {}
4✔
27

4✔
28
/**
4✔
29
 * An accept condition that captures the global event-log position at the time a {@link EventStore#query}
4✔
30
 * call was made.  Pass it as the `expectedVersion` argument to {@link EventStore#commit} to enforce
4✔
31
 * DCB-style (Dynamic Consistency Boundary) optimistic concurrency: the commit is rejected only when
4✔
32
 * one or more events that match the original query (types + optional matcher) have been appended to
4✔
33
 * the store between the `query` call and the `commit` call.
4✔
34
 *
4✔
35
 * @property {string[]} types   The event types included in the query.
4✔
36
 * @property {function(object, object): boolean|null} matcher An optional function `(payload, metadata) => boolean`
4✔
37
 *   used to narrow the conflict check.  When `null`, any new event of a listed type causes a conflict.
4✔
38
 * @property {number}   noneMatchAfter The global store length (total event count) at the time the query was made.
4✔
39
 */
4✔
40
class CommitCondition {
4✔
41
    /**
4✔
42
     * @param {string[]} types
4✔
43
     * @param {(function(object, object): boolean)|object|null} [matcher]
4✔
44
     * @param {number}   noneMatchAfter
4✔
45
     * @param {boolean}  [raw=false]
4✔
46
     */
4✔
47
    constructor(types, matcher = null, noneMatchAfter, raw = false) {
4✔
48
        this.types = types;
96✔
49
        this.matcher = matcher;
96✔
50
        this.raw = raw;
96✔
51
        this.noneMatchAfter = noneMatchAfter;
96✔
52
    }
96✔
53
}
4✔
54

4✔
55
/**
4✔
56
 * An event store optimized for working with many streams.
4✔
57
 * An event stream is implemented as an iterator over an index on the storage, therefore indexes need to be lightweight
4✔
58
 * and highly performant in read-only mode.
4✔
59
 */
4✔
60
class EventStore extends events.EventEmitter {
4✔
61

4✔
62
    /**
4✔
63
     * @param {string} [storeName] The name of the store which will be used as storage prefix. Default 'eventstore'.
4✔
64
     * @param {object} [config] An object with config options.
4✔
65
     * @param {string} [config.storageDirectory] The directory where the data should be stored. Default './data'.
4✔
66
     * @param {string} [config.streamsDirectory] The directory where the streams should be stored. Default '{storageDirectory}/streams'.
4✔
67
     * @param {object} [config.storageConfig] Additional config options given to the storage backend. See `Storage`.
4✔
68
     * @param {boolean} [config.readOnly] If the storage should be mounted in read-only mode.
4✔
69
     * @param {string|function(object): string} [config.typeAccessor] Dot-notation path (e.g. `'type'`) or
4✔
70
     *   function `(event) => string` identifying the event type. Enables type-based queries via
4✔
71
     *   {@link EventStore#query} and ensures proper index routing for those queries.
4✔
72
     * @param {object|function(string): object} [config.streamMetadata] A metadata object or a function `(streamName) => object`
4✔
73
     *   that is called whenever a new stream partition is created. The returned object is stored once in the partition
4✔
74
     *   file header and surfaced to `preCommit` / `preRead` hooks. Takes precedence only when
4✔
75
     *   `config.storageConfig.metadata` is not also set.
4✔
76
     */
4✔
77
    constructor(storeName = 'eventstore', config = {}) {
4✔
78
        super();
676✔
79
        if (typeof storeName !== 'string') {
676✔
80
            config = storeName;
672✔
81
            storeName = 'eventstore';
672✔
82
        }
672✔
83

676✔
84
        if (typeof config.typeAccessor === 'string' && config.typeAccessor) {
676✔
85
            const accessorPath = config.typeAccessor;
24✔
86
            this.typeAccessor = (event) => getPropertyAtPath(event, accessorPath);
24✔
87
            this.typeMatcherFn = buildTypeMatcherFn(accessorPath);
24✔
88
        } else {
676✔
89
            this.typeAccessor = typeof config.typeAccessor === 'function' ? config.typeAccessor : null;
652✔
90
            this.typeMatcherFn = null;
652✔
91
        }
652✔
92

676✔
93
        this.storageDirectory = path.resolve(config.storageDirectory || /* istanbul ignore next */ './data');
676!
94
        let defaults = {
676✔
95
            dataDirectory: this.storageDirectory,
676✔
96
            indexDirectory: config.streamsDirectory || path.join(this.storageDirectory, 'streams'),
676✔
97
            partitioner: (event) => event.stream,
676✔
98
            readOnly: config.readOnly || false
676✔
99
        };
676✔
100
        const storageConfig = Object.assign(defaults, config.storageConfig);
676✔
101

676✔
102
        // When typeAccessor is a string path, ensure the corresponding full document path
676✔
103
        // (payload.<path>) is present in matcherProperties so the IndexMatcher discriminant
676✔
104
        // table can route type-stream lookups in O(1) on every write.
676✔
105
        if (this.typeMatcherFn) {
676✔
106
            const fullPath = `payload.${config.typeAccessor}`;
24✔
107
            const currentProps = storageConfig.matcherProperties || DEFAULT_MATCHER_PROPERTIES;
24✔
108
            if (!currentProps.includes(fullPath)) {
24✔
109
                storageConfig.matcherProperties = [...currentProps, fullPath];
8✔
110
            }
8✔
111
        }
24✔
112

676✔
113
        // Translate the high-level streamMetadata option into the storage-level metadata function,
676✔
114
        // but only when the caller has not already provided a lower-level storageConfig.metadata.
676✔
115
        if (config.streamMetadata !== undefined && storageConfig.metadata === undefined) {
676✔
116
            if (typeof config.streamMetadata === 'function') {
76✔
117
                storageConfig.metadata = config.streamMetadata;
4✔
118
            } else {
76✔
119
                storageConfig.metadata = (streamName) => config.streamMetadata[streamName] || {};
72✔
120
            }
72✔
121
        }
76✔
122

676✔
123
        this.initialize(storeName, storageConfig);
676✔
124
    }
676✔
125

4✔
126
    /**
4✔
127
     * @private
4✔
128
     * @param {string} storeName
4✔
129
     * @param {object} storageConfig
4✔
130
     */
4✔
131
    initialize(storeName, storageConfig) {
4✔
132
        this.storageConfig = storageConfig;
676✔
133
        this.streamsDirectory = path.resolve(storageConfig.indexDirectory);
676✔
134
        this.storeName = storeName;
676✔
135
        this.consumers = new Map();
676✔
136

676✔
137
        const storage = storageConfig.readOnly === true
676✔
138
            ? new ReadOnlyStorage(storeName, storageConfig)
676✔
139
            : new Storage(storeName, storageConfig);
676✔
140

676✔
141
        this.mountStorage(storage, () => {
676✔
142
            this.checkUnfinishedCommits();
148✔
143
            this.emit('ready');
148✔
144
        });
676✔
145
    }
676✔
146

4✔
147
    /**
4✔
148
     * Wire up a storage instance: reset the streams map, attach the index-created listener,
4✔
149
     * register a one-shot opened handler, then open the storage.
4✔
150
     * @private
4✔
151
     * @param {ReadableStorage} storage
4✔
152
     * @param {function} [onOpened] Called once when the storage is opened.
4✔
153
     */
4✔
154
    mountStorage(storage, onOpened) {
4✔
155
        this.storage = storage;
692✔
156
        this.streams = Object.create(null);
692✔
157
        this.streams._all = { index: this.storage.index };
692✔
158
        this.storage.on('index-created', this.registerStream.bind(this));
692✔
159
        this.storage.open(onOpened);
692✔
160
    }
692✔
161

4✔
162
    /**
4✔
163
     * Check if the last commit in the store was unfinished, which is the case if not all events of the commit have been written.
4✔
164
     * Torn writes are handled at the storage level, so this method only deals with unfinished commits.
4✔
165
     * @private
4✔
166
     */
4✔
167
    checkUnfinishedCommits() {
4✔
168
        let position = this.storage.length;
148✔
169
        let lastEvent;
148✔
170
        let truncateIndex = false;
148✔
171
        while (position > 0) {
148✔
172
            try {
80✔
173
                lastEvent = this.storage.read(position);
80✔
174
            } catch (e) {
80!
UNCOV
175
                // A preRead hook may throw (e.g. access control). Stop repair check.
×
UNCOV
176
                return;
×
177
            }
×
178
            if (lastEvent !== false) break;
80✔
179
            truncateIndex = true;
16✔
180
            position--;
16✔
181
        }
16✔
182

148✔
183
        if (lastEvent && lastEvent.metadata.commitSize && lastEvent.metadata.commitVersion !== lastEvent.metadata.commitSize - 1) {
148✔
184
            this.emit('unfinished-commit', lastEvent);
8✔
185
            // commitId = global sequence number at which the commit started
8✔
186
            this.storage.truncate(lastEvent.metadata.commitId);
8✔
187
        } else if (truncateIndex) {
148✔
188
            // The index contained items that are not in the storage file; truncate everything
4✔
189
            // after `position`, the last sequence number that was successfully read.
4✔
190
            this.storage.truncate(position);
4✔
191
        }
4✔
192
    }
148✔
193

4✔
194
    /**
4✔
195
     * @private
4✔
196
     * @param {string} name The full stream name, including the `stream-` prefix (and optional `.closed` suffix).
4✔
197
     */
4✔
198
    registerStream(name) {
4✔
199
        if (!name.startsWith('stream-')) {
1,100!
UNCOV
200
            return;
×
UNCOV
201
        }
×
202
        let streamName = name.slice(7);
1,100✔
203
        // Detect the `.closed` suffix — present both in the initial scan and when the directory
1,100✔
204
        // watcher emits 'index-created' after a writer renames the file (e.g. 'stream-foo-bar.closed').
1,100✔
205
        let isClosed = false;
1,100✔
206
        if (streamName.endsWith('.closed')) {
1,100✔
207
            streamName = streamName.slice(0, -7);
8✔
208
            isClosed = true;
8✔
209
        }
8✔
210
        if (streamName in this.streams) {
1,100✔
211
            if (isClosed && !this.streams[streamName].closed) {
44✔
212
                // The stream was renamed to .closed while this instance had it open.
4✔
213
                // The old ReadOnlyIndex was already closed via onRename, so we open the new one.
4✔
214
                const closedIndexName = 'stream-' + streamName + '.closed';
4✔
215
                const closedIndex = this.storage.openReadonlyIndex(closedIndexName);
4✔
216
                // deepcode ignore PrototypePollutionFunctionParams: streams is a Map
4✔
217
                this.streams[streamName] = { index: closedIndex, closed: true };
4✔
218
                this.emit('stream-closed', streamName);
4✔
219
            }
4✔
220
            return;
44✔
221
        }
44✔
222
        const index = isClosed
1,056✔
223
            ? this.storage.openReadonlyIndex(name)
1,100✔
224
            : this.storage.openIndex(name);
1,100✔
225
        // deepcode ignore PrototypePollutionFunctionParams: streams is a Map
1,100✔
226
        this.streams[streamName] = { index, closed: isClosed };
1,100✔
227
        this.emit('stream-available', streamName);
1,100✔
228
    }
1,100✔
229

4✔
230
    /**
4✔
231
     * Close the event store and free up all resources.
4✔
232
     * Stops all registered consumers before closing storage.
4✔
233
     *
4✔
234
     * @api
4✔
235
     */
4✔
236
    close() {
4✔
237
        for (const consumer of this.consumers.values()) {
668✔
238
            consumer.stop();
24✔
239
        }
24✔
240
        this.consumers.clear();
668✔
241
        this.storage.close();
668✔
242
    }
668✔
243

4✔
244
    /**
4✔
245
     * Flush all pending writes, then re-open the store in read-only mode.
4✔
246
     * Any registered consumers are stopped. The `callback` is invoked once the
4✔
247
     * new read-only storage has finished opening.
4✔
248
     *
4✔
249
     * Does not re-emit `'ready'` — use the callback to react to the transition.
4✔
250
     *
4✔
251
     * @api
4✔
252
     * @param {function} [callback] Called when the store is ready in read-only mode.
4✔
253
     */
4✔
254
    makeReadOnly(callback) {
4✔
255
        if (this.storage instanceof ReadOnlyStorage) {
20!
UNCOV
256
            callback?.();
×
UNCOV
257
            return
×
258
        }
×
259
        for (const consumer of this.consumers.values()) {
20!
260
            consumer.stop();
×
UNCOV
261
        }
×
262
        this.consumers.clear();
20✔
263

20✔
264
        this.storage.flush();
20✔
265
        this.storage.close();
20✔
266

20✔
267
        const readOnlyConfig = Object.assign({}, this.storageConfig, { readOnly: true });
20✔
268
        this.storageConfig = readOnlyConfig;
20✔
269

20✔
270
        this.mountStorage(new ReadOnlyStorage(this.storeName, readOnlyConfig), callback);
20✔
271
    }
20✔
272

4✔
273
    /**
4✔
274
     * Override EventEmitter.on() to delegate 'preCommit' and 'preRead' event registrations
4✔
275
     * to the underlying storage, so that `eventstore.on('preCommit', handler)` works naturally.
4✔
276
     * All other events are handled by the default EventEmitter.
4✔
277
     *
4✔
278
     * @param {string} event
4✔
279
     * @param {function} listener
4✔
280
     * @returns {this}
4✔
281
     */
4✔
282
    on(event, listener) {
4✔
283
        if (this.isStorageHookEvent(event)) {
228✔
284
            this.delegateStorageHookEvent('on', event, listener);
76✔
285
            return this;
76✔
286
        }
76✔
287
        return super.on(event, listener);
152✔
288
    }
228✔
289

4✔
290
    /**
4✔
291
     * @inheritDoc
4✔
292
     */
4✔
293
    addListener(event, listener) {
4✔
294
        return this.on(event, listener);
4✔
295
    }
4✔
296

4✔
297
    /**
4✔
298
     * Override EventEmitter.once() to delegate 'preCommit' and 'preRead' to the underlying storage.
4✔
299
     *
4✔
300
     * @param {string} event
4✔
301
     * @param {function} listener
4✔
302
     * @returns {this}
4✔
303
     */
4✔
304
    once(event, listener) {
4✔
305
        if (this.isStorageHookEvent(event)) {
12✔
306
            this.delegateStorageHookEvent('once', event, listener);
8✔
307
            return this;
8✔
308
        }
8✔
309
        return super.once(event, listener);
4✔
310
    }
12✔
311

4✔
312
    /**
4✔
313
     * Override EventEmitter.off() / removeListener() to delegate 'preCommit' and 'preRead'
4✔
314
     * to the underlying storage.
4✔
315
     *
4✔
316
     * @param {string} event
4✔
317
     * @param {function} listener
4✔
318
     * @returns {this}
4✔
319
     */
4✔
320
    off(event, listener) {
4✔
321
        if (this.isStorageHookEvent(event)) {
24✔
322
            this.storage.off(event, listener);
12✔
323
            return this;
12✔
324
        }
12✔
325
        return super.off(event, listener);
12✔
326
    }
24✔
327

4✔
328
    isStorageHookEvent(event) {
4✔
329
        return STORAGE_HOOK_EVENTS.has(event);
264✔
330
    }
264✔
331

4✔
332
    delegateStorageHookEvent(method, event, listener) {
4✔
333
        if (event === 'preCommit') {
84✔
334
            assert(!(this.storage instanceof ReadOnlyStorage), 'The storage was opened in read-only mode. Can not register a preCommit handler on it.');
44✔
335
        }
44✔
336
        this.storage[method](event, listener);
76✔
337
    }
84✔
338

4✔
339
    /**
4✔
340
     * @inheritDoc
4✔
341
     */
4✔
342
    removeListener(event, listener) {
4✔
343
        return this.off(event, listener);
12✔
344
    }
12✔
345

4✔
346
    /**
4✔
347
     * Convenience method to register a handler called before an event is committed to storage.
4✔
348
     * Equivalent to `eventstore.on('preCommit', hook)`.
4✔
349
     * The handler receives `(event, partitionMetadata)` and may throw to abort the write.
4✔
350
     * Multiple handlers can be registered; all run on every write in registration order.
4✔
351
     * The handler is invoked on every write, so its logic should be cheap, fast, and synchronous.
4✔
352
     *
4✔
353
     * @api
4✔
354
     * @param {function(object, object): void} hook A function receiving (event, partitionMetadata).
4✔
355
     * @throws {Error} If the storage was opened in read-only mode.
4✔
356
     */
4✔
357
    preCommit(hook) {
4✔
358
        this.on('preCommit', hook);
20✔
359
    }
20✔
360

4✔
361
    /**
4✔
362
     * Convenience method to register a handler called before an event is read from storage.
4✔
363
     * Equivalent to `eventstore.on('preRead', hook)`.
4✔
364
     * The handler receives `(position, partitionMetadata)` and may throw to abort the read.
4✔
365
     * Multiple handlers can be registered; all run on every read in registration order.
4✔
366
     * The handler is invoked on every read, so its logic should be cheap, fast, and synchronous.
4✔
367
     *
4✔
368
     * @api
4✔
369
     * @param {function(number, object): void} hook A function receiving (position, partitionMetadata).
4✔
370
     */
4✔
371
    preRead(hook) {
4✔
372
        this.on('preRead', hook);
12✔
373
    }
12✔
374

4✔
375
    /**
4✔
376
     * Get the number of events stored.
4✔
377
     *
4✔
378
     * @api
4✔
379
     * @returns {number}
4✔
380
     */
4✔
381
    get length() {
4✔
382
        return this.storage.length;
1,484✔
383
    }
1,484✔
384

4✔
385
    /**
4✔
386
     * Check a {@link CommitCondition} against the current state of the store.
4✔
387
     * Iterates a join stream over all condition type streams starting from
4✔
388
     * `condition.noneMatchAfter` (the global position captured at query time), and throws an
4✔
389
     * {@link OptimisticConcurrencyError} when a new event of a listed type satisfies
4✔
390
     * `condition.matcher(payload, metadata)` (or any such event when no matcher is provided).
4✔
391
     *
4✔
392
     * @private
4✔
393
     * @param {CommitCondition} condition
4✔
394
     * @throws {OptimisticConcurrencyError}
4✔
395
     */
4✔
396
    checkCondition(condition) {
4✔
397
        if (this.storage.length <= condition.noneMatchAfter) return; // no new events since condition was obtained
28✔
398

16✔
399
        const existingTypes = condition.types.filter(t => t in this.streams);
16✔
400
        if (existingTypes.length === 0) return;
28!
401

16✔
402
        // Only events after condition.noneMatchAfter can be conflicts.
16✔
403
        // Pass the original matcher and raw flag so the stream filters at the source.
16✔
404
        const stream = this.fromStreams(
16✔
405
            '_check_' + condition.types.join('_'),
16✔
406
            existingTypes,
16✔
407
            condition.noneMatchAfter + 1,
16✔
408
            -1,
16✔
409
            condition.matcher,
16✔
410
            condition.raw
16✔
411
        );
16✔
412

16✔
413
        assert(stream.next() === false, `Optimistic Concurrency error. A conflicting event was committed since the condition was obtained.`, OptimisticConcurrencyError);
16✔
414
    }
28✔
415

4✔
416
    /**
4✔
417
     * Ensure a dedicated type stream exists for each event's type, creating it if needed.
4✔
418
     * Must be called before the entity stream is created to guarantee correct index routing.
4✔
419
     *
4✔
420
     * @private
4✔
421
     * @param {Array<object>} events The events to process.
4✔
422
     */
4✔
423
    ensureTypeStreams(events) {
4✔
424
        if (!this.typeAccessor) return;
1,236✔
425
        for (const event of events) {
1,236✔
426
            const type = this.resolveValidatedTypeStreamName(event);
148✔
427
            if (type && !(type in this.streams)) {
148✔
428
                const matcher = this.typeMatcherFn
112✔
429
                    ? this.typeMatcherFn(type)
112✔
430
                    : (doc) => this.typeAccessor(doc.payload) === type;
112✔
431
                this.createEventStream(type, matcher, false);
112✔
432
            }
112✔
433
        }
148✔
434
    }
1,236✔
435

4✔
436
    /**
4✔
437
     * @private
4✔
438
     * @param {object} event
4✔
439
     * @returns {string|null}
4✔
440
     */
4✔
441
    resolveValidatedTypeStreamName(event) {
4✔
442
        const type = this.typeAccessor(event);
148✔
443
        if (type === undefined || type === null || type === '') {
148✔
444
            return null;
8✔
445
        }
8✔
446
        assert(typeof type === 'string', 'typeAccessor must return a string.');
140✔
447
        assert(STREAM_NAME_PATTERN.test(type), `typeAccessor must return a valid stream name. Got: "${type}"`);
140✔
448
        return type;
140✔
449
    }
148✔
450

4✔
451
    /**
4✔
452
     * @private
4✔
453
     * @param {string[]} types
4✔
454
     * @returns {string[]}
4✔
455
     */
4✔
456
    getExistingQueryTypes(types) {
4✔
457
        const queryTypes = [];
100✔
458
        for (const type of types) {
100✔
459
            if (type in this.streams) {
112✔
460
                queryTypes.push(type);
60✔
461
                continue;
60✔
462
            }
60✔
463
            if (!this.typeAccessor) {
112✔
464
                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.`);
4✔
465
            }
4✔
466
        }
112✔
467
        return queryTypes;
96✔
468
    }
100✔
469

4✔
470
    /**
4✔
471
     * Commit a list of events for the given stream name, which is expected to be at the given version.
4✔
472
     * Note that the events committed may still appear in other streams too - the given stream name is only
4✔
473
     * relevant for optimistic concurrency checks with the given expected version.
4✔
474
     *
4✔
475
     * @api
4✔
476
     * @param {string} streamName The name of the stream to commit the events to.
4✔
477
     * @param {Array<object>|object} events The events to commit or a single event.
4✔
478
     * @param {number|CommitCondition} [expectedVersion] One of the `ExpectedVersion` constants, a positive
4✔
479
     *   stream version number, or a {@link CommitCondition} obtained from {@link EventStore#query}.
4✔
480
     * @param {object} [metadata] The commit metadata to use as base. Useful for replication and adding storage metadata.
4✔
481
     * @param {function} [callback] A function that will be executed when all events have been committed.
4✔
482
     * @throws {OptimisticConcurrencyError} if the stream is not at the expected version, or if a
4✔
483
     *   {@link CommitCondition} was provided and conflicting events have been committed since it was obtained.
4✔
484
     */
4✔
485
    commit(streamName, events, expectedVersion = ExpectedVersion.Any, metadata = {}, callback = null) {
4✔
486
        assert(!(this.storage instanceof ReadOnlyStorage), 'The storage was opened in read-only mode. Can not commit to it.');
1,272✔
487
        assert(typeof streamName === 'string' && streamName !== '', 'Must specify a stream name for commit.');
1,272✔
488
        assert(typeof events !== 'undefined' && events !== null, 'No events specified for commit.');
1,272✔
489

1,272✔
490
        ({ events, expectedVersion, metadata, callback } = fixCommitArgumentTypes(
1,272✔
491
            events,
1,272✔
492
            expectedVersion,
1,272✔
493
            metadata,
1,272✔
494
            callback,
1,272✔
495
            ExpectedVersion.Any,
1,272✔
496
            CommitCondition
1,272✔
497
        ));
1,272✔
498

1,272✔
499
        // Perform DCB-style concurrency check when a CommitCondition is provided.
1,272✔
500
        if (expectedVersion instanceof CommitCondition) {
1,272✔
501
            this.checkCondition(expectedVersion);
28✔
502
            expectedVersion = ExpectedVersion.Any;
28✔
503
        }
28✔
504

1,236✔
505
        // When typeAccessor is configured, ensure a dedicated type stream exists for each event
1,236✔
506
        // before the entity stream write so the type stream index is never incomplete.
1,236✔
507
        this.ensureTypeStreams(events);
1,236✔
508

1,236✔
509
        if (!(streamName in this.streams)) {
1,272✔
510
            this.createEventStream(streamName, { stream: streamName }, false);
780✔
511
        }
780✔
512
        assert(!this.streams[streamName].closed, `Stream "${streamName}" is closed and cannot be written to.`);
1,228✔
513
        let streamVersion = this.streams[streamName].index.length;
1,228✔
514
        assert(expectedVersion === ExpectedVersion.Any || streamVersion === expectedVersion,
1,272✔
515
            `Optimistic Concurrency error. Expected stream "${streamName}" at version ${expectedVersion} but is at version ${streamVersion}.`,
1,272✔
516
            OptimisticConcurrencyError
1,272✔
517
        );
1,272✔
518

1,272✔
519
        if (events.length > 1) {
1,272✔
520
            delete metadata.commitVersion;
72✔
521
        }
72✔
522

1,208✔
523
        const commitId = this.length;
1,208✔
524
        let commitVersion = 0;
1,208✔
525
        const commitSize = events.length;
1,208✔
526
        const committedAt = Date.now();
1,208✔
527
        const commit = Object.assign({
1,208✔
528
            commitId,
1,208✔
529
            committedAt
1,208✔
530
        }, metadata, {
1,208✔
531
            streamName,
1,208✔
532
            streamVersion,
1,208✔
533
            events: []
1,208✔
534
        });
1,208✔
535
        const commitCallback = () => {
1,208✔
536
            this.emit('commit', commit);
1,204✔
537
            callback(commit);
1,204✔
538
        };
1,208✔
539
        for (let event of events) {
1,272✔
540
            const eventMetadata = Object.assign({ commitId, committedAt, commitVersion, commitSize }, metadata, { streamVersion });
1,308✔
541
            const storedEvent = { stream: streamName, payload: event, metadata: eventMetadata };
1,308✔
542
            commitVersion++;
1,308✔
543
            streamVersion++;
1,308✔
544
            commit.events.push(event);
1,308✔
545
            this.storage.write(storedEvent, commitVersion !== events.length ? undefined : commitCallback);
1,308✔
546
        }
1,308✔
547
    }
1,272✔
548

4✔
549
    /**
4✔
550
     * @api
4✔
551
     * @param {string} streamName The name of the stream to get the version for.
4✔
552
     * @returns {number} The version that the given stream is at currently, or -1 if the stream does not exist.
4✔
553
     */
4✔
554
    getStreamVersion(streamName) {
4✔
555
        if (!(streamName in this.streams)) {
116✔
556
            return -1;
20✔
557
        }
20✔
558
        return this.streams[streamName].index.length;
96✔
559
    }
116✔
560

4✔
561
    /**
4✔
562
     * Query the event store for events matching a set of event types and an optional filter function.
4✔
563
     * Returns a pre-filtered event stream and a {@link CommitCondition} that can be passed to
4✔
564
     * {@link EventStore#commit} to enforce optimistic concurrency.
4✔
565
     *
4✔
566
     * A conflict occurs when at least one event appended between the `query` call and the `commit` call
4✔
567
     * belongs to one of the listed types and (when `matcher` is provided) also satisfies
4✔
568
     * `matcher(payload, metadata)`.  Events written before the `query` call are never treated as conflicts.
4✔
569
     *
4✔
570
     * **Behaviour when a type stream does not exist:**
4✔
571
     * - Without `typeAccessor` configured: throws an error, because the store cannot guarantee that no
4✔
572
     *   events of that type exist (the stream was never created).  Create the stream explicitly first,
4✔
573
     *   or configure `typeAccessor` to have streams created automatically on commit.
4✔
574
     * - With `typeAccessor` configured: treats the missing stream as empty (0-length).  The stream will
4✔
575
     *   be created automatically the first time an event of that type is committed.
4✔
576
     *
4✔
577
     * @api
4✔
578
     * @param {string[]} types A non-empty array of event-type names to query.
4✔
579
     * @param {function|object|null} [matcher] Optional matcher used for stream pre-filtering.
4✔
580
     *   In object mode, function predicates receive `(payload, metadata)`.
4✔
581
     * @param {number} [minRevision=1] The 1-based minimum global revision to include in the returned stream (inclusive).
4✔
582
     * @param {boolean} [raw=false] If true, return NDJSON buffers from the query stream.
4✔
583
     * @returns {{ condition: CommitCondition, stream: EventStream }} An object with:
4✔
584
     *   - `condition` — the {@link CommitCondition} to pass to {@link EventStore#commit}.
4✔
585
     *   - `stream` — a read-only event stream containing all matching events.
4✔
586
     * @throws {Error} if `types` is not a non-empty array.
4✔
587
     * @throws {Error} if `typeAccessor` is not configured and any of the listed type streams do not exist.
4✔
588
     */
4✔
589
    query(types, matcher = null, minRevision = 1, raw = false) {
4✔
590
        assert(Array.isArray(types) && types.length > 0, 'Must specify a non-empty array of event types for query.');
108✔
591
        const queryTypes = this.getExistingQueryTypes(types);
108✔
592
        const condition = new CommitCondition(types, matcher, this.storage.length, raw);
108✔
593
        const stream = this.fromStreams('_query_' + types.join('_'), queryTypes, minRevision, -1, matcher, raw);
108✔
594
        return { stream, condition };
108✔
595
    }
108✔
596

4✔
597
    /**
4✔
598
     * Get an event stream for the given stream name within the revision boundaries.
4✔
599
     *
4✔
600
     * @api
4✔
601
     * @param {string} streamName The name of the stream to get.
4✔
602
     * @param {number} [minRevision=1] The 1-based minimum revision to include in the events (inclusive).
4✔
603
     * @param {number} [maxRevision=-1] The 1-based maximum revision to include in the events (inclusive).
4✔
604
     * @param {function|object|null} [predicate] Optional matcher (see {@link EventStream}).
4✔
605
     * @param {boolean} [raw=false] If true, return NDJSON buffers.
4✔
606
     * @returns {EventStream|boolean} The event stream or false if a stream with the name doesn't exist.
4✔
607
     */
4✔
608
    getEventStream(streamName, minRevision = 1, maxRevision = -1, predicate = null, raw = false) {
4✔
609
        ({ predicate, raw } = normalizePredicateRaw(predicate, raw));
212✔
610
        if (!(streamName in this.streams)) {
212✔
611
            return false;
4✔
612
        }
4✔
613
        return new EventStream(streamName, this, minRevision, maxRevision, predicate, raw);
208✔
614
    }
212✔
615

4✔
616
    /**
4✔
617
     * Get a stream for all events within the revision boundaries.
4✔
618
     * This is the same as `getEventStream('_all', ...)`.
4✔
619
     *
4✔
620
     * @api
4✔
621
     * @param {number} [minRevision=1] The 1-based minimum revision to include in the events (inclusive).
4✔
622
     * @param {number} [maxRevision=-1] The 1-based maximum revision to include in the events (inclusive).
4✔
623
     * @param {function|object|null} [predicate] Optional matcher (see {@link EventStream}).
4✔
624
     * @param {boolean} [raw=false] If true, return NDJSON buffers.
4✔
625
     * @returns {EventStream} The event stream.
4✔
626
     */
4✔
627
    getAllEvents(minRevision = 1, maxRevision = -1, predicate = null, raw = false) {
4✔
628
        ({ predicate, raw } = normalizePredicateRaw(predicate, raw));
12✔
629
        return this.getEventStream('_all', minRevision, maxRevision, predicate, raw);
12✔
630
    }
12✔
631

4✔
632
    /**
4✔
633
     * Create a virtual event stream from existing streams by joining them.
4✔
634
     *
4✔
635
     * @param {string} streamName The (transient) name of the joined stream.
4✔
636
     * @param {Array<string>} streamNames An array of the stream names to join.
4✔
637
     * @param {number} [minRevision=1] The 1-based minimum revision to include in the events (inclusive).
4✔
638
     * @param {number} [maxRevision=-1] The 1-based maximum revision to include in the events (inclusive).
4✔
639
     * @param {function|object|null} [predicate] Optional matcher (see {@link EventStream}).
4✔
640
     * @param {boolean} [raw=false] If true, return NDJSON buffers.
4✔
641
     * @returns {EventStream} The joined event stream.
4✔
642
     * @throws {Error} if any of the streams doesn't exist.
4✔
643
     */
4✔
644
    fromStreams(streamName, streamNames, minRevision = 1, maxRevision = -1, predicate = null, raw = false) {
4✔
645
        ({ predicate, raw } = normalizePredicateRaw(predicate, raw));
172✔
646
        assert(streamNames instanceof Array, 'Must specify an array of stream names.');
172✔
647

172✔
648
        if (streamNames.length === 0) {
172✔
649
            return new EventStream(streamName, this);
44✔
650
        }
44✔
651

120✔
652
        for (let stream of streamNames) {
172✔
653
            assert(stream in this.streams, `Stream "${stream}" does not exist.`);
288✔
654
        }
288✔
655

116✔
656
        if (streamNames.length === 1) {
172✔
657
            const stream = new EventStream(streamNames[0], this, minRevision, maxRevision, predicate, raw);
60✔
658
            stream.name = streamName;
60✔
659
            return stream;
60✔
660
        }
60✔
661

56✔
662
        return new JoinEventStream(streamName, streamNames, this, minRevision, maxRevision, predicate, raw);
56✔
663
    }
172✔
664

4✔
665
    /**
4✔
666
     * Get a stream for a category of streams. This will effectively return a joined stream of all streams that start
4✔
667
     * with the given `categoryName` followed by a dash (flat layout, e.g. `users-123`) or a slash (hierarchical
4✔
668
     * layout, e.g. `users/123`).
4✔
669
     * If you frequently use this for a category consisting of a lot of streams (e.g. `users`), consider creating a
4✔
670
     * dedicated physical stream for the category:
4✔
671
     *
4✔
672
     *    `eventstore.createEventStream('users', e => e.stream.startsWith('users-') || e.stream.startsWith('users/'))`
4✔
673
     *
4✔
674
     * @api
4✔
675
     * @param {string} categoryName The name of the category to get a stream for. A category is a stream name prefix.
4✔
676
     * @param {number} [minRevision=1] The 1-based minimum revision to include in the events (inclusive).
4✔
677
     * @param {number} [maxRevision=-1] The 1-based maximum revision to include in the events (inclusive).
4✔
678
     * @param {function|object|null} [predicate] Optional matcher (see {@link EventStream}).
4✔
679
     * @param {boolean} [raw=false] If true, return NDJSON buffers.
4✔
680
     * @returns {EventStream} The joined event stream for all streams of the given category.
4✔
681
     * @throws {Error} If no stream for this category exists.
4✔
682
     */
4✔
683
    getEventStreamForCategory(categoryName, minRevision = 1, maxRevision = -1, predicate = null, raw = false) {
4✔
684
        ({ predicate, raw } = normalizePredicateRaw(predicate, raw));
40✔
685
        if (categoryName in this.streams) {
40✔
686
            return this.getEventStream(categoryName, minRevision, maxRevision, predicate, raw);
4✔
687
        }
4✔
688
        const categoryStreams = Object.keys(this.streams).filter(streamName =>
36✔
689
            streamName.startsWith(categoryName + '-') ||
224✔
690
            streamName.startsWith(categoryName + '/')
128✔
691
        );
36✔
692

36✔
693
        assert(categoryStreams.length > 0, `No streams for category '${categoryName}' exist.`);
36✔
694

36✔
695
        return this.fromStreams(categoryName, categoryStreams, minRevision, maxRevision, predicate, raw);
36✔
696
    }
40✔
697

4✔
698
    /**
4✔
699
     * Create a new stream with the given matcher.
4✔
700
     *
4✔
701
     * @api
4✔
702
     * @param {string} streamName The name of the stream to create.
4✔
703
     * @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.
4✔
704
     * @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).
4✔
705
     * @returns {EventStream} The EventStream with all existing events matching the matcher.
4✔
706
     * @throws {Error} If a stream with that name already exists.
4✔
707
     * @throws {Error} If the stream could not be created.
4✔
708
     */
4✔
709
    createEventStream(streamName, matcher, reindex = true) {
4✔
710
        assert(!(this.storage instanceof ReadOnlyStorage), 'The storage was opened in read-only mode. Can not create new stream on it.');
980✔
711
        assert(!(streamName in this.streams), 'Can not recreate stream!');
980✔
712

980✔
713
        const streamIndexName = 'stream-' + streamName;
980✔
714
        if (streamName.includes('/')) {
980✔
715
            const subDir = path.join(this.streamsDirectory, this.storeName + '.stream-' + path.dirname(streamName));
88✔
716
            ensureDirectory(subDir);
88✔
717
        }
88✔
718
        const index = this.storage.ensureIndex(streamIndexName, matcher, reindex);
960✔
719
        assert(index !== null, `Error creating stream index ${streamName}.`);
960✔
720

960✔
721
        // deepcode ignore PrototypePollutionFunctionParams: streams is a Map
960✔
722
        this.streams[streamName] = { index, matcher };
960✔
723
        this.emit('stream-created', streamName);
960✔
724
        return new EventStream(streamName, this);
960✔
725
    }
980✔
726

4✔
727
    /**
4✔
728
     * Delete an event stream. Will do nothing if the stream with the name doesn't exist.
4✔
729
     *
4✔
730
     * Note that you can delete a write stream, but that will not delete the events written to it.
4✔
731
     * Also, on next write, that stream will be rebuilt from all existing events, which might take some time.
4✔
732
     *
4✔
733
     * @api
4✔
734
     * @param {string} streamName The name of the stream to delete.
4✔
735
     * @returns void
4✔
736
     */
4✔
737
    deleteEventStream(streamName) {
4✔
738
        assert(!(this.storage instanceof ReadOnlyStorage), 'The storage was opened in read-only mode. Can not delete a stream on it.');
12✔
739

12✔
740
        if (!(streamName in this.streams)) {
12✔
741
            return;
4✔
742
        }
4✔
743
        this.streams[streamName].index.destroy();
4✔
744
        delete this.streams[streamName];
4✔
745
        this.emit('stream-deleted', streamName);
4✔
746
    }
12✔
747

4✔
748
    /**
4✔
749
     * Close a stream so that no new events are indexed into it.
4✔
750
     * The stream will still be readable, but any attempt to write to it will throw an error.
4✔
751
     * A closed stream is persisted by renaming its index file to include a `.closed` marker
4✔
752
     * (e.g. `stream-X.closed.index`), so it will be recognized as closed when the store is reopened.
4✔
753
     *
4✔
754
     * @api
4✔
755
     * @param {string} streamName The name of the stream to close.
4✔
756
     * @returns void
4✔
757
     * @throws {Error} If the storage is read-only.
4✔
758
     * @throws {Error} If the stream does not exist.
4✔
759
     * @throws {Error} If the stream is already closed.
4✔
760
     */
4✔
761
    closeEventStream(streamName) {
4✔
762
        assert(!(this.storage instanceof ReadOnlyStorage), 'The storage was opened in read-only mode. Can not close a stream on it.');
48✔
763
        assert(streamName in this.streams, `Stream "${streamName}" does not exist.`);
48✔
764
        assert(!this.streams[streamName].closed, `Stream "${streamName}" is already closed.`);
48✔
765

48✔
766
        const indexName = 'stream-' + streamName;
48✔
767
        const { index } = this.streams[streamName];
48✔
768

48✔
769
        // Flush and close the index before renaming the file
48✔
770
        index.close();
48✔
771

48✔
772
        // Rename the index file to mark it as closed (e.g. stream-foo.index -> stream-foo.closed.index)
48✔
773
        const closedFileName = index.fileName.replace(/\.index$/, '.closed.index');
48✔
774
        fs.renameSync(index.fileName, closedFileName);
48✔
775

48✔
776
        // Remove from secondary indexes so that new writes are no longer indexed into this stream
48✔
777
        this.storage.removeSecondaryIndex(indexName);
48✔
778

48✔
779
        // Reopen the renamed index for read access, outside the secondary indexes write path
48✔
780
        const closedIndexName = indexName + '.closed';
48✔
781
        const closedIndex = this.storage.openReadonlyIndex(closedIndexName);
48✔
782

48✔
783
        // deepcode ignore PrototypePollutionFunctionParams: streams is a Map
48✔
784
        this.streams[streamName] = { index: closedIndex, closed: true };
48✔
785
        this.emit('stream-closed', streamName);
48✔
786
    }
48✔
787

4✔
788
    /**
4✔
789
     * Get a durable consumer for the given stream, or look up an existing consumer by identifier.
4✔
790
     *
4✔
791
     * When called with a single argument, returns the running consumer registered under that
4✔
792
     * identifier, or `null` if none is found — useful for read endpoints that need the live
4✔
793
     * in-memory instance without creating a new one.
4✔
794
     *
4✔
795
     * When called with two or more arguments, creates (or re-uses) a Consumer for the given
4✔
796
     * stream and identifier, registers it in `this.consumers`, and returns it.
4✔
797
     *
4✔
798
     * @param {string} streamNameOrIdentifier The stream name, or the consumer identifier when used as a registry lookup.
4✔
799
     * @param {string} [identifier] The unique identifying name of this consumer. Omit for registry-only lookup.
4✔
800
     * @param {object} [initialState] The initial state of the consumer.
4✔
801
     * @param {number} [since] The stream revision to start consuming from.
4✔
802
     * @returns {Consumer|null} A durable consumer, or `null` when looking up by identifier and none is registered.
4✔
803
     */
4✔
804
    getConsumer(streamNameOrIdentifier, identifier, initialState = {}, since = 0) {
4✔
805
        if (identifier === undefined) {
28!
UNCOV
806
            return this.consumers.get(streamNameOrIdentifier) ?? null;
×
UNCOV
807
        }
×
808
        const streamName = streamNameOrIdentifier;
28✔
809
        if (this.consumers.has(identifier)) {
28✔
810
            const existingConsumer = this.consumers.get(identifier);
4✔
811
            if (existingConsumer.streamName === streamName) {
4!
UNCOV
812
                return existingConsumer;
×
UNCOV
813
            }
×
814
            // Rebind identifier to the requested stream when a consumer with the same
4✔
815
            // identifier already exists for another stream.
4✔
816
            existingConsumer.stop();
4✔
817
        }
4✔
818
        const consumer = new Consumer(this.storage, streamName === '_all' ? '_all' : 'stream-' + streamName, identifier, initialState, since);
28✔
819
        consumer.streamName = streamName;
28✔
820
        this.consumers.set(identifier, consumer);
28✔
821
        return consumer;
28✔
822
    }
28✔
823

4✔
824
    /**
4✔
825
     * Scan the existing consumers on this EventStore and asynchronously invoke a callback with the parsed list.
4✔
826
     *
4✔
827
     * Each consumer entry provides `{ name, stream, identifier }` parsed from the on-disk filename.
4✔
828
     * Pass `autoStart = true` to eagerly open every discovered consumer and register it in
4✔
829
     * `this.consumers` so that it is immediately available for registry lookups.
4✔
830
     *
4✔
831
     * @param {function(error: Error|null, consumers: Array<{name: string, stream: string, identifier: string}>)} callback
4✔
832
     * @param {boolean} [autoStart=false] When true, calls `getConsumer(stream, identifier)` for each discovered consumer.
4✔
833
     */
4✔
834
    scanConsumers(callback, autoStart = false) {
4✔
835
        const consumersPath = path.join(this.storage.indexDirectory, 'consumers');
8✔
836
        if (!fs.existsSync(consumersPath)) {
8✔
837
            callback(null, []);
4✔
838
            return;
4✔
839
        }
4✔
840
        const regex = new RegExp(`^${this.storage.storageFile}\\.([^.]*\\..*)$`);
4✔
841
        const consumerNames = [];
4✔
842
        scanForFiles(consumersPath, regex, consumerNames.push.bind(consumerNames), /* istanbul ignore next */ (err) => {
4✔
843
            if (err) {
4!
UNCOV
844
                return callback(err, []);
×
UNCOV
845
            }
×
846
            const consumers = consumerNames.map(name => {
4✔
847
                const splitIndex = name.lastIndexOf('.');
8✔
848
                const indexName = name.slice(0, splitIndex);
8✔
849
                const identifier = name.slice(splitIndex + 1);
8✔
850
                const stream = parseStreamFromIndexName(indexName);
8✔
851
                return { name, stream, identifier };
8✔
852
            });
4✔
853
            if (autoStart) {
4!
UNCOV
854
                for (const { stream, identifier } of consumers) {
×
UNCOV
855
                    this.getConsumer(stream, identifier);
×
856
                }
×
857
            }
×
858
            callback(null, consumers);
4✔
859
        });
4✔
860
    }
8✔
861
}
4✔
862

4✔
863

4✔
864
EventStore.Storage = Storage;
4✔
865
EventStore.Index = Index;
4✔
866

4✔
867
export default EventStore;
4✔
868
export { ExpectedVersion, OptimisticConcurrencyError, CommitCondition, LOCK_THROW, LOCK_RECLAIM };
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc