jexidb 2.2.3 → 2.2.6
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 +745 -157
- package/package.json +1 -1
- package/src/Database.mjs +274 -28
- package/src/FileHandler.mjs +88 -1
- package/src/managers/IndexManager.mjs +55 -6
- package/src/managers/QueryManager.mjs +320 -132
- package/src/utils/ioTimeout.mjs +36 -0
package/dist/Database.cjs
CHANGED
|
@@ -1406,6 +1406,28 @@ class IndexManager {
|
|
|
1406
1406
|
return result;
|
|
1407
1407
|
}
|
|
1408
1408
|
|
|
1409
|
+
// Handle $or queries - union of the branch results
|
|
1410
|
+
if (criteria.$or && Array.isArray(criteria.$or)) {
|
|
1411
|
+
const orResult = new Set();
|
|
1412
|
+
for (const orCondition of criteria.$or) {
|
|
1413
|
+
const branchLines = this.query(orCondition, options);
|
|
1414
|
+
for (const lineNumber of branchLines) {
|
|
1415
|
+
orResult.add(lineNumber);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Other fields at the root level still narrow the union (AND semantics)
|
|
1420
|
+
const otherCriteria = {
|
|
1421
|
+
...criteria
|
|
1422
|
+
};
|
|
1423
|
+
delete otherCriteria.$or;
|
|
1424
|
+
if (Object.keys(otherCriteria).length > 0) {
|
|
1425
|
+
const otherResults = this.query(otherCriteria, options);
|
|
1426
|
+
return new Set([...orResult].filter(x => otherResults.has(x)));
|
|
1427
|
+
}
|
|
1428
|
+
return orResult;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1409
1431
|
// Handle $and queries with parallel processing optimization
|
|
1410
1432
|
if (criteria.$and && Array.isArray(criteria.$and)) {
|
|
1411
1433
|
// OPTIMIZATION: Process conditions in parallel for better performance
|
|
@@ -1458,20 +1480,43 @@ class IndexManager {
|
|
|
1458
1480
|
if (field.startsWith('$')) continue;
|
|
1459
1481
|
if (typeof data[field] === 'undefined') continue;
|
|
1460
1482
|
const originalCriteriaValue = criteria[field];
|
|
1461
|
-
|
|
1483
|
+
// INTENTIONAL (CHANGELOG [2.2.5]): RegExp is not an operator map - never normalize it
|
|
1484
|
+
// (spreading a RegExp yields {} and would make the regex match every record).
|
|
1485
|
+
const criteriaValue = originalCriteriaValue instanceof RegExp ? originalCriteriaValue : normalizeCriteriaOperators(originalCriteriaValue, {
|
|
1462
1486
|
target: 'legacy',
|
|
1463
1487
|
preserveOriginal: true
|
|
1464
1488
|
});
|
|
1465
1489
|
let lineNumbersForField = new Set();
|
|
1466
1490
|
const isNumericField = this.opts.indexes[field] === 'number';
|
|
1467
1491
|
|
|
1468
|
-
//
|
|
1492
|
+
// INTENTIONAL OPTIMIZATION (see CHANGELOG [2.2.5]): raw RegExp on string/term
|
|
1493
|
+
// indexes is answered via the index keys instead of full-file streaming. For
|
|
1494
|
+
// term-mapped fields the keys are numeric term IDs, translated back to words with
|
|
1495
|
+
// termManager.getTerm() before testing the regex. DO NOT change this back to
|
|
1496
|
+
// "RegExp => streaming" - it regresses partial search to a full scan per database.
|
|
1469
1497
|
if (criteriaValue instanceof RegExp) {
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1498
|
+
const fieldIndex = data[field];
|
|
1499
|
+
const indexType = this.opts.indexes && this.opts.indexes[field];
|
|
1500
|
+
const isNumericIndexType = indexType === 'number' || indexType === 'array:number';
|
|
1501
|
+
if (!fieldIndex || isNumericIndexType) {
|
|
1502
|
+
// Not index-answerable (numeric index) - handled by streaming fallback
|
|
1503
|
+
continue;
|
|
1504
|
+
}
|
|
1505
|
+
const termManager = this.database && this.database.termManager;
|
|
1506
|
+
const isTermField = !!(termManager && termManager.termMappingFields && termManager.termMappingFields.includes(field));
|
|
1507
|
+
const regex = criteriaValue;
|
|
1508
|
+
for (const key of Object.keys(fieldIndex)) {
|
|
1509
|
+
const word = isTermField ? termManager.getTerm(Number(key)) : key;
|
|
1510
|
+
if (word === null || word === undefined) continue;
|
|
1511
|
+
regex.lastIndex = 0;
|
|
1512
|
+
if (!regex.test(String(word))) continue;
|
|
1513
|
+
const numbers = this._getAllLineNumbers(fieldIndex[key]);
|
|
1514
|
+
for (const lineNumber of numbers) lineNumbersForField.add(lineNumber);
|
|
1515
|
+
}
|
|
1516
|
+
// Fall through to field consolidation below (RegExp is an object, so it is
|
|
1517
|
+
// excluded from the object/operator branch that follows).
|
|
1518
|
+
}
|
|
1519
|
+
if (typeof criteriaValue === 'object' && !(criteriaValue instanceof RegExp) && !Array.isArray(criteriaValue) && criteriaValue !== null) {
|
|
1475
1520
|
const fieldIndex = data[field];
|
|
1476
1521
|
|
|
1477
1522
|
// Handle $in operator for array queries
|
|
@@ -4289,6 +4334,39 @@ class OperationQueue {
|
|
|
4289
4334
|
}
|
|
4290
4335
|
}
|
|
4291
4336
|
|
|
4337
|
+
/**
|
|
4338
|
+
* Shared helpers to normalize and detect I/O timeout / abort errors.
|
|
4339
|
+
*
|
|
4340
|
+
* Node surfaces read timeouts in several inconsistent shapes depending on the
|
|
4341
|
+
* path used (`error.name`, `error.code` or `error.message`). These helpers
|
|
4342
|
+
* centralize creation and detection so every retry branch across Database.mjs
|
|
4343
|
+
* and FileHandler.mjs agrees, and timeouts are always delivered as normal
|
|
4344
|
+
* promise rejections - never as an `uncaughtException`.
|
|
4345
|
+
*/
|
|
4346
|
+
|
|
4347
|
+
/**
|
|
4348
|
+
* Create a normalized I/O timeout error that every retry branch recognizes.
|
|
4349
|
+
* @param {number} timeoutMs - Timeout that elapsed, in milliseconds.
|
|
4350
|
+
* @returns {Error}
|
|
4351
|
+
*/
|
|
4352
|
+
function createIoTimeoutError(timeoutMs) {
|
|
4353
|
+
const err = new Error(`I/O timeout after ${timeoutMs}ms`);
|
|
4354
|
+
err.name = 'AbortError';
|
|
4355
|
+
err.code = 'ETIMEDOUT';
|
|
4356
|
+
return err;
|
|
4357
|
+
}
|
|
4358
|
+
|
|
4359
|
+
/**
|
|
4360
|
+
* Detect whether an error represents an I/O timeout or abort, regardless of the
|
|
4361
|
+
* shape it was produced in.
|
|
4362
|
+
* @param {*} error - Error to inspect (may be null/undefined).
|
|
4363
|
+
* @returns {boolean}
|
|
4364
|
+
*/
|
|
4365
|
+
function isIoTimeoutError(error) {
|
|
4366
|
+
if (!error) return false;
|
|
4367
|
+
return error.name === 'AbortError' || error.code === 'ETIMEDOUT' || error.message === 'AbortError';
|
|
4368
|
+
}
|
|
4369
|
+
|
|
4292
4370
|
/*
|
|
4293
4371
|
How it works:
|
|
4294
4372
|
`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 +5251,23 @@ class FileHandler {
|
|
|
5173
5251
|
await fs.promises.rename(tmpFile, targetFile);
|
|
5174
5252
|
return; // Success
|
|
5175
5253
|
} catch (error) {
|
|
5254
|
+
// ENOENT: the temp file vanished before the rename. With unique temp names
|
|
5255
|
+
// (see _atomicReplaceFile) this means another concurrent writer already
|
|
5256
|
+
// replaced the target (its rename won, ours was superseded) or an external
|
|
5257
|
+
// cleanup removed our temp. If the target exists, the write was superseded,
|
|
5258
|
+
// not an error. (CHANGELOG [2.2.5])
|
|
5259
|
+
if (error.code === 'ENOENT') {
|
|
5260
|
+
try {
|
|
5261
|
+
await fs.promises.access(targetFile);
|
|
5262
|
+
return; // target already replaced by a concurrent writer - ok
|
|
5263
|
+
} catch (accessError) {
|
|
5264
|
+
if (attempt < maxRetries) {
|
|
5265
|
+
await new Promise(resolve => setTimeout(resolve, 50 * attempt));
|
|
5266
|
+
continue;
|
|
5267
|
+
}
|
|
5268
|
+
throw error;
|
|
5269
|
+
}
|
|
5270
|
+
}
|
|
5176
5271
|
if (error.code === 'EPERM' && attempt < maxRetries) {
|
|
5177
5272
|
// Quick delay: 50ms, 100ms, 200ms
|
|
5178
5273
|
const delay = 50 * attempt;
|
|
@@ -5244,6 +5339,69 @@ class FileHandler {
|
|
|
5244
5339
|
throw fallbackError;
|
|
5245
5340
|
}
|
|
5246
5341
|
}
|
|
5342
|
+
|
|
5343
|
+
/**
|
|
5344
|
+
* Atomically replace this file's content: write to a sibling temp file, then
|
|
5345
|
+
* rename over the target (with a Windows copy fallback on EPERM). Concurrent
|
|
5346
|
+
* readers never observe a half-written file.
|
|
5347
|
+
* @param {Buffer|string} data - Full content of the new file.
|
|
5348
|
+
*/
|
|
5349
|
+
async _atomicReplaceFile(data) {
|
|
5350
|
+
// Use a UNIQUE temp name per write. A fixed "<file>.tmp" collides when two
|
|
5351
|
+
// Database instances write the same file concurrently: each removes the
|
|
5352
|
+
// other's temp before its rename, causing ENOENT. (CHANGELOG [2.2.5])
|
|
5353
|
+
const tmpFile = `${this.file}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
|
5354
|
+
const release = this.fileMutex ? await this.fileMutex.acquire() : () => {};
|
|
5355
|
+
try {
|
|
5356
|
+
await fs.promises.writeFile(tmpFile, data);
|
|
5357
|
+
|
|
5358
|
+
// Windows: brief delay helps readers release the target handle before the
|
|
5359
|
+
// rename (EPERM mitigation, mirroring _writeFileWithRetry).
|
|
5360
|
+
if (process.platform === 'win32') {
|
|
5361
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
5362
|
+
}
|
|
5363
|
+
await this._safeRename(tmpFile, this.file);
|
|
5364
|
+
} catch (error) {
|
|
5365
|
+
// Best-effort cleanup of our own temp on failure.
|
|
5366
|
+
await fs.promises.unlink(tmpFile).catch(() => {});
|
|
5367
|
+
throw error;
|
|
5368
|
+
} finally {
|
|
5369
|
+
release();
|
|
5370
|
+
}
|
|
5371
|
+
}
|
|
5372
|
+
|
|
5373
|
+
/**
|
|
5374
|
+
* Atomically replace the whole data file with the given content.
|
|
5375
|
+
*
|
|
5376
|
+
* When `opts.updatingSentinel` is enabled, a `<file>.updating.jdb` sentinel is
|
|
5377
|
+
* created for the duration of the swap so cross-process readers can detect an
|
|
5378
|
+
* active writer and avoid touching the file mid-update.
|
|
5379
|
+
*
|
|
5380
|
+
* @param {Buffer|string} data - Full content of the new data file.
|
|
5381
|
+
*/
|
|
5382
|
+
async replaceDataFile(data) {
|
|
5383
|
+
const sentinelFile = this.file.replace(/\.jdb$/i, '.updating.jdb');
|
|
5384
|
+
const useSentinel = !!(this.opts && this.opts.updatingSentinel);
|
|
5385
|
+
let releaseSentinel = null;
|
|
5386
|
+
if (useSentinel) {
|
|
5387
|
+
// Signal "writer active" before touching the target
|
|
5388
|
+
await fs.promises.writeFile(sentinelFile, String(Date.now()), 'utf8').catch(() => {});
|
|
5389
|
+
releaseSentinel = () => fs.promises.unlink(sentinelFile).catch(() => {});
|
|
5390
|
+
}
|
|
5391
|
+
try {
|
|
5392
|
+
await this._atomicReplaceFile(data);
|
|
5393
|
+
} finally {
|
|
5394
|
+
if (releaseSentinel) await releaseSentinel();
|
|
5395
|
+
}
|
|
5396
|
+
}
|
|
5397
|
+
|
|
5398
|
+
/**
|
|
5399
|
+
* Atomically replace this file's content (used for the .idx.jdb index file).
|
|
5400
|
+
* @param {Buffer|string} data - Full content of the new file.
|
|
5401
|
+
*/
|
|
5402
|
+
async writeFileAtomic(data) {
|
|
5403
|
+
await this._atomicReplaceFile(data);
|
|
5404
|
+
}
|
|
5247
5405
|
async writeData(data, immediate, fd) {
|
|
5248
5406
|
await fd.write(data);
|
|
5249
5407
|
}
|
|
@@ -5357,7 +5515,11 @@ class FileHandler {
|
|
|
5357
5515
|
const results = await this._readWithStreamingInternal(criteria, options, matchesCriteria, serializer, controller.signal);
|
|
5358
5516
|
return results;
|
|
5359
5517
|
} catch (error) {
|
|
5360
|
-
|
|
5518
|
+
// p-retry "stop" signal (may be absent when p-retry is mocked in tests)
|
|
5519
|
+
if (typeof pRetry.AbortError === 'function' && error instanceof pRetry.AbortError) {
|
|
5520
|
+
throw error;
|
|
5521
|
+
}
|
|
5522
|
+
if (isIoTimeoutError(error)) {
|
|
5361
5523
|
if (this.opts.debugMode) {
|
|
5362
5524
|
console.log(`⚠️ Streaming read attempt ${attempt} timed out, retrying...`);
|
|
5363
5525
|
}
|
|
@@ -6209,10 +6371,18 @@ class QueryManager {
|
|
|
6209
6371
|
}
|
|
6210
6372
|
|
|
6211
6373
|
// Use indices to pre-filter and reduce streaming scope
|
|
6212
|
-
const
|
|
6374
|
+
const preFilterCriteria = this._extractIndexableCriteria(criteria);
|
|
6375
|
+
const preFilteredLines = this.indexManager.query(preFilterCriteria, streamingOptions);
|
|
6376
|
+
|
|
6377
|
+
// Fields answered by the pre-filter already match, so the streaming matcher
|
|
6378
|
+
// only evaluates the residual conditions (instead of re-checking everything).
|
|
6379
|
+
const preFilteredFields = this._collectCriteriaFields(preFilterCriteria);
|
|
6213
6380
|
|
|
6214
6381
|
// Stream only the pre-filtered records
|
|
6215
|
-
return this._streamPreFilteredRecords(preFilteredLines, criteria,
|
|
6382
|
+
return this._streamPreFilteredRecords(preFilteredLines, criteria, {
|
|
6383
|
+
...streamingOptions,
|
|
6384
|
+
_prefilteredFields: preFilteredFields
|
|
6385
|
+
});
|
|
6216
6386
|
}
|
|
6217
6387
|
}
|
|
6218
6388
|
|
|
@@ -6223,6 +6393,29 @@ class QueryManager {
|
|
|
6223
6393
|
return this._streamAllRecords(criteria, streamingOptions);
|
|
6224
6394
|
}
|
|
6225
6395
|
|
|
6396
|
+
/**
|
|
6397
|
+
* Whether a single field condition can/should be answered by the index during
|
|
6398
|
+
* pre-filtering (so the LEAST number of entries is streamed).
|
|
6399
|
+
*
|
|
6400
|
+
* - Raw RegExp is pre-filterable only on string/term indexes (IndexManager.query
|
|
6401
|
+
* resolves it via the index keys). Regex on numeric indexes -> residual.
|
|
6402
|
+
* - Complement operators ($ne/$nin) expand to nearly every record, so they are
|
|
6403
|
+
* NOT pre-filtered: they are applied LAST, on the minimal candidate set, by the
|
|
6404
|
+
* streaming matcher. (CHANGELOG [2.2.5])
|
|
6405
|
+
*/
|
|
6406
|
+
_canPrefilterFieldCondition(field, condition) {
|
|
6407
|
+
if (!this.indexManager?.opts?.indexes?.[field]) return false;
|
|
6408
|
+
if (condition instanceof RegExp) {
|
|
6409
|
+
const idxType = this.indexManager.opts.indexes[field];
|
|
6410
|
+
return idxType !== 'number' && idxType !== 'array:number';
|
|
6411
|
+
}
|
|
6412
|
+
if (condition && typeof condition === 'object' && !Array.isArray(condition)) {
|
|
6413
|
+
const ops = Object.keys(condition).map(op => normalizeOperator(op));
|
|
6414
|
+
if (ops.some(op => op === '$ne' || op === '$nin')) return false;
|
|
6415
|
+
}
|
|
6416
|
+
return true;
|
|
6417
|
+
}
|
|
6418
|
+
|
|
6226
6419
|
/**
|
|
6227
6420
|
* Get indexable fields from criteria
|
|
6228
6421
|
* @param {Object} criteria - Query criteria
|
|
@@ -6241,12 +6434,24 @@ class QueryManager {
|
|
|
6241
6434
|
}
|
|
6242
6435
|
}
|
|
6243
6436
|
|
|
6437
|
+
// Handle $or conditions - only when EVERY branch is pre-filterable, otherwise
|
|
6438
|
+
// the union would narrow the candidate set and lose rows.
|
|
6439
|
+
if (criteria.$or && Array.isArray(criteria.$or) && criteria.$or.length > 0) {
|
|
6440
|
+
const orCriteria = criteria.$or.map(condition => this._extractIndexableCriteria(condition));
|
|
6441
|
+
if (orCriteria.every(condition => Object.keys(condition).length > 0)) {
|
|
6442
|
+
for (const condition of orCriteria) {
|
|
6443
|
+
indexableFields.push(...this._getIndexableFields(condition));
|
|
6444
|
+
}
|
|
6445
|
+
}
|
|
6446
|
+
}
|
|
6447
|
+
|
|
6244
6448
|
// Handle regular field conditions
|
|
6245
6449
|
for (const [field, condition] of Object.entries(criteria)) {
|
|
6246
6450
|
if (field.startsWith('$')) continue; // Skip logical operators
|
|
6247
6451
|
|
|
6248
|
-
//
|
|
6249
|
-
|
|
6452
|
+
// Pre-filter with the index only what it can answer minimally; everything
|
|
6453
|
+
// else is applied LAST over the streamed candidates (filter last, stream least).
|
|
6454
|
+
if (!this._canPrefilterFieldCondition(field, condition)) {
|
|
6250
6455
|
continue;
|
|
6251
6456
|
}
|
|
6252
6457
|
if (this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]) {
|
|
@@ -6275,6 +6480,15 @@ class QueryManager {
|
|
|
6275
6480
|
}
|
|
6276
6481
|
}
|
|
6277
6482
|
|
|
6483
|
+
// Handle $or conditions - a branch that cannot be pre-filtered would make the
|
|
6484
|
+
// union too narrow, so $or is only pre-filterable when every branch is.
|
|
6485
|
+
if (criteria.$or && Array.isArray(criteria.$or) && criteria.$or.length > 0) {
|
|
6486
|
+
const indexableOrConditions = criteria.$or.map(orCondition => this._extractIndexableCriteria(orCondition));
|
|
6487
|
+
if (indexableOrConditions.every(condition => Object.keys(condition).length > 0)) {
|
|
6488
|
+
indexableCriteria.$or = indexableOrConditions;
|
|
6489
|
+
}
|
|
6490
|
+
}
|
|
6491
|
+
|
|
6278
6492
|
// Handle $not operator - include it if it can be processed by IndexManager
|
|
6279
6493
|
if (criteria.$not && typeof criteria.$not === 'object') {
|
|
6280
6494
|
// Check if $not condition contains only indexable fields
|
|
@@ -6293,8 +6507,9 @@ class QueryManager {
|
|
|
6293
6507
|
for (const [field, condition] of Object.entries(criteria)) {
|
|
6294
6508
|
if (field.startsWith('$')) continue; // Skip logical operators (already handled above)
|
|
6295
6509
|
|
|
6296
|
-
//
|
|
6297
|
-
|
|
6510
|
+
// Pre-filter with the index only what it can answer minimally; complement/
|
|
6511
|
+
// numeric-regex conditions are left residual and applied LAST on the candidates.
|
|
6512
|
+
if (!this._canPrefilterFieldCondition(field, condition)) {
|
|
6298
6513
|
continue;
|
|
6299
6514
|
}
|
|
6300
6515
|
if (this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]) {
|
|
@@ -6304,6 +6519,37 @@ class QueryManager {
|
|
|
6304
6519
|
return indexableCriteria;
|
|
6305
6520
|
}
|
|
6306
6521
|
|
|
6522
|
+
/**
|
|
6523
|
+
* Collect every field referenced by criteria, including inside $and/$or/$not
|
|
6524
|
+
* @param {Object} criteria - Criteria to inspect
|
|
6525
|
+
* @param {Set<string>} accumulator - Fields collected so far
|
|
6526
|
+
* @returns {Set<string>} - Field names
|
|
6527
|
+
*/
|
|
6528
|
+
_collectCriteriaFields(criteria, accumulator = new Set()) {
|
|
6529
|
+
if (!criteria || typeof criteria !== 'object') {
|
|
6530
|
+
return accumulator;
|
|
6531
|
+
}
|
|
6532
|
+
if (Array.isArray(criteria)) {
|
|
6533
|
+
for (const condition of criteria) {
|
|
6534
|
+
this._collectCriteriaFields(condition, accumulator);
|
|
6535
|
+
}
|
|
6536
|
+
return accumulator;
|
|
6537
|
+
}
|
|
6538
|
+
for (const [field, value] of Object.entries(criteria)) {
|
|
6539
|
+
if (field === '$and' || field === '$or') {
|
|
6540
|
+
this._collectCriteriaFields(value, accumulator);
|
|
6541
|
+
continue;
|
|
6542
|
+
}
|
|
6543
|
+
if (field === '$not') {
|
|
6544
|
+
// $not is negated, so its fields are not "already matched" by the pre-filter
|
|
6545
|
+
continue;
|
|
6546
|
+
}
|
|
6547
|
+
if (field.startsWith('$')) continue;
|
|
6548
|
+
accumulator.add(field);
|
|
6549
|
+
}
|
|
6550
|
+
return accumulator;
|
|
6551
|
+
}
|
|
6552
|
+
|
|
6307
6553
|
/**
|
|
6308
6554
|
* Determine whether the database currently has any records (persisted or pending)
|
|
6309
6555
|
* @returns {boolean}
|
|
@@ -6509,8 +6755,8 @@ class QueryManager {
|
|
|
6509
6755
|
return !notResult;
|
|
6510
6756
|
}
|
|
6511
6757
|
|
|
6512
|
-
// OPTIMIZATION 4: Skip
|
|
6513
|
-
const
|
|
6758
|
+
// OPTIMIZATION 4: Skip only the fields that the pre-filter actually answered
|
|
6759
|
+
const prefilteredFields = options._prefilteredFields instanceof Set ? options._prefilteredFields : new Set(this._getIndexableFields(criteria));
|
|
6514
6760
|
|
|
6515
6761
|
// Handle explicit logical operators at the top level
|
|
6516
6762
|
if (criteria.$or && Array.isArray(criteria.$or)) {
|
|
@@ -6536,8 +6782,8 @@ class QueryManager {
|
|
|
6536
6782
|
for (const [field, condition] of Object.entries(criteria)) {
|
|
6537
6783
|
if (field.startsWith('$')) continue;
|
|
6538
6784
|
|
|
6539
|
-
// OPTIMIZATION 4: Skip
|
|
6540
|
-
if (
|
|
6785
|
+
// OPTIMIZATION 4: Skip fields that were already pre-filtered
|
|
6786
|
+
if (prefilteredFields.has(field)) {
|
|
6541
6787
|
continue;
|
|
6542
6788
|
}
|
|
6543
6789
|
if (!this.matchesFieldCondition(record, field, condition, options)) {
|
|
@@ -6620,6 +6866,21 @@ class QueryManager {
|
|
|
6620
6866
|
forceFullScan: true
|
|
6621
6867
|
});
|
|
6622
6868
|
}
|
|
6869
|
+
|
|
6870
|
+
// Swap detection: if another process atomically replaced the data file, the
|
|
6871
|
+
// in-memory offsets/index describe the previous file. Reload cleanly, or -
|
|
6872
|
+
// when a writer sentinel is present / the clean reload fails - fall back to
|
|
6873
|
+
// a full streaming scan so stale byte offsets are never used on the new file.
|
|
6874
|
+
const refreshStatus = await this.database._refreshFromDiskIfChanged();
|
|
6875
|
+
if (refreshStatus === 'busy') {
|
|
6876
|
+
if (this.opts.debugMode) {
|
|
6877
|
+
console.log('⚠️ findWithIndexed: file busy (writer active) or clean reload failed, falling back to full streaming');
|
|
6878
|
+
}
|
|
6879
|
+
return this.findWithStreaming(criteria, {
|
|
6880
|
+
...options,
|
|
6881
|
+
forceFullScan: true
|
|
6882
|
+
});
|
|
6883
|
+
}
|
|
6623
6884
|
let results = [];
|
|
6624
6885
|
const limit = options.limit; // No default limit - return all results unless explicitly limited
|
|
6625
6886
|
|
|
@@ -6849,21 +7110,16 @@ class QueryManager {
|
|
|
6849
7110
|
* @returns {boolean} - True if matches
|
|
6850
7111
|
*/
|
|
6851
7112
|
matchesFieldCondition(record, field, condition, options = {}) {
|
|
6852
|
-
|
|
7113
|
+
let value = record[field];
|
|
6853
7114
|
|
|
6854
|
-
// Debug logging for all field conditions
|
|
7115
|
+
// Debug logging for all field conditions - SAMPLED: only the first record of
|
|
7116
|
+
// each query logs per field, so a full streaming scan (which calls this for
|
|
7117
|
+
// every record) does not spam the console with one line per record.
|
|
6855
7118
|
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}':`, {
|
|
7119
|
+
if (!options._dbFieldLog) options._dbFieldLog = {};
|
|
7120
|
+
if (!options._dbFieldLog[field]) {
|
|
7121
|
+
options._dbFieldLog[field] = true;
|
|
7122
|
+
console.log(`🔍 Checking field '${field}':`, {
|
|
6867
7123
|
value,
|
|
6868
7124
|
condition,
|
|
6869
7125
|
record: record.name || record.id
|
|
@@ -6871,6 +7127,28 @@ class QueryManager {
|
|
|
6871
7127
|
}
|
|
6872
7128
|
}
|
|
6873
7129
|
|
|
7130
|
+
// Debug logging for term mapping fields (sampled the same way)
|
|
7131
|
+
if (this.database.opts.termMapping && Object.keys(this.database.opts.indexes || {}).includes(field)) {
|
|
7132
|
+
if (this.database.opts.debugMode) {
|
|
7133
|
+
if (!options._dbFieldLog) options._dbFieldLog = {};
|
|
7134
|
+
if (!options._dbFieldLog[`tm:${field}`]) {
|
|
7135
|
+
options._dbFieldLog[`tm:${field}`] = true;
|
|
7136
|
+
console.log(`🔍 Checking term mapping field '${field}':`, {
|
|
7137
|
+
value,
|
|
7138
|
+
condition,
|
|
7139
|
+
record: record.name || record.id
|
|
7140
|
+
});
|
|
7141
|
+
}
|
|
7142
|
+
}
|
|
7143
|
+
}
|
|
7144
|
+
|
|
7145
|
+
// Term-mapped array fields store numeric term IDs, so both sides are translated
|
|
7146
|
+
// to terms here; otherwise string queries would never match on the streaming path.
|
|
7147
|
+
if (Array.isArray(value) && this._isTermMappingField(field)) {
|
|
7148
|
+
value = this._termIdsToTerms(value);
|
|
7149
|
+
condition = this._normalizeTermCondition(condition);
|
|
7150
|
+
}
|
|
7151
|
+
|
|
6874
7152
|
// Handle null/undefined values
|
|
6875
7153
|
if (value === null || value === undefined) {
|
|
6876
7154
|
return condition === null || condition === undefined;
|
|
@@ -6993,17 +7271,87 @@ class QueryManager {
|
|
|
6993
7271
|
}
|
|
6994
7272
|
}
|
|
6995
7273
|
|
|
7274
|
+
/**
|
|
7275
|
+
* Whether a field stores its values as numeric term IDs (term mapping)
|
|
7276
|
+
* @param {string} field
|
|
7277
|
+
* @returns {boolean}
|
|
7278
|
+
*/
|
|
7279
|
+
_isTermMappingField(field) {
|
|
7280
|
+
const termManager = this.database && this.database.termManager;
|
|
7281
|
+
return !!(termManager && Array.isArray(termManager.termMappingFields) && termManager.termMappingFields.includes(field));
|
|
7282
|
+
}
|
|
7283
|
+
|
|
7284
|
+
/**
|
|
7285
|
+
* Translate stored term IDs back to terms for matching
|
|
7286
|
+
* @param {Array} ids
|
|
7287
|
+
* @returns {Array}
|
|
7288
|
+
*/
|
|
7289
|
+
_termIdsToTerms(ids) {
|
|
7290
|
+
const termManager = this.database.termManager;
|
|
7291
|
+
return ids.map(id => {
|
|
7292
|
+
if (typeof id !== 'number') return id;
|
|
7293
|
+
const term = termManager.getTerm(id);
|
|
7294
|
+
return term === null ? id : term;
|
|
7295
|
+
});
|
|
7296
|
+
}
|
|
7297
|
+
|
|
7298
|
+
/**
|
|
7299
|
+
* Translate numeric term IDs used in a query condition to their terms
|
|
7300
|
+
* @param {*} condition
|
|
7301
|
+
* @returns {*}
|
|
7302
|
+
*/
|
|
7303
|
+
_normalizeTermCondition(condition) {
|
|
7304
|
+
const toTerm = value => {
|
|
7305
|
+
if (typeof value !== 'number') return value;
|
|
7306
|
+
const term = this.database.termManager.getTerm(value);
|
|
7307
|
+
return term === null ? value : term;
|
|
7308
|
+
};
|
|
7309
|
+
if (typeof condition === 'number') {
|
|
7310
|
+
return toTerm(condition);
|
|
7311
|
+
}
|
|
7312
|
+
if (condition instanceof RegExp || condition === null || typeof condition !== 'object') {
|
|
7313
|
+
return condition;
|
|
7314
|
+
}
|
|
7315
|
+
if (Array.isArray(condition)) {
|
|
7316
|
+
return condition.map(toTerm);
|
|
7317
|
+
}
|
|
7318
|
+
const normalized = {};
|
|
7319
|
+
for (const [operator, operatorValue] of Object.entries(condition)) {
|
|
7320
|
+
const canonical = normalizeOperator(operator);
|
|
7321
|
+
if (canonical === '$in' || canonical === '$all' || canonical === '$nin') {
|
|
7322
|
+
const values = Array.isArray(operatorValue) ? operatorValue : [operatorValue];
|
|
7323
|
+
normalized[operator] = values.map(toTerm);
|
|
7324
|
+
} else if (canonical === '$eq') {
|
|
7325
|
+
normalized[operator] = toTerm(operatorValue);
|
|
7326
|
+
} else {
|
|
7327
|
+
normalized[operator] = operatorValue;
|
|
7328
|
+
}
|
|
7329
|
+
}
|
|
7330
|
+
return normalized;
|
|
7331
|
+
}
|
|
7332
|
+
|
|
6996
7333
|
/**
|
|
6997
7334
|
* Preprocess query to handle array field syntax automatically
|
|
6998
7335
|
* @param {Object} criteria - Query criteria
|
|
6999
7336
|
* @returns {Object} - Processed criteria
|
|
7000
7337
|
*/
|
|
7001
|
-
preprocessQuery(criteria) {
|
|
7002
|
-
if (!criteria || typeof criteria !== 'object') {
|
|
7338
|
+
preprocessQuery(criteria, nested = false) {
|
|
7339
|
+
if (!criteria || typeof criteria !== 'object' || Array.isArray(criteria)) {
|
|
7003
7340
|
return criteria;
|
|
7004
7341
|
}
|
|
7005
7342
|
const processed = {};
|
|
7006
7343
|
for (const [field, value] of Object.entries(criteria)) {
|
|
7344
|
+
// $and / $or / $not hold nested criteria and must be processed recursively,
|
|
7345
|
+
// otherwise conditions inside logical operators never reach the $in form.
|
|
7346
|
+
if (field === '$and' || field === '$or') {
|
|
7347
|
+
processed[field] = Array.isArray(value) ? value.map(condition => this.preprocessQuery(condition, true)) : value;
|
|
7348
|
+
continue;
|
|
7349
|
+
}
|
|
7350
|
+
if (field === '$not') {
|
|
7351
|
+
processed[field] = value && typeof value === 'object' && !Array.isArray(value) ? this.preprocessQuery(value, true) : value;
|
|
7352
|
+
continue;
|
|
7353
|
+
}
|
|
7354
|
+
|
|
7007
7355
|
// Check if this is a term mapping field
|
|
7008
7356
|
const isTermMappingField = this.database.opts.termMapping && this.database.termManager && this.database.termManager.termMappingFields && this.database.termManager.termMappingFields.includes(field);
|
|
7009
7357
|
if (isTermMappingField) {
|
|
@@ -7031,6 +7379,10 @@ class QueryManager {
|
|
|
7031
7379
|
} else {
|
|
7032
7380
|
processed[field] = value;
|
|
7033
7381
|
}
|
|
7382
|
+
} else if (nested && (value === null || value === undefined)) {
|
|
7383
|
+
// Nested branches may test for a missing value (e.g. $not: {field: null});
|
|
7384
|
+
// only top-level null queries are rejected as invalid.
|
|
7385
|
+
processed[field] = value;
|
|
7034
7386
|
} else {
|
|
7035
7387
|
// Invalid value for term mapping field
|
|
7036
7388
|
throw new Error(`Invalid query for array field '${field}'. Use { $in: [value] } syntax or direct value.`);
|
|
@@ -7118,110 +7470,18 @@ class QueryManager {
|
|
|
7118
7470
|
}
|
|
7119
7471
|
}
|
|
7120
7472
|
|
|
7121
|
-
// Handle $and queries -
|
|
7122
|
-
|
|
7123
|
-
const
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
const notFields = Object.keys(andCondition.$not);
|
|
7127
|
-
return notFields.every(field => {
|
|
7128
|
-
if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
|
|
7129
|
-
return false;
|
|
7130
|
-
}
|
|
7131
|
-
// For term mapping, $not can be processed with indices
|
|
7132
|
-
return this.opts.termMapping && Object.keys(this.opts.indexes || {}).includes(field);
|
|
7133
|
-
});
|
|
7134
|
-
}
|
|
7135
|
-
|
|
7136
|
-
// Handle regular field conditions
|
|
7137
|
-
return Object.keys(andCondition).every(field => {
|
|
7138
|
-
if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
|
|
7139
|
-
return false;
|
|
7140
|
-
}
|
|
7141
|
-
const condition = andCondition[field];
|
|
7142
|
-
|
|
7143
|
-
// RegExp cannot be efficiently queried using indices - must use streaming
|
|
7144
|
-
if (condition instanceof RegExp) {
|
|
7145
|
-
return false;
|
|
7146
|
-
}
|
|
7147
|
-
if (typeof condition === 'object' && !Array.isArray(condition)) {
|
|
7148
|
-
const operators = Object.keys(condition).map(op => normalizeOperator(op));
|
|
7149
|
-
const indexType = this.indexManager?.opts?.indexes?.[field];
|
|
7150
|
-
const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number';
|
|
7151
|
-
const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size'];
|
|
7152
|
-
const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size'];
|
|
7153
|
-
|
|
7154
|
-
// Check if this is a term mapping field (array:string or string fields with term mapping)
|
|
7155
|
-
const isTermMappingField = this.database.termManager && this.database.termManager.termMappingFields && this.database.termManager.termMappingFields.includes(field);
|
|
7156
|
-
if (isTermMappingField) {
|
|
7157
|
-
const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size'];
|
|
7158
|
-
return operators.every(op => !termMappingDisallowed.includes(op));
|
|
7159
|
-
} else {
|
|
7160
|
-
const disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault;
|
|
7161
|
-
return operators.every(op => !disallowed.includes(op));
|
|
7162
|
-
}
|
|
7163
|
-
}
|
|
7164
|
-
return true;
|
|
7165
|
-
});
|
|
7166
|
-
});
|
|
7167
|
-
if (!allAndConditionsIndexed) {
|
|
7473
|
+
// Handle $and / $or queries - every branch must be indexable to use indices
|
|
7474
|
+
for (const logicalOperator of ['$and', '$or']) {
|
|
7475
|
+
const branches = criteria[logicalOperator];
|
|
7476
|
+
if (!Array.isArray(branches)) continue;
|
|
7477
|
+
if (!branches.every(branch => this._isCriteriaIndexable(branch))) {
|
|
7168
7478
|
if (this.opts.debugMode) {
|
|
7169
|
-
console.log(
|
|
7479
|
+
console.log(`📊 QueryStrategy: STREAMING - Some ${logicalOperator} conditions not indexed or operators not supported`);
|
|
7170
7480
|
}
|
|
7171
7481
|
return 'streaming';
|
|
7172
7482
|
}
|
|
7173
7483
|
}
|
|
7174
|
-
|
|
7175
|
-
// Skip $and and $not as they're handled separately above
|
|
7176
|
-
if (field === '$and' || field === '$not') return true;
|
|
7177
|
-
if (!this.opts.indexes || !this.opts.indexes[field]) {
|
|
7178
|
-
if (this.opts.debugMode) {
|
|
7179
|
-
console.log(`🔍 Field '${field}' not indexed. Available indexes:`, Object.keys(this.opts.indexes || {}));
|
|
7180
|
-
}
|
|
7181
|
-
return false;
|
|
7182
|
-
}
|
|
7183
|
-
|
|
7184
|
-
// Check if the field uses operators that are supported by IndexManager
|
|
7185
|
-
const condition = criteria[field];
|
|
7186
|
-
|
|
7187
|
-
// RegExp cannot be efficiently queried using indices - must use streaming
|
|
7188
|
-
if (condition instanceof RegExp) {
|
|
7189
|
-
if (this.opts.debugMode) {
|
|
7190
|
-
console.log(`🔍 Field '${field}' uses RegExp - requires streaming strategy`);
|
|
7191
|
-
}
|
|
7192
|
-
return false;
|
|
7193
|
-
}
|
|
7194
|
-
if (typeof condition === 'object' && !Array.isArray(condition) && condition !== null) {
|
|
7195
|
-
const operators = Object.keys(condition).map(op => normalizeOperator(op));
|
|
7196
|
-
if (this.opts.debugMode) {
|
|
7197
|
-
console.log(`🔍 Field '${field}' has operators:`, operators);
|
|
7198
|
-
}
|
|
7199
|
-
const indexType = this.indexManager?.opts?.indexes?.[field];
|
|
7200
|
-
const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number';
|
|
7201
|
-
const isArrayStringIndex = indexType === 'array:string';
|
|
7202
|
-
const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size'];
|
|
7203
|
-
const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size'];
|
|
7204
|
-
|
|
7205
|
-
// Check if this is a term mapping field (array:string or string fields with term mapping)
|
|
7206
|
-
const isTermMappingField = this.database.termManager && this.database.termManager.termMappingFields && this.database.termManager.termMappingFields.includes(field);
|
|
7207
|
-
|
|
7208
|
-
// With term mapping enabled on THIS FIELD, we can support complex operators via partial reads
|
|
7209
|
-
// Also support $all for array:string indexed fields (IndexManager.query supports it via Set intersection)
|
|
7210
|
-
if (isTermMappingField) {
|
|
7211
|
-
const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size'];
|
|
7212
|
-
return operators.every(op => !termMappingDisallowed.includes(op));
|
|
7213
|
-
} else {
|
|
7214
|
-
let disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault;
|
|
7215
|
-
// Remove $all from disallowed if field is array:string (IndexManager supports $all via Set intersection)
|
|
7216
|
-
if (isArrayStringIndex) {
|
|
7217
|
-
disallowed = disallowed.filter(op => op !== '$all');
|
|
7218
|
-
}
|
|
7219
|
-
return operators.every(op => !disallowed.includes(op));
|
|
7220
|
-
}
|
|
7221
|
-
}
|
|
7222
|
-
return true;
|
|
7223
|
-
});
|
|
7224
|
-
if (!allFieldsIndexed) {
|
|
7484
|
+
if (!this._isCriteriaIndexable(criteria)) {
|
|
7225
7485
|
if (this.opts.debugMode) {
|
|
7226
7486
|
console.log('📊 QueryStrategy: STREAMING - Some fields not indexed or operators not supported');
|
|
7227
7487
|
}
|
|
@@ -7260,6 +7520,76 @@ class QueryManager {
|
|
|
7260
7520
|
return 'indexed';
|
|
7261
7521
|
}
|
|
7262
7522
|
|
|
7523
|
+
/**
|
|
7524
|
+
* Whether a criteria object can be fully resolved through indexes
|
|
7525
|
+
* @param {Object} criteria - Criteria or a single logical branch
|
|
7526
|
+
* @returns {boolean} - True if every condition is index-backed
|
|
7527
|
+
*/
|
|
7528
|
+
_isCriteriaIndexable(criteria) {
|
|
7529
|
+
if (!criteria || typeof criteria !== 'object' || Array.isArray(criteria)) {
|
|
7530
|
+
return false;
|
|
7531
|
+
}
|
|
7532
|
+
const fields = Object.keys(criteria);
|
|
7533
|
+
if (fields.length === 0) {
|
|
7534
|
+
return false;
|
|
7535
|
+
}
|
|
7536
|
+
return fields.every(field => {
|
|
7537
|
+
const value = criteria[field];
|
|
7538
|
+
if (field === '$and' || field === '$or') {
|
|
7539
|
+
return Array.isArray(value) && value.length > 0 && value.every(branch => this._isCriteriaIndexable(branch));
|
|
7540
|
+
}
|
|
7541
|
+
if (field === '$not') {
|
|
7542
|
+
// $not is only index-supported when term mapping is enabled
|
|
7543
|
+
if (!this.opts.termMapping || !value || typeof value !== 'object' || Array.isArray(value)) {
|
|
7544
|
+
return false;
|
|
7545
|
+
}
|
|
7546
|
+
const notFields = Object.keys(value);
|
|
7547
|
+
return notFields.length > 0 && notFields.every(notField => !!(this.indexManager.opts.indexes && this.indexManager.opts.indexes[notField]));
|
|
7548
|
+
}
|
|
7549
|
+
if (field.startsWith('$')) {
|
|
7550
|
+
return false;
|
|
7551
|
+
}
|
|
7552
|
+
return this._isFieldConditionIndexable(field, value);
|
|
7553
|
+
});
|
|
7554
|
+
}
|
|
7555
|
+
|
|
7556
|
+
/**
|
|
7557
|
+
* Whether a single field condition is supported by that field's index
|
|
7558
|
+
* @param {string} field - Field name
|
|
7559
|
+
* @param {*} condition - Condition value
|
|
7560
|
+
* @returns {boolean} - True if the index can answer this condition
|
|
7561
|
+
*/
|
|
7562
|
+
_isFieldConditionIndexable(field, condition) {
|
|
7563
|
+
if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
|
|
7564
|
+
return false;
|
|
7565
|
+
}
|
|
7566
|
+
const indexType = this.indexManager.opts.indexes[field];
|
|
7567
|
+
|
|
7568
|
+
// INTENTIONAL (CHANGELOG [2.2.5]): raw RegExp on string/term indexes is resolved
|
|
7569
|
+
// via index keys (not streaming). Do not revert to "regex => streaming".
|
|
7570
|
+
if (condition instanceof RegExp) {
|
|
7571
|
+
return indexType !== 'number' && indexType !== 'array:number';
|
|
7572
|
+
}
|
|
7573
|
+
|
|
7574
|
+
// Index lookups cannot express "is null/undefined" reliably
|
|
7575
|
+
if (condition === null || condition === undefined) {
|
|
7576
|
+
return false;
|
|
7577
|
+
}
|
|
7578
|
+
if (typeof condition !== 'object' || Array.isArray(condition)) {
|
|
7579
|
+
return true;
|
|
7580
|
+
}
|
|
7581
|
+
const operators = Object.keys(condition).map(op => normalizeOperator(op));
|
|
7582
|
+
const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number';
|
|
7583
|
+
const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size'];
|
|
7584
|
+
const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size'];
|
|
7585
|
+
if (this._isTermMappingField(field)) {
|
|
7586
|
+
const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size'];
|
|
7587
|
+
return operators.every(op => !termMappingDisallowed.includes(op));
|
|
7588
|
+
}
|
|
7589
|
+
const disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault;
|
|
7590
|
+
return operators.every(op => !disallowed.includes(op));
|
|
7591
|
+
}
|
|
7592
|
+
|
|
7263
7593
|
/**
|
|
7264
7594
|
* Estimate number of results for a query
|
|
7265
7595
|
* @param {Object} criteria - Query criteria
|
|
@@ -8999,7 +9329,11 @@ class Database extends events.EventEmitter {
|
|
|
8999
9329
|
enableArraySerialization: opts.enableArraySerialization !== false,
|
|
9000
9330
|
// Enable array serialization by default
|
|
9001
9331
|
// Index rebuild options
|
|
9002
|
-
allowIndexRebuild: opts.allowIndexRebuild === true
|
|
9332
|
+
allowIndexRebuild: opts.allowIndexRebuild === true,
|
|
9333
|
+
// Allow automatic index rebuild when corrupted (default false - throws error)
|
|
9334
|
+
// Read-only mode: never create, write, rebuild or auto-flush. Use it to
|
|
9335
|
+
// open a DB purely for reads (e.g. while another process is the writer).
|
|
9336
|
+
readOnly: opts.readOnly === true
|
|
9003
9337
|
}, opts);
|
|
9004
9338
|
|
|
9005
9339
|
// CRITICAL FIX: Initialize AbortController for lifecycle management
|
|
@@ -9029,6 +9363,14 @@ class Database extends events.EventEmitter {
|
|
|
9029
9363
|
this._offsetRecoveryInProgress = false;
|
|
9030
9364
|
this.writeBufferTotalSize = 0;
|
|
9031
9365
|
|
|
9366
|
+
// Swap detection state: the data file may be atomically replaced by another
|
|
9367
|
+
// process while this instance holds offsets/index computed for the previous
|
|
9368
|
+
// file. _offsetsFileSignature remembers which on-disk file the offsets
|
|
9369
|
+
// describe (size + mtime) so the read path can detect a swap cheaply.
|
|
9370
|
+
this._offsetsFileSignature = null;
|
|
9371
|
+
this._dataFingerprint = null; // cached stat (short TTL) for the read path
|
|
9372
|
+
this._offsetsReloadPromise = null; // single-flight clean reload after a swap
|
|
9373
|
+
|
|
9032
9374
|
// Initialize managers
|
|
9033
9375
|
this.initializeManagers();
|
|
9034
9376
|
|
|
@@ -9404,8 +9746,8 @@ class Database extends events.EventEmitter {
|
|
|
9404
9746
|
if (this.normalizedFile) {
|
|
9405
9747
|
const fileExists = await this.fileHandler.exists();
|
|
9406
9748
|
if (!fileExists) {
|
|
9407
|
-
if (!this.opts.create) {
|
|
9408
|
-
throw new Error(`Database file '${this.normalizedFile}' does not exist and create option is disabled`);
|
|
9749
|
+
if (!this.opts.create || this.opts.readOnly) {
|
|
9750
|
+
throw new Error(`Database file '${this.normalizedFile}' does not exist and ${this.opts.readOnly ? 'readOnly mode is enabled' : 'create option is disabled'}`);
|
|
9409
9751
|
}
|
|
9410
9752
|
// File will be created when first data is written
|
|
9411
9753
|
} else {
|
|
@@ -9581,6 +9923,10 @@ class Database extends events.EventEmitter {
|
|
|
9581
9923
|
}
|
|
9582
9924
|
}
|
|
9583
9925
|
|
|
9926
|
+
// Remember which on-disk data file these offsets describe, so the
|
|
9927
|
+
// read path can detect an atomic swap performed by another process.
|
|
9928
|
+
this._offsetsFileSignature = await this._statDataFingerprint();
|
|
9929
|
+
|
|
9584
9930
|
// Load indexOffset for proper range calculations
|
|
9585
9931
|
if (parsedIdxData.indexOffset !== undefined) {
|
|
9586
9932
|
this.indexOffset = parsedIdxData.indexOffset;
|
|
@@ -9881,6 +10227,14 @@ class Database extends events.EventEmitter {
|
|
|
9881
10227
|
// CRITICAL FIX: Check if database is destroyed
|
|
9882
10228
|
if (this.destroyed) return;
|
|
9883
10229
|
|
|
10230
|
+
// Read-only mode: never persist data to disk
|
|
10231
|
+
if (this.opts.readOnly) {
|
|
10232
|
+
if (this.opts.debugMode) {
|
|
10233
|
+
console.log('💾 _doSave: Database is read-only, skipping save');
|
|
10234
|
+
}
|
|
10235
|
+
return;
|
|
10236
|
+
}
|
|
10237
|
+
|
|
9884
10238
|
// CRITICAL FIX: Use atomic check-and-set to prevent concurrent save operations
|
|
9885
10239
|
if (this.isSaving) {
|
|
9886
10240
|
if (this.opts.debugMode) {
|
|
@@ -10288,15 +10642,20 @@ class Database extends events.EventEmitter {
|
|
|
10288
10642
|
console.log(`💾 Save: Calculated indexOffset: ${this.indexOffset}, allData.length: ${allData.length}`);
|
|
10289
10643
|
}
|
|
10290
10644
|
|
|
10291
|
-
// CRITICAL FIX: Write main data file first
|
|
10645
|
+
// CRITICAL FIX: Write main data file first (atomically: temp + rename so a
|
|
10646
|
+
// concurrent reader never sees a half-written file).
|
|
10292
10647
|
// Index will be saved AFTER reconstruction to ensure it contains correct data
|
|
10293
|
-
await this.fileHandler.
|
|
10648
|
+
await this.fileHandler.replaceDataFile(jsonlData);
|
|
10294
10649
|
if (this.opts.debugMode) {
|
|
10295
10650
|
console.log(`💾 Saved ${allData.length} records to ${this.normalizedFile}`);
|
|
10296
10651
|
}
|
|
10297
10652
|
|
|
10298
10653
|
// CRITICAL FIX: Invalidate file size cache after save operation
|
|
10299
10654
|
this._cachedFileStats = null;
|
|
10655
|
+
|
|
10656
|
+
// The in-memory offsets now describe the file we just wrote. Refresh the
|
|
10657
|
+
// swap-detection signature so the next indexed read does not reload.
|
|
10658
|
+
this._offsetsFileSignature = await this._statDataFingerprint();
|
|
10300
10659
|
this.shouldSave = false;
|
|
10301
10660
|
this.lastSaveTime = Date.now();
|
|
10302
10661
|
|
|
@@ -10695,6 +11054,9 @@ class Database extends events.EventEmitter {
|
|
|
10695
11054
|
*/
|
|
10696
11055
|
async insert(data) {
|
|
10697
11056
|
this._validateInitialization('insert');
|
|
11057
|
+
if (this.opts.readOnly) {
|
|
11058
|
+
throw new Error('Database is read-only');
|
|
11059
|
+
}
|
|
10698
11060
|
return this.operationQueue.enqueue(async () => {
|
|
10699
11061
|
this.isInsideOperationQueue = true;
|
|
10700
11062
|
try {
|
|
@@ -10789,6 +11151,9 @@ class Database extends events.EventEmitter {
|
|
|
10789
11151
|
*/
|
|
10790
11152
|
async insertBatch(dataArray) {
|
|
10791
11153
|
this._validateInitialization('insertBatch');
|
|
11154
|
+
if (this.opts.readOnly) {
|
|
11155
|
+
throw new Error('Database is read-only');
|
|
11156
|
+
}
|
|
10792
11157
|
|
|
10793
11158
|
// If we're already inside the operation queue (e.g., from insert()), avoid re-enqueueing to prevent deadlocks
|
|
10794
11159
|
if (this.isInsideOperationQueue) {
|
|
@@ -10814,6 +11179,11 @@ class Database extends events.EventEmitter {
|
|
|
10814
11179
|
* Internal implementation for insertBatch to allow inline execution when already inside the queue
|
|
10815
11180
|
*/
|
|
10816
11181
|
async _insertBatchInternal(dataArray) {
|
|
11182
|
+
// Read-only mode: never write to disk
|
|
11183
|
+
if (this.opts.readOnly) {
|
|
11184
|
+
throw new Error('Database is read-only');
|
|
11185
|
+
}
|
|
11186
|
+
|
|
10817
11187
|
// CRITICAL FIX: Validate state before insert operation
|
|
10818
11188
|
this.validateState();
|
|
10819
11189
|
if (!Array.isArray(dataArray) || dataArray.length === 0) {
|
|
@@ -10946,15 +11316,13 @@ class Database extends events.EventEmitter {
|
|
|
10946
11316
|
if (this.integrityCorrections.dataIntegrity > 5) {
|
|
10947
11317
|
console.warn(`⚠️ Frequent integrity corrections (${this.integrityCorrections.dataIntegrity} times) - this indicates a systemic issue`);
|
|
10948
11318
|
}
|
|
10949
|
-
this.indexManager.setTotalLines(offsetsLength);
|
|
10950
11319
|
|
|
10951
|
-
//
|
|
10952
|
-
|
|
10953
|
-
|
|
10954
|
-
|
|
10955
|
-
|
|
10956
|
-
|
|
10957
|
-
}
|
|
11320
|
+
// Reconcile the in-memory totalLines with the actual persisted offset
|
|
11321
|
+
// count. NOTE: deliberately NOT persisted here - find() is a read path
|
|
11322
|
+
// and must never rewrite the .idx file (especially while a concurrent
|
|
11323
|
+
// writer may be replacing it). A later save() persists a fully
|
|
11324
|
+
// consistent .idx anyway.
|
|
11325
|
+
this.indexManager.setTotalLines(offsetsLength);
|
|
10958
11326
|
}
|
|
10959
11327
|
}
|
|
10960
11328
|
|
|
@@ -11608,6 +11976,15 @@ class Database extends events.EventEmitter {
|
|
|
11608
11976
|
if (!this._indexRebuildNeeded) return;
|
|
11609
11977
|
if (!this.indexManager || !this.indexManager.indexedFields || this.indexManager.indexedFields.length === 0) return;
|
|
11610
11978
|
|
|
11979
|
+
// If the index was idle-unloaded (or never loaded), recover it cheaply from
|
|
11980
|
+
// the on-disk .idx file BEFORE considering a full rebuild. A rebuild is only
|
|
11981
|
+
// needed when the on-disk index is genuinely missing/corrupt. Mirrors the
|
|
11982
|
+
// score() hot path and prevents an idle-unload from triggering a full-file
|
|
11983
|
+
// rebuild (or a throw when allowIndexRebuild is false).
|
|
11984
|
+
if (!this.indexManager.indexLoaded) {
|
|
11985
|
+
await this._ensureLazyIndexLoaded();
|
|
11986
|
+
}
|
|
11987
|
+
|
|
11611
11988
|
// Check if index actually needs rebuilding
|
|
11612
11989
|
let needsRebuild = false;
|
|
11613
11990
|
for (const field of this.indexManager.indexedFields) {
|
|
@@ -11621,6 +11998,13 @@ class Database extends events.EventEmitter {
|
|
|
11621
11998
|
return;
|
|
11622
11999
|
}
|
|
11623
12000
|
|
|
12001
|
+
// Read-only mode: never write/rebuild on disk. Let queries fall back to a
|
|
12002
|
+
// full streaming scan instead of throwing or rewriting the index file.
|
|
12003
|
+
if (this.opts.readOnly) {
|
|
12004
|
+
this._indexRebuildNeeded = false;
|
|
12005
|
+
return;
|
|
12006
|
+
}
|
|
12007
|
+
|
|
11624
12008
|
// Check if rebuild is allowed
|
|
11625
12009
|
if (!this.opts.allowIndexRebuild) {
|
|
11626
12010
|
const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb');
|
|
@@ -11688,9 +12072,13 @@ class Database extends events.EventEmitter {
|
|
|
11688
12072
|
crlfDelay: Infinity
|
|
11689
12073
|
});
|
|
11690
12074
|
|
|
11691
|
-
// Handle abort
|
|
12075
|
+
// Handle abort. Destroy the stream WITHOUT an error payload, then
|
|
12076
|
+
// close readline. Destroying with `new Error('AbortError')` makes
|
|
12077
|
+
// the stream emit an unhandled 'error' (readline is closed first, so
|
|
12078
|
+
// the for-await loop exits cleanly and never observes it), which
|
|
12079
|
+
// Node escalates to an uncaughtException and crashes the host app.
|
|
11692
12080
|
controller.signal.addEventListener('abort', () => {
|
|
11693
|
-
stream.destroy(
|
|
12081
|
+
stream.destroy();
|
|
11694
12082
|
rl.close();
|
|
11695
12083
|
});
|
|
11696
12084
|
var _iteratorAbruptCompletion = false;
|
|
@@ -11738,6 +12126,10 @@ class Database extends events.EventEmitter {
|
|
|
11738
12126
|
}
|
|
11739
12127
|
}
|
|
11740
12128
|
}
|
|
12129
|
+
|
|
12130
|
+
// If aborted during schema detection, surface a normalized timeout
|
|
12131
|
+
// error so the retry wrapper (and ultimately the caller) observes it
|
|
12132
|
+
// instead of an uncaughtException.
|
|
11741
12133
|
} catch (err) {
|
|
11742
12134
|
_didIteratorError = true;
|
|
11743
12135
|
_iteratorError = err;
|
|
@@ -11752,6 +12144,9 @@ class Database extends events.EventEmitter {
|
|
|
11752
12144
|
}
|
|
11753
12145
|
}
|
|
11754
12146
|
}
|
|
12147
|
+
if (controller.signal.aborted) {
|
|
12148
|
+
throw createIoTimeoutError(timeoutMs);
|
|
12149
|
+
}
|
|
11755
12150
|
stream.destroy();
|
|
11756
12151
|
}
|
|
11757
12152
|
|
|
@@ -11769,9 +12164,10 @@ class Database extends events.EventEmitter {
|
|
|
11769
12164
|
crlfDelay: Infinity
|
|
11770
12165
|
});
|
|
11771
12166
|
|
|
11772
|
-
// Handle abort
|
|
12167
|
+
// Handle abort. See the schema stream above - destroy WITHOUT an error
|
|
12168
|
+
// payload to avoid an unhandled 'error' becoming an uncaughtException.
|
|
11773
12169
|
controller.signal.addEventListener('abort', () => {
|
|
11774
|
-
stream.destroy(
|
|
12170
|
+
stream.destroy();
|
|
11775
12171
|
rl.close();
|
|
11776
12172
|
});
|
|
11777
12173
|
let localCount = 0;
|
|
@@ -11802,6 +12198,9 @@ class Database extends events.EventEmitter {
|
|
|
11802
12198
|
currentOffset += Buffer.byteLength(line, 'utf8') + 1;
|
|
11803
12199
|
}
|
|
11804
12200
|
}
|
|
12201
|
+
|
|
12202
|
+
// If the read was aborted, do NOT treat a partial read as a successful
|
|
12203
|
+
// rebuild - surface a normalized timeout error so it can be retried.
|
|
11805
12204
|
} catch (err) {
|
|
11806
12205
|
_didIteratorError2 = true;
|
|
11807
12206
|
_iteratorError2 = err;
|
|
@@ -11816,10 +12215,17 @@ class Database extends events.EventEmitter {
|
|
|
11816
12215
|
}
|
|
11817
12216
|
}
|
|
11818
12217
|
}
|
|
12218
|
+
if (controller.signal.aborted) {
|
|
12219
|
+
throw createIoTimeoutError(timeoutMs);
|
|
12220
|
+
}
|
|
11819
12221
|
count += localCount;
|
|
11820
12222
|
stream.destroy();
|
|
11821
12223
|
} catch (error) {
|
|
11822
|
-
|
|
12224
|
+
// p-retry "stop" signal (may be absent when p-retry is mocked in tests)
|
|
12225
|
+
if (typeof pRetry.AbortError === 'function' && error instanceof pRetry.AbortError) {
|
|
12226
|
+
throw error;
|
|
12227
|
+
}
|
|
12228
|
+
if (isIoTimeoutError(error)) {
|
|
11823
12229
|
if (this.opts.debugMode) {
|
|
11824
12230
|
console.log(`⚠️ Index rebuild attempt ${attempt} timed out, retrying...`);
|
|
11825
12231
|
}
|
|
@@ -12550,6 +12956,11 @@ class Database extends events.EventEmitter {
|
|
|
12550
12956
|
await this._ensureLazyIndexLoaded();
|
|
12551
12957
|
}
|
|
12552
12958
|
|
|
12959
|
+
// Swap detection: if another process atomically replaced the data file,
|
|
12960
|
+
// reload offsets + index (and term mapping) cleanly so the scores below are
|
|
12961
|
+
// computed against the current file, never stale byte ranges.
|
|
12962
|
+
await this._refreshFromDiskIfChanged();
|
|
12963
|
+
|
|
12553
12964
|
// Access the index for this field
|
|
12554
12965
|
const fieldIndex = this.indexManager.index.data[fieldName];
|
|
12555
12966
|
if (!fieldIndex || Object.keys(fieldIndex).length === 0) {
|
|
@@ -12781,6 +13192,9 @@ class Database extends events.EventEmitter {
|
|
|
12781
13192
|
}
|
|
12782
13193
|
}
|
|
12783
13194
|
}
|
|
13195
|
+
|
|
13196
|
+
// If the read was aborted, do not silently return partial scores -
|
|
13197
|
+
// surface a normalized timeout error so it can be retried.
|
|
12784
13198
|
} catch (err) {
|
|
12785
13199
|
_didIteratorError3 = true;
|
|
12786
13200
|
_iteratorError3 = err;
|
|
@@ -12795,9 +13209,16 @@ class Database extends events.EventEmitter {
|
|
|
12795
13209
|
}
|
|
12796
13210
|
}
|
|
12797
13211
|
}
|
|
13212
|
+
if (controller.signal.aborted) {
|
|
13213
|
+
throw createIoTimeoutError(timeoutMs);
|
|
13214
|
+
}
|
|
12798
13215
|
results.push(...rangeResults);
|
|
12799
13216
|
} catch (error) {
|
|
12800
|
-
|
|
13217
|
+
// p-retry "stop" signal (may be absent when p-retry is mocked in tests)
|
|
13218
|
+
if (typeof pRetry.AbortError === 'function' && error instanceof pRetry.AbortError) {
|
|
13219
|
+
throw error;
|
|
13220
|
+
}
|
|
13221
|
+
if (isIoTimeoutError(error)) {
|
|
12801
13222
|
if (this.opts.debugMode) {
|
|
12802
13223
|
console.log(`⚠️ Score range read attempt ${attempt} timed out, retrying...`);
|
|
12803
13224
|
}
|
|
@@ -13913,10 +14334,27 @@ class Database extends events.EventEmitter {
|
|
|
13913
14334
|
this.indexManager.indexLoaded = true;
|
|
13914
14335
|
return;
|
|
13915
14336
|
}
|
|
13916
|
-
|
|
13917
|
-
|
|
14337
|
+
|
|
14338
|
+
// Single-flight: N concurrent callers (find/count/score) share a single
|
|
14339
|
+
// on-disk load. The promise is cleared in a finally so a rejected load is
|
|
14340
|
+
// never left behind to poison every future attempt.
|
|
14341
|
+
if (!this._lazyIndexLoadPromise) {
|
|
14342
|
+
this._lazyIndexLoadPromise = this._doLazyIndexLoad().finally(() => {
|
|
14343
|
+
this._lazyIndexLoadPromise = null;
|
|
14344
|
+
});
|
|
13918
14345
|
}
|
|
13919
|
-
this._lazyIndexLoadPromise
|
|
14346
|
+
return this._lazyIndexLoadPromise;
|
|
14347
|
+
}
|
|
14348
|
+
|
|
14349
|
+
/**
|
|
14350
|
+
* Actually perform the lazy index load from the on-disk .idx file.
|
|
14351
|
+
* Never rejects: on failure it degrades to an empty in-memory index (queries
|
|
14352
|
+
* fall back to streaming) instead of poisoning the single-flight or surfacing
|
|
14353
|
+
* a background cold-load as an unhandled rejection.
|
|
14354
|
+
* @private
|
|
14355
|
+
*/
|
|
14356
|
+
async _doLazyIndexLoad() {
|
|
14357
|
+
try {
|
|
13920
14358
|
const indexData = await this._loadIndexDataFromFile();
|
|
13921
14359
|
if (indexData) {
|
|
13922
14360
|
this.indexManager.load(indexData);
|
|
@@ -13927,9 +14365,153 @@ class Database extends events.EventEmitter {
|
|
|
13927
14365
|
this.indexManager.indexLoaded = true;
|
|
13928
14366
|
this.indexManager.markIndexUsed();
|
|
13929
14367
|
}
|
|
13930
|
-
|
|
14368
|
+
} catch (error) {
|
|
14369
|
+
if (this.opts.debugMode) {
|
|
14370
|
+
console.error(`⚠️ _ensureLazyIndexLoaded: Lazy index load failed, continuing with empty index: ${error && error.message}`);
|
|
14371
|
+
}
|
|
14372
|
+
// Never leave the index flagged as not-loaded: mark it loaded (empty) so
|
|
14373
|
+
// later calls short-circuit and queries degrade to a full scan.
|
|
14374
|
+
this.indexManager.indexLoaded = true;
|
|
14375
|
+
}
|
|
14376
|
+
}
|
|
14377
|
+
|
|
14378
|
+
/**
|
|
14379
|
+
* Path of the "writer active" sentinel file for this data file, e.g.
|
|
14380
|
+
* `list.jdb` -> `list.updating.jdb`. While this file exists, a writer is
|
|
14381
|
+
* atomically replacing the database and readers must not refresh/repair it.
|
|
14382
|
+
* @returns {string}
|
|
14383
|
+
*/
|
|
14384
|
+
_getUpdatingSentinelPath() {
|
|
14385
|
+
return this.normalizedFile.replace(/\.jdb$/i, '.updating.jdb');
|
|
14386
|
+
}
|
|
14387
|
+
|
|
14388
|
+
/**
|
|
14389
|
+
* Whether a writer sentinel is currently present on disk.
|
|
14390
|
+
* @returns {Promise<boolean>}
|
|
14391
|
+
*/
|
|
14392
|
+
async hasUpdatingSentinel() {
|
|
14393
|
+
try {
|
|
14394
|
+
await fs.promises.access(this._getUpdatingSentinelPath());
|
|
14395
|
+
return true;
|
|
14396
|
+
} catch (error) {
|
|
14397
|
+
return false;
|
|
14398
|
+
}
|
|
14399
|
+
}
|
|
14400
|
+
|
|
14401
|
+
/**
|
|
14402
|
+
* Capture the current data-file fingerprint (size + mtime) for swap
|
|
14403
|
+
* detection. Best-effort: returns null when the file cannot be stat'ed.
|
|
14404
|
+
* @returns {Promise<{size: number, mtimeMs: number}|null>}
|
|
14405
|
+
*/
|
|
14406
|
+
async _statDataFingerprint() {
|
|
14407
|
+
try {
|
|
14408
|
+
const st = await fs.promises.stat(this.normalizedFile);
|
|
14409
|
+
return {
|
|
14410
|
+
size: st.size,
|
|
14411
|
+
mtimeMs: st.mtimeMs
|
|
14412
|
+
};
|
|
14413
|
+
} catch (error) {
|
|
14414
|
+
return null;
|
|
14415
|
+
}
|
|
14416
|
+
}
|
|
14417
|
+
|
|
14418
|
+
/**
|
|
14419
|
+
* Cheap swap detection for the read path. The data file may be atomically
|
|
14420
|
+
* replaced by a writer (same or another process); the in-memory offsets and
|
|
14421
|
+
* index describe the previous file. When the on-disk size/mtime no longer
|
|
14422
|
+
* match the signature captured when the offsets were loaded, reload offsets
|
|
14423
|
+
* and index cleanly from the current .idx.
|
|
14424
|
+
*
|
|
14425
|
+
* Returns:
|
|
14426
|
+
* - 'ok' : nothing changed / refresh not applicable
|
|
14427
|
+
* - 'changed' : file was swapped and offsets + index were cleanly reloaded
|
|
14428
|
+
* - 'busy' : a writer sentinel is present (or the clean reload failed) -
|
|
14429
|
+
* the caller must fall back to a full streaming scan instead
|
|
14430
|
+
* of using possibly stale byte offsets on the new file
|
|
14431
|
+
* @returns {Promise<'ok'|'changed'|'busy'>}
|
|
14432
|
+
*/
|
|
14433
|
+
async _refreshFromDiskIfChanged() {
|
|
14434
|
+
// If a writer is mid-update, never refresh/repair in place.
|
|
14435
|
+
if (this.opts.updatingSentinel && (await this.hasUpdatingSentinel())) {
|
|
14436
|
+
return 'busy';
|
|
14437
|
+
}
|
|
14438
|
+
if (!this._offsetsFileSignature) return 'ok';
|
|
14439
|
+
if (!this.offsets || this.offsets.length === 0) return 'ok';
|
|
14440
|
+
|
|
14441
|
+
// Cheap stat with a short TTL so hot queries don't syscall every time.
|
|
14442
|
+
const now = Date.now();
|
|
14443
|
+
if (!this._dataFingerprint || !this._dataFingerprint.at || now - this._dataFingerprint.at > 250) {
|
|
14444
|
+
const st = await this._statDataFingerprint();
|
|
14445
|
+
this._dataFingerprint = {
|
|
14446
|
+
...(st || {
|
|
14447
|
+
size: 0,
|
|
14448
|
+
mtimeMs: 0
|
|
14449
|
+
}),
|
|
14450
|
+
at: now
|
|
14451
|
+
};
|
|
14452
|
+
}
|
|
14453
|
+
const fp = this._dataFingerprint;
|
|
14454
|
+
const sig = this._offsetsFileSignature;
|
|
14455
|
+
if (fp.size === sig.size && fp.mtimeMs === sig.mtimeMs) return 'ok';
|
|
14456
|
+
const reloaded = await this._reloadOffsetsAndIndex();
|
|
14457
|
+
if (!reloaded) return 'busy';
|
|
14458
|
+
// Signature now describes the (already stat'ed) new file
|
|
14459
|
+
this._offsetsFileSignature = {
|
|
14460
|
+
size: fp.size,
|
|
14461
|
+
mtimeMs: fp.mtimeMs
|
|
14462
|
+
};
|
|
14463
|
+
return 'changed';
|
|
14464
|
+
}
|
|
14465
|
+
|
|
14466
|
+
/**
|
|
14467
|
+
* Cleanly reload offsets/indexOffset/index data from the on-disk .idx file
|
|
14468
|
+
* after the data file was swapped. Single-flight so N concurrent queries
|
|
14469
|
+
* share one reload.
|
|
14470
|
+
* @returns {Promise<boolean>} true when the reload produced consistent state
|
|
14471
|
+
*/
|
|
14472
|
+
async _reloadOffsetsAndIndex() {
|
|
14473
|
+
if (this._offsetsReloadPromise) {
|
|
14474
|
+
return this._offsetsReloadPromise;
|
|
14475
|
+
}
|
|
14476
|
+
this._offsetsReloadPromise = (async () => {
|
|
14477
|
+
const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb');
|
|
14478
|
+
try {
|
|
14479
|
+
const raw = await fs.promises.readFile(idxPath, 'utf8');
|
|
14480
|
+
const parsed = JSON.parse(raw);
|
|
14481
|
+
if (!parsed || !Array.isArray(parsed.offsets)) return false;
|
|
14482
|
+
this.offsets = parsed.offsets;
|
|
14483
|
+
this.indexOffset = typeof parsed.indexOffset === 'number' ? parsed.indexOffset : 0;
|
|
14484
|
+
|
|
14485
|
+
// Reload term mapping (if any) first so term IDs/words match the new
|
|
14486
|
+
// index and the records it describes.
|
|
14487
|
+
if (parsed.termMapping && this.termManager && typeof this.termManager.loadTerms === 'function') {
|
|
14488
|
+
this.termManager.loadTerms(parsed.termMapping);
|
|
14489
|
+
}
|
|
14490
|
+
|
|
14491
|
+
// Clear the current in-memory index so IndexManager.load() does not
|
|
14492
|
+
// short-circuit (load() skips when it already sees actual data).
|
|
14493
|
+
if (this.indexManager && this.indexManager.indexedFields) {
|
|
14494
|
+
for (const field of this.indexManager.indexedFields) {
|
|
14495
|
+
if (this.indexManager.index && this.indexManager.index.data) {
|
|
14496
|
+
this.indexManager.index.data[field] = {};
|
|
14497
|
+
}
|
|
14498
|
+
}
|
|
14499
|
+
}
|
|
14500
|
+
// Reload the in-memory index from the same (new) .idx data
|
|
14501
|
+
this.indexManager.indexLoaded = false;
|
|
14502
|
+
await this._ensureLazyIndexLoaded();
|
|
14503
|
+
this.indexManager.setTotalLines(this.offsets.length);
|
|
14504
|
+
return true;
|
|
14505
|
+
} catch (error) {
|
|
14506
|
+
if (this.opts.debugMode) {
|
|
14507
|
+
console.warn(`⚠️ _reloadOffsetsAndIndex: could not reload from ${idxPath}: ${error && error.message}`);
|
|
14508
|
+
}
|
|
14509
|
+
return false;
|
|
14510
|
+
} finally {
|
|
14511
|
+
this._offsetsReloadPromise = null;
|
|
14512
|
+
}
|
|
13931
14513
|
})();
|
|
13932
|
-
return this.
|
|
14514
|
+
return this._offsetsReloadPromise;
|
|
13933
14515
|
}
|
|
13934
14516
|
|
|
13935
14517
|
/**
|
|
@@ -14267,6 +14849,10 @@ class Database extends events.EventEmitter {
|
|
|
14267
14849
|
yield _awaitAsyncGenerator(new Promise(resolve => setTimeout(resolve, 10)));
|
|
14268
14850
|
}
|
|
14269
14851
|
}
|
|
14852
|
+
|
|
14853
|
+
// Swap detection: if another process replaced the data file, reload offsets
|
|
14854
|
+
// and index cleanly so the line map / ranges describe the current file.
|
|
14855
|
+
yield _awaitAsyncGenerator(_this2._refreshFromDiskIfChanged());
|
|
14270
14856
|
let count = 0;
|
|
14271
14857
|
let remainingSkip = options.skip || 0;
|
|
14272
14858
|
let map;
|
|
@@ -15003,6 +15589,8 @@ class Database extends events.EventEmitter {
|
|
|
15003
15589
|
* @private
|
|
15004
15590
|
*/
|
|
15005
15591
|
async _saveIndexDataToFile() {
|
|
15592
|
+
// Read-only mode: never write to the index file on disk
|
|
15593
|
+
if (this.opts.readOnly) return;
|
|
15006
15594
|
if (this.indexManager) {
|
|
15007
15595
|
try {
|
|
15008
15596
|
const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb');
|
|
@@ -15069,7 +15657,7 @@ class Database extends events.EventEmitter {
|
|
|
15069
15657
|
// This ensures the database structure is complete
|
|
15070
15658
|
const originalFile = this.fileHandler.file;
|
|
15071
15659
|
this.fileHandler.file = idxPath;
|
|
15072
|
-
await this.fileHandler.
|
|
15660
|
+
await this.fileHandler.writeFileAtomic(JSON.stringify(indexData, null, 2));
|
|
15073
15661
|
this.fileHandler.file = originalFile;
|
|
15074
15662
|
if (this.opts.debugMode) {
|
|
15075
15663
|
console.log(`💾 Index data saved to ${idxPath}`);
|