jexidb 2.2.2 → 2.2.5

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