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