jexidb 2.2.2 → 2.2.5
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.
- package/README.md +50 -0
- package/dist/Database.cjs +538 -56
- package/package.json +1 -1
- package/src/Database.mjs +274 -28
- package/src/FileHandler.mjs +88 -1
- package/src/managers/IndexManager.mjs +66 -7
- package/src/managers/QueryManager.mjs +69 -14
- package/src/utils/ioTimeout.mjs +36 -0
package/dist/Database.cjs
CHANGED
|
@@ -1458,20 +1458,43 @@ class IndexManager {
|
|
|
1458
1458
|
if (field.startsWith('$')) continue;
|
|
1459
1459
|
if (typeof data[field] === 'undefined') continue;
|
|
1460
1460
|
const originalCriteriaValue = criteria[field];
|
|
1461
|
-
|
|
1461
|
+
// INTENTIONAL (CHANGELOG [2.2.5]): RegExp is not an operator map - never normalize it
|
|
1462
|
+
// (spreading a RegExp yields {} and would make the regex match every record).
|
|
1463
|
+
const criteriaValue = originalCriteriaValue instanceof RegExp ? originalCriteriaValue : normalizeCriteriaOperators(originalCriteriaValue, {
|
|
1462
1464
|
target: 'legacy',
|
|
1463
1465
|
preserveOriginal: true
|
|
1464
1466
|
});
|
|
1465
1467
|
let lineNumbersForField = new Set();
|
|
1466
1468
|
const isNumericField = this.opts.indexes[field] === 'number';
|
|
1467
1469
|
|
|
1468
|
-
//
|
|
1470
|
+
// INTENTIONAL OPTIMIZATION (see CHANGELOG [2.2.5]): raw RegExp on string/term
|
|
1471
|
+
// indexes is answered via the index keys instead of full-file streaming. For
|
|
1472
|
+
// term-mapped fields the keys are numeric term IDs, translated back to words with
|
|
1473
|
+
// termManager.getTerm() before testing the regex. DO NOT change this back to
|
|
1474
|
+
// "RegExp => streaming" - it regresses partial search to a full scan per database.
|
|
1469
1475
|
if (criteriaValue instanceof RegExp) {
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1476
|
+
const fieldIndex = data[field];
|
|
1477
|
+
const indexType = this.opts.indexes && this.opts.indexes[field];
|
|
1478
|
+
const isNumericIndexType = indexType === 'number' || indexType === 'array:number';
|
|
1479
|
+
if (!fieldIndex || isNumericIndexType) {
|
|
1480
|
+
// Not index-answerable (numeric index) - handled by streaming fallback
|
|
1481
|
+
continue;
|
|
1482
|
+
}
|
|
1483
|
+
const termManager = this.database && this.database.termManager;
|
|
1484
|
+
const isTermField = !!(termManager && termManager.termMappingFields && termManager.termMappingFields.includes(field));
|
|
1485
|
+
const regex = criteriaValue;
|
|
1486
|
+
for (const key of Object.keys(fieldIndex)) {
|
|
1487
|
+
const word = isTermField ? termManager.getTerm(Number(key)) : key;
|
|
1488
|
+
if (word === null || word === undefined) continue;
|
|
1489
|
+
regex.lastIndex = 0;
|
|
1490
|
+
if (!regex.test(String(word))) continue;
|
|
1491
|
+
const numbers = this._getAllLineNumbers(fieldIndex[key]);
|
|
1492
|
+
for (const lineNumber of numbers) lineNumbersForField.add(lineNumber);
|
|
1493
|
+
}
|
|
1494
|
+
// Fall through to field consolidation below (RegExp is an object, so it is
|
|
1495
|
+
// excluded from the object/operator branch that follows).
|
|
1496
|
+
}
|
|
1497
|
+
if (typeof criteriaValue === 'object' && !(criteriaValue instanceof RegExp) && !Array.isArray(criteriaValue) && criteriaValue !== null) {
|
|
1475
1498
|
const fieldIndex = data[field];
|
|
1476
1499
|
|
|
1477
1500
|
// Handle $in operator for array queries
|
|
@@ -2662,6 +2685,11 @@ class IndexManager {
|
|
|
2662
2685
|
* on-disk index after an unload must first ensure it is loaded again, e.g.
|
|
2663
2686
|
* `await database._ensureLazyIndexLoaded()`.
|
|
2664
2687
|
*
|
|
2688
|
+
* The return format is ALWAYS the actual term strings (words/values), never the
|
|
2689
|
+
* internal numeric term IDs. For term-mapped columns the index stores term IDs as
|
|
2690
|
+
* keys, so they are translated back to words through the TermManager before being
|
|
2691
|
+
* returned. This keeps the API consistent across indexed fields.
|
|
2692
|
+
*
|
|
2665
2693
|
* @param {string} column - Indexed field name (e.g. 'nameTerms', 'groupTerms', 'category')
|
|
2666
2694
|
* @returns {Set<string>} Terms currently indexed for the column (empty Set if the
|
|
2667
2695
|
* column is not indexed or the index is not loaded)
|
|
@@ -2670,7 +2698,29 @@ class IndexManager {
|
|
|
2670
2698
|
if (!this.index || !this.index.data || !this.index.data[column]) {
|
|
2671
2699
|
return new Set();
|
|
2672
2700
|
}
|
|
2673
|
-
|
|
2701
|
+
const keys = Object.keys(this.index.data[column]);
|
|
2702
|
+
|
|
2703
|
+
// Consistent contract: always return term strings, never numeric term IDs.
|
|
2704
|
+
const termManager = this.database?.termManager;
|
|
2705
|
+
const isTermMappingField = Boolean(termManager && termManager.termMappingFields && termManager.termMappingFields.includes(column));
|
|
2706
|
+
if (!isTermMappingField) {
|
|
2707
|
+
return new Set(keys);
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
// Term-mapped column: index keys are numeric term IDs -> translate back to words.
|
|
2711
|
+
const terms = new Set();
|
|
2712
|
+
for (const key of keys) {
|
|
2713
|
+
if (/^\d+$/.test(key)) {
|
|
2714
|
+
const word = termManager.getTerm?.(Number(key));
|
|
2715
|
+
if (word !== null && word !== undefined) {
|
|
2716
|
+
terms.add(word);
|
|
2717
|
+
}
|
|
2718
|
+
} else {
|
|
2719
|
+
// Defensive: key is already a term string (legacy/in-memory data)
|
|
2720
|
+
terms.add(key);
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
return terms;
|
|
2674
2724
|
}
|
|
2675
2725
|
|
|
2676
2726
|
/**
|
|
@@ -4262,6 +4312,39 @@ class OperationQueue {
|
|
|
4262
4312
|
}
|
|
4263
4313
|
}
|
|
4264
4314
|
|
|
4315
|
+
/**
|
|
4316
|
+
* Shared helpers to normalize and detect I/O timeout / abort errors.
|
|
4317
|
+
*
|
|
4318
|
+
* Node surfaces read timeouts in several inconsistent shapes depending on the
|
|
4319
|
+
* path used (`error.name`, `error.code` or `error.message`). These helpers
|
|
4320
|
+
* centralize creation and detection so every retry branch across Database.mjs
|
|
4321
|
+
* and FileHandler.mjs agrees, and timeouts are always delivered as normal
|
|
4322
|
+
* promise rejections - never as an `uncaughtException`.
|
|
4323
|
+
*/
|
|
4324
|
+
|
|
4325
|
+
/**
|
|
4326
|
+
* Create a normalized I/O timeout error that every retry branch recognizes.
|
|
4327
|
+
* @param {number} timeoutMs - Timeout that elapsed, in milliseconds.
|
|
4328
|
+
* @returns {Error}
|
|
4329
|
+
*/
|
|
4330
|
+
function createIoTimeoutError(timeoutMs) {
|
|
4331
|
+
const err = new Error(`I/O timeout after ${timeoutMs}ms`);
|
|
4332
|
+
err.name = 'AbortError';
|
|
4333
|
+
err.code = 'ETIMEDOUT';
|
|
4334
|
+
return err;
|
|
4335
|
+
}
|
|
4336
|
+
|
|
4337
|
+
/**
|
|
4338
|
+
* Detect whether an error represents an I/O timeout or abort, regardless of the
|
|
4339
|
+
* shape it was produced in.
|
|
4340
|
+
* @param {*} error - Error to inspect (may be null/undefined).
|
|
4341
|
+
* @returns {boolean}
|
|
4342
|
+
*/
|
|
4343
|
+
function isIoTimeoutError(error) {
|
|
4344
|
+
if (!error) return false;
|
|
4345
|
+
return error.name === 'AbortError' || error.code === 'ETIMEDOUT' || error.message === 'AbortError';
|
|
4346
|
+
}
|
|
4347
|
+
|
|
4265
4348
|
/*
|
|
4266
4349
|
How it works:
|
|
4267
4350
|
`this.#head` is an instance of `Node` which keeps track of its current value and nests another instance of `Node` that keeps the value that comes after it. When a value is provided to `.enqueue()`, the code needs to iterate through `this.#head`, going deeper and deeper to find the last value. However, iterating through every single item is slow. This problem is solved by saving a reference to the last value as `this.#tail` so that it can reference it to add a new value.
|
|
@@ -5146,6 +5229,23 @@ class FileHandler {
|
|
|
5146
5229
|
await fs.promises.rename(tmpFile, targetFile);
|
|
5147
5230
|
return; // Success
|
|
5148
5231
|
} catch (error) {
|
|
5232
|
+
// ENOENT: the temp file vanished before the rename. With unique temp names
|
|
5233
|
+
// (see _atomicReplaceFile) this means another concurrent writer already
|
|
5234
|
+
// replaced the target (its rename won, ours was superseded) or an external
|
|
5235
|
+
// cleanup removed our temp. If the target exists, the write was superseded,
|
|
5236
|
+
// not an error. (CHANGELOG [2.2.5])
|
|
5237
|
+
if (error.code === 'ENOENT') {
|
|
5238
|
+
try {
|
|
5239
|
+
await fs.promises.access(targetFile);
|
|
5240
|
+
return; // target already replaced by a concurrent writer - ok
|
|
5241
|
+
} catch (accessError) {
|
|
5242
|
+
if (attempt < maxRetries) {
|
|
5243
|
+
await new Promise(resolve => setTimeout(resolve, 50 * attempt));
|
|
5244
|
+
continue;
|
|
5245
|
+
}
|
|
5246
|
+
throw error;
|
|
5247
|
+
}
|
|
5248
|
+
}
|
|
5149
5249
|
if (error.code === 'EPERM' && attempt < maxRetries) {
|
|
5150
5250
|
// Quick delay: 50ms, 100ms, 200ms
|
|
5151
5251
|
const delay = 50 * attempt;
|
|
@@ -5217,6 +5317,69 @@ class FileHandler {
|
|
|
5217
5317
|
throw fallbackError;
|
|
5218
5318
|
}
|
|
5219
5319
|
}
|
|
5320
|
+
|
|
5321
|
+
/**
|
|
5322
|
+
* Atomically replace this file's content: write to a sibling temp file, then
|
|
5323
|
+
* rename over the target (with a Windows copy fallback on EPERM). Concurrent
|
|
5324
|
+
* readers never observe a half-written file.
|
|
5325
|
+
* @param {Buffer|string} data - Full content of the new file.
|
|
5326
|
+
*/
|
|
5327
|
+
async _atomicReplaceFile(data) {
|
|
5328
|
+
// Use a UNIQUE temp name per write. A fixed "<file>.tmp" collides when two
|
|
5329
|
+
// Database instances write the same file concurrently: each removes the
|
|
5330
|
+
// other's temp before its rename, causing ENOENT. (CHANGELOG [2.2.5])
|
|
5331
|
+
const tmpFile = `${this.file}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
|
5332
|
+
const release = this.fileMutex ? await this.fileMutex.acquire() : () => {};
|
|
5333
|
+
try {
|
|
5334
|
+
await fs.promises.writeFile(tmpFile, data);
|
|
5335
|
+
|
|
5336
|
+
// Windows: brief delay helps readers release the target handle before the
|
|
5337
|
+
// rename (EPERM mitigation, mirroring _writeFileWithRetry).
|
|
5338
|
+
if (process.platform === 'win32') {
|
|
5339
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
5340
|
+
}
|
|
5341
|
+
await this._safeRename(tmpFile, this.file);
|
|
5342
|
+
} catch (error) {
|
|
5343
|
+
// Best-effort cleanup of our own temp on failure.
|
|
5344
|
+
await fs.promises.unlink(tmpFile).catch(() => {});
|
|
5345
|
+
throw error;
|
|
5346
|
+
} finally {
|
|
5347
|
+
release();
|
|
5348
|
+
}
|
|
5349
|
+
}
|
|
5350
|
+
|
|
5351
|
+
/**
|
|
5352
|
+
* Atomically replace the whole data file with the given content.
|
|
5353
|
+
*
|
|
5354
|
+
* When `opts.updatingSentinel` is enabled, a `<file>.updating.jdb` sentinel is
|
|
5355
|
+
* created for the duration of the swap so cross-process readers can detect an
|
|
5356
|
+
* active writer and avoid touching the file mid-update.
|
|
5357
|
+
*
|
|
5358
|
+
* @param {Buffer|string} data - Full content of the new data file.
|
|
5359
|
+
*/
|
|
5360
|
+
async replaceDataFile(data) {
|
|
5361
|
+
const sentinelFile = this.file.replace(/\.jdb$/i, '.updating.jdb');
|
|
5362
|
+
const useSentinel = !!(this.opts && this.opts.updatingSentinel);
|
|
5363
|
+
let releaseSentinel = null;
|
|
5364
|
+
if (useSentinel) {
|
|
5365
|
+
// Signal "writer active" before touching the target
|
|
5366
|
+
await fs.promises.writeFile(sentinelFile, String(Date.now()), 'utf8').catch(() => {});
|
|
5367
|
+
releaseSentinel = () => fs.promises.unlink(sentinelFile).catch(() => {});
|
|
5368
|
+
}
|
|
5369
|
+
try {
|
|
5370
|
+
await this._atomicReplaceFile(data);
|
|
5371
|
+
} finally {
|
|
5372
|
+
if (releaseSentinel) await releaseSentinel();
|
|
5373
|
+
}
|
|
5374
|
+
}
|
|
5375
|
+
|
|
5376
|
+
/**
|
|
5377
|
+
* Atomically replace this file's content (used for the .idx.jdb index file).
|
|
5378
|
+
* @param {Buffer|string} data - Full content of the new file.
|
|
5379
|
+
*/
|
|
5380
|
+
async writeFileAtomic(data) {
|
|
5381
|
+
await this._atomicReplaceFile(data);
|
|
5382
|
+
}
|
|
5220
5383
|
async writeData(data, immediate, fd) {
|
|
5221
5384
|
await fd.write(data);
|
|
5222
5385
|
}
|
|
@@ -5330,7 +5493,11 @@ class FileHandler {
|
|
|
5330
5493
|
const results = await this._readWithStreamingInternal(criteria, options, matchesCriteria, serializer, controller.signal);
|
|
5331
5494
|
return results;
|
|
5332
5495
|
} catch (error) {
|
|
5333
|
-
|
|
5496
|
+
// p-retry "stop" signal (may be absent when p-retry is mocked in tests)
|
|
5497
|
+
if (typeof pRetry.AbortError === 'function' && error instanceof pRetry.AbortError) {
|
|
5498
|
+
throw error;
|
|
5499
|
+
}
|
|
5500
|
+
if (isIoTimeoutError(error)) {
|
|
5334
5501
|
if (this.opts.debugMode) {
|
|
5335
5502
|
console.log(`⚠️ Streaming read attempt ${attempt} timed out, retrying...`);
|
|
5336
5503
|
}
|
|
@@ -6196,6 +6363,29 @@ class QueryManager {
|
|
|
6196
6363
|
return this._streamAllRecords(criteria, streamingOptions);
|
|
6197
6364
|
}
|
|
6198
6365
|
|
|
6366
|
+
/**
|
|
6367
|
+
* Whether a single field condition can/should be answered by the index during
|
|
6368
|
+
* pre-filtering (so the LEAST number of entries is streamed).
|
|
6369
|
+
*
|
|
6370
|
+
* - Raw RegExp is pre-filterable only on string/term indexes (IndexManager.query
|
|
6371
|
+
* resolves it via the index keys). Regex on numeric indexes -> residual.
|
|
6372
|
+
* - Complement operators ($ne/$nin) expand to nearly every record, so they are
|
|
6373
|
+
* NOT pre-filtered: they are applied LAST, on the minimal candidate set, by the
|
|
6374
|
+
* streaming matcher. (CHANGELOG [2.2.5])
|
|
6375
|
+
*/
|
|
6376
|
+
_canPrefilterFieldCondition(field, condition) {
|
|
6377
|
+
if (!this.indexManager?.opts?.indexes?.[field]) return false;
|
|
6378
|
+
if (condition instanceof RegExp) {
|
|
6379
|
+
const idxType = this.indexManager.opts.indexes[field];
|
|
6380
|
+
return idxType !== 'number' && idxType !== 'array:number';
|
|
6381
|
+
}
|
|
6382
|
+
if (condition && typeof condition === 'object' && !Array.isArray(condition)) {
|
|
6383
|
+
const ops = Object.keys(condition).map(op => normalizeOperator(op));
|
|
6384
|
+
if (ops.some(op => op === '$ne' || op === '$nin')) return false;
|
|
6385
|
+
}
|
|
6386
|
+
return true;
|
|
6387
|
+
}
|
|
6388
|
+
|
|
6199
6389
|
/**
|
|
6200
6390
|
* Get indexable fields from criteria
|
|
6201
6391
|
* @param {Object} criteria - Query criteria
|
|
@@ -6218,8 +6408,9 @@ class QueryManager {
|
|
|
6218
6408
|
for (const [field, condition] of Object.entries(criteria)) {
|
|
6219
6409
|
if (field.startsWith('$')) continue; // Skip logical operators
|
|
6220
6410
|
|
|
6221
|
-
//
|
|
6222
|
-
|
|
6411
|
+
// Pre-filter with the index only what it can answer minimally; everything
|
|
6412
|
+
// else is applied LAST over the streamed candidates (filter last, stream least).
|
|
6413
|
+
if (!this._canPrefilterFieldCondition(field, condition)) {
|
|
6223
6414
|
continue;
|
|
6224
6415
|
}
|
|
6225
6416
|
if (this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]) {
|
|
@@ -6266,8 +6457,9 @@ class QueryManager {
|
|
|
6266
6457
|
for (const [field, condition] of Object.entries(criteria)) {
|
|
6267
6458
|
if (field.startsWith('$')) continue; // Skip logical operators (already handled above)
|
|
6268
6459
|
|
|
6269
|
-
//
|
|
6270
|
-
|
|
6460
|
+
// Pre-filter with the index only what it can answer minimally; complement/
|
|
6461
|
+
// numeric-regex conditions are left residual and applied LAST on the candidates.
|
|
6462
|
+
if (!this._canPrefilterFieldCondition(field, condition)) {
|
|
6271
6463
|
continue;
|
|
6272
6464
|
}
|
|
6273
6465
|
if (this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]) {
|
|
@@ -6593,6 +6785,21 @@ class QueryManager {
|
|
|
6593
6785
|
forceFullScan: true
|
|
6594
6786
|
});
|
|
6595
6787
|
}
|
|
6788
|
+
|
|
6789
|
+
// Swap detection: if another process atomically replaced the data file, the
|
|
6790
|
+
// in-memory offsets/index describe the previous file. Reload cleanly, or -
|
|
6791
|
+
// when a writer sentinel is present / the clean reload fails - fall back to
|
|
6792
|
+
// a full streaming scan so stale byte offsets are never used on the new file.
|
|
6793
|
+
const refreshStatus = await this.database._refreshFromDiskIfChanged();
|
|
6794
|
+
if (refreshStatus === 'busy') {
|
|
6795
|
+
if (this.opts.debugMode) {
|
|
6796
|
+
console.log('⚠️ findWithIndexed: file busy (writer active) or clean reload failed, falling back to full streaming');
|
|
6797
|
+
}
|
|
6798
|
+
return this.findWithStreaming(criteria, {
|
|
6799
|
+
...options,
|
|
6800
|
+
forceFullScan: true
|
|
6801
|
+
});
|
|
6802
|
+
}
|
|
6596
6803
|
let results = [];
|
|
6597
6804
|
const limit = options.limit; // No default limit - return all results unless explicitly limited
|
|
6598
6805
|
|
|
@@ -6824,19 +7031,14 @@ class QueryManager {
|
|
|
6824
7031
|
matchesFieldCondition(record, field, condition, options = {}) {
|
|
6825
7032
|
const value = record[field];
|
|
6826
7033
|
|
|
6827
|
-
// Debug logging for all field conditions
|
|
7034
|
+
// Debug logging for all field conditions - SAMPLED: only the first record of
|
|
7035
|
+
// each query logs per field, so a full streaming scan (which calls this for
|
|
7036
|
+
// every record) does not spam the console with one line per record.
|
|
6828
7037
|
if (this.database.opts.debugMode) {
|
|
6829
|
-
|
|
6830
|
-
|
|
6831
|
-
|
|
6832
|
-
|
|
6833
|
-
});
|
|
6834
|
-
}
|
|
6835
|
-
|
|
6836
|
-
// Debug logging for term mapping fields
|
|
6837
|
-
if (this.database.opts.termMapping && Object.keys(this.database.opts.indexes || {}).includes(field)) {
|
|
6838
|
-
if (this.database.opts.debugMode) {
|
|
6839
|
-
console.log(`🔍 Checking term mapping field '${field}':`, {
|
|
7038
|
+
if (!options._dbFieldLog) options._dbFieldLog = {};
|
|
7039
|
+
if (!options._dbFieldLog[field]) {
|
|
7040
|
+
options._dbFieldLog[field] = true;
|
|
7041
|
+
console.log(`🔍 Checking field '${field}':`, {
|
|
6840
7042
|
value,
|
|
6841
7043
|
condition,
|
|
6842
7044
|
record: record.name || record.id
|
|
@@ -6844,6 +7046,21 @@ class QueryManager {
|
|
|
6844
7046
|
}
|
|
6845
7047
|
}
|
|
6846
7048
|
|
|
7049
|
+
// Debug logging for term mapping fields (sampled the same way)
|
|
7050
|
+
if (this.database.opts.termMapping && Object.keys(this.database.opts.indexes || {}).includes(field)) {
|
|
7051
|
+
if (this.database.opts.debugMode) {
|
|
7052
|
+
if (!options._dbFieldLog) options._dbFieldLog = {};
|
|
7053
|
+
if (!options._dbFieldLog[`tm:${field}`]) {
|
|
7054
|
+
options._dbFieldLog[`tm:${field}`] = true;
|
|
7055
|
+
console.log(`🔍 Checking term mapping field '${field}':`, {
|
|
7056
|
+
value,
|
|
7057
|
+
condition,
|
|
7058
|
+
record: record.name || record.id
|
|
7059
|
+
});
|
|
7060
|
+
}
|
|
7061
|
+
}
|
|
7062
|
+
}
|
|
7063
|
+
|
|
6847
7064
|
// Handle null/undefined values
|
|
6848
7065
|
if (value === null || value === undefined) {
|
|
6849
7066
|
return condition === null || condition === undefined;
|
|
@@ -7113,9 +7330,11 @@ class QueryManager {
|
|
|
7113
7330
|
}
|
|
7114
7331
|
const condition = andCondition[field];
|
|
7115
7332
|
|
|
7116
|
-
//
|
|
7333
|
+
// INTENTIONAL (CHANGELOG [2.2.5]): raw RegExp on string/term indexes is resolved
|
|
7334
|
+
// via index keys (not streaming). Do not revert to "regex => streaming".
|
|
7117
7335
|
if (condition instanceof RegExp) {
|
|
7118
|
-
|
|
7336
|
+
const idxType = this.indexManager?.opts?.indexes?.[field];
|
|
7337
|
+
return idxType !== 'number' && idxType !== 'array:number';
|
|
7119
7338
|
}
|
|
7120
7339
|
if (typeof condition === 'object' && !Array.isArray(condition)) {
|
|
7121
7340
|
const operators = Object.keys(condition).map(op => normalizeOperator(op));
|
|
@@ -7157,12 +7376,17 @@ class QueryManager {
|
|
|
7157
7376
|
// Check if the field uses operators that are supported by IndexManager
|
|
7158
7377
|
const condition = criteria[field];
|
|
7159
7378
|
|
|
7160
|
-
//
|
|
7379
|
+
// INTENTIONAL (CHANGELOG [2.2.5]): raw RegExp on string/term indexes is resolved
|
|
7380
|
+
// via index keys (not streaming). Do not revert to "regex => streaming".
|
|
7161
7381
|
if (condition instanceof RegExp) {
|
|
7162
|
-
|
|
7163
|
-
|
|
7382
|
+
const idxType = this.indexManager?.opts?.indexes?.[field];
|
|
7383
|
+
if (idxType === 'number' || idxType === 'array:number') {
|
|
7384
|
+
if (this.opts.debugMode) {
|
|
7385
|
+
console.log(`🔍 Field '${field}' uses RegExp on a numeric index - requires streaming strategy`);
|
|
7386
|
+
}
|
|
7387
|
+
return false;
|
|
7164
7388
|
}
|
|
7165
|
-
return
|
|
7389
|
+
return true;
|
|
7166
7390
|
}
|
|
7167
7391
|
if (typeof condition === 'object' && !Array.isArray(condition) && condition !== null) {
|
|
7168
7392
|
const operators = Object.keys(condition).map(op => normalizeOperator(op));
|
|
@@ -8972,7 +9196,11 @@ class Database extends events.EventEmitter {
|
|
|
8972
9196
|
enableArraySerialization: opts.enableArraySerialization !== false,
|
|
8973
9197
|
// Enable array serialization by default
|
|
8974
9198
|
// Index rebuild options
|
|
8975
|
-
allowIndexRebuild: opts.allowIndexRebuild === true
|
|
9199
|
+
allowIndexRebuild: opts.allowIndexRebuild === true,
|
|
9200
|
+
// Allow automatic index rebuild when corrupted (default false - throws error)
|
|
9201
|
+
// Read-only mode: never create, write, rebuild or auto-flush. Use it to
|
|
9202
|
+
// open a DB purely for reads (e.g. while another process is the writer).
|
|
9203
|
+
readOnly: opts.readOnly === true
|
|
8976
9204
|
}, opts);
|
|
8977
9205
|
|
|
8978
9206
|
// CRITICAL FIX: Initialize AbortController for lifecycle management
|
|
@@ -9002,6 +9230,14 @@ class Database extends events.EventEmitter {
|
|
|
9002
9230
|
this._offsetRecoveryInProgress = false;
|
|
9003
9231
|
this.writeBufferTotalSize = 0;
|
|
9004
9232
|
|
|
9233
|
+
// Swap detection state: the data file may be atomically replaced by another
|
|
9234
|
+
// process while this instance holds offsets/index computed for the previous
|
|
9235
|
+
// file. _offsetsFileSignature remembers which on-disk file the offsets
|
|
9236
|
+
// describe (size + mtime) so the read path can detect a swap cheaply.
|
|
9237
|
+
this._offsetsFileSignature = null;
|
|
9238
|
+
this._dataFingerprint = null; // cached stat (short TTL) for the read path
|
|
9239
|
+
this._offsetsReloadPromise = null; // single-flight clean reload after a swap
|
|
9240
|
+
|
|
9005
9241
|
// Initialize managers
|
|
9006
9242
|
this.initializeManagers();
|
|
9007
9243
|
|
|
@@ -9377,8 +9613,8 @@ class Database extends events.EventEmitter {
|
|
|
9377
9613
|
if (this.normalizedFile) {
|
|
9378
9614
|
const fileExists = await this.fileHandler.exists();
|
|
9379
9615
|
if (!fileExists) {
|
|
9380
|
-
if (!this.opts.create) {
|
|
9381
|
-
throw new Error(`Database file '${this.normalizedFile}' does not exist and create option is disabled`);
|
|
9616
|
+
if (!this.opts.create || this.opts.readOnly) {
|
|
9617
|
+
throw new Error(`Database file '${this.normalizedFile}' does not exist and ${this.opts.readOnly ? 'readOnly mode is enabled' : 'create option is disabled'}`);
|
|
9382
9618
|
}
|
|
9383
9619
|
// File will be created when first data is written
|
|
9384
9620
|
} else {
|
|
@@ -9554,6 +9790,10 @@ class Database extends events.EventEmitter {
|
|
|
9554
9790
|
}
|
|
9555
9791
|
}
|
|
9556
9792
|
|
|
9793
|
+
// Remember which on-disk data file these offsets describe, so the
|
|
9794
|
+
// read path can detect an atomic swap performed by another process.
|
|
9795
|
+
this._offsetsFileSignature = await this._statDataFingerprint();
|
|
9796
|
+
|
|
9557
9797
|
// Load indexOffset for proper range calculations
|
|
9558
9798
|
if (parsedIdxData.indexOffset !== undefined) {
|
|
9559
9799
|
this.indexOffset = parsedIdxData.indexOffset;
|
|
@@ -9854,6 +10094,14 @@ class Database extends events.EventEmitter {
|
|
|
9854
10094
|
// CRITICAL FIX: Check if database is destroyed
|
|
9855
10095
|
if (this.destroyed) return;
|
|
9856
10096
|
|
|
10097
|
+
// Read-only mode: never persist data to disk
|
|
10098
|
+
if (this.opts.readOnly) {
|
|
10099
|
+
if (this.opts.debugMode) {
|
|
10100
|
+
console.log('💾 _doSave: Database is read-only, skipping save');
|
|
10101
|
+
}
|
|
10102
|
+
return;
|
|
10103
|
+
}
|
|
10104
|
+
|
|
9857
10105
|
// CRITICAL FIX: Use atomic check-and-set to prevent concurrent save operations
|
|
9858
10106
|
if (this.isSaving) {
|
|
9859
10107
|
if (this.opts.debugMode) {
|
|
@@ -10261,15 +10509,20 @@ class Database extends events.EventEmitter {
|
|
|
10261
10509
|
console.log(`💾 Save: Calculated indexOffset: ${this.indexOffset}, allData.length: ${allData.length}`);
|
|
10262
10510
|
}
|
|
10263
10511
|
|
|
10264
|
-
// CRITICAL FIX: Write main data file first
|
|
10512
|
+
// CRITICAL FIX: Write main data file first (atomically: temp + rename so a
|
|
10513
|
+
// concurrent reader never sees a half-written file).
|
|
10265
10514
|
// Index will be saved AFTER reconstruction to ensure it contains correct data
|
|
10266
|
-
await this.fileHandler.
|
|
10515
|
+
await this.fileHandler.replaceDataFile(jsonlData);
|
|
10267
10516
|
if (this.opts.debugMode) {
|
|
10268
10517
|
console.log(`💾 Saved ${allData.length} records to ${this.normalizedFile}`);
|
|
10269
10518
|
}
|
|
10270
10519
|
|
|
10271
10520
|
// CRITICAL FIX: Invalidate file size cache after save operation
|
|
10272
10521
|
this._cachedFileStats = null;
|
|
10522
|
+
|
|
10523
|
+
// The in-memory offsets now describe the file we just wrote. Refresh the
|
|
10524
|
+
// swap-detection signature so the next indexed read does not reload.
|
|
10525
|
+
this._offsetsFileSignature = await this._statDataFingerprint();
|
|
10273
10526
|
this.shouldSave = false;
|
|
10274
10527
|
this.lastSaveTime = Date.now();
|
|
10275
10528
|
|
|
@@ -10668,6 +10921,9 @@ class Database extends events.EventEmitter {
|
|
|
10668
10921
|
*/
|
|
10669
10922
|
async insert(data) {
|
|
10670
10923
|
this._validateInitialization('insert');
|
|
10924
|
+
if (this.opts.readOnly) {
|
|
10925
|
+
throw new Error('Database is read-only');
|
|
10926
|
+
}
|
|
10671
10927
|
return this.operationQueue.enqueue(async () => {
|
|
10672
10928
|
this.isInsideOperationQueue = true;
|
|
10673
10929
|
try {
|
|
@@ -10762,6 +11018,9 @@ class Database extends events.EventEmitter {
|
|
|
10762
11018
|
*/
|
|
10763
11019
|
async insertBatch(dataArray) {
|
|
10764
11020
|
this._validateInitialization('insertBatch');
|
|
11021
|
+
if (this.opts.readOnly) {
|
|
11022
|
+
throw new Error('Database is read-only');
|
|
11023
|
+
}
|
|
10765
11024
|
|
|
10766
11025
|
// If we're already inside the operation queue (e.g., from insert()), avoid re-enqueueing to prevent deadlocks
|
|
10767
11026
|
if (this.isInsideOperationQueue) {
|
|
@@ -10787,6 +11046,11 @@ class Database extends events.EventEmitter {
|
|
|
10787
11046
|
* Internal implementation for insertBatch to allow inline execution when already inside the queue
|
|
10788
11047
|
*/
|
|
10789
11048
|
async _insertBatchInternal(dataArray) {
|
|
11049
|
+
// Read-only mode: never write to disk
|
|
11050
|
+
if (this.opts.readOnly) {
|
|
11051
|
+
throw new Error('Database is read-only');
|
|
11052
|
+
}
|
|
11053
|
+
|
|
10790
11054
|
// CRITICAL FIX: Validate state before insert operation
|
|
10791
11055
|
this.validateState();
|
|
10792
11056
|
if (!Array.isArray(dataArray) || dataArray.length === 0) {
|
|
@@ -10919,15 +11183,13 @@ class Database extends events.EventEmitter {
|
|
|
10919
11183
|
if (this.integrityCorrections.dataIntegrity > 5) {
|
|
10920
11184
|
console.warn(`⚠️ Frequent integrity corrections (${this.integrityCorrections.dataIntegrity} times) - this indicates a systemic issue`);
|
|
10921
11185
|
}
|
|
10922
|
-
this.indexManager.setTotalLines(offsetsLength);
|
|
10923
11186
|
|
|
10924
|
-
//
|
|
10925
|
-
|
|
10926
|
-
|
|
10927
|
-
|
|
10928
|
-
|
|
10929
|
-
|
|
10930
|
-
}
|
|
11187
|
+
// Reconcile the in-memory totalLines with the actual persisted offset
|
|
11188
|
+
// count. NOTE: deliberately NOT persisted here - find() is a read path
|
|
11189
|
+
// and must never rewrite the .idx file (especially while a concurrent
|
|
11190
|
+
// writer may be replacing it). A later save() persists a fully
|
|
11191
|
+
// consistent .idx anyway.
|
|
11192
|
+
this.indexManager.setTotalLines(offsetsLength);
|
|
10931
11193
|
}
|
|
10932
11194
|
}
|
|
10933
11195
|
|
|
@@ -11581,6 +11843,15 @@ class Database extends events.EventEmitter {
|
|
|
11581
11843
|
if (!this._indexRebuildNeeded) return;
|
|
11582
11844
|
if (!this.indexManager || !this.indexManager.indexedFields || this.indexManager.indexedFields.length === 0) return;
|
|
11583
11845
|
|
|
11846
|
+
// If the index was idle-unloaded (or never loaded), recover it cheaply from
|
|
11847
|
+
// the on-disk .idx file BEFORE considering a full rebuild. A rebuild is only
|
|
11848
|
+
// needed when the on-disk index is genuinely missing/corrupt. Mirrors the
|
|
11849
|
+
// score() hot path and prevents an idle-unload from triggering a full-file
|
|
11850
|
+
// rebuild (or a throw when allowIndexRebuild is false).
|
|
11851
|
+
if (!this.indexManager.indexLoaded) {
|
|
11852
|
+
await this._ensureLazyIndexLoaded();
|
|
11853
|
+
}
|
|
11854
|
+
|
|
11584
11855
|
// Check if index actually needs rebuilding
|
|
11585
11856
|
let needsRebuild = false;
|
|
11586
11857
|
for (const field of this.indexManager.indexedFields) {
|
|
@@ -11594,6 +11865,13 @@ class Database extends events.EventEmitter {
|
|
|
11594
11865
|
return;
|
|
11595
11866
|
}
|
|
11596
11867
|
|
|
11868
|
+
// Read-only mode: never write/rebuild on disk. Let queries fall back to a
|
|
11869
|
+
// full streaming scan instead of throwing or rewriting the index file.
|
|
11870
|
+
if (this.opts.readOnly) {
|
|
11871
|
+
this._indexRebuildNeeded = false;
|
|
11872
|
+
return;
|
|
11873
|
+
}
|
|
11874
|
+
|
|
11597
11875
|
// Check if rebuild is allowed
|
|
11598
11876
|
if (!this.opts.allowIndexRebuild) {
|
|
11599
11877
|
const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb');
|
|
@@ -11661,9 +11939,13 @@ class Database extends events.EventEmitter {
|
|
|
11661
11939
|
crlfDelay: Infinity
|
|
11662
11940
|
});
|
|
11663
11941
|
|
|
11664
|
-
// Handle abort
|
|
11942
|
+
// Handle abort. Destroy the stream WITHOUT an error payload, then
|
|
11943
|
+
// close readline. Destroying with `new Error('AbortError')` makes
|
|
11944
|
+
// the stream emit an unhandled 'error' (readline is closed first, so
|
|
11945
|
+
// the for-await loop exits cleanly and never observes it), which
|
|
11946
|
+
// Node escalates to an uncaughtException and crashes the host app.
|
|
11665
11947
|
controller.signal.addEventListener('abort', () => {
|
|
11666
|
-
stream.destroy(
|
|
11948
|
+
stream.destroy();
|
|
11667
11949
|
rl.close();
|
|
11668
11950
|
});
|
|
11669
11951
|
var _iteratorAbruptCompletion = false;
|
|
@@ -11711,6 +11993,10 @@ class Database extends events.EventEmitter {
|
|
|
11711
11993
|
}
|
|
11712
11994
|
}
|
|
11713
11995
|
}
|
|
11996
|
+
|
|
11997
|
+
// If aborted during schema detection, surface a normalized timeout
|
|
11998
|
+
// error so the retry wrapper (and ultimately the caller) observes it
|
|
11999
|
+
// instead of an uncaughtException.
|
|
11714
12000
|
} catch (err) {
|
|
11715
12001
|
_didIteratorError = true;
|
|
11716
12002
|
_iteratorError = err;
|
|
@@ -11725,6 +12011,9 @@ class Database extends events.EventEmitter {
|
|
|
11725
12011
|
}
|
|
11726
12012
|
}
|
|
11727
12013
|
}
|
|
12014
|
+
if (controller.signal.aborted) {
|
|
12015
|
+
throw createIoTimeoutError(timeoutMs);
|
|
12016
|
+
}
|
|
11728
12017
|
stream.destroy();
|
|
11729
12018
|
}
|
|
11730
12019
|
|
|
@@ -11742,9 +12031,10 @@ class Database extends events.EventEmitter {
|
|
|
11742
12031
|
crlfDelay: Infinity
|
|
11743
12032
|
});
|
|
11744
12033
|
|
|
11745
|
-
// Handle abort
|
|
12034
|
+
// Handle abort. See the schema stream above - destroy WITHOUT an error
|
|
12035
|
+
// payload to avoid an unhandled 'error' becoming an uncaughtException.
|
|
11746
12036
|
controller.signal.addEventListener('abort', () => {
|
|
11747
|
-
stream.destroy(
|
|
12037
|
+
stream.destroy();
|
|
11748
12038
|
rl.close();
|
|
11749
12039
|
});
|
|
11750
12040
|
let localCount = 0;
|
|
@@ -11775,6 +12065,9 @@ class Database extends events.EventEmitter {
|
|
|
11775
12065
|
currentOffset += Buffer.byteLength(line, 'utf8') + 1;
|
|
11776
12066
|
}
|
|
11777
12067
|
}
|
|
12068
|
+
|
|
12069
|
+
// If the read was aborted, do NOT treat a partial read as a successful
|
|
12070
|
+
// rebuild - surface a normalized timeout error so it can be retried.
|
|
11778
12071
|
} catch (err) {
|
|
11779
12072
|
_didIteratorError2 = true;
|
|
11780
12073
|
_iteratorError2 = err;
|
|
@@ -11789,10 +12082,17 @@ class Database extends events.EventEmitter {
|
|
|
11789
12082
|
}
|
|
11790
12083
|
}
|
|
11791
12084
|
}
|
|
12085
|
+
if (controller.signal.aborted) {
|
|
12086
|
+
throw createIoTimeoutError(timeoutMs);
|
|
12087
|
+
}
|
|
11792
12088
|
count += localCount;
|
|
11793
12089
|
stream.destroy();
|
|
11794
12090
|
} catch (error) {
|
|
11795
|
-
|
|
12091
|
+
// p-retry "stop" signal (may be absent when p-retry is mocked in tests)
|
|
12092
|
+
if (typeof pRetry.AbortError === 'function' && error instanceof pRetry.AbortError) {
|
|
12093
|
+
throw error;
|
|
12094
|
+
}
|
|
12095
|
+
if (isIoTimeoutError(error)) {
|
|
11796
12096
|
if (this.opts.debugMode) {
|
|
11797
12097
|
console.log(`⚠️ Index rebuild attempt ${attempt} timed out, retrying...`);
|
|
11798
12098
|
}
|
|
@@ -12523,6 +12823,11 @@ class Database extends events.EventEmitter {
|
|
|
12523
12823
|
await this._ensureLazyIndexLoaded();
|
|
12524
12824
|
}
|
|
12525
12825
|
|
|
12826
|
+
// Swap detection: if another process atomically replaced the data file,
|
|
12827
|
+
// reload offsets + index (and term mapping) cleanly so the scores below are
|
|
12828
|
+
// computed against the current file, never stale byte ranges.
|
|
12829
|
+
await this._refreshFromDiskIfChanged();
|
|
12830
|
+
|
|
12526
12831
|
// Access the index for this field
|
|
12527
12832
|
const fieldIndex = this.indexManager.index.data[fieldName];
|
|
12528
12833
|
if (!fieldIndex || Object.keys(fieldIndex).length === 0) {
|
|
@@ -12754,6 +13059,9 @@ class Database extends events.EventEmitter {
|
|
|
12754
13059
|
}
|
|
12755
13060
|
}
|
|
12756
13061
|
}
|
|
13062
|
+
|
|
13063
|
+
// If the read was aborted, do not silently return partial scores -
|
|
13064
|
+
// surface a normalized timeout error so it can be retried.
|
|
12757
13065
|
} catch (err) {
|
|
12758
13066
|
_didIteratorError3 = true;
|
|
12759
13067
|
_iteratorError3 = err;
|
|
@@ -12768,9 +13076,16 @@ class Database extends events.EventEmitter {
|
|
|
12768
13076
|
}
|
|
12769
13077
|
}
|
|
12770
13078
|
}
|
|
13079
|
+
if (controller.signal.aborted) {
|
|
13080
|
+
throw createIoTimeoutError(timeoutMs);
|
|
13081
|
+
}
|
|
12771
13082
|
results.push(...rangeResults);
|
|
12772
13083
|
} catch (error) {
|
|
12773
|
-
|
|
13084
|
+
// p-retry "stop" signal (may be absent when p-retry is mocked in tests)
|
|
13085
|
+
if (typeof pRetry.AbortError === 'function' && error instanceof pRetry.AbortError) {
|
|
13086
|
+
throw error;
|
|
13087
|
+
}
|
|
13088
|
+
if (isIoTimeoutError(error)) {
|
|
12774
13089
|
if (this.opts.debugMode) {
|
|
12775
13090
|
console.log(`⚠️ Score range read attempt ${attempt} timed out, retrying...`);
|
|
12776
13091
|
}
|
|
@@ -13886,10 +14201,27 @@ class Database extends events.EventEmitter {
|
|
|
13886
14201
|
this.indexManager.indexLoaded = true;
|
|
13887
14202
|
return;
|
|
13888
14203
|
}
|
|
13889
|
-
|
|
13890
|
-
|
|
14204
|
+
|
|
14205
|
+
// Single-flight: N concurrent callers (find/count/score) share a single
|
|
14206
|
+
// on-disk load. The promise is cleared in a finally so a rejected load is
|
|
14207
|
+
// never left behind to poison every future attempt.
|
|
14208
|
+
if (!this._lazyIndexLoadPromise) {
|
|
14209
|
+
this._lazyIndexLoadPromise = this._doLazyIndexLoad().finally(() => {
|
|
14210
|
+
this._lazyIndexLoadPromise = null;
|
|
14211
|
+
});
|
|
13891
14212
|
}
|
|
13892
|
-
this._lazyIndexLoadPromise
|
|
14213
|
+
return this._lazyIndexLoadPromise;
|
|
14214
|
+
}
|
|
14215
|
+
|
|
14216
|
+
/**
|
|
14217
|
+
* Actually perform the lazy index load from the on-disk .idx file.
|
|
14218
|
+
* Never rejects: on failure it degrades to an empty in-memory index (queries
|
|
14219
|
+
* fall back to streaming) instead of poisoning the single-flight or surfacing
|
|
14220
|
+
* a background cold-load as an unhandled rejection.
|
|
14221
|
+
* @private
|
|
14222
|
+
*/
|
|
14223
|
+
async _doLazyIndexLoad() {
|
|
14224
|
+
try {
|
|
13893
14225
|
const indexData = await this._loadIndexDataFromFile();
|
|
13894
14226
|
if (indexData) {
|
|
13895
14227
|
this.indexManager.load(indexData);
|
|
@@ -13900,9 +14232,153 @@ class Database extends events.EventEmitter {
|
|
|
13900
14232
|
this.indexManager.indexLoaded = true;
|
|
13901
14233
|
this.indexManager.markIndexUsed();
|
|
13902
14234
|
}
|
|
13903
|
-
|
|
14235
|
+
} catch (error) {
|
|
14236
|
+
if (this.opts.debugMode) {
|
|
14237
|
+
console.error(`⚠️ _ensureLazyIndexLoaded: Lazy index load failed, continuing with empty index: ${error && error.message}`);
|
|
14238
|
+
}
|
|
14239
|
+
// Never leave the index flagged as not-loaded: mark it loaded (empty) so
|
|
14240
|
+
// later calls short-circuit and queries degrade to a full scan.
|
|
14241
|
+
this.indexManager.indexLoaded = true;
|
|
14242
|
+
}
|
|
14243
|
+
}
|
|
14244
|
+
|
|
14245
|
+
/**
|
|
14246
|
+
* Path of the "writer active" sentinel file for this data file, e.g.
|
|
14247
|
+
* `list.jdb` -> `list.updating.jdb`. While this file exists, a writer is
|
|
14248
|
+
* atomically replacing the database and readers must not refresh/repair it.
|
|
14249
|
+
* @returns {string}
|
|
14250
|
+
*/
|
|
14251
|
+
_getUpdatingSentinelPath() {
|
|
14252
|
+
return this.normalizedFile.replace(/\.jdb$/i, '.updating.jdb');
|
|
14253
|
+
}
|
|
14254
|
+
|
|
14255
|
+
/**
|
|
14256
|
+
* Whether a writer sentinel is currently present on disk.
|
|
14257
|
+
* @returns {Promise<boolean>}
|
|
14258
|
+
*/
|
|
14259
|
+
async hasUpdatingSentinel() {
|
|
14260
|
+
try {
|
|
14261
|
+
await fs.promises.access(this._getUpdatingSentinelPath());
|
|
14262
|
+
return true;
|
|
14263
|
+
} catch (error) {
|
|
14264
|
+
return false;
|
|
14265
|
+
}
|
|
14266
|
+
}
|
|
14267
|
+
|
|
14268
|
+
/**
|
|
14269
|
+
* Capture the current data-file fingerprint (size + mtime) for swap
|
|
14270
|
+
* detection. Best-effort: returns null when the file cannot be stat'ed.
|
|
14271
|
+
* @returns {Promise<{size: number, mtimeMs: number}|null>}
|
|
14272
|
+
*/
|
|
14273
|
+
async _statDataFingerprint() {
|
|
14274
|
+
try {
|
|
14275
|
+
const st = await fs.promises.stat(this.normalizedFile);
|
|
14276
|
+
return {
|
|
14277
|
+
size: st.size,
|
|
14278
|
+
mtimeMs: st.mtimeMs
|
|
14279
|
+
};
|
|
14280
|
+
} catch (error) {
|
|
14281
|
+
return null;
|
|
14282
|
+
}
|
|
14283
|
+
}
|
|
14284
|
+
|
|
14285
|
+
/**
|
|
14286
|
+
* Cheap swap detection for the read path. The data file may be atomically
|
|
14287
|
+
* replaced by a writer (same or another process); the in-memory offsets and
|
|
14288
|
+
* index describe the previous file. When the on-disk size/mtime no longer
|
|
14289
|
+
* match the signature captured when the offsets were loaded, reload offsets
|
|
14290
|
+
* and index cleanly from the current .idx.
|
|
14291
|
+
*
|
|
14292
|
+
* Returns:
|
|
14293
|
+
* - 'ok' : nothing changed / refresh not applicable
|
|
14294
|
+
* - 'changed' : file was swapped and offsets + index were cleanly reloaded
|
|
14295
|
+
* - 'busy' : a writer sentinel is present (or the clean reload failed) -
|
|
14296
|
+
* the caller must fall back to a full streaming scan instead
|
|
14297
|
+
* of using possibly stale byte offsets on the new file
|
|
14298
|
+
* @returns {Promise<'ok'|'changed'|'busy'>}
|
|
14299
|
+
*/
|
|
14300
|
+
async _refreshFromDiskIfChanged() {
|
|
14301
|
+
// If a writer is mid-update, never refresh/repair in place.
|
|
14302
|
+
if (this.opts.updatingSentinel && (await this.hasUpdatingSentinel())) {
|
|
14303
|
+
return 'busy';
|
|
14304
|
+
}
|
|
14305
|
+
if (!this._offsetsFileSignature) return 'ok';
|
|
14306
|
+
if (!this.offsets || this.offsets.length === 0) return 'ok';
|
|
14307
|
+
|
|
14308
|
+
// Cheap stat with a short TTL so hot queries don't syscall every time.
|
|
14309
|
+
const now = Date.now();
|
|
14310
|
+
if (!this._dataFingerprint || !this._dataFingerprint.at || now - this._dataFingerprint.at > 250) {
|
|
14311
|
+
const st = await this._statDataFingerprint();
|
|
14312
|
+
this._dataFingerprint = {
|
|
14313
|
+
...(st || {
|
|
14314
|
+
size: 0,
|
|
14315
|
+
mtimeMs: 0
|
|
14316
|
+
}),
|
|
14317
|
+
at: now
|
|
14318
|
+
};
|
|
14319
|
+
}
|
|
14320
|
+
const fp = this._dataFingerprint;
|
|
14321
|
+
const sig = this._offsetsFileSignature;
|
|
14322
|
+
if (fp.size === sig.size && fp.mtimeMs === sig.mtimeMs) return 'ok';
|
|
14323
|
+
const reloaded = await this._reloadOffsetsAndIndex();
|
|
14324
|
+
if (!reloaded) return 'busy';
|
|
14325
|
+
// Signature now describes the (already stat'ed) new file
|
|
14326
|
+
this._offsetsFileSignature = {
|
|
14327
|
+
size: fp.size,
|
|
14328
|
+
mtimeMs: fp.mtimeMs
|
|
14329
|
+
};
|
|
14330
|
+
return 'changed';
|
|
14331
|
+
}
|
|
14332
|
+
|
|
14333
|
+
/**
|
|
14334
|
+
* Cleanly reload offsets/indexOffset/index data from the on-disk .idx file
|
|
14335
|
+
* after the data file was swapped. Single-flight so N concurrent queries
|
|
14336
|
+
* share one reload.
|
|
14337
|
+
* @returns {Promise<boolean>} true when the reload produced consistent state
|
|
14338
|
+
*/
|
|
14339
|
+
async _reloadOffsetsAndIndex() {
|
|
14340
|
+
if (this._offsetsReloadPromise) {
|
|
14341
|
+
return this._offsetsReloadPromise;
|
|
14342
|
+
}
|
|
14343
|
+
this._offsetsReloadPromise = (async () => {
|
|
14344
|
+
const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb');
|
|
14345
|
+
try {
|
|
14346
|
+
const raw = await fs.promises.readFile(idxPath, 'utf8');
|
|
14347
|
+
const parsed = JSON.parse(raw);
|
|
14348
|
+
if (!parsed || !Array.isArray(parsed.offsets)) return false;
|
|
14349
|
+
this.offsets = parsed.offsets;
|
|
14350
|
+
this.indexOffset = typeof parsed.indexOffset === 'number' ? parsed.indexOffset : 0;
|
|
14351
|
+
|
|
14352
|
+
// Reload term mapping (if any) first so term IDs/words match the new
|
|
14353
|
+
// index and the records it describes.
|
|
14354
|
+
if (parsed.termMapping && this.termManager && typeof this.termManager.loadTerms === 'function') {
|
|
14355
|
+
this.termManager.loadTerms(parsed.termMapping);
|
|
14356
|
+
}
|
|
14357
|
+
|
|
14358
|
+
// Clear the current in-memory index so IndexManager.load() does not
|
|
14359
|
+
// short-circuit (load() skips when it already sees actual data).
|
|
14360
|
+
if (this.indexManager && this.indexManager.indexedFields) {
|
|
14361
|
+
for (const field of this.indexManager.indexedFields) {
|
|
14362
|
+
if (this.indexManager.index && this.indexManager.index.data) {
|
|
14363
|
+
this.indexManager.index.data[field] = {};
|
|
14364
|
+
}
|
|
14365
|
+
}
|
|
14366
|
+
}
|
|
14367
|
+
// Reload the in-memory index from the same (new) .idx data
|
|
14368
|
+
this.indexManager.indexLoaded = false;
|
|
14369
|
+
await this._ensureLazyIndexLoaded();
|
|
14370
|
+
this.indexManager.setTotalLines(this.offsets.length);
|
|
14371
|
+
return true;
|
|
14372
|
+
} catch (error) {
|
|
14373
|
+
if (this.opts.debugMode) {
|
|
14374
|
+
console.warn(`⚠️ _reloadOffsetsAndIndex: could not reload from ${idxPath}: ${error && error.message}`);
|
|
14375
|
+
}
|
|
14376
|
+
return false;
|
|
14377
|
+
} finally {
|
|
14378
|
+
this._offsetsReloadPromise = null;
|
|
14379
|
+
}
|
|
13904
14380
|
})();
|
|
13905
|
-
return this.
|
|
14381
|
+
return this._offsetsReloadPromise;
|
|
13906
14382
|
}
|
|
13907
14383
|
|
|
13908
14384
|
/**
|
|
@@ -14240,6 +14716,10 @@ class Database extends events.EventEmitter {
|
|
|
14240
14716
|
yield _awaitAsyncGenerator(new Promise(resolve => setTimeout(resolve, 10)));
|
|
14241
14717
|
}
|
|
14242
14718
|
}
|
|
14719
|
+
|
|
14720
|
+
// Swap detection: if another process replaced the data file, reload offsets
|
|
14721
|
+
// and index cleanly so the line map / ranges describe the current file.
|
|
14722
|
+
yield _awaitAsyncGenerator(_this2._refreshFromDiskIfChanged());
|
|
14243
14723
|
let count = 0;
|
|
14244
14724
|
let remainingSkip = options.skip || 0;
|
|
14245
14725
|
let map;
|
|
@@ -14976,6 +15456,8 @@ class Database extends events.EventEmitter {
|
|
|
14976
15456
|
* @private
|
|
14977
15457
|
*/
|
|
14978
15458
|
async _saveIndexDataToFile() {
|
|
15459
|
+
// Read-only mode: never write to the index file on disk
|
|
15460
|
+
if (this.opts.readOnly) return;
|
|
14979
15461
|
if (this.indexManager) {
|
|
14980
15462
|
try {
|
|
14981
15463
|
const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb');
|
|
@@ -15042,7 +15524,7 @@ class Database extends events.EventEmitter {
|
|
|
15042
15524
|
// This ensures the database structure is complete
|
|
15043
15525
|
const originalFile = this.fileHandler.file;
|
|
15044
15526
|
this.fileHandler.file = idxPath;
|
|
15045
|
-
await this.fileHandler.
|
|
15527
|
+
await this.fileHandler.writeFileAtomic(JSON.stringify(indexData, null, 2));
|
|
15046
15528
|
this.fileHandler.file = originalFile;
|
|
15047
15529
|
if (this.opts.debugMode) {
|
|
15048
15530
|
console.log(`💾 Index data saved to ${idxPath}`);
|