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

albe / node-event-storage / 29703735145

19 Jul 2026 09:05PM UTC coverage: 98.215% (-0.08%) from 98.299%
29703735145

Pull #339

github

web-flow
Merge 40733da81 into 5e2321a21
Pull Request #339: Add startup state snapshots for fast storage initialization with lazy filesystem backfill

1636 of 1703 branches covered (96.07%)

Branch coverage included in aggregate %.

215 of 223 new or added lines in 4 files covered. (96.41%)

8 existing lines in 3 files now uncovered.

7552 of 7652 relevant lines covered (98.69%)

993.99 hits per line

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

96.98
/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, IndexNotFoundError } from './Storage.js';
4✔
7
import Index from './Index.js';
4✔
8
import Consumer from './Consumer.js';
4✔
9
import { assert } from './utils/util.js';
4✔
10
import { ensureDirectory, resolvePath, scanForFiles } from './utils/fsUtil.js';
4✔
11
import { fixCommitArgumentTypes, parseStreamFromIndexName, normalizePredicateRaw } from './utils/apiHelpers.js';
4✔
12
import { normalizeSelector, buildStreamSource } from "./utils/indexUtil.js";
4✔
13
import { isDcbQuery, compileDcbQuery } from "./utils/dcbUtil.js";
4✔
14

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

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

4✔
27

4✔
28
class OptimisticConcurrencyError extends Error {}
4✔
29

4✔
30
/**
4✔
31
 * @typedef {string | SelectorNode[]} SelectorNode
4✔
32
 */
4✔
33

4✔
34
/**
4✔
35
 * @typedef {object} QueryItem
4✔
36
 * @property {string[]} [types] Event type names to match (OR within the group).
4✔
37
 * @property {string[]} [tags] Tag values to match (each tag is ANDed with the others and with types).
4✔
38
 */
4✔
39

4✔
40
/**
4✔
41
 * @typedef {object} DcbQuery
4✔
42
 * @property {QueryItem[]} items Non-empty array of query items combined with OR semantics at the top level.
4✔
43
 */
4✔
44

4✔
45
/**
4✔
46
 * An accept condition that captures the global event-log position at the time a {@link EventStore#query}
4✔
47
 * call was made.  Pass it as the `expectedVersion` argument to {@link EventStore#commit} to enforce
4✔
48
 * DCB-style (Dynamic Consistency Boundary) optimistic concurrency: the commit is rejected only when
4✔
49
 * one or more events that match the original query (selector + optional matcher) have been appended to
4✔
50
 * the store between the `query` call and the `commit` call.
4✔
51
 *
4✔
52
 * @property {SelectorNode[]|string} selector The normalized stream selector used in the query.
4✔
53
 * @property {string[]} types   Backwards-compatible alias for the leaf stream names of the selector.
4✔
54
 * @property {function(object, object): boolean|null} matcher An optional function `(payload, metadata) => boolean`
4✔
55
 *   used to narrow the conflict check.  When `null`, any new event of a listed type causes a conflict.
4✔
56
 * @property {number}   noneMatchAfter The global store length (total event count) at the time the query was made.
4✔
57
 */
4✔
58
class CommitCondition {
4✔
59
    /**
4✔
60
     * @param {SelectorNode[]|string} selector The (normalized) stream selector.
4✔
61
     * @param {(function(object, object): boolean)|object|null} [matcher]
4✔
62
     * @param {number} noneMatchAfter
4✔
63
     * @param {boolean} [raw=false]
4✔
64
     */
4✔
65
    constructor(selector, matcher = null, noneMatchAfter, raw = false) {
4✔
66
        this.selector = selector;
116✔
67
        this.matcher = matcher;
116✔
68
        this.raw = raw;
116✔
69
        this.noneMatchAfter = noneMatchAfter;
116✔
70
    }
116✔
71
}
4✔
72

4✔
73
/**
4✔
74
 * An event store optimized for working with many streams.
4✔
75
 * An event stream is implemented as an iterator over an index on the storage, therefore indexes need to be lightweight
4✔
76
 * and highly performant in read-only mode.
4✔
77
 */
4✔
78
class EventStore extends events.EventEmitter {
4✔
79

4✔
80
    /**
4✔
81
     * @param {string} [storeName] The name of the store which will be used as storage prefix. Default 'eventstore'.
4✔
82
     * @param {object} [config] An object with config options.
4✔
83
     * @param {string} [config.storageDirectory] The directory where the data should be stored. Default './data'.
4✔
84
     * @param {string} [config.streamsDirectory] The directory where the streams should be stored. Default '{storageDirectory}/streams'.
4✔
85
     * @param {object} [config.storageConfig] Additional config options given to the storage backend. See `Storage`.
4✔
86
     * @param {boolean} [config.readOnly] If the storage should be mounted in read-only mode.
4✔
87
     * @param {Array<{path: string, nameBuilder: function(string): string}>} [config.streamSources] Generic
4✔
88
     *   stream-index definitions. Each entry specifies a dot-notation `path` into the event payload and a
4✔
89
     *   `nameBuilder(value) => streamName` function. On every {@link EventStore#commit} each entry is
4✔
90
     *   evaluated: scalar values are passed directly to `nameBuilder`; array values are iterated so each
4✔
91
     *   element produces its own stream. Non-string and empty values are skipped silently. The resulting
4✔
92
     *   stream names must satisfy the stream-name pattern. Each entry's `path` is automatically registered
4✔
93
     *   in `matcherProperties` for O(1) IndexMatcher routing.
4✔
94
     * @param {string} [config.typeAccessor] Shorthand for a `streamSources` entry with `nameBuilder = v => v`.
4✔
95
     *   Dot-notation path to the event type field (e.g. `'type'`). Enables type-based
4✔
96
     *   {@link EventStore#query} selectors and {@link DcbQuery} `types` items.
4✔
97
     * @param {string} [config.tagsAccessor] Shorthand for a `streamSources` entry with
4✔
98
     *   `nameBuilder = v => \`tags/${v}\``. Dot-notation path to an array field of tag values (e.g.
4✔
99
     *   `'tags'`). Enables tag-scoped {@link DcbQuery} `tags` items. Values are recommended to use `/`
4✔
100
     *   as a hierarchy separator (e.g. `'course/jdsj4'` → stream `tags/course/jdsj4`).
4✔
101
     * @param {object|function(string): object} [config.streamMetadata] A metadata object or a function `(streamName) => object`
4✔
102
     *   that is called whenever a new stream partition is created. The returned object is stored once in the partition
4✔
103
     *   file header and surfaced to `preCommit` / `preRead` hooks. Takes precedence only when
4✔
104
     *   `config.storageConfig.metadata` is not also set.
4✔
105
     */
4✔
106
    constructor(storeName = 'eventstore', config = {}) {
4✔
107
        super();
820✔
108
        if (typeof storeName !== 'string') {
820✔
109
            config = storeName;
800✔
110
            storeName = 'eventstore';
800✔
111
        }
800✔
112

820✔
113
        this.streamSources = [];
820✔
114
        this.typeSource = null;
820✔
115
        this.tagsSource = null;
820✔
116

820✔
117
        if (config.typeAccessor) {
820✔
118
            assert(typeof config.typeAccessor === 'string', 'typeAccessor must be a dot-notation string path (e.g. \'type\').');
156✔
119
            this.typeSource = buildStreamSource(config.typeAccessor, (v) => v);
156✔
120
            this.streamSources.push(this.typeSource);
156✔
121
        }
156✔
122

816✔
123
        if (config.tagsAccessor) {
820✔
124
            assert(typeof config.tagsAccessor === 'string', 'tagsAccessor must be a dot-notation string path (e.g. \'tags\').');
68✔
125
            this.tagsSource = buildStreamSource(config.tagsAccessor, (v) => `tags/${v}`);
68✔
126
            this.streamSources.push(this.tagsSource);
68✔
127
        }
68✔
128

812✔
129
        for (const { path, nameBuilder } of config.streamSources ?? []) {
820✔
130
            assert(typeof path === 'string' && path, 'Each streamSources entry must have a non-empty string path.');
40✔
131
            assert(typeof nameBuilder === 'function', 'Each streamSources entry must have a nameBuilder function.');
40✔
132
            this.streamSources.push(buildStreamSource(path, nameBuilder));
40✔
133
        }
40✔
134

804✔
135
        this.storageDirectory = resolvePath(config.storageDirectory || /* istanbul ignore next */ './data');
820!
136
        let defaults = {
820✔
137
            dataDirectory: this.storageDirectory,
820✔
138
            indexDirectory: config.streamsDirectory || path.join(this.storageDirectory, 'streams'),
820✔
139
            partitioner: (event) => event.stream,
820✔
140
            readOnly: config.readOnly || false
820✔
141
        };
820✔
142
        const storageConfig = Object.assign(defaults, config.storageConfig);
820✔
143

820✔
144
        // Register each source's payload path in matcherProperties so the IndexMatcher
820✔
145
        // discriminant table can route stream lookups in O(1) on every write.
820✔
146
        for (const source of this.streamSources) {
820✔
147
            const fullPath = `payload.${source.path}`;
248✔
148
            const currentProps = storageConfig.matcherProperties || DEFAULT_MATCHER_PROPERTIES;
248✔
149
            if (!currentProps.includes(fullPath)) {
248✔
150
                storageConfig.matcherProperties = [...currentProps, fullPath];
100✔
151
            }
100✔
152
        }
248✔
153

804✔
154
        // Translate the high-level streamMetadata option into the storage-level metadata function,
804✔
155
        // but only when the caller has not already provided a lower-level storageConfig.metadata.
804✔
156
        if (config.streamMetadata !== undefined && storageConfig.metadata === undefined) {
820✔
157
            if (typeof config.streamMetadata === 'function') {
76✔
158
                storageConfig.metadata = config.streamMetadata;
4✔
159
            } else {
76✔
160
                storageConfig.metadata = (streamName) => config.streamMetadata[streamName] || {};
72✔
161
            }
72✔
162
        }
76✔
163

804✔
164
        this.initialize(storeName, storageConfig);
804✔
165
    }
820✔
166

4✔
167
    /**
4✔
168
     * @private
4✔
169
     * @param {string} storeName
4✔
170
     * @param {object} storageConfig
4✔
171
     */
4✔
172
    initialize(storeName, storageConfig) {
4✔
173
        this.storageConfig = storageConfig;
804✔
174
        this.streamsDirectory = resolvePath(storageConfig.indexDirectory);
804✔
175
        this.storeName = storeName;
804✔
176
        this.consumers = new Map();
804✔
177
        // Read-only stores never enter commit(), so no stream hydration pass is required.
804✔
178
        // Writable stores defer hydration until the first write path.
804✔
179
        this.knownStreamsHydrated = storageConfig.readOnly === true;
804✔
180

804✔
181
        const storage = storageConfig.readOnly === true
804✔
182
            ? new ReadOnlyStorage(storeName, storageConfig)
804✔
183
            : new Storage(storeName, storageConfig);
804✔
184

804✔
185
        this.mountStorage(storage, () => {
804✔
186
            if (storageConfig.readOnly !== true) {
172✔
187
                this.checkUnfinishedCommits();
136✔
188
            }
136✔
189
            this.emit('ready');
172✔
190
        });
804✔
191
    }
804✔
192

4✔
193
    /**
4✔
194
     * Wire up a storage instance: reset the streams map, attach the index-created listener,
4✔
195
     * register a one-shot opened handler, then open the storage.
4✔
196
     * @private
4✔
197
     * @param {ReadableStorage} storage
4✔
198
     * @param {function} [onOpened] Called once when the storage is opened.
4✔
199
     */
4✔
200
    mountStorage(storage, onOpened) {
4✔
201
        this.storage = storage;
820✔
202
        this.streams = Object.create(null);
820✔
203
        this.streams._all = { index: this.storage.index };
820✔
204
        this.storage.on('index-created', this.registerStream.bind(this));
820✔
205
        this.storage.open(onOpened);
820✔
206
    }
820✔
207

4✔
208
    /**
4✔
209
     * 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✔
210
     * Torn writes are handled at the storage level, so this method only deals with unfinished commits.
4✔
211
     * @private
4✔
212
     */
4✔
213
    checkUnfinishedCommits() {
4✔
214
        let position = this.storage.length;
136✔
215
        let lastEvent;
136✔
216
        let truncateIndex = false;
136✔
217
        while (position > 0) {
136✔
218
            try {
64✔
219
                lastEvent = this.storage.read(position);
64✔
220
            } catch (e) {
64!
221
                // A preRead hook may throw (e.g. access control). Stop repair check.
×
222
                return;
×
223
            }
×
224
            if (lastEvent !== false) break;
64✔
225
            truncateIndex = true;
16✔
226
            position--;
16✔
227
        }
16✔
228

136✔
229
        if (lastEvent && lastEvent.metadata.commitSize && lastEvent.metadata.commitVersion !== lastEvent.metadata.commitSize - 1) {
136✔
230
            this.emit('unfinished-commit', lastEvent);
8✔
231
            // commitId = global sequence number at which the commit started
8✔
232
            this.storage.truncate(lastEvent.metadata.commitId);
8✔
233
        } else if (truncateIndex) {
136✔
234
            // The index contained items that are not in the storage file; truncate everything
4✔
235
            // after `position`, the last sequence number that was successfully read.
4✔
236
            this.storage.truncate(position);
4✔
237
        }
4✔
238
    }
136✔
239

4✔
240
    /**
4✔
241
     * @private
4✔
242
     * @param {string} name The full stream name, including the `stream-` prefix (and optional `.closed` suffix).
4✔
243
     */
4✔
244
    registerStream(name) {
4✔
245
        if (!name.startsWith('stream-')) {
1,392!
246
            return;
×
247
        }
×
248
        let streamName = name.slice(7);
1,392✔
249
        // Detect the `.closed` suffix — present both in the initial scan and when the directory
1,392✔
250
        // watcher emits 'index-created' after a writer renames the file (e.g. 'stream-foo-bar.closed').
1,392✔
251
        let isClosed = false;
1,392✔
252
        if (streamName.endsWith('.closed')) {
1,392✔
253
            streamName = streamName.slice(0, -7);
12✔
254
            isClosed = true;
12✔
255
        }
12✔
256
        if (streamName in this.streams) {
1,392✔
257
            if (isClosed && !this.streams[streamName].closed) {
48✔
258
                // The stream was renamed to .closed while this instance had it open.
4✔
259
                // The old ReadOnlyIndex was already closed via onRename, so we open the new one.
4✔
260
                const closedIndexName = 'stream-' + streamName + '.closed';
4✔
261
                const closedIndex = this.storage.openReadonlyIndex(closedIndexName);
4✔
262
                // deepcode ignore PrototypePollutionFunctionParams: streams is a Map
4✔
263
                this.streams[streamName] = { index: closedIndex, closed: true };
4✔
264
                this.emit('stream-closed', streamName);
4✔
265
            }
4✔
266
            return;
48✔
267
        }
48✔
268
        let index;
1,344✔
269
        try {
1,344✔
270
            index = isClosed
1,344✔
271
                ? this.storage.openReadonlyIndex(name)
1,392✔
272
                : this.storage.openIndex(name);
1,392✔
273
        } catch (error) {
1,392✔
274
            if (error instanceof IndexNotFoundError) {
12✔
275
                return;
12✔
276
            }
12✔
NEW
277
            throw error;
×
NEW
278
        }
×
279
        // deepcode ignore PrototypePollutionFunctionParams: streams is a Map
1,332✔
280
        this.streams[streamName] = { index, closed: isClosed };
1,332✔
281
        this.emit('stream-available', streamName);
1,332✔
282
    }
1,392✔
283

4✔
284
    /**
4✔
285
     * Close the event store and free up all resources.
4✔
286
     * Stops all registered consumers before closing storage.
4✔
287
     *
4✔
288
     * @api
4✔
289
     */
4✔
290
    close() {
4✔
291
        for (const consumer of this.consumers.values()) {
840✔
292
            consumer.stop();
24✔
293
        }
24✔
294
        this.consumers.clear();
840✔
295
        this.storage.close();
840✔
296
    }
840✔
297

4✔
298
    /**
4✔
299
     * Flush all pending writes, then re-open the store in read-only mode.
4✔
300
     * Any registered consumers are stopped. The `callback` is invoked once the
4✔
301
     * new read-only storage has finished opening.
4✔
302
     *
4✔
303
     * Does not re-emit `'ready'` — use the callback to react to the transition.
4✔
304
     *
4✔
305
     * @api
4✔
306
     * @param {function} [callback] Called when the store is ready in read-only mode.
4✔
307
     */
4✔
308
    makeReadOnly(callback) {
4✔
309
        if (this.storage instanceof ReadOnlyStorage) {
20!
310
            callback?.();
×
311
            return
×
312
        }
×
313
        for (const consumer of this.consumers.values()) {
20!
314
            consumer.stop();
×
315
        }
×
316
        this.consumers.clear();
20✔
317

20✔
318
        this.storage.flush();
20✔
319
        this.storage.close();
20✔
320

20✔
321
        const readOnlyConfig = Object.assign({}, this.storageConfig, { readOnly: true });
20✔
322
        this.storageConfig = readOnlyConfig;
20✔
323

20✔
324
        this.mountStorage(new ReadOnlyStorage(this.storeName, readOnlyConfig), callback);
20✔
325
    }
20✔
326

4✔
327
    /**
4✔
328
     * Override EventEmitter.on() to delegate 'preCommit' and 'preRead' event registrations
4✔
329
     * to the underlying storage, so that `eventstore.on('preCommit', handler)` works naturally.
4✔
330
     * All other events are handled by the default EventEmitter.
4✔
331
     *
4✔
332
     * @param {string} event
4✔
333
     * @param {function} listener
4✔
334
     * @returns {this}
4✔
335
     */
4✔
336
    on(event, listener) {
4✔
337
        if (this.isStorageHookEvent(event)) {
248✔
338
            this.delegateStorageHookEvent('on', event, listener);
76✔
339
            return this;
76✔
340
        }
76✔
341
        return super.on(event, listener);
172✔
342
    }
248✔
343

4✔
344
    /**
4✔
345
     * @inheritDoc
4✔
346
     */
4✔
347
    addListener(event, listener) {
4✔
348
        return this.on(event, listener);
4✔
349
    }
4✔
350

4✔
351
    /**
4✔
352
     * Override EventEmitter.once() to delegate 'preCommit' and 'preRead' to the underlying storage.
4✔
353
     *
4✔
354
     * @param {string} event
4✔
355
     * @param {function} listener
4✔
356
     * @returns {this}
4✔
357
     */
4✔
358
    once(event, listener) {
4✔
359
        if (this.isStorageHookEvent(event)) {
16✔
360
            this.delegateStorageHookEvent('once', event, listener);
8✔
361
            return this;
8✔
362
        }
8✔
363
        return super.once(event, listener);
8✔
364
    }
16✔
365

4✔
366
    /**
4✔
367
     * Override EventEmitter.off() / removeListener() to delegate 'preCommit' and 'preRead'
4✔
368
     * to the underlying storage.
4✔
369
     *
4✔
370
     * @param {string} event
4✔
371
     * @param {function} listener
4✔
372
     * @returns {this}
4✔
373
     */
4✔
374
    off(event, listener) {
4✔
375
        if (this.isStorageHookEvent(event)) {
28✔
376
            this.storage.off(event, listener);
12✔
377
            return this;
12✔
378
        }
12✔
379
        return super.off(event, listener);
16✔
380
    }
28✔
381

4✔
382
    isStorageHookEvent(event) {
4✔
383
        return STORAGE_HOOK_EVENTS.has(event);
292✔
384
    }
292✔
385

4✔
386
    delegateStorageHookEvent(method, event, listener) {
4✔
387
        if (event === 'preCommit') {
84✔
388
            assert(!(this.storage instanceof ReadOnlyStorage), 'The storage was opened in read-only mode. Can not register a preCommit handler on it.');
44✔
389
        }
44✔
390
        this.storage[method](event, listener);
76✔
391
    }
84✔
392

4✔
393
    /**
4✔
394
     * @inheritDoc
4✔
395
     */
4✔
396
    removeListener(event, listener) {
4✔
397
        return this.off(event, listener);
16✔
398
    }
16✔
399

4✔
400
    /**
4✔
401
     * Convenience method to register a handler called before an event is committed to storage.
4✔
402
     * Equivalent to `eventstore.on('preCommit', hook)`.
4✔
403
     * The handler receives `(event, partitionMetadata)` and may throw to abort the write.
4✔
404
     * Multiple handlers can be registered; all run on every write in registration order.
4✔
405
     * The handler is invoked on every write, so its logic should be cheap, fast, and synchronous.
4✔
406
     *
4✔
407
     * @api
4✔
408
     * @param {function(object, object): void} hook A function receiving (event, partitionMetadata).
4✔
409
     * @throws {Error} If the storage was opened in read-only mode.
4✔
410
     */
4✔
411
    preCommit(hook) {
4✔
412
        this.on('preCommit', hook);
20✔
413
    }
20✔
414

4✔
415
    /**
4✔
416
     * Convenience method to register a handler called before an event is read from storage.
4✔
417
     * Equivalent to `eventstore.on('preRead', hook)`.
4✔
418
     * The handler receives `(position, partitionMetadata)` and may throw to abort the read.
4✔
419
     * Multiple handlers can be registered; all run on every read in registration order.
4✔
420
     * The handler is invoked on every read, so its logic should be cheap, fast, and synchronous.
4✔
421
     *
4✔
422
     * @api
4✔
423
     * @param {function(number, object): void} hook A function receiving (position, partitionMetadata).
4✔
424
     */
4✔
425
    preRead(hook) {
4✔
426
        this.on('preRead', hook);
12✔
427
    }
12✔
428

4✔
429
    /**
4✔
430
     * Get the number of events stored.
4✔
431
     *
4✔
432
     * @api
4✔
433
     * @returns {number}
4✔
434
     */
4✔
435
    get length() {
4✔
436
        return this.storage.length;
1,960✔
437
    }
1,960✔
438

4✔
439
    /**
4✔
440
     * Check a {@link CommitCondition} against the current state of the store.
4✔
441
     * Iterates a join stream over all condition type streams starting from
4✔
442
     * `condition.noneMatchAfter` (the global position captured at query time), and throws an
4✔
443
     * {@link OptimisticConcurrencyError} when a new event of a listed type satisfies
4✔
444
     * `condition.matcher(payload, metadata)` (or any such event when no matcher is provided).
4✔
445
     *
4✔
446
     * @private
4✔
447
     * @param {CommitCondition} condition
4✔
448
     * @throws {OptimisticConcurrencyError}
4✔
449
     */
4✔
450
    checkCondition(condition) {
4✔
451
        if (this.storage.length <= condition.noneMatchAfter) return; // no new events since condition was obtained
32✔
452

20✔
453
        const selector = condition.selector || condition.types;
32!
454

32✔
455
        // Only events after condition.noneMatchAfter can be conflicts.
32✔
456
        // Pass the original matcher and raw flag so the stream filters at the source.
32✔
457
        const stream = this.fromStreams(
32✔
458
            '_check_' + Date.now(),
32✔
459
            selector,
32✔
460
            condition.noneMatchAfter + 1,
32✔
461
            -1,
32✔
462
            condition.matcher,
32✔
463
            condition.raw
32✔
464
        );
32✔
465

32✔
466
        assert(stream.next() === false, `Optimistic Concurrency error. A conflicting event was committed since the condition was obtained.`, OptimisticConcurrencyError);
32✔
467
    }
32✔
468

4✔
469
    /**
4✔
470
     * Ensure a dedicated stream exists for each indexed property value across all stream sources.
4✔
471
     * Must be called before the entity stream write so those indexes are never incomplete.
4✔
472
     *
4✔
473
     * @private
4✔
474
     * @param {Array<object>} events
4✔
475
     */
4✔
476
    ensureStreams(events) {
4✔
477
        if (this.streamSources.length === 0) return;
1,396✔
478
        for (const { accessor, nameBuilder, matcherFn, path } of this.streamSources) {
1,396✔
479
            for (const event of events) {
244✔
480
                const raw = accessor(event);
632✔
481
                const isArrayValue = Array.isArray(raw);
632✔
482
                const values = isArrayValue ? raw : (raw != null && raw !== '' ? [raw] : []);
632✔
483
                const buildMatcher = matcherFn(isArrayValue ? '$has' : undefined);
632✔
484
                for (const value of values) {
632✔
485
                    if (typeof value !== 'string' || !value) continue;
620✔
486
                    const streamName = nameBuilder(value);
612✔
487
                    assert(STREAM_NAME_PATTERN.test(streamName), `Invalid stream name "${streamName}" derived from path "${path}".`);
612✔
488
                    if (!(streamName in this.streams)) {
620✔
489
                        this.createEventStream(streamName, buildMatcher(value), false);
248✔
490
                    }
248✔
491
                }
620✔
492
            }
624✔
493
        }
236✔
494
    }
1,396✔
495

4✔
496
    /**
4✔
497
     * Delay hydrating known stream indexes until a write path is entered to keep startup fast,
4✔
498
     * while still guaranteeing existing stream versions/matchers are loaded before commit logic runs.
4✔
499
     * @private
4✔
500
     */
4✔
501
    ensureKnownStreamsHydratedForWrite() {
4✔
502
        if (this.knownStreamsHydrated) {
1,412✔
503
            return;
840✔
504
        }
840✔
505
        this.knownStreamsHydrated = true;
572✔
506
        for (const indexName of this.storage.knownIndexes) {
1,412✔
507
            this.registerStream(indexName);
44✔
508
        }
44✔
509
    }
1,412✔
510

4✔
511
    /**
4✔
512
     * Commit a list of events for the given stream name, which is expected to be at the given version.
4✔
513
     * Note that the events committed may still appear in other streams too - the given stream name is only
4✔
514
     * relevant for optimistic concurrency checks with the given expected version.
4✔
515
     *
4✔
516
     * @api
4✔
517
     * @param {string} streamName The name of the stream to commit the events to.
4✔
518
     * @param {Array<object>|object} events The events to commit or a single event.
4✔
519
     * @param {number|CommitCondition} [expectedVersion] One of the `ExpectedVersion` constants, a positive
4✔
520
     *   stream version number, or a {@link CommitCondition} obtained from {@link EventStore#query}.
4✔
521
     * @param {object} [metadata] The commit metadata to use as base. Useful for replication and adding storage metadata.
4✔
522
     * @param {function} [callback] A function that will be executed when all events have been committed.
4✔
523
     * @throws {OptimisticConcurrencyError} if the stream is not at the expected version, or if a
4✔
524
     *   {@link CommitCondition} was provided and conflicting events have been committed since it was obtained.
4✔
525
     */
4✔
526
    commit(streamName, events, expectedVersion = ExpectedVersion.Any, metadata = {}, callback = null) {
4✔
527
        assert(!(this.storage instanceof ReadOnlyStorage), 'The storage was opened in read-only mode. Can not commit to it.');
1,436✔
528
        assert(typeof streamName === 'string' && streamName !== '', 'Must specify a stream name for commit.');
1,436✔
529
        assert(typeof events !== 'undefined' && events !== null, 'No events specified for commit.');
1,436✔
530
        this.ensureKnownStreamsHydratedForWrite();
1,436✔
531

1,436✔
532
        ({ events, expectedVersion, metadata, callback } = fixCommitArgumentTypes(
1,436✔
533
            events,
1,436✔
534
            expectedVersion,
1,436✔
535
            metadata,
1,436✔
536
            callback,
1,436✔
537
            ExpectedVersion.Any,
1,436✔
538
            CommitCondition
1,436✔
539
        ));
1,436✔
540

1,436✔
541
        // Perform DCB-style concurrency check when a CommitCondition is provided.
1,436✔
542
        if (expectedVersion instanceof CommitCondition) {
1,436✔
543
            this.checkCondition(expectedVersion);
32✔
544
            expectedVersion = ExpectedVersion.Any;
32✔
545
        }
32✔
546

1,396✔
547
        // Ensure all indexed streams exist before the entity stream write so those indexes are never incomplete.
1,396✔
548
        this.ensureStreams(events);
1,396✔
549

1,396✔
550
        if (!(streamName in this.streams)) {
1,436✔
551
            this.createEventStream(streamName, { stream: streamName }, false);
908✔
552
        }
908✔
553
        assert(!this.streams[streamName].closed, `Stream "${streamName}" is closed and cannot be written to.`);
1,388✔
554
        let streamVersion = this.streams[streamName].index.length;
1,388✔
555
        assert(expectedVersion === ExpectedVersion.Any || streamVersion === expectedVersion,
1,436✔
556
            `Optimistic Concurrency error. Expected stream "${streamName}" at version ${expectedVersion} but is at version ${streamVersion}.`,
1,436✔
557
            OptimisticConcurrencyError
1,436✔
558
        );
1,436✔
559

1,436✔
560
        if (events.length > 1) {
1,436✔
561
            delete metadata.commitVersion;
112✔
562
        }
112✔
563

1,368✔
564
        const commitId = this.length;
1,368✔
565
        let commitVersion = 0;
1,368✔
566
        const commitSize = events.length;
1,368✔
567
        const committedAt = Date.now();
1,368✔
568
        const commit = Object.assign({
1,368✔
569
            commitId,
1,368✔
570
            committedAt
1,368✔
571
        }, metadata, {
1,368✔
572
            streamName,
1,368✔
573
            streamVersion,
1,368✔
574
            events: []
1,368✔
575
        });
1,368✔
576
        const commitCallback = () => {
1,368✔
577
            this.emit('commit', commit);
1,364✔
578
            callback(commit);
1,364✔
579
        };
1,368✔
580
        for (let event of events) {
1,436✔
581
            const eventMetadata = Object.assign({ commitId, committedAt, commitVersion, commitSize }, metadata, { streamVersion });
1,836✔
582
            const storedEvent = { stream: streamName, payload: event, metadata: eventMetadata };
1,836✔
583
            commitVersion++;
1,836✔
584
            streamVersion++;
1,836✔
585
            commit.events.push(event);
1,836✔
586
            this.storage.write(storedEvent, commitVersion !== events.length ? undefined : commitCallback);
1,836✔
587
        }
1,836✔
588
    }
1,436✔
589

4✔
590
    /**
4✔
591
     * @api
4✔
592
     * @param {string} streamName The name of the stream to get the version for.
4✔
593
     * @returns {number} The version that the given stream is at currently, or -1 if the stream does not exist.
4✔
594
     */
4✔
595
    getStreamVersion(streamName) {
4✔
596
        if (!(streamName in this.streams)) {
172✔
597
            return -1;
28✔
598
        }
28✔
599
        return this.streams[streamName].index.length;
144✔
600
    }
172✔
601

4✔
602
    /**
4✔
603
     * Query the event store for events matching a set of event types and an optional filter function.
4✔
604
     * Returns a pre-filtered event stream and a {@link CommitCondition} that can be passed to
4✔
605
     * {@link EventStore#commit} to enforce optimistic concurrency.
4✔
606
     *
4✔
607
     * A conflict occurs when at least one event appended between the `query` call and the `commit` call
4✔
608
     * belongs to one of the listed types and (when `matcher` is provided) also satisfies
4✔
609
     * `matcher(payload, metadata)`.  Events written before the `query` call are never treated as conflicts.
4✔
610
     *
4✔
611
     * Missing stream references are treated as empty sets. In OR groups they have no effect;
4✔
612
     * in AND groups they collapse that branch to an empty result.
4✔
613
     *
4✔
614
     * @api
4✔
615
     * @param {SelectorNode[]|object} selectorOrDcbQuery A non-empty selector array, or a {@link DcbQuery} object
4✔
616
     *   with an `items` property (compiled automatically to selector algebra).
4✔
617
     * @param {function|object|null} [matcher] Optional matcher used for stream pre-filtering.
4✔
618
     *   In object mode, function predicates receive `(payload, metadata)`.
4✔
619
     * @param {number} [minRevision=1] The 1-based minimum global revision to include in the returned stream (inclusive).
4✔
620
     * @param {boolean} [raw=false] If true, return NDJSON buffers from the query stream.
4✔
621
     * @returns {{ condition: CommitCondition, stream: EventStream }} An object with:
4✔
622
     *   - `condition` — the {@link CommitCondition} to pass to {@link EventStore#commit}.
4✔
623
     *   - `stream` — a read-only event stream containing all matching events.
4✔
624
     *   Nested selector example (items=OR, per-item=AND, inner=OR):
4✔
625
     *   `[[courseTag, ['CourseCreated', 'CourseCapacityChanged']], [studentTag, ['StudentCreated']]]`
4✔
626
     * @throws {Error} if `selector` is not a non-empty array.
4✔
627
     */
4✔
628
    query(selectorOrDcbQuery, matcher = null, minRevision = 1, raw = false) {
4✔
629
        let selector = selectorOrDcbQuery;
136✔
630
        if (isDcbQuery(selectorOrDcbQuery)) {
136✔
631
            selector = compileDcbQuery(
32✔
632
                selectorOrDcbQuery,
32✔
633
                (type) => this.resolveTypeStream(type),
32✔
634
                (tag) => this.resolveTagStream(tag)
32✔
635
            );
32✔
636
        }
32✔
637
        assert(Array.isArray(selector) && selector.length > 0, 'Must specify a non-empty array of stream selectors for query.');
136✔
638
        // Normalize the selector once here; both the condition and the stream use
136✔
639
        // the normalized form.  Wrap a string result (single-stream reduction) in
136✔
640
        // an array so fromStreams always receives an array.
136✔
641
        const normalized = normalizeSelector(selector);
136✔
642
        const querySelector = Array.isArray(normalized) ? normalized : [normalized];
136✔
643
        const condition = new CommitCondition(querySelector, matcher, this.storage.length, raw);
136✔
644
        const stream = this.fromStreams('_query_' + Date.now(), querySelector, minRevision, -1, matcher, raw);
136✔
645
        return { stream, condition };
136✔
646
    }
136✔
647

4✔
648
    /**
4✔
649
     * @private
4✔
650
     * @param {string} type
4✔
651
     * @returns {string} Stream name for the given event type.
4✔
652
     * @throws {Error} When typeAccessor is not configured.
4✔
653
     */
4✔
654
    resolveTypeStream(type) {
4✔
655
        assert(this.typeSource !== null, 'DcbQuery references "types" but typeAccessor not configured.');
24✔
656
        return this.typeSource.nameBuilder(type);
24✔
657
    }
24✔
658

4✔
659
    /**
4✔
660
     * @private
4✔
661
     * @param {string} tag
4✔
662
     * @returns {string} Stream name for the given tag value.
4✔
663
     * @throws {Error} When tagsAccessor is not configured.
4✔
664
     */
4✔
665
    resolveTagStream(tag) {
4✔
666
        assert(this.tagsSource !== null, 'DcbQuery references "tags" but tagsAccessor not configured.');
24✔
667
        return this.tagsSource.nameBuilder(tag);
24✔
668
    }
24✔
669

4✔
670
    /**
4✔
671
     * Get an event stream for the given stream name within the revision boundaries.
4✔
672
     *
4✔
673
     * @api
4✔
674
     * @param {string} streamName The name of the stream to get.
4✔
675
     * @param {number} [minRevision=1] The 1-based minimum revision to include in the events (inclusive).
4✔
676
     * @param {number} [maxRevision=-1] The 1-based maximum revision to include in the events (inclusive).
4✔
677
     * @param {function|object|null} [predicate] Optional matcher (see {@link EventStream}).
4✔
678
     * @param {boolean} [raw=false] If true, return NDJSON buffers.
4✔
679
     * @returns {EventStream|boolean} The event stream or false if a stream with the name doesn't exist.
4✔
680
     */
4✔
681
    getEventStream(streamName, minRevision = 1, maxRevision = -1, predicate = null, raw = false) {
4✔
682
        ({ predicate, raw } = normalizePredicateRaw(predicate, raw));
228✔
683
        if (!(streamName in this.streams)) {
228✔
684
            return false;
4✔
685
        }
4✔
686
        return new EventStream(streamName, this, minRevision, maxRevision, predicate, raw);
224✔
687
    }
228✔
688

4✔
689
    /**
4✔
690
     * Get a stream for all events within the revision boundaries.
4✔
691
     * This is the same as `getEventStream('_all', ...)`.
4✔
692
     *
4✔
693
     * @api
4✔
694
     * @param {number} [minRevision=1] The 1-based minimum revision to include in the events (inclusive).
4✔
695
     * @param {number} [maxRevision=-1] The 1-based maximum revision to include in the events (inclusive).
4✔
696
     * @param {function|object|null} [predicate] Optional matcher (see {@link EventStream}).
4✔
697
     * @param {boolean} [raw=false] If true, return NDJSON buffers.
4✔
698
     * @returns {EventStream} The event stream.
4✔
699
     */
4✔
700
    getAllEvents(minRevision = 1, maxRevision = -1, predicate = null, raw = false) {
4✔
701
        ({ predicate, raw } = normalizePredicateRaw(predicate, raw));
12✔
702
        return this.getEventStream('_all', minRevision, maxRevision, predicate, raw);
12✔
703
    }
12✔
704

4✔
705
    /**
4✔
706
     * Create a virtual event stream from existing streams.
4✔
707
     *
4✔
708
     * Uses selector algebra with alternating operator levels (depth 0 = OR, depth 1 = AND, ...).
4✔
709
     *
4✔
710
     * @param {string} streamName The (transient) name of the joined stream.
4✔
711
     * @param {Array<string|Array<string>>} streamNames Stream selector input.
4✔
712
     * @param {number} [minRevision=1] The 1-based minimum revision to include in the events (inclusive).
4✔
713
     * @param {number} [maxRevision=-1] The 1-based maximum revision to include in the events (inclusive).
4✔
714
     * @param {function|object|null} [predicate] Optional matcher (see {@link EventStream}).
4✔
715
     * @param {boolean} [raw=false] If true, return NDJSON buffers.
4✔
716
     * @returns {EventStream|JoinEventStream}
4✔
717
     * @throws {Error} if any selected stream doesn't exist.
4✔
718
     */
4✔
719
    fromStreams(streamName, streamNames, minRevision = 1, maxRevision = -1, predicate = null, raw = false) {
4✔
720
        ({ predicate, raw } = normalizePredicateRaw(predicate, raw));
212✔
721
        assert(streamNames instanceof Array, 'Must specify an array of stream names.');
212✔
722

212✔
723
        return new JoinEventStream(streamName, streamNames, this, minRevision, maxRevision, predicate, raw);
212✔
724
    }
212✔
725

4✔
726
    /**
4✔
727
     * Get a stream for a category of streams. This will effectively return a joined stream of all streams that start
4✔
728
     * with the given `categoryName` followed by a dash (flat layout, e.g. `users-123`) or a slash (hierarchical
4✔
729
     * layout, e.g. `users/123`).
4✔
730
     * If you frequently use this for a category consisting of a lot of streams (e.g. `users`), consider creating a
4✔
731
     * dedicated physical stream for the category:
4✔
732
     *
4✔
733
     *    `eventstore.createEventStream('users', e => e.stream.startsWith('users-') || e.stream.startsWith('users/'))`
4✔
734
     *
4✔
735
     * @api
4✔
736
     * @param {string} categoryName The name of the category to get a stream for. A category is a stream name prefix.
4✔
737
     * @param {number} [minRevision=1] The 1-based minimum revision to include in the events (inclusive).
4✔
738
     * @param {number} [maxRevision=-1] The 1-based maximum revision to include in the events (inclusive).
4✔
739
     * @param {function|object|null} [predicate] Optional matcher (see {@link EventStream}).
4✔
740
     * @param {boolean} [raw=false] If true, return NDJSON buffers.
4✔
741
     * @returns {EventStream} The joined event stream for all streams of the given category.
4✔
742
     * @throws {Error} If no stream for this category exists.
4✔
743
     */
4✔
744
    getEventStreamForCategory(categoryName, minRevision = 1, maxRevision = -1, predicate = null, raw = false) {
4✔
745
        ({ predicate, raw } = normalizePredicateRaw(predicate, raw));
40✔
746
        if (categoryName in this.streams) {
40✔
747
            return this.getEventStream(categoryName, minRevision, maxRevision, predicate, raw);
4✔
748
        }
4✔
749
        const categoryStreams = Object.keys(this.streams).filter(streamName =>
36✔
750
            streamName.startsWith(categoryName + '-') ||
224✔
751
            streamName.startsWith(categoryName + '/')
128✔
752
        );
36✔
753

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

36✔
756
        return this.fromStreams(categoryName, categoryStreams, minRevision, maxRevision, predicate, raw);
36✔
757
    }
40✔
758

4✔
759
    /**
4✔
760
     * Create a new stream with the given matcher.
4✔
761
     *
4✔
762
     * @api
4✔
763
     * @param {string} streamName The name of the stream to create.
4✔
764
     * @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✔
765
     * @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✔
766
     * @returns {EventStream} The EventStream with all existing events matching the matcher.
4✔
767
     * @throws {Error} If a stream with that name already exists.
4✔
768
     * @throws {Error} If the stream could not be created.
4✔
769
     */
4✔
770
    createEventStream(streamName, matcher, reindex = true) {
4✔
771
        assert(!(this.storage instanceof ReadOnlyStorage), 'The storage was opened in read-only mode. Can not create new stream on it.');
1,248✔
772
        assert(!(streamName in this.streams), 'Can not recreate stream!');
1,248✔
773

1,248✔
774
        const streamIndexName = 'stream-' + streamName;
1,248✔
775
        if (streamName.includes('/')) {
1,248✔
776
            const subDir = path.join(this.streamsDirectory, this.storeName + '.stream-' + path.dirname(streamName));
196✔
777
            ensureDirectory(subDir);
196✔
778
        }
196✔
779
        const index = this.storage.ensureIndex(streamIndexName, matcher, reindex);
1,228✔
780
        assert(index !== null, `Error creating stream index ${streamName}.`);
1,228✔
781

1,228✔
782
        // deepcode ignore PrototypePollutionFunctionParams: streams is a Map
1,228✔
783
        this.streams[streamName] = { index, matcher };
1,228✔
784
        this.emit('stream-created', streamName);
1,228✔
785
        return new EventStream(streamName, this);
1,228✔
786
    }
1,248✔
787

4✔
788
    /**
4✔
789
     * Delete an event stream. Will do nothing if the stream with the name doesn't exist.
4✔
790
     *
4✔
791
     * Note that you can delete a write stream, but that will not delete the events written to it.
4✔
792
     * Also, on next write, that stream will be rebuilt from all existing events, which might take some time.
4✔
793
     *
4✔
794
     * @api
4✔
795
     * @param {string} streamName The name of the stream to delete.
4✔
796
     * @returns void
4✔
797
     */
4✔
798
    deleteEventStream(streamName) {
4✔
799
        assert(!(this.storage instanceof ReadOnlyStorage), 'The storage was opened in read-only mode. Can not delete a stream on it.');
12✔
800

12✔
801
        if (!(streamName in this.streams)) {
12✔
802
            return;
4✔
803
        }
4✔
804
        this.streams[streamName].index.destroy();
4✔
805
        delete this.streams[streamName];
4✔
806
        this.emit('stream-deleted', streamName);
4✔
807
    }
12✔
808

4✔
809
    /**
4✔
810
     * Close a stream so that no new events are indexed into it.
4✔
811
     * The stream will still be readable, but any attempt to write to it will throw an error.
4✔
812
     * A closed stream is persisted by renaming its index file to include a `.closed` marker
4✔
813
     * (e.g. `stream-X.closed.index`), so it will be recognized as closed when the store is reopened.
4✔
814
     *
4✔
815
     * @api
4✔
816
     * @param {string} streamName The name of the stream to close.
4✔
817
     * @returns void
4✔
818
     * @throws {Error} If the storage is read-only.
4✔
819
     * @throws {Error} If the stream does not exist.
4✔
820
     * @throws {Error} If the stream is already closed.
4✔
821
     */
4✔
822
    closeEventStream(streamName) {
4✔
823
        assert(!(this.storage instanceof ReadOnlyStorage), 'The storage was opened in read-only mode. Can not close a stream on it.');
48✔
824
        assert(streamName in this.streams, `Stream "${streamName}" does not exist.`);
48✔
825
        assert(!this.streams[streamName].closed, `Stream "${streamName}" is already closed.`);
48✔
826

48✔
827
        const indexName = 'stream-' + streamName;
48✔
828
        const { index } = this.streams[streamName];
48✔
829

48✔
830
        // Flush and close the index before renaming the file
48✔
831
        index.close();
48✔
832

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

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

48✔
840
        // Reopen the renamed index for read access, outside the secondary indexes write path
48✔
841
        const closedIndexName = indexName + '.closed';
48✔
842
        const closedIndex = this.storage.openReadonlyIndex(closedIndexName);
48✔
843

48✔
844
        // deepcode ignore PrototypePollutionFunctionParams: streams is a Map
48✔
845
        this.streams[streamName] = { index: closedIndex, closed: true };
48✔
846
        this.emit('stream-closed', streamName);
48✔
847
    }
48✔
848

4✔
849
    /**
4✔
850
     * Get a durable consumer for the given stream, or look up an existing consumer by identifier.
4✔
851
     *
4✔
852
     * When called with a single argument, returns the running consumer registered under that
4✔
853
     * identifier, or `null` if none is found — useful for read endpoints that need the live
4✔
854
     * in-memory instance without creating a new one.
4✔
855
     *
4✔
856
     * When called with two or more arguments, creates (or re-uses) a Consumer for the given
4✔
857
     * stream and identifier, registers it in `this.consumers`, and returns it.
4✔
858
     *
4✔
859
     * @param {string} streamNameOrIdentifier The stream name, or the consumer identifier when used as a registry lookup.
4✔
860
     * @param {string} [identifier] The unique identifying name of this consumer. Omit for registry-only lookup.
4✔
861
     * @param {object} [initialState] The initial state of the consumer.
4✔
862
     * @param {number} [since] The stream revision to start consuming from.
4✔
863
     * @returns {Consumer|null} A durable consumer, or `null` when looking up by identifier and none is registered.
4✔
864
     */
4✔
865
    getConsumer(streamNameOrIdentifier, identifier, initialState = {}, since = 0) {
4✔
866
        if (identifier === undefined) {
28!
867
            return this.consumers.get(streamNameOrIdentifier) ?? null;
×
868
        }
×
869
        const streamName = streamNameOrIdentifier;
28✔
870
        if (this.consumers.has(identifier)) {
28✔
871
            const existingConsumer = this.consumers.get(identifier);
4✔
872
            if (existingConsumer.streamName === streamName) {
4!
873
                return existingConsumer;
×
874
            }
×
875
            // Rebind identifier to the requested stream when a consumer with the same
4✔
876
            // identifier already exists for another stream.
4✔
877
            existingConsumer.stop();
4✔
878
        }
4✔
879
        const consumer = new Consumer(this.storage, streamName === '_all' ? '_all' : 'stream-' + streamName, identifier, initialState, since);
28✔
880
        consumer.streamName = streamName;
28✔
881
        this.consumers.set(identifier, consumer);
28✔
882
        return consumer;
28✔
883
    }
28✔
884

4✔
885
    /**
4✔
886
     * Scan the existing consumers on this EventStore and asynchronously invoke a callback with the parsed list.
4✔
887
     *
4✔
888
     * Each consumer entry provides `{ name, stream, identifier }` parsed from the on-disk filename.
4✔
889
     * Pass `autoStart = true` to eagerly open every discovered consumer and register it in
4✔
890
     * `this.consumers` so that it is immediately available for registry lookups.
4✔
891
     *
4✔
892
     * @param {function(error: Error|null, consumers: Array<{name: string, stream: string, identifier: string}>)} callback
4✔
893
     * @param {boolean} [autoStart=false] When true, calls `getConsumer(stream, identifier)` for each discovered consumer.
4✔
894
     */
4✔
895
    scanConsumers(callback, autoStart = false) {
4✔
896
        const consumersPath = path.join(this.storage.indexDirectory, 'consumers');
8✔
897
        if (!fs.existsSync(consumersPath)) {
8✔
898
            callback(null, []);
4✔
899
            return;
4✔
900
        }
4✔
901
        const regex = new RegExp(`^${this.storage.storageFile}\\.([^.]*\\..*)$`);
4✔
902
        const consumerNames = [];
4✔
903
        scanForFiles(consumersPath, regex, consumerNames.push.bind(consumerNames), /* istanbul ignore next */ (err) => {
4✔
904
            if (err) {
4!
905
                return callback(err, []);
×
906
            }
×
907
            const consumers = consumerNames.map(name => {
4✔
908
                const splitIndex = name.lastIndexOf('.');
8✔
909
                const indexName = name.slice(0, splitIndex);
8✔
910
                const identifier = name.slice(splitIndex + 1);
8✔
911
                const stream = parseStreamFromIndexName(indexName);
8✔
912
                return { name, stream, identifier };
8✔
913
            });
4✔
914
            if (autoStart) {
4!
915
                for (const { stream, identifier } of consumers) {
×
916
                    this.getConsumer(stream, identifier);
×
917
                }
×
918
            }
×
919
            callback(null, consumers);
4✔
920
        });
4✔
921
    }
8✔
922
}
4✔
923

4✔
924

4✔
925
EventStore.Storage = Storage;
4✔
926
EventStore.Index = Index;
4✔
927

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

© 2026 Coveralls, Inc