event-storage 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/index.js +1 -1
- package/package.json +2 -1
- package/src/Consumer.js +2 -1
- package/src/EventStore.js +212 -38
- package/src/EventStream.js +28 -4
- package/src/Index/WritableIndex.js +3 -1
- package/src/IndexMatcher.js +205 -0
- package/src/JoinEventStream.js +35 -21
- package/src/Partition/WritablePartition.js +3 -1
- package/src/PartitionPool.js +149 -0
- package/src/Storage/ReadOnlyStorage.js +2 -2
- package/src/Storage/ReadableStorage.js +138 -46
- package/src/Storage/WritableStorage.js +45 -27
- package/src/fsUtil.js +123 -0
- package/src/metadataUtil.js +49 -2
- package/src/util.js +15 -66
|
@@ -4,10 +4,28 @@ import events from 'events';
|
|
|
4
4
|
import Partition, { ReadOnly as ReadOnlyPartition } from '../Partition.js';
|
|
5
5
|
import Index, { ReadOnly as ReadOnlyIndex } from '../Index.js';
|
|
6
6
|
import { assert, wrapAndCheck, kWayMerge } from '../util.js';
|
|
7
|
+
import { scanForFiles } from '../fsUtil.js';
|
|
7
8
|
import { createHmac, matches, buildMetadataForMatcher } from '../metadataUtil.js';
|
|
9
|
+
import IndexMatcher from '../IndexMatcher.js';
|
|
10
|
+
import PartitionPool from '../PartitionPool.js';
|
|
8
11
|
|
|
9
12
|
const DEFAULT_READ_BUFFER_SIZE = 4 * 1024;
|
|
10
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Default ordered list of document property paths used as discriminant keys when
|
|
16
|
+
* classifying object matchers into the fast-lookup table. Each path may use
|
|
17
|
+
* dot-notation for nested access (e.g. `'payload.type'`). The first path that
|
|
18
|
+
* resolves to a scalar value in a given matcher wins; remaining paths are not
|
|
19
|
+
* examined for that matcher.
|
|
20
|
+
*/
|
|
21
|
+
const DEFAULT_MATCHER_PROPERTIES = ['stream', 'payload.type'];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Default maximum number of partition file descriptors kept open simultaneously.
|
|
25
|
+
* Partitions beyond this limit are evicted using LRU order. 0 disables the limit.
|
|
26
|
+
*/
|
|
27
|
+
const DEFAULT_MAX_OPEN_PARTITIONS = 1024;
|
|
28
|
+
|
|
11
29
|
/**
|
|
12
30
|
* Reverses the items of an iterable
|
|
13
31
|
* @param {Generator|Iterable} iterator
|
|
@@ -43,6 +61,13 @@ class ReadableStorage extends events.EventEmitter {
|
|
|
43
61
|
* @param {object} [config.indexOptions] An options object that should be passed to all indexes on construction.
|
|
44
62
|
* @param {string} [config.hmacSecret] A private key that is used to verify matchers retrieved from indexes.
|
|
45
63
|
* @param {object} [config.metadata] A metadata object to be stored in all partitions belonging to this storage.
|
|
64
|
+
* @param {string[]} [config.matcherProperties] Ordered list of document property paths (dot-notation) used as
|
|
65
|
+
* discriminant keys for the fast secondary-index lookup table. Only the first property that resolves to a scalar
|
|
66
|
+
* value inside a given object matcher is used; the rest are checked via the full `matches()` fallback.
|
|
67
|
+
* Default: `['stream', 'payload.type']`.
|
|
68
|
+
* @param {number} [config.maxOpenPartitions] Maximum number of partition file descriptors kept open at one time.
|
|
69
|
+
* When the limit is reached the least-recently-used partition is closed to make room. 0 disables the limit.
|
|
70
|
+
* Default: 1024.
|
|
46
71
|
*/
|
|
47
72
|
constructor(storageName = 'storage', config = {}) {
|
|
48
73
|
super();
|
|
@@ -58,7 +83,9 @@ class ReadableStorage extends events.EventEmitter {
|
|
|
58
83
|
indexFile: this.storageFile + '.index',
|
|
59
84
|
indexOptions: {},
|
|
60
85
|
hmacSecret: '',
|
|
61
|
-
metadata: {}
|
|
86
|
+
metadata: {},
|
|
87
|
+
matcherProperties: DEFAULT_MATCHER_PROPERTIES,
|
|
88
|
+
maxOpenPartitions: DEFAULT_MAX_OPEN_PARTITIONS
|
|
62
89
|
};
|
|
63
90
|
config = Object.assign(defaults, config);
|
|
64
91
|
this.serializer = config.serializer;
|
|
@@ -67,7 +94,13 @@ class ReadableStorage extends events.EventEmitter {
|
|
|
67
94
|
|
|
68
95
|
this.dataDirectory = path.resolve(config.dataDirectory);
|
|
69
96
|
|
|
70
|
-
|
|
97
|
+
const partitionDefaults = { readBufferSize: DEFAULT_READ_BUFFER_SIZE };
|
|
98
|
+
this.partitionConfig = Object.assign(partitionDefaults, config);
|
|
99
|
+
this.partitions = new PartitionPool(config.maxOpenPartitions);
|
|
100
|
+
|
|
101
|
+
// initialized: null = not started (or scan cancelled), false = in progress, true = done
|
|
102
|
+
this.initialized = null;
|
|
103
|
+
|
|
71
104
|
this.initializeIndexes(config);
|
|
72
105
|
}
|
|
73
106
|
|
|
@@ -111,6 +144,9 @@ class ReadableStorage extends events.EventEmitter {
|
|
|
111
144
|
this.index = index;
|
|
112
145
|
this.secondaryIndexes = {};
|
|
113
146
|
this.readonlyIndexes = {};
|
|
147
|
+
|
|
148
|
+
/** Fast secondary-index lookup — classifies matchers for O(1) candidate resolution on write. */
|
|
149
|
+
this.indexMatcher = new IndexMatcher(config.matcherProperties);
|
|
114
150
|
}
|
|
115
151
|
|
|
116
152
|
/**
|
|
@@ -122,56 +158,91 @@ class ReadableStorage extends events.EventEmitter {
|
|
|
122
158
|
}
|
|
123
159
|
|
|
124
160
|
/**
|
|
125
|
-
* Scan
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* @private
|
|
129
|
-
* @param {object} config The configuration object containing options for the partitions.
|
|
130
|
-
* @returns void
|
|
161
|
+
* Scan partitions and secondary index files; emit 'index-created' for each found index.
|
|
162
|
+
* @param {function} done Called when both scans finish.
|
|
131
163
|
*/
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
164
|
+
scanFiles(done) {
|
|
165
|
+
const escaped = this.storageFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
166
|
+
const partitionPattern = new RegExp(`^(${escaped}.*)$`);
|
|
167
|
+
scanForFiles(this.dataDirectory, partitionPattern, (file) => {
|
|
168
|
+
if (file.endsWith('.index') || file.endsWith('.branch') || file.endsWith('.lock')) return;
|
|
169
|
+
const partition = this.createPartition(file, this.partitionConfig);
|
|
170
|
+
this.partitions.add(partition.id, partition);
|
|
171
|
+
}, (partErr) => {
|
|
172
|
+
/* istanbul ignore if */
|
|
173
|
+
if (partErr) throw partErr;
|
|
138
174
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (file.substr(-6) === '.index') continue;
|
|
142
|
-
if (file.substr(-7) === '.branch') continue;
|
|
143
|
-
if (file.substr(-5) === '.lock') continue;
|
|
144
|
-
if (file.substr(0, this.storageFile.length) !== this.storageFile) continue;
|
|
175
|
+
// Scan was cancelled by close() between the two scan phases.
|
|
176
|
+
if (this.initialized === null) return;
|
|
145
177
|
|
|
146
|
-
|
|
147
|
-
this.
|
|
148
|
-
|
|
178
|
+
// No secondary indexes exist yet — nothing to scan.
|
|
179
|
+
if (!fs.existsSync(this.indexDirectory)) {
|
|
180
|
+
return done();
|
|
181
|
+
}
|
|
182
|
+
const indexPattern = new RegExp(`^${escaped}\\.(.+)\\.index$`);
|
|
183
|
+
scanForFiles(this.indexDirectory, indexPattern, (name) => {
|
|
184
|
+
this.emit('index-created', name);
|
|
185
|
+
}, (indexErr) => {
|
|
186
|
+
// The directory could disappear between existsSync and readdir (e.g. test cleanup).
|
|
187
|
+
/* istanbul ignore if */
|
|
188
|
+
if (indexErr && indexErr.code !== 'ENOENT') throw indexErr;
|
|
189
|
+
done();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
149
192
|
}
|
|
150
193
|
|
|
151
194
|
/**
|
|
152
|
-
*
|
|
153
|
-
* Will emit an 'opened' event if finished.
|
|
195
|
+
* Only the primary index is opened eagerly; secondary indexes open on demand.
|
|
154
196
|
*
|
|
155
|
-
* @
|
|
156
|
-
* @returns {boolean}
|
|
197
|
+
* @protected
|
|
157
198
|
*/
|
|
158
|
-
|
|
199
|
+
openIndexes() {
|
|
159
200
|
this.index.open();
|
|
201
|
+
}
|
|
160
202
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
203
|
+
/**
|
|
204
|
+
* Open the storage; scans existing partitions and indexes asynchronously on first open.
|
|
205
|
+
* Re-opens after `close()` are synchronous.
|
|
206
|
+
* Will emit an `'opened'` event when finished.
|
|
207
|
+
*
|
|
208
|
+
* @api
|
|
209
|
+
* @param {function(): void} [callback] Called after indexes open, before `'opened'` is emitted.
|
|
210
|
+
* Can be used as a synchronous alternative to listening to the `'opened'` event.
|
|
211
|
+
* @returns {boolean}
|
|
212
|
+
*/
|
|
213
|
+
open(callback) {
|
|
214
|
+
if (this.initialized === true) {
|
|
215
|
+
this.openIndexes();
|
|
216
|
+
callback?.();
|
|
217
|
+
this.emit('opened');
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
if (this.initialized === false) {
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
this.initialized = false;
|
|
224
|
+
this.scanFiles(() => {
|
|
225
|
+
// Guard: close() while scanning resets initialized to null.
|
|
226
|
+
if (this.initialized === null) return;
|
|
227
|
+
this.initialized = true;
|
|
228
|
+
this.openIndexes();
|
|
229
|
+
callback?.();
|
|
230
|
+
this.emit('opened');
|
|
231
|
+
});
|
|
164
232
|
return true;
|
|
165
233
|
}
|
|
166
234
|
|
|
167
235
|
/**
|
|
168
|
-
* Close the storage and
|
|
236
|
+
* Close the storage and free up all resources.
|
|
169
237
|
* Will emit a 'closed' event when finished.
|
|
170
238
|
*
|
|
171
239
|
* @api
|
|
172
|
-
* @returns void
|
|
173
240
|
*/
|
|
174
241
|
close() {
|
|
242
|
+
// Cancel in-progress scan so the callback does not re-open after an explicit close.
|
|
243
|
+
if (this.initialized === false) {
|
|
244
|
+
this.initialized = null;
|
|
245
|
+
}
|
|
175
246
|
this.index.close();
|
|
176
247
|
this.forEachSecondaryIndex(index => index.close());
|
|
177
248
|
for (let index of Object.values(this.readonlyIndexes)) {
|
|
@@ -182,20 +253,17 @@ class ReadableStorage extends events.EventEmitter {
|
|
|
182
253
|
}
|
|
183
254
|
|
|
184
255
|
/**
|
|
185
|
-
* Get a partition
|
|
186
|
-
* If a partition with the given name does not exist, a new one will be created.
|
|
256
|
+
* Get a partition by its id.
|
|
187
257
|
* If a partition with the given id does not exist, an error is thrown.
|
|
188
258
|
*
|
|
189
259
|
* @protected
|
|
190
|
-
* @param {string
|
|
260
|
+
* @param {number|string} partitionIdentifier The partition Id
|
|
191
261
|
* @returns {ReadablePartition}
|
|
192
|
-
* @throws {Error} If
|
|
262
|
+
* @throws {Error} If no such partition exists.
|
|
193
263
|
*/
|
|
194
264
|
getPartition(partitionIdentifier) {
|
|
195
|
-
assert(
|
|
196
|
-
|
|
197
|
-
this.partitions[partitionIdentifier].open();
|
|
198
|
-
return this.partitions[partitionIdentifier];
|
|
265
|
+
assert(this.partitions.has(partitionIdentifier), `Partition #${partitionIdentifier} does not exist.`);
|
|
266
|
+
return this.partitions.open(partitionIdentifier);
|
|
199
267
|
}
|
|
200
268
|
|
|
201
269
|
/**
|
|
@@ -359,10 +427,27 @@ class ReadableStorage extends events.EventEmitter {
|
|
|
359
427
|
const metadata = buildMetadataForMatcher(matcher, this.hmac);
|
|
360
428
|
let { index } = this.secondaryIndexes[name] = this.createIndex(indexName, Object.assign({}, this.indexOptions, { metadata }));
|
|
361
429
|
|
|
430
|
+
// Register the actual stored matcher (may have been reconstructed from metadata by WritableStorage.createIndex).
|
|
431
|
+
this.indexMatcher.add(name, this.secondaryIndexes[name].matcher);
|
|
432
|
+
|
|
362
433
|
index.open();
|
|
363
434
|
return index;
|
|
364
435
|
}
|
|
365
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Remove a secondary index from the write path and the matcher lookup table.
|
|
439
|
+
*
|
|
440
|
+
* @api
|
|
441
|
+
* @param {string} name The secondary index name to remove.
|
|
442
|
+
*/
|
|
443
|
+
removeSecondaryIndex(name) {
|
|
444
|
+
const entry = this.secondaryIndexes[name];
|
|
445
|
+
if (entry) {
|
|
446
|
+
this.indexMatcher.remove(name);
|
|
447
|
+
delete this.secondaryIndexes[name];
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
366
451
|
/**
|
|
367
452
|
* Iterate documents across all partitions in sequenceNumber order using a k-way merge.
|
|
368
453
|
* Opens any closed partition automatically.
|
|
@@ -448,6 +533,9 @@ class ReadableStorage extends events.EventEmitter {
|
|
|
448
533
|
/**
|
|
449
534
|
* Helper method to iterate over all secondary indexes.
|
|
450
535
|
*
|
|
536
|
+
* When `matchDocument` is provided, `this.indexMatcher.forEachMatch()` is used to
|
|
537
|
+
* efficiently find only the matching indexes via the discriminant lookup table.
|
|
538
|
+
*
|
|
451
539
|
* @protected
|
|
452
540
|
* @param {function(ReadableIndex, string)} iterationHandler
|
|
453
541
|
* @param {object} [matchDocument] If supplied, only indexes the document matches on will be iterated.
|
|
@@ -458,11 +546,17 @@ class ReadableStorage extends events.EventEmitter {
|
|
|
458
546
|
return;
|
|
459
547
|
}
|
|
460
548
|
|
|
461
|
-
|
|
462
|
-
|
|
549
|
+
if (!matchDocument) {
|
|
550
|
+
// No document filter: iterate all secondary indexes unconditionally.
|
|
551
|
+
for (const indexName of Object.keys(this.secondaryIndexes)) {
|
|
463
552
|
iterationHandler(this.secondaryIndexes[indexName].index, indexName);
|
|
464
553
|
}
|
|
554
|
+
return;
|
|
465
555
|
}
|
|
556
|
+
|
|
557
|
+
this.indexMatcher.forEachMatch(matchDocument, indexName => {
|
|
558
|
+
iterationHandler(this.secondaryIndexes[indexName].index, indexName);
|
|
559
|
+
});
|
|
466
560
|
}
|
|
467
561
|
|
|
468
562
|
/**
|
|
@@ -477,9 +571,7 @@ class ReadableStorage extends events.EventEmitter {
|
|
|
477
571
|
return;
|
|
478
572
|
}
|
|
479
573
|
|
|
480
|
-
|
|
481
|
-
iterationHandler(this.partitions[partition]);
|
|
482
|
-
}
|
|
574
|
+
this.partitions.forEach(iterationHandler);
|
|
483
575
|
}
|
|
484
576
|
|
|
485
577
|
}
|
|
@@ -3,7 +3,8 @@ import path from 'path';
|
|
|
3
3
|
import WritablePartition from '../Partition/WritablePartition.js';
|
|
4
4
|
import WritableIndex, { Entry as WritableIndexEntry } from '../Index/WritableIndex.js';
|
|
5
5
|
import ReadableStorage from './ReadableStorage.js';
|
|
6
|
-
import { assert
|
|
6
|
+
import { assert } from '../util.js';
|
|
7
|
+
import { ensureDirectory } from '../fsUtil.js';
|
|
7
8
|
import { matches, buildMetadataForMatcher, buildMatcherFromMetadata } from '../metadataUtil.js';
|
|
8
9
|
|
|
9
10
|
const DEFAULT_WRITE_BUFFER_SIZE = 16 * 1024;
|
|
@@ -61,24 +62,30 @@ class WritableStorage extends ReadableStorage {
|
|
|
61
62
|
super(storageName, config);
|
|
62
63
|
|
|
63
64
|
this.lockFile = path.resolve(this.dataDirectory, this.storageFile + '.lock');
|
|
64
|
-
|
|
65
|
-
this.unlock();
|
|
66
|
-
}
|
|
65
|
+
this._lockMode = config.lock;
|
|
67
66
|
this.partitioner = config.partitioner;
|
|
68
67
|
}
|
|
69
68
|
|
|
70
69
|
/**
|
|
71
70
|
* @inheritDoc
|
|
71
|
+
* Acquires the write lock synchronously.
|
|
72
|
+
* For LOCK_RECLAIM, removes any orphaned lock before trying to acquire our own; torn-write
|
|
73
|
+
* repair runs after the primary index is open, before `'opened'` is emitted.
|
|
74
|
+
*
|
|
72
75
|
* @returns {boolean}
|
|
73
76
|
* @throws {StorageLockedError} If this storage is locked by another process.
|
|
74
77
|
*/
|
|
75
|
-
open() {
|
|
78
|
+
open(callback) {
|
|
79
|
+
const needsRepair = this._lockMode === LOCK_RECLAIM && this.unlock();
|
|
80
|
+
|
|
76
81
|
if (!this.lock()) {
|
|
77
82
|
return true;
|
|
78
83
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
|
|
85
|
+
const onOpen = needsRepair
|
|
86
|
+
? () => { this.checkTornWrites(); callback?.(); }
|
|
87
|
+
: callback;
|
|
88
|
+
return super.open(onOpen);
|
|
82
89
|
}
|
|
83
90
|
|
|
84
91
|
/**
|
|
@@ -170,6 +177,8 @@ class WritableStorage extends ReadableStorage {
|
|
|
170
177
|
}
|
|
171
178
|
|
|
172
179
|
this.forEachPartition(partition => partition.close());
|
|
180
|
+
// Partitions were closed directly (bypassing the pool), so reset the open-handle tracking.
|
|
181
|
+
this.partitions.clearOpenHandles();
|
|
173
182
|
}
|
|
174
183
|
|
|
175
184
|
/**
|
|
@@ -234,19 +243,21 @@ class WritableStorage extends ReadableStorage {
|
|
|
234
243
|
* Unlock this storage, no matter if it was previously locked by this writer.
|
|
235
244
|
* Only use this if you are sure there is no other process still having a writer open.
|
|
236
245
|
* Current implementation just deletes a lock file that is named like the storage.
|
|
246
|
+
* @returns {boolean} True if an orphaned lock from another process was removed.
|
|
237
247
|
*/
|
|
238
248
|
unlock() {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
249
|
+
const lockExists = fs.existsSync(this.lockFile);
|
|
250
|
+
const orphaned = lockExists && !this.locked;
|
|
251
|
+
if (lockExists) {
|
|
243
252
|
fs.rmdirSync(this.lockFile);
|
|
244
253
|
}
|
|
245
254
|
this.locked = false;
|
|
255
|
+
return orphaned;
|
|
246
256
|
}
|
|
247
257
|
|
|
248
258
|
/**
|
|
249
259
|
* @inheritDoc
|
|
260
|
+
* Unlocks the storage, then delegates to the parent close().
|
|
250
261
|
*/
|
|
251
262
|
close() {
|
|
252
263
|
if (this.locked) {
|
|
@@ -305,6 +316,8 @@ class WritableStorage extends ReadableStorage {
|
|
|
305
316
|
* If a partition with the given name does not exist, a new one will be created.
|
|
306
317
|
* If a partition with the given id does not exist, an error is thrown.
|
|
307
318
|
*
|
|
319
|
+
* Partition opening and LRU tracking are delegated to `super.getPartition()`.
|
|
320
|
+
*
|
|
308
321
|
* @protected
|
|
309
322
|
* @param {string|number} partitionIdentifier The partition name or the partition Id
|
|
310
323
|
* @returns {ReadablePartition}
|
|
@@ -315,15 +328,16 @@ class WritableStorage extends ReadableStorage {
|
|
|
315
328
|
const partitionShortName = partitionIdentifier;
|
|
316
329
|
const partitionName = this.storageFile + (partitionIdentifier.length ? '.' + partitionIdentifier : '');
|
|
317
330
|
partitionIdentifier = WritablePartition.idFor(partitionName);
|
|
318
|
-
if (!this.partitions
|
|
331
|
+
if (!this.partitions.has(partitionIdentifier)) {
|
|
319
332
|
const partitionConfig = typeof this.partitionConfig.metadata === 'function'
|
|
320
333
|
? { ...this.partitionConfig, metadata: this.partitionConfig.metadata(partitionShortName) }
|
|
321
334
|
: this.partitionConfig;
|
|
322
|
-
|
|
335
|
+
if (partitionName.includes('/')) {
|
|
336
|
+
ensureDirectory(path.join(this.dataDirectory, path.dirname(partitionName)));
|
|
337
|
+
}
|
|
338
|
+
this.partitions.add(partitionIdentifier, this.createPartition(partitionName, partitionConfig));
|
|
323
339
|
this.emit('partition-created', partitionIdentifier);
|
|
324
340
|
}
|
|
325
|
-
this.partitions[partitionIdentifier].open();
|
|
326
|
-
return this.partitions[partitionIdentifier];
|
|
327
341
|
}
|
|
328
342
|
return super.getPartition(partitionIdentifier);
|
|
329
343
|
}
|
|
@@ -366,10 +380,11 @@ class WritableStorage extends ReadableStorage {
|
|
|
366
380
|
* @api
|
|
367
381
|
* @param {string} name The index name.
|
|
368
382
|
* @param {Matcher} [matcher] An object that describes the document properties that need to match to add it this index or a function that receives a document and returns true if the document should be indexed.
|
|
383
|
+
* @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.
|
|
369
384
|
* @returns {ReadableIndex} The index containing all documents that match the query.
|
|
370
385
|
* @throws {Error} if the index doesn't exist yet and no matcher was specified.
|
|
371
386
|
*/
|
|
372
|
-
ensureIndex(name, matcher) {
|
|
387
|
+
ensureIndex(name, matcher, reindex = true) {
|
|
373
388
|
if (name === '_all') {
|
|
374
389
|
return this.index;
|
|
375
390
|
}
|
|
@@ -386,18 +401,21 @@ class WritableStorage extends ReadableStorage {
|
|
|
386
401
|
|
|
387
402
|
const metadata = buildMetadataForMatcher(matcher, this.hmac);
|
|
388
403
|
const { index } = this.createIndex(indexName, Object.assign({}, this.indexOptions, { metadata }));
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
404
|
+
if (reindex) {
|
|
405
|
+
try {
|
|
406
|
+
this.forEachDocument((document, indexEntry) => {
|
|
407
|
+
if (matches(document, matcher)) {
|
|
408
|
+
index.add(indexEntry);
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
} catch (e) {
|
|
412
|
+
index.destroy();
|
|
413
|
+
throw e;
|
|
414
|
+
}
|
|
398
415
|
}
|
|
399
416
|
|
|
400
417
|
this.secondaryIndexes[name] = { index, matcher };
|
|
418
|
+
this.indexMatcher.add(name, matcher);
|
|
401
419
|
this.emit('index-created', name);
|
|
402
420
|
return index;
|
|
403
421
|
}
|
|
@@ -423,7 +441,7 @@ class WritableStorage extends ReadableStorage {
|
|
|
423
441
|
*/
|
|
424
442
|
forEachDistinctPartitionOf(entries, iterationHandler) {
|
|
425
443
|
const partitions = [];
|
|
426
|
-
const numPartitions =
|
|
444
|
+
const numPartitions = this.partitions.count;
|
|
427
445
|
for (let entry of entries) {
|
|
428
446
|
if (partitions.indexOf(entry.partition) >= 0) {
|
|
429
447
|
continue;
|
package/src/fsUtil.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { mkdirpSync } from 'mkdirp';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Ensure that the given directory exists.
|
|
7
|
+
* @param {string} dirName
|
|
8
|
+
* @return {boolean} true if the directory existed already
|
|
9
|
+
*/
|
|
10
|
+
function ensureDirectory(dirName) {
|
|
11
|
+
if (!fs.existsSync(dirName)) {
|
|
12
|
+
try {
|
|
13
|
+
mkdirpSync(dirName);
|
|
14
|
+
} catch (e) {
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Invoke `onEach` if `relativePath` matches `regexPattern`, passing the first capture group or the full match.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} relativePath
|
|
25
|
+
* @param {RegExp} regexPattern
|
|
26
|
+
* @param {function(string)} onEach
|
|
27
|
+
*/
|
|
28
|
+
function visitMatchingPath(relativePath, regexPattern, onEach) {
|
|
29
|
+
const match = relativePath.match(regexPattern);
|
|
30
|
+
if (match !== null) {
|
|
31
|
+
onEach(match[1] !== undefined ? match[1] : match[0]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Classify `entries` into matching files (visited via `onEach`) and subdirectory names (returned).
|
|
37
|
+
*
|
|
38
|
+
* @param {fs.Dirent[]} entries
|
|
39
|
+
* @param {string} relativePrefix
|
|
40
|
+
* @param {RegExp} regexPattern
|
|
41
|
+
* @param {function(string)} onEach
|
|
42
|
+
* @returns {string[]} names of subdirectory entries
|
|
43
|
+
*/
|
|
44
|
+
function classifyEntries(entries, relativePrefix, regexPattern, onEach) {
|
|
45
|
+
const subdirs = [];
|
|
46
|
+
for (let entry of entries) {
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
subdirs.push(entry.name);
|
|
49
|
+
} else {
|
|
50
|
+
visitMatchingPath(relativePrefix + entry.name, regexPattern, onEach);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return subdirs;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Sequentially scan each name in `subdirs`, calling `done` when all are complete or on first error.
|
|
58
|
+
*
|
|
59
|
+
* @param {string[]} subdirs
|
|
60
|
+
* @param {string} dir
|
|
61
|
+
* @param {string} relativePrefix
|
|
62
|
+
* @param {RegExp} regexPattern
|
|
63
|
+
* @param {function(string)} onEach
|
|
64
|
+
* @param {function(Error?)} done
|
|
65
|
+
*/
|
|
66
|
+
function scanSubdirs(subdirs, dir, relativePrefix, regexPattern, onEach, done) {
|
|
67
|
+
let i = 0;
|
|
68
|
+
function next() {
|
|
69
|
+
if (i >= subdirs.length) return done(null);
|
|
70
|
+
const name = subdirs[i++];
|
|
71
|
+
scanDir(path.join(dir, name), relativePrefix + name + '/', false, regexPattern, onEach, (err) => {
|
|
72
|
+
if (err) return done(err);
|
|
73
|
+
next();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
next();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Asynchronously scan one directory level, then recurse into subdirectories sequentially.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} dir
|
|
83
|
+
* @param {string} relativePrefix
|
|
84
|
+
* @param {boolean} isRoot
|
|
85
|
+
* @param {RegExp} regexPattern
|
|
86
|
+
* @param {function(string)} onEach
|
|
87
|
+
* @param {function(Error?)} done
|
|
88
|
+
*/
|
|
89
|
+
function scanDir(dir, relativePrefix, isRoot, regexPattern, onEach, done) {
|
|
90
|
+
fs.readdir(dir, { withFileTypes: true }, (err, entries) => {
|
|
91
|
+
if (err) {
|
|
92
|
+
/* istanbul ignore next */
|
|
93
|
+
if (!isRoot && err.code === 'ENOENT') return done(null);
|
|
94
|
+
return done(err);
|
|
95
|
+
}
|
|
96
|
+
const subdirs = classifyEntries(entries, relativePrefix, regexPattern, onEach);
|
|
97
|
+
scanSubdirs(subdirs, dir, relativePrefix, regexPattern, onEach, done);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Scan a directory (and its subdirectories) for files whose relative paths match a regex pattern,
|
|
103
|
+
* calling a callback for each match.
|
|
104
|
+
*
|
|
105
|
+
* The regex is matched against the **relative path from `directory`** (e.g. `eventstore.stream-x/foo.index`),
|
|
106
|
+
* so patterns that capture a path prefix work transparently for both flat and nested layouts.
|
|
107
|
+
*
|
|
108
|
+
* The `onEach` callback receives the first capturing group of the match (`match[1]`), or the full
|
|
109
|
+
* match (`match[0]`) when no capturing group is defined in the pattern.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} directory The root directory to scan.
|
|
112
|
+
* @param {RegExp} regexPattern The pattern to match relative file paths against.
|
|
113
|
+
* @param {function(string)} onEach Called with the first capturing group (or full match) for each matching path.
|
|
114
|
+
* @param {function(Error?)} onDone Called when the scan is complete, or with an error if one occurred.
|
|
115
|
+
*/
|
|
116
|
+
function scanForFiles(directory, regexPattern, onEach, onDone) {
|
|
117
|
+
scanDir(directory, '', true, regexPattern, onEach, onDone);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export {
|
|
121
|
+
ensureDirectory,
|
|
122
|
+
scanForFiles,
|
|
123
|
+
};
|
package/src/metadataUtil.js
CHANGED
|
@@ -1,4 +1,27 @@
|
|
|
1
1
|
import crypto from 'crypto';
|
|
2
|
+
import { assertEqual } from './util.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build a buffer containing the file magic header and a JSON stringified metadata block, padded to be a multiple of 16 bytes long.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} magic
|
|
8
|
+
* @param {object} metadata
|
|
9
|
+
* @returns {Buffer} A buffer containing the header data
|
|
10
|
+
*/
|
|
11
|
+
function buildMetadataHeader(magic, metadata) {
|
|
12
|
+
assertEqual(magic.length, 8, 'The header magic bytes length is wrong.');
|
|
13
|
+
let metadataString = JSON.stringify(metadata);
|
|
14
|
+
let metadataSize = Buffer.byteLength(metadataString, 'utf8');
|
|
15
|
+
// 8 byte MAGIC, 4 byte metadata size, 1 byte line break
|
|
16
|
+
const pad = (16 - ((8 + 4 + metadataSize + 1) % 16)) % 16;
|
|
17
|
+
metadataString += ' '.repeat(pad) + "\n";
|
|
18
|
+
metadataSize += pad + 1;
|
|
19
|
+
const metadataBuffer = Buffer.allocUnsafe(8 + 4 + metadataSize);
|
|
20
|
+
metadataBuffer.write(magic, 0, 8, 'utf8');
|
|
21
|
+
metadataBuffer.writeUInt32BE(metadataSize, 8);
|
|
22
|
+
metadataBuffer.write(metadataString, 8 + 4, metadataSize, 'utf8');
|
|
23
|
+
return metadataBuffer;
|
|
24
|
+
}
|
|
2
25
|
|
|
3
26
|
/**
|
|
4
27
|
* @param {string} secret The secret to use for calculating further HMACs
|
|
@@ -26,7 +49,11 @@ function matches(document, matcher) {
|
|
|
26
49
|
if (typeof matcher === 'function') return matcher(document);
|
|
27
50
|
|
|
28
51
|
for (let prop of Object.getOwnPropertyNames(matcher)) {
|
|
29
|
-
if (
|
|
52
|
+
if (Array.isArray(matcher[prop])) {
|
|
53
|
+
if (!matcher[prop].includes(document[prop])) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
} else if (typeof matcher[prop] === 'object') {
|
|
30
57
|
if (!matches(document[prop], matcher[prop])) {
|
|
31
58
|
return false;
|
|
32
59
|
}
|
|
@@ -71,9 +98,29 @@ function buildMatcherFromMetadata(matcherMetadata, hmac) {
|
|
|
71
98
|
return matcher;
|
|
72
99
|
}
|
|
73
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Builds a factory function that, given a type string, returns an object matcher for
|
|
103
|
+
* documents whose payload contains that type at the given dot-notation path.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} payloadPath Dot-notation path relative to the event payload (e.g. `'type'`, `'meta.kind'`).
|
|
106
|
+
* @returns {function(string): object} A function `(typeValue) => objectMatcher`.
|
|
107
|
+
*/
|
|
108
|
+
function buildTypeMatcherFn(payloadPath) {
|
|
109
|
+
const parts = payloadPath.split('.');
|
|
110
|
+
return function(typeValue) {
|
|
111
|
+
let obj = typeValue;
|
|
112
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
113
|
+
obj = { [parts[i]]: obj };
|
|
114
|
+
}
|
|
115
|
+
return { payload: obj };
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
74
119
|
export {
|
|
75
120
|
createHmac,
|
|
76
121
|
matches,
|
|
122
|
+
buildMetadataHeader,
|
|
77
123
|
buildMetadataForMatcher,
|
|
78
|
-
buildMatcherFromMetadata
|
|
124
|
+
buildMatcherFromMetadata,
|
|
125
|
+
buildTypeMatcherFn
|
|
79
126
|
};
|