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/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
- const criteriaValue = normalizeCriteriaOperators(originalCriteriaValue, {
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
- // Handle RegExp values directly (MUST check before object check since RegExp is an object)
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
- // RegExp cannot be efficiently queried using indices - fall back to streaming
1471
- // This will be handled by the QueryManager's streaming strategy
1472
- continue;
1473
- }
1474
- if (typeof criteriaValue === 'object' && !Array.isArray(criteriaValue) && criteriaValue !== null) {
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
- if (error.name === 'AbortError' || error.code === 'ETIMEDOUT') {
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 preFilteredLines = this.indexManager.query(this._extractIndexableCriteria(criteria), streamingOptions);
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, streamingOptions);
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
- // RegExp conditions cannot be pre-filtered using indices
6249
- if (condition instanceof RegExp) {
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
- // RegExp conditions cannot be pre-filtered using indices
6297
- if (condition instanceof RegExp) {
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 indexable fields since they were already pre-filtered
6513
- const indexableFields = this._getIndexableFields(criteria);
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 indexable fields that were already pre-filtered
6540
- if (indexableFields.includes(field)) {
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
- const value = record[field];
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
- console.log(`🔍 Checking field '${field}':`, {
6857
- value,
6858
- condition,
6859
- record: record.name || record.id
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 - check if all conditions in $and are indexable
7122
- if (criteria.$and && Array.isArray(criteria.$and)) {
7123
- const allAndConditionsIndexed = criteria.$and.every(andCondition => {
7124
- // Handle $not conditions within $and
7125
- if (andCondition.$not) {
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('📊 QueryStrategy: STREAMING - Some $and conditions not indexed or operators not supported');
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
- const allFieldsIndexed = Object.keys(criteria).every(field => {
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 // Allow automatic index rebuild when corrupted (default false - throws error)
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.writeBatch([jsonlData]);
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
- // Try to persist the fix, but don't fail the operation if it doesn't work
10952
- try {
10953
- await this._saveIndexDataToFile();
10954
- } catch (error) {
10955
- // Just track the failure - don't throw since this is a safety net
10956
- this.integrityCorrections.indexSaveFailures++;
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(new Error('AbortError'));
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(new Error('AbortError'));
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
- if (error.name === 'AbortError' || error.code === 'ETIMEDOUT') {
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
- if (error.name === 'AbortError' || error.code === 'ETIMEDOUT') {
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
- if (this._lazyIndexLoadPromise) {
13917
- return this._lazyIndexLoadPromise;
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 = (async () => {
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
- this._lazyIndexLoadPromise = null;
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._lazyIndexLoadPromise;
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.writeAll(JSON.stringify(indexData, null, 2));
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}`);