event-storage 1.1.0 → 1.2.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.
@@ -3,13 +3,14 @@ import path from 'path';
3
3
  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
- import { assert, wrapAndCheck, kWayMerge } from '../util.js';
7
- import { scanForFiles } from '../fsUtil.js';
8
- import { createHmac, matches, buildMetadataForMatcher } from '../metadataUtil.js';
6
+ import { assert, wrapAndCheck, iterate, kWayMerge } from '../utils/util.js';
7
+ import { scanForFiles } from '../utils/fsUtil.js';
8
+ import { createHmac, matches, buildMetadataForMatcher } from '../utils/metadataUtil.js';
9
9
  import IndexMatcher from '../IndexMatcher.js';
10
10
  import PartitionPool from '../PartitionPool.js';
11
11
 
12
12
  const DEFAULT_READ_BUFFER_SIZE = 4 * 1024;
13
+ const NDJSON_NEWLINE = Buffer.from('\n');
13
14
 
14
15
  /**
15
16
  * Default ordered list of document property paths used as discriminant keys when
@@ -26,18 +27,6 @@ const DEFAULT_MATCHER_PROPERTIES = ['stream', 'payload.type'];
26
27
  */
27
28
  const DEFAULT_MAX_OPEN_PARTITIONS = 1024;
28
29
 
29
- /**
30
- * Reverses the items of an iterable
31
- * @param {Generator|Iterable} iterator
32
- * @returns {Generator<*>}
33
- */
34
- function *reverse(iterator) {
35
- const items = Array.from(iterator);
36
- for (let i = items.length - 1; i >= 0; i--) {
37
- yield items[i];
38
- }
39
- }
40
-
41
30
  /**
42
31
  * @typedef {object|function(object):boolean} Matcher
43
32
  */
@@ -284,16 +273,19 @@ class ReadableStorage extends events.EventEmitter {
284
273
  * @param {number} partitionId The partition to read from.
285
274
  * @param {number} position The file position to read from.
286
275
  * @param {number} [size] The expected byte size of the document at the given position.
287
- * @returns {object} The document stored at the given position.
276
+ * @param {boolean} [raw] Whether to return raw buffers instead of deserialized objects. Default false.
277
+ * @param {boolean} [backwardsHint] If set to true, will optimize buffering for backwards reading.
278
+ * @returns {object|{ buffer: Buffer, time64: number, sequenceNumber: number }} The document stored at the given position.
288
279
  * @throws {Error} if the document at the given position can not be deserialized.
289
280
  */
290
- readFrom(partitionId, position, size) {
281
+ readFrom(partitionId, position, size, raw = false, backwardsHint = false) {
291
282
  const partition = this.getPartition(partitionId);
292
283
  if (this.listenerCount('preRead') > 0) {
293
284
  this.emit('preRead', position, partition.metadata);
294
285
  }
295
- const data = partition.readFrom(position, size);
296
- return this.serializer.deserialize(data);
286
+ const headerOut = {};
287
+ const buffer = partition.readFrom(position, size, headerOut, backwardsHint);
288
+ return raw ? { buffer, time64: headerOut.time64, sequenceNumber: headerOut.sequenceNumber } : this.serializer.deserialize(buffer.toString('utf8'));
297
289
  }
298
290
 
299
291
  /**
@@ -306,10 +298,7 @@ class ReadableStorage extends events.EventEmitter {
306
298
  */
307
299
  read(number, index) {
308
300
  index = index || this.index;
309
-
310
- if (!index.isOpen()) {
311
- index.open();
312
- }
301
+ index.open();
313
302
 
314
303
  const entry = index.get(number);
315
304
  if (entry === false) {
@@ -329,30 +318,22 @@ class ReadableStorage extends events.EventEmitter {
329
318
  * @param {ReadableIndex|false} [index] The index to use for finding the documents in the range.
330
319
  * Pass `false` to skip the global index and iterate all partitions directly in sequenceNumber order
331
320
  * (useful when the global index is unavailable or corrupted).
321
+ * @param {boolean} [raw] Whether to return raw buffers instead of deserialized objects. Default false.
332
322
  * @returns {Generator<object>} A generator that will read each document in the range one by one.
333
323
  */
334
- *readRange(from, until = -1, index = null) {
335
- const lengthSource = index || this.index;
336
- if (!lengthSource.isOpen()) {
337
- lengthSource.open();
324
+ *readRange(from, until = -1, index = null, raw = false) {
325
+ let length = Number.MAX_SAFE_INTEGER;
326
+ if (index !== false) {
327
+ index = index || this.index;
328
+ index.open();
329
+ length = index.length;
338
330
  }
339
331
 
340
- const readFrom = wrapAndCheck(from, lengthSource.length);
341
- const readUntil = wrapAndCheck(until, lengthSource.length);
332
+ const readFrom = wrapAndCheck(from, length);
333
+ const readUntil = wrapAndCheck(until, length);
342
334
  assert(readFrom > 0 && readUntil > 0, `Range scan error for range ${from} - ${until}.`);
343
335
 
344
- if (readFrom > readUntil) {
345
- const batchSize = 10;
346
- let batchUntil = readFrom;
347
- while (batchUntil >= readUntil) {
348
- const batchFrom = Math.max(readUntil, batchUntil - batchSize);
349
- yield* reverse(this.iterateRange(batchFrom, batchUntil, index));
350
- batchUntil = batchFrom - 1;
351
- }
352
- return undefined;
353
- }
354
-
355
- yield* this.iterateRange(readFrom, readUntil, index);
336
+ yield* this.iterateRange(readFrom, readUntil, index, raw);
356
337
  }
357
338
 
358
339
  /**
@@ -362,23 +343,25 @@ class ReadableStorage extends events.EventEmitter {
362
343
  * @param {number} from
363
344
  * @param {number} until
364
345
  * @param {ReadableIndex|false|null} index
346
+ * @param {boolean} [raw] Whether to return raw buffers instead of deserialized objects. Default false.
365
347
  * @returns {Generator<object>}
366
348
  */
367
- *iterateRange(from, until, index) {
349
+ *iterateRange(from, until, index, raw = false) {
368
350
  if (index === false) {
369
- // Explicitly disabled index: iterate all partitions and merge by sequenceNumber.
370
- // Document header sequenceNumber is 0-based; from/until are 1-based index positions.
371
- for (const entry of this.iterateDocumentsNoIndex(from - 1, until - 1)) {
372
- yield entry.document;
351
+ for (const { document } of this.iterateDocumentsNoIndex(from - 1, until - 1)) {
352
+ yield document;
373
353
  }
374
354
  return;
375
355
  }
376
356
 
377
357
  const idx = index || this.index;
378
- const entries = idx.range(from, until);
379
- for (let entry of entries) {
380
- const document = this.readFrom(entry.partition, entry.position, entry.size);
381
- yield document;
358
+ const forwards = from <= until;
359
+ const lo = Math.min(from, until);
360
+ const hi = Math.max(from, until);
361
+ const entries = idx.range(lo, hi);
362
+ if (!entries) return;
363
+ for (const entry of iterate(entries, forwards)) {
364
+ yield this.readFrom(entry.partition, entry.position, entry.size, raw, !forwards);
382
365
  }
383
366
  }
384
367
 
@@ -448,62 +431,50 @@ class ReadableStorage extends events.EventEmitter {
448
431
  }
449
432
  }
450
433
 
451
- /**
452
- * Iterate documents across all partitions in sequenceNumber order using a k-way merge.
453
- * Opens any closed partition automatically.
454
- *
455
- * @protected
456
- * @param {number} [from=0] The 0-based sequenceNumber to start from (inclusive).
457
- * @param {number} [until=Number.MAX_SAFE_INTEGER] The 0-based sequenceNumber to read until (inclusive).
458
- * @returns {Generator<{document: object, sequenceNumber: number, partitionName: string, position: number, size: number, partition: number}>}
459
- */
460
- *iterateDocumentsNoIndex(from = 0, until = Number.MAX_SAFE_INTEGER) {
461
- const streams = [];
462
-
463
- this.forEachPartition(partition => {
464
- if (!partition.isOpen()) {
465
- partition.open();
466
- }
467
-
468
- const found = partition.findDocument(from);
469
- if (found && found.headerOut.sequenceNumber <= until) {
470
- const nextPosition = found.headerOut.position + partition.documentWriteSize(found.headerOut.dataSize);
471
- const reader = partition.readAll(nextPosition, found.headerOut);
472
- streams.push({ ...found, reader, partition: partition.id, partitionName: partition.name });
473
- }
474
- });
475
-
476
- const items = [];
477
- kWayMerge(
478
- streams,
479
- stream => stream.headerOut.sequenceNumber,
480
- stream => {
481
- const next = stream.reader.next();
482
- if (!next.done && stream.headerOut.sequenceNumber <= until) {
483
- stream.data = next.value;
484
- return true;
485
- }
486
- return false;
487
- },
488
- stream => items.push({
489
- document: this.serializer.deserialize(stream.data),
490
- sequenceNumber: stream.headerOut.sequenceNumber,
491
- partitionName: stream.partitionName,
492
- position: stream.headerOut.position,
493
- size: stream.headerOut.dataSize,
494
- partition: stream.partition,
495
- })
496
- );
497
-
498
- yield* items;
499
- }
434
+ /**
435
+ * Build the standard document result entry from a readRange yield.
436
+ * @private
437
+ * @param {{ data: Buffer, entry: { number: number, position: number, size: number, partition: number } }} [readItem]
438
+ */
439
+ buildDocumentEntry(readItem) {
440
+ return {
441
+ document: this.serializer.deserialize(readItem.data.toString('utf8')),
442
+ // Replicate the index entry structure here, so iteration can be used easily to reindex
443
+ entry: readItem.entry
444
+ };
445
+ }
446
+
447
+ /**
448
+ * Iterate documents across all partitions in sequenceNumber order using a k-way merge.
449
+ * Opens any closed partition automatically.
450
+ *
451
+ * @protected
452
+ * @param {number} [from=0] The 0-based sequenceNumber to start from (inclusive).
453
+ * @param {number} [until=Number.MAX_SAFE_INTEGER] The 0-based sequenceNumber to read until (inclusive).
454
+ * @returns {Generator<{document: object, entry: { sequenceNumber: number, position: number, size: number, partition: number }}>}
455
+ */
456
+ *iterateDocumentsNoIndex(from = 0, until = Number.MAX_SAFE_INTEGER) {
457
+ const forwards = from <= until;
458
+ const partitions = [];
459
+ this.forEachPartition(partition => {
460
+ partition.open();
461
+ partitions.push(partition.readRange(from, until));
462
+ });
463
+
464
+ yield* kWayMerge(
465
+ partitions,
466
+ item => item.entry.number,
467
+ forwards,
468
+ item => this.buildDocumentEntry(item)
469
+ );
470
+ }
500
471
 
501
472
  /**
502
473
  * Helper method to iterate over all documents, invoking a callback for each one.
503
474
  * Pass `noIndex = true` to iterate all partitions directly in sequenceNumber order
504
475
  * (useful when the global index is unavailable or corrupted).
505
476
  * When `noIndex` is false the second callback argument is the raw index `EntryInterface`.
506
- * When `noIndex` is true the second callback argument has `{ partition, position, size, sequenceNumber, partitionName }`.
477
+ * When `noIndex` is true the second callback argument has `{ partition, position, size, sequenceNumber }`.
507
478
  *
508
479
  * @protected
509
480
  * @param {function(object, object): void} iterationHandler
@@ -516,8 +487,8 @@ class ReadableStorage extends events.EventEmitter {
516
487
  }
517
488
 
518
489
  if (noIndex) {
519
- for (const { document, ...entryInfo } of this.iterateDocumentsNoIndex()) {
520
- iterationHandler(document, entryInfo);
490
+ for (const { document, entry } of this.iterateDocumentsNoIndex()) {
491
+ iterationHandler(document, entry);
521
492
  }
522
493
  return;
523
494
  }
@@ -3,9 +3,9 @@ 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 } from '../util.js';
7
- import { ensureDirectory } from '../fsUtil.js';
8
- import { matches, buildMetadataForMatcher, buildMatcherFromMetadata } from '../metadataUtil.js';
6
+ import { assert } from '../utils/util.js';
7
+ import { ensureDirectory } from '../utils/fsUtil.js';
8
+ import { matches, buildMetadataForMatcher, buildMatcherFromMetadata } from '../utils/metadataUtil.js';
9
9
 
10
10
  const DEFAULT_WRITE_BUFFER_SIZE = 16 * 1024;
11
11
 
@@ -64,6 +64,7 @@ class WritableStorage extends ReadableStorage {
64
64
  this.lockFile = path.resolve(this.dataDirectory, this.storageFile + '.lock');
65
65
  this._lockMode = config.lock;
66
66
  this.partitioner = config.partitioner;
67
+ this.partitionIds = {};
67
68
  }
68
69
 
69
70
  /**
@@ -204,8 +205,8 @@ class WritableStorage extends ReadableStorage {
204
205
 
205
206
  // Scan partitions in sequence-number order and rebuild index entries.
206
207
  // iterateDocumentsNoIndex opens any closed partitions automatically.
207
- for (const { document, partition, position, size } of this.iterateDocumentsNoIndex(fromSequenceNumber, Number.MAX_SAFE_INTEGER)) {
208
- const newEntry = new WritableIndexEntry(this.index.length + 1, position, size, partition);
208
+ for (const { document, entry } of this.iterateDocumentsNoIndex(fromSequenceNumber)) {
209
+ const newEntry = new WritableIndexEntry(this.index.length + 1, entry.position, entry.size, entry.partition);
209
210
  this.index.add(newEntry);
210
211
 
211
212
  this.forEachWritableSecondaryIndex((secIndex) => {
@@ -231,9 +232,8 @@ class WritableStorage extends ReadableStorage {
231
232
  this.locked = true;
232
233
  } catch (e) {
233
234
  /* istanbul ignore if */
234
- if (e.code !== 'EEXIST') {
235
- throw new Error(`Error creating lock for storage ${this.storageFile}: ` + e.message);
236
- }
235
+ assert(e.code === 'EEXIST', `Error creating lock for storage ${this.storageFile}: ` + e.message)
236
+
237
237
  throw new StorageLockedError(`Storage ${this.storageFile} is locked by another process`);
238
238
  }
239
239
  return true;
@@ -311,6 +311,36 @@ class WritableStorage extends ReadableStorage {
311
311
  this.on('preCommit', hook);
312
312
  }
313
313
 
314
+ getPartitionIdForName(partitionShortName, partitionName) {
315
+ const partitionId = this.partitionIds[partitionShortName] ?? WritablePartition.idFor(partitionName);
316
+ this.partitionIds[partitionShortName] = partitionId;
317
+ return partitionId;
318
+ }
319
+
320
+ buildPartitionConfig(partitionShortName) {
321
+ if (typeof this.partitionConfig.metadata !== 'function') {
322
+ return this.partitionConfig;
323
+ }
324
+ return { ...this.partitionConfig, metadata: this.partitionConfig.metadata(partitionShortName) };
325
+ }
326
+
327
+ ensurePartitionDirectory(partitionName) {
328
+ if (!partitionName.includes('/')) {
329
+ return;
330
+ }
331
+ ensureDirectory(path.join(this.dataDirectory, path.dirname(partitionName)));
332
+ }
333
+
334
+ createNamedPartition(partitionId, partitionName, partitionShortName) {
335
+ if (this.partitions.has(partitionId)) {
336
+ return;
337
+ }
338
+ const partitionConfig = this.buildPartitionConfig(partitionShortName);
339
+ this.ensurePartitionDirectory(partitionName);
340
+ this.partitions.add(partitionId, this.createPartition(partitionName, partitionConfig));
341
+ this.emit('partition-created', partitionId);
342
+ }
343
+
314
344
  /**
315
345
  * Get a partition either by name or by id.
316
346
  * If a partition with the given name does not exist, a new one will be created.
@@ -327,17 +357,8 @@ class WritableStorage extends ReadableStorage {
327
357
  if (typeof partitionIdentifier === 'string') {
328
358
  const partitionShortName = partitionIdentifier;
329
359
  const partitionName = this.storageFile + (partitionIdentifier.length ? '.' + partitionIdentifier : '');
330
- partitionIdentifier = WritablePartition.idFor(partitionName);
331
- if (!this.partitions.has(partitionIdentifier)) {
332
- const partitionConfig = typeof this.partitionConfig.metadata === 'function'
333
- ? { ...this.partitionConfig, metadata: this.partitionConfig.metadata(partitionShortName) }
334
- : this.partitionConfig;
335
- if (partitionName.includes('/')) {
336
- ensureDirectory(path.join(this.dataDirectory, path.dirname(partitionName)));
337
- }
338
- this.partitions.add(partitionIdentifier, this.createPartition(partitionName, partitionConfig));
339
- this.emit('partition-created', partitionIdentifier);
340
- }
360
+ partitionIdentifier = this.getPartitionIdForName(partitionShortName, partitionName);
361
+ this.createNamedPartition(partitionIdentifier, partitionName, partitionShortName);
341
362
  }
342
363
  return super.getPartition(partitionIdentifier);
343
364
  }
@@ -354,18 +375,15 @@ class WritableStorage extends ReadableStorage {
354
375
 
355
376
  const partitionName = this.partitioner(document, this.index.length + 1);
356
377
  const partition = this.getPartition(partitionName);
357
- if (this.listenerCount('preCommit') > 0) {
358
- this.emit('preCommit', document, partition.metadata);
359
- }
378
+ this.emit('preCommit', document, partition.metadata);
379
+
360
380
  const position = partition.write(data, this.length, callback);
361
381
 
362
382
  assert(position !== false, 'Error writing document.');
363
383
 
364
384
  const indexEntry = this.addIndex(partition.id, position, dataSize, document);
365
385
  this.forEachSecondaryIndex((index, name) => {
366
- if (!index.isOpen()) {
367
- index.open();
368
- }
386
+ index.open();
369
387
  index.add(indexEntry);
370
388
  this.emit('index-add', name, index.length, document);
371
389
  }, document);
package/src/Watcher.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import events from 'events';
4
- import { assert } from './util.js';
4
+ import { assert } from './utils/util.js';
5
5
 
6
6
  /** @type {Map<string, DirectoryWatcher>} */
7
7
  const directoryWatchers = new Map();
@@ -0,0 +1,87 @@
1
+ const BYTE_QUOTE = 0x22;
2
+ const BYTE_ESCAPE = 0x5c;
3
+ const BYTE_OPEN_OBJECT = 0x7b;
4
+ const BYTE_CLOSE_OBJECT = 0x7d;
5
+ const BYTE_OPEN_ARRAY = 0x5b;
6
+ const BYTE_CLOSE_ARRAY = 0x5d;
7
+ const BYTE_COMMA = 0x2c;
8
+
9
+ /**
10
+ * Find the position of `pattern` within `buffer` at depth 0 (the top-level object), starting
11
+ * from `startOffset`. It scans character-by-character tracking JSON nesting depth and string
12
+ * quoting. If `matchPosition` arrives at depth > 0 it means the pattern is inside a nested
13
+ * object/array, so the scan continues searching for the next candidate at depth 0. Returns -1
14
+ * when no such position exists before the end of the buffer or when a closing brace reduces depth
15
+ * below zero (the top-level object has ended).
16
+ */
17
+ function indexOfSameLevel(buffer, pattern, startOffset = 0, matchPosition) {
18
+ /* c8 ignore start */
19
+ // Defensive fallback: public call path precomputes an initial candidate in preCheck.
20
+ if (matchPosition === undefined) {
21
+ matchPosition = buffer.indexOf(pattern, startOffset);
22
+ }
23
+ if (matchPosition === -1) {
24
+ return -1;
25
+ }
26
+ /* c8 ignore stop */
27
+
28
+ let depth = 0;
29
+ let inString = false;
30
+ let i = startOffset;
31
+
32
+ while (i < buffer.length) {
33
+ if (inString) {
34
+ if (buffer[i] === BYTE_ESCAPE) { // '\\'
35
+ i += 2;
36
+ continue;
37
+ }
38
+ if (buffer[i] === BYTE_QUOTE) { // '"'
39
+ inString = false;
40
+ }
41
+ i++;
42
+ continue;
43
+ }
44
+
45
+ const ch = buffer[i];
46
+ if (ch === BYTE_OPEN_OBJECT || ch === BYTE_OPEN_ARRAY) { // '{' or '['
47
+ depth++;
48
+ i++;
49
+ continue;
50
+ } else if (ch === BYTE_CLOSE_OBJECT || ch === BYTE_CLOSE_ARRAY) { // '}' or ']'
51
+ depth--;
52
+
53
+ if (depth < 0) {
54
+ return -1;
55
+ }
56
+
57
+ i++;
58
+ continue;
59
+ } else if (ch === BYTE_QUOTE) { // '"'
60
+ inString = true;
61
+ }
62
+
63
+ if (i >= matchPosition) {
64
+ if (i === matchPosition && ch === BYTE_QUOTE && depth === 0) { // '"'
65
+ const end = i + pattern.length;
66
+ if (pattern[pattern.length - 1] === BYTE_OPEN_OBJECT) { // '{'
67
+ return i;
68
+ }
69
+ if (buffer[end] === BYTE_COMMA || buffer[end] === BYTE_CLOSE_OBJECT || buffer[end] === BYTE_CLOSE_ARRAY) { // ',' or '}' or ']'
70
+ return i;
71
+ }
72
+ }
73
+
74
+ matchPosition = buffer.indexOf(pattern, matchPosition + 1);
75
+ if (matchPosition < 0) {
76
+ return -1;
77
+ }
78
+ }
79
+
80
+ i++;
81
+ }
82
+
83
+ /* c8 ignore next */
84
+ return -1;
85
+ }
86
+
87
+ export { BYTE_OPEN_OBJECT, indexOfSameLevel };