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.
@@ -1,46 +1,27 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const EventEmitter = require('events');
4
- const { assert } = require('../util');
3
+ const events = require('events');
4
+ const { assert, alignTo, hash, binarySearch } = require('../util');
5
5
 
6
6
  const DEFAULT_READ_BUFFER_SIZE = 64 * 1024;
7
7
  const DOCUMENT_HEADER_SIZE = 16;
8
8
  const DOCUMENT_ALIGNMENT = 4;
9
+ const DOCUMENT_SEPARATOR = "\x00\x00\x1E\n";
10
+ const DOCUMENT_FOOTER_SIZE = 4 /* additional data size footer */ + DOCUMENT_SEPARATOR.length;
9
11
 
10
- // node-event-store partition V02
11
- const HEADER_MAGIC = "nesprt02";
12
+ // node-event-store partition V03
13
+ const HEADER_MAGIC = "nesprt03";
14
+
15
+ const NES_EPOCH = new Date('2020-01-01T00:00:00');
12
16
 
13
17
  class CorruptFileError extends Error {}
14
18
  class InvalidDataSizeError extends Error {}
15
19
 
16
- /**
17
- * Method for hashing a string (partition name) to a 32-bit unsigned integer.
18
- *
19
- * @param {string} str
20
- * @returns {number}
21
- */
22
- function hash(str) {
23
- if (str.length === 0) {
24
- return 0;
25
- }
26
- let hash = 5381,
27
- i = str.length;
28
-
29
- while(i) {
30
- hash = ((hash << 5) + hash) ^ str.charCodeAt(--i); // jshint ignore:line
31
- }
32
-
33
- /* JavaScript does bitwise operations (like XOR, above) on 32-bit signed
34
- * integers. Since we want the results to be always positive, convert the
35
- * signed int to an unsigned by doing an unsigned bitshift. */
36
- return hash >>> 0; // jshint ignore:line
37
- }
38
-
39
20
  /**
40
21
  * A partition is a single file where the storage will write documents to depending on some partitioning rules.
41
22
  * In the case of an event store, this is most likely the (write) streams.
42
23
  */
43
- class ReadablePartition extends EventEmitter {
24
+ class ReadablePartition extends events.EventEmitter {
44
25
 
45
26
  /**
46
27
  * Get the id for a specific partition name.
@@ -109,6 +90,7 @@ class ReadablePartition extends EventEmitter {
109
90
  this.headerSize = 0;
110
91
  this.size = this.readFileSize();
111
92
  if (this.size <= 0) {
93
+ this.close();
112
94
  return false;
113
95
  }
114
96
 
@@ -146,6 +128,7 @@ class ReadablePartition extends EventEmitter {
146
128
  const metadata = metadataBuffer.toString('utf8').trim();
147
129
  try {
148
130
  this.metadata = JSON.parse(metadata);
131
+ this.metadata.epoch = this.metadata.epoch /* istanbul ignore next */|| NES_EPOCH.getTime();
149
132
  } catch (e) {
150
133
  throw new Error('Invalid metadata.');
151
134
  }
@@ -160,12 +143,12 @@ class ReadablePartition extends EventEmitter {
160
143
  * @returns {number} The size of the data including header, padded to 16 bytes alignment and ended with a line break.
161
144
  */
162
145
  documentWriteSize(dataSize) {
163
- const padSize = (DOCUMENT_ALIGNMENT - ((dataSize + 1) % DOCUMENT_ALIGNMENT)) % DOCUMENT_ALIGNMENT;
164
- return dataSize + 1 + padSize + DOCUMENT_HEADER_SIZE;
146
+ const padSize = alignTo(dataSize + DOCUMENT_FOOTER_SIZE, DOCUMENT_ALIGNMENT);
147
+ return DOCUMENT_HEADER_SIZE + dataSize + padSize + DOCUMENT_FOOTER_SIZE;
165
148
  }
166
149
 
167
150
  /**
168
- * @private
151
+ * @protected
169
152
  * @returns {number} The file size not including the file header.
170
153
  */
171
154
  readFileSize() {
@@ -208,7 +191,7 @@ class ReadablePartition extends EventEmitter {
208
191
  * @param {number} offset The position inside the buffer to start reading from.
209
192
  * @param {number} position The file position to start reading from.
210
193
  * @param {number} [size] The expected byte size of the document at the given position.
211
- * @returns {{ dataSize: number, sequenceNumber, number, time64: number }} The metadata fields of the document
194
+ * @returns {{ dataSize: number, sequenceNumber: number, time64: number }} The metadata fields of the document
212
195
  * @throws {Error} if the storage entry at the given position is corrupted.
213
196
  * @throws {InvalidDataSizeError} if the document size at the given position does not match the provided size.
214
197
  * @throws {CorruptFileError} if the document at the given position can not be read completely.
@@ -221,10 +204,6 @@ class ReadablePartition extends EventEmitter {
221
204
  throw new InvalidDataSizeError(`Invalid document size ${dataSize} at position ${position}, expected ${size}.`);
222
205
  }
223
206
 
224
- if (position + dataSize + DOCUMENT_HEADER_SIZE > this.size) {
225
- throw new CorruptFileError(`Invalid document at position ${position}. This may be caused by an unfinished write.`);
226
- }
227
-
228
207
  const sequenceNumber = buffer.readUInt32BE(offset + 4);
229
208
  const time64 = buffer.readDoubleBE(offset + 8);
230
209
  return ({ dataSize, sequenceNumber, time64 });
@@ -249,23 +228,41 @@ class ReadablePartition extends EventEmitter {
249
228
  return ({ buffer: this.readBuffer, cursor: bufferCursor, length: this.readBufferLength });
250
229
  }
251
230
 
231
+ /**
232
+ * Prepare the read buffer for reading *before* the specified position. Don't try to reader *after* the returned cursor.
233
+ *
234
+ * @protected
235
+ * @param {number} position The position in the file to prepare the read buffer for reading before.
236
+ * @returns {{ buffer: Buffer|null, cursor: number, length: number }} A reader object with properties `buffer`, `cursor` and `length`.
237
+ */
238
+ prepareReadBufferBackwards(position) {
239
+ if (position < 0) {
240
+ return ({ buffer: null, cursor: 0, length: 0 });
241
+ }
242
+ let bufferCursor = position - this.readBufferPos;
243
+ if (this.readBufferPos < 0 || (this.readBufferPos > 0 && bufferCursor < DOCUMENT_FOOTER_SIZE) || bufferCursor > this.readBufferLength) {
244
+ this.fillBuffer(Math.max(position - this.readBuffer.byteLength, 0));
245
+ bufferCursor = position - this.readBufferPos;
246
+ }
247
+ return ({ buffer: this.readBuffer, cursor: bufferCursor, length: this.readBufferLength });
248
+ }
249
+
252
250
  /**
253
251
  * Read the data from the given position.
254
252
  *
255
253
  * @api
256
254
  * @param {number} position The file position to read from.
257
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.
258
258
  * @returns {string|boolean} The data stored at the given position or false if no data could be read.
259
259
  * @throws {Error} if the storage entry at the given position is corrupted.
260
260
  * @throws {InvalidDataSizeError} if the document size at the given position does not match the provided size.
261
261
  * @throws {CorruptFileError} if the document at the given position can not be read completely.
262
262
  */
263
- readFrom(position, size = 0) {
264
- if (!this.fd) {
265
- return false;
266
- }
267
-
268
- assert((position % DOCUMENT_ALIGNMENT) === 0, `Invalid read position. Needs to be a multiple of ${DOCUMENT_ALIGNMENT}.`);
263
+ readFrom(position, size = 0, headerOut = null) {
264
+ assert(this.fd, 'Partition is not opened.');
265
+ assert((position % DOCUMENT_ALIGNMENT) === 0, `Invalid read position ${position}. Needs to be a multiple of ${DOCUMENT_ALIGNMENT}.`);
269
266
 
270
267
  const reader = this.prepareReadBuffer(position);
271
268
  if (reader.length < size + DOCUMENT_HEADER_SIZE) {
@@ -273,7 +270,18 @@ class ReadablePartition extends EventEmitter {
273
270
  }
274
271
 
275
272
  let dataPosition = reader.cursor + DOCUMENT_HEADER_SIZE;
276
- 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
+ }
279
+
280
+ // TODO: This should only be checked on opening
281
+ const writeSize = this.documentWriteSize(dataSize);
282
+ if (position + writeSize > this.size) {
283
+ throw new CorruptFileError(`Invalid document at position ${position}. This may be caused by an unfinished write.`);
284
+ }
277
285
 
278
286
  if (dataSize + DOCUMENT_HEADER_SIZE > reader.buffer.byteLength) {
279
287
  //console.log('sync read for large document size', dataLength, 'at position', position);
@@ -291,21 +299,165 @@ class ReadablePartition extends EventEmitter {
291
299
  }
292
300
 
293
301
  /**
302
+ * Find the start position of the document that precedes the given position.
303
+ *
304
+ * @protected
305
+ * @param {number} position The file position to read backwards from.
306
+ * @returns {number|boolean} The start position of the first document before the given position or false if no header could be found.
307
+ */
308
+ findDocumentPositionBefore(position) {
309
+ assert(this.fd, 'Partition is not opened.');
310
+ position -= (position % DOCUMENT_ALIGNMENT);
311
+ if (position <= 0) {
312
+ return false;
313
+ }
314
+
315
+ const separatorSize = DOCUMENT_SEPARATOR.length;
316
+ // Optimization if we are at an exact document boundary, where we can just read the document size
317
+ let reader = this.prepareReadBufferBackwards(position);
318
+ const block = reader.buffer.toString('ascii', reader.cursor - separatorSize, reader.cursor);
319
+ if (block === DOCUMENT_SEPARATOR) {
320
+ const dataSize = reader.buffer.readUInt32BE(reader.cursor - separatorSize - 4);
321
+ return position - this.documentWriteSize(dataSize);
322
+ }
323
+
324
+ do {
325
+ reader = this.prepareReadBufferBackwards(position - separatorSize);
326
+
327
+ const bufferSeparatorPosition = reader.buffer.lastIndexOf(DOCUMENT_SEPARATOR, reader.cursor - separatorSize, 'ascii');
328
+ if (bufferSeparatorPosition >= 0) {
329
+ position = this.readBufferPos + bufferSeparatorPosition + separatorSize;
330
+ break;
331
+ }
332
+ position -= this.readBufferLength;
333
+ } while (position > 0);
334
+ return Math.max(0, position);
335
+ }
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
+ *
294
366
  * @api
295
- * @returns {Generator} A generator that returns all documents in this partition.
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.
296
369
  */
297
- *readAll() {
298
- let position = 0;
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
+
421
+ /**
422
+ * @api
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.
427
+ * @returns {Generator<string>} A generator that returns all documents in this partition.
428
+ */
429
+ *readAll(after = 0, headerOut = null) {
430
+ let position = after < 0 ? this.size + after + 1 : after;
431
+ const internalHeader = headerOut !== null ? headerOut : {};
299
432
  let data;
300
- while ((data = this.readFrom(position)) !== false) {
433
+ while ((data = this.readFrom(position, 0, internalHeader)) !== false) {
434
+ if (headerOut !== null) {
435
+ headerOut.position = position;
436
+ }
301
437
  yield data;
302
- position += this.documentWriteSize(Buffer.byteLength(data, 'utf8'));
438
+ position += this.documentWriteSize(internalHeader.dataSize);
303
439
  }
304
440
  }
305
441
 
442
+ /**
443
+ * @api
444
+ * @param {number} [before] The document position to start reading backward from.
445
+ * @returns {Generator<string>} A generator that returns all documents in this partition in reverse order.
446
+ */
447
+ *readAllBackwards(before = -1) {
448
+ let position = before < 0 ? this.size + before + 1 : before;
449
+ while ((position = this.findDocumentPositionBefore(position)) !== false) {
450
+ const data = this.readFrom(position);
451
+ yield data;
452
+ }
453
+ }
306
454
  }
307
455
 
308
456
  module.exports = ReadablePartition;
309
457
  module.exports.CorruptFileError = CorruptFileError;
310
458
  module.exports.InvalidDataSizeError = InvalidDataSizeError;
311
- module.exports.HEADER_MAGIC = HEADER_MAGIC;
459
+ module.exports.HEADER_MAGIC = HEADER_MAGIC;
460
+ module.exports.DOCUMENT_SEPARATOR = DOCUMENT_SEPARATOR;
461
+ module.exports.DOCUMENT_ALIGNMENT = DOCUMENT_ALIGNMENT;
462
+ module.exports.DOCUMENT_HEADER_SIZE = DOCUMENT_HEADER_SIZE;
463
+ module.exports.DOCUMENT_FOOTER_SIZE = DOCUMENT_FOOTER_SIZE;
@@ -1,24 +1,11 @@
1
1
  const fs = require('fs');
2
- const mkdirpSync = require('mkdirp').sync;
3
2
  const ReadablePartition = require('./ReadablePartition');
4
- const { assert, buildMetadataHeader } = require('../util');
3
+ const { assert, buildMetadataHeader, alignTo, ensureDirectory } = require('../util');
5
4
  const Clock = require('../Clock');
6
5
 
7
6
  const DEFAULT_WRITE_BUFFER_SIZE = 16 * 1024;
8
- const DOCUMENT_HEADER_SIZE = 16;
9
- const DOCUMENT_ALIGNMENT = 4;
10
- const DOCUMENT_PAD = ' '.repeat(15) + "\n";
11
-
12
- const NES_EPOCH = new Date('2020-01-01T00:00:00');
13
-
14
- /**
15
- * @param {number} dataSize
16
- * @returns {string} The data padded to 16 bytes alignment and ended with a line break.
17
- */
18
- function padData(dataSize) {
19
- const padSize = (DOCUMENT_ALIGNMENT - ((dataSize + 1) % DOCUMENT_ALIGNMENT)) % DOCUMENT_ALIGNMENT;
20
- return DOCUMENT_PAD.substr(-padSize - 1);
21
- }
7
+ const { DOCUMENT_ALIGNMENT, DOCUMENT_SEPARATOR, DOCUMENT_HEADER_SIZE, DOCUMENT_FOOTER_SIZE } = ReadablePartition;
8
+ const DOCUMENT_PAD = ' '.repeat(DOCUMENT_ALIGNMENT);
22
9
 
23
10
  /**
24
11
  * A partition is a single file where the storage will write documents to depending on some partitioning rules.
@@ -49,11 +36,10 @@ class WritablePartition extends ReadablePartition {
49
36
  },
50
37
  clock: Clock
51
38
  };
39
+ config.metadata = Object.assign(defaults.metadata, config.metadata);
52
40
  config = Object.assign(defaults, config);
53
41
  super(name, config);
54
- if (!fs.existsSync(this.dataDirectory)) {
55
- mkdirpSync(this.dataDirectory);
56
- }
42
+ ensureDirectory(this.dataDirectory);
57
43
  this.fileMode = 'a+';
58
44
  this.writeBufferSize = config.writeBufferSize >>> 0; // jshint ignore:line
59
45
  this.maxWriteBufferDocuments = config.maxWriteBufferDocuments >>> 0; // jshint ignore:line
@@ -75,25 +61,28 @@ class WritablePartition extends ReadablePartition {
75
61
  return true;
76
62
  }
77
63
 
78
- this.writeBuffer = Buffer.allocUnsafeSlow(this.writeBufferSize);
64
+ let success = super.open();
65
+ if (!success) {
66
+ if (this.size + this.headerSize !== 0) {
67
+ // If file is not empty, we can not open and initialize it
68
+ return false;
69
+ }
70
+ this.writeMetadata();
71
+ success = super.open();
72
+ }
73
+
74
+ this.writeBuffer = success && Buffer.allocUnsafeSlow(this.writeBufferSize);
79
75
  // Where inside the write buffer the next write is added
80
76
  this.writeBufferCursor = 0;
81
77
  // How many documents are currently in the write buffer
82
78
  this.writeBufferDocuments = 0;
83
79
  this.flushCallbacks = [];
80
+ // Pre-allocated buffer for document header (16 bytes) + size footer (4 bytes) used in writeUnbuffered
81
+ this.writeMetaBuffer = Buffer.allocUnsafe(DOCUMENT_HEADER_SIZE + 4);
84
82
 
85
- if (super.open() === false) {
86
- const stat = fs.statSync(this.fileName);
87
- if (stat.size !== 0) {
88
- return false;
89
- }
90
- this.metadata.epoch = Date.now();
91
- this.writeMetadata();
92
- this.size = 0;
93
- }
94
- this.clock = new this.ClockConstructor(this.metadata.epoch || NES_EPOCH.getTime());
83
+ this.clock = new this.ClockConstructor(this.metadata.epoch);
95
84
 
96
- return true;
85
+ return success;
97
86
  }
98
87
 
99
88
  /**
@@ -103,14 +92,14 @@ class WritablePartition extends ReadablePartition {
103
92
  * @returns void
104
93
  */
105
94
  close() {
106
- if (this.fd) {
95
+ if (this.fd && this.writeBuffer) {
107
96
  this.flush();
108
97
  fs.fsyncSync(this.fd);
109
- }
110
- if (this.writeBuffer) {
98
+
111
99
  this.writeBuffer = null;
112
100
  this.writeBufferCursor = 0;
113
101
  this.writeBufferDocuments = 0;
102
+ this.writeMetaBuffer = null;
114
103
  }
115
104
  super.close();
116
105
  }
@@ -123,7 +112,7 @@ class WritablePartition extends ReadablePartition {
123
112
  */
124
113
  writeMetadata() {
125
114
  const metadataBuffer = buildMetadataHeader(ReadablePartition.HEADER_MAGIC, this.metadata);
126
- fs.writeSync(this.fd, metadataBuffer, 0, metadataBuffer.byteLength, 0);
115
+ fs.writeFileSync(this.fileName, metadataBuffer);
127
116
  this.headerSize = metadataBuffer.byteLength;
128
117
  }
129
118
 
@@ -148,8 +137,9 @@ class WritablePartition extends ReadablePartition {
148
137
 
149
138
  this.writeBufferCursor = 0;
150
139
  this.writeBufferDocuments = 0;
151
- this.flushCallbacks.forEach(callback => callback());
140
+ const callbacks = this.flushCallbacks;
152
141
  this.flushCallbacks = [];
142
+ for (let i = 0; i < callbacks.length; i++) callbacks[i]();
153
143
 
154
144
  return true;
155
145
  }
@@ -192,6 +182,7 @@ class WritablePartition extends ReadablePartition {
192
182
  if (time64 === null) {
193
183
  time64 = this.clock.time();
194
184
  }
185
+ /* istanbul ignore if */
195
186
  if (time64 < 0) {
196
187
  throw new Error('Time may not be negative!');
197
188
  }
@@ -212,12 +203,16 @@ class WritablePartition extends ReadablePartition {
212
203
  */
213
204
  writeUnbuffered(data, dataSize, sequenceNumber, callback) {
214
205
  this.flush();
215
- const dataHeader = Buffer.alloc(DOCUMENT_HEADER_SIZE);
216
- this.writeDocumentHeader(dataHeader, 0, dataSize, sequenceNumber);
206
+ this.writeDocumentHeader(this.writeMetaBuffer, 0, dataSize, sequenceNumber);
217
207
 
218
208
  let bytesWritten = 0;
219
- bytesWritten += fs.writeSync(this.fd, dataHeader);
209
+ bytesWritten += fs.writeSync(this.fd, this.writeMetaBuffer, 0, DOCUMENT_HEADER_SIZE);
220
210
  bytesWritten += fs.writeSync(this.fd, data);
211
+ const padSize = alignTo(dataSize + DOCUMENT_FOOTER_SIZE, DOCUMENT_ALIGNMENT);
212
+ bytesWritten += fs.writeSync(this.fd, DOCUMENT_PAD.substr(0, padSize));
213
+ this.writeMetaBuffer.writeUInt32BE(dataSize, 0);
214
+ bytesWritten += fs.writeSync(this.fd, this.writeMetaBuffer, 0, 4);
215
+ bytesWritten += fs.writeSync(this.fd, DOCUMENT_SEPARATOR);
221
216
  if (typeof callback === 'function') {
222
217
  process.nextTick(callback);
223
218
  }
@@ -234,12 +229,17 @@ class WritablePartition extends ReadablePartition {
234
229
  * @returns {number} Number of bytes written.
235
230
  */
236
231
  writeBuffered(data, dataSize, sequenceNumber, callback) {
237
- const bytesToWrite = Buffer.byteLength(data, 'utf8') + DOCUMENT_HEADER_SIZE;
232
+ const bytesToWrite = this.documentWriteSize(dataSize);
238
233
  this.flushIfWriteBufferTooSmall(bytesToWrite);
239
234
 
240
235
  let bytesWritten = 0;
241
236
  bytesWritten += this.writeDocumentHeader(this.writeBuffer, this.writeBufferCursor, dataSize, sequenceNumber);
242
237
  bytesWritten += this.writeBuffer.write(data, this.writeBufferCursor + bytesWritten, 'utf8');
238
+ const padSize = alignTo(dataSize + DOCUMENT_FOOTER_SIZE, DOCUMENT_ALIGNMENT);
239
+ bytesWritten += this.writeBuffer.write(DOCUMENT_PAD.substr(0, padSize), this.writeBufferCursor + bytesWritten, 'utf8');
240
+ this.writeBuffer.writeUInt32BE(dataSize, this.writeBufferCursor + bytesWritten);
241
+ bytesWritten += 4;
242
+ bytesWritten += this.writeBuffer.write(DOCUMENT_SEPARATOR, this.writeBufferCursor + bytesWritten, 'utf8');
243
243
  this.writeBufferCursor += bytesWritten;
244
244
  this.writeBufferDocuments++;
245
245
  if (typeof callback === 'function') {
@@ -257,9 +257,7 @@ class WritablePartition extends ReadablePartition {
257
257
  * @returns {number|boolean} The file position at which the data was written or false on error.
258
258
  */
259
259
  write(data, sequenceNumber, callback) {
260
- if (!this.fd) {
261
- return false;
262
- }
260
+ assert(this.fd, 'Partition is not opened.');
263
261
  if (typeof sequenceNumber === 'function') {
264
262
  callback = sequenceNumber;
265
263
  sequenceNumber = null;
@@ -267,7 +265,6 @@ class WritablePartition extends ReadablePartition {
267
265
  const dataSize = Buffer.byteLength(data, 'utf8');
268
266
  assert(dataSize <= 64 * 1024 * 1024, 'Document is too large! Maximum is 64 MB');
269
267
 
270
- data += padData(dataSize);
271
268
  const dataPosition = this.size;
272
269
  if (dataSize + DOCUMENT_HEADER_SIZE >= this.writeBuffer.byteLength * 4 / 5) {
273
270
  this.size += this.writeUnbuffered(data, dataSize, sequenceNumber, callback);
@@ -298,7 +295,42 @@ class WritablePartition extends ReadablePartition {
298
295
  }
299
296
 
300
297
  /**
301
- * Truncate the internal read buffer after the given position.
298
+ * Prepare the read buffer for reading *before* the specified position. Don't try to read *after* the returned cursor.
299
+ *
300
+ * @protected
301
+ * @param {number} position The position in the file to prepare the read buffer for reading before.
302
+ * @returns {object} A reader object with properties `buffer`, `cursor` and `length`.
303
+ */
304
+ prepareReadBufferBackwards(position) {
305
+ const bufferPos = this.size - this.writeBufferCursor;
306
+ // Handle the case when data that is still in write buffer is supposed to be read backwards
307
+ if (this.dirtyReads && this.writeBufferCursor > 0 && position > bufferPos) {
308
+ return { buffer: this.writeBuffer, cursor: position - bufferPos, length: this.writeBufferCursor };
309
+ }
310
+ return super.prepareReadBufferBackwards(position);
311
+ }
312
+
313
+ /**
314
+ * Read all documents in reverse write order, ignoring any unflushed write-buffer data when
315
+ * dirty reads are disabled.
316
+ *
317
+ * @api
318
+ * @param {number} [before] The document position to start reading backward from.
319
+ * @returns {Generator<string>} A generator that returns all documents in this partition in reverse order.
320
+ */
321
+ *readAllBackwards(before = -1) {
322
+ if (!this.dirtyReads && this.writeBufferCursor > 0) {
323
+ const flushedSize = this.size - this.writeBufferCursor;
324
+ const clampedBefore = before < 0 ? flushedSize : Math.min(before, flushedSize);
325
+ yield* super.readAllBackwards(clampedBefore);
326
+ return;
327
+ }
328
+ yield* super.readAllBackwards(before);
329
+ }
330
+
331
+ /**
332
+ *
333
+ * @internal
302
334
  * @param {number} after The byte position to truncate the read buffer after.
303
335
  */
304
336
  truncateReadBuffer(after) {
@@ -310,39 +342,80 @@ class WritablePartition extends ReadablePartition {
310
342
  }
311
343
  }
312
344
 
345
+ /**
346
+ * Truncate the partition, removing all documents with sequenceNumber > after.
347
+ * Scans the partition file backwards to find the cutoff position.
348
+ *
349
+ * @api
350
+ * @param {number} after Keep all documents with sequenceNumber <= after. Documents with
351
+ * sequenceNumber > after (and any torn write at the end) are removed.
352
+ */
353
+ truncateAfterSequence(after) {
354
+ let position = this.size;
355
+ let truncateAt = this.size; // default: nothing to truncate
356
+ while ((position = this.findDocumentPositionBefore(position)) !== false) {
357
+ const reader = this.prepareReadBufferBackwards(position);
358
+ if (!reader.buffer) break;
359
+ const { sequenceNumber } = this.readDocumentHeader(reader.buffer, reader.cursor, position);
360
+ if (sequenceNumber > after) {
361
+ // This document must be removed; record its start as the new cutoff.
362
+ truncateAt = position;
363
+ } else {
364
+ break;
365
+ }
366
+ }
367
+ this.truncate(truncateAt);
368
+ }
369
+
313
370
  /**
314
371
  * Truncate the partition storage at the given position.
315
372
  *
373
+ * @api
316
374
  * @param {number} after The file position after which to truncate the partition.
317
375
  */
318
376
  truncate(after) {
319
- if (after > this.size) {
377
+ if (after >= this.size) {
320
378
  return;
321
379
  }
322
- if (after < 0) {
323
- after = 0;
324
- }
380
+ this.open();
381
+ after = Math.max(0, after);
325
382
  this.flush();
326
383
 
327
- let position = after, data;
384
+ // Always save the truncated part for manual recovery, even if it contains corrupted data
385
+ this.branchOff('truncated-' + Date.now(), after);
386
+
328
387
  try {
329
- data = this.readFrom(position);
388
+ this.readFrom(after);
330
389
  } catch (e) {
331
- throw new Error('Can only truncate on valid document boundaries.');
332
- }
333
- // copy all truncated documents to some delete log
334
- const deletedBranch = new WritablePartition(this.name + '-' + after + '.branch', { dataDirectory: this.dataDirectory });
335
- deletedBranch.open();
336
- while (data) {
337
- deletedBranch.write(data);
338
- position += this.documentWriteSize(Buffer.byteLength(data, 'utf8'));
339
- data = this.readFrom(position);
390
+ if (!(e instanceof ReadablePartition.CorruptFileError)) {
391
+ throw new Error('Can only truncate on valid document boundaries.');
392
+ }
340
393
  }
341
- deletedBranch.close();
342
394
 
343
395
  fs.truncateSync(this.fileName, this.headerSize + after);
344
396
  this.truncateReadBuffer(after);
345
397
  this.size = after;
398
+ this.emit('truncated', after);
399
+ }
400
+
401
+ /**
402
+ * Create a branch of this partition starting from the given position.
403
+ *
404
+ * @internal
405
+ * @param {string} branchName The name that identifies the branch (will be prefixed with this partition name and suffixed with the position)
406
+ * @param {number} position The file position from where to branch off
407
+ * @returns {WritablePartition} The branched off partition
408
+ */
409
+ branchOff(branchName, position) {
410
+ const deletedBranch = new WritablePartition(this.name + '-' + branchName + '-' + position + '.branch', { dataDirectory: this.dataDirectory, metadata: { epoch: this.metadata.epoch } });
411
+ deletedBranch.open();
412
+ do {
413
+ const reader = this.prepareReadBuffer(position);
414
+ fs.writeSync(deletedBranch.fd, reader.buffer, reader.cursor, reader.length);
415
+ position += reader.length;
416
+ } while (position < this.size);
417
+ deletedBranch.close();
418
+ return deletedBranch;
346
419
  }
347
420
  }
348
421