jexidb 2.2.3 → 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 +510 -55
- package/package.json +1 -1
- package/src/Database.mjs +274 -28
- package/src/FileHandler.mjs +88 -1
- package/src/managers/IndexManager.mjs +32 -6
- 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
|
|
@@ -4289,6 +4312,39 @@ class OperationQueue {
|
|
|
4289
4312
|
}
|
|
4290
4313
|
}
|
|
4291
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
|
+
|
|
4292
4348
|
/*
|
|
4293
4349
|
How it works:
|
|
4294
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.
|
|
@@ -5173,6 +5229,23 @@ class FileHandler {
|
|
|
5173
5229
|
await fs.promises.rename(tmpFile, targetFile);
|
|
5174
5230
|
return; // Success
|
|
5175
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
|
+
}
|
|
5176
5249
|
if (error.code === 'EPERM' && attempt < maxRetries) {
|
|
5177
5250
|
// Quick delay: 50ms, 100ms, 200ms
|
|
5178
5251
|
const delay = 50 * attempt;
|
|
@@ -5244,6 +5317,69 @@ class FileHandler {
|
|
|
5244
5317
|
throw fallbackError;
|
|
5245
5318
|
}
|
|
5246
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
|
+
}
|
|
5247
5383
|
async writeData(data, immediate, fd) {
|
|
5248
5384
|
await fd.write(data);
|
|
5249
5385
|
}
|
|
@@ -5357,7 +5493,11 @@ class FileHandler {
|
|
|
5357
5493
|
const results = await this._readWithStreamingInternal(criteria, options, matchesCriteria, serializer, controller.signal);
|
|
5358
5494
|
return results;
|
|
5359
5495
|
} catch (error) {
|
|
5360
|
-
|
|
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)) {
|
|
5361
5501
|
if (this.opts.debugMode) {
|
|
5362
5502
|
console.log(`⚠️ Streaming read attempt ${attempt} timed out, retrying...`);
|
|
5363
5503
|
}
|
|
@@ -6223,6 +6363,29 @@ class QueryManager {
|
|
|
6223
6363
|
return this._streamAllRecords(criteria, streamingOptions);
|
|
6224
6364
|
}
|
|
6225
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
|
+
|
|
6226
6389
|
/**
|
|
6227
6390
|
* Get indexable fields from criteria
|
|
6228
6391
|
* @param {Object} criteria - Query criteria
|
|
@@ -6245,8 +6408,9 @@ class QueryManager {
|
|
|
6245
6408
|
for (const [field, condition] of Object.entries(criteria)) {
|
|
6246
6409
|
if (field.startsWith('$')) continue; // Skip logical operators
|
|
6247
6410
|
|
|
6248
|
-
//
|
|
6249
|
-
|
|
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)) {
|
|
6250
6414
|
continue;
|
|
6251
6415
|
}
|
|
6252
6416
|
if (this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]) {
|
|
@@ -6293,8 +6457,9 @@ class QueryManager {
|
|
|
6293
6457
|
for (const [field, condition] of Object.entries(criteria)) {
|
|
6294
6458
|
if (field.startsWith('$')) continue; // Skip logical operators (already handled above)
|
|
6295
6459
|
|
|
6296
|
-
//
|
|
6297
|
-
|
|
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)) {
|
|
6298
6463
|
continue;
|
|
6299
6464
|
}
|
|
6300
6465
|
if (this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]) {
|
|
@@ -6620,6 +6785,21 @@ class QueryManager {
|
|
|
6620
6785
|
forceFullScan: true
|
|
6621
6786
|
});
|
|
6622
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
|
+
}
|
|
6623
6803
|
let results = [];
|
|
6624
6804
|
const limit = options.limit; // No default limit - return all results unless explicitly limited
|
|
6625
6805
|
|
|
@@ -6851,19 +7031,14 @@ class QueryManager {
|
|
|
6851
7031
|
matchesFieldCondition(record, field, condition, options = {}) {
|
|
6852
7032
|
const value = record[field];
|
|
6853
7033
|
|
|
6854
|
-
// 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.
|
|
6855
7037
|
if (this.database.opts.debugMode) {
|
|
6856
|
-
|
|
6857
|
-
|
|
6858
|
-
|
|
6859
|
-
|
|
6860
|
-
});
|
|
6861
|
-
}
|
|
6862
|
-
|
|
6863
|
-
// Debug logging for term mapping fields
|
|
6864
|
-
if (this.database.opts.termMapping && Object.keys(this.database.opts.indexes || {}).includes(field)) {
|
|
6865
|
-
if (this.database.opts.debugMode) {
|
|
6866
|
-
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}':`, {
|
|
6867
7042
|
value,
|
|
6868
7043
|
condition,
|
|
6869
7044
|
record: record.name || record.id
|
|
@@ -6871,6 +7046,21 @@ class QueryManager {
|
|
|
6871
7046
|
}
|
|
6872
7047
|
}
|
|
6873
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
|
+
|
|
6874
7064
|
// Handle null/undefined values
|
|
6875
7065
|
if (value === null || value === undefined) {
|
|
6876
7066
|
return condition === null || condition === undefined;
|
|
@@ -7140,9 +7330,11 @@ class QueryManager {
|
|
|
7140
7330
|
}
|
|
7141
7331
|
const condition = andCondition[field];
|
|
7142
7332
|
|
|
7143
|
-
//
|
|
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".
|
|
7144
7335
|
if (condition instanceof RegExp) {
|
|
7145
|
-
|
|
7336
|
+
const idxType = this.indexManager?.opts?.indexes?.[field];
|
|
7337
|
+
return idxType !== 'number' && idxType !== 'array:number';
|
|
7146
7338
|
}
|
|
7147
7339
|
if (typeof condition === 'object' && !Array.isArray(condition)) {
|
|
7148
7340
|
const operators = Object.keys(condition).map(op => normalizeOperator(op));
|
|
@@ -7184,12 +7376,17 @@ class QueryManager {
|
|
|
7184
7376
|
// Check if the field uses operators that are supported by IndexManager
|
|
7185
7377
|
const condition = criteria[field];
|
|
7186
7378
|
|
|
7187
|
-
//
|
|
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".
|
|
7188
7381
|
if (condition instanceof RegExp) {
|
|
7189
|
-
|
|
7190
|
-
|
|
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;
|
|
7191
7388
|
}
|
|
7192
|
-
return
|
|
7389
|
+
return true;
|
|
7193
7390
|
}
|
|
7194
7391
|
if (typeof condition === 'object' && !Array.isArray(condition) && condition !== null) {
|
|
7195
7392
|
const operators = Object.keys(condition).map(op => normalizeOperator(op));
|
|
@@ -8999,7 +9196,11 @@ class Database extends events.EventEmitter {
|
|
|
8999
9196
|
enableArraySerialization: opts.enableArraySerialization !== false,
|
|
9000
9197
|
// Enable array serialization by default
|
|
9001
9198
|
// Index rebuild options
|
|
9002
|
-
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
|
|
9003
9204
|
}, opts);
|
|
9004
9205
|
|
|
9005
9206
|
// CRITICAL FIX: Initialize AbortController for lifecycle management
|
|
@@ -9029,6 +9230,14 @@ class Database extends events.EventEmitter {
|
|
|
9029
9230
|
this._offsetRecoveryInProgress = false;
|
|
9030
9231
|
this.writeBufferTotalSize = 0;
|
|
9031
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
|
+
|
|
9032
9241
|
// Initialize managers
|
|
9033
9242
|
this.initializeManagers();
|
|
9034
9243
|
|
|
@@ -9404,8 +9613,8 @@ class Database extends events.EventEmitter {
|
|
|
9404
9613
|
if (this.normalizedFile) {
|
|
9405
9614
|
const fileExists = await this.fileHandler.exists();
|
|
9406
9615
|
if (!fileExists) {
|
|
9407
|
-
if (!this.opts.create) {
|
|
9408
|
-
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'}`);
|
|
9409
9618
|
}
|
|
9410
9619
|
// File will be created when first data is written
|
|
9411
9620
|
} else {
|
|
@@ -9581,6 +9790,10 @@ class Database extends events.EventEmitter {
|
|
|
9581
9790
|
}
|
|
9582
9791
|
}
|
|
9583
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
|
+
|
|
9584
9797
|
// Load indexOffset for proper range calculations
|
|
9585
9798
|
if (parsedIdxData.indexOffset !== undefined) {
|
|
9586
9799
|
this.indexOffset = parsedIdxData.indexOffset;
|
|
@@ -9881,6 +10094,14 @@ class Database extends events.EventEmitter {
|
|
|
9881
10094
|
// CRITICAL FIX: Check if database is destroyed
|
|
9882
10095
|
if (this.destroyed) return;
|
|
9883
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
|
+
|
|
9884
10105
|
// CRITICAL FIX: Use atomic check-and-set to prevent concurrent save operations
|
|
9885
10106
|
if (this.isSaving) {
|
|
9886
10107
|
if (this.opts.debugMode) {
|
|
@@ -10288,15 +10509,20 @@ class Database extends events.EventEmitter {
|
|
|
10288
10509
|
console.log(`💾 Save: Calculated indexOffset: ${this.indexOffset}, allData.length: ${allData.length}`);
|
|
10289
10510
|
}
|
|
10290
10511
|
|
|
10291
|
-
// 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).
|
|
10292
10514
|
// Index will be saved AFTER reconstruction to ensure it contains correct data
|
|
10293
|
-
await this.fileHandler.
|
|
10515
|
+
await this.fileHandler.replaceDataFile(jsonlData);
|
|
10294
10516
|
if (this.opts.debugMode) {
|
|
10295
10517
|
console.log(`💾 Saved ${allData.length} records to ${this.normalizedFile}`);
|
|
10296
10518
|
}
|
|
10297
10519
|
|
|
10298
10520
|
// CRITICAL FIX: Invalidate file size cache after save operation
|
|
10299
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();
|
|
10300
10526
|
this.shouldSave = false;
|
|
10301
10527
|
this.lastSaveTime = Date.now();
|
|
10302
10528
|
|
|
@@ -10695,6 +10921,9 @@ class Database extends events.EventEmitter {
|
|
|
10695
10921
|
*/
|
|
10696
10922
|
async insert(data) {
|
|
10697
10923
|
this._validateInitialization('insert');
|
|
10924
|
+
if (this.opts.readOnly) {
|
|
10925
|
+
throw new Error('Database is read-only');
|
|
10926
|
+
}
|
|
10698
10927
|
return this.operationQueue.enqueue(async () => {
|
|
10699
10928
|
this.isInsideOperationQueue = true;
|
|
10700
10929
|
try {
|
|
@@ -10789,6 +11018,9 @@ class Database extends events.EventEmitter {
|
|
|
10789
11018
|
*/
|
|
10790
11019
|
async insertBatch(dataArray) {
|
|
10791
11020
|
this._validateInitialization('insertBatch');
|
|
11021
|
+
if (this.opts.readOnly) {
|
|
11022
|
+
throw new Error('Database is read-only');
|
|
11023
|
+
}
|
|
10792
11024
|
|
|
10793
11025
|
// If we're already inside the operation queue (e.g., from insert()), avoid re-enqueueing to prevent deadlocks
|
|
10794
11026
|
if (this.isInsideOperationQueue) {
|
|
@@ -10814,6 +11046,11 @@ class Database extends events.EventEmitter {
|
|
|
10814
11046
|
* Internal implementation for insertBatch to allow inline execution when already inside the queue
|
|
10815
11047
|
*/
|
|
10816
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
|
+
|
|
10817
11054
|
// CRITICAL FIX: Validate state before insert operation
|
|
10818
11055
|
this.validateState();
|
|
10819
11056
|
if (!Array.isArray(dataArray) || dataArray.length === 0) {
|
|
@@ -10946,15 +11183,13 @@ class Database extends events.EventEmitter {
|
|
|
10946
11183
|
if (this.integrityCorrections.dataIntegrity > 5) {
|
|
10947
11184
|
console.warn(`⚠️ Frequent integrity corrections (${this.integrityCorrections.dataIntegrity} times) - this indicates a systemic issue`);
|
|
10948
11185
|
}
|
|
10949
|
-
this.indexManager.setTotalLines(offsetsLength);
|
|
10950
11186
|
|
|
10951
|
-
//
|
|
10952
|
-
|
|
10953
|
-
|
|
10954
|
-
|
|
10955
|
-
|
|
10956
|
-
|
|
10957
|
-
}
|
|
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);
|
|
10958
11193
|
}
|
|
10959
11194
|
}
|
|
10960
11195
|
|
|
@@ -11608,6 +11843,15 @@ class Database extends events.EventEmitter {
|
|
|
11608
11843
|
if (!this._indexRebuildNeeded) return;
|
|
11609
11844
|
if (!this.indexManager || !this.indexManager.indexedFields || this.indexManager.indexedFields.length === 0) return;
|
|
11610
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
|
+
|
|
11611
11855
|
// Check if index actually needs rebuilding
|
|
11612
11856
|
let needsRebuild = false;
|
|
11613
11857
|
for (const field of this.indexManager.indexedFields) {
|
|
@@ -11621,6 +11865,13 @@ class Database extends events.EventEmitter {
|
|
|
11621
11865
|
return;
|
|
11622
11866
|
}
|
|
11623
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
|
+
|
|
11624
11875
|
// Check if rebuild is allowed
|
|
11625
11876
|
if (!this.opts.allowIndexRebuild) {
|
|
11626
11877
|
const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb');
|
|
@@ -11688,9 +11939,13 @@ class Database extends events.EventEmitter {
|
|
|
11688
11939
|
crlfDelay: Infinity
|
|
11689
11940
|
});
|
|
11690
11941
|
|
|
11691
|
-
// 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.
|
|
11692
11947
|
controller.signal.addEventListener('abort', () => {
|
|
11693
|
-
stream.destroy(
|
|
11948
|
+
stream.destroy();
|
|
11694
11949
|
rl.close();
|
|
11695
11950
|
});
|
|
11696
11951
|
var _iteratorAbruptCompletion = false;
|
|
@@ -11738,6 +11993,10 @@ class Database extends events.EventEmitter {
|
|
|
11738
11993
|
}
|
|
11739
11994
|
}
|
|
11740
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.
|
|
11741
12000
|
} catch (err) {
|
|
11742
12001
|
_didIteratorError = true;
|
|
11743
12002
|
_iteratorError = err;
|
|
@@ -11752,6 +12011,9 @@ class Database extends events.EventEmitter {
|
|
|
11752
12011
|
}
|
|
11753
12012
|
}
|
|
11754
12013
|
}
|
|
12014
|
+
if (controller.signal.aborted) {
|
|
12015
|
+
throw createIoTimeoutError(timeoutMs);
|
|
12016
|
+
}
|
|
11755
12017
|
stream.destroy();
|
|
11756
12018
|
}
|
|
11757
12019
|
|
|
@@ -11769,9 +12031,10 @@ class Database extends events.EventEmitter {
|
|
|
11769
12031
|
crlfDelay: Infinity
|
|
11770
12032
|
});
|
|
11771
12033
|
|
|
11772
|
-
// Handle abort
|
|
12034
|
+
// Handle abort. See the schema stream above - destroy WITHOUT an error
|
|
12035
|
+
// payload to avoid an unhandled 'error' becoming an uncaughtException.
|
|
11773
12036
|
controller.signal.addEventListener('abort', () => {
|
|
11774
|
-
stream.destroy(
|
|
12037
|
+
stream.destroy();
|
|
11775
12038
|
rl.close();
|
|
11776
12039
|
});
|
|
11777
12040
|
let localCount = 0;
|
|
@@ -11802,6 +12065,9 @@ class Database extends events.EventEmitter {
|
|
|
11802
12065
|
currentOffset += Buffer.byteLength(line, 'utf8') + 1;
|
|
11803
12066
|
}
|
|
11804
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.
|
|
11805
12071
|
} catch (err) {
|
|
11806
12072
|
_didIteratorError2 = true;
|
|
11807
12073
|
_iteratorError2 = err;
|
|
@@ -11816,10 +12082,17 @@ class Database extends events.EventEmitter {
|
|
|
11816
12082
|
}
|
|
11817
12083
|
}
|
|
11818
12084
|
}
|
|
12085
|
+
if (controller.signal.aborted) {
|
|
12086
|
+
throw createIoTimeoutError(timeoutMs);
|
|
12087
|
+
}
|
|
11819
12088
|
count += localCount;
|
|
11820
12089
|
stream.destroy();
|
|
11821
12090
|
} catch (error) {
|
|
11822
|
-
|
|
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)) {
|
|
11823
12096
|
if (this.opts.debugMode) {
|
|
11824
12097
|
console.log(`⚠️ Index rebuild attempt ${attempt} timed out, retrying...`);
|
|
11825
12098
|
}
|
|
@@ -12550,6 +12823,11 @@ class Database extends events.EventEmitter {
|
|
|
12550
12823
|
await this._ensureLazyIndexLoaded();
|
|
12551
12824
|
}
|
|
12552
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
|
+
|
|
12553
12831
|
// Access the index for this field
|
|
12554
12832
|
const fieldIndex = this.indexManager.index.data[fieldName];
|
|
12555
12833
|
if (!fieldIndex || Object.keys(fieldIndex).length === 0) {
|
|
@@ -12781,6 +13059,9 @@ class Database extends events.EventEmitter {
|
|
|
12781
13059
|
}
|
|
12782
13060
|
}
|
|
12783
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.
|
|
12784
13065
|
} catch (err) {
|
|
12785
13066
|
_didIteratorError3 = true;
|
|
12786
13067
|
_iteratorError3 = err;
|
|
@@ -12795,9 +13076,16 @@ class Database extends events.EventEmitter {
|
|
|
12795
13076
|
}
|
|
12796
13077
|
}
|
|
12797
13078
|
}
|
|
13079
|
+
if (controller.signal.aborted) {
|
|
13080
|
+
throw createIoTimeoutError(timeoutMs);
|
|
13081
|
+
}
|
|
12798
13082
|
results.push(...rangeResults);
|
|
12799
13083
|
} catch (error) {
|
|
12800
|
-
|
|
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)) {
|
|
12801
13089
|
if (this.opts.debugMode) {
|
|
12802
13090
|
console.log(`⚠️ Score range read attempt ${attempt} timed out, retrying...`);
|
|
12803
13091
|
}
|
|
@@ -13913,10 +14201,27 @@ class Database extends events.EventEmitter {
|
|
|
13913
14201
|
this.indexManager.indexLoaded = true;
|
|
13914
14202
|
return;
|
|
13915
14203
|
}
|
|
13916
|
-
|
|
13917
|
-
|
|
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
|
+
});
|
|
13918
14212
|
}
|
|
13919
|
-
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 {
|
|
13920
14225
|
const indexData = await this._loadIndexDataFromFile();
|
|
13921
14226
|
if (indexData) {
|
|
13922
14227
|
this.indexManager.load(indexData);
|
|
@@ -13927,9 +14232,153 @@ class Database extends events.EventEmitter {
|
|
|
13927
14232
|
this.indexManager.indexLoaded = true;
|
|
13928
14233
|
this.indexManager.markIndexUsed();
|
|
13929
14234
|
}
|
|
13930
|
-
|
|
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
|
+
}
|
|
13931
14380
|
})();
|
|
13932
|
-
return this.
|
|
14381
|
+
return this._offsetsReloadPromise;
|
|
13933
14382
|
}
|
|
13934
14383
|
|
|
13935
14384
|
/**
|
|
@@ -14267,6 +14716,10 @@ class Database extends events.EventEmitter {
|
|
|
14267
14716
|
yield _awaitAsyncGenerator(new Promise(resolve => setTimeout(resolve, 10)));
|
|
14268
14717
|
}
|
|
14269
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());
|
|
14270
14723
|
let count = 0;
|
|
14271
14724
|
let remainingSkip = options.skip || 0;
|
|
14272
14725
|
let map;
|
|
@@ -15003,6 +15456,8 @@ class Database extends events.EventEmitter {
|
|
|
15003
15456
|
* @private
|
|
15004
15457
|
*/
|
|
15005
15458
|
async _saveIndexDataToFile() {
|
|
15459
|
+
// Read-only mode: never write to the index file on disk
|
|
15460
|
+
if (this.opts.readOnly) return;
|
|
15006
15461
|
if (this.indexManager) {
|
|
15007
15462
|
try {
|
|
15008
15463
|
const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb');
|
|
@@ -15069,7 +15524,7 @@ class Database extends events.EventEmitter {
|
|
|
15069
15524
|
// This ensures the database structure is complete
|
|
15070
15525
|
const originalFile = this.fileHandler.file;
|
|
15071
15526
|
this.fileHandler.file = idxPath;
|
|
15072
|
-
await this.fileHandler.
|
|
15527
|
+
await this.fileHandler.writeFileAtomic(JSON.stringify(indexData, null, 2));
|
|
15073
15528
|
this.fileHandler.file = originalFile;
|
|
15074
15529
|
if (this.opts.debugMode) {
|
|
15075
15530
|
console.log(`💾 Index data saved to ${idxPath}`);
|