event-storage 0.7.2 → 0.9.1

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.
@@ -13,6 +13,8 @@ class ReadOnlyStorage extends ReadableStorage {
13
13
  */
14
14
  constructor(storageName = 'storage', config = {}) {
15
15
  super(storageName, config);
16
+ this.storageFilesFilter = this.storageFilesFilter.bind(this);
17
+ this.onStorageFileChanged = this.onStorageFileChanged.bind(this);
16
18
  }
17
19
 
18
20
  /**
@@ -33,8 +35,8 @@ class ReadOnlyStorage extends ReadableStorage {
33
35
  */
34
36
  open() {
35
37
  if (!this.watcher) {
36
- this.watcher = new Watcher(this.dataDirectory, this.storageFilesFilter.bind(this));
37
- this.watcher.on('rename', this.onStorageFileChanged.bind(this));
38
+ this.watcher = new Watcher([this.dataDirectory, this.indexDirectory], this.storageFilesFilter);
39
+ this.watcher.on('rename', this.onStorageFileChanged);
38
40
  }
39
41
  return super.open();
40
42
  }
@@ -45,8 +47,9 @@ class ReadOnlyStorage extends ReadableStorage {
45
47
  */
46
48
  onStorageFileChanged(filename) {
47
49
  if (filename.substr(-6) === '.index') {
50
+ const indexName = filename.substr(this.storageFile.length + 1, filename.length - this.storageFile.length - 7);
48
51
  // New indexes are not automatically opened in the reader
49
- this.emit('index-created', filename);
52
+ this.emit('index-created', indexName);
50
53
  return;
51
54
  }
52
55
 
@@ -1,9 +1,10 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const EventEmitter = require('events');
3
+ const events = require('events');
4
4
  const Partition = require('../Partition');
5
5
  const Index = require('../Index');
6
- const { assert, createHmac, matches, wrapAndCheck, buildMetadataForMatcher } = require('../util');
6
+ const { assert, wrapAndCheck, kWayMerge } = require('../util');
7
+ const { createHmac, matches, buildMetadataForMatcher } = require('../metadataUtil');
7
8
 
8
9
  const DEFAULT_READ_BUFFER_SIZE = 4 * 1024;
9
10
 
@@ -19,11 +20,15 @@ function *reverse(iterator) {
19
20
  }
20
21
  }
21
22
 
23
+ /**
24
+ * @typedef {object|function(object):boolean} Matcher
25
+ */
26
+
22
27
  /**
23
28
  * An append-only storage with highly performant positional range scans.
24
29
  * It's highly optimized for an event-store and hence does not support compaction or data-rewrite, nor any querying
25
30
  */
26
- class ReadableStorage extends EventEmitter {
31
+ class ReadableStorage extends events.EventEmitter {
27
32
 
28
33
  /**
29
34
  * @param {string} [storageName] The name of the storage.
@@ -62,15 +67,15 @@ class ReadableStorage extends EventEmitter {
62
67
 
63
68
  this.dataDirectory = path.resolve(config.dataDirectory);
64
69
 
65
- this.initializeIndexes(config);
66
70
  this.scanPartitions(config);
71
+ this.initializeIndexes(config);
67
72
  }
68
73
 
69
74
  /**
70
75
  * @protected
71
76
  * @param {string} name
72
77
  * @param {object} [options]
73
- * @returns {{ index: ReadableIndex, matcher?: object|function }}
78
+ * @returns {{ index: ReadableIndex, matcher?: Matcher }}
74
79
  */
75
80
  createIndex(name, options = {}) {
76
81
  /** @type ReadableIndex */
@@ -105,6 +110,7 @@ class ReadableStorage extends EventEmitter {
105
110
  const { index } = this.createIndex(config.indexFile, this.indexOptions);
106
111
  this.index = index;
107
112
  this.secondaryIndexes = {};
113
+ this.readonlyIndexes = {};
108
114
  }
109
115
 
110
116
  /**
@@ -128,12 +134,13 @@ class ReadableStorage extends EventEmitter {
128
134
  readBufferSize: DEFAULT_READ_BUFFER_SIZE
129
135
  };
130
136
  this.partitionConfig = Object.assign(defaults, config);
131
- this.partitions = {};
137
+ this.partitions = Object.create(null);
132
138
 
133
139
  const files = fs.readdirSync(this.dataDirectory);
134
140
  for (let file of files) {
135
141
  if (file.substr(-6) === '.index') continue;
136
142
  if (file.substr(-7) === '.branch') continue;
143
+ if (file.substr(-5) === '.lock') continue;
137
144
  if (file.substr(0, this.storageFile.length) !== this.storageFile) continue;
138
145
 
139
146
  const partition = this.createPartition(file, this.partitionConfig);
@@ -167,6 +174,9 @@ class ReadableStorage extends EventEmitter {
167
174
  close() {
168
175
  this.index.close();
169
176
  this.forEachSecondaryIndex(index => index.close());
177
+ for (let index of Object.values(this.readonlyIndexes)) {
178
+ index.close();
179
+ }
170
180
  this.forEachPartition(partition => partition.close());
171
181
  this.emit('closed');
172
182
  }
@@ -188,6 +198,19 @@ class ReadableStorage extends EventEmitter {
188
198
  return this.partitions[partitionIdentifier];
189
199
  }
190
200
 
201
+ /**
202
+ * Register a handler that is called before a document is read from a partition.
203
+ * The handler receives the position and the partition metadata and may throw to abort the read.
204
+ * Multiple handlers can be registered; all run on every read in registration order.
205
+ * Equivalent to `storage.on('preRead', hook)`.
206
+ *
207
+ * @api
208
+ * @param {function(number, object): void} hook A function receiving (position, partitionMetadata).
209
+ */
210
+ preRead(hook) {
211
+ this.on('preRead', hook);
212
+ }
213
+
191
214
  /**
192
215
  * @protected
193
216
  * @param {number} partitionId The partition to read from.
@@ -198,6 +221,9 @@ class ReadableStorage extends EventEmitter {
198
221
  */
199
222
  readFrom(partitionId, position, size) {
200
223
  const partition = this.getPartition(partitionId);
224
+ if (this.listenerCount('preRead') > 0) {
225
+ this.emit('preRead', position, partition.metadata);
226
+ }
201
227
  const data = partition.readFrom(position, size);
202
228
  return this.serializer.deserialize(data);
203
229
  }
@@ -232,21 +258,25 @@ class ReadableStorage extends EventEmitter {
232
258
  * @api
233
259
  * @param {number} from The 1-based document number (inclusive) to start reading from.
234
260
  * @param {number} [until] The 1-based document number (inclusive) to read until. Defaults to index.length.
235
- * @param {ReadableIndex} [index] The index to use for finding the documents in the range.
261
+ * @param {ReadableIndex|false} [index] The index to use for finding the documents in the range.
262
+ * Pass `false` to skip the global index and iterate all partitions directly in sequenceNumber order
263
+ * (useful when the global index is unavailable or corrupted).
236
264
  * @returns {Generator<object>} A generator that will read each document in the range one by one.
237
265
  */
238
266
  *readRange(from, until = -1, index = null) {
239
- index = index || this.index;
240
- index.open();
267
+ const lengthSource = index || this.index;
268
+ if (!lengthSource.isOpen()) {
269
+ lengthSource.open();
270
+ }
241
271
 
242
- const readFrom = wrapAndCheck(from, index.length);
243
- const readUntil = wrapAndCheck(until, index.length);
244
- assert(readFrom !== false && readUntil !== false, `Range scan error for range ${from} - ${until}.`);
272
+ const readFrom = wrapAndCheck(from, lengthSource.length);
273
+ const readUntil = wrapAndCheck(until, lengthSource.length);
274
+ assert(readFrom > 0 && readUntil > 0, `Range scan error for range ${from} - ${until}.`);
245
275
 
246
276
  if (readFrom > readUntil) {
247
277
  const batchSize = 10;
248
278
  let batchUntil = readFrom;
249
- while (batchUntil > readUntil) {
279
+ while (batchUntil >= readUntil) {
250
280
  const batchFrom = Math.max(readUntil, batchUntil - batchSize);
251
281
  yield* reverse(this.iterateRange(batchFrom, batchUntil, index));
252
282
  batchUntil = batchFrom - 1;
@@ -259,31 +289,66 @@ class ReadableStorage extends EventEmitter {
259
289
 
260
290
  /**
261
291
  * Iterate all documents in this storage in range from to until inside the index.
292
+ * If index is false, iterates all partitions directly in sequenceNumber order.
262
293
  * @private
263
294
  * @param {number} from
264
295
  * @param {number} until
265
- * @param {ReadableIndex} index
296
+ * @param {ReadableIndex|false|null} index
266
297
  * @returns {Generator<object>}
267
298
  */
268
299
  *iterateRange(from, until, index) {
269
- const entries = index.range(from, until);
300
+ if (index === false) {
301
+ // Explicitly disabled index: iterate all partitions and merge by sequenceNumber.
302
+ // Document header sequenceNumber is 0-based; from/until are 1-based index positions.
303
+ for (const entry of this.iterateDocumentsNoIndex(from - 1, until - 1)) {
304
+ yield entry.document;
305
+ }
306
+ return;
307
+ }
308
+
309
+ const idx = index || this.index;
310
+ const entries = idx.range(from, until);
270
311
  for (let entry of entries) {
271
312
  const document = this.readFrom(entry.partition, entry.position, entry.size);
272
313
  yield document;
273
314
  }
274
315
  }
275
316
 
317
+ /**
318
+ * Open an existing readonly index for reading, without registering it in the secondary indexes write path.
319
+ * Use this for indexes whose files carry a status marker (e.g. `stream-foo.closed.index`).
320
+ *
321
+ * @api
322
+ * @param {string} name The readonly index name (e.g. 'stream-foo.closed').
323
+ * @returns {ReadableIndex}
324
+ * @throws {Error} if the readonly index does not exist.
325
+ */
326
+ openReadonlyIndex(name) {
327
+ if (name in this.readonlyIndexes) {
328
+ return this.readonlyIndexes[name];
329
+ }
330
+ const indexName = this.storageFile + '.' + name + '.index';
331
+ assert(fs.existsSync(path.join(this.indexDirectory, indexName)), `Index "${name}" does not exist.`);
332
+ const { index } = this.createIndex(indexName, Object.assign({}, this.indexOptions));
333
+ index.open();
334
+ this.readonlyIndexes[name] = index;
335
+ return index;
336
+ }
337
+
276
338
  /**
277
339
  * Open an existing index.
278
340
  *
279
341
  * @api
280
342
  * @param {string} name The index name.
281
- * @param {object|function} [matcher] The matcher object or function that the index needs to have been defined with. If not given it will not be validated.
343
+ * @param {Matcher} [matcher] The matcher object or function that the index needs to have been defined with. If not given it will not be validated.
282
344
  * @returns {ReadableIndex}
283
345
  * @throws {Error} if the index with that name does not exist.
284
346
  * @throws {Error} if the HMAC for the matcher does not match.
285
347
  */
286
348
  openIndex(name, matcher) {
349
+ if (name === '_all') {
350
+ return this.index;
351
+ }
287
352
  if (name in this.secondaryIndexes) {
288
353
  return this.secondaryIndexes[name].index;
289
354
  }
@@ -299,17 +364,79 @@ class ReadableStorage extends EventEmitter {
299
364
  }
300
365
 
301
366
  /**
302
- * Helper method to iterate over all documents.
367
+ * Iterate documents across all partitions in sequenceNumber order using a k-way merge.
368
+ * Opens any closed partition automatically.
303
369
  *
304
370
  * @protected
305
- * @param {function(object, EntryInterface)} iterationHandler
371
+ * @param {number} [from=0] The 0-based sequenceNumber to start from (inclusive).
372
+ * @param {number} [until=Number.MAX_SAFE_INTEGER] The 0-based sequenceNumber to read until (inclusive).
373
+ * @returns {Generator<{document: object, sequenceNumber: number, partitionName: string, position: number, size: number, partition: number}>}
306
374
  */
307
- forEachDocument(iterationHandler) {
375
+ *iterateDocumentsNoIndex(from = 0, until = Number.MAX_SAFE_INTEGER) {
376
+ const streams = [];
377
+
378
+ this.forEachPartition(partition => {
379
+ if (!partition.isOpen()) {
380
+ partition.open();
381
+ }
382
+
383
+ const found = partition.findDocument(from);
384
+ if (found && found.headerOut.sequenceNumber <= until) {
385
+ const nextPosition = found.headerOut.position + partition.documentWriteSize(found.headerOut.dataSize);
386
+ const reader = partition.readAll(nextPosition, found.headerOut);
387
+ streams.push({ ...found, reader, partition: partition.id, partitionName: partition.name });
388
+ }
389
+ });
390
+
391
+ const items = [];
392
+ kWayMerge(
393
+ streams,
394
+ stream => stream.headerOut.sequenceNumber,
395
+ stream => {
396
+ const next = stream.reader.next();
397
+ if (!next.done && stream.headerOut.sequenceNumber <= until) {
398
+ stream.data = next.value;
399
+ return true;
400
+ }
401
+ return false;
402
+ },
403
+ stream => items.push({
404
+ document: this.serializer.deserialize(stream.data),
405
+ sequenceNumber: stream.headerOut.sequenceNumber,
406
+ partitionName: stream.partitionName,
407
+ position: stream.headerOut.position,
408
+ size: stream.headerOut.dataSize,
409
+ partition: stream.partition,
410
+ })
411
+ );
412
+
413
+ yield* items;
414
+ }
415
+
416
+ /**
417
+ * Helper method to iterate over all documents, invoking a callback for each one.
418
+ * Pass `noIndex = true` to iterate all partitions directly in sequenceNumber order
419
+ * (useful when the global index is unavailable or corrupted).
420
+ * When `noIndex` is false the second callback argument is the raw index `EntryInterface`.
421
+ * When `noIndex` is true the second callback argument has `{ partition, position, size, sequenceNumber, partitionName }`.
422
+ *
423
+ * @protected
424
+ * @param {function(object, object): void} iterationHandler
425
+ * @param {boolean} [noIndex=false] When true, bypasses the index and iterates partitions directly.
426
+ */
427
+ forEachDocument(iterationHandler, noIndex = false) {
308
428
  /* istanbul ignore if */
309
429
  if (typeof iterationHandler !== 'function') {
310
430
  return;
311
431
  }
312
432
 
433
+ if (noIndex) {
434
+ for (const { document, ...entryInfo } of this.iterateDocumentsNoIndex()) {
435
+ iterationHandler(document, entryInfo);
436
+ }
437
+ return;
438
+ }
439
+
313
440
  const entries = this.index.all();
314
441
 
315
442
  for (let entry of entries) {
@@ -359,3 +486,4 @@ class ReadableStorage extends EventEmitter {
359
486
 
360
487
  module.exports = ReadableStorage;
361
488
  module.exports.matches = matches;
489
+ module.exports.CorruptFileError = Partition.CorruptFileError;
@@ -1,15 +1,22 @@
1
1
  const fs = require('fs');
2
- const mkdirpSync = require('mkdirp').sync;
3
2
  const path = require('path');
4
3
  const WritablePartition = require('../Partition/WritablePartition');
5
4
  const WritableIndex = require('../Index/WritableIndex');
6
5
  const ReadableStorage = require('./ReadableStorage');
7
- const { assert, matches, buildMetadataForMatcher, buildMatcherFromMetadata } = require('../util');
6
+ const { assert, ensureDirectory } = require('../util');
7
+ const { matches, buildMetadataForMatcher, buildMatcherFromMetadata } = require('../metadataUtil');
8
8
 
9
9
  const DEFAULT_WRITE_BUFFER_SIZE = 16 * 1024;
10
10
 
11
+ const LOCK_RECLAIM = 0x1;
12
+ const LOCK_THROW = 0x2;
13
+
11
14
  class StorageLockedError extends Error {}
12
15
 
16
+ /**
17
+ * @typedef {object|function(object):boolean} Matcher
18
+ */
19
+
13
20
  /**
14
21
  * An append-only storage with highly performant positional range scans.
15
22
  * It's highly optimized for an event-store and hence does not support compaction or data-rewrite, nor any querying
@@ -33,6 +40,7 @@ class WritableStorage extends ReadableStorage {
33
40
  * @param {function(object, number): string} [config.partitioner] A function that takes a document and sequence number and returns a partition name that the document should be stored in. Defaults to write all documents to the primary partition.
34
41
  * @param {object} [config.indexOptions] An options object that should be passed to all indexes on construction.
35
42
  * @param {string} [config.hmacSecret] A private key that is used to verify matchers retrieved from indexes.
43
+ * @param {number} [config.lock] One of LOCK_* constants that defines how an existing lock should be handled.
36
44
  */
37
45
  constructor(storageName = 'storage', config = {}) {
38
46
  if (typeof storageName !== 'string') {
@@ -49,13 +57,13 @@ class WritableStorage extends ReadableStorage {
49
57
  };
50
58
  config = Object.assign(defaults, config);
51
59
  config.indexOptions = Object.assign({ syncOnFlush: config.syncOnFlush }, config.indexOptions);
52
- if (!fs.existsSync(config.dataDirectory)) {
53
- try {
54
- mkdirpSync(config.dataDirectory);
55
- } catch (e) {
56
- }
57
- }
60
+ ensureDirectory(config.dataDirectory);
58
61
  super(storageName, config);
62
+
63
+ this.lockFile = path.resolve(this.dataDirectory, this.storageFile + '.lock');
64
+ if (config.lock === LOCK_RECLAIM) {
65
+ this.unlock();
66
+ }
59
67
  this.partitioner = config.partitioner;
60
68
  }
61
69
 
@@ -68,7 +76,135 @@ class WritableStorage extends ReadableStorage {
68
76
  if (!this.lock()) {
69
77
  return true;
70
78
  }
71
- return super.open();
79
+ const result = super.open();
80
+ this.emit('ready');
81
+ return result;
82
+ }
83
+
84
+ /**
85
+ * Helper method to iterate over all writable secondary indexes.
86
+ * Opens each index before calling the callback (passing the previous open status),
87
+ * and closes it afterwards if it was not already open.
88
+ *
89
+ * @protected
90
+ * @param {function(WritableIndex, string, boolean)} iterationHandler Called with (index, name, wasOpen).
91
+ * @param {object} [matchDocument] If supplied, only indexes the document matches on will be iterated.
92
+ */
93
+ forEachWritableSecondaryIndex(iterationHandler, matchDocument) {
94
+ this.forEachSecondaryIndex((index, name) => {
95
+ /* istanbul ignore if */
96
+ if (!(index instanceof WritableIndex)) return;
97
+ const wasOpen = index.isOpen();
98
+ if (!wasOpen) index.open();
99
+ iterationHandler(index, name, wasOpen);
100
+ if (!wasOpen) index.close();
101
+ }, matchDocument);
102
+ }
103
+
104
+ /**
105
+ * Scan every partition's last document to detect torn writes inline.
106
+ * A document is torn when its expected end exceeds the actual file size:
107
+ * position + documentWriteSize(dataSize) > partition.size
108
+ *
109
+ * @private
110
+ * @returns {{ lastValidSequenceNumber: number, maxPartitionSequenceNumber: number }}
111
+ */
112
+ findTornWriteBoundary() {
113
+ let lastValidSequenceNumber = Number.MAX_SAFE_INTEGER;
114
+ let maxPartitionSequenceNumber = -1;
115
+ this.forEachPartition(partition => {
116
+ partition.open();
117
+ const last = partition.readLast();
118
+ /* istanbul ignore if */
119
+ if (!last) return;
120
+ const { header: { sequenceNumber, dataSize }, position } = last;
121
+ if (position + partition.documentWriteSize(dataSize) > partition.size) {
122
+ // Torn write: the document extends beyond the end of the file.
123
+ lastValidSequenceNumber = Math.min(lastValidSequenceNumber, sequenceNumber);
124
+ } else {
125
+ maxPartitionSequenceNumber = Math.max(maxPartitionSequenceNumber, sequenceNumber);
126
+ }
127
+ });
128
+ return { lastValidSequenceNumber, maxPartitionSequenceNumber };
129
+ }
130
+
131
+ /**
132
+ * Check all partitions for torn writes, physically repair each partition, truncate all indexes
133
+ * to the torn-write boundary, and then reindex to rebuild any missing index entries.
134
+ *
135
+ * A document is torn when the partition file ends before the document's expected end position
136
+ * (i.e. position + documentWriteSize(dataSize) > partition.size). Detected inline in
137
+ * findTornWriteBoundary(), without any checkTornWrite() call.
138
+ *
139
+ * Repair flow:
140
+ * 1. findTornWriteBoundary() reads the last document of every partition and finds the global
141
+ * torn-write boundary (minimum torn sequence number across all partitions).
142
+ * 2. If torn writes were found, truncateAfterSequence() removes all documents at or beyond
143
+ * the boundary from each partition.
144
+ * 3. Truncate all indexes to the torn-write boundary, then reindex to fill any lagging entries.
145
+ * 4. If no torn writes were found but the index is lagging, reindex directly.
146
+ */
147
+ checkTornWrites() {
148
+ const { lastValidSequenceNumber, maxPartitionSequenceNumber } = this.findTornWriteBoundary();
149
+
150
+ if (lastValidSequenceNumber < Number.MAX_SAFE_INTEGER) {
151
+ // Phase 2: remove all documents at or beyond the torn-write boundary from each partition.
152
+ this.forEachPartition(partition => {
153
+ partition.open();
154
+ partition.truncateAfterSequence(lastValidSequenceNumber - 1);
155
+ });
156
+
157
+ // Truncate all indexes to the torn-write boundary.
158
+ this.index.open();
159
+ this.index.truncate(lastValidSequenceNumber);
160
+ /* istanbul ignore next */
161
+ this.forEachWritableSecondaryIndex(index => {
162
+ index.truncate(index.find(lastValidSequenceNumber));
163
+ });
164
+
165
+ // Reindex to fill in any missing complete-document entries.
166
+ this.reindex(this.index.length);
167
+ } else if (maxPartitionSequenceNumber >= 0 && maxPartitionSequenceNumber + 1 > this.index.length) {
168
+ // No torn writes, but the index is lagging — repair it.
169
+ this.reindex(this.index.length);
170
+ }
171
+
172
+ this.forEachPartition(partition => partition.close());
173
+ }
174
+
175
+ /**
176
+ * Rebuild the primary index and all loaded secondary indexes starting from the given sequence
177
+ * number by scanning the partition data directly.
178
+ * This is the building block for both auto-repair (invoked automatically when the primary
179
+ * index is found to be lagging in checkTornWrites()) and for user-driven re-indexing after
180
+ * index corruption.
181
+ *
182
+ * @api
183
+ * @param {number} [fromSequenceNumber=0] The number of primary index entries to keep intact.
184
+ * All index entries beyond this position will be removed and rebuilt from partition data.
185
+ * Defaults to 0, which rebuilds all indexes from scratch.
186
+ */
187
+ reindex(fromSequenceNumber = 0) {
188
+ this.index.truncate(fromSequenceNumber);
189
+
190
+ // Truncate all loaded secondary indexes to match the new primary length.
191
+ this.forEachWritableSecondaryIndex(index => {
192
+ // find(0) returns 0, so truncate(0) will remove all entries when fromSequenceNumber===0
193
+ index.truncate(fromSequenceNumber === 0 ? 0 : index.find(fromSequenceNumber));
194
+ });
195
+
196
+ // Scan partitions in sequence-number order and rebuild index entries.
197
+ // iterateDocumentsNoIndex opens any closed partitions automatically.
198
+ for (const { document, partition, position, size } of this.iterateDocumentsNoIndex(fromSequenceNumber, Number.MAX_SAFE_INTEGER)) {
199
+ const newEntry = new WritableIndex.Entry(this.index.length + 1, position, size, partition);
200
+ this.index.add(newEntry);
201
+
202
+ this.forEachWritableSecondaryIndex((secIndex) => {
203
+ secIndex.add(newEntry);
204
+ }, document);
205
+ }
206
+
207
+ this.flush();
72
208
  }
73
209
 
74
210
  /**
@@ -81,7 +217,6 @@ class WritableStorage extends ReadableStorage {
81
217
  if (this.locked) {
82
218
  return false;
83
219
  }
84
- this.lockFile = path.resolve(this.dataDirectory, this.storageFile + '.lock');
85
220
  try {
86
221
  fs.mkdirSync(this.lockFile);
87
222
  this.locked = true;
@@ -101,7 +236,12 @@ class WritableStorage extends ReadableStorage {
101
236
  * Current implementation just deletes a lock file that is named like the storage.
102
237
  */
103
238
  unlock() {
104
- fs.rmdirSync(this.lockFile);
239
+ if (fs.existsSync(this.lockFile)) {
240
+ if (!this.locked) {
241
+ this.checkTornWrites();
242
+ }
243
+ fs.rmdirSync(this.lockFile);
244
+ }
105
245
  this.locked = false;
106
246
  }
107
247
 
@@ -147,6 +287,19 @@ class WritableStorage extends ReadableStorage {
147
287
  return entry;
148
288
  }
149
289
 
290
+ /**
291
+ * Register a handler that is called before a document is written to storage.
292
+ * The handler receives the document and the partition metadata and may throw to abort the write.
293
+ * Multiple handlers can be registered; all run on every write in registration order.
294
+ * Equivalent to `storage.on('preCommit', hook)`.
295
+ *
296
+ * @api
297
+ * @param {function(object, object): void} hook A function receiving (document, partitionMetadata).
298
+ */
299
+ preCommit(hook) {
300
+ this.on('preCommit', hook);
301
+ }
302
+
150
303
  /**
151
304
  * Get a partition either by name or by id.
152
305
  * If a partition with the given name does not exist, a new one will be created.
@@ -159,10 +312,14 @@ class WritableStorage extends ReadableStorage {
159
312
  */
160
313
  getPartition(partitionIdentifier) {
161
314
  if (typeof partitionIdentifier === 'string') {
315
+ const partitionShortName = partitionIdentifier;
162
316
  const partitionName = this.storageFile + (partitionIdentifier.length ? '.' + partitionIdentifier : '');
163
317
  partitionIdentifier = WritablePartition.idFor(partitionName);
164
318
  if (!this.partitions[partitionIdentifier]) {
165
- this.partitions[partitionIdentifier] = this.createPartition(partitionName, this.partitionConfig);
319
+ const partitionConfig = typeof this.partitionConfig.metadata === 'function'
320
+ ? { ...this.partitionConfig, metadata: this.partitionConfig.metadata(partitionShortName) }
321
+ : this.partitionConfig;
322
+ this.partitions[partitionIdentifier] = this.createPartition(partitionName, partitionConfig);
166
323
  this.emit('partition-created', partitionIdentifier);
167
324
  }
168
325
  this.partitions[partitionIdentifier].open();
@@ -183,6 +340,9 @@ class WritableStorage extends ReadableStorage {
183
340
 
184
341
  const partitionName = this.partitioner(document, this.index.length + 1);
185
342
  const partition = this.getPartition(partitionName);
343
+ if (this.listenerCount('preCommit') > 0) {
344
+ this.emit('preCommit', document, partition.metadata);
345
+ }
186
346
  const position = partition.write(data, this.length, callback);
187
347
 
188
348
  assert(position !== false, 'Error writing document.');
@@ -205,11 +365,14 @@ class WritableStorage extends ReadableStorage {
205
365
  *
206
366
  * @api
207
367
  * @param {string} name The index name.
208
- * @param {object|function} [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.
368
+ * @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.
209
369
  * @returns {ReadableIndex} The index containing all documents that match the query.
210
370
  * @throws {Error} if the index doesn't exist yet and no matcher was specified.
211
371
  */
212
372
  ensureIndex(name, matcher) {
373
+ if (name === '_all') {
374
+ return this.index;
375
+ }
213
376
  if (name in this.secondaryIndexes) {
214
377
  return this.secondaryIndexes[name].index;
215
378
  }
@@ -276,6 +439,10 @@ class WritableStorage extends ReadableStorage {
276
439
  /**
277
440
  * Truncate all partitions after the given (global) sequence number.
278
441
  *
442
+ * Assumes the primary index is fully consistent with the partition data. Looks up the first
443
+ * index entry after `after` for each affected partition and truncates the partition file
444
+ * at that entry's byte position.
445
+ *
279
446
  * @private
280
447
  * @param {number} after The document sequence number to truncate after.
281
448
  */
@@ -309,31 +476,39 @@ class WritableStorage extends ReadableStorage {
309
476
  if (!this.index.isOpen()) {
310
477
  this.index.open();
311
478
  }
479
+ if (after < 0) {
480
+ after += this.index.length;
481
+ }
312
482
 
313
483
  this.truncatePartitions(after);
314
484
 
315
485
  this.index.truncate(after);
316
- this.forEachSecondaryIndex(index => {
317
- if (!(index instanceof WritableIndex)) {
318
- return;
319
- }
320
- let closeIndex = false;
321
- if (!index.isOpen()) {
322
- index.open();
323
- closeIndex = true;
324
- }
486
+ this.forEachWritableSecondaryIndex(index => {
325
487
  index.truncate(index.find(after));
326
- if (closeIndex) {
327
- index.close();
328
- }
329
488
  });
330
489
  }
331
490
 
491
+ /**
492
+ * @inheritDoc
493
+ * Open an existing secondary index and repair any stale entries beyond the current primary
494
+ * index length. Stale entries can be present when checkTornWrites() truncated the primary
495
+ * index before this secondary index was loaded into memory.
496
+ */
497
+ openIndex(name, matcher) {
498
+ const index = super.openIndex(name, matcher);
499
+ const lastEntry = index.lastEntry;
500
+ if (lastEntry !== false && lastEntry.number > this.index.length) {
501
+ // Secondary index is ahead of primary: truncate stale entries.
502
+ index.truncate(index.find(this.index.length));
503
+ }
504
+ return index;
505
+ }
506
+
332
507
  /**
333
508
  * @protected
334
509
  * @param {string} name
335
510
  * @param {object} [options]
336
- * @returns {{ index: WritableIndex, matcher: object|function }}
511
+ * @returns {{ index: WritableIndex, matcher: Matcher }}
337
512
  */
338
513
  createIndex(name, options = {}) {
339
514
  const index = new WritableIndex(name, options);
@@ -366,4 +541,7 @@ class WritableStorage extends ReadableStorage {
366
541
  }
367
542
 
368
543
  module.exports = WritableStorage;
369
- module.exports.StorageLockedError = StorageLockedError;
544
+ module.exports.StorageLockedError = StorageLockedError;
545
+ module.exports.CorruptFileError = ReadableStorage.CorruptFileError;
546
+ module.exports.LOCK_THROW = LOCK_THROW;
547
+ module.exports.LOCK_RECLAIM = LOCK_RECLAIM;