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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jexidb",
3
- "version": "2.2.3",
3
+ "version": "2.2.5",
4
4
  "type": "module",
5
5
  "description": "JexiDB is a pure JS NPM library for managing data on disk efficiently, without the need for a server.",
6
6
  "main": "./dist/Database.cjs",
package/src/Database.mjs CHANGED
@@ -6,6 +6,7 @@ import fs from 'fs'
6
6
  import readline from 'readline'
7
7
  import pRetry from 'p-retry'
8
8
  import { OperationQueue } from './OperationQueue.mjs'
9
+ import { createIoTimeoutError, isIoTimeoutError } from './utils/ioTimeout.mjs'
9
10
 
10
11
  /**
11
12
  * IterateEntry class for intuitive API with automatic change detection
@@ -359,6 +360,9 @@ class Database extends EventEmitter {
359
360
  enableArraySerialization: opts.enableArraySerialization !== false, // Enable array serialization by default
360
361
  // Index rebuild options
361
362
  allowIndexRebuild: opts.allowIndexRebuild === true, // Allow automatic index rebuild when corrupted (default false - throws error)
363
+ // Read-only mode: never create, write, rebuild or auto-flush. Use it to
364
+ // open a DB purely for reads (e.g. while another process is the writer).
365
+ readOnly: opts.readOnly === true,
362
366
  }, opts)
363
367
 
364
368
  // CRITICAL FIX: Initialize AbortController for lifecycle management
@@ -387,6 +391,14 @@ class Database extends EventEmitter {
387
391
  this.initialized = false
388
392
  this._offsetRecoveryInProgress = false
389
393
  this.writeBufferTotalSize = 0
394
+
395
+ // Swap detection state: the data file may be atomically replaced by another
396
+ // process while this instance holds offsets/index computed for the previous
397
+ // file. _offsetsFileSignature remembers which on-disk file the offsets
398
+ // describe (size + mtime) so the read path can detect a swap cheaply.
399
+ this._offsetsFileSignature = null
400
+ this._dataFingerprint = null // cached stat (short TTL) for the read path
401
+ this._offsetsReloadPromise = null // single-flight clean reload after a swap
390
402
 
391
403
 
392
404
  // Initialize managers
@@ -779,8 +791,8 @@ class Database extends EventEmitter {
779
791
  const fileExists = await this.fileHandler.exists()
780
792
 
781
793
  if (!fileExists) {
782
- if (!this.opts.create) {
783
- throw new Error(`Database file '${this.normalizedFile}' does not exist and create option is disabled`)
794
+ if (!this.opts.create || this.opts.readOnly) {
795
+ throw new Error(`Database file '${this.normalizedFile}' does not exist and ${this.opts.readOnly ? 'readOnly mode is enabled' : 'create option is disabled'}`)
784
796
  }
785
797
  // File will be created when first data is written
786
798
  } else {
@@ -975,6 +987,10 @@ class Database extends EventEmitter {
975
987
  }
976
988
  }
977
989
  }
990
+
991
+ // Remember which on-disk data file these offsets describe, so the
992
+ // read path can detect an atomic swap performed by another process.
993
+ this._offsetsFileSignature = await this._statDataFingerprint()
978
994
 
979
995
  // Load indexOffset for proper range calculations
980
996
  if (parsedIdxData.indexOffset !== undefined) {
@@ -1306,6 +1322,14 @@ class Database extends EventEmitter {
1306
1322
  async _doSave() {
1307
1323
  // CRITICAL FIX: Check if database is destroyed
1308
1324
  if (this.destroyed) return
1325
+
1326
+ // Read-only mode: never persist data to disk
1327
+ if (this.opts.readOnly) {
1328
+ if (this.opts.debugMode) {
1329
+ console.log('💾 _doSave: Database is read-only, skipping save')
1330
+ }
1331
+ return
1332
+ }
1309
1333
 
1310
1334
  // CRITICAL FIX: Use atomic check-and-set to prevent concurrent save operations
1311
1335
  if (this.isSaving) {
@@ -1690,9 +1714,10 @@ class Database extends EventEmitter {
1690
1714
  console.log(`💾 Save: Calculated indexOffset: ${this.indexOffset}, allData.length: ${allData.length}`)
1691
1715
  }
1692
1716
 
1693
- // CRITICAL FIX: Write main data file first
1717
+ // CRITICAL FIX: Write main data file first (atomically: temp + rename so a
1718
+ // concurrent reader never sees a half-written file).
1694
1719
  // Index will be saved AFTER reconstruction to ensure it contains correct data
1695
- await this.fileHandler.writeBatch([jsonlData])
1720
+ await this.fileHandler.replaceDataFile(jsonlData)
1696
1721
 
1697
1722
  if (this.opts.debugMode) {
1698
1723
  console.log(`💾 Saved ${allData.length} records to ${this.normalizedFile}`)
@@ -1700,6 +1725,10 @@ class Database extends EventEmitter {
1700
1725
 
1701
1726
  // CRITICAL FIX: Invalidate file size cache after save operation
1702
1727
  this._cachedFileStats = null
1728
+
1729
+ // The in-memory offsets now describe the file we just wrote. Refresh the
1730
+ // swap-detection signature so the next indexed read does not reload.
1731
+ this._offsetsFileSignature = await this._statDataFingerprint()
1703
1732
 
1704
1733
  this.shouldSave = false
1705
1734
  this.lastSaveTime = Date.now()
@@ -2120,6 +2149,10 @@ class Database extends EventEmitter {
2120
2149
  */
2121
2150
  async insert(data) {
2122
2151
  this._validateInitialization('insert')
2152
+
2153
+ if (this.opts.readOnly) {
2154
+ throw new Error('Database is read-only')
2155
+ }
2123
2156
 
2124
2157
  return this.operationQueue.enqueue(async () => {
2125
2158
  this.isInsideOperationQueue = true
@@ -2210,6 +2243,10 @@ class Database extends EventEmitter {
2210
2243
  */
2211
2244
  async insertBatch(dataArray) {
2212
2245
  this._validateInitialization('insertBatch')
2246
+
2247
+ if (this.opts.readOnly) {
2248
+ throw new Error('Database is read-only')
2249
+ }
2213
2250
 
2214
2251
  // If we're already inside the operation queue (e.g., from insert()), avoid re-enqueueing to prevent deadlocks
2215
2252
  if (this.isInsideOperationQueue) {
@@ -2236,6 +2273,11 @@ class Database extends EventEmitter {
2236
2273
  * Internal implementation for insertBatch to allow inline execution when already inside the queue
2237
2274
  */
2238
2275
  async _insertBatchInternal(dataArray) {
2276
+ // Read-only mode: never write to disk
2277
+ if (this.opts.readOnly) {
2278
+ throw new Error('Database is read-only')
2279
+ }
2280
+
2239
2281
  // CRITICAL FIX: Validate state before insert operation
2240
2282
  this.validateState()
2241
2283
 
@@ -2374,15 +2416,12 @@ class Database extends EventEmitter {
2374
2416
  console.warn(`⚠️ Frequent integrity corrections (${this.integrityCorrections.dataIntegrity} times) - this indicates a systemic issue`)
2375
2417
  }
2376
2418
 
2419
+ // Reconcile the in-memory totalLines with the actual persisted offset
2420
+ // count. NOTE: deliberately NOT persisted here - find() is a read path
2421
+ // and must never rewrite the .idx file (especially while a concurrent
2422
+ // writer may be replacing it). A later save() persists a fully
2423
+ // consistent .idx anyway.
2377
2424
  this.indexManager.setTotalLines(offsetsLength)
2378
-
2379
- // Try to persist the fix, but don't fail the operation if it doesn't work
2380
- try {
2381
- await this._saveIndexDataToFile()
2382
- } catch (error) {
2383
- // Just track the failure - don't throw since this is a safety net
2384
- this.integrityCorrections.indexSaveFailures++
2385
- }
2386
2425
  }
2387
2426
  }
2388
2427
 
@@ -3070,7 +3109,16 @@ class Database extends EventEmitter {
3070
3109
  }
3071
3110
  if (!this._indexRebuildNeeded) return
3072
3111
  if (!this.indexManager || !this.indexManager.indexedFields || this.indexManager.indexedFields.length === 0) return
3073
-
3112
+
3113
+ // If the index was idle-unloaded (or never loaded), recover it cheaply from
3114
+ // the on-disk .idx file BEFORE considering a full rebuild. A rebuild is only
3115
+ // needed when the on-disk index is genuinely missing/corrupt. Mirrors the
3116
+ // score() hot path and prevents an idle-unload from triggering a full-file
3117
+ // rebuild (or a throw when allowIndexRebuild is false).
3118
+ if (!this.indexManager.indexLoaded) {
3119
+ await this._ensureLazyIndexLoaded()
3120
+ }
3121
+
3074
3122
  // Check if index actually needs rebuilding
3075
3123
  let needsRebuild = false
3076
3124
  for (const field of this.indexManager.indexedFields) {
@@ -3084,7 +3132,14 @@ class Database extends EventEmitter {
3084
3132
  this._indexRebuildNeeded = false
3085
3133
  return
3086
3134
  }
3087
-
3135
+
3136
+ // Read-only mode: never write/rebuild on disk. Let queries fall back to a
3137
+ // full streaming scan instead of throwing or rewriting the index file.
3138
+ if (this.opts.readOnly) {
3139
+ this._indexRebuildNeeded = false
3140
+ return
3141
+ }
3142
+
3088
3143
  // Check if rebuild is allowed
3089
3144
  if (!this.opts.allowIndexRebuild) {
3090
3145
  const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb')
@@ -3165,9 +3220,13 @@ class Database extends EventEmitter {
3165
3220
  crlfDelay: Infinity
3166
3221
  })
3167
3222
 
3168
- // Handle abort
3223
+ // Handle abort. Destroy the stream WITHOUT an error payload, then
3224
+ // close readline. Destroying with `new Error('AbortError')` makes
3225
+ // the stream emit an unhandled 'error' (readline is closed first, so
3226
+ // the for-await loop exits cleanly and never observes it), which
3227
+ // Node escalates to an uncaughtException and crashes the host app.
3169
3228
  controller.signal.addEventListener('abort', () => {
3170
- stream.destroy(new Error('AbortError'));
3229
+ stream.destroy();
3171
3230
  rl.close();
3172
3231
  });
3173
3232
 
@@ -3209,6 +3268,13 @@ class Database extends EventEmitter {
3209
3268
  }
3210
3269
  }
3211
3270
  }
3271
+
3272
+ // If aborted during schema detection, surface a normalized timeout
3273
+ // error so the retry wrapper (and ultimately the caller) observes it
3274
+ // instead of an uncaughtException.
3275
+ if (controller.signal.aborted) {
3276
+ throw createIoTimeoutError(timeoutMs)
3277
+ }
3212
3278
  stream.destroy()
3213
3279
  }
3214
3280
 
@@ -3228,9 +3294,10 @@ class Database extends EventEmitter {
3228
3294
  crlfDelay: Infinity
3229
3295
  })
3230
3296
 
3231
- // Handle abort
3297
+ // Handle abort. See the schema stream above - destroy WITHOUT an error
3298
+ // payload to avoid an unhandled 'error' becoming an uncaughtException.
3232
3299
  controller.signal.addEventListener('abort', () => {
3233
- stream.destroy(new Error('AbortError'));
3300
+ stream.destroy();
3234
3301
  rl.close();
3235
3302
  });
3236
3303
 
@@ -3257,10 +3324,20 @@ class Database extends EventEmitter {
3257
3324
  currentOffset += Buffer.byteLength(line, 'utf8') + 1
3258
3325
  }
3259
3326
 
3327
+ // If the read was aborted, do NOT treat a partial read as a successful
3328
+ // rebuild - surface a normalized timeout error so it can be retried.
3329
+ if (controller.signal.aborted) {
3330
+ throw createIoTimeoutError(timeoutMs)
3331
+ }
3332
+
3260
3333
  count += localCount;
3261
3334
  stream.destroy()
3262
3335
  } catch (error) {
3263
- if (error.name === 'AbortError' || error.code === 'ETIMEDOUT') {
3336
+ // p-retry "stop" signal (may be absent when p-retry is mocked in tests)
3337
+ if (typeof pRetry.AbortError === 'function' && error instanceof pRetry.AbortError) {
3338
+ throw error
3339
+ }
3340
+ if (isIoTimeoutError(error)) {
3264
3341
  if (this.opts.debugMode) {
3265
3342
  console.log(`⚠️ Index rebuild attempt ${attempt} timed out, retrying...`);
3266
3343
  }
@@ -4063,7 +4140,12 @@ class Database extends EventEmitter {
4063
4140
  if (!this.indexManager.indexLoaded) {
4064
4141
  await this._ensureLazyIndexLoaded()
4065
4142
  }
4066
-
4143
+
4144
+ // Swap detection: if another process atomically replaced the data file,
4145
+ // reload offsets + index (and term mapping) cleanly so the scores below are
4146
+ // computed against the current file, never stale byte ranges.
4147
+ await this._refreshFromDiskIfChanged()
4148
+
4067
4149
  // Access the index for this field
4068
4150
  const fieldIndex = this.indexManager.index.data[fieldName]
4069
4151
  if (!fieldIndex || Object.keys(fieldIndex).length === 0) {
@@ -4305,9 +4387,19 @@ class Database extends EventEmitter {
4305
4387
  }
4306
4388
  }
4307
4389
 
4390
+ // If the read was aborted, do not silently return partial scores -
4391
+ // surface a normalized timeout error so it can be retried.
4392
+ if (controller.signal.aborted) {
4393
+ throw createIoTimeoutError(timeoutMs)
4394
+ }
4395
+
4308
4396
  results.push(...rangeResults);
4309
4397
  } catch (error) {
4310
- if (error.name === 'AbortError' || error.code === 'ETIMEDOUT') {
4398
+ // p-retry "stop" signal (may be absent when p-retry is mocked in tests)
4399
+ if (typeof pRetry.AbortError === 'function' && error instanceof pRetry.AbortError) {
4400
+ throw error
4401
+ }
4402
+ if (isIoTimeoutError(error)) {
4311
4403
  if (this.opts.debugMode) {
4312
4404
  console.log(`⚠️ Score range read attempt ${attempt} timed out, retrying...`);
4313
4405
  }
@@ -5391,11 +5483,26 @@ class Database extends EventEmitter {
5391
5483
  return
5392
5484
  }
5393
5485
 
5394
- if (this._lazyIndexLoadPromise) {
5395
- return this._lazyIndexLoadPromise
5486
+ // Single-flight: N concurrent callers (find/count/score) share a single
5487
+ // on-disk load. The promise is cleared in a finally so a rejected load is
5488
+ // never left behind to poison every future attempt.
5489
+ if (!this._lazyIndexLoadPromise) {
5490
+ this._lazyIndexLoadPromise = this._doLazyIndexLoad().finally(() => {
5491
+ this._lazyIndexLoadPromise = null
5492
+ })
5396
5493
  }
5494
+ return this._lazyIndexLoadPromise
5495
+ }
5397
5496
 
5398
- this._lazyIndexLoadPromise = (async () => {
5497
+ /**
5498
+ * Actually perform the lazy index load from the on-disk .idx file.
5499
+ * Never rejects: on failure it degrades to an empty in-memory index (queries
5500
+ * fall back to streaming) instead of poisoning the single-flight or surfacing
5501
+ * a background cold-load as an unhandled rejection.
5502
+ * @private
5503
+ */
5504
+ async _doLazyIndexLoad() {
5505
+ try {
5399
5506
  const indexData = await this._loadIndexDataFromFile()
5400
5507
  if (indexData) {
5401
5508
  this.indexManager.load(indexData)
@@ -5406,11 +5513,143 @@ class Database extends EventEmitter {
5406
5513
  this.indexManager.indexLoaded = true
5407
5514
  this.indexManager.markIndexUsed()
5408
5515
  }
5516
+ } catch (error) {
5517
+ if (this.opts.debugMode) {
5518
+ console.error(`⚠️ _ensureLazyIndexLoaded: Lazy index load failed, continuing with empty index: ${error && error.message}`)
5519
+ }
5520
+ // Never leave the index flagged as not-loaded: mark it loaded (empty) so
5521
+ // later calls short-circuit and queries degrade to a full scan.
5522
+ this.indexManager.indexLoaded = true
5523
+ }
5524
+ }
5409
5525
 
5410
- this._lazyIndexLoadPromise = null
5411
- })()
5526
+ /**
5527
+ * Path of the "writer active" sentinel file for this data file, e.g.
5528
+ * `list.jdb` -> `list.updating.jdb`. While this file exists, a writer is
5529
+ * atomically replacing the database and readers must not refresh/repair it.
5530
+ * @returns {string}
5531
+ */
5532
+ _getUpdatingSentinelPath() {
5533
+ return this.normalizedFile.replace(/\.jdb$/i, '.updating.jdb')
5534
+ }
5412
5535
 
5413
- return this._lazyIndexLoadPromise
5536
+ /**
5537
+ * Whether a writer sentinel is currently present on disk.
5538
+ * @returns {Promise<boolean>}
5539
+ */
5540
+ async hasUpdatingSentinel() {
5541
+ try {
5542
+ await fs.promises.access(this._getUpdatingSentinelPath())
5543
+ return true
5544
+ } catch (error) {
5545
+ return false
5546
+ }
5547
+ }
5548
+
5549
+ /**
5550
+ * Capture the current data-file fingerprint (size + mtime) for swap
5551
+ * detection. Best-effort: returns null when the file cannot be stat'ed.
5552
+ * @returns {Promise<{size: number, mtimeMs: number}|null>}
5553
+ */
5554
+ async _statDataFingerprint() {
5555
+ try {
5556
+ const st = await fs.promises.stat(this.normalizedFile)
5557
+ return { size: st.size, mtimeMs: st.mtimeMs }
5558
+ } catch (error) {
5559
+ return null
5560
+ }
5561
+ }
5562
+
5563
+ /**
5564
+ * Cheap swap detection for the read path. The data file may be atomically
5565
+ * replaced by a writer (same or another process); the in-memory offsets and
5566
+ * index describe the previous file. When the on-disk size/mtime no longer
5567
+ * match the signature captured when the offsets were loaded, reload offsets
5568
+ * and index cleanly from the current .idx.
5569
+ *
5570
+ * Returns:
5571
+ * - 'ok' : nothing changed / refresh not applicable
5572
+ * - 'changed' : file was swapped and offsets + index were cleanly reloaded
5573
+ * - 'busy' : a writer sentinel is present (or the clean reload failed) -
5574
+ * the caller must fall back to a full streaming scan instead
5575
+ * of using possibly stale byte offsets on the new file
5576
+ * @returns {Promise<'ok'|'changed'|'busy'>}
5577
+ */
5578
+ async _refreshFromDiskIfChanged() {
5579
+ // If a writer is mid-update, never refresh/repair in place.
5580
+ if (this.opts.updatingSentinel && await this.hasUpdatingSentinel()) {
5581
+ return 'busy'
5582
+ }
5583
+
5584
+ if (!this._offsetsFileSignature) return 'ok'
5585
+ if (!this.offsets || this.offsets.length === 0) return 'ok'
5586
+
5587
+ // Cheap stat with a short TTL so hot queries don't syscall every time.
5588
+ const now = Date.now()
5589
+ if (!this._dataFingerprint || !this._dataFingerprint.at || now - this._dataFingerprint.at > 250) {
5590
+ const st = await this._statDataFingerprint()
5591
+ this._dataFingerprint = { ...(st || { size: 0, mtimeMs: 0 }), at: now }
5592
+ }
5593
+ const fp = this._dataFingerprint
5594
+ const sig = this._offsetsFileSignature
5595
+ if (fp.size === sig.size && fp.mtimeMs === sig.mtimeMs) return 'ok'
5596
+
5597
+ const reloaded = await this._reloadOffsetsAndIndex()
5598
+ if (!reloaded) return 'busy'
5599
+ // Signature now describes the (already stat'ed) new file
5600
+ this._offsetsFileSignature = { size: fp.size, mtimeMs: fp.mtimeMs }
5601
+ return 'changed'
5602
+ }
5603
+
5604
+ /**
5605
+ * Cleanly reload offsets/indexOffset/index data from the on-disk .idx file
5606
+ * after the data file was swapped. Single-flight so N concurrent queries
5607
+ * share one reload.
5608
+ * @returns {Promise<boolean>} true when the reload produced consistent state
5609
+ */
5610
+ async _reloadOffsetsAndIndex() {
5611
+ if (this._offsetsReloadPromise) {
5612
+ return this._offsetsReloadPromise
5613
+ }
5614
+ this._offsetsReloadPromise = (async () => {
5615
+ const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb')
5616
+ try {
5617
+ const raw = await fs.promises.readFile(idxPath, 'utf8')
5618
+ const parsed = JSON.parse(raw)
5619
+ if (!parsed || !Array.isArray(parsed.offsets)) return false
5620
+ this.offsets = parsed.offsets
5621
+ this.indexOffset = typeof parsed.indexOffset === 'number' ? parsed.indexOffset : 0
5622
+
5623
+ // Reload term mapping (if any) first so term IDs/words match the new
5624
+ // index and the records it describes.
5625
+ if (parsed.termMapping && this.termManager && typeof this.termManager.loadTerms === 'function') {
5626
+ this.termManager.loadTerms(parsed.termMapping)
5627
+ }
5628
+
5629
+ // Clear the current in-memory index so IndexManager.load() does not
5630
+ // short-circuit (load() skips when it already sees actual data).
5631
+ if (this.indexManager && this.indexManager.indexedFields) {
5632
+ for (const field of this.indexManager.indexedFields) {
5633
+ if (this.indexManager.index && this.indexManager.index.data) {
5634
+ this.indexManager.index.data[field] = {}
5635
+ }
5636
+ }
5637
+ }
5638
+ // Reload the in-memory index from the same (new) .idx data
5639
+ this.indexManager.indexLoaded = false
5640
+ await this._ensureLazyIndexLoaded()
5641
+ this.indexManager.setTotalLines(this.offsets.length)
5642
+ return true
5643
+ } catch (error) {
5644
+ if (this.opts.debugMode) {
5645
+ console.warn(`⚠️ _reloadOffsetsAndIndex: could not reload from ${idxPath}: ${error && error.message}`)
5646
+ }
5647
+ return false
5648
+ } finally {
5649
+ this._offsetsReloadPromise = null
5650
+ }
5651
+ })()
5652
+ return this._offsetsReloadPromise
5414
5653
  }
5415
5654
 
5416
5655
  /**
@@ -5775,6 +6014,10 @@ class Database extends EventEmitter {
5775
6014
  await new Promise(resolve => setTimeout(resolve, 10))
5776
6015
  }
5777
6016
  }
6017
+
6018
+ // Swap detection: if another process replaced the data file, reload offsets
6019
+ // and index cleanly so the line map / ranges describe the current file.
6020
+ await this._refreshFromDiskIfChanged()
5778
6021
 
5779
6022
  let count = 0
5780
6023
  let remainingSkip = options.skip || 0
@@ -6415,6 +6658,9 @@ class Database extends EventEmitter {
6415
6658
  * @private
6416
6659
  */
6417
6660
  async _saveIndexDataToFile() {
6661
+ // Read-only mode: never write to the index file on disk
6662
+ if (this.opts.readOnly) return
6663
+
6418
6664
  if (this.indexManager) {
6419
6665
  try {
6420
6666
  const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb')
@@ -6483,7 +6729,7 @@ class Database extends EventEmitter {
6483
6729
  // This ensures the database structure is complete
6484
6730
  const originalFile = this.fileHandler.file
6485
6731
  this.fileHandler.file = idxPath
6486
- await this.fileHandler.writeAll(JSON.stringify(indexData, null, 2))
6732
+ await this.fileHandler.writeFileAtomic(JSON.stringify(indexData, null, 2))
6487
6733
  this.fileHandler.file = originalFile
6488
6734
 
6489
6735
  if (this.opts.debugMode) {
@@ -3,6 +3,7 @@ import path from 'path'
3
3
  import readline from 'readline'
4
4
  import pLimit from 'p-limit'
5
5
  import pRetry from 'p-retry'
6
+ import { isIoTimeoutError } from './utils/ioTimeout.mjs'
6
7
 
7
8
  export default class FileHandler {
8
9
  constructor(file, fileMutex = null, opts = {}) {
@@ -707,6 +708,23 @@ export default class FileHandler {
707
708
  await fs.promises.rename(tmpFile, targetFile);
708
709
  return; // Success
709
710
  } catch (error) {
711
+ // ENOENT: the temp file vanished before the rename. With unique temp names
712
+ // (see _atomicReplaceFile) this means another concurrent writer already
713
+ // replaced the target (its rename won, ours was superseded) or an external
714
+ // cleanup removed our temp. If the target exists, the write was superseded,
715
+ // not an error. (CHANGELOG [2.2.5])
716
+ if (error.code === 'ENOENT') {
717
+ try {
718
+ await fs.promises.access(targetFile)
719
+ return // target already replaced by a concurrent writer - ok
720
+ } catch (accessError) {
721
+ if (attempt < maxRetries) {
722
+ await new Promise(resolve => setTimeout(resolve, 50 * attempt))
723
+ continue
724
+ }
725
+ throw error
726
+ }
727
+ }
710
728
  if (error.code === 'EPERM' && attempt < maxRetries) {
711
729
  // Quick delay: 50ms, 100ms, 200ms
712
730
  const delay = 50 * attempt;
@@ -786,6 +804,71 @@ export default class FileHandler {
786
804
  }
787
805
  }
788
806
 
807
+ /**
808
+ * Atomically replace this file's content: write to a sibling temp file, then
809
+ * rename over the target (with a Windows copy fallback on EPERM). Concurrent
810
+ * readers never observe a half-written file.
811
+ * @param {Buffer|string} data - Full content of the new file.
812
+ */
813
+ async _atomicReplaceFile(data) {
814
+ // Use a UNIQUE temp name per write. A fixed "<file>.tmp" collides when two
815
+ // Database instances write the same file concurrently: each removes the
816
+ // other's temp before its rename, causing ENOENT. (CHANGELOG [2.2.5])
817
+ const tmpFile = `${this.file}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`
818
+ const release = this.fileMutex ? await this.fileMutex.acquire() : () => {}
819
+ try {
820
+ await fs.promises.writeFile(tmpFile, data)
821
+
822
+ // Windows: brief delay helps readers release the target handle before the
823
+ // rename (EPERM mitigation, mirroring _writeFileWithRetry).
824
+ if (process.platform === 'win32') {
825
+ await new Promise(resolve => setTimeout(resolve, 10))
826
+ }
827
+
828
+ await this._safeRename(tmpFile, this.file)
829
+ } catch (error) {
830
+ // Best-effort cleanup of our own temp on failure.
831
+ await fs.promises.unlink(tmpFile).catch(() => {})
832
+ throw error
833
+ } finally {
834
+ release()
835
+ }
836
+ }
837
+
838
+ /**
839
+ * Atomically replace the whole data file with the given content.
840
+ *
841
+ * When `opts.updatingSentinel` is enabled, a `<file>.updating.jdb` sentinel is
842
+ * created for the duration of the swap so cross-process readers can detect an
843
+ * active writer and avoid touching the file mid-update.
844
+ *
845
+ * @param {Buffer|string} data - Full content of the new data file.
846
+ */
847
+ async replaceDataFile(data) {
848
+ const sentinelFile = this.file.replace(/\.jdb$/i, '.updating.jdb')
849
+ const useSentinel = !!(this.opts && this.opts.updatingSentinel)
850
+
851
+ let releaseSentinel = null
852
+ if (useSentinel) {
853
+ // Signal "writer active" before touching the target
854
+ await fs.promises.writeFile(sentinelFile, String(Date.now()), 'utf8').catch(() => {})
855
+ releaseSentinel = () => fs.promises.unlink(sentinelFile).catch(() => {})
856
+ }
857
+ try {
858
+ await this._atomicReplaceFile(data)
859
+ } finally {
860
+ if (releaseSentinel) await releaseSentinel()
861
+ }
862
+ }
863
+
864
+ /**
865
+ * Atomically replace this file's content (used for the .idx.jdb index file).
866
+ * @param {Buffer|string} data - Full content of the new file.
867
+ */
868
+ async writeFileAtomic(data) {
869
+ await this._atomicReplaceFile(data)
870
+ }
871
+
789
872
  async writeData(data, immediate, fd) {
790
873
  await fd.write(data)
791
874
  }
@@ -898,7 +981,11 @@ export default class FileHandler {
898
981
  const results = await this._readWithStreamingInternal(criteria, options, matchesCriteria, serializer, controller.signal);
899
982
  return results;
900
983
  } catch (error) {
901
- if (error.name === 'AbortError' || error.code === 'ETIMEDOUT') {
984
+ // p-retry "stop" signal (may be absent when p-retry is mocked in tests)
985
+ if (typeof pRetry.AbortError === 'function' && error instanceof pRetry.AbortError) {
986
+ throw error
987
+ }
988
+ if (isIoTimeoutError(error)) {
902
989
  if (this.opts.debugMode) {
903
990
  console.log(`⚠️ Streaming read attempt ${attempt} timed out, retrying...`);
904
991
  }
@@ -1074,18 +1074,44 @@ export default class IndexManager {
1074
1074
  if (typeof data[field] === 'undefined') continue;
1075
1075
 
1076
1076
  const originalCriteriaValue = criteria[field];
1077
- const criteriaValue = normalizeCriteriaOperators(originalCriteriaValue, { target: 'legacy', preserveOriginal: true });
1077
+ // INTENTIONAL (CHANGELOG [2.2.5]): RegExp is not an operator map - never normalize it
1078
+ // (spreading a RegExp yields {} and would make the regex match every record).
1079
+ const criteriaValue = originalCriteriaValue instanceof RegExp
1080
+ ? originalCriteriaValue
1081
+ : normalizeCriteriaOperators(originalCriteriaValue, { target: 'legacy', preserveOriginal: true });
1078
1082
  let lineNumbersForField = new Set();
1079
1083
  const isNumericField = this.opts.indexes[field] === 'number';
1080
1084
 
1081
- // Handle RegExp values directly (MUST check before object check since RegExp is an object)
1085
+ // INTENTIONAL OPTIMIZATION (see CHANGELOG [2.2.5]): raw RegExp on string/term
1086
+ // indexes is answered via the index keys instead of full-file streaming. For
1087
+ // term-mapped fields the keys are numeric term IDs, translated back to words with
1088
+ // termManager.getTerm() before testing the regex. DO NOT change this back to
1089
+ // "RegExp => streaming" - it regresses partial search to a full scan per database.
1082
1090
  if (criteriaValue instanceof RegExp) {
1083
- // RegExp cannot be efficiently queried using indices - fall back to streaming
1084
- // This will be handled by the QueryManager's streaming strategy
1085
- continue;
1091
+ const fieldIndex = data[field];
1092
+ const indexType = this.opts.indexes && this.opts.indexes[field];
1093
+ const isNumericIndexType = indexType === 'number' || indexType === 'array:number';
1094
+ if (!fieldIndex || isNumericIndexType) {
1095
+ // Not index-answerable (numeric index) - handled by streaming fallback
1096
+ continue;
1097
+ }
1098
+ const termManager = this.database && this.database.termManager;
1099
+ const isTermField = !!(termManager && termManager.termMappingFields &&
1100
+ termManager.termMappingFields.includes(field));
1101
+ const regex = criteriaValue;
1102
+ for (const key of Object.keys(fieldIndex)) {
1103
+ const word = isTermField ? termManager.getTerm(Number(key)) : key;
1104
+ if (word === null || word === undefined) continue;
1105
+ regex.lastIndex = 0;
1106
+ if (!regex.test(String(word))) continue;
1107
+ const numbers = this._getAllLineNumbers(fieldIndex[key]);
1108
+ for (const lineNumber of numbers) lineNumbersForField.add(lineNumber);
1109
+ }
1110
+ // Fall through to field consolidation below (RegExp is an object, so it is
1111
+ // excluded from the object/operator branch that follows).
1086
1112
  }
1087
1113
 
1088
- if (typeof criteriaValue === 'object' && !Array.isArray(criteriaValue) && criteriaValue !== null) {
1114
+ if (typeof criteriaValue === 'object' && !(criteriaValue instanceof RegExp) && !Array.isArray(criteriaValue) && criteriaValue !== null) {
1089
1115
  const fieldIndex = data[field];
1090
1116
 
1091
1117
  // Handle $in operator for array queries