event-storage 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/index.js +1 -1
- package/package.json +3 -4
- package/src/Consumer.js +9 -6
- package/src/EventStore.js +336 -80
- package/src/EventStream.js +73 -11
- package/src/Index/ReadableIndex.js +7 -5
- package/src/Index/WritableIndex.js +4 -4
- package/src/IndexMatcher.js +205 -0
- package/src/JoinEventStream.js +44 -46
- package/src/Partition/ReadablePartition.js +153 -85
- package/src/Partition/WritablePartition.js +37 -26
- package/src/PartitionPool.js +149 -0
- package/src/Storage/ReadOnlyStorage.js +5 -5
- package/src/Storage/ReadableStorage.js +203 -140
- package/src/Storage/WritableStorage.js +81 -45
- package/src/Watcher.js +1 -1
- package/src/utils/fsUtil.js +123 -0
- package/src/utils/jsonUtil.js +87 -0
- package/src/utils/metadataUtil.js +247 -0
- package/src/utils/util.js +202 -0
- package/src/metadataUtil.js +0 -79
- package/src/util.js +0 -218
package/src/EventStore.js
CHANGED
|
@@ -6,15 +6,51 @@ import events from 'events';
|
|
|
6
6
|
import Storage, { ReadOnly as ReadOnlyStorage, LOCK_THROW, LOCK_RECLAIM } from './Storage.js';
|
|
7
7
|
import Index from './Index.js';
|
|
8
8
|
import Consumer from './Consumer.js';
|
|
9
|
-
import { assert,
|
|
9
|
+
import { assert, getPropertyAtPath } from './utils/util.js';
|
|
10
|
+
import { ensureDirectory, scanForFiles } from './utils/fsUtil.js';
|
|
11
|
+
import { buildTypeMatcherFn } from './utils/metadataUtil.js';
|
|
10
12
|
|
|
11
13
|
const ExpectedVersion = {
|
|
12
14
|
Any: -1,
|
|
13
15
|
EmptyStream: 0
|
|
14
16
|
};
|
|
15
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Default matcher property paths mirroring the Storage default, used for index optimization.
|
|
20
|
+
*/
|
|
21
|
+
const DEFAULT_MATCHER_PROPERTIES = ['stream', 'payload.type'];
|
|
22
|
+
const STREAM_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_]*(?:[\/:@~+=\-#.][A-Za-z0-9_]+)*$/;
|
|
23
|
+
const STORAGE_HOOK_EVENTS = new Set(['preCommit', 'preRead']);
|
|
24
|
+
|
|
16
25
|
class OptimisticConcurrencyError extends Error {}
|
|
17
26
|
|
|
27
|
+
/**
|
|
28
|
+
* An accept condition that captures the global event-log position at the time a {@link EventStore#query}
|
|
29
|
+
* call was made. Pass it as the `expectedVersion` argument to {@link EventStore#commit} to enforce
|
|
30
|
+
* DCB-style (Dynamic Consistency Boundary) optimistic concurrency: the commit is rejected only when
|
|
31
|
+
* one or more events that match the original query (types + optional matcher) have been appended to
|
|
32
|
+
* the store between the `query` call and the `commit` call.
|
|
33
|
+
*
|
|
34
|
+
* @property {string[]} types The event types included in the query.
|
|
35
|
+
* @property {function(object, object): boolean|null} matcher An optional function `(payload, metadata) => boolean`
|
|
36
|
+
* used to narrow the conflict check. When `null`, any new event of a listed type causes a conflict.
|
|
37
|
+
* @property {number} noneMatchAfter The global store length (total event count) at the time the query was made.
|
|
38
|
+
*/
|
|
39
|
+
class CommitCondition {
|
|
40
|
+
/**
|
|
41
|
+
* @param {string[]} types
|
|
42
|
+
* @param {function(object, object): boolean|object|null} [matcher]
|
|
43
|
+
* @param {number} noneMatchAfter
|
|
44
|
+
* @param {boolean} [raw=false]
|
|
45
|
+
*/
|
|
46
|
+
constructor(types, matcher = null, noneMatchAfter, raw = false) {
|
|
47
|
+
this.types = types;
|
|
48
|
+
this.matcher = matcher;
|
|
49
|
+
this.raw = raw;
|
|
50
|
+
this.noneMatchAfter = noneMatchAfter;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
18
54
|
/**
|
|
19
55
|
* An event store optimized for working with many streams.
|
|
20
56
|
* An event stream is implemented as an iterator over an index on the storage, therefore indexes need to be lightweight
|
|
@@ -29,6 +65,9 @@ class EventStore extends events.EventEmitter {
|
|
|
29
65
|
* @param {string} [config.streamsDirectory] The directory where the streams should be stored. Default '{storageDirectory}/streams'.
|
|
30
66
|
* @param {object} [config.storageConfig] Additional config options given to the storage backend. See `Storage`.
|
|
31
67
|
* @param {boolean} [config.readOnly] If the storage should be mounted in read-only mode.
|
|
68
|
+
* @param {string|function(object): string} [config.typeAccessor] Dot-notation path (e.g. `'type'`) or
|
|
69
|
+
* function `(event) => string` identifying the event type. Enables type-based queries via
|
|
70
|
+
* {@link EventStore#query} and ensures proper index routing for those queries.
|
|
32
71
|
* @param {object|function(string): object} [config.streamMetadata] A metadata object or a function `(streamName) => object`
|
|
33
72
|
* that is called whenever a new stream partition is created. The returned object is stored once in the partition
|
|
34
73
|
* file header and surfaced to `preCommit` / `preRead` hooks. Takes precedence only when
|
|
@@ -41,6 +80,15 @@ class EventStore extends events.EventEmitter {
|
|
|
41
80
|
storeName = 'eventstore';
|
|
42
81
|
}
|
|
43
82
|
|
|
83
|
+
if (typeof config.typeAccessor === 'string' && config.typeAccessor) {
|
|
84
|
+
const accessorPath = config.typeAccessor;
|
|
85
|
+
this.typeAccessor = (event) => getPropertyAtPath(event, accessorPath);
|
|
86
|
+
this.typeMatcherFn = buildTypeMatcherFn(accessorPath);
|
|
87
|
+
} else {
|
|
88
|
+
this.typeAccessor = typeof config.typeAccessor === 'function' ? config.typeAccessor : null;
|
|
89
|
+
this.typeMatcherFn = null;
|
|
90
|
+
}
|
|
91
|
+
|
|
44
92
|
this.storageDirectory = path.resolve(config.storageDirectory || /* istanbul ignore next */ './data');
|
|
45
93
|
let defaults = {
|
|
46
94
|
dataDirectory: this.storageDirectory,
|
|
@@ -50,6 +98,17 @@ class EventStore extends events.EventEmitter {
|
|
|
50
98
|
};
|
|
51
99
|
const storageConfig = Object.assign(defaults, config.storageConfig);
|
|
52
100
|
|
|
101
|
+
// When typeAccessor is a string path, ensure the corresponding full document path
|
|
102
|
+
// (payload.<path>) is present in matcherProperties so the IndexMatcher discriminant
|
|
103
|
+
// table can route type-stream lookups in O(1) on every write.
|
|
104
|
+
if (this.typeMatcherFn) {
|
|
105
|
+
const fullPath = `payload.${config.typeAccessor}`;
|
|
106
|
+
const currentProps = storageConfig.matcherProperties || DEFAULT_MATCHER_PROPERTIES;
|
|
107
|
+
if (!currentProps.includes(fullPath)) {
|
|
108
|
+
storageConfig.matcherProperties = [...currentProps, fullPath];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
53
112
|
// Translate the high-level streamMetadata option into the storage-level metadata function,
|
|
54
113
|
// but only when the caller has not already provided a lower-level storageConfig.metadata.
|
|
55
114
|
if (config.streamMetadata !== undefined && storageConfig.metadata === undefined) {
|
|
@@ -75,18 +134,18 @@ class EventStore extends events.EventEmitter {
|
|
|
75
134
|
this.storage = (storageConfig.readOnly === true) ?
|
|
76
135
|
new ReadOnlyStorage(storeName, storageConfig)
|
|
77
136
|
: new Storage(storeName, storageConfig);
|
|
78
|
-
this.storage.open();
|
|
79
137
|
this.streams = Object.create(null);
|
|
80
138
|
this.streams._all = { index: this.storage.index };
|
|
139
|
+
this.consumers = new Map();
|
|
81
140
|
|
|
82
|
-
this.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
throw err;
|
|
86
|
-
}
|
|
141
|
+
this.storage.on('index-created', this.registerStream.bind(this));
|
|
142
|
+
|
|
143
|
+
this.storage.on('opened', () => {
|
|
87
144
|
this.checkUnfinishedCommits();
|
|
88
145
|
this.emit('ready');
|
|
89
146
|
});
|
|
147
|
+
|
|
148
|
+
this.storage.open();
|
|
90
149
|
}
|
|
91
150
|
|
|
92
151
|
/**
|
|
@@ -121,22 +180,6 @@ class EventStore extends events.EventEmitter {
|
|
|
121
180
|
}
|
|
122
181
|
}
|
|
123
182
|
|
|
124
|
-
/**
|
|
125
|
-
* Scan the streams directory for existing streams so they are ready for `getEventStream()`.
|
|
126
|
-
*
|
|
127
|
-
* @private
|
|
128
|
-
* @param {function} callback A callback that will be called when all existing streams are found.
|
|
129
|
-
*/
|
|
130
|
-
scanStreams(callback) {
|
|
131
|
-
/* istanbul ignore if */
|
|
132
|
-
if (typeof callback !== 'function') {
|
|
133
|
-
callback = () => {};
|
|
134
|
-
}
|
|
135
|
-
// Find existing streams by scanning dir for filenames starting with 'stream-'
|
|
136
|
-
scanForFiles(this.streamsDirectory, /(stream-.*)\.index$/, this.registerStream.bind(this), callback);
|
|
137
|
-
this.storage.on('index-created', this.registerStream.bind(this));
|
|
138
|
-
}
|
|
139
|
-
|
|
140
183
|
/**
|
|
141
184
|
* @private
|
|
142
185
|
* @param {string} name The full stream name, including the `stream-` prefix (and optional `.closed` suffix).
|
|
@@ -176,10 +219,15 @@ class EventStore extends events.EventEmitter {
|
|
|
176
219
|
|
|
177
220
|
/**
|
|
178
221
|
* Close the event store and free up all resources.
|
|
222
|
+
* Stops all registered consumers before closing storage.
|
|
179
223
|
*
|
|
180
224
|
* @api
|
|
181
225
|
*/
|
|
182
226
|
close() {
|
|
227
|
+
for (const consumer of this.consumers.values()) {
|
|
228
|
+
consumer.stop();
|
|
229
|
+
}
|
|
230
|
+
this.consumers.clear();
|
|
183
231
|
this.storage.close();
|
|
184
232
|
}
|
|
185
233
|
|
|
@@ -193,11 +241,8 @@ class EventStore extends events.EventEmitter {
|
|
|
193
241
|
* @returns {this}
|
|
194
242
|
*/
|
|
195
243
|
on(event, listener) {
|
|
196
|
-
if (event
|
|
197
|
-
|
|
198
|
-
assert(!(this.storage instanceof ReadOnlyStorage), 'The storage was opened in read-only mode. Can not register a preCommit handler on it.');
|
|
199
|
-
}
|
|
200
|
-
this.storage.on(event, listener);
|
|
244
|
+
if (this.isStorageHookEvent(event)) {
|
|
245
|
+
this.delegateStorageHookEvent('on', event, listener);
|
|
201
246
|
return this;
|
|
202
247
|
}
|
|
203
248
|
return super.on(event, listener);
|
|
@@ -218,11 +263,8 @@ class EventStore extends events.EventEmitter {
|
|
|
218
263
|
* @returns {this}
|
|
219
264
|
*/
|
|
220
265
|
once(event, listener) {
|
|
221
|
-
if (event
|
|
222
|
-
|
|
223
|
-
assert(!(this.storage instanceof ReadOnlyStorage), 'The storage was opened in read-only mode. Can not register a preCommit handler on it.');
|
|
224
|
-
}
|
|
225
|
-
this.storage.once(event, listener);
|
|
266
|
+
if (this.isStorageHookEvent(event)) {
|
|
267
|
+
this.delegateStorageHookEvent('once', event, listener);
|
|
226
268
|
return this;
|
|
227
269
|
}
|
|
228
270
|
return super.once(event, listener);
|
|
@@ -237,13 +279,24 @@ class EventStore extends events.EventEmitter {
|
|
|
237
279
|
* @returns {this}
|
|
238
280
|
*/
|
|
239
281
|
off(event, listener) {
|
|
240
|
-
if (event
|
|
282
|
+
if (this.isStorageHookEvent(event)) {
|
|
241
283
|
this.storage.off(event, listener);
|
|
242
284
|
return this;
|
|
243
285
|
}
|
|
244
286
|
return super.off(event, listener);
|
|
245
287
|
}
|
|
246
288
|
|
|
289
|
+
isStorageHookEvent(event) {
|
|
290
|
+
return STORAGE_HOOK_EVENTS.has(event);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
delegateStorageHookEvent(method, event, listener) {
|
|
294
|
+
if (event === 'preCommit') {
|
|
295
|
+
assert(!(this.storage instanceof ReadOnlyStorage), 'The storage was opened in read-only mode. Can not register a preCommit handler on it.');
|
|
296
|
+
}
|
|
297
|
+
this.storage[method](event, listener);
|
|
298
|
+
}
|
|
299
|
+
|
|
247
300
|
/**
|
|
248
301
|
* @inheritDoc
|
|
249
302
|
*/
|
|
@@ -299,16 +352,16 @@ class EventStore extends events.EventEmitter {
|
|
|
299
352
|
*
|
|
300
353
|
* @private
|
|
301
354
|
* @param {Array<object>|object} events
|
|
302
|
-
* @param {number} [expectedVersion]
|
|
355
|
+
* @param {number|CommitCondition} [expectedVersion]
|
|
303
356
|
* @param {object|function} [metadata]
|
|
304
357
|
* @param {function} [callback]
|
|
305
|
-
* @returns {{events: Array<object>, metadata: object, callback: function, expectedVersion: number}}
|
|
358
|
+
* @returns {{events: Array<object>, metadata: object, callback: function, expectedVersion: number|CommitCondition}}
|
|
306
359
|
*/
|
|
307
360
|
static fixArgumentTypes(events, expectedVersion, metadata, callback) {
|
|
308
361
|
if (!(events instanceof Array)) {
|
|
309
362
|
events = [events];
|
|
310
363
|
}
|
|
311
|
-
if (typeof expectedVersion !== 'number') {
|
|
364
|
+
if (typeof expectedVersion !== 'number' && !(expectedVersion instanceof CommitCondition)) {
|
|
312
365
|
callback = metadata;
|
|
313
366
|
metadata = expectedVersion;
|
|
314
367
|
expectedVersion = ExpectedVersion.Any;
|
|
@@ -323,6 +376,79 @@ class EventStore extends events.EventEmitter {
|
|
|
323
376
|
return { events, expectedVersion, metadata, callback };
|
|
324
377
|
}
|
|
325
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Check a {@link CommitCondition} against the current state of the store.
|
|
381
|
+
* Iterates a join stream over all condition type streams starting from
|
|
382
|
+
* `condition.noneMatchAfter` (the global position captured at query time), and throws an
|
|
383
|
+
* {@link OptimisticConcurrencyError} when a new event of a listed type satisfies
|
|
384
|
+
* `condition.matcher(payload, metadata)` (or any such event when no matcher is provided).
|
|
385
|
+
*
|
|
386
|
+
* @param {CommitCondition} condition
|
|
387
|
+
* @throws {OptimisticConcurrencyError}
|
|
388
|
+
*/
|
|
389
|
+
checkCondition(condition) {
|
|
390
|
+
if (this.storage.length <= condition.noneMatchAfter) return; // no new events since condition was obtained
|
|
391
|
+
|
|
392
|
+
const existingTypes = condition.types.filter(t => t in this.streams);
|
|
393
|
+
if (existingTypes.length === 0) return;
|
|
394
|
+
|
|
395
|
+
// Only events after condition.noneMatchAfter can be conflicts.
|
|
396
|
+
// Pass the original matcher and raw flag so the stream filters at the source.
|
|
397
|
+
const stream = this.fromStreams(
|
|
398
|
+
'_check_' + condition.types.join('_'),
|
|
399
|
+
existingTypes,
|
|
400
|
+
condition.noneMatchAfter + 1,
|
|
401
|
+
-1,
|
|
402
|
+
condition.matcher,
|
|
403
|
+
condition.raw
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
assert(stream.next() === false, `Optimistic Concurrency error. A conflicting event was committed since the condition was obtained.`, OptimisticConcurrencyError);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Ensure a dedicated type stream exists for each event's type, creating it if needed.
|
|
411
|
+
* Must be called before the entity stream is created to guarantee correct index routing.
|
|
412
|
+
*
|
|
413
|
+
* @param {Array<object>} events The events to process.
|
|
414
|
+
*/
|
|
415
|
+
ensureTypeStreams(events) {
|
|
416
|
+
if (!this.typeAccessor) return;
|
|
417
|
+
for (const event of events) {
|
|
418
|
+
const type = this.resolveValidatedTypeStreamName(event);
|
|
419
|
+
if (type && !(type in this.streams)) {
|
|
420
|
+
const matcher = this.typeMatcherFn
|
|
421
|
+
? this.typeMatcherFn(type)
|
|
422
|
+
: (doc) => this.typeAccessor(doc.payload) === type;
|
|
423
|
+
this.createEventStream(type, matcher, false);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
resolveValidatedTypeStreamName(event) {
|
|
429
|
+
const type = this.typeAccessor(event);
|
|
430
|
+
if (type === undefined || type === null || type === '') {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
assert(typeof type === 'string', 'typeAccessor must return a string.');
|
|
434
|
+
assert(STREAM_NAME_PATTERN.test(type), `typeAccessor must return a valid stream name. Got: "${type}"`);
|
|
435
|
+
return type;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
getExistingQueryTypes(types) {
|
|
439
|
+
const queryTypes = [];
|
|
440
|
+
for (const type of types) {
|
|
441
|
+
if (type in this.streams) {
|
|
442
|
+
queryTypes.push(type);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (!this.typeAccessor) {
|
|
446
|
+
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.`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return queryTypes;
|
|
450
|
+
}
|
|
451
|
+
|
|
326
452
|
/**
|
|
327
453
|
* Commit a list of events for the given stream name, which is expected to be at the given version.
|
|
328
454
|
* Note that the events committed may still appear in other streams too - the given stream name is only
|
|
@@ -331,10 +457,12 @@ class EventStore extends events.EventEmitter {
|
|
|
331
457
|
* @api
|
|
332
458
|
* @param {string} streamName The name of the stream to commit the events to.
|
|
333
459
|
* @param {Array<object>|object} events The events to commit or a single event.
|
|
334
|
-
* @param {number} [expectedVersion] One of ExpectedVersion constants
|
|
460
|
+
* @param {number|CommitCondition} [expectedVersion] One of the `ExpectedVersion` constants, a positive
|
|
461
|
+
* stream version number, or a {@link CommitCondition} obtained from {@link EventStore#query}.
|
|
335
462
|
* @param {object} [metadata] The commit metadata to use as base. Useful for replication and adding storage metadata.
|
|
336
463
|
* @param {function} [callback] A function that will be executed when all events have been committed.
|
|
337
|
-
* @throws {OptimisticConcurrencyError} if the stream is not at the expected version
|
|
464
|
+
* @throws {OptimisticConcurrencyError} if the stream is not at the expected version, or if a
|
|
465
|
+
* {@link CommitCondition} was provided and conflicting events have been committed since it was obtained.
|
|
338
466
|
*/
|
|
339
467
|
commit(streamName, events, expectedVersion = ExpectedVersion.Any, metadata = {}, callback = null) {
|
|
340
468
|
assert(!(this.storage instanceof ReadOnlyStorage), 'The storage was opened in read-only mode. Can not commit to it.');
|
|
@@ -343,14 +471,25 @@ class EventStore extends events.EventEmitter {
|
|
|
343
471
|
|
|
344
472
|
({ events, expectedVersion, metadata, callback } = EventStore.fixArgumentTypes(events, expectedVersion, metadata, callback));
|
|
345
473
|
|
|
474
|
+
// Perform DCB-style concurrency check when a CommitCondition is provided.
|
|
475
|
+
if (expectedVersion instanceof CommitCondition) {
|
|
476
|
+
this.checkCondition(expectedVersion);
|
|
477
|
+
expectedVersion = ExpectedVersion.Any;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// When typeAccessor is configured, ensure a dedicated type stream exists for each event
|
|
481
|
+
// before the entity stream write so the type stream index is never incomplete.
|
|
482
|
+
this.ensureTypeStreams(events);
|
|
483
|
+
|
|
346
484
|
if (!(streamName in this.streams)) {
|
|
347
|
-
this.createEventStream(streamName, { stream: streamName });
|
|
485
|
+
this.createEventStream(streamName, { stream: streamName }, false);
|
|
348
486
|
}
|
|
349
487
|
assert(!this.streams[streamName].closed, `Stream "${streamName}" is closed and cannot be written to.`);
|
|
350
488
|
let streamVersion = this.streams[streamName].index.length;
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
489
|
+
assert(expectedVersion === ExpectedVersion.Any || streamVersion === expectedVersion,
|
|
490
|
+
`Optimistic Concurrency error. Expected stream "${streamName}" at version ${expectedVersion} but is at version ${streamVersion}.`,
|
|
491
|
+
OptimisticConcurrencyError
|
|
492
|
+
);
|
|
354
493
|
|
|
355
494
|
if (events.length > 1) {
|
|
356
495
|
delete metadata.commitVersion;
|
|
@@ -394,20 +533,59 @@ class EventStore extends events.EventEmitter {
|
|
|
394
533
|
return this.streams[streamName].index.length;
|
|
395
534
|
}
|
|
396
535
|
|
|
536
|
+
/**
|
|
537
|
+
* Query the event store for events matching a set of event types and an optional filter function.
|
|
538
|
+
* Returns a pre-filtered event stream and a {@link CommitCondition} that can be passed to
|
|
539
|
+
* {@link EventStore#commit} to enforce optimistic concurrency.
|
|
540
|
+
*
|
|
541
|
+
* A conflict occurs when at least one event appended between the `query` call and the `commit` call
|
|
542
|
+
* belongs to one of the listed types and (when `matcher` is provided) also satisfies
|
|
543
|
+
* `matcher(payload, metadata)`. Events written before the `query` call are never treated as conflicts.
|
|
544
|
+
*
|
|
545
|
+
* **Behaviour when a type stream does not exist:**
|
|
546
|
+
* - Without `typeAccessor` configured: throws an error, because the store cannot guarantee that no
|
|
547
|
+
* events of that type exist (the stream was never created). Create the stream explicitly first,
|
|
548
|
+
* or configure `typeAccessor` to have streams created automatically on commit.
|
|
549
|
+
* - With `typeAccessor` configured: treats the missing stream as empty (0-length). The stream will
|
|
550
|
+
* be created automatically the first time an event of that type is committed.
|
|
551
|
+
*
|
|
552
|
+
* @api
|
|
553
|
+
* @param {string[]} types A non-empty array of event-type names to query.
|
|
554
|
+
* @param {function|object|null} [matcher] Optional matcher used for stream pre-filtering.
|
|
555
|
+
* In object mode, function predicates receive `(payload, metadata)`.
|
|
556
|
+
* @param {number} [minRevision=1] The 1-based minimum global revision to include in the returned stream (inclusive).
|
|
557
|
+
* @param {boolean} [raw=false] If true, return NDJSON buffers from the query stream.
|
|
558
|
+
* @returns {{ condition: CommitCondition, stream: EventStream }} An object with:
|
|
559
|
+
* - `condition` — the {@link CommitCondition} to pass to {@link EventStore#commit}.
|
|
560
|
+
* - `stream` — a read-only event stream containing all matching events.
|
|
561
|
+
* @throws {Error} if `types` is not a non-empty array.
|
|
562
|
+
* @throws {Error} if `typeAccessor` is not configured and any of the listed type streams do not exist.
|
|
563
|
+
*/
|
|
564
|
+
query(types, matcher = null, minRevision = 1, raw = false) {
|
|
565
|
+
assert(Array.isArray(types) && types.length > 0, 'Must specify a non-empty array of event types for query.');
|
|
566
|
+
const queryTypes = this.getExistingQueryTypes(types);
|
|
567
|
+
const condition = new CommitCondition(types, matcher, this.storage.length, raw);
|
|
568
|
+
const stream = this.fromStreams('_query_' + types.join('_'), queryTypes, minRevision, -1, matcher, raw);
|
|
569
|
+
return { stream, condition };
|
|
570
|
+
}
|
|
571
|
+
|
|
397
572
|
/**
|
|
398
573
|
* Get an event stream for the given stream name within the revision boundaries.
|
|
399
574
|
*
|
|
400
575
|
* @api
|
|
401
576
|
* @param {string} streamName The name of the stream to get.
|
|
402
|
-
* @param {number} [minRevision] The 1-based minimum revision to include in the events (inclusive).
|
|
403
|
-
* @param {number} [maxRevision] The 1-based maximum revision to include in the events (inclusive).
|
|
577
|
+
* @param {number} [minRevision=1] The 1-based minimum revision to include in the events (inclusive).
|
|
578
|
+
* @param {number} [maxRevision=-1] The 1-based maximum revision to include in the events (inclusive).
|
|
579
|
+
* @param {function|object|null} [predicate] Optional matcher (see {@link EventStream}).
|
|
580
|
+
* @param {boolean} [raw=false] If true, return NDJSON buffers.
|
|
404
581
|
* @returns {EventStream|boolean} The event stream or false if a stream with the name doesn't exist.
|
|
405
582
|
*/
|
|
406
|
-
getEventStream(streamName, minRevision = 1, maxRevision = -1) {
|
|
583
|
+
getEventStream(streamName, minRevision = 1, maxRevision = -1, predicate = null, raw = false) {
|
|
584
|
+
({ predicate, raw } = normalizePredicateRaw(predicate, raw));
|
|
407
585
|
if (!(streamName in this.streams)) {
|
|
408
586
|
return false;
|
|
409
587
|
}
|
|
410
|
-
return new EventStream(streamName, this, minRevision, maxRevision);
|
|
588
|
+
return new EventStream(streamName, this, minRevision, maxRevision, predicate, raw);
|
|
411
589
|
}
|
|
412
590
|
|
|
413
591
|
/**
|
|
@@ -415,12 +593,15 @@ class EventStore extends events.EventEmitter {
|
|
|
415
593
|
* This is the same as `getEventStream('_all', ...)`.
|
|
416
594
|
*
|
|
417
595
|
* @api
|
|
418
|
-
* @param {number} [minRevision] The 1-based minimum revision to include in the events (inclusive).
|
|
419
|
-
* @param {number} [maxRevision] The 1-based maximum revision to include in the events (inclusive).
|
|
596
|
+
* @param {number} [minRevision=1] The 1-based minimum revision to include in the events (inclusive).
|
|
597
|
+
* @param {number} [maxRevision=-1] The 1-based maximum revision to include in the events (inclusive).
|
|
598
|
+
* @param {function|object|null} [predicate] Optional matcher (see {@link EventStream}).
|
|
599
|
+
* @param {boolean} [raw=false] If true, return NDJSON buffers.
|
|
420
600
|
* @returns {EventStream} The event stream.
|
|
421
601
|
*/
|
|
422
|
-
getAllEvents(minRevision = 1, maxRevision = -1) {
|
|
423
|
-
|
|
602
|
+
getAllEvents(minRevision = 1, maxRevision = -1, predicate = null, raw = false) {
|
|
603
|
+
({ predicate, raw } = normalizePredicateRaw(predicate, raw));
|
|
604
|
+
return this.getEventStream('_all', minRevision, maxRevision, predicate, raw);
|
|
424
605
|
}
|
|
425
606
|
|
|
426
607
|
/**
|
|
@@ -428,45 +609,65 @@ class EventStore extends events.EventEmitter {
|
|
|
428
609
|
*
|
|
429
610
|
* @param {string} streamName The (transient) name of the joined stream.
|
|
430
611
|
* @param {Array<string>} streamNames An array of the stream names to join.
|
|
431
|
-
* @param {number} [minRevision] The 1-based minimum revision to include in the events (inclusive).
|
|
432
|
-
* @param {number} [maxRevision] The 1-based maximum revision to include in the events (inclusive).
|
|
612
|
+
* @param {number} [minRevision=1] The 1-based minimum revision to include in the events (inclusive).
|
|
613
|
+
* @param {number} [maxRevision=-1] The 1-based maximum revision to include in the events (inclusive).
|
|
614
|
+
* @param {function|object|null} [predicate] Optional matcher (see {@link EventStream}).
|
|
615
|
+
* @param {boolean} [raw=false] If true, return NDJSON buffers.
|
|
433
616
|
* @returns {EventStream} The joined event stream.
|
|
434
617
|
* @throws {Error} if any of the streams doesn't exist.
|
|
435
618
|
*/
|
|
436
|
-
fromStreams(streamName, streamNames, minRevision = 1, maxRevision = -1) {
|
|
619
|
+
fromStreams(streamName, streamNames, minRevision = 1, maxRevision = -1, predicate = null, raw = false) {
|
|
620
|
+
({ predicate, raw } = normalizePredicateRaw(predicate, raw));
|
|
437
621
|
assert(streamNames instanceof Array, 'Must specify an array of stream names.');
|
|
438
622
|
|
|
623
|
+
if (streamNames.length === 0) {
|
|
624
|
+
return new EventStream(streamName, this);
|
|
625
|
+
}
|
|
626
|
+
|
|
439
627
|
for (let stream of streamNames) {
|
|
440
628
|
assert(stream in this.streams, `Stream "${stream}" does not exist.`);
|
|
441
629
|
}
|
|
442
|
-
|
|
630
|
+
|
|
631
|
+
if (streamNames.length === 1) {
|
|
632
|
+
const stream = new EventStream(streamNames[0], this, minRevision, maxRevision, predicate, raw);
|
|
633
|
+
stream.name = streamName;
|
|
634
|
+
return stream;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return new JoinEventStream(streamName, streamNames, this, minRevision, maxRevision, predicate, raw);
|
|
443
638
|
}
|
|
444
639
|
|
|
445
640
|
/**
|
|
446
641
|
* Get a stream for a category of streams. This will effectively return a joined stream of all streams that start
|
|
447
|
-
* with the given `categoryName` followed by a dash.
|
|
642
|
+
* with the given `categoryName` followed by a dash (flat layout, e.g. `users-123`) or a slash (hierarchical
|
|
643
|
+
* layout, e.g. `users/123`).
|
|
448
644
|
* If you frequently use this for a category consisting of a lot of streams (e.g. `users`), consider creating a
|
|
449
645
|
* dedicated physical stream for the category:
|
|
450
646
|
*
|
|
451
|
-
* `eventstore.createEventStream('users', e => e.stream.startsWith('users-'))`
|
|
647
|
+
* `eventstore.createEventStream('users', e => e.stream.startsWith('users-') || e.stream.startsWith('users/'))`
|
|
452
648
|
*
|
|
453
649
|
* @api
|
|
454
650
|
* @param {string} categoryName The name of the category to get a stream for. A category is a stream name prefix.
|
|
455
|
-
* @param {number} [minRevision] The 1-based minimum revision to include in the events (inclusive).
|
|
456
|
-
* @param {number} [maxRevision] The 1-based maximum revision to include in the events (inclusive).
|
|
651
|
+
* @param {number} [minRevision=1] The 1-based minimum revision to include in the events (inclusive).
|
|
652
|
+
* @param {number} [maxRevision=-1] The 1-based maximum revision to include in the events (inclusive).
|
|
653
|
+
* @param {function|object|null} [predicate] Optional matcher (see {@link EventStream}).
|
|
654
|
+
* @param {boolean} [raw=false] If true, return NDJSON buffers.
|
|
457
655
|
* @returns {EventStream} The joined event stream for all streams of the given category.
|
|
458
656
|
* @throws {Error} If no stream for this category exists.
|
|
459
657
|
*/
|
|
460
|
-
getEventStreamForCategory(categoryName, minRevision = 1, maxRevision = -1) {
|
|
658
|
+
getEventStreamForCategory(categoryName, minRevision = 1, maxRevision = -1, predicate = null, raw = false) {
|
|
659
|
+
({ predicate, raw } = normalizePredicateRaw(predicate, raw));
|
|
461
660
|
if (categoryName in this.streams) {
|
|
462
|
-
return this.getEventStream(categoryName, minRevision, maxRevision);
|
|
661
|
+
return this.getEventStream(categoryName, minRevision, maxRevision, predicate, raw);
|
|
463
662
|
}
|
|
464
|
-
const categoryStreams = Object.keys(this.streams).filter(streamName =>
|
|
663
|
+
const categoryStreams = Object.keys(this.streams).filter(streamName =>
|
|
664
|
+
streamName.startsWith(categoryName + '-') ||
|
|
665
|
+
streamName.startsWith(categoryName + '/')
|
|
666
|
+
);
|
|
465
667
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
return this.fromStreams(categoryName, categoryStreams, minRevision, maxRevision);
|
|
668
|
+
assert(categoryStreams.length > 0, `No streams for category '${categoryName}' exist.`);
|
|
669
|
+
|
|
670
|
+
return this.fromStreams(categoryName, categoryStreams, minRevision, maxRevision, predicate, raw);
|
|
470
671
|
}
|
|
471
672
|
|
|
472
673
|
/**
|
|
@@ -475,16 +676,21 @@ class EventStore extends events.EventEmitter {
|
|
|
475
676
|
* @api
|
|
476
677
|
* @param {string} streamName The name of the stream to create.
|
|
477
678
|
* @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.
|
|
679
|
+
* @param {boolean} [reindex=true] Whether to scan existing documents and populate the new index. Set to false when it is known that no existing documents can match the matcher (e.g. when creating a brand-new write stream).
|
|
478
680
|
* @returns {EventStream} The EventStream with all existing events matching the matcher.
|
|
479
681
|
* @throws {Error} If a stream with that name already exists.
|
|
480
682
|
* @throws {Error} If the stream could not be created.
|
|
481
683
|
*/
|
|
482
|
-
createEventStream(streamName, matcher) {
|
|
684
|
+
createEventStream(streamName, matcher, reindex = true) {
|
|
483
685
|
assert(!(this.storage instanceof ReadOnlyStorage), 'The storage was opened in read-only mode. Can not create new stream on it.');
|
|
484
686
|
assert(!(streamName in this.streams), 'Can not recreate stream!');
|
|
485
687
|
|
|
486
688
|
const streamIndexName = 'stream-' + streamName;
|
|
487
|
-
|
|
689
|
+
if (streamName.includes('/')) {
|
|
690
|
+
const subDir = path.join(this.streamsDirectory, this.storeName + '.stream-' + path.dirname(streamName));
|
|
691
|
+
ensureDirectory(subDir);
|
|
692
|
+
}
|
|
693
|
+
const index = this.storage.ensureIndex(streamIndexName, matcher, reindex);
|
|
488
694
|
assert(index !== null, `Error creating stream index ${streamName}.`);
|
|
489
695
|
|
|
490
696
|
// deepcode ignore PrototypePollutionFunctionParams: streams is a Map
|
|
@@ -543,7 +749,7 @@ class EventStore extends events.EventEmitter {
|
|
|
543
749
|
fs.renameSync(index.fileName, closedFileName);
|
|
544
750
|
|
|
545
751
|
// Remove from secondary indexes so that new writes are no longer indexed into this stream
|
|
546
|
-
|
|
752
|
+
this.storage.removeSecondaryIndex(indexName);
|
|
547
753
|
|
|
548
754
|
// Reopen the renamed index for read access, outside the secondary indexes write path
|
|
549
755
|
const closedIndexName = indexName + '.closed';
|
|
@@ -555,43 +761,93 @@ class EventStore extends events.EventEmitter {
|
|
|
555
761
|
}
|
|
556
762
|
|
|
557
763
|
/**
|
|
558
|
-
* Get a durable consumer for the given stream
|
|
764
|
+
* Get a durable consumer for the given stream, or look up an existing consumer by identifier.
|
|
765
|
+
*
|
|
766
|
+
* When called with a single argument, returns the running consumer registered under that
|
|
767
|
+
* identifier, or `null` if none is found — useful for read endpoints that need the live
|
|
768
|
+
* in-memory instance without creating a new one.
|
|
769
|
+
*
|
|
770
|
+
* When called with two or more arguments, creates (or re-uses) a Consumer for the given
|
|
771
|
+
* stream and identifier, registers it in `this.consumers`, and returns it.
|
|
559
772
|
*
|
|
560
|
-
* @param {string}
|
|
561
|
-
* @param {string} identifier The unique identifying name of this consumer.
|
|
773
|
+
* @param {string} streamNameOrIdentifier The stream name, or the consumer identifier when used as a registry lookup.
|
|
774
|
+
* @param {string} [identifier] The unique identifying name of this consumer. Omit for registry-only lookup.
|
|
562
775
|
* @param {object} [initialState] The initial state of the consumer.
|
|
563
776
|
* @param {number} [since] The stream revision to start consuming from.
|
|
564
|
-
* @returns {Consumer} A durable consumer
|
|
777
|
+
* @returns {Consumer|null} A durable consumer, or `null` when looking up by identifier and none is registered.
|
|
565
778
|
*/
|
|
566
|
-
getConsumer(
|
|
779
|
+
getConsumer(streamNameOrIdentifier, identifier, initialState = {}, since = 0) {
|
|
780
|
+
if (identifier === undefined) {
|
|
781
|
+
return this.consumers.get(streamNameOrIdentifier) ?? null;
|
|
782
|
+
}
|
|
783
|
+
const streamName = streamNameOrIdentifier;
|
|
784
|
+
if (this.consumers.has(identifier)) {
|
|
785
|
+
return this.consumers.get(identifier);
|
|
786
|
+
}
|
|
567
787
|
const consumer = new Consumer(this.storage, streamName === '_all' ? '_all' : 'stream-' + streamName, identifier, initialState, since);
|
|
568
788
|
consumer.streamName = streamName;
|
|
789
|
+
this.consumers.set(identifier, consumer);
|
|
569
790
|
return consumer;
|
|
570
791
|
}
|
|
571
792
|
|
|
572
793
|
/**
|
|
573
|
-
* Scan the existing consumers on this EventStore and asynchronously
|
|
574
|
-
*
|
|
794
|
+
* Scan the existing consumers on this EventStore and asynchronously invoke a callback with the parsed list.
|
|
795
|
+
*
|
|
796
|
+
* Each consumer entry provides `{ name, stream, identifier }` parsed from the on-disk filename.
|
|
797
|
+
* Pass `autoStart = true` to eagerly open every discovered consumer and register it in
|
|
798
|
+
* `this.consumers` so that it is immediately available for registry lookups.
|
|
799
|
+
*
|
|
800
|
+
* @param {function(error: Error|null, consumers: Array<{name: string, stream: string, identifier: string}>)} callback
|
|
801
|
+
* @param {boolean} [autoStart=false] When true, calls `getConsumer(stream, identifier)` for each discovered consumer.
|
|
575
802
|
*/
|
|
576
|
-
scanConsumers(callback) {
|
|
803
|
+
scanConsumers(callback, autoStart = false) {
|
|
577
804
|
const consumersPath = path.join(this.storage.indexDirectory, 'consumers');
|
|
578
805
|
if (!fs.existsSync(consumersPath)) {
|
|
579
806
|
callback(null, []);
|
|
580
807
|
return;
|
|
581
808
|
}
|
|
582
809
|
const regex = new RegExp(`^${this.storage.storageFile}\\.([^.]*\\..*)$`);
|
|
583
|
-
const
|
|
584
|
-
scanForFiles(consumersPath, regex,
|
|
810
|
+
const consumerNames = [];
|
|
811
|
+
scanForFiles(consumersPath, regex, consumerNames.push.bind(consumerNames), /* istanbul ignore next */ (err) => {
|
|
585
812
|
if (err) {
|
|
586
813
|
return callback(err, []);
|
|
587
814
|
}
|
|
815
|
+
const consumers = consumerNames.map(name => {
|
|
816
|
+
const splitIndex = name.lastIndexOf('.');
|
|
817
|
+
const indexName = name.slice(0, splitIndex);
|
|
818
|
+
const identifier = name.slice(splitIndex + 1);
|
|
819
|
+
const stream = parseStreamFromIndexName(indexName);
|
|
820
|
+
return { name, stream, identifier };
|
|
821
|
+
});
|
|
822
|
+
if (autoStart) {
|
|
823
|
+
for (const { stream, identifier } of consumers) {
|
|
824
|
+
this.getConsumer(stream, identifier);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
588
827
|
callback(null, consumers);
|
|
589
828
|
});
|
|
590
829
|
}
|
|
591
830
|
}
|
|
592
831
|
|
|
832
|
+
function parseStreamFromIndexName(indexName) {
|
|
833
|
+
if (indexName === '_all') {
|
|
834
|
+
return '_all';
|
|
835
|
+
}
|
|
836
|
+
if (indexName.startsWith('stream-')) {
|
|
837
|
+
return indexName.slice(7);
|
|
838
|
+
}
|
|
839
|
+
return indexName;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function normalizePredicateRaw(predicate, raw) {
|
|
843
|
+
if (typeof predicate === 'boolean' && raw === false) {
|
|
844
|
+
return { predicate: null, raw: predicate };
|
|
845
|
+
}
|
|
846
|
+
return { predicate, raw };
|
|
847
|
+
}
|
|
848
|
+
|
|
593
849
|
EventStore.Storage = Storage;
|
|
594
850
|
EventStore.Index = Index;
|
|
595
851
|
|
|
596
852
|
export default EventStore;
|
|
597
|
-
export { ExpectedVersion, OptimisticConcurrencyError, LOCK_THROW, LOCK_RECLAIM };
|
|
853
|
+
export { ExpectedVersion, OptimisticConcurrencyError, CommitCondition, LOCK_THROW, LOCK_RECLAIM };
|