• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

albe / node-event-storage / 29703735145

19 Jul 2026 09:05PM UTC coverage: 98.215% (-0.08%) from 98.299%
29703735145

Pull #339

github

web-flow
Merge 40733da81 into 5e2321a21
Pull Request #339: Add startup state snapshots for fast storage initialization with lazy filesystem backfill

1636 of 1703 branches covered (96.07%)

Branch coverage included in aggregate %.

215 of 223 new or added lines in 4 files covered. (96.41%)

8 existing lines in 3 files now uncovered.

7552 of 7652 relevant lines covered (98.69%)

993.99 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.64
/src/Storage/ReadableStorage.js
1
import fs from 'fs';
4✔
2
import path from 'path';
4✔
3
import events from 'events';
4✔
4
import { ReadOnly as ReadOnlyPartition } from '../Partition.js';
4✔
5
import { ReadOnly as ReadOnlyIndex } from '../Index.js';
4✔
6
import { assert, wrapAndCheck, iterate, kWayMerge } from '../utils/util.js';
4✔
7
import { resolvePath, scanForFiles } from '../utils/fsUtil.js';
4✔
8
import { createHmac, matches, buildMetadataForMatcher } from '../utils/metadataUtil.js';
4✔
9
import { normalizeNamedCtorArgs } from '../utils/apiHelpers.js';
4✔
10
import IndexMatcher from '../IndexMatcher.js';
4✔
11
import FileHandlePool from '../FileHandlePool.js';
4✔
12
import ReadablePartition from "../Partition/ReadablePartition.js";
4✔
13

4✔
14
const DEFAULT_READ_BUFFER_SIZE = 4 * 1024;
4✔
15
const STARTUP_STATE_FILE_SUFFIX = '.startup-state.json';
4✔
16

4✔
17
class IndexNotFoundError extends Error {
4✔
18
    constructor(indexName) {
4✔
19
        super(`Index "${indexName}" does not exist.`);
16✔
20
        this.name = 'IndexNotFoundError';
16✔
21
        this.code = 'INDEX_NOT_FOUND';
16✔
22
    }
16✔
23
}
4✔
24

4✔
25
/**
4✔
26
 * Default ordered list of document property paths used as discriminant keys when
4✔
27
 * classifying object matchers into the fast-lookup table.  Each path may use
4✔
28
 * dot-notation for nested access (e.g. `'payload.type'`).  The first path that
4✔
29
 * resolves to a scalar value in a given matcher wins; remaining paths are not
4✔
30
 * examined for that matcher.
4✔
31
 */
4✔
32
const DEFAULT_MATCHER_PROPERTIES = ['stream', 'payload.type'];
4✔
33

4✔
34
/**
4✔
35
 * Default maximum number of partition file descriptors kept open simultaneously.
4✔
36
 * Partitions beyond this limit are evicted using LRU order. 0 disables the limit.
4✔
37
 */
4✔
38
const DEFAULT_MAX_OPEN_PARTITIONS = 1024;
4✔
39
const DEFAULT_MAX_OPEN_INDEXES = 1024;
4✔
40

4✔
41
/**
4✔
42
 * @typedef {object|function(object):boolean} Matcher
4✔
43
 */
4✔
44

4✔
45
/**
4✔
46
 * An append-only storage with highly performant positional range scans.
4✔
47
 * It's highly optimized for an event-store and hence does not support compaction or data-rewrite, nor any querying
4✔
48
 */
4✔
49
class ReadableStorage extends events.EventEmitter {
4✔
50

4✔
51
    /**
4✔
52
     * @param {string} [storageName] The name of the storage.
4✔
53
     * @param {object} [config] An object with storage parameters.
4✔
54
     * @param {object} [config.serializer] A serializer object with methods serialize(document) and deserialize(data).
4✔
55
     * @param {function(object): string} config.serializer.serialize Default is JSON.stringify.
4✔
56
     * @param {function(string): object} config.serializer.deserialize Default is JSON.parse.
4✔
57
     * @param {string} [config.dataDirectory] The path where the storage data should reside. Default '.'.
4✔
58
     * @param {string} [config.indexDirectory] The path where the indexes should be stored. Defaults to dataDirectory.
4✔
59
     * @param {string} [config.indexFile] The name of the primary index. Default '{storageName}.index'.
4✔
60
     * @param {number} [config.readBufferSize] Size of the read buffer in bytes. Default 4096.
4✔
61
     * @param {object} [config.indexOptions] An options object that should be passed to all indexes on construction.
4✔
62
     * @param {string} [config.hmacSecret] A private key that is used to verify matchers retrieved from indexes.
4✔
63
     * @param {object} [config.metadata] A metadata object to be stored in all partitions belonging to this storage.
4✔
64
     * @param {string[]} [config.matcherProperties] Ordered list of document property paths (dot-notation) used as
4✔
65
     *   discriminant keys for the fast secondary-index lookup table. Only the first property that resolves to a scalar
4✔
66
     *   value inside a given object matcher is used; the rest are checked via the full `matches()` fallback.
4✔
67
     *   Default: `['stream', 'payload.type']`.
4✔
68
     * @param {number} [config.maxOpenPartitions] Maximum number of partition file descriptors kept open at one time.
4✔
69
     *   When the limit is reached the least-recently-used partition is closed to make room. 0 disables the limit.
4✔
70
     *   Default: 1024.
4✔
71
     * @param {number} [config.maxOpenIndexes] Maximum number of index file descriptors kept open at one time.
4✔
72
     *   When the limit is reached the least-recently-used index handle is closed to make room. 0 disables the limit.
4✔
73
     *   Default: 1024.
4✔
74
     */
4✔
75
    constructor(storageName = 'storage', config = {}) {
4✔
76
        super();
1,528✔
77
        ({ name: storageName, options: config } = normalizeNamedCtorArgs(storageName, config));
1,528✔
78

1,528✔
79
        this.storageFile = storageName || 'storage';
1,528✔
80
        const defaults = {
1,528✔
81
            serializer: { serialize: JSON.stringify, deserialize: JSON.parse },
1,528✔
82
            dataDirectory: '.',
1,528✔
83
            indexFile: this.storageFile + '.index',
1,528✔
84
            indexOptions: {},
1,528✔
85
            hmacSecret: '',
1,528✔
86
            metadata: {},
1,528✔
87
            matcherProperties: DEFAULT_MATCHER_PROPERTIES,
1,528✔
88
            maxOpenPartitions: DEFAULT_MAX_OPEN_PARTITIONS,
1,528✔
89
            maxOpenIndexes: DEFAULT_MAX_OPEN_INDEXES
1,528✔
90
        };
1,528✔
91
        config = Object.assign(defaults, config);
1,528✔
92
        this.serializer = config.serializer;
1,528✔
93

1,528✔
94
        this.hmac = createHmac(config.hmacSecret);
1,528✔
95

1,528✔
96
        this.dataDirectory = resolvePath(config.dataDirectory);
1,528✔
97

1,528✔
98
        const partitionDefaults = { readBufferSize: DEFAULT_READ_BUFFER_SIZE };
1,528✔
99
        this.partitionConfig = Object.assign(partitionDefaults, config);
1,528✔
100
        this.partitionHandlePool = new FileHandlePool(config.maxOpenPartitions);
1,528✔
101
        this.indexHandlePool = new FileHandlePool(config.maxOpenIndexes);
1,528✔
102
        this.partitions = Object.create(null);
1,528✔
103

1,528✔
104
        // initialized: null = not started (or scan cancelled), false = in progress, true = done
1,528✔
105
        this.initialized = null;
1,528✔
106

1,528✔
107
        this.initializeIndexes(config);
1,528✔
108
    }
1,528✔
109

4✔
110
    /**
4✔
111
     * @protected
4✔
112
     * @param {string} name
4✔
113
     * @param {object} [options]
4✔
114
     * @returns {{ index: ReadableIndex, matcher?: Matcher }}
4✔
115
     */
4✔
116
    createIndex(name, options = {}) {
4✔
117
        /** @type ReadableIndex */
192✔
118
        const index = new ReadOnlyIndex(name, { ...options, fileHandlePool: this.indexHandlePool });
192✔
119
        return { index };
192✔
120
    }
192✔
121

4✔
122
    /**
4✔
123
     * @protected
4✔
124
     * @param {string} name
4✔
125
     * @param {object} [options]
4✔
126
     * @returns {ReadablePartition}
4✔
127
     */
4✔
128
    createPartition(name, options = {}) {
4✔
129
        return new ReadOnlyPartition(name, { ...options, fileHandlePool: this.partitionHandlePool });
488✔
130
    }
488✔
131

4✔
132
    /**
4✔
133
     * Create/open the primary index and build the base configuration for all secondary indexes.
4✔
134
     *
4✔
135
     * @private
4✔
136
     * @param {object} config The configuration object
4✔
137
     * @returns void
4✔
138
     */
4✔
139
    initializeIndexes(config) {
4✔
140
        this.indexDirectory = resolvePath(config.indexDirectory || this.dataDirectory);
1,528✔
141
        this.startupStateFile = path.join(this.indexDirectory, '.' + this.storageFile + STARTUP_STATE_FILE_SUFFIX);
1,528✔
142

1,528✔
143
        this.indexOptions = config.indexOptions;
1,528✔
144
        this.indexOptions.dataDirectory = this.indexDirectory;
1,528✔
145
        // Safety precaution to prevent accidentally restricting main index
1,528✔
146
        delete this.indexOptions.matcher;
1,528✔
147
        const { index } = this.createIndex(config.indexFile, Object.assign({}, this.indexOptions, { syncOnMissingWatchFilename: true }));
1,528✔
148
        this.index = index;
1,528✔
149
        this.secondaryIndexes = {};
1,528✔
150
        this.readonlyIndexes = {};
1,528✔
151
        this.knownIndexes = new Set();
1,528✔
152

1,528✔
153
        /** Fast secondary-index lookup — classifies matchers for O(1) candidate resolution on write. */
1,528✔
154
        this.indexMatcher = new IndexMatcher(config.matcherProperties);
1,528✔
155
    }
1,528✔
156

4✔
157
    /**
4✔
158
     * The amount of documents in the storage.
4✔
159
     * @returns {number}
4✔
160
     */
4✔
161
    get length() {
4✔
162
        return this.index.length;
6,776✔
163
    }
6,776✔
164

4✔
165
    /**
4✔
166
     * Register a partition by its relative file name if it is not already known.
4✔
167
     * Shared by the file-watch path and the initial scan so both stay consistent.
4✔
168
     *
4✔
169
     * @protected
4✔
170
     * @param {string} filename
4✔
171
     * @returns {number} The id of the (now registered) partition.
4✔
172
     */
4✔
173
    registerPartitionFile(filename) {
4✔
174
        const partitionId = ReadablePartition.idFor(filename);
987✔
175
        if (!(partitionId in this.partitions)) {
987✔
176
            const partition = this.createPartition(filename, this.partitionConfig);
614✔
177
            this.partitions[partition.id] = partition;
614✔
178
            this.emit('partition-created', partition.id);
614✔
179
            this.onKnownStateChanged();
614✔
180
        }
614✔
181
        return partitionId;
987✔
182
    }
987✔
183

4✔
184
    /**
4✔
185
     * Track a known secondary index name and optionally emit `index-created` for first discovery.
4✔
186
     *
4✔
187
     * @protected
4✔
188
     * @param {string} name
4✔
189
     * @param {boolean} [emitEvent=true]
4✔
190
     * @returns {boolean} True when the name was newly tracked.
4✔
191
     */
4✔
192
    registerKnownIndex(name, emitEvent = true) {
4✔
193
        if (this.knownIndexes.has(name)) {
2,126✔
194
            return false;
268✔
195
        }
268✔
196
        this.knownIndexes.add(name);
1,858✔
197
        if (emitEvent) {
2,126✔
198
            this.emit('index-created', name);
1,798✔
199
        }
1,798✔
200
        this.onKnownStateChanged();
1,858✔
201
        return true;
1,858✔
202
    }
2,126✔
203

4✔
204
    /**
4✔
205
     * @private
4✔
206
     * @param {string} name
4✔
207
     */
4✔
208
    throwIndexNotFoundError(name) {
4✔
209
        throw new IndexNotFoundError(name);
16✔
210
    }
16✔
211

4✔
212
    /**
4✔
213
     * Build a startup-state snapshot from currently known partitions and indexes.
4✔
214
     *
4✔
215
     * @protected
4✔
216
     * @returns {{version: number, partitions: string[], indexes: string[]}}
4✔
217
     */
4✔
218
    buildStartupStateSnapshot() {
4✔
219
        const partitions = [];
2,225✔
220
        this.forEachPartition(partition => {
2,225✔
221
            partitions.push(partition.name);
3,346✔
222
        });
2,225✔
223
        return {
2,225✔
224
            version: 1,
2,225✔
225
            partitions: partitions.sort(),
2,225✔
226
            indexes: Array.from(this.knownIndexes).sort()
2,225✔
227
        };
2,225✔
228
    }
2,225✔
229

4✔
230
    /**
4✔
231
     * Load known partition/index names from the persisted startup-state snapshot.
4✔
232
     * Missing files are ignored and discovered later by the background scan.
4✔
233
     *
4✔
234
     * @protected
4✔
235
     * @returns {boolean} True when a valid snapshot file was consumed.
4✔
236
     */
4✔
237
    loadStartupStateSnapshot() {
4✔
238
        if (!fs.existsSync(this.startupStateFile)) {
1,448✔
239
            return false;
1,218✔
240
        }
1,218✔
241
        let snapshot;
230✔
242
        try {
230✔
243
            snapshot = JSON.parse(fs.readFileSync(this.startupStateFile, 'utf8'));
230✔
244
        } catch (e) {
1,448!
NEW
245
            return false;
×
NEW
246
        }
×
247
        if (!snapshot || snapshot.version !== 1) {
1,448!
NEW
248
            return false;
×
NEW
249
        }
×
250

230✔
251
        const partitions = Array.isArray(snapshot.partitions) ? snapshot.partitions : [];
1,448!
252
        for (const partitionName of partitions) {
1,448✔
253
            if (typeof partitionName !== 'string' || partitionName === '') {
279!
NEW
254
                continue;
×
NEW
255
            }
×
256
            this.registerPartitionFile(partitionName);
279✔
257
        }
279✔
258

230✔
259
        const indexes = Array.isArray(snapshot.indexes) ? snapshot.indexes : [];
1,448!
260
        for (const indexName of indexes) {
1,448✔
261
            if (typeof indexName !== 'string' || indexName === '' || indexName === '_all') {
120!
UNCOV
262
                continue;
×
UNCOV
263
            }
×
264
            this.registerKnownIndex(indexName);
120✔
265
        }
120✔
266
        return true;
230✔
267
    }
1,448✔
268

4✔
269
    /**
4✔
270
     * Scan partitions and secondary index files; emit 'index-created' for each found index.
4✔
271
     * @param {function} done Called when both scans finish.
4✔
272
     */
4✔
273
    scanFiles(done) {
4✔
274
        const escaped = this.storageFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1,448✔
275
        const partitionPattern = new RegExp(`^(${escaped}.*)$`);
1,448✔
276
        scanForFiles(this.dataDirectory, partitionPattern, (file) => {
1,448✔
277
            if (this.initialized === null) {
1,000✔
278
                return;
136✔
279
            }
136✔
280
            if (file.endsWith('.index') || file.endsWith('.branch') || file.endsWith('.lock')) {
1,000✔
281
                return;
584✔
282
            }
584✔
283
            this.registerPartitionFile(file);
280✔
284
        }, (partErr) => {
1,448✔
285
            /* c8 ignore next */
4✔
286
            if (partErr) throw partErr;
4✔
287

1,444✔
288
            // Scan was cancelled by close() between the two scan phases.
1,444✔
289
            if (this.initialized === null) return;
1,448✔
290

527✔
291
            // No secondary indexes exist yet — nothing to scan.
527✔
292
            if (!fs.existsSync(this.indexDirectory)) {
1,448✔
293
                return done();
1✔
294
            }
1✔
295
            const indexPattern = new RegExp(`^${escaped}\\.(.+)\\.index$`);
526✔
296
            scanForFiles(this.indexDirectory, indexPattern, (name) => {
526✔
297
                if (this.initialized === null) {
192✔
298
                    return;
14✔
299
                }
14✔
300
                this.registerKnownIndex(name);
178✔
301
            }, (indexErr) => {
526✔
302
                // The directory could disappear between existsSync and readdir (e.g. test cleanup).
526✔
303
                /* c8 ignore next */
4✔
304
                if (indexErr && indexErr.code !== 'ENOENT') throw indexErr;
4✔
305
                done();
526✔
306
            });
526✔
307
        });
1,448✔
308
    }
1,448✔
309

4✔
310
    /**
4✔
311
     * Only the primary index is opened eagerly; secondary indexes open on demand.
4✔
312
     *
4✔
313
     * @protected
4✔
314
     */
4✔
315
    openIndexes() {
4✔
316
        this.index.open();
485✔
317
    }
485✔
318

4✔
319
    /**
4✔
320
     * Open the storage; scans existing partitions and indexes asynchronously on first open.
4✔
321
     * Re-opens after `close()` are synchronous.
4✔
322
     * Will emit an `'opened'` event when finished.
4✔
323
     *
4✔
324
     * @api
4✔
325
     * @param {function(): void} [callback] Called after indexes open, before `'opened'` is emitted.
4✔
326
     *   Can be used as a synchronous alternative to listening to the `'opened'` event.
4✔
327
     * @returns {boolean}
4✔
328
     */
4✔
329
    open(callback) {
4✔
330
        if (this.initialized === true) {
1,460✔
331
            this.openIndexes();
8✔
332
            callback?.();
8✔
333
            this.emit('opened');
8✔
334
            return true;
8✔
335
        }
8✔
336
        if (this.initialized === false) {
1,460✔
337
            return true;
4✔
338
        }
4✔
339
        this.initialized = false;
1,448✔
340
        const finishOpen = () => {
1,448✔
341
            if (this.initialized === null) return;
536✔
342
            this.initialized = true;
477✔
343
            this.openIndexes();
477✔
344
            callback?.();
536✔
345
            this.emit('opened');
536✔
346
        };
1,448✔
347
        if (this.loadStartupStateSnapshot()) {
1,460✔
348
            this.scanFiles(() => {
230✔
349
                // Guard: close() while scanning resets initialized to null.
217✔
350
                if (this.initialized === null) return;
217!
351
                this.onKnownStateChanged();
217✔
352
            });
230✔
353
            setImmediate(finishOpen);
230✔
354
            return true;
230✔
355
        }
230✔
356
        this.scanFiles(() => {
1,218✔
357
            // Guard: close() while scanning resets initialized to null.
306✔
358
            finishOpen();
306✔
359
            this.onKnownStateChanged();
306✔
360
        });
1,218✔
361
        return true;
1,218✔
362
    }
1,460✔
363

4✔
364
    /**
4✔
365
     * Close the storage and free up all resources.
4✔
366
     * Will emit a 'closed' event when finished.
4✔
367
     *
4✔
368
     * @api
4✔
369
     */
4✔
370
    close() {
4✔
371
        // Cancel in-progress scan so the callback does not re-open after an explicit close.
2,236✔
372
        if (this.initialized === false) {
2,236✔
373
            this.initialized = null;
1,051✔
374
        }
1,051✔
375
        this.index.close();
2,236✔
376
        this.forEachSecondaryIndex(index => index.close());
2,236✔
377
        for (let index of Object.values(this.readonlyIndexes)) {
2,236✔
378
            index.close();
52✔
379
        }
52✔
380
        this.readonlyIndexes = {};
2,236✔
381
        this.forEachPartition(partition => partition.close());
2,236✔
382
        this.emit('closed');
2,236✔
383
    }
2,236✔
384

4✔
385
    /**
4✔
386
     * Get a partition by its id.
4✔
387
     * If a partition with the given id does not exist, an error is thrown.
4✔
388
     *
4✔
389
     * @protected
4✔
390
     * @param {number|string} partitionIdentifier The partition Id
4✔
391
     * @returns {ReadablePartition}
4✔
392
     * @throws {Error} If no such partition exists.
4✔
393
     */
4✔
394
    getPartition(partitionIdentifier) {
4✔
395
        assert(partitionIdentifier in this.partitions, `Partition #${partitionIdentifier} does not exist.`);
7,940✔
396
        const partition = this.partitions[partitionIdentifier];
7,940✔
397
        partition.open();
7,940✔
398
        return partition;
7,940✔
399
    }
7,940✔
400

4✔
401
    /**
4✔
402
     * Register a handler that is called before a document is read from a partition.
4✔
403
     * The handler receives the position and the partition metadata and may throw to abort the read.
4✔
404
     * Multiple handlers can be registered; all run on every read in registration order.
4✔
405
     * Equivalent to `storage.on('preRead', hook)`.
4✔
406
     *
4✔
407
     * @api
4✔
408
     * @param {function(number, object): void} hook A function receiving (position, partitionMetadata).
4✔
409
     */
4✔
410
    preRead(hook) {
4✔
411
        this.on('preRead', hook);
12✔
412
    }
12✔
413

4✔
414
    /**
4✔
415
     * @protected
4✔
416
     * @param {number} partitionId The partition to read from.
4✔
417
     * @param {number} position The file position to read from.
4✔
418
     * @param {number} [size] The expected byte size of the document at the given position.
4✔
419
     * @param {boolean} [raw] Whether to return raw buffers instead of deserialized objects. Default false.
4✔
420
     * @param {boolean} [backwardsHint] If set to true, will optimize buffering for backwards reading.
4✔
421
     * @returns {object|{ buffer: Buffer, time64: number, sequenceNumber: number }} The document stored at the given position.
4✔
422
     * @throws {Error} if the document at the given position can not be deserialized.
4✔
423
     */
4✔
424
    readFrom(partitionId, position, size, raw = false, backwardsHint = false) {
4✔
425
        const partition = this.getPartition(partitionId);
3,440✔
426
        if (this.listenerCount('preRead') > 0) {
3,440✔
427
            this.emit('preRead', position, partition.metadata);
64✔
428
        }
64✔
429
        const headerOut = {};
3,432✔
430
        const buffer = partition.readFrom(position, size, headerOut, backwardsHint);
3,432✔
431
        return raw ? { buffer, time64: headerOut.time64, sequenceNumber: headerOut.sequenceNumber } : this.serializer.deserialize(buffer.toString('utf8'));
3,440✔
432
    }
3,440✔
433

4✔
434
    /**
4✔
435
     * Read a single document from the given position, in the full index or in the provided index.
4✔
436
     *
4✔
437
     * @api
4✔
438
     * @param {number} number The 1-based document number (inside the given index) to read.
4✔
439
     * @param {ReadableIndex} [index] The index to use for finding the document position.
4✔
440
     * @returns {object} The document at the given position inside the index.
4✔
441
     */
4✔
442
    read(number, index) {
4✔
443
        index = index || this.index;
408✔
444
        index.open();
408✔
445

408✔
446
        const entry = index.get(number);
408✔
447
        if (entry === false) {
408✔
448
            return false;
4✔
449
        }
4✔
450

404✔
451
        return this.readFrom(entry.partition, entry.position, entry.size);
404✔
452
    }
408✔
453

4✔
454
    /**
4✔
455
     * Read a range of documents from the given position range, in the full index or in the provided index.
4✔
456
     * Returns a generator in order to reduce memory usage and be able to read lots of documents with little latency.
4✔
457
     *
4✔
458
     * @api
4✔
459
     * @param {number} from The 1-based document number (inclusive) to start reading from.
4✔
460
     * @param {number} [until] The 1-based document number (inclusive) to read until. Defaults to index.length.
4✔
461
     * @param {ReadableIndex|false} [index] The index to use for finding the documents in the range.
4✔
462
     *   Pass `false` to skip the global index and iterate all partitions directly in sequenceNumber order
4✔
463
     *   (useful when the global index is unavailable or corrupted).
4✔
464
     * @param {boolean} [raw] Whether to return raw buffers instead of deserialized objects. Default false.
4✔
465
     * @returns {Generator<object>} A generator that will read each document in the range one by one.
4✔
466
     */
4✔
467
    *readRange(from, until = -1, index = null, raw = false) {
4✔
468
        let length = Number.MAX_SAFE_INTEGER;
436✔
469
        if (index !== false) {
436✔
470
            index = index || this.index;
424✔
471
            index.open();
424✔
472
            length = index.length;
424✔
473
        }
424✔
474

436✔
475
        const readFrom = wrapAndCheck(from, length);
436✔
476
        const readUntil = wrapAndCheck(until, length);
436✔
477
        assert(readFrom > 0 && readUntil > 0, `Range scan error for range ${from} - ${until}.`);
436✔
478

436✔
479
        yield* this.iterateRange(readFrom, readUntil, index, raw);
436✔
480
    }
436✔
481

4✔
482
    /**
4✔
483
     * Iterate all documents in this storage in range from to until inside the index.
4✔
484
     * If index is false, iterates all partitions directly in sequenceNumber order.
4✔
485
     * @private
4✔
486
     * @param {number} from
4✔
487
     * @param {number} until
4✔
488
     * @param {ReadableIndex|false|null} index
4✔
489
     * @param {boolean} [raw] Whether to return raw buffers instead of deserialized objects. Default false.
4✔
490
     * @returns {Generator<object>}
4✔
491
     */
4✔
492
    *iterateRange(from, until, index, raw = false) {
4✔
493
        if (index === false) {
420✔
494
            for (const { document } of this.iterateDocumentsNoIndex(from - 1, until - 1)) {
12✔
495
                yield document;
92✔
496
            }
92✔
497
            return;
12✔
498
        }
12✔
499

408✔
500
        const idx = index || this.index;
420!
501
        const forwards = from <= until;
420✔
502
        const lo = Math.min(from, until);
420✔
503
        const hi = Math.max(from, until);
420✔
504
        const entries = idx.range(lo, hi);
420✔
505
        if (!entries) return;
420!
506
        for (const entry of iterate(entries, forwards)) {
420✔
507
            yield this.readFrom(entry.partition, entry.position, entry.size, raw, !forwards);
1,632✔
508
        }
1,608✔
509
    }
420✔
510

4✔
511
    /**
4✔
512
     * Open an existing readonly index for reading, without registering it in the secondary indexes write path.
4✔
513
     * Use this for indexes whose files carry a status marker (e.g. `stream-foo.closed.index`).
4✔
514
     *
4✔
515
     * @api
4✔
516
     * @param {string} name The readonly index name (e.g. 'stream-foo.closed').
4✔
517
     * @returns {ReadableIndex}
4✔
518
     * @throws {Error} if the readonly index does not exist.
4✔
519
     */
4✔
520
    openReadonlyIndex(name) {
4✔
521
        if (name in this.readonlyIndexes) {
56✔
522
            return this.readonlyIndexes[name];
4✔
523
        }
4✔
524
        const indexName = this.storageFile + '.' + name + '.index';
52✔
525
        if (!fs.existsSync(path.join(this.indexDirectory, indexName))) {
56!
UNCOV
526
            this.throwIndexNotFoundError(name);
×
UNCOV
527
        }
×
528
        const { index } = this.createIndex(indexName, Object.assign({}, this.indexOptions));
52✔
529
        index.open();
52✔
530
        this.readonlyIndexes[name] = index;
52✔
531
        this.registerKnownIndex(name, false);
52✔
532
        return index;
52✔
533
    }
56✔
534

4✔
535
    /**
4✔
536
     * Open an existing index.
4✔
537
     *
4✔
538
     * @api
4✔
539
     * @param {string} name The index name.
4✔
540
     * @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.
4✔
541
     * @returns {ReadableIndex}
4✔
542
     * @throws {Error} if the index with that name does not exist.
4✔
543
     * @throws {Error} if the HMAC for the matcher does not match.
4✔
544
     */
4✔
545
    openIndex(name, matcher) {
4✔
546
        if (name === '_all') {
1,544✔
547
            return this.index;
12✔
548
        }
12✔
549
        if (name in this.secondaryIndexes) {
1,544✔
550
            return this.secondaryIndexes[name].index;
1,384✔
551
        }
1,384✔
552

148✔
553
        const indexName = this.storageFile + '.' + name + '.index';
148✔
554
        if (!fs.existsSync(path.join(this.indexDirectory, indexName))) {
1,544✔
555
            this.throwIndexNotFoundError(name);
16✔
556
        }
16✔
557

132✔
558
        const metadata = buildMetadataForMatcher(matcher, this.hmac);
132✔
559
        let { index } = this.secondaryIndexes[name] = this.createIndex(indexName, Object.assign({}, this.indexOptions, { metadata }));
132✔
560

132✔
561
        // Register the actual stored matcher (may have been reconstructed from metadata by WritableStorage.createIndex).
132✔
562
        this.indexMatcher.add(name, this.secondaryIndexes[name].matcher);
132✔
563
        this.registerKnownIndex(name, false);
132✔
564

132✔
565
        index.open();
132✔
566
        return index;
132✔
567
    }
1,544✔
568

4✔
569
    /**
4✔
570
     * Remove a secondary index from the write path and the matcher lookup table.
4✔
571
     *
4✔
572
     * @api
4✔
573
     * @param {string} name The secondary index name to remove.
4✔
574
     */
4✔
575
    removeSecondaryIndex(name) {
4✔
576
        const entry = this.secondaryIndexes[name];
44✔
577
        if (entry) {
44✔
578
            this.indexMatcher.remove(name);
40✔
579
            delete this.secondaryIndexes[name];
40✔
580
        }
40✔
581
    }
44✔
582

4✔
583
     /**
4✔
584
      * Build the standard document result entry from a readRange yield.
4✔
585
      * @private
4✔
586
      * @param {{ data: Buffer, entry: { number: number, position: number, size: number, partition: number } }} [readItem]
4✔
587
      */
4✔
588
     buildDocumentEntry(readItem) {
4✔
589
         return {
228✔
590
             document: this.serializer.deserialize(readItem.data.toString('utf8')),
228✔
591
             // Replicate the index entry structure here, so iteration can be used easily to reindex
228✔
592
             entry: readItem.entry
228✔
593
         };
228✔
594
     }
228✔
595

4✔
596
     /**
4✔
597
      * Iterate documents across all partitions in sequenceNumber order using a k-way merge.
4✔
598
      * Opens any closed partition automatically.
4✔
599
      *
4✔
600
      * @protected
4✔
601
      * @param {number} [from=0] The 0-based sequenceNumber to start from (inclusive).
4✔
602
      * @param {number} [until=Number.MAX_SAFE_INTEGER] The 0-based sequenceNumber to read until (inclusive).
4✔
603
      * @returns {Generator<{document: object, entry: { sequenceNumber: number, position: number, size: number, partition: number }}>}
4✔
604
      */
4✔
605
     *iterateDocumentsNoIndex(from = 0, until = Number.MAX_SAFE_INTEGER) {
4✔
606
         const forwards = from <= until;
64✔
607
         const partitions = [];
64✔
608
         this.forEachPartition(partition => {
64✔
609
             partition.open();
109✔
610
             partitions.push(partition.readRange(from, until));
109✔
611
         });
64✔
612

64✔
613
         yield* kWayMerge(
64✔
614
             partitions,
64✔
615
             item => item.entry.number,
64✔
616
             forwards,
64✔
617
             item => this.buildDocumentEntry(item)
64✔
618
         );
64✔
619
     }
64✔
620

4✔
621
    /**
4✔
622
     * Helper method to iterate over all documents, invoking a callback for each one.
4✔
623
     * Pass `noIndex = true` to iterate all partitions directly in sequenceNumber order
4✔
624
     * (useful when the global index is unavailable or corrupted).
4✔
625
     * When `noIndex` is false the second callback argument is the raw index `EntryInterface`.
4✔
626
     * When `noIndex` is true the second callback argument has `{ partition, position, size, sequenceNumber }`.
4✔
627
     *
4✔
628
     * @protected
4✔
629
     * @param {function(object, object): void} iterationHandler
4✔
630
     * @param {boolean} [noIndex=false] When true, bypasses the index and iterates partitions directly.
4✔
631
     */
4✔
632
    forEachDocument(iterationHandler, noIndex = false) {
4✔
633
        /* c8 ignore next 3  */
4✔
634
        if (typeof iterationHandler !== 'function') {
4✔
635
            return;
4✔
636
        }
4✔
637

508✔
638
        if (noIndex) {
508✔
639
            for (const { document, entry } of this.iterateDocumentsNoIndex()) {
4✔
640
                iterationHandler(document, entry);
24✔
641
            }
24✔
642
            return;
4✔
643
        }
4✔
644

504✔
645
        const entries = this.index.all();
504✔
646

504✔
647
        for (let entry of entries) {
508✔
648
            const document = this.readFrom(entry.partition, entry.position, entry.size);
48✔
649
            iterationHandler(document, entry);
48✔
650
        }
48✔
651
    }
508✔
652

4✔
653
    /**
4✔
654
     * Helper method to iterate over all secondary indexes.
4✔
655
     *
4✔
656
     * When `matchDocument` is provided, `this.indexMatcher.forEachMatch()` is used to
4✔
657
     * efficiently find only the matching indexes via the discriminant lookup table.
4✔
658
     *
4✔
659
     * @protected
4✔
660
     * @param {function(ReadableIndex, string)} iterationHandler
4✔
661
     * @param {object} [matchDocument] If supplied, only indexes the document matches on will be iterated.
4✔
662
     */
4✔
663
    forEachSecondaryIndex(iterationHandler, matchDocument) {
4✔
664
        /* c8 ignore next 3  */
4✔
665
        if (typeof iterationHandler !== 'function') {
4✔
666
            return;
4✔
667
        }
4✔
668

7,072✔
669
        if (!matchDocument) {
7,072✔
670
            // No document filter: iterate all secondary indexes unconditionally.
2,548✔
671
            for (const indexName of Object.keys(this.secondaryIndexes)) {
2,548✔
672
                iterationHandler(this.secondaryIndexes[indexName].index, indexName);
2,132✔
673
            }
2,132✔
674
            return;
2,548✔
675
        }
2,548✔
676

4,524✔
677
        this.indexMatcher.forEachMatch(matchDocument, indexName => {
4,524✔
678
            iterationHandler(this.secondaryIndexes[indexName].index, indexName);
3,128✔
679
        });
4,524✔
680
    }
7,072✔
681

4✔
682
    /**
4✔
683
     * Helper method to iterate over all partitions.
4✔
684
     *
4✔
685
     * @protected
4✔
686
     * @param {function(ReadablePartition)} iterationHandler
4✔
687
     */
4✔
688
    forEachPartition(iterationHandler) {
4✔
689
        /* c8 ignore next 3  */
4✔
690
        if (typeof iterationHandler !== 'function') {
4✔
691
            return;
4✔
692
        }
4✔
693

4,809✔
694
        for (const partitionId of Object.keys(this.partitions)) {
4,809✔
695
            iterationHandler(this.partitions[partitionId]);
8,197✔
696
        }
8,197✔
697
    }
4,809✔
698

4✔
699
    /**
4✔
700
     * Hook called when the set of known partitions/indexes changes.
4✔
701
     * WritableStorage overrides this to persist startup-state snapshots.
4✔
702
     *
4✔
703
     * @protected
4✔
704
     */
4✔
705
    onKnownStateChanged() {}
4✔
706

4✔
707
}
4✔
708

4✔
709
export default ReadableStorage;
4✔
710
export { matches, IndexNotFoundError };
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc