jexidb 2.2.3 → 2.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -4289,6 +4312,39 @@ class OperationQueue {
4289
4312
  }
4290
4313
  }
4291
4314
 
4315
+ /**
4316
+ * Shared helpers to normalize and detect I/O timeout / abort errors.
4317
+ *
4318
+ * Node surfaces read timeouts in several inconsistent shapes depending on the
4319
+ * path used (`error.name`, `error.code` or `error.message`). These helpers
4320
+ * centralize creation and detection so every retry branch across Database.mjs
4321
+ * and FileHandler.mjs agrees, and timeouts are always delivered as normal
4322
+ * promise rejections - never as an `uncaughtException`.
4323
+ */
4324
+
4325
+ /**
4326
+ * Create a normalized I/O timeout error that every retry branch recognizes.
4327
+ * @param {number} timeoutMs - Timeout that elapsed, in milliseconds.
4328
+ * @returns {Error}
4329
+ */
4330
+ function createIoTimeoutError(timeoutMs) {
4331
+ const err = new Error(`I/O timeout after ${timeoutMs}ms`);
4332
+ err.name = 'AbortError';
4333
+ err.code = 'ETIMEDOUT';
4334
+ return err;
4335
+ }
4336
+
4337
+ /**
4338
+ * Detect whether an error represents an I/O timeout or abort, regardless of the
4339
+ * shape it was produced in.
4340
+ * @param {*} error - Error to inspect (may be null/undefined).
4341
+ * @returns {boolean}
4342
+ */
4343
+ function isIoTimeoutError(error) {
4344
+ if (!error) return false;
4345
+ return error.name === 'AbortError' || error.code === 'ETIMEDOUT' || error.message === 'AbortError';
4346
+ }
4347
+
4292
4348
  /*
4293
4349
  How it works:
4294
4350
  `this.#head` is an instance of `Node` which keeps track of its current value and nests another instance of `Node` that keeps the value that comes after it. When a value is provided to `.enqueue()`, the code needs to iterate through `this.#head`, going deeper and deeper to find the last value. However, iterating through every single item is slow. This problem is solved by saving a reference to the last value as `this.#tail` so that it can reference it to add a new value.
@@ -5173,6 +5229,23 @@ class FileHandler {
5173
5229
  await fs.promises.rename(tmpFile, targetFile);
5174
5230
  return; // Success
5175
5231
  } catch (error) {
5232
+ // ENOENT: the temp file vanished before the rename. With unique temp names
5233
+ // (see _atomicReplaceFile) this means another concurrent writer already
5234
+ // replaced the target (its rename won, ours was superseded) or an external
5235
+ // cleanup removed our temp. If the target exists, the write was superseded,
5236
+ // not an error. (CHANGELOG [2.2.5])
5237
+ if (error.code === 'ENOENT') {
5238
+ try {
5239
+ await fs.promises.access(targetFile);
5240
+ return; // target already replaced by a concurrent writer - ok
5241
+ } catch (accessError) {
5242
+ if (attempt < maxRetries) {
5243
+ await new Promise(resolve => setTimeout(resolve, 50 * attempt));
5244
+ continue;
5245
+ }
5246
+ throw error;
5247
+ }
5248
+ }
5176
5249
  if (error.code === 'EPERM' && attempt < maxRetries) {
5177
5250
  // Quick delay: 50ms, 100ms, 200ms
5178
5251
  const delay = 50 * attempt;
@@ -5244,6 +5317,69 @@ class FileHandler {
5244
5317
  throw fallbackError;
5245
5318
  }
5246
5319
  }
5320
+
5321
+ /**
5322
+ * Atomically replace this file's content: write to a sibling temp file, then
5323
+ * rename over the target (with a Windows copy fallback on EPERM). Concurrent
5324
+ * readers never observe a half-written file.
5325
+ * @param {Buffer|string} data - Full content of the new file.
5326
+ */
5327
+ async _atomicReplaceFile(data) {
5328
+ // Use a UNIQUE temp name per write. A fixed "<file>.tmp" collides when two
5329
+ // Database instances write the same file concurrently: each removes the
5330
+ // other's temp before its rename, causing ENOENT. (CHANGELOG [2.2.5])
5331
+ const tmpFile = `${this.file}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
5332
+ const release = this.fileMutex ? await this.fileMutex.acquire() : () => {};
5333
+ try {
5334
+ await fs.promises.writeFile(tmpFile, data);
5335
+
5336
+ // Windows: brief delay helps readers release the target handle before the
5337
+ // rename (EPERM mitigation, mirroring _writeFileWithRetry).
5338
+ if (process.platform === 'win32') {
5339
+ await new Promise(resolve => setTimeout(resolve, 10));
5340
+ }
5341
+ await this._safeRename(tmpFile, this.file);
5342
+ } catch (error) {
5343
+ // Best-effort cleanup of our own temp on failure.
5344
+ await fs.promises.unlink(tmpFile).catch(() => {});
5345
+ throw error;
5346
+ } finally {
5347
+ release();
5348
+ }
5349
+ }
5350
+
5351
+ /**
5352
+ * Atomically replace the whole data file with the given content.
5353
+ *
5354
+ * When `opts.updatingSentinel` is enabled, a `<file>.updating.jdb` sentinel is
5355
+ * created for the duration of the swap so cross-process readers can detect an
5356
+ * active writer and avoid touching the file mid-update.
5357
+ *
5358
+ * @param {Buffer|string} data - Full content of the new data file.
5359
+ */
5360
+ async replaceDataFile(data) {
5361
+ const sentinelFile = this.file.replace(/\.jdb$/i, '.updating.jdb');
5362
+ const useSentinel = !!(this.opts && this.opts.updatingSentinel);
5363
+ let releaseSentinel = null;
5364
+ if (useSentinel) {
5365
+ // Signal "writer active" before touching the target
5366
+ await fs.promises.writeFile(sentinelFile, String(Date.now()), 'utf8').catch(() => {});
5367
+ releaseSentinel = () => fs.promises.unlink(sentinelFile).catch(() => {});
5368
+ }
5369
+ try {
5370
+ await this._atomicReplaceFile(data);
5371
+ } finally {
5372
+ if (releaseSentinel) await releaseSentinel();
5373
+ }
5374
+ }
5375
+
5376
+ /**
5377
+ * Atomically replace this file's content (used for the .idx.jdb index file).
5378
+ * @param {Buffer|string} data - Full content of the new file.
5379
+ */
5380
+ async writeFileAtomic(data) {
5381
+ await this._atomicReplaceFile(data);
5382
+ }
5247
5383
  async writeData(data, immediate, fd) {
5248
5384
  await fd.write(data);
5249
5385
  }
@@ -5357,7 +5493,11 @@ class FileHandler {
5357
5493
  const results = await this._readWithStreamingInternal(criteria, options, matchesCriteria, serializer, controller.signal);
5358
5494
  return results;
5359
5495
  } catch (error) {
5360
- 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)) {
5361
5501
  if (this.opts.debugMode) {
5362
5502
  console.log(`⚠️ Streaming read attempt ${attempt} timed out, retrying...`);
5363
5503
  }
@@ -6223,6 +6363,29 @@ class QueryManager {
6223
6363
  return this._streamAllRecords(criteria, streamingOptions);
6224
6364
  }
6225
6365
 
6366
+ /**
6367
+ * Whether a single field condition can/should be answered by the index during
6368
+ * pre-filtering (so the LEAST number of entries is streamed).
6369
+ *
6370
+ * - Raw RegExp is pre-filterable only on string/term indexes (IndexManager.query
6371
+ * resolves it via the index keys). Regex on numeric indexes -> residual.
6372
+ * - Complement operators ($ne/$nin) expand to nearly every record, so they are
6373
+ * NOT pre-filtered: they are applied LAST, on the minimal candidate set, by the
6374
+ * streaming matcher. (CHANGELOG [2.2.5])
6375
+ */
6376
+ _canPrefilterFieldCondition(field, condition) {
6377
+ if (!this.indexManager?.opts?.indexes?.[field]) return false;
6378
+ if (condition instanceof RegExp) {
6379
+ const idxType = this.indexManager.opts.indexes[field];
6380
+ return idxType !== 'number' && idxType !== 'array:number';
6381
+ }
6382
+ if (condition && typeof condition === 'object' && !Array.isArray(condition)) {
6383
+ const ops = Object.keys(condition).map(op => normalizeOperator(op));
6384
+ if (ops.some(op => op === '$ne' || op === '$nin')) return false;
6385
+ }
6386
+ return true;
6387
+ }
6388
+
6226
6389
  /**
6227
6390
  * Get indexable fields from criteria
6228
6391
  * @param {Object} criteria - Query criteria
@@ -6245,8 +6408,9 @@ class QueryManager {
6245
6408
  for (const [field, condition] of Object.entries(criteria)) {
6246
6409
  if (field.startsWith('$')) continue; // Skip logical operators
6247
6410
 
6248
- // RegExp conditions cannot be pre-filtered using indices
6249
- 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)) {
6250
6414
  continue;
6251
6415
  }
6252
6416
  if (this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]) {
@@ -6293,8 +6457,9 @@ class QueryManager {
6293
6457
  for (const [field, condition] of Object.entries(criteria)) {
6294
6458
  if (field.startsWith('$')) continue; // Skip logical operators (already handled above)
6295
6459
 
6296
- // RegExp conditions cannot be pre-filtered using indices
6297
- 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)) {
6298
6463
  continue;
6299
6464
  }
6300
6465
  if (this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]) {
@@ -6620,6 +6785,21 @@ class QueryManager {
6620
6785
  forceFullScan: true
6621
6786
  });
6622
6787
  }
6788
+
6789
+ // Swap detection: if another process atomically replaced the data file, the
6790
+ // in-memory offsets/index describe the previous file. Reload cleanly, or -
6791
+ // when a writer sentinel is present / the clean reload fails - fall back to
6792
+ // a full streaming scan so stale byte offsets are never used on the new file.
6793
+ const refreshStatus = await this.database._refreshFromDiskIfChanged();
6794
+ if (refreshStatus === 'busy') {
6795
+ if (this.opts.debugMode) {
6796
+ console.log('⚠️ findWithIndexed: file busy (writer active) or clean reload failed, falling back to full streaming');
6797
+ }
6798
+ return this.findWithStreaming(criteria, {
6799
+ ...options,
6800
+ forceFullScan: true
6801
+ });
6802
+ }
6623
6803
  let results = [];
6624
6804
  const limit = options.limit; // No default limit - return all results unless explicitly limited
6625
6805
 
@@ -6851,19 +7031,14 @@ class QueryManager {
6851
7031
  matchesFieldCondition(record, field, condition, options = {}) {
6852
7032
  const value = record[field];
6853
7033
 
6854
- // Debug logging for all field conditions
7034
+ // Debug logging for all field conditions - SAMPLED: only the first record of
7035
+ // each query logs per field, so a full streaming scan (which calls this for
7036
+ // every record) does not spam the console with one line per record.
6855
7037
  if (this.database.opts.debugMode) {
6856
- 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}':`, {
7038
+ if (!options._dbFieldLog) options._dbFieldLog = {};
7039
+ if (!options._dbFieldLog[field]) {
7040
+ options._dbFieldLog[field] = true;
7041
+ console.log(`🔍 Checking field '${field}':`, {
6867
7042
  value,
6868
7043
  condition,
6869
7044
  record: record.name || record.id
@@ -6871,6 +7046,21 @@ class QueryManager {
6871
7046
  }
6872
7047
  }
6873
7048
 
7049
+ // Debug logging for term mapping fields (sampled the same way)
7050
+ if (this.database.opts.termMapping && Object.keys(this.database.opts.indexes || {}).includes(field)) {
7051
+ if (this.database.opts.debugMode) {
7052
+ if (!options._dbFieldLog) options._dbFieldLog = {};
7053
+ if (!options._dbFieldLog[`tm:${field}`]) {
7054
+ options._dbFieldLog[`tm:${field}`] = true;
7055
+ console.log(`🔍 Checking term mapping field '${field}':`, {
7056
+ value,
7057
+ condition,
7058
+ record: record.name || record.id
7059
+ });
7060
+ }
7061
+ }
7062
+ }
7063
+
6874
7064
  // Handle null/undefined values
6875
7065
  if (value === null || value === undefined) {
6876
7066
  return condition === null || condition === undefined;
@@ -7140,9 +7330,11 @@ class QueryManager {
7140
7330
  }
7141
7331
  const condition = andCondition[field];
7142
7332
 
7143
- // 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".
7144
7335
  if (condition instanceof RegExp) {
7145
- return false;
7336
+ const idxType = this.indexManager?.opts?.indexes?.[field];
7337
+ return idxType !== 'number' && idxType !== 'array:number';
7146
7338
  }
7147
7339
  if (typeof condition === 'object' && !Array.isArray(condition)) {
7148
7340
  const operators = Object.keys(condition).map(op => normalizeOperator(op));
@@ -7184,12 +7376,17 @@ class QueryManager {
7184
7376
  // Check if the field uses operators that are supported by IndexManager
7185
7377
  const condition = criteria[field];
7186
7378
 
7187
- // 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".
7188
7381
  if (condition instanceof RegExp) {
7189
- if (this.opts.debugMode) {
7190
- 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;
7191
7388
  }
7192
- return false;
7389
+ return true;
7193
7390
  }
7194
7391
  if (typeof condition === 'object' && !Array.isArray(condition) && condition !== null) {
7195
7392
  const operators = Object.keys(condition).map(op => normalizeOperator(op));
@@ -8999,7 +9196,11 @@ class Database extends events.EventEmitter {
8999
9196
  enableArraySerialization: opts.enableArraySerialization !== false,
9000
9197
  // Enable array serialization by default
9001
9198
  // Index rebuild options
9002
- allowIndexRebuild: opts.allowIndexRebuild === true // 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
9003
9204
  }, opts);
9004
9205
 
9005
9206
  // CRITICAL FIX: Initialize AbortController for lifecycle management
@@ -9029,6 +9230,14 @@ class Database extends events.EventEmitter {
9029
9230
  this._offsetRecoveryInProgress = false;
9030
9231
  this.writeBufferTotalSize = 0;
9031
9232
 
9233
+ // Swap detection state: the data file may be atomically replaced by another
9234
+ // process while this instance holds offsets/index computed for the previous
9235
+ // file. _offsetsFileSignature remembers which on-disk file the offsets
9236
+ // describe (size + mtime) so the read path can detect a swap cheaply.
9237
+ this._offsetsFileSignature = null;
9238
+ this._dataFingerprint = null; // cached stat (short TTL) for the read path
9239
+ this._offsetsReloadPromise = null; // single-flight clean reload after a swap
9240
+
9032
9241
  // Initialize managers
9033
9242
  this.initializeManagers();
9034
9243
 
@@ -9404,8 +9613,8 @@ class Database extends events.EventEmitter {
9404
9613
  if (this.normalizedFile) {
9405
9614
  const fileExists = await this.fileHandler.exists();
9406
9615
  if (!fileExists) {
9407
- if (!this.opts.create) {
9408
- throw new Error(`Database file '${this.normalizedFile}' does not exist and create option is disabled`);
9616
+ if (!this.opts.create || this.opts.readOnly) {
9617
+ throw new Error(`Database file '${this.normalizedFile}' does not exist and ${this.opts.readOnly ? 'readOnly mode is enabled' : 'create option is disabled'}`);
9409
9618
  }
9410
9619
  // File will be created when first data is written
9411
9620
  } else {
@@ -9581,6 +9790,10 @@ class Database extends events.EventEmitter {
9581
9790
  }
9582
9791
  }
9583
9792
 
9793
+ // Remember which on-disk data file these offsets describe, so the
9794
+ // read path can detect an atomic swap performed by another process.
9795
+ this._offsetsFileSignature = await this._statDataFingerprint();
9796
+
9584
9797
  // Load indexOffset for proper range calculations
9585
9798
  if (parsedIdxData.indexOffset !== undefined) {
9586
9799
  this.indexOffset = parsedIdxData.indexOffset;
@@ -9881,6 +10094,14 @@ class Database extends events.EventEmitter {
9881
10094
  // CRITICAL FIX: Check if database is destroyed
9882
10095
  if (this.destroyed) return;
9883
10096
 
10097
+ // Read-only mode: never persist data to disk
10098
+ if (this.opts.readOnly) {
10099
+ if (this.opts.debugMode) {
10100
+ console.log('💾 _doSave: Database is read-only, skipping save');
10101
+ }
10102
+ return;
10103
+ }
10104
+
9884
10105
  // CRITICAL FIX: Use atomic check-and-set to prevent concurrent save operations
9885
10106
  if (this.isSaving) {
9886
10107
  if (this.opts.debugMode) {
@@ -10288,15 +10509,20 @@ class Database extends events.EventEmitter {
10288
10509
  console.log(`💾 Save: Calculated indexOffset: ${this.indexOffset}, allData.length: ${allData.length}`);
10289
10510
  }
10290
10511
 
10291
- // CRITICAL FIX: Write main data file first
10512
+ // CRITICAL FIX: Write main data file first (atomically: temp + rename so a
10513
+ // concurrent reader never sees a half-written file).
10292
10514
  // Index will be saved AFTER reconstruction to ensure it contains correct data
10293
- await this.fileHandler.writeBatch([jsonlData]);
10515
+ await this.fileHandler.replaceDataFile(jsonlData);
10294
10516
  if (this.opts.debugMode) {
10295
10517
  console.log(`💾 Saved ${allData.length} records to ${this.normalizedFile}`);
10296
10518
  }
10297
10519
 
10298
10520
  // CRITICAL FIX: Invalidate file size cache after save operation
10299
10521
  this._cachedFileStats = null;
10522
+
10523
+ // The in-memory offsets now describe the file we just wrote. Refresh the
10524
+ // swap-detection signature so the next indexed read does not reload.
10525
+ this._offsetsFileSignature = await this._statDataFingerprint();
10300
10526
  this.shouldSave = false;
10301
10527
  this.lastSaveTime = Date.now();
10302
10528
 
@@ -10695,6 +10921,9 @@ class Database extends events.EventEmitter {
10695
10921
  */
10696
10922
  async insert(data) {
10697
10923
  this._validateInitialization('insert');
10924
+ if (this.opts.readOnly) {
10925
+ throw new Error('Database is read-only');
10926
+ }
10698
10927
  return this.operationQueue.enqueue(async () => {
10699
10928
  this.isInsideOperationQueue = true;
10700
10929
  try {
@@ -10789,6 +11018,9 @@ class Database extends events.EventEmitter {
10789
11018
  */
10790
11019
  async insertBatch(dataArray) {
10791
11020
  this._validateInitialization('insertBatch');
11021
+ if (this.opts.readOnly) {
11022
+ throw new Error('Database is read-only');
11023
+ }
10792
11024
 
10793
11025
  // If we're already inside the operation queue (e.g., from insert()), avoid re-enqueueing to prevent deadlocks
10794
11026
  if (this.isInsideOperationQueue) {
@@ -10814,6 +11046,11 @@ class Database extends events.EventEmitter {
10814
11046
  * Internal implementation for insertBatch to allow inline execution when already inside the queue
10815
11047
  */
10816
11048
  async _insertBatchInternal(dataArray) {
11049
+ // Read-only mode: never write to disk
11050
+ if (this.opts.readOnly) {
11051
+ throw new Error('Database is read-only');
11052
+ }
11053
+
10817
11054
  // CRITICAL FIX: Validate state before insert operation
10818
11055
  this.validateState();
10819
11056
  if (!Array.isArray(dataArray) || dataArray.length === 0) {
@@ -10946,15 +11183,13 @@ class Database extends events.EventEmitter {
10946
11183
  if (this.integrityCorrections.dataIntegrity > 5) {
10947
11184
  console.warn(`⚠️ Frequent integrity corrections (${this.integrityCorrections.dataIntegrity} times) - this indicates a systemic issue`);
10948
11185
  }
10949
- this.indexManager.setTotalLines(offsetsLength);
10950
11186
 
10951
- // 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
- }
11187
+ // Reconcile the in-memory totalLines with the actual persisted offset
11188
+ // count. NOTE: deliberately NOT persisted here - find() is a read path
11189
+ // and must never rewrite the .idx file (especially while a concurrent
11190
+ // writer may be replacing it). A later save() persists a fully
11191
+ // consistent .idx anyway.
11192
+ this.indexManager.setTotalLines(offsetsLength);
10958
11193
  }
10959
11194
  }
10960
11195
 
@@ -11608,6 +11843,15 @@ class Database extends events.EventEmitter {
11608
11843
  if (!this._indexRebuildNeeded) return;
11609
11844
  if (!this.indexManager || !this.indexManager.indexedFields || this.indexManager.indexedFields.length === 0) return;
11610
11845
 
11846
+ // If the index was idle-unloaded (or never loaded), recover it cheaply from
11847
+ // the on-disk .idx file BEFORE considering a full rebuild. A rebuild is only
11848
+ // needed when the on-disk index is genuinely missing/corrupt. Mirrors the
11849
+ // score() hot path and prevents an idle-unload from triggering a full-file
11850
+ // rebuild (or a throw when allowIndexRebuild is false).
11851
+ if (!this.indexManager.indexLoaded) {
11852
+ await this._ensureLazyIndexLoaded();
11853
+ }
11854
+
11611
11855
  // Check if index actually needs rebuilding
11612
11856
  let needsRebuild = false;
11613
11857
  for (const field of this.indexManager.indexedFields) {
@@ -11621,6 +11865,13 @@ class Database extends events.EventEmitter {
11621
11865
  return;
11622
11866
  }
11623
11867
 
11868
+ // Read-only mode: never write/rebuild on disk. Let queries fall back to a
11869
+ // full streaming scan instead of throwing or rewriting the index file.
11870
+ if (this.opts.readOnly) {
11871
+ this._indexRebuildNeeded = false;
11872
+ return;
11873
+ }
11874
+
11624
11875
  // Check if rebuild is allowed
11625
11876
  if (!this.opts.allowIndexRebuild) {
11626
11877
  const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb');
@@ -11688,9 +11939,13 @@ class Database extends events.EventEmitter {
11688
11939
  crlfDelay: Infinity
11689
11940
  });
11690
11941
 
11691
- // Handle abort
11942
+ // Handle abort. Destroy the stream WITHOUT an error payload, then
11943
+ // close readline. Destroying with `new Error('AbortError')` makes
11944
+ // the stream emit an unhandled 'error' (readline is closed first, so
11945
+ // the for-await loop exits cleanly and never observes it), which
11946
+ // Node escalates to an uncaughtException and crashes the host app.
11692
11947
  controller.signal.addEventListener('abort', () => {
11693
- stream.destroy(new Error('AbortError'));
11948
+ stream.destroy();
11694
11949
  rl.close();
11695
11950
  });
11696
11951
  var _iteratorAbruptCompletion = false;
@@ -11738,6 +11993,10 @@ class Database extends events.EventEmitter {
11738
11993
  }
11739
11994
  }
11740
11995
  }
11996
+
11997
+ // If aborted during schema detection, surface a normalized timeout
11998
+ // error so the retry wrapper (and ultimately the caller) observes it
11999
+ // instead of an uncaughtException.
11741
12000
  } catch (err) {
11742
12001
  _didIteratorError = true;
11743
12002
  _iteratorError = err;
@@ -11752,6 +12011,9 @@ class Database extends events.EventEmitter {
11752
12011
  }
11753
12012
  }
11754
12013
  }
12014
+ if (controller.signal.aborted) {
12015
+ throw createIoTimeoutError(timeoutMs);
12016
+ }
11755
12017
  stream.destroy();
11756
12018
  }
11757
12019
 
@@ -11769,9 +12031,10 @@ class Database extends events.EventEmitter {
11769
12031
  crlfDelay: Infinity
11770
12032
  });
11771
12033
 
11772
- // Handle abort
12034
+ // Handle abort. See the schema stream above - destroy WITHOUT an error
12035
+ // payload to avoid an unhandled 'error' becoming an uncaughtException.
11773
12036
  controller.signal.addEventListener('abort', () => {
11774
- stream.destroy(new Error('AbortError'));
12037
+ stream.destroy();
11775
12038
  rl.close();
11776
12039
  });
11777
12040
  let localCount = 0;
@@ -11802,6 +12065,9 @@ class Database extends events.EventEmitter {
11802
12065
  currentOffset += Buffer.byteLength(line, 'utf8') + 1;
11803
12066
  }
11804
12067
  }
12068
+
12069
+ // If the read was aborted, do NOT treat a partial read as a successful
12070
+ // rebuild - surface a normalized timeout error so it can be retried.
11805
12071
  } catch (err) {
11806
12072
  _didIteratorError2 = true;
11807
12073
  _iteratorError2 = err;
@@ -11816,10 +12082,17 @@ class Database extends events.EventEmitter {
11816
12082
  }
11817
12083
  }
11818
12084
  }
12085
+ if (controller.signal.aborted) {
12086
+ throw createIoTimeoutError(timeoutMs);
12087
+ }
11819
12088
  count += localCount;
11820
12089
  stream.destroy();
11821
12090
  } catch (error) {
11822
- 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)) {
11823
12096
  if (this.opts.debugMode) {
11824
12097
  console.log(`⚠️ Index rebuild attempt ${attempt} timed out, retrying...`);
11825
12098
  }
@@ -12550,6 +12823,11 @@ class Database extends events.EventEmitter {
12550
12823
  await this._ensureLazyIndexLoaded();
12551
12824
  }
12552
12825
 
12826
+ // Swap detection: if another process atomically replaced the data file,
12827
+ // reload offsets + index (and term mapping) cleanly so the scores below are
12828
+ // computed against the current file, never stale byte ranges.
12829
+ await this._refreshFromDiskIfChanged();
12830
+
12553
12831
  // Access the index for this field
12554
12832
  const fieldIndex = this.indexManager.index.data[fieldName];
12555
12833
  if (!fieldIndex || Object.keys(fieldIndex).length === 0) {
@@ -12781,6 +13059,9 @@ class Database extends events.EventEmitter {
12781
13059
  }
12782
13060
  }
12783
13061
  }
13062
+
13063
+ // If the read was aborted, do not silently return partial scores -
13064
+ // surface a normalized timeout error so it can be retried.
12784
13065
  } catch (err) {
12785
13066
  _didIteratorError3 = true;
12786
13067
  _iteratorError3 = err;
@@ -12795,9 +13076,16 @@ class Database extends events.EventEmitter {
12795
13076
  }
12796
13077
  }
12797
13078
  }
13079
+ if (controller.signal.aborted) {
13080
+ throw createIoTimeoutError(timeoutMs);
13081
+ }
12798
13082
  results.push(...rangeResults);
12799
13083
  } catch (error) {
12800
- 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)) {
12801
13089
  if (this.opts.debugMode) {
12802
13090
  console.log(`⚠️ Score range read attempt ${attempt} timed out, retrying...`);
12803
13091
  }
@@ -13913,10 +14201,27 @@ class Database extends events.EventEmitter {
13913
14201
  this.indexManager.indexLoaded = true;
13914
14202
  return;
13915
14203
  }
13916
- if (this._lazyIndexLoadPromise) {
13917
- 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
+ });
13918
14212
  }
13919
- 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 {
13920
14225
  const indexData = await this._loadIndexDataFromFile();
13921
14226
  if (indexData) {
13922
14227
  this.indexManager.load(indexData);
@@ -13927,9 +14232,153 @@ class Database extends events.EventEmitter {
13927
14232
  this.indexManager.indexLoaded = true;
13928
14233
  this.indexManager.markIndexUsed();
13929
14234
  }
13930
- 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
+ }
13931
14380
  })();
13932
- return this._lazyIndexLoadPromise;
14381
+ return this._offsetsReloadPromise;
13933
14382
  }
13934
14383
 
13935
14384
  /**
@@ -14267,6 +14716,10 @@ class Database extends events.EventEmitter {
14267
14716
  yield _awaitAsyncGenerator(new Promise(resolve => setTimeout(resolve, 10)));
14268
14717
  }
14269
14718
  }
14719
+
14720
+ // Swap detection: if another process replaced the data file, reload offsets
14721
+ // and index cleanly so the line map / ranges describe the current file.
14722
+ yield _awaitAsyncGenerator(_this2._refreshFromDiskIfChanged());
14270
14723
  let count = 0;
14271
14724
  let remainingSkip = options.skip || 0;
14272
14725
  let map;
@@ -15003,6 +15456,8 @@ class Database extends events.EventEmitter {
15003
15456
  * @private
15004
15457
  */
15005
15458
  async _saveIndexDataToFile() {
15459
+ // Read-only mode: never write to the index file on disk
15460
+ if (this.opts.readOnly) return;
15006
15461
  if (this.indexManager) {
15007
15462
  try {
15008
15463
  const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb');
@@ -15069,7 +15524,7 @@ class Database extends events.EventEmitter {
15069
15524
  // This ensures the database structure is complete
15070
15525
  const originalFile = this.fileHandler.file;
15071
15526
  this.fileHandler.file = idxPath;
15072
- await this.fileHandler.writeAll(JSON.stringify(indexData, null, 2));
15527
+ await this.fileHandler.writeFileAtomic(JSON.stringify(indexData, null, 2));
15073
15528
  this.fileHandler.file = originalFile;
15074
15529
  if (this.opts.debugMode) {
15075
15530
  console.log(`💾 Index data saved to ${idxPath}`);