jexidb 2.1.0 → 2.1.1

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/src/Database.mjs CHANGED
@@ -76,12 +76,16 @@ class InsertSession {
76
76
  constructor(database, sessionOptions = {}) {
77
77
  this.database = database
78
78
  this.batchSize = sessionOptions.batchSize || 100
79
+ this.enableAutoSave = sessionOptions.enableAutoSave !== undefined ? sessionOptions.enableAutoSave : true
79
80
  this.totalInserted = 0
80
81
  this.flushing = false
81
82
  this.batches = [] // Array of batches to avoid slice() in flush()
82
83
  this.currentBatch = [] // Current batch being filled
83
84
  this.sessionId = Math.random().toString(36).substr(2, 9)
84
85
 
86
+ // Track pending auto-flush operations
87
+ this.pendingAutoFlushes = new Set()
88
+
85
89
  // Register this session as active
86
90
  this.database.activeInsertSessions.add(this)
87
91
  }
@@ -103,46 +107,153 @@ class InsertSession {
103
107
  this.currentBatch.push(finalRecord)
104
108
  this.totalInserted++
105
109
 
106
- // If batch is full, move it to batches array
110
+ // If batch is full, move it to batches array and trigger auto-flush
107
111
  if (this.currentBatch.length >= this.batchSize) {
108
112
  this.batches.push(this.currentBatch)
109
113
  this.currentBatch = []
114
+
115
+ // Auto-flush in background (non-blocking)
116
+ // This ensures batches are flushed automatically without blocking add()
117
+ this.autoFlush().catch(err => {
118
+ // Log error but don't throw - we don't want to break the add() flow
119
+ console.error('Auto-flush error in InsertSession:', err)
120
+ })
110
121
  }
111
122
 
112
123
  return finalRecord
113
124
  }
114
125
 
115
- async flush() {
116
- // Check if there's anything to flush
117
- if (this.batches.length === 0 && this.currentBatch.length === 0) return
118
-
119
- // Prevent concurrent flushes
126
+ async autoFlush() {
127
+ // Only flush if not already flushing
128
+ // This method will process all pending batches
120
129
  if (this.flushing) return
130
+
131
+ // Create a promise for this auto-flush operation
132
+ const flushPromise = this._doFlush()
133
+ this.pendingAutoFlushes.add(flushPromise)
134
+
135
+ // Remove from pending set when complete (success or error)
136
+ flushPromise
137
+ .then(() => {
138
+ this.pendingAutoFlushes.delete(flushPromise)
139
+ })
140
+ .catch((err) => {
141
+ this.pendingAutoFlushes.delete(flushPromise)
142
+ throw err
143
+ })
144
+
145
+ return flushPromise
146
+ }
147
+
148
+ async _doFlush() {
149
+ // Check if database is destroyed or closed before starting
150
+ if (this.database.destroyed || this.database.closed) {
151
+ // Clear batches if database is closed/destroyed
152
+ this.batches = []
153
+ this.currentBatch = []
154
+ return
155
+ }
156
+
157
+ // Prevent concurrent flushes - if already flushing, wait for it
158
+ if (this.flushing) {
159
+ // Wait for the current flush to complete
160
+ while (this.flushing) {
161
+ await new Promise(resolve => setTimeout(resolve, 1))
162
+ }
163
+ // After waiting, check if there's anything left to flush
164
+ // If another flush completed everything, we're done
165
+ if (this.batches.length === 0 && this.currentBatch.length === 0) return
166
+
167
+ // Check again if database was closed during wait
168
+ if (this.database.destroyed || this.database.closed) {
169
+ this.batches = []
170
+ this.currentBatch = []
171
+ return
172
+ }
173
+ }
174
+
121
175
  this.flushing = true
122
176
 
123
177
  try {
124
- // Process all complete batches
125
- for (const batch of this.batches) {
126
- await this.database.insertBatch(batch)
127
- }
178
+ // Process continuously until queue is completely empty
179
+ // This handles the case where new data is added during the flush
180
+ while (this.batches.length > 0 || this.currentBatch.length > 0) {
181
+ // Check if database was closed during processing
182
+ if (this.database.destroyed || this.database.closed) {
183
+ // Clear remaining batches
184
+ this.batches = []
185
+ this.currentBatch = []
186
+ return
187
+ }
128
188
 
129
- // Process remaining records in current batch
130
- if (this.currentBatch.length > 0) {
131
- await this.database.insertBatch(this.currentBatch)
132
- }
189
+ // Process all complete batches that exist at this moment
190
+ // Note: new batches may be added to this.batches during this loop
191
+ const batchesToProcess = this.batches.length
192
+ for (let i = 0; i < batchesToProcess; i++) {
193
+ // Check again before each batch
194
+ if (this.database.destroyed || this.database.closed) {
195
+ this.batches = []
196
+ this.currentBatch = []
197
+ return
198
+ }
199
+
200
+ const batch = this.batches.shift() // Remove from front
201
+ await this.database.insertBatch(batch)
202
+ }
133
203
 
134
- // Clear all batches
135
- this.batches = []
136
- this.currentBatch = []
204
+ // Process current batch if it has data
205
+ // Note: new records may be added to currentBatch during processing
206
+ if (this.currentBatch.length > 0) {
207
+ // Check if database was closed
208
+ if (this.database.destroyed || this.database.closed) {
209
+ this.batches = []
210
+ this.currentBatch = []
211
+ return
212
+ }
213
+
214
+ // Check if currentBatch reached batchSize during processing
215
+ if (this.currentBatch.length >= this.batchSize) {
216
+ // Move it to batches array and process in next iteration
217
+ this.batches.push(this.currentBatch)
218
+ this.currentBatch = []
219
+ continue
220
+ }
221
+
222
+ // Process the current batch
223
+ const batchToProcess = this.currentBatch
224
+ this.currentBatch = [] // Clear before processing to allow new adds
225
+ await this.database.insertBatch(batchToProcess)
226
+ }
227
+ }
137
228
  } finally {
138
229
  this.flushing = false
139
230
  }
140
231
  }
141
232
 
233
+ async flush() {
234
+ // Wait for any pending auto-flushes to complete first
235
+ await this.waitForAutoFlushes()
236
+
237
+ // Then do a final flush to ensure everything is processed
238
+ await this._doFlush()
239
+ }
240
+
241
+ async waitForAutoFlushes() {
242
+ // Wait for all pending auto-flush operations to complete
243
+ if (this.pendingAutoFlushes.size > 0) {
244
+ await Promise.all(Array.from(this.pendingAutoFlushes))
245
+ }
246
+ }
247
+
142
248
  async commit() {
143
249
  // CRITICAL FIX: Make session auto-reusable by removing committed state
144
250
  // Allow multiple commits on the same session
145
251
 
252
+ // First, wait for all pending auto-flushes to complete
253
+ await this.waitForAutoFlushes()
254
+
255
+ // Then flush any remaining data (including currentBatch)
256
+ // This ensures everything is inserted before commit returns
146
257
  await this.flush()
147
258
 
148
259
  // Reset session state for next commit cycle
@@ -158,6 +269,9 @@ class InsertSession {
158
269
  const startTime = Date.now()
159
270
  const hasTimeout = maxWaitTime !== null && maxWaitTime !== undefined
160
271
 
272
+ // Wait for auto-flushes first
273
+ await this.waitForAutoFlushes()
274
+
161
275
  while (this.flushing || this.batches.length > 0 || this.currentBatch.length > 0) {
162
276
  // Check timeout only if we have one
163
277
  if (hasTimeout && (Date.now() - startTime) >= maxWaitTime) {
@@ -174,7 +288,10 @@ class InsertSession {
174
288
  * Check if this session has pending operations
175
289
  */
176
290
  hasPendingOperations() {
177
- return this.flushing || this.batches.length > 0 || this.currentBatch.length > 0
291
+ return this.pendingAutoFlushes.size > 0 ||
292
+ this.flushing ||
293
+ this.batches.length > 0 ||
294
+ this.currentBatch.length > 0
178
295
  }
179
296
 
180
297
  /**
@@ -189,6 +306,7 @@ class InsertSession {
189
306
  this.currentBatch = []
190
307
  this.totalInserted = 0
191
308
  this.flushing = false
309
+ this.pendingAutoFlushes.clear()
192
310
  }
193
311
  }
194
312
 
@@ -238,6 +356,8 @@ class Database extends EventEmitter {
238
356
  streamingThreshold: opts.streamingThreshold || 0.8, // Use streaming when limit > 80% of total records
239
357
  // Serialization options
240
358
  enableArraySerialization: opts.enableArraySerialization !== false, // Enable array serialization by default
359
+ // Index rebuild options
360
+ allowIndexRebuild: opts.allowIndexRebuild === true, // Allow automatic index rebuild when corrupted (default false - throws error)
241
361
  }, opts)
242
362
 
243
363
  // CRITICAL FIX: Initialize AbortController for lifecycle management
@@ -264,6 +384,8 @@ class Database extends EventEmitter {
264
384
  this.isSaving = false
265
385
  this.lastSaveTime = null
266
386
  this.initialized = false
387
+ this._offsetRecoveryInProgress = false
388
+ this.writeBufferTotalSize = 0
267
389
 
268
390
 
269
391
  // Initialize managers
@@ -310,10 +432,11 @@ class Database extends EventEmitter {
310
432
 
311
433
  // Validate indexes array (new format) - but only if we have fields
312
434
  if (this.opts.originalIndexes && Array.isArray(this.opts.originalIndexes)) {
313
- if (!this.opts.fields) {
314
- throw new Error('Index fields array requires fields configuration. Use: { fields: {...}, indexes: [...] }')
435
+ if (this.opts.fields) {
436
+ this.validateIndexFields(this.opts.originalIndexes)
437
+ } else if (this.opts.debugMode) {
438
+ console.log('⚠️ Skipping index field validation because no fields configuration was provided')
315
439
  }
316
- this.validateIndexFields(this.opts.originalIndexes)
317
440
  }
318
441
 
319
442
  if (this.opts.debugMode) {
@@ -330,10 +453,14 @@ class Database extends EventEmitter {
330
453
  * Validate field types
331
454
  */
332
455
  validateFieldTypes(fields, configType) {
333
- const supportedTypes = ['string', 'number', 'boolean', 'array:string', 'array:number', 'array:boolean', 'array', 'object']
456
+ const supportedTypes = ['string', 'number', 'boolean', 'array:string', 'array:number', 'array:boolean', 'array', 'object', 'auto']
334
457
  const errors = []
335
458
 
336
459
  for (const [fieldName, fieldType] of Object.entries(fields)) {
460
+ if (fieldType === 'auto') {
461
+ continue
462
+ }
463
+
337
464
  // Check if type is supported
338
465
  if (!supportedTypes.includes(fieldType)) {
339
466
  errors.push(`Unsupported ${configType} type '${fieldType}' for field '${fieldName}'. Supported types: ${supportedTypes.join(', ')}`)
@@ -383,26 +510,24 @@ class Database extends EventEmitter {
383
510
  * Prepare index configuration for IndexManager
384
511
  */
385
512
  prepareIndexConfiguration() {
386
- // Convert new fields/indexes format to legacy format for IndexManager
387
- if (this.opts.fields && Array.isArray(this.opts.indexes)) {
388
- // New format: { fields: {...}, indexes: [...] }
513
+ if (Array.isArray(this.opts.indexes)) {
389
514
  const indexedFields = {}
390
- const originalIndexes = [...this.opts.indexes] // Keep original for validation
391
-
515
+ const originalIndexes = [...this.opts.indexes]
516
+ const hasFieldConfig = this.opts.fields && typeof this.opts.fields === 'object'
517
+
392
518
  for (const fieldName of this.opts.indexes) {
393
- if (this.opts.fields[fieldName]) {
519
+ if (hasFieldConfig && this.opts.fields[fieldName]) {
394
520
  indexedFields[fieldName] = this.opts.fields[fieldName]
521
+ } else {
522
+ indexedFields[fieldName] = 'auto'
395
523
  }
396
524
  }
397
-
398
- // Store original indexes for validation
525
+
399
526
  this.opts.originalIndexes = originalIndexes
400
-
401
- // Replace indexes array with object for IndexManager
402
527
  this.opts.indexes = indexedFields
403
-
528
+
404
529
  if (this.opts.debugMode) {
405
- console.log(`🔍 Converted fields/indexes format: ${Object.keys(indexedFields).join(', ')} [${this.instanceId}]`)
530
+ console.log(`🔍 Normalized indexes array to object: ${Object.keys(indexedFields).join(', ')} [${this.instanceId}]`)
406
531
  }
407
532
  }
408
533
  // Legacy format (indexes as object) is already compatible
@@ -436,6 +561,19 @@ class Database extends EventEmitter {
436
561
  this.termManager.termMappingFields = termMappingFields
437
562
  this.opts.termMapping = true // Always enable term mapping for optimal performance
438
563
 
564
+ // Validation: Ensure all array:string indexed fields are in term mapping fields
565
+ if (this.opts.indexes) {
566
+ const arrayStringFields = []
567
+ for (const [field, type] of Object.entries(this.opts.indexes)) {
568
+ if (type === 'array:string' && !termMappingFields.includes(field)) {
569
+ arrayStringFields.push(field)
570
+ }
571
+ }
572
+ if (arrayStringFields.length > 0) {
573
+ console.warn(`⚠️ Warning: The following array:string indexed fields were not added to term mapping: ${arrayStringFields.join(', ')}. This may impact performance.`)
574
+ }
575
+ }
576
+
439
577
  if (this.opts.debugMode) {
440
578
  if (termMappingFields.length > 0) {
441
579
  console.log(`🔍 TermManager initialized for fields: ${termMappingFields.join(', ')} [${this.instanceId}]`)
@@ -462,6 +600,7 @@ class Database extends EventEmitter {
462
600
  this.writeBuffer = []
463
601
  this.writeBufferOffsets = [] // Track offsets for writeBuffer records
464
602
  this.writeBufferSizes = [] // Track sizes for writeBuffer records
603
+ this.writeBufferTotalSize = 0
465
604
  this.isInsideOperationQueue = false // Flag to prevent deadlock in save() calls
466
605
 
467
606
  // Initialize other managers
@@ -483,8 +622,8 @@ class Database extends EventEmitter {
483
622
  const termMappingFields = []
484
623
 
485
624
  for (const [field, type] of Object.entries(this.opts.indexes)) {
486
- // Fields that should use term mapping
487
- if (type === 'array:string' || type === 'string') {
625
+ // Fields that should use term mapping (only array fields)
626
+ if (type === 'array:string') {
488
627
  termMappingFields.push(field)
489
628
  }
490
629
  }
@@ -704,6 +843,9 @@ class Database extends EventEmitter {
704
843
  // Don't load the entire file - just initialize empty state
705
844
  // The actual record count will come from loaded offsets
706
845
  this.writeBuffer = [] // writeBuffer is only for new unsaved records
846
+ this.writeBufferOffsets = []
847
+ this.writeBufferSizes = []
848
+ this.writeBufferTotalSize = 0
707
849
 
708
850
  // recordCount will be determined from loaded offsets
709
851
  // If no offsets were loaded, we'll count records only if needed
@@ -713,13 +855,55 @@ class Database extends EventEmitter {
713
855
  const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb')
714
856
  try {
715
857
  const idxFileHandler = new FileHandler(idxPath, this.fileMutex, this.opts)
858
+
859
+ // Check if file exists BEFORE trying to read it
860
+ const fileExists = await idxFileHandler.exists()
861
+ if (!fileExists) {
862
+ // File doesn't exist - this will be handled by catch block
863
+ throw new Error('Index file does not exist')
864
+ }
865
+
716
866
  const idxData = await idxFileHandler.readAll()
717
- if (idxData && idxData.trim()) {
867
+
868
+ // If file exists but is empty or has no content, treat as corrupted
869
+ if (!idxData || !idxData.trim()) {
870
+ // File exists but is empty - treat as corrupted
871
+ const fileExists = await this.fileHandler.exists()
872
+ if (fileExists) {
873
+ const stats = await this.fileHandler.getFileStats()
874
+ if (stats && stats.size > 0) {
875
+ // Data file has content but index is empty - corrupted
876
+ if (!this.opts.allowIndexRebuild) {
877
+ throw new Error(
878
+ `Index file is corrupted: ${idxPath} exists but contains no index data, ` +
879
+ `while the data file has ${stats.size} bytes. ` +
880
+ `Set allowIndexRebuild: true to automatically rebuild the index, ` +
881
+ `or manually fix/delete the corrupted index file.`
882
+ )
883
+ }
884
+ // Schedule rebuild if allowed
885
+ if (this.opts.debugMode) {
886
+ console.log(`⚠️ Index file exists but is empty while data file has ${stats.size} bytes - scheduling rebuild`)
887
+ }
888
+ this._scheduleIndexRebuild()
889
+ // Continue execution - rebuild will happen on first query
890
+ // Don't return - let the code continue to load other things if needed
891
+ }
892
+ }
893
+ // If data file is also empty, just continue (no error needed)
894
+ // Don't return - let the code continue to load other things if needed
895
+ } else {
896
+ // File has content - parse and load it
718
897
  const parsedIdxData = JSON.parse(idxData)
719
898
 
720
899
  // Always load offsets if available (even without indexed fields)
721
900
  if (parsedIdxData.offsets && Array.isArray(parsedIdxData.offsets)) {
722
901
  this.offsets = parsedIdxData.offsets
902
+ // CRITICAL FIX: Update IndexManager totalLines to match offsets length
903
+ // This ensures queries and length property work correctly even if offsets are reset later
904
+ if (this.indexManager && this.offsets.length > 0) {
905
+ this.indexManager.setTotalLines(this.offsets.length)
906
+ }
723
907
  if (this.opts.debugMode) {
724
908
  console.log(`📂 Loaded ${this.offsets.length} offsets from ${idxPath}`)
725
909
  }
@@ -733,24 +917,8 @@ class Database extends EventEmitter {
733
917
  }
734
918
  }
735
919
 
736
- // Load index data only if available and we have indexed fields
737
- if (parsedIdxData && parsedIdxData.index && this.indexManager.indexedFields && this.indexManager.indexedFields.length > 0) {
738
- this.indexManager.load(parsedIdxData.index)
739
-
740
- // Load term mapping data from .idx file if it exists
741
- if (parsedIdxData.termMapping && this.termManager) {
742
- await this.termManager.loadTerms(parsedIdxData.termMapping)
743
- if (this.opts.debugMode) {
744
- console.log(`📂 Loaded term mapping from ${idxPath}`)
745
- }
746
- }
747
-
748
- if (this.opts.debugMode) {
749
- console.log(`📂 Loaded index data from ${idxPath}`)
750
- }
751
- }
752
-
753
920
  // Load configuration from .idx file if database exists
921
+ // CRITICAL: Load config FIRST so indexes are available for term mapping detection
754
922
  if (parsedIdxData.config) {
755
923
  const config = parsedIdxData.config
756
924
 
@@ -764,11 +932,94 @@ class Database extends EventEmitter {
764
932
 
765
933
  if (config.indexes) {
766
934
  this.opts.indexes = config.indexes
935
+ if (this.indexManager) {
936
+ this.indexManager.setIndexesConfig(config.indexes)
937
+ }
767
938
  if (this.opts.debugMode) {
768
939
  console.log(`📂 Loaded indexes config from ${idxPath}:`, Object.keys(config.indexes))
769
940
  }
770
941
  }
771
942
 
943
+ // CRITICAL FIX: Update term mapping fields AFTER loading indexes from config
944
+ // This ensures termManager knows which fields use term mapping
945
+ // (getTermMappingFields() was called during init() before indexes were loaded)
946
+ if (this.termManager && config.indexes) {
947
+ const termMappingFields = this.getTermMappingFields()
948
+ this.termManager.termMappingFields = termMappingFields
949
+ if (this.opts.debugMode && termMappingFields.length > 0) {
950
+ console.log(`🔍 Updated term mapping fields after loading indexes: ${termMappingFields.join(', ')}`)
951
+ }
952
+ }
953
+ }
954
+
955
+ // Load term mapping data from .idx file if it exists
956
+ // CRITICAL: Load termMapping even if index is empty (terms are needed for queries)
957
+ // NOTE: termMappingFields should already be set above from config.indexes
958
+ if (parsedIdxData.termMapping && this.termManager && this.termManager.termMappingFields && this.termManager.termMappingFields.length > 0) {
959
+ await this.termManager.loadTerms(parsedIdxData.termMapping)
960
+ if (this.opts.debugMode) {
961
+ console.log(`📂 Loaded term mapping from ${idxPath} (${Object.keys(parsedIdxData.termMapping).length} terms)`)
962
+ }
963
+ }
964
+
965
+ // Load index data only if available and we have indexed fields
966
+ if (parsedIdxData && parsedIdxData.index && this.indexManager.indexedFields && this.indexManager.indexedFields.length > 0) {
967
+ this.indexManager.load(parsedIdxData.index)
968
+
969
+ if (this.opts.debugMode) {
970
+ console.log(`📂 Loaded index data from ${idxPath}`)
971
+ }
972
+
973
+ // Check if loaded index is actually empty (corrupted)
974
+ let hasAnyIndexData = false
975
+ for (const field of this.indexManager.indexedFields) {
976
+ if (this.indexManager.hasUsableIndexData(field)) {
977
+ hasAnyIndexData = true
978
+ break
979
+ }
980
+ }
981
+
982
+ if (this.opts.debugMode) {
983
+ console.log(`📊 Index check: hasAnyIndexData=${hasAnyIndexData}, indexedFields=${this.indexManager.indexedFields.join(',')}`)
984
+ }
985
+
986
+ // Schedule rebuild if index is empty AND file exists with data
987
+ if (!hasAnyIndexData) {
988
+ // Check if the actual .jdb file has data
989
+ const fileExists = await this.fileHandler.exists()
990
+ if (this.opts.debugMode) {
991
+ console.log(`📊 File check: exists=${fileExists}`)
992
+ }
993
+ if (fileExists) {
994
+ const stats = await this.fileHandler.getFileStats()
995
+ if (this.opts.debugMode) {
996
+ console.log(`📊 File stats: size=${stats?.size}`)
997
+ }
998
+ if (stats && stats.size > 0) {
999
+ // File has data but index is empty - corrupted index detected
1000
+ if (!this.opts.allowIndexRebuild) {
1001
+ const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb')
1002
+ throw new Error(
1003
+ `Index file is corrupted: ${idxPath} exists but contains no index data, ` +
1004
+ `while the data file has ${stats.size} bytes. ` +
1005
+ `Set allowIndexRebuild: true to automatically rebuild the index, ` +
1006
+ `or manually fix/delete the corrupted index file.`
1007
+ )
1008
+ }
1009
+ // Schedule rebuild if allowed
1010
+ if (this.opts.debugMode) {
1011
+ console.log(`⚠️ Index loaded but empty while file has ${stats.size} bytes - scheduling rebuild`)
1012
+ }
1013
+ this._scheduleIndexRebuild()
1014
+ }
1015
+ }
1016
+ }
1017
+ }
1018
+
1019
+ // Continue with remaining config loading
1020
+ if (parsedIdxData.config) {
1021
+ const config = parsedIdxData.config
1022
+
772
1023
  if (config.originalIndexes) {
773
1024
  this.opts.originalIndexes = config.originalIndexes
774
1025
  if (this.opts.debugMode) {
@@ -787,11 +1038,86 @@ class Database extends EventEmitter {
787
1038
  }
788
1039
  } catch (idxError) {
789
1040
  // Index file doesn't exist or is corrupted, rebuild from data
1041
+ // BUT: if error is about rebuild being disabled, re-throw it immediately
1042
+ if (idxError.message && (idxError.message.includes('allowIndexRebuild') || idxError.message.includes('corrupted'))) {
1043
+ throw idxError
1044
+ }
1045
+
1046
+ // If error is "Index file does not exist", check if we should throw or rebuild
1047
+ if (idxError.message && idxError.message.includes('does not exist')) {
1048
+ // Check if the actual .jdb file has data that needs indexing
1049
+ try {
1050
+ const fileExists = await this.fileHandler.exists()
1051
+ if (fileExists) {
1052
+ const stats = await this.fileHandler.getFileStats()
1053
+ if (stats && stats.size > 0) {
1054
+ // File has data but index is missing
1055
+ if (!this.opts.allowIndexRebuild) {
1056
+ const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb')
1057
+ throw new Error(
1058
+ `Index file is missing or corrupted: ${idxPath} does not exist or is invalid, ` +
1059
+ `while the data file has ${stats.size} bytes. ` +
1060
+ `Set allowIndexRebuild: true to automatically rebuild the index, ` +
1061
+ `or manually create/fix the index file.`
1062
+ )
1063
+ }
1064
+ // Schedule rebuild if allowed
1065
+ if (this.opts.debugMode) {
1066
+ console.log(`⚠️ .jdb file has ${stats.size} bytes but index missing - scheduling rebuild`)
1067
+ }
1068
+ this._scheduleIndexRebuild()
1069
+ return // Exit early
1070
+ }
1071
+ }
1072
+ } catch (statsError) {
1073
+ if (this.opts.debugMode) {
1074
+ console.log('⚠️ Could not check file stats:', statsError.message)
1075
+ }
1076
+ // Re-throw if it's our error about rebuild being disabled
1077
+ if (statsError.message && statsError.message.includes('allowIndexRebuild')) {
1078
+ throw statsError
1079
+ }
1080
+ }
1081
+ // If no data file or empty, just continue (no error needed)
1082
+ return
1083
+ }
1084
+
790
1085
  if (this.opts.debugMode) {
791
- console.log('📂 No index file found, rebuilding indexes from data')
1086
+ console.log('📂 No index file found or corrupted, checking if rebuild is needed...')
1087
+ }
1088
+
1089
+ // Check if the actual .jdb file has data that needs indexing
1090
+ try {
1091
+ const fileExists = await this.fileHandler.exists()
1092
+ if (fileExists) {
1093
+ const stats = await this.fileHandler.getFileStats()
1094
+ if (stats && stats.size > 0) {
1095
+ // File has data but index is missing or corrupted
1096
+ if (!this.opts.allowIndexRebuild) {
1097
+ const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb')
1098
+ throw new Error(
1099
+ `Index file is missing or corrupted: ${idxPath} does not exist or is invalid, ` +
1100
+ `while the data file has ${stats.size} bytes. ` +
1101
+ `Set allowIndexRebuild: true to automatically rebuild the index, ` +
1102
+ `or manually create/fix the index file.`
1103
+ )
1104
+ }
1105
+ // Schedule rebuild if allowed
1106
+ if (this.opts.debugMode) {
1107
+ console.log(`⚠️ .jdb file has ${stats.size} bytes but index missing - scheduling rebuild`)
1108
+ }
1109
+ this._scheduleIndexRebuild()
1110
+ }
1111
+ }
1112
+ } catch (statsError) {
1113
+ if (this.opts.debugMode) {
1114
+ console.log('⚠️ Could not check file stats:', statsError.message)
1115
+ }
1116
+ // Re-throw if it's our error about rebuild being disabled
1117
+ if (statsError.message && statsError.message.includes('allowIndexRebuild')) {
1118
+ throw statsError
1119
+ }
792
1120
  }
793
- // We can't rebuild index without violating no-memory-storage rule
794
- // Index will be rebuilt as needed during queries
795
1121
  }
796
1122
  } else {
797
1123
  // No indexed fields, no need to rebuild indexes
@@ -820,6 +1146,28 @@ class Database extends EventEmitter {
820
1146
  console.log(`💾 save() called: writeBuffer.length=${this.writeBuffer.length}, offsets.length=${this.offsets.length}`)
821
1147
  }
822
1148
 
1149
+ // CRITICAL FIX: Wait for all active insert sessions to complete their auto-flushes
1150
+ // This prevents race conditions where save() writes data while auto-flushes are still adding to writeBuffer
1151
+ if (this.activeInsertSessions && this.activeInsertSessions.size > 0) {
1152
+ if (this.opts.debugMode) {
1153
+ console.log(`⏳ save(): Waiting for ${this.activeInsertSessions.size} active insert sessions to complete auto-flushes`)
1154
+ }
1155
+
1156
+ const sessionPromises = Array.from(this.activeInsertSessions).map(session =>
1157
+ session.waitForAutoFlushes().catch(err => {
1158
+ if (this.opts.debugMode) {
1159
+ console.warn(`⚠️ save(): Error waiting for insert session: ${err.message}`)
1160
+ }
1161
+ })
1162
+ )
1163
+
1164
+ await Promise.all(sessionPromises)
1165
+
1166
+ if (this.opts.debugMode) {
1167
+ console.log(`✅ save(): All insert sessions completed auto-flushes`)
1168
+ }
1169
+ }
1170
+
823
1171
  // Auto-save removed - no need to pause anything
824
1172
 
825
1173
  try {
@@ -1210,8 +1558,34 @@ class Database extends EventEmitter {
1210
1558
  }
1211
1559
 
1212
1560
  // Rebuild index from the saved records
1561
+ // CRITICAL: Process term mapping for records loaded from file to ensure ${field}Ids are available
1213
1562
  for (let i = 0; i < allData.length; i++) {
1214
- const record = allData[i]
1563
+ let record = allData[i]
1564
+
1565
+ // CRITICAL FIX: Ensure records have ${field}Ids for term mapping fields
1566
+ // Records from writeBuffer already have ${field}Ids from processTermMapping
1567
+ // Records from file need to be processed to restore ${field}Ids
1568
+ const termMappingFields = this.getTermMappingFields()
1569
+ if (termMappingFields.length > 0 && this.termManager) {
1570
+ for (const field of termMappingFields) {
1571
+ if (record[field] && Array.isArray(record[field])) {
1572
+ // Check if field contains term IDs (numbers) or terms (strings)
1573
+ const firstValue = record[field][0]
1574
+ if (typeof firstValue === 'number') {
1575
+ // Already term IDs, create ${field}Ids
1576
+ record[`${field}Ids`] = record[field]
1577
+ } else if (typeof firstValue === 'string') {
1578
+ // Terms, need to convert to term IDs
1579
+ const termIds = record[field].map(term => {
1580
+ const termId = this.termManager.getTermIdWithoutIncrement(term)
1581
+ return termId !== undefined ? termId : this.termManager.getTermId(term)
1582
+ })
1583
+ record[`${field}Ids`] = termIds
1584
+ }
1585
+ }
1586
+ }
1587
+ }
1588
+
1215
1589
  await this.indexManager.add(record, i)
1216
1590
  }
1217
1591
  }
@@ -1247,6 +1621,8 @@ class Database extends EventEmitter {
1247
1621
  this.writeBuffer = []
1248
1622
  this.writeBufferOffsets = []
1249
1623
  this.writeBufferSizes = []
1624
+ this.writeBufferTotalSize = 0
1625
+ this.writeBufferTotalSize = 0
1250
1626
  }
1251
1627
 
1252
1628
  // indexOffset already set correctly to currentOffset (total file size) above
@@ -1465,18 +1841,16 @@ class Database extends EventEmitter {
1465
1841
  }
1466
1842
 
1467
1843
  // OPTIMIZATION: Process records using pre-computed term IDs
1468
- return records.map(record => {
1469
- const processedRecord = { ...record }
1470
-
1844
+ for (const record of records) {
1471
1845
  for (const field of termMappingFields) {
1472
1846
  if (record[field] && Array.isArray(record[field])) {
1473
1847
  const termIds = record[field].map(term => termIdMap.get(term))
1474
- processedRecord[`${field}Ids`] = termIds
1848
+ record[`${field}Ids`] = termIds
1475
1849
  }
1476
1850
  }
1477
-
1478
- return processedRecord
1479
- })
1851
+ }
1852
+
1853
+ return records
1480
1854
  }
1481
1855
 
1482
1856
 
@@ -1570,18 +1944,19 @@ class Database extends EventEmitter {
1570
1944
  // OPTIMIZATION: Calculate and store offset and size for writeBuffer record
1571
1945
  // SPACE OPTIMIZATION: Remove term IDs before serialization
1572
1946
  const cleanRecord = this.removeTermIdsForSerialization(record)
1573
- const recordJson = this.serializer.serialize(cleanRecord).toString('utf8')
1574
- const recordSize = Buffer.byteLength(recordJson, 'utf8')
1947
+ const recordBuffer = this.serializer.serialize(cleanRecord)
1948
+ const recordSize = recordBuffer.length
1575
1949
 
1576
1950
  // Calculate offset based on end of file + previous writeBuffer sizes
1577
- const previousWriteBufferSize = this.writeBufferSizes.reduce((sum, size) => sum + size, 0)
1951
+ const previousWriteBufferSize = this.writeBufferTotalSize
1578
1952
  const recordOffset = this.indexOffset + previousWriteBufferSize
1579
1953
 
1580
1954
  this.writeBufferOffsets.push(recordOffset)
1581
1955
  this.writeBufferSizes.push(recordSize)
1956
+ this.writeBufferTotalSize += recordSize
1582
1957
 
1583
- // OPTIMIZATION: Use the current writeBuffer size as the line number (0-based index)
1584
- const lineNumber = this.writeBuffer.length - 1
1958
+ // OPTIMIZATION: Use the absolute line number (persisted records + writeBuffer index)
1959
+ const lineNumber = this._getAbsoluteLineNumber(this.writeBuffer.length - 1)
1585
1960
 
1586
1961
  // OPTIMIZATION: Defer index updates to batch processing
1587
1962
  // Store the record for batch index processing
@@ -1652,7 +2027,7 @@ class Database extends EventEmitter {
1652
2027
  console.log(`💾 _insertBatchInternal: processing size=${dataArray.length}, startWriteBuffer=${this.writeBuffer.length}`)
1653
2028
  }
1654
2029
  const records = []
1655
- const startLineNumber = this.writeBuffer.length
2030
+ const existingWriteBufferLength = this.writeBuffer.length
1656
2031
 
1657
2032
  // Initialize schema if not already done (auto-detect from first record)
1658
2033
  if (this.serializer && !this.serializer.schemaManager.isInitialized && dataArray.length > 0) {
@@ -1684,13 +2059,13 @@ class Database extends EventEmitter {
1684
2059
  this.writeBuffer.push(...schemaEnforcedRecords)
1685
2060
 
1686
2061
  // OPTIMIZATION: Calculate offsets and sizes in batch (O(n))
1687
- let runningTotalSize = this.writeBufferSizes.reduce((sum, size) => sum + size, 0)
2062
+ let runningTotalSize = this.writeBufferTotalSize
1688
2063
  for (let i = 0; i < processedRecords.length; i++) {
1689
2064
  const record = processedRecords[i]
1690
2065
  // SPACE OPTIMIZATION: Remove term IDs before serialization
1691
2066
  const cleanRecord = this.removeTermIdsForSerialization(record)
1692
- const recordJson = this.serializer.serialize(cleanRecord).toString('utf8')
1693
- const recordSize = Buffer.byteLength(recordJson, 'utf8')
2067
+ const recordBuffer = this.serializer.serialize(cleanRecord)
2068
+ const recordSize = recordBuffer.length
1694
2069
 
1695
2070
  const recordOffset = this.indexOffset + runningTotalSize
1696
2071
  runningTotalSize += recordSize
@@ -1698,6 +2073,7 @@ class Database extends EventEmitter {
1698
2073
  this.writeBufferOffsets.push(recordOffset)
1699
2074
  this.writeBufferSizes.push(recordSize)
1700
2075
  }
2076
+ this.writeBufferTotalSize = runningTotalSize
1701
2077
 
1702
2078
  // OPTIMIZATION: Batch process index updates
1703
2079
  if (!this.pendingIndexUpdates) {
@@ -1705,7 +2081,7 @@ class Database extends EventEmitter {
1705
2081
  }
1706
2082
 
1707
2083
  for (let i = 0; i < processedRecords.length; i++) {
1708
- const lineNumber = startLineNumber + i
2084
+ const lineNumber = this._getAbsoluteLineNumber(existingWriteBufferLength + i)
1709
2085
  this.pendingIndexUpdates.push({ record: processedRecords[i], lineNumber })
1710
2086
  }
1711
2087
 
@@ -1745,7 +2121,7 @@ class Database extends EventEmitter {
1745
2121
  try {
1746
2122
  // Validate indexed query mode if enabled
1747
2123
  if (this.opts.indexedQueryMode === 'strict') {
1748
- this._validateIndexedQuery(criteria)
2124
+ this._validateIndexedQuery(criteria, options)
1749
2125
  }
1750
2126
 
1751
2127
  // Get results from file (QueryManager already handles term ID restoration)
@@ -1820,8 +2196,15 @@ class Database extends EventEmitter {
1820
2196
  /**
1821
2197
  * Validate indexed query mode for strict mode
1822
2198
  * @private
2199
+ * @param {Object} criteria - Query criteria
2200
+ * @param {Object} options - Query options
1823
2201
  */
1824
- _validateIndexedQuery(criteria) {
2202
+ _validateIndexedQuery(criteria, options = {}) {
2203
+ // Allow bypassing strict mode validation with allowNonIndexed option
2204
+ if (options.allowNonIndexed === true) {
2205
+ return; // Skip validation for this query
2206
+ }
2207
+
1825
2208
  if (!criteria || typeof criteria !== 'object') {
1826
2209
  return // Allow null/undefined criteria
1827
2210
  }
@@ -2100,7 +2483,7 @@ class Database extends EventEmitter {
2100
2483
  if (index !== -1) {
2101
2484
  // Record is already in writeBuffer, update it
2102
2485
  this.writeBuffer[index] = updated
2103
- lineNumber = index
2486
+ lineNumber = this._getAbsoluteLineNumber(index)
2104
2487
  if (this.opts.debugMode) {
2105
2488
  console.log(`🔄 UPDATE: Updated existing writeBuffer record at index ${index}`)
2106
2489
  }
@@ -2108,7 +2491,7 @@ class Database extends EventEmitter {
2108
2491
  // Record is in file, add updated version to writeBuffer
2109
2492
  // This will ensure the updated record is saved and replaces the file version
2110
2493
  this.writeBuffer.push(updated)
2111
- lineNumber = this.writeBuffer.length - 1
2494
+ lineNumber = this._getAbsoluteLineNumber(this.writeBuffer.length - 1)
2112
2495
  if (this.opts.debugMode) {
2113
2496
  console.log(`🔄 UPDATE: Added new record to writeBuffer at index ${lineNumber}`)
2114
2497
  }
@@ -2294,6 +2677,21 @@ class Database extends EventEmitter {
2294
2677
  const savedRecords = this.offsets.length
2295
2678
  const writeBufferRecords = this.writeBuffer.length
2296
2679
 
2680
+ // CRITICAL FIX: If offsets are empty but indexOffset exists, use fallback calculation
2681
+ // This handles cases where offsets weren't loaded or were reset
2682
+ if (savedRecords === 0 && this.indexOffset > 0 && this.initialized) {
2683
+ // Try to use IndexManager totalLines if available
2684
+ if (this.indexManager && this.indexManager.totalLines > 0) {
2685
+ return this.indexManager.totalLines + writeBufferRecords
2686
+ }
2687
+
2688
+ // Fallback: estimate from indexOffset (less accurate but better than 0)
2689
+ // This is a defensive fix for cases where offsets are missing but file has data
2690
+ if (this.opts.debugMode) {
2691
+ console.log(`⚠️ LENGTH: offsets array is empty but indexOffset=${this.indexOffset}, using IndexManager.totalLines or estimation`)
2692
+ }
2693
+ }
2694
+
2297
2695
  // CRITICAL FIX: Validate that offsets array is consistent with actual data
2298
2696
  // This prevents the bug where database reassignment causes desynchronization
2299
2697
  if (this.initialized && savedRecords > 0) {
@@ -2339,22 +2737,7 @@ class Database extends EventEmitter {
2339
2737
  * Calculate current writeBuffer size in bytes (similar to published v1.1.0)
2340
2738
  */
2341
2739
  currentWriteBufferSize() {
2342
- if (!this.writeBuffer || this.writeBuffer.length === 0) {
2343
- return 0
2344
- }
2345
-
2346
- // Calculate total size of all records in writeBuffer
2347
- let totalSize = 0
2348
- for (const record of this.writeBuffer) {
2349
- if (record) {
2350
- // SPACE OPTIMIZATION: Remove term IDs before size calculation
2351
- const cleanRecord = this.removeTermIdsForSerialization(record)
2352
- const recordJson = JSON.stringify(cleanRecord) + '\n'
2353
- totalSize += Buffer.byteLength(recordJson, 'utf8')
2354
- }
2355
- }
2356
-
2357
- return totalSize
2740
+ return this.writeBufferTotalSize || 0
2358
2741
  }
2359
2742
 
2360
2743
  /**
@@ -2387,62 +2770,236 @@ class Database extends EventEmitter {
2387
2770
  }
2388
2771
 
2389
2772
  /**
2390
- * Destroy database - DESTRUCTIVE MODE
2391
- * Assumes save() has already been called by user
2392
- * If anything is still active, it indicates a bug - log error and force cleanup
2773
+ * Schedule index rebuild when index data is missing or corrupted
2774
+ * @private
2393
2775
  */
2394
- async destroy() {
2395
- if (this.destroyed) return
2776
+ _scheduleIndexRebuild() {
2777
+ // Mark that rebuild is needed
2778
+ this._indexRebuildNeeded = true
2396
2779
 
2397
- // Mark as destroying immediately to prevent new operations
2398
- this.destroying = true
2780
+ // Rebuild will happen lazily on first query if index is empty
2781
+ // This avoids blocking init() but ensures index is available when needed
2782
+ }
2783
+
2784
+ /**
2785
+ * Rebuild indexes from data file if needed
2786
+ * @private
2787
+ */
2788
+ async _rebuildIndexesIfNeeded() {
2789
+ if (this.opts.debugMode) {
2790
+ console.log(`🔍 _rebuildIndexesIfNeeded called: _indexRebuildNeeded=${this._indexRebuildNeeded}`)
2791
+ }
2792
+ if (!this._indexRebuildNeeded) return
2793
+ if (!this.indexManager || !this.indexManager.indexedFields || this.indexManager.indexedFields.length === 0) return
2399
2794
 
2400
- // Wait for all active insert sessions to complete before destroying
2401
- if (this.activeInsertSessions.size > 0) {
2402
- if (this.opts.debugMode) {
2403
- console.log(`⏳ destroy: Waiting for ${this.activeInsertSessions.size} active insert sessions`)
2795
+ // Check if index actually needs rebuilding
2796
+ let needsRebuild = false
2797
+ for (const field of this.indexManager.indexedFields) {
2798
+ if (!this.indexManager.hasUsableIndexData(field)) {
2799
+ needsRebuild = true
2800
+ break
2404
2801
  }
2405
-
2406
- const sessionPromises = Array.from(this.activeInsertSessions).map(session =>
2407
- session.waitForOperations(null) // Wait indefinitely for sessions to complete
2802
+ }
2803
+
2804
+ if (!needsRebuild) {
2805
+ this._indexRebuildNeeded = false
2806
+ return
2807
+ }
2808
+
2809
+ // Check if rebuild is allowed
2810
+ if (!this.opts.allowIndexRebuild) {
2811
+ const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb')
2812
+ throw new Error(
2813
+ `Index rebuild required but disabled: Index file ${idxPath} is corrupted or missing, ` +
2814
+ `and allowIndexRebuild is set to false. ` +
2815
+ `Set allowIndexRebuild: true to automatically rebuild the index, ` +
2816
+ `or manually fix/delete the corrupted index file.`
2408
2817
  )
2409
-
2410
- try {
2411
- await Promise.all(sessionPromises)
2412
- } catch (error) {
2413
- if (this.opts.debugMode) {
2414
- console.log(`⚠️ destroy: Error waiting for sessions: ${error.message}`)
2415
- }
2416
- // Continue with destruction even if sessions have issues
2417
- }
2418
-
2419
- // Destroy all active sessions
2420
- for (const session of this.activeInsertSessions) {
2421
- session.destroy()
2422
- }
2423
- this.activeInsertSessions.clear()
2424
2818
  }
2425
2819
 
2426
- // CRITICAL FIX: Add timeout protection to prevent destroy() from hanging
2427
- const destroyPromise = this._performDestroy()
2428
- let timeoutHandle = null
2429
- const timeoutPromise = new Promise((_, reject) => {
2430
- timeoutHandle = setTimeout(() => {
2431
- reject(new Error('Destroy operation timed out after 5 seconds'))
2432
- }, 5000)
2433
- })
2820
+ if (this.opts.debugMode) {
2821
+ console.log('🔨 Rebuilding indexes from data file...')
2822
+ }
2434
2823
 
2435
2824
  try {
2436
- await Promise.race([destroyPromise, timeoutPromise])
2437
- } catch (error) {
2438
- if (error.message === 'Destroy operation timed out after 5 seconds') {
2439
- console.error('🚨 DESTROY TIMEOUT: Force destroying database after timeout')
2440
- // Force mark as destroyed even if cleanup failed
2441
- this.destroyed = true
2442
- this.destroying = false
2443
- return
2444
- }
2445
- throw error
2825
+ // Read all records and rebuild index
2826
+ let count = 0
2827
+ const startTime = Date.now()
2828
+
2829
+ // Auto-detect schema from first line if not initialized
2830
+ if (!this.serializer.schemaManager.isInitialized) {
2831
+ const fs = await import('fs')
2832
+ const readline = await import('readline')
2833
+ const stream = fs.createReadStream(this.fileHandler.file, {
2834
+ highWaterMark: 64 * 1024,
2835
+ encoding: 'utf8'
2836
+ })
2837
+ const rl = readline.createInterface({
2838
+ input: stream,
2839
+ crlfDelay: Infinity
2840
+ })
2841
+
2842
+ for await (const line of rl) {
2843
+ if (line && line.trim()) {
2844
+ try {
2845
+ const firstRecord = JSON.parse(line)
2846
+ if (Array.isArray(firstRecord)) {
2847
+ // Try to infer schema from opts.fields if available
2848
+ if (this.opts.fields && typeof this.opts.fields === 'object') {
2849
+ const fieldNames = Object.keys(this.opts.fields)
2850
+ if (fieldNames.length >= firstRecord.length) {
2851
+ // Use first N fields from opts.fields to match array length
2852
+ const schema = fieldNames.slice(0, firstRecord.length)
2853
+ this.serializer.initializeSchema(schema)
2854
+ if (this.opts.debugMode) {
2855
+ console.log(`🔍 Inferred schema from opts.fields: ${schema.join(', ')}`)
2856
+ }
2857
+ } else {
2858
+ throw new Error(`Cannot rebuild index: array has ${firstRecord.length} elements but opts.fields only defines ${fieldNames.length} fields. Schema must be explicitly provided.`)
2859
+ }
2860
+ } else {
2861
+ throw new Error('Cannot rebuild index: schema missing, file uses array format, and opts.fields not provided. The .idx.jdb file is corrupted.')
2862
+ }
2863
+ } else {
2864
+ // Object format, initialize from object keys
2865
+ this.serializer.initializeSchema(firstRecord, true)
2866
+ if (this.opts.debugMode) {
2867
+ console.log(`🔍 Auto-detected schema from object: ${Object.keys(firstRecord).join(', ')}`)
2868
+ }
2869
+ }
2870
+ break
2871
+ } catch (error) {
2872
+ if (this.opts.debugMode) {
2873
+ console.error('❌ Failed to auto-detect schema:', error.message)
2874
+ }
2875
+ throw error
2876
+ }
2877
+ }
2878
+ }
2879
+ stream.destroy()
2880
+ }
2881
+
2882
+ // Use streaming to read records without loading everything into memory
2883
+ // Also rebuild offsets while we're at it
2884
+ const fs = await import('fs')
2885
+ const readline = await import('readline')
2886
+
2887
+ this.offsets = []
2888
+ let currentOffset = 0
2889
+
2890
+ const stream = fs.createReadStream(this.fileHandler.file, {
2891
+ highWaterMark: 64 * 1024,
2892
+ encoding: 'utf8'
2893
+ })
2894
+
2895
+ const rl = readline.createInterface({
2896
+ input: stream,
2897
+ crlfDelay: Infinity
2898
+ })
2899
+
2900
+ try {
2901
+ for await (const line of rl) {
2902
+ if (line && line.trim()) {
2903
+ try {
2904
+ // Record the offset for this line
2905
+ this.offsets.push(currentOffset)
2906
+
2907
+ const record = this.serializer.deserialize(line)
2908
+ const recordWithTerms = this.restoreTermIdsAfterDeserialization(record)
2909
+ await this.indexManager.add(recordWithTerms, count)
2910
+ count++
2911
+ } catch (error) {
2912
+ // Skip invalid lines
2913
+ if (this.opts.debugMode) {
2914
+ console.log(`⚠️ Rebuild: Failed to deserialize line ${count}:`, error.message)
2915
+ }
2916
+ }
2917
+ }
2918
+ // Update offset for next line (including newline character)
2919
+ currentOffset += Buffer.byteLength(line, 'utf8') + 1
2920
+ }
2921
+ } finally {
2922
+ stream.destroy()
2923
+ }
2924
+
2925
+ // Update indexManager totalLines
2926
+ if (this.indexManager) {
2927
+ this.indexManager.setTotalLines(this.offsets.length)
2928
+ }
2929
+
2930
+ this._indexRebuildNeeded = false
2931
+
2932
+ if (this.opts.debugMode) {
2933
+ console.log(`✅ Index rebuilt from ${count} records in ${Date.now() - startTime}ms`)
2934
+ }
2935
+
2936
+ // Save the rebuilt index
2937
+ await this._saveIndexDataToFile()
2938
+ } catch (error) {
2939
+ if (this.opts.debugMode) {
2940
+ console.error('❌ Failed to rebuild indexes:', error.message)
2941
+ }
2942
+ // Don't throw - queries will fall back to streaming
2943
+ }
2944
+ }
2945
+
2946
+ /**
2947
+ * Destroy database - DESTRUCTIVE MODE
2948
+ * Assumes save() has already been called by user
2949
+ * If anything is still active, it indicates a bug - log error and force cleanup
2950
+ */
2951
+ async destroy() {
2952
+ if (this.destroyed) return
2953
+
2954
+ // Mark as destroying immediately to prevent new operations
2955
+ this.destroying = true
2956
+
2957
+ // Wait for all active insert sessions to complete before destroying
2958
+ if (this.activeInsertSessions.size > 0) {
2959
+ if (this.opts.debugMode) {
2960
+ console.log(`⏳ destroy: Waiting for ${this.activeInsertSessions.size} active insert sessions`)
2961
+ }
2962
+
2963
+ const sessionPromises = Array.from(this.activeInsertSessions).map(session =>
2964
+ session.waitForOperations(null) // Wait indefinitely for sessions to complete
2965
+ )
2966
+
2967
+ try {
2968
+ await Promise.all(sessionPromises)
2969
+ } catch (error) {
2970
+ if (this.opts.debugMode) {
2971
+ console.log(`⚠️ destroy: Error waiting for sessions: ${error.message}`)
2972
+ }
2973
+ // Continue with destruction even if sessions have issues
2974
+ }
2975
+
2976
+ // Destroy all active sessions
2977
+ for (const session of this.activeInsertSessions) {
2978
+ session.destroy()
2979
+ }
2980
+ this.activeInsertSessions.clear()
2981
+ }
2982
+
2983
+ // CRITICAL FIX: Add timeout protection to prevent destroy() from hanging
2984
+ const destroyPromise = this._performDestroy()
2985
+ let timeoutHandle = null
2986
+ const timeoutPromise = new Promise((_, reject) => {
2987
+ timeoutHandle = setTimeout(() => {
2988
+ reject(new Error('Destroy operation timed out after 5 seconds'))
2989
+ }, 5000)
2990
+ })
2991
+
2992
+ try {
2993
+ await Promise.race([destroyPromise, timeoutPromise])
2994
+ } catch (error) {
2995
+ if (error.message === 'Destroy operation timed out after 5 seconds') {
2996
+ console.error('🚨 DESTROY TIMEOUT: Force destroying database after timeout')
2997
+ // Force mark as destroyed even if cleanup failed
2998
+ this.destroyed = true
2999
+ this.destroying = false
3000
+ return
3001
+ }
3002
+ throw error
2446
3003
  } finally {
2447
3004
  // Clear the timeout to prevent Jest open handle warning
2448
3005
  if (timeoutHandle) {
@@ -2503,6 +3060,8 @@ class Database extends EventEmitter {
2503
3060
  this.writeBuffer = []
2504
3061
  this.writeBufferOffsets = []
2505
3062
  this.writeBufferSizes = []
3063
+ this.writeBufferTotalSize = 0
3064
+ this.writeBufferTotalSize = 0
2506
3065
  this.deletedIds.clear()
2507
3066
  this.pendingOperations.clear()
2508
3067
  this.pendingIndexUpdates = []
@@ -2570,8 +3129,211 @@ class Database extends EventEmitter {
2570
3129
  async count(criteria = {}, options = {}) {
2571
3130
  this._validateInitialization('count')
2572
3131
 
2573
- const results = await this.find(criteria, options)
2574
- return results.length
3132
+ // OPTIMIZATION: Use queryManager.count() instead of find() for better performance
3133
+ // This is especially faster for indexed queries which can use indexManager.query().size
3134
+ const fileCount = await this.queryManager.count(criteria, options)
3135
+
3136
+ // Count matching records in writeBuffer
3137
+ const writeBufferCount = this.writeBuffer.filter(record =>
3138
+ this.queryManager.matchesCriteria(record, criteria, options)
3139
+ ).length
3140
+
3141
+ return fileCount + writeBufferCount
3142
+ }
3143
+
3144
+ /**
3145
+ * Check if any records exist for given field and terms (index-only, ultra-fast)
3146
+ * Delegates to IndexManager.exists() for maximum performance
3147
+ *
3148
+ * @param {string} fieldName - Indexed field name
3149
+ * @param {string|Array<string>} terms - Single term or array of terms
3150
+ * @param {Object} options - Options: { $all: true/false, caseInsensitive: true/false, excludes: Array<string> }
3151
+ * @returns {Promise<boolean>} - True if at least one match exists
3152
+ *
3153
+ * @example
3154
+ * // Check if channel exists
3155
+ * const exists = await db.exists('nameTerms', ['a', 'e'], { $all: true });
3156
+ *
3157
+ * @example
3158
+ * // Check if 'tv' exists but not 'globo'
3159
+ * const exists = await db.exists('nameTerms', 'tv', { excludes: ['globo'] });
3160
+ */
3161
+ async exists(fieldName, terms, options = {}) {
3162
+ this._validateInitialization('exists')
3163
+ return this.indexManager.exists(fieldName, terms, options)
3164
+ }
3165
+
3166
+ /**
3167
+ * Calculate coverage for grouped include/exclude term sets
3168
+ * @param {string} fieldName - Name of the indexed field
3169
+ * @param {Array<object>} groups - Array of { terms, excludes } objects
3170
+ * @param {object} options - Optional settings
3171
+ * @returns {Promise<number>} Coverage percentage between 0 and 100
3172
+ */
3173
+ async coverage(fieldName, groups, options = {}) {
3174
+ this._validateInitialization('coverage')
3175
+
3176
+ if (typeof fieldName !== 'string' || !fieldName.trim()) {
3177
+ throw new Error('fieldName must be a non-empty string')
3178
+ }
3179
+
3180
+ if (!Array.isArray(groups)) {
3181
+ throw new Error('groups must be an array')
3182
+ }
3183
+
3184
+ if (groups.length === 0) {
3185
+ return 0
3186
+ }
3187
+
3188
+ if (!this.opts.indexes || !this.opts.indexes[fieldName]) {
3189
+ throw new Error(`Field "${fieldName}" is not indexed`)
3190
+ }
3191
+
3192
+ const fieldType = this.opts.indexes[fieldName]
3193
+ const supportedTypes = ['array:string', 'string']
3194
+ if (!supportedTypes.includes(fieldType)) {
3195
+ throw new Error(`coverage() only supports fields of type ${supportedTypes.join(', ')} (found: ${fieldType})`)
3196
+ }
3197
+
3198
+ const fieldIndex = this.indexManager?.index?.data?.[fieldName]
3199
+ if (!fieldIndex) {
3200
+ return 0
3201
+ }
3202
+
3203
+ const isTermMapped = this.termManager &&
3204
+ this.termManager.termMappingFields &&
3205
+ this.termManager.termMappingFields.includes(fieldName)
3206
+
3207
+ const normalizeTerm = (term) => {
3208
+ if (term === undefined || term === null) {
3209
+ return ''
3210
+ }
3211
+ return String(term).trim()
3212
+ }
3213
+
3214
+ const resolveKey = (term) => {
3215
+ if (isTermMapped) {
3216
+ const termId = this.termManager.getTermIdWithoutIncrement(term)
3217
+ if (termId === null || termId === undefined) {
3218
+ return null
3219
+ }
3220
+ return String(termId)
3221
+ }
3222
+ return String(term)
3223
+ }
3224
+
3225
+ let matchedGroups = 0
3226
+
3227
+ for (const group of groups) {
3228
+ if (!group || typeof group !== 'object') {
3229
+ throw new Error('Each coverage group must be an object')
3230
+ }
3231
+
3232
+ const includeTermsRaw = Array.isArray(group.terms) ? group.terms : []
3233
+ const excludeTermsRaw = Array.isArray(group.excludes) ? group.excludes : []
3234
+
3235
+ const includeTerms = Array.from(new Set(
3236
+ includeTermsRaw
3237
+ .map(normalizeTerm)
3238
+ .filter(term => term.length > 0)
3239
+ ))
3240
+
3241
+ if (includeTerms.length === 0) {
3242
+ throw new Error('Each coverage group must define at least one term')
3243
+ }
3244
+
3245
+ const excludeTerms = Array.from(new Set(
3246
+ excludeTermsRaw
3247
+ .map(normalizeTerm)
3248
+ .filter(term => term.length > 0)
3249
+ ))
3250
+
3251
+ let candidateLines = null
3252
+ let groupMatched = true
3253
+
3254
+ for (const term of includeTerms) {
3255
+ const key = resolveKey(term)
3256
+ if (key === null) {
3257
+ groupMatched = false
3258
+ break
3259
+ }
3260
+
3261
+ const termData = fieldIndex[key]
3262
+ if (!termData) {
3263
+ groupMatched = false
3264
+ break
3265
+ }
3266
+
3267
+ const lineNumbers = this.indexManager._getAllLineNumbers(termData)
3268
+ if (!lineNumbers || lineNumbers.length === 0) {
3269
+ groupMatched = false
3270
+ break
3271
+ }
3272
+
3273
+ if (candidateLines === null) {
3274
+ candidateLines = new Set(lineNumbers)
3275
+ } else {
3276
+ const termSet = new Set(lineNumbers)
3277
+ for (const line of Array.from(candidateLines)) {
3278
+ if (!termSet.has(line)) {
3279
+ candidateLines.delete(line)
3280
+ }
3281
+ }
3282
+ }
3283
+
3284
+ if (!candidateLines || candidateLines.size === 0) {
3285
+ groupMatched = false
3286
+ break
3287
+ }
3288
+ }
3289
+
3290
+ if (!groupMatched || !candidateLines || candidateLines.size === 0) {
3291
+ continue
3292
+ }
3293
+
3294
+ for (const term of excludeTerms) {
3295
+ const key = resolveKey(term)
3296
+ if (key === null) {
3297
+ continue
3298
+ }
3299
+
3300
+ const termData = fieldIndex[key]
3301
+ if (!termData) {
3302
+ continue
3303
+ }
3304
+
3305
+ const excludeLines = this.indexManager._getAllLineNumbers(termData)
3306
+ if (!excludeLines || excludeLines.length === 0) {
3307
+ continue
3308
+ }
3309
+
3310
+ for (const line of excludeLines) {
3311
+ if (!candidateLines.size) {
3312
+ break
3313
+ }
3314
+ candidateLines.delete(line)
3315
+ }
3316
+
3317
+ if (!candidateLines.size) {
3318
+ break
3319
+ }
3320
+ }
3321
+
3322
+ if (candidateLines && candidateLines.size > 0) {
3323
+ matchedGroups++
3324
+ }
3325
+ }
3326
+
3327
+ if (matchedGroups === 0) {
3328
+ return 0
3329
+ }
3330
+
3331
+ const precision = typeof options.precision === 'number' && options.precision >= 0
3332
+ ? options.precision
3333
+ : 2
3334
+
3335
+ const coverageValue = (matchedGroups / groups.length) * 100
3336
+ return Number(coverageValue.toFixed(precision))
2575
3337
  }
2576
3338
 
2577
3339
  /**
@@ -2589,7 +3351,8 @@ class Database extends EventEmitter {
2589
3351
  const opts = {
2590
3352
  limit: options.limit ?? 100,
2591
3353
  sort: options.sort ?? 'desc',
2592
- includeScore: options.includeScore !== false
3354
+ includeScore: options.includeScore !== false,
3355
+ mode: options.mode ?? 'sum'
2593
3356
  }
2594
3357
 
2595
3358
  // Validate fieldName
@@ -2613,6 +3376,12 @@ class Database extends EventEmitter {
2613
3376
  throw new Error(`Score value for term "${term}" must be a number`)
2614
3377
  }
2615
3378
  }
3379
+
3380
+ // Validate mode
3381
+ const allowedModes = new Set(['sum', 'max', 'avg', 'first'])
3382
+ if (!allowedModes.has(opts.mode)) {
3383
+ throw new Error(`Invalid score mode "${opts.mode}". Must be one of: ${Array.from(allowedModes).join(', ')}`)
3384
+ }
2616
3385
 
2617
3386
  // Check if field is indexed and is array:string type
2618
3387
  if (!this.opts.indexes || !this.opts.indexes[fieldName]) {
@@ -2637,6 +3406,7 @@ class Database extends EventEmitter {
2637
3406
 
2638
3407
  // Accumulate scores for each line number
2639
3408
  const scoreMap = new Map()
3409
+ const countMap = opts.mode === 'avg' ? new Map() : null
2640
3410
 
2641
3411
  // Iterate through each term in the scores object
2642
3412
  for (const [term, weight] of Object.entries(scores)) {
@@ -2666,8 +3436,44 @@ class Database extends EventEmitter {
2666
3436
 
2667
3437
  // Add weight to score for each line number
2668
3438
  for (const lineNumber of lineNumbers) {
2669
- const currentScore = scoreMap.get(lineNumber) || 0
2670
- scoreMap.set(lineNumber, currentScore + weight)
3439
+ const currentScore = scoreMap.get(lineNumber)
3440
+
3441
+ switch (opts.mode) {
3442
+ case 'sum': {
3443
+ const nextScore = (currentScore || 0) + weight
3444
+ scoreMap.set(lineNumber, nextScore)
3445
+ break
3446
+ }
3447
+ case 'max': {
3448
+ if (currentScore === undefined) {
3449
+ scoreMap.set(lineNumber, weight)
3450
+ } else {
3451
+ scoreMap.set(lineNumber, Math.max(currentScore, weight))
3452
+ }
3453
+ break
3454
+ }
3455
+ case 'avg': {
3456
+ const previous = currentScore || 0
3457
+ scoreMap.set(lineNumber, previous + weight)
3458
+ const count = (countMap.get(lineNumber) || 0) + 1
3459
+ countMap.set(lineNumber, count)
3460
+ break
3461
+ }
3462
+ case 'first': {
3463
+ if (currentScore === undefined) {
3464
+ scoreMap.set(lineNumber, weight)
3465
+ }
3466
+ break
3467
+ }
3468
+ }
3469
+ }
3470
+ }
3471
+
3472
+ // For average mode, divide total by count
3473
+ if (opts.mode === 'avg') {
3474
+ for (const [lineNumber, totalScore] of scoreMap.entries()) {
3475
+ const count = countMap.get(lineNumber) || 1
3476
+ scoreMap.set(lineNumber, totalScore / count)
2671
3477
  }
2672
3478
  }
2673
3479
 
@@ -2944,11 +3750,52 @@ class Database extends EventEmitter {
2944
3750
  }
2945
3751
 
2946
3752
  // CRITICAL FIX: Only remove processed items from writeBuffer after all async operations complete
2947
- // OPTIMIZATION: Use Set.has() for O(1) lookup - same Set used for processing
2948
3753
  const beforeLength = this.writeBuffer.length
2949
- this.writeBuffer = this.writeBuffer.filter(item => !itemsToProcess.has(item))
3754
+ if (beforeLength > 0) {
3755
+ const originalRecords = this.writeBuffer
3756
+ const originalOffsets = this.writeBufferOffsets
3757
+ const originalSizes = this.writeBufferSizes
3758
+ const retainedRecords = []
3759
+ const retainedOffsets = []
3760
+ const retainedSizes = []
3761
+ let retainedTotal = 0
3762
+ let removedCount = 0
3763
+
3764
+ for (let i = 0; i < originalRecords.length; i++) {
3765
+ const record = originalRecords[i]
3766
+ if (itemsToProcess.has(record)) {
3767
+ removedCount++
3768
+ continue
3769
+ }
3770
+
3771
+ retainedRecords.push(record)
3772
+ if (originalOffsets && i < originalOffsets.length) {
3773
+ retainedOffsets.push(originalOffsets[i])
3774
+ }
3775
+ if (originalSizes && i < originalSizes.length) {
3776
+ const size = originalSizes[i]
3777
+ if (size !== undefined) {
3778
+ retainedSizes.push(size)
3779
+ retainedTotal += size
3780
+ }
3781
+ }
3782
+ }
3783
+
3784
+ if (removedCount > 0) {
3785
+ this.writeBuffer = retainedRecords
3786
+ this.writeBufferOffsets = retainedOffsets
3787
+ this.writeBufferSizes = retainedSizes
3788
+ this.writeBufferTotalSize = retainedTotal
3789
+ }
3790
+ }
2950
3791
  const afterLength = this.writeBuffer.length
2951
3792
 
3793
+ if (afterLength === 0) {
3794
+ this.writeBufferOffsets = []
3795
+ this.writeBufferSizes = []
3796
+ this.writeBufferTotalSize = 0
3797
+ }
3798
+
2952
3799
  if (this.opts.debugMode && beforeLength !== afterLength) {
2953
3800
  console.log(`💾 _processWriteBuffer: Removed ${beforeLength - afterLength} items from writeBuffer (${beforeLength} -> ${afterLength})`)
2954
3801
  }
@@ -3534,6 +4381,240 @@ class Database extends EventEmitter {
3534
4381
  }).filter(n => n !== undefined)
3535
4382
  }
3536
4383
 
4384
+ /**
4385
+ * Get the base line number for writeBuffer entries (number of persisted records)
4386
+ * @private
4387
+ */
4388
+ _getWriteBufferBaseLineNumber() {
4389
+ return Array.isArray(this.offsets) ? this.offsets.length : 0
4390
+ }
4391
+
4392
+ /**
4393
+ * Convert a writeBuffer index into an absolute line number
4394
+ * @param {number} writeBufferIndex - Index inside writeBuffer (0-based)
4395
+ * @returns {number} Absolute line number (0-based)
4396
+ * @private
4397
+ */
4398
+ _getAbsoluteLineNumber(writeBufferIndex) {
4399
+ if (typeof writeBufferIndex !== 'number' || writeBufferIndex < 0) {
4400
+ throw new Error('Invalid writeBuffer index')
4401
+ }
4402
+ return this._getWriteBufferBaseLineNumber() + writeBufferIndex
4403
+ }
4404
+
4405
+ async *_streamingRecoveryGenerator(criteria, options, alreadyYielded = 0, map = null, remainingSkipValue = 0) {
4406
+ if (this._offsetRecoveryInProgress) {
4407
+ return
4408
+ }
4409
+
4410
+ if (!this.fileHandler || !this.fileHandler.file) {
4411
+ return
4412
+ }
4413
+
4414
+ this._offsetRecoveryInProgress = true
4415
+
4416
+ const fsModule = this._fsModule || (this._fsModule = await import('fs'))
4417
+ let fd
4418
+
4419
+ try {
4420
+ fd = await fsModule.promises.open(this.fileHandler.file, 'r')
4421
+ } catch (error) {
4422
+ this._offsetRecoveryInProgress = false
4423
+ if (this.opts.debugMode) {
4424
+ console.warn(`⚠️ Offset recovery skipped: ${error.message}`)
4425
+ }
4426
+ return
4427
+ }
4428
+
4429
+ const chunkSize = this.opts.offsetRecoveryChunkSize || 64 * 1024
4430
+ let buffer = Buffer.alloc(0)
4431
+ let readOffset = 0
4432
+ const originalOffsets = Array.isArray(this.offsets) ? [...this.offsets] : []
4433
+ const newOffsets = []
4434
+ let offsetAdjusted = false
4435
+ let limitReached = false
4436
+ let lineIndex = 0
4437
+ let lastLineEnd = 0
4438
+ let producedTotal = alreadyYielded || 0
4439
+ let remainingSkip = remainingSkipValue || 0
4440
+ let remainingAlreadyYielded = alreadyYielded || 0
4441
+ const limit = typeof options?.limit === 'number' ? options.limit : null
4442
+ const includeOffsets = options?.includeOffsets === true
4443
+ const includeLinePosition = this.opts.includeLinePosition
4444
+ const mapSet = map instanceof Set ? new Set(map) : (Array.isArray(map) ? new Set(map) : null)
4445
+ const criteriaIsObject = criteria && typeof criteria === 'object' && !Array.isArray(criteria) && !(criteria instanceof Set)
4446
+ const hasCriteria = criteriaIsObject && Object.keys(criteria).length > 0
4447
+
4448
+ const decodeLineBuffer = (lineBuffer) => {
4449
+ let trimmed = lineBuffer
4450
+ if (trimmed.length > 0 && trimmed[trimmed.length - 1] === 0x0A) {
4451
+ trimmed = trimmed.subarray(0, trimmed.length - 1)
4452
+ }
4453
+ if (trimmed.length > 0 && trimmed[trimmed.length - 1] === 0x0D) {
4454
+ trimmed = trimmed.subarray(0, trimmed.length - 1)
4455
+ }
4456
+ return trimmed
4457
+ }
4458
+
4459
+ const processLine = async (lineBuffer, lineStart) => {
4460
+ const lineLength = lineBuffer.length
4461
+ newOffsets[lineIndex] = lineStart
4462
+ const expected = originalOffsets[lineIndex]
4463
+ if (expected !== undefined && expected !== lineStart) {
4464
+ offsetAdjusted = true
4465
+ if (this.opts.debugMode) {
4466
+ console.warn(`⚠️ Offset mismatch detected at line ${lineIndex}: expected ${expected}, actual ${lineStart}`)
4467
+ }
4468
+ } else if (expected === undefined) {
4469
+ offsetAdjusted = true
4470
+ }
4471
+
4472
+ lastLineEnd = Math.max(lastLineEnd, lineStart + lineLength)
4473
+
4474
+ let entryWithTerms = null
4475
+ let shouldYield = false
4476
+
4477
+ const decodedBuffer = decodeLineBuffer(lineBuffer)
4478
+ if (decodedBuffer.length > 0) {
4479
+ let lineString
4480
+ try {
4481
+ lineString = decodedBuffer.toString('utf8')
4482
+ } catch (error) {
4483
+ lineString = decodedBuffer.toString('utf8', { replacement: '?' })
4484
+ }
4485
+
4486
+ try {
4487
+ const record = await this.serializer.deserialize(lineString)
4488
+ if (record && typeof record === 'object') {
4489
+ entryWithTerms = this.restoreTermIdsAfterDeserialization(record)
4490
+ if (includeLinePosition) {
4491
+ entryWithTerms._ = lineIndex
4492
+ }
4493
+
4494
+ if (mapSet) {
4495
+ shouldYield = mapSet.has(lineIndex)
4496
+ if (shouldYield) {
4497
+ mapSet.delete(lineIndex)
4498
+ }
4499
+ } else if (hasCriteria) {
4500
+ shouldYield = this.queryManager.matchesCriteria(entryWithTerms, criteria, options)
4501
+ } else {
4502
+ shouldYield = true
4503
+ }
4504
+ }
4505
+ } catch (error) {
4506
+ if (this.opts.debugMode) {
4507
+ console.warn(`⚠️ Offset recovery failed to deserialize line ${lineIndex} at ${lineStart}: ${error.message}`)
4508
+ }
4509
+ }
4510
+ }
4511
+
4512
+ let yieldedEntry = null
4513
+
4514
+ if (shouldYield && entryWithTerms) {
4515
+ if (remainingSkip > 0) {
4516
+ remainingSkip--
4517
+ } else if (remainingAlreadyYielded > 0) {
4518
+ remainingAlreadyYielded--
4519
+ } else if (!limit || producedTotal < limit) {
4520
+ producedTotal++
4521
+ yieldedEntry = includeOffsets
4522
+ ? { entry: entryWithTerms, start: lineStart, _: lineIndex }
4523
+ : entryWithTerms
4524
+ } else {
4525
+ limitReached = true
4526
+ }
4527
+ }
4528
+
4529
+ lineIndex++
4530
+
4531
+ if (yieldedEntry) {
4532
+ return yieldedEntry
4533
+ }
4534
+ return null
4535
+ }
4536
+
4537
+ let recoveryFailed = false
4538
+
4539
+ try {
4540
+ while (true) {
4541
+ const tempBuffer = Buffer.allocUnsafe(chunkSize)
4542
+ const { bytesRead } = await fd.read(tempBuffer, 0, chunkSize, readOffset)
4543
+
4544
+ if (bytesRead === 0) {
4545
+ if (buffer.length > 0) {
4546
+ const lineStart = readOffset - buffer.length
4547
+ const yieldedEntry = await processLine(buffer, lineStart)
4548
+ if (yieldedEntry) {
4549
+ yield yieldedEntry
4550
+ }
4551
+ }
4552
+ break
4553
+ }
4554
+
4555
+ readOffset += bytesRead
4556
+ let chunk = buffer.length > 0
4557
+ ? Buffer.concat([buffer, tempBuffer.subarray(0, bytesRead)])
4558
+ : tempBuffer.subarray(0, bytesRead)
4559
+
4560
+ let processedUpTo = 0
4561
+ const chunkBaseOffset = readOffset - chunk.length
4562
+
4563
+ while (true) {
4564
+ const newlineIndex = chunk.indexOf(0x0A, processedUpTo)
4565
+ if (newlineIndex === -1) {
4566
+ break
4567
+ }
4568
+
4569
+ const lineBuffer = chunk.subarray(processedUpTo, newlineIndex + 1)
4570
+ const lineStart = chunkBaseOffset + processedUpTo
4571
+ const yieldedEntry = await processLine(lineBuffer, lineStart)
4572
+ processedUpTo = newlineIndex + 1
4573
+
4574
+ if (yieldedEntry) {
4575
+ yield yieldedEntry
4576
+ }
4577
+ }
4578
+
4579
+ buffer = chunk.subarray(processedUpTo)
4580
+ }
4581
+ } catch (error) {
4582
+ recoveryFailed = true
4583
+ if (this.opts.debugMode) {
4584
+ console.warn(`⚠️ Offset recovery aborted: ${error.message}`)
4585
+ }
4586
+ } finally {
4587
+ await fd.close().catch(() => {})
4588
+ this._offsetRecoveryInProgress = false
4589
+
4590
+ if (recoveryFailed) {
4591
+ return
4592
+ }
4593
+
4594
+ this.offsets = newOffsets
4595
+ if (lineIndex < this.offsets.length) {
4596
+ this.offsets.length = lineIndex
4597
+ }
4598
+
4599
+ if (originalOffsets.length !== newOffsets.length) {
4600
+ offsetAdjusted = true
4601
+ }
4602
+
4603
+ this.indexOffset = lastLineEnd
4604
+
4605
+ if (offsetAdjusted) {
4606
+ this.shouldSave = true
4607
+ try {
4608
+ await this._saveIndexDataToFile()
4609
+ } catch (error) {
4610
+ if (this.opts.debugMode) {
4611
+ console.warn(`⚠️ Failed to persist recovered offsets: ${error.message}`)
4612
+ }
4613
+ }
4614
+ }
4615
+ }
4616
+ }
4617
+
3537
4618
  /**
3538
4619
  * Walk through records using streaming (real implementation)
3539
4620
  */
@@ -3547,6 +4628,7 @@ class Database extends EventEmitter {
3547
4628
  if (this.indexOffset === 0 && this.writeBuffer.length === 0) return
3548
4629
 
3549
4630
  let count = 0
4631
+ let remainingSkip = options.skip || 0
3550
4632
 
3551
4633
  let map
3552
4634
  if (!Array.isArray(criteria)) {
@@ -3557,8 +4639,9 @@ class Database extends EventEmitter {
3557
4639
  map = [...this.indexManager.query(criteria, options)]
3558
4640
  } else {
3559
4641
  // For empty criteria {} or null/undefined, get all records
3560
- // Use writeBuffer length when indexOffset is 0 (data not saved yet)
3561
- const totalRecords = this.indexOffset > 0 ? this.indexOffset : this.writeBuffer.length
4642
+ const totalRecords = this.offsets && this.offsets.length > 0
4643
+ ? this.offsets.length
4644
+ : this.writeBuffer.length
3562
4645
  map = [...Array(totalRecords).keys()]
3563
4646
  }
3564
4647
  } else {
@@ -3577,6 +4660,10 @@ class Database extends EventEmitter {
3577
4660
  }
3578
4661
  const entry = this.writeBuffer[i]
3579
4662
  if (entry && this.queryManager.matchesCriteria(entry, criteria, options)) {
4663
+ if (remainingSkip > 0) {
4664
+ remainingSkip--
4665
+ continue
4666
+ }
3580
4667
  count++
3581
4668
  if (options.includeOffsets) {
3582
4669
  yield { entry, start: 0, _: i }
@@ -3597,6 +4684,10 @@ class Database extends EventEmitter {
3597
4684
  if (lineNumber < this.writeBuffer.length) {
3598
4685
  const entry = this.writeBuffer[lineNumber]
3599
4686
  if (entry) {
4687
+ if (remainingSkip > 0) {
4688
+ remainingSkip--
4689
+ continue
4690
+ }
3600
4691
  count++
3601
4692
  if (options.includeOffsets) {
3602
4693
  yield { entry, start: 0, _: lineNumber }
@@ -3657,6 +4748,11 @@ class Database extends EventEmitter {
3657
4748
  // SPACE OPTIMIZATION: Restore term IDs to terms for user
3658
4749
  const recordWithTerms = this.restoreTermIdsAfterDeserialization(record)
3659
4750
 
4751
+ if (remainingSkip > 0) {
4752
+ remainingSkip--
4753
+ continue
4754
+ }
4755
+
3660
4756
  count++
3661
4757
  if (options.includeOffsets) {
3662
4758
  yield { entry: recordWithTerms, start: row.start, _: row._ || 0 }
@@ -3667,7 +4763,21 @@ class Database extends EventEmitter {
3667
4763
  yield recordWithTerms
3668
4764
  }
3669
4765
  } catch (error) {
3670
- // Skip invalid lines
4766
+ // CRITICAL FIX: Log deserialization errors instead of silently ignoring them
4767
+ // This helps identify data corruption issues
4768
+ if (1||this.opts.debugMode) {
4769
+ console.warn(`⚠️ walk(): Failed to deserialize record at offset ${row.start}: ${error.message}`)
4770
+ console.warn(`⚠️ walk(): Problematic line (first 200 chars): ${row.line.substring(0, 200)}`)
4771
+ }
4772
+ if (!this._offsetRecoveryInProgress) {
4773
+ for await (const recoveredEntry of this._streamingRecoveryGenerator(criteria, options, count, map, remainingSkip)) {
4774
+ yield recoveredEntry
4775
+ count++
4776
+ }
4777
+ return
4778
+ }
4779
+ // Skip invalid lines but continue processing
4780
+ // This prevents one corrupted record from stopping the entire walk operation
3671
4781
  }
3672
4782
  }
3673
4783
  if (options.limit && count >= options.limit) {
@@ -3696,6 +4806,12 @@ class Database extends EventEmitter {
3696
4806
  if (options.limit && count >= options.limit) {
3697
4807
  break
3698
4808
  }
4809
+
4810
+ if (remainingSkip > 0) {
4811
+ remainingSkip--
4812
+ continue
4813
+ }
4814
+
3699
4815
  count++
3700
4816
 
3701
4817
  // SPACE OPTIMIZATION: Restore term IDs to terms for user
@@ -3732,20 +4848,44 @@ class Database extends EventEmitter {
3732
4848
  if (options.limit && count >= options.limit) {
3733
4849
  break
3734
4850
  }
3735
- const entry = await this.serializer.deserialize(row.line, { compress: this.opts.compress, v8: this.opts.v8 })
3736
- if (entry === null) continue
3737
4851
 
3738
- // SPACE OPTIMIZATION: Restore term IDs to terms for user
3739
- const entryWithTerms = this.restoreTermIdsAfterDeserialization(entry)
4852
+ try {
4853
+ const entry = await this.serializer.deserialize(row.line, { compress: this.opts.compress, v8: this.opts.v8 })
4854
+ if (entry === null) continue
4855
+
4856
+ // SPACE OPTIMIZATION: Restore term IDs to terms for user
4857
+ const entryWithTerms = this.restoreTermIdsAfterDeserialization(entry)
3740
4858
 
3741
- count++
3742
- if (options.includeOffsets) {
3743
- yield { entry: entryWithTerms, start: row.start, _: row._ || this.offsets.findIndex(n => n === row.start) }
3744
- } else {
3745
- if (this.opts.includeLinePosition) {
3746
- entryWithTerms._ = row._ || this.offsets.findIndex(n => n === row.start)
4859
+ if (remainingSkip > 0) {
4860
+ remainingSkip--
4861
+ continue
4862
+ }
4863
+
4864
+ count++
4865
+ if (options.includeOffsets) {
4866
+ yield { entry: entryWithTerms, start: row.start, _: row._ || this.offsets.findIndex(n => n === row.start) }
4867
+ } else {
4868
+ if (this.opts.includeLinePosition) {
4869
+ entryWithTerms._ = row._ || this.offsets.findIndex(n => n === row.start)
4870
+ }
4871
+ yield entryWithTerms
3747
4872
  }
3748
- yield entryWithTerms
4873
+ } catch (error) {
4874
+ // CRITICAL FIX: Log deserialization errors instead of silently ignoring them
4875
+ // This helps identify data corruption issues
4876
+ if (1||this.opts.debugMode) {
4877
+ console.warn(`⚠️ walk(): Failed to deserialize record at offset ${row.start}: ${error.message}`)
4878
+ console.warn(`⚠️ walk(): Problematic line (first 200 chars): ${row.line.substring(0, 200)}`)
4879
+ }
4880
+ if (!this._offsetRecoveryInProgress) {
4881
+ for await (const recoveredEntry of this._streamingRecoveryGenerator(criteria, options, count, map, remainingSkip)) {
4882
+ yield recoveredEntry
4883
+ count++
4884
+ }
4885
+ return
4886
+ }
4887
+ // Skip invalid lines but continue processing
4888
+ // This prevents one corrupted record from stopping the entire walk operation
3749
4889
  }
3750
4890
  }
3751
4891
  }
@@ -3899,16 +5039,20 @@ class Database extends EventEmitter {
3899
5039
 
3900
5040
  // Update record in writeBuffer or add to writeBuffer
3901
5041
  const index = this.writeBuffer.findIndex(r => r.id === record.id)
5042
+ let targetIndex
3902
5043
  if (index !== -1) {
3903
5044
  // Record is already in writeBuffer, update it
3904
5045
  this.writeBuffer[index] = record
5046
+ targetIndex = index
3905
5047
  } else {
3906
5048
  // Record is in file, add updated version to writeBuffer
3907
5049
  this.writeBuffer.push(record)
5050
+ targetIndex = this.writeBuffer.length - 1
3908
5051
  }
3909
5052
 
3910
5053
  // Update index
3911
- await this.indexManager.update(record, record, this.writeBuffer.length - 1)
5054
+ const absoluteLineNumber = this._getAbsoluteLineNumber(targetIndex)
5055
+ await this.indexManager.update(record, record, absoluteLineNumber)
3912
5056
  }
3913
5057
 
3914
5058
  if (this.opts.debugMode) {
@@ -3982,8 +5126,24 @@ class Database extends EventEmitter {
3982
5126
  this.writeBufferSizes = []
3983
5127
  }
3984
5128
  } else {
3985
- // Even if no data to save, ensure index data is persisted
3986
- await this._saveIndexDataToFile()
5129
+ // Only save index data if it actually has content
5130
+ // Don't overwrite a valid index with an empty one
5131
+ if (this.indexManager && this.indexManager.indexedFields && this.indexManager.indexedFields.length > 0) {
5132
+ let hasIndexData = false
5133
+ for (const field of this.indexManager.indexedFields) {
5134
+ if (this.indexManager.hasUsableIndexData(field)) {
5135
+ hasIndexData = true
5136
+ break
5137
+ }
5138
+ }
5139
+ // Only save if we have actual index data OR if offsets are populated
5140
+ // (offsets being populated means we've processed data)
5141
+ if (hasIndexData || (this.offsets && this.offsets.length > 0)) {
5142
+ await this._saveIndexDataToFile()
5143
+ } else if (this.opts.debugMode) {
5144
+ console.log('⚠️ close(): Skipping index save - index is empty and no offsets')
5145
+ }
5146
+ }
3987
5147
  }
3988
5148
 
3989
5149
  // 2. Mark as closed (but not destroyed) to allow reopening
@@ -4019,8 +5179,43 @@ class Database extends EventEmitter {
4019
5179
  if (this.indexManager) {
4020
5180
  try {
4021
5181
  const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb')
5182
+ const indexJSON = this.indexManager.indexedFields && this.indexManager.indexedFields.length > 0 ? this.indexManager.toJSON() : {}
5183
+
5184
+ // Check if index is empty
5185
+ const isEmpty = !indexJSON || Object.keys(indexJSON).length === 0 ||
5186
+ (this.indexManager.indexedFields && this.indexManager.indexedFields.every(field => {
5187
+ const fieldIndex = indexJSON[field]
5188
+ return !fieldIndex || (typeof fieldIndex === 'object' && Object.keys(fieldIndex).length === 0)
5189
+ }))
5190
+
5191
+ // PROTECTION: Don't overwrite a valid index file with empty data
5192
+ // If the .idx.jdb file exists and has data, and we're trying to save empty index,
5193
+ // skip the save to prevent corruption
5194
+ if (isEmpty && !this.offsets?.length) {
5195
+ const fs = await import('fs')
5196
+ if (fs.existsSync(idxPath)) {
5197
+ try {
5198
+ const existingData = JSON.parse(await fs.promises.readFile(idxPath, 'utf8'))
5199
+ const existingHasData = existingData.index && Object.keys(existingData.index).length > 0
5200
+ const existingHasOffsets = existingData.offsets && existingData.offsets.length > 0
5201
+
5202
+ if (existingHasData || existingHasOffsets) {
5203
+ if (this.opts.debugMode) {
5204
+ console.log(`⚠️ _saveIndexDataToFile: Skipping save - would overwrite valid index with empty data`)
5205
+ }
5206
+ return // Don't overwrite valid index with empty one
5207
+ }
5208
+ } catch (error) {
5209
+ // If we can't read existing file, proceed with save (might be corrupted)
5210
+ if (this.opts.debugMode) {
5211
+ console.log(`⚠️ _saveIndexDataToFile: Could not read existing index file, proceeding with save`)
5212
+ }
5213
+ }
5214
+ }
5215
+ }
5216
+
4022
5217
  const indexData = {
4023
- index: this.indexManager.indexedFields && this.indexManager.indexedFields.length > 0 ? this.indexManager.toJSON() : {},
5218
+ index: indexJSON,
4024
5219
  offsets: this.offsets, // Save actual offsets for efficient file operations
4025
5220
  indexOffset: this.indexOffset, // Save file size for proper range calculations
4026
5221
  // Save configuration for reuse when database exists