event-storage 0.8.0 → 1.0.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.
@@ -1,7 +1,7 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const events = require('events');
4
- const { assert, alignTo } = require('../util');
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import events from 'events';
4
+ import { assert, alignTo, hash, binarySearch } from '../util.js';
5
5
 
6
6
  const DEFAULT_READ_BUFFER_SIZE = 64 * 1024;
7
7
  const DOCUMENT_HEADER_SIZE = 16;
@@ -17,30 +17,6 @@ const NES_EPOCH = new Date('2020-01-01T00:00:00');
17
17
  class CorruptFileError extends Error {}
18
18
  class InvalidDataSizeError extends Error {}
19
19
 
20
- /**
21
- * Method for hashing a string (partition name) to a 32-bit unsigned integer.
22
- *
23
- * @param {string} str
24
- * @returns {number}
25
- */
26
- function hash(str) {
27
- /* istanbul ignore if */
28
- if (str.length === 0) {
29
- return 0;
30
- }
31
- let hash = 5381,
32
- i = str.length;
33
-
34
- while(i) {
35
- hash = ((hash << 5) + hash) ^ str.charCodeAt(--i); // jshint ignore:line
36
- }
37
-
38
- /* JavaScript does bitwise operations (like XOR, above) on 32-bit signed
39
- * integers. Since we want the results to be always positive, convert the
40
- * signed int to an unsigned by doing an unsigned bitshift. */
41
- return hash >>> 0; // jshint ignore:line
42
- }
43
-
44
20
  /**
45
21
  * A partition is a single file where the storage will write documents to depending on some partitioning rules.
46
22
  * In the case of an event store, this is most likely the (write) streams.
@@ -123,21 +99,6 @@ class ReadablePartition extends events.EventEmitter {
123
99
  return true;
124
100
  }
125
101
 
126
- /**
127
- * @returns {number} -1 if the partition is ok and the sequence number of the broken document if a torn write was detected.
128
- */
129
- checkTornWrite() {
130
- const reader = this.prepareReadBufferBackwards(this.size);
131
- const separator = reader.buffer.toString('ascii', reader.cursor - DOCUMENT_SEPARATOR.length, reader.cursor);
132
- if (separator !== DOCUMENT_SEPARATOR) {
133
- const position = this.findDocumentPositionBefore(this.size);
134
- const reader = this.prepareReadBuffer(position);
135
- const { sequenceNumber } = this.readDocumentHeader(reader.buffer, reader.cursor, position);
136
- return sequenceNumber;
137
- }
138
- return -1;
139
- }
140
-
141
102
  /**
142
103
  * Read the partition metadata from the file.
143
104
  *
@@ -279,7 +240,7 @@ class ReadablePartition extends events.EventEmitter {
279
240
  return ({ buffer: null, cursor: 0, length: 0 });
280
241
  }
281
242
  let bufferCursor = position - this.readBufferPos;
282
- if (this.readBufferPos < 0 || (this.readBufferPos > 0 && bufferCursor < DOCUMENT_FOOTER_SIZE)) {
243
+ if (this.readBufferPos < 0 || (this.readBufferPos > 0 && bufferCursor < DOCUMENT_FOOTER_SIZE) || bufferCursor > this.readBufferLength) {
283
244
  this.fillBuffer(Math.max(position - this.readBuffer.byteLength, 0));
284
245
  bufferCursor = position - this.readBufferPos;
285
246
  }
@@ -292,12 +253,14 @@ class ReadablePartition extends events.EventEmitter {
292
253
  * @api
293
254
  * @param {number} position The file position to read from.
294
255
  * @param {number} [size] The expected byte size of the document at the given position.
256
+ * @param {object|null} [headerOut] Optional object to populate with the document header fields
257
+ * (`dataSize`, `sequenceNumber`, `time64`). Pass an existing object to avoid extra allocation.
295
258
  * @returns {string|boolean} The data stored at the given position or false if no data could be read.
296
259
  * @throws {Error} if the storage entry at the given position is corrupted.
297
260
  * @throws {InvalidDataSizeError} if the document size at the given position does not match the provided size.
298
261
  * @throws {CorruptFileError} if the document at the given position can not be read completely.
299
262
  */
300
- readFrom(position, size = 0) {
263
+ readFrom(position, size = 0, headerOut = null) {
301
264
  assert(this.fd, 'Partition is not opened.');
302
265
  assert((position % DOCUMENT_ALIGNMENT) === 0, `Invalid read position ${position}. Needs to be a multiple of ${DOCUMENT_ALIGNMENT}.`);
303
266
 
@@ -307,7 +270,12 @@ class ReadablePartition extends events.EventEmitter {
307
270
  }
308
271
 
309
272
  let dataPosition = reader.cursor + DOCUMENT_HEADER_SIZE;
310
- const { dataSize } = this.readDocumentHeader(reader.buffer, reader.cursor, position, size);
273
+ const { dataSize, sequenceNumber, time64 } = this.readDocumentHeader(reader.buffer, reader.cursor, position, size);
274
+ if (headerOut !== null) {
275
+ headerOut.dataSize = dataSize;
276
+ headerOut.sequenceNumber = sequenceNumber;
277
+ headerOut.time64 = time64;
278
+ }
311
279
 
312
280
  // TODO: This should only be checked on opening
313
281
  const writeSize = this.documentWriteSize(dataSize);
@@ -366,17 +334,108 @@ class ReadablePartition extends events.EventEmitter {
366
334
  return Math.max(0, position);
367
335
  }
368
336
 
337
+ /**
338
+ * Find the document that starts immediately before `position`, fill the read buffer
339
+ * centered around that document's start, and return its file position and parsed header.
340
+ * The buffer is centered by calling `prepareReadBufferBackwards` with a position
341
+ * half a buffer-length ahead of the document start, clamped to file size, so the
342
+ * document start lands near the middle of the buffer.
343
+ *
344
+ * @private
345
+ * @param {number} position The file position to search before.
346
+ * @returns {{ header: {dataSize: number, sequenceNumber: number, time64: number}, position: number }|null}
347
+ * The document header and file position, or null if no document could be found.
348
+ */
349
+ readDocumentBefore(position) {
350
+ const docPos = this.findDocumentPositionBefore(position);
351
+ /* istanbul ignore if */
352
+ if (docPos === false || docPos < 0) return null;
353
+ const reader = this.prepareReadBufferBackwards(Math.min(docPos + (this.readBuffer.byteLength >> 1), this.size));
354
+ /* istanbul ignore if */
355
+ if (!reader.buffer) return null;
356
+ const cursor = docPos - this.readBufferPos;
357
+ /* istanbul ignore if */
358
+ if (cursor < 0 || cursor + DOCUMENT_HEADER_SIZE > reader.length) return null;
359
+ const header = this.readDocumentHeader(reader.buffer, cursor, docPos);
360
+ return { header, position: docPos };
361
+ }
362
+
363
+ /**
364
+ * Read the header and file position of the last document in this partition.
365
+ *
366
+ * @api
367
+ * @returns {{ header: {dataSize: number, sequenceNumber: number, time64: number}, position: number } | null}
368
+ * The last document's header and its file position, or null if the partition is empty or unreadable.
369
+ */
370
+ readLast() {
371
+ if (this.size === 0) return null;
372
+ return this.readDocumentBefore(this.size);
373
+ }
374
+
375
+ /**
376
+ * Find the first document whose sequenceNumber is >= the given value.
377
+ * Uses readLast() to short-circuit when the partition contains no such document.
378
+ * Uses a binary search over file positions via readDocumentBefore() to locate the
379
+ * document. The search tracks both the lower bound (position just after the last
380
+ * confirmed "< sequenceNumber" doc) and the upper bound (minimum position of any
381
+ * probed doc with sequenceNumber >= target). The upper bound, when available, is
382
+ * the exact target document, so no further linear scan is needed.
383
+ *
384
+ * @api
385
+ * @param {number} sequenceNumber The 0-based sequence number to search for.
386
+ * @returns {{ reader: Generator<string>, headerOut: object, data: string }|null}
387
+ * The matched document with its reader and shared headerOut, or null if no such document exists.
388
+ */
389
+ findDocument(sequenceNumber) {
390
+ const last = this.readLast();
391
+ if (!last || last.header.sequenceNumber < sequenceNumber) {
392
+ return null;
393
+ }
394
+
395
+ let startPosition = this.size;
396
+ binarySearch(
397
+ sequenceNumber,
398
+ this.size,
399
+ (pos) => {
400
+ const doc = this.readDocumentBefore(pos);
401
+ if (!doc) return sequenceNumber;
402
+ if (doc.header.sequenceNumber < sequenceNumber) {
403
+ startPosition = Math.max(startPosition, doc.position + this.documentWriteSize(doc.header.dataSize));
404
+ } else {
405
+ startPosition = Math.min(startPosition, doc.position);
406
+ }
407
+ return doc.header.sequenceNumber;
408
+ }
409
+ );
410
+
411
+ const headerOut = {};
412
+ const data = this.readFrom(startPosition, 0, headerOut);
413
+ /* istanbul ignore if */
414
+ if (data === false) {
415
+ return null;
416
+ }
417
+ headerOut.position = startPosition;
418
+ return { headerOut, data };
419
+ }
420
+
369
421
  /**
370
422
  * @api
371
423
  * @param {number} [after] The document position to start reading from.
424
+ * @param {object|null} [headerOut] Optional object to populate with document header fields
425
+ * (`dataSize`, `sequenceNumber`, `time64`, `position`) on each yield. Pass an existing object
426
+ * to avoid extra allocation. The object is mutated in place before each yield.
372
427
  * @returns {Generator<string>} A generator that returns all documents in this partition.
373
428
  */
374
- *readAll(after = 0) {
429
+ *readAll(after = 0, headerOut = null) {
375
430
  let position = after < 0 ? this.size + after + 1 : after;
431
+ const internalHeader = headerOut !== null ? headerOut : {};
376
432
  let data;
377
- while ((data = this.readFrom(position)) !== false) {
433
+ while ((data = this.readFrom(position, 0, internalHeader)) !== false) {
434
+ if (headerOut !== null) {
435
+ headerOut.position = position;
436
+ }
378
437
  yield data;
379
- position += this.documentWriteSize(Buffer.byteLength(data, 'utf8'));
438
+ position += this.documentWriteSize(internalHeader.dataSize);
380
439
  }
381
440
  }
382
441
 
@@ -394,11 +453,5 @@ class ReadablePartition extends events.EventEmitter {
394
453
  }
395
454
  }
396
455
 
397
- module.exports = ReadablePartition;
398
- module.exports.CorruptFileError = CorruptFileError;
399
- module.exports.InvalidDataSizeError = InvalidDataSizeError;
400
- module.exports.HEADER_MAGIC = HEADER_MAGIC;
401
- module.exports.DOCUMENT_SEPARATOR = DOCUMENT_SEPARATOR;
402
- module.exports.DOCUMENT_ALIGNMENT = DOCUMENT_ALIGNMENT;
403
- module.exports.DOCUMENT_HEADER_SIZE = DOCUMENT_HEADER_SIZE;
404
- module.exports.DOCUMENT_FOOTER_SIZE = DOCUMENT_FOOTER_SIZE;
456
+ export default ReadablePartition;
457
+ export { CorruptFileError, InvalidDataSizeError, HEADER_MAGIC, DOCUMENT_SEPARATOR, DOCUMENT_ALIGNMENT, DOCUMENT_HEADER_SIZE, DOCUMENT_FOOTER_SIZE };
@@ -1,10 +1,9 @@
1
- const fs = require('fs');
2
- const ReadablePartition = require('./ReadablePartition');
3
- const { assert, buildMetadataHeader, alignTo, ensureDirectory } = require('../util');
4
- const Clock = require('../Clock');
1
+ import fs from 'fs';
2
+ import ReadablePartition, { CorruptFileError, HEADER_MAGIC, DOCUMENT_ALIGNMENT, DOCUMENT_SEPARATOR, DOCUMENT_HEADER_SIZE, DOCUMENT_FOOTER_SIZE } from './ReadablePartition.js';
3
+ import { assert, buildMetadataHeader, alignTo, ensureDirectory } from '../util.js';
4
+ import Clock from '../Clock.js';
5
5
 
6
6
  const DEFAULT_WRITE_BUFFER_SIZE = 16 * 1024;
7
- const { DOCUMENT_ALIGNMENT, DOCUMENT_SEPARATOR, DOCUMENT_HEADER_SIZE, DOCUMENT_FOOTER_SIZE } = ReadablePartition;
8
7
  const DOCUMENT_PAD = ' '.repeat(DOCUMENT_ALIGNMENT);
9
8
 
10
9
  /**
@@ -77,6 +76,8 @@ class WritablePartition extends ReadablePartition {
77
76
  // How many documents are currently in the write buffer
78
77
  this.writeBufferDocuments = 0;
79
78
  this.flushCallbacks = [];
79
+ // Pre-allocated buffer for document header (16 bytes) + size footer (4 bytes) used in writeUnbuffered
80
+ this.writeMetaBuffer = Buffer.allocUnsafe(DOCUMENT_HEADER_SIZE + 4);
80
81
 
81
82
  this.clock = new this.ClockConstructor(this.metadata.epoch);
82
83
 
@@ -97,6 +98,7 @@ class WritablePartition extends ReadablePartition {
97
98
  this.writeBuffer = null;
98
99
  this.writeBufferCursor = 0;
99
100
  this.writeBufferDocuments = 0;
101
+ this.writeMetaBuffer = null;
100
102
  }
101
103
  super.close();
102
104
  }
@@ -108,7 +110,7 @@ class WritablePartition extends ReadablePartition {
108
110
  * @returns void
109
111
  */
110
112
  writeMetadata() {
111
- const metadataBuffer = buildMetadataHeader(ReadablePartition.HEADER_MAGIC, this.metadata);
113
+ const metadataBuffer = buildMetadataHeader(HEADER_MAGIC, this.metadata);
112
114
  fs.writeFileSync(this.fileName, metadataBuffer);
113
115
  this.headerSize = metadataBuffer.byteLength;
114
116
  }
@@ -134,8 +136,9 @@ class WritablePartition extends ReadablePartition {
134
136
 
135
137
  this.writeBufferCursor = 0;
136
138
  this.writeBufferDocuments = 0;
137
- this.flushCallbacks.forEach(callback => callback());
139
+ const callbacks = this.flushCallbacks;
138
140
  this.flushCallbacks = [];
141
+ for (let i = 0; i < callbacks.length; i++) callbacks[i]();
139
142
 
140
143
  return true;
141
144
  }
@@ -199,17 +202,15 @@ class WritablePartition extends ReadablePartition {
199
202
  */
200
203
  writeUnbuffered(data, dataSize, sequenceNumber, callback) {
201
204
  this.flush();
202
- const dataHeader = Buffer.alloc(DOCUMENT_HEADER_SIZE);
203
- this.writeDocumentHeader(dataHeader, 0, dataSize, sequenceNumber);
205
+ this.writeDocumentHeader(this.writeMetaBuffer, 0, dataSize, sequenceNumber);
204
206
 
205
207
  let bytesWritten = 0;
206
- bytesWritten += fs.writeSync(this.fd, dataHeader);
208
+ bytesWritten += fs.writeSync(this.fd, this.writeMetaBuffer, 0, DOCUMENT_HEADER_SIZE);
207
209
  bytesWritten += fs.writeSync(this.fd, data);
208
210
  const padSize = alignTo(dataSize + DOCUMENT_FOOTER_SIZE, DOCUMENT_ALIGNMENT);
209
211
  bytesWritten += fs.writeSync(this.fd, DOCUMENT_PAD.substr(0, padSize));
210
- const dataSizeBuffer = Buffer.alloc(4);
211
- dataSizeBuffer.writeUInt32BE(dataSize, 0);
212
- bytesWritten += fs.writeSync(this.fd, dataSizeBuffer);
212
+ this.writeMetaBuffer.writeUInt32BE(dataSize, 0);
213
+ bytesWritten += fs.writeSync(this.fd, this.writeMetaBuffer, 0, 4);
213
214
  bytesWritten += fs.writeSync(this.fd, DOCUMENT_SEPARATOR);
214
215
  if (typeof callback === 'function') {
215
216
  process.nextTick(callback);
@@ -227,7 +228,7 @@ class WritablePartition extends ReadablePartition {
227
228
  * @returns {number} Number of bytes written.
228
229
  */
229
230
  writeBuffered(data, dataSize, sequenceNumber, callback) {
230
- const bytesToWrite = this.documentWriteSize(Buffer.byteLength(data, 'utf8'));
231
+ const bytesToWrite = this.documentWriteSize(dataSize);
231
232
  this.flushIfWriteBufferTooSmall(bytesToWrite);
232
233
 
233
234
  let bytesWritten = 0;
@@ -293,7 +294,40 @@ class WritablePartition extends ReadablePartition {
293
294
  }
294
295
 
295
296
  /**
296
- * Truncate the internal read buffer after the given position.
297
+ * Prepare the read buffer for reading *before* the specified position. Don't try to read *after* the returned cursor.
298
+ *
299
+ * @protected
300
+ * @param {number} position The position in the file to prepare the read buffer for reading before.
301
+ * @returns {object} A reader object with properties `buffer`, `cursor` and `length`.
302
+ */
303
+ prepareReadBufferBackwards(position) {
304
+ const bufferPos = this.size - this.writeBufferCursor;
305
+ // Handle the case when data that is still in write buffer is supposed to be read backwards
306
+ if (this.dirtyReads && this.writeBufferCursor > 0 && position > bufferPos) {
307
+ return { buffer: this.writeBuffer, cursor: position - bufferPos, length: this.writeBufferCursor };
308
+ }
309
+ return super.prepareReadBufferBackwards(position);
310
+ }
311
+
312
+ /**
313
+ * Read all documents in reverse write order, ignoring any unflushed write-buffer data when
314
+ * dirty reads are disabled.
315
+ *
316
+ * @api
317
+ * @param {number} [before] The document position to start reading backward from.
318
+ * @returns {Generator<string>} A generator that returns all documents in this partition in reverse order.
319
+ */
320
+ *readAllBackwards(before = -1) {
321
+ if (!this.dirtyReads && this.writeBufferCursor > 0) {
322
+ const flushedSize = this.size - this.writeBufferCursor;
323
+ const clampedBefore = before < 0 ? flushedSize : Math.min(before, flushedSize);
324
+ yield* super.readAllBackwards(clampedBefore);
325
+ return;
326
+ }
327
+ yield* super.readAllBackwards(before);
328
+ }
329
+
330
+ /**
297
331
  *
298
332
  * @internal
299
333
  * @param {number} after The byte position to truncate the read buffer after.
@@ -307,6 +341,31 @@ class WritablePartition extends ReadablePartition {
307
341
  }
308
342
  }
309
343
 
344
+ /**
345
+ * Truncate the partition, removing all documents with sequenceNumber > after.
346
+ * Scans the partition file backwards to find the cutoff position.
347
+ *
348
+ * @api
349
+ * @param {number} after Keep all documents with sequenceNumber <= after. Documents with
350
+ * sequenceNumber > after (and any torn write at the end) are removed.
351
+ */
352
+ truncateAfterSequence(after) {
353
+ let position = this.size;
354
+ let truncateAt = this.size; // default: nothing to truncate
355
+ while ((position = this.findDocumentPositionBefore(position)) !== false) {
356
+ const reader = this.prepareReadBufferBackwards(position);
357
+ if (!reader.buffer) break;
358
+ const { sequenceNumber } = this.readDocumentHeader(reader.buffer, reader.cursor, position);
359
+ if (sequenceNumber > after) {
360
+ // This document must be removed; record its start as the new cutoff.
361
+ truncateAt = position;
362
+ } else {
363
+ break;
364
+ }
365
+ }
366
+ this.truncate(truncateAt);
367
+ }
368
+
310
369
  /**
311
370
  * Truncate the partition storage at the given position.
312
371
  *
@@ -314,25 +373,24 @@ class WritablePartition extends ReadablePartition {
314
373
  * @param {number} after The file position after which to truncate the partition.
315
374
  */
316
375
  truncate(after) {
317
- if (after > this.size) {
376
+ if (after >= this.size) {
318
377
  return;
319
378
  }
320
379
  this.open();
321
380
  after = Math.max(0, after);
322
381
  this.flush();
323
382
 
383
+ // Always save the truncated part for manual recovery, even if it contains corrupted data
384
+ this.branchOff('truncated-' + Date.now(), after);
385
+
324
386
  try {
325
387
  this.readFrom(after);
326
388
  } catch (e) {
327
- if (!(e instanceof ReadablePartition.CorruptFileError)) {
389
+ if (!(e instanceof CorruptFileError)) {
328
390
  throw new Error('Can only truncate on valid document boundaries.');
329
391
  }
330
392
  }
331
393
 
332
- // copy all truncated documents to some delete log
333
- const backupName = (new Date()).toISOString().substring(0,10);
334
- this.branchOff(backupName, after);
335
-
336
394
  fs.truncateSync(this.fileName, this.headerSize + after);
337
395
  this.truncateReadBuffer(after);
338
396
  this.size = after;
@@ -360,5 +418,5 @@ class WritablePartition extends ReadablePartition {
360
418
  }
361
419
  }
362
420
 
363
- module.exports = WritablePartition;
364
- module.exports.CorruptFileError = ReadablePartition.CorruptFileError;
421
+ export default WritablePartition;
422
+ export { CorruptFileError };
package/src/Partition.js CHANGED
@@ -1,5 +1,8 @@
1
- const WritablePartition = require('./Partition/WritablePartition');
2
- const ReadOnlyPartition = require('./Partition/ReadOnlyPartition');
1
+ import WritablePartition, { CorruptFileError } from './Partition/WritablePartition.js';
2
+ import ReadOnlyPartition from './Partition/ReadOnlyPartition.js';
3
3
 
4
- module.exports = WritablePartition;
5
- module.exports.ReadOnly = ReadOnlyPartition;
4
+ WritablePartition.ReadOnly = ReadOnlyPartition;
5
+ WritablePartition.CorruptFileError = CorruptFileError;
6
+
7
+ export default WritablePartition;
8
+ export { ReadOnlyPartition as ReadOnly, CorruptFileError };
@@ -1,6 +1,6 @@
1
- const ReadableStorage = require('./ReadableStorage');
2
- const ReadablePartition = require('../Partition/ReadablePartition');
3
- const Watcher = require('../Watcher');
1
+ import ReadableStorage from './ReadableStorage.js';
2
+ import ReadablePartition from '../Partition/ReadablePartition.js';
3
+ import Watcher from '../Watcher.js';
4
4
 
5
5
  /**
6
6
  * An append-only storage with highly performant positional range scans.
@@ -113,4 +113,4 @@ class ReadOnlyStorage extends ReadableStorage {
113
113
  }
114
114
  }
115
115
 
116
- module.exports = ReadOnlyStorage;
116
+ export default ReadOnlyStorage;