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/dist/Database.cjs CHANGED
@@ -90,12 +90,16 @@ class InsertSession {
90
90
  constructor(database, sessionOptions = {}) {
91
91
  this.database = database;
92
92
  this.batchSize = sessionOptions.batchSize || 100;
93
+ this.enableAutoSave = sessionOptions.enableAutoSave !== undefined ? sessionOptions.enableAutoSave : true;
93
94
  this.totalInserted = 0;
94
95
  this.flushing = false;
95
96
  this.batches = []; // Array of batches to avoid slice() in flush()
96
97
  this.currentBatch = []; // Current batch being filled
97
98
  this.sessionId = Math.random().toString(36).substr(2, 9);
98
99
 
100
+ // Track pending auto-flush operations
101
+ this.pendingAutoFlushes = new Set();
102
+
99
103
  // Register this session as active
100
104
  this.database.activeInsertSessions.add(this);
101
105
  }
@@ -118,42 +122,141 @@ class InsertSession {
118
122
  this.currentBatch.push(finalRecord);
119
123
  this.totalInserted++;
120
124
 
121
- // If batch is full, move it to batches array
125
+ // If batch is full, move it to batches array and trigger auto-flush
122
126
  if (this.currentBatch.length >= this.batchSize) {
123
127
  this.batches.push(this.currentBatch);
124
128
  this.currentBatch = [];
129
+
130
+ // Auto-flush in background (non-blocking)
131
+ // This ensures batches are flushed automatically without blocking add()
132
+ this.autoFlush().catch(err => {
133
+ // Log error but don't throw - we don't want to break the add() flow
134
+ console.error('Auto-flush error in InsertSession:', err);
135
+ });
125
136
  }
126
137
  return finalRecord;
127
138
  }
128
- async flush() {
129
- // Check if there's anything to flush
130
- if (this.batches.length === 0 && this.currentBatch.length === 0) return;
131
-
132
- // Prevent concurrent flushes
139
+ async autoFlush() {
140
+ // Only flush if not already flushing
141
+ // This method will process all pending batches
133
142
  if (this.flushing) return;
134
- this.flushing = true;
135
- try {
136
- // Process all complete batches
137
- for (const batch of this.batches) {
138
- await this.database.insertBatch(batch);
139
- }
140
143
 
141
- // Process remaining records in current batch
142
- if (this.currentBatch.length > 0) {
143
- await this.database.insertBatch(this.currentBatch);
144
- }
144
+ // Create a promise for this auto-flush operation
145
+ const flushPromise = this._doFlush();
146
+ this.pendingAutoFlushes.add(flushPromise);
145
147
 
146
- // Clear all batches
148
+ // Remove from pending set when complete (success or error)
149
+ flushPromise.then(() => {
150
+ this.pendingAutoFlushes.delete(flushPromise);
151
+ }).catch(err => {
152
+ this.pendingAutoFlushes.delete(flushPromise);
153
+ throw err;
154
+ });
155
+ return flushPromise;
156
+ }
157
+ async _doFlush() {
158
+ // Check if database is destroyed or closed before starting
159
+ if (this.database.destroyed || this.database.closed) {
160
+ // Clear batches if database is closed/destroyed
147
161
  this.batches = [];
148
162
  this.currentBatch = [];
163
+ return;
164
+ }
165
+
166
+ // Prevent concurrent flushes - if already flushing, wait for it
167
+ if (this.flushing) {
168
+ // Wait for the current flush to complete
169
+ while (this.flushing) {
170
+ await new Promise(resolve => setTimeout(resolve, 1));
171
+ }
172
+ // After waiting, check if there's anything left to flush
173
+ // If another flush completed everything, we're done
174
+ if (this.batches.length === 0 && this.currentBatch.length === 0) return;
175
+
176
+ // Check again if database was closed during wait
177
+ if (this.database.destroyed || this.database.closed) {
178
+ this.batches = [];
179
+ this.currentBatch = [];
180
+ return;
181
+ }
182
+ }
183
+ this.flushing = true;
184
+ try {
185
+ // Process continuously until queue is completely empty
186
+ // This handles the case where new data is added during the flush
187
+ while (this.batches.length > 0 || this.currentBatch.length > 0) {
188
+ // Check if database was closed during processing
189
+ if (this.database.destroyed || this.database.closed) {
190
+ // Clear remaining batches
191
+ this.batches = [];
192
+ this.currentBatch = [];
193
+ return;
194
+ }
195
+
196
+ // Process all complete batches that exist at this moment
197
+ // Note: new batches may be added to this.batches during this loop
198
+ const batchesToProcess = this.batches.length;
199
+ for (let i = 0; i < batchesToProcess; i++) {
200
+ // Check again before each batch
201
+ if (this.database.destroyed || this.database.closed) {
202
+ this.batches = [];
203
+ this.currentBatch = [];
204
+ return;
205
+ }
206
+ const batch = this.batches.shift(); // Remove from front
207
+ await this.database.insertBatch(batch);
208
+ }
209
+
210
+ // Process current batch if it has data
211
+ // Note: new records may be added to currentBatch during processing
212
+ if (this.currentBatch.length > 0) {
213
+ // Check if database was closed
214
+ if (this.database.destroyed || this.database.closed) {
215
+ this.batches = [];
216
+ this.currentBatch = [];
217
+ return;
218
+ }
219
+
220
+ // Check if currentBatch reached batchSize during processing
221
+ if (this.currentBatch.length >= this.batchSize) {
222
+ // Move it to batches array and process in next iteration
223
+ this.batches.push(this.currentBatch);
224
+ this.currentBatch = [];
225
+ continue;
226
+ }
227
+
228
+ // Process the current batch
229
+ const batchToProcess = this.currentBatch;
230
+ this.currentBatch = []; // Clear before processing to allow new adds
231
+ await this.database.insertBatch(batchToProcess);
232
+ }
233
+ }
149
234
  } finally {
150
235
  this.flushing = false;
151
236
  }
152
237
  }
238
+ async flush() {
239
+ // Wait for any pending auto-flushes to complete first
240
+ await this.waitForAutoFlushes();
241
+
242
+ // Then do a final flush to ensure everything is processed
243
+ await this._doFlush();
244
+ }
245
+ async waitForAutoFlushes() {
246
+ // Wait for all pending auto-flush operations to complete
247
+ if (this.pendingAutoFlushes.size > 0) {
248
+ await Promise.all(Array.from(this.pendingAutoFlushes));
249
+ }
250
+ }
153
251
  async commit() {
154
252
  // CRITICAL FIX: Make session auto-reusable by removing committed state
155
253
  // Allow multiple commits on the same session
156
254
 
255
+ // First, wait for all pending auto-flushes to complete
256
+ await this.waitForAutoFlushes();
257
+
258
+ // Then flush any remaining data (including currentBatch)
259
+ // This ensures everything is inserted before commit returns
157
260
  await this.flush();
158
261
 
159
262
  // Reset session state for next commit cycle
@@ -168,6 +271,9 @@ class InsertSession {
168
271
  async waitForOperations(maxWaitTime = null) {
169
272
  const startTime = Date.now();
170
273
  const hasTimeout = maxWaitTime !== null && maxWaitTime !== undefined;
274
+
275
+ // Wait for auto-flushes first
276
+ await this.waitForAutoFlushes();
171
277
  while (this.flushing || this.batches.length > 0 || this.currentBatch.length > 0) {
172
278
  // Check timeout only if we have one
173
279
  if (hasTimeout && Date.now() - startTime >= maxWaitTime) {
@@ -182,7 +288,7 @@ class InsertSession {
182
288
  * Check if this session has pending operations
183
289
  */
184
290
  hasPendingOperations() {
185
- return this.flushing || this.batches.length > 0 || this.currentBatch.length > 0;
291
+ return this.pendingAutoFlushes.size > 0 || this.flushing || this.batches.length > 0 || this.currentBatch.length > 0;
186
292
  }
187
293
 
188
294
  /**
@@ -197,6 +303,7 @@ class InsertSession {
197
303
  this.currentBatch = [];
198
304
  this.totalInserted = 0;
199
305
  this.flushing = false;
306
+ this.pendingAutoFlushes.clear();
200
307
  }
201
308
  }
202
309
 
@@ -255,7 +362,10 @@ class Database extends _events.EventEmitter {
255
362
  streamingThreshold: opts.streamingThreshold || 0.8,
256
363
  // Use streaming when limit > 80% of total records
257
364
  // Serialization options
258
- enableArraySerialization: opts.enableArraySerialization !== false // Enable array serialization by default
365
+ enableArraySerialization: opts.enableArraySerialization !== false,
366
+ // Enable array serialization by default
367
+ // Index rebuild options
368
+ allowIndexRebuild: opts.allowIndexRebuild === true // Allow automatic index rebuild when corrupted (default false - throws error)
259
369
  }, opts);
260
370
 
261
371
  // CRITICAL FIX: Initialize AbortController for lifecycle management
@@ -282,6 +392,8 @@ class Database extends _events.EventEmitter {
282
392
  this.isSaving = false;
283
393
  this.lastSaveTime = null;
284
394
  this.initialized = false;
395
+ this._offsetRecoveryInProgress = false;
396
+ this.writeBufferTotalSize = 0;
285
397
 
286
398
  // Initialize managers
287
399
  this.initializeManagers();
@@ -327,10 +439,11 @@ class Database extends _events.EventEmitter {
327
439
 
328
440
  // Validate indexes array (new format) - but only if we have fields
329
441
  if (this.opts.originalIndexes && Array.isArray(this.opts.originalIndexes)) {
330
- if (!this.opts.fields) {
331
- throw new Error('Index fields array requires fields configuration. Use: { fields: {...}, indexes: [...] }');
442
+ if (this.opts.fields) {
443
+ this.validateIndexFields(this.opts.originalIndexes);
444
+ } else if (this.opts.debugMode) {
445
+ console.log('⚠️ Skipping index field validation because no fields configuration was provided');
332
446
  }
333
- this.validateIndexFields(this.opts.originalIndexes);
334
447
  }
335
448
  if (this.opts.debugMode) {
336
449
  const fieldCount = this.opts.fields ? Object.keys(this.opts.fields).length : 0;
@@ -345,9 +458,13 @@ class Database extends _events.EventEmitter {
345
458
  * Validate field types
346
459
  */
347
460
  validateFieldTypes(fields, configType) {
348
- const supportedTypes = ['string', 'number', 'boolean', 'array:string', 'array:number', 'array:boolean', 'array', 'object'];
461
+ const supportedTypes = ['string', 'number', 'boolean', 'array:string', 'array:number', 'array:boolean', 'array', 'object', 'auto'];
349
462
  const errors = [];
350
463
  for (const [fieldName, fieldType] of Object.entries(fields)) {
464
+ if (fieldType === 'auto') {
465
+ continue;
466
+ }
467
+
351
468
  // Check if type is supported
352
469
  if (!supportedTypes.includes(fieldType)) {
353
470
  errors.push(`Unsupported ${configType} type '${fieldType}' for field '${fieldName}'. Supported types: ${supportedTypes.join(', ')}`);
@@ -393,25 +510,21 @@ class Database extends _events.EventEmitter {
393
510
  * Prepare index configuration for IndexManager
394
511
  */
395
512
  prepareIndexConfiguration() {
396
- // Convert new fields/indexes format to legacy format for IndexManager
397
- if (this.opts.fields && Array.isArray(this.opts.indexes)) {
398
- // New format: { fields: {...}, indexes: [...] }
513
+ if (Array.isArray(this.opts.indexes)) {
399
514
  const indexedFields = {};
400
- const originalIndexes = [...this.opts.indexes]; // Keep original for validation
401
-
515
+ const originalIndexes = [...this.opts.indexes];
516
+ const hasFieldConfig = this.opts.fields && typeof this.opts.fields === 'object';
402
517
  for (const fieldName of this.opts.indexes) {
403
- if (this.opts.fields[fieldName]) {
518
+ if (hasFieldConfig && this.opts.fields[fieldName]) {
404
519
  indexedFields[fieldName] = this.opts.fields[fieldName];
520
+ } else {
521
+ indexedFields[fieldName] = 'auto';
405
522
  }
406
523
  }
407
-
408
- // Store original indexes for validation
409
524
  this.opts.originalIndexes = originalIndexes;
410
-
411
- // Replace indexes array with object for IndexManager
412
525
  this.opts.indexes = indexedFields;
413
526
  if (this.opts.debugMode) {
414
- console.log(`🔍 Converted fields/indexes format: ${Object.keys(indexedFields).join(', ')} [${this.instanceId}]`);
527
+ console.log(`🔍 Normalized indexes array to object: ${Object.keys(indexedFields).join(', ')} [${this.instanceId}]`);
415
528
  }
416
529
  }
417
530
  // Legacy format (indexes as object) is already compatible
@@ -445,6 +558,18 @@ class Database extends _events.EventEmitter {
445
558
  this.termManager.termMappingFields = termMappingFields;
446
559
  this.opts.termMapping = true; // Always enable term mapping for optimal performance
447
560
 
561
+ // Validation: Ensure all array:string indexed fields are in term mapping fields
562
+ if (this.opts.indexes) {
563
+ const arrayStringFields = [];
564
+ for (const [field, type] of Object.entries(this.opts.indexes)) {
565
+ if (type === 'array:string' && !termMappingFields.includes(field)) {
566
+ arrayStringFields.push(field);
567
+ }
568
+ }
569
+ if (arrayStringFields.length > 0) {
570
+ console.warn(`⚠️ Warning: The following array:string indexed fields were not added to term mapping: ${arrayStringFields.join(', ')}. This may impact performance.`);
571
+ }
572
+ }
448
573
  if (this.opts.debugMode) {
449
574
  if (termMappingFields.length > 0) {
450
575
  console.log(`🔍 TermManager initialized for fields: ${termMappingFields.join(', ')} [${this.instanceId}]`);
@@ -471,6 +596,7 @@ class Database extends _events.EventEmitter {
471
596
  this.writeBuffer = [];
472
597
  this.writeBufferOffsets = []; // Track offsets for writeBuffer records
473
598
  this.writeBufferSizes = []; // Track sizes for writeBuffer records
599
+ this.writeBufferTotalSize = 0;
474
600
  this.isInsideOperationQueue = false; // Flag to prevent deadlock in save() calls
475
601
 
476
602
  // Initialize other managers
@@ -491,8 +617,8 @@ class Database extends _events.EventEmitter {
491
617
  // Auto-detect fields that benefit from term mapping
492
618
  const termMappingFields = [];
493
619
  for (const [field, type] of Object.entries(this.opts.indexes)) {
494
- // Fields that should use term mapping
495
- if (type === 'array:string' || type === 'string') {
620
+ // Fields that should use term mapping (only array fields)
621
+ if (type === 'array:string') {
496
622
  termMappingFields.push(field);
497
623
  }
498
624
  }
@@ -690,6 +816,9 @@ class Database extends _events.EventEmitter {
690
816
  // Don't load the entire file - just initialize empty state
691
817
  // The actual record count will come from loaded offsets
692
818
  this.writeBuffer = []; // writeBuffer is only for new unsaved records
819
+ this.writeBufferOffsets = [];
820
+ this.writeBufferSizes = [];
821
+ this.writeBufferTotalSize = 0;
693
822
 
694
823
  // recordCount will be determined from loaded offsets
695
824
  // If no offsets were loaded, we'll count records only if needed
@@ -699,13 +828,49 @@ class Database extends _events.EventEmitter {
699
828
  const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb');
700
829
  try {
701
830
  const idxFileHandler = new _FileHandler.default(idxPath, this.fileMutex, this.opts);
831
+
832
+ // Check if file exists BEFORE trying to read it
833
+ const fileExists = await idxFileHandler.exists();
834
+ if (!fileExists) {
835
+ // File doesn't exist - this will be handled by catch block
836
+ throw new Error('Index file does not exist');
837
+ }
702
838
  const idxData = await idxFileHandler.readAll();
703
- if (idxData && idxData.trim()) {
839
+
840
+ // If file exists but is empty or has no content, treat as corrupted
841
+ if (!idxData || !idxData.trim()) {
842
+ // File exists but is empty - treat as corrupted
843
+ const fileExists = await this.fileHandler.exists();
844
+ if (fileExists) {
845
+ const stats = await this.fileHandler.getFileStats();
846
+ if (stats && stats.size > 0) {
847
+ // Data file has content but index is empty - corrupted
848
+ if (!this.opts.allowIndexRebuild) {
849
+ throw new Error(`Index file is corrupted: ${idxPath} exists but contains no index data, ` + `while the data file has ${stats.size} bytes. ` + `Set allowIndexRebuild: true to automatically rebuild the index, ` + `or manually fix/delete the corrupted index file.`);
850
+ }
851
+ // Schedule rebuild if allowed
852
+ if (this.opts.debugMode) {
853
+ console.log(`⚠️ Index file exists but is empty while data file has ${stats.size} bytes - scheduling rebuild`);
854
+ }
855
+ this._scheduleIndexRebuild();
856
+ // Continue execution - rebuild will happen on first query
857
+ // Don't return - let the code continue to load other things if needed
858
+ }
859
+ }
860
+ // If data file is also empty, just continue (no error needed)
861
+ // Don't return - let the code continue to load other things if needed
862
+ } else {
863
+ // File has content - parse and load it
704
864
  const parsedIdxData = JSON.parse(idxData);
705
865
 
706
866
  // Always load offsets if available (even without indexed fields)
707
867
  if (parsedIdxData.offsets && Array.isArray(parsedIdxData.offsets)) {
708
868
  this.offsets = parsedIdxData.offsets;
869
+ // CRITICAL FIX: Update IndexManager totalLines to match offsets length
870
+ // This ensures queries and length property work correctly even if offsets are reset later
871
+ if (this.indexManager && this.offsets.length > 0) {
872
+ this.indexManager.setTotalLines(this.offsets.length);
873
+ }
709
874
  if (this.opts.debugMode) {
710
875
  console.log(`📂 Loaded ${this.offsets.length} offsets from ${idxPath}`);
711
876
  }
@@ -719,23 +884,8 @@ class Database extends _events.EventEmitter {
719
884
  }
720
885
  }
721
886
 
722
- // Load index data only if available and we have indexed fields
723
- if (parsedIdxData && parsedIdxData.index && this.indexManager.indexedFields && this.indexManager.indexedFields.length > 0) {
724
- this.indexManager.load(parsedIdxData.index);
725
-
726
- // Load term mapping data from .idx file if it exists
727
- if (parsedIdxData.termMapping && this.termManager) {
728
- await this.termManager.loadTerms(parsedIdxData.termMapping);
729
- if (this.opts.debugMode) {
730
- console.log(`📂 Loaded term mapping from ${idxPath}`);
731
- }
732
- }
733
- if (this.opts.debugMode) {
734
- console.log(`📂 Loaded index data from ${idxPath}`);
735
- }
736
- }
737
-
738
887
  // Load configuration from .idx file if database exists
888
+ // CRITICAL: Load config FIRST so indexes are available for term mapping detection
739
889
  if (parsedIdxData.config) {
740
890
  const config = parsedIdxData.config;
741
891
 
@@ -748,10 +898,86 @@ class Database extends _events.EventEmitter {
748
898
  }
749
899
  if (config.indexes) {
750
900
  this.opts.indexes = config.indexes;
901
+ if (this.indexManager) {
902
+ this.indexManager.setIndexesConfig(config.indexes);
903
+ }
751
904
  if (this.opts.debugMode) {
752
905
  console.log(`📂 Loaded indexes config from ${idxPath}:`, Object.keys(config.indexes));
753
906
  }
754
907
  }
908
+
909
+ // CRITICAL FIX: Update term mapping fields AFTER loading indexes from config
910
+ // This ensures termManager knows which fields use term mapping
911
+ // (getTermMappingFields() was called during init() before indexes were loaded)
912
+ if (this.termManager && config.indexes) {
913
+ const termMappingFields = this.getTermMappingFields();
914
+ this.termManager.termMappingFields = termMappingFields;
915
+ if (this.opts.debugMode && termMappingFields.length > 0) {
916
+ console.log(`🔍 Updated term mapping fields after loading indexes: ${termMappingFields.join(', ')}`);
917
+ }
918
+ }
919
+ }
920
+
921
+ // Load term mapping data from .idx file if it exists
922
+ // CRITICAL: Load termMapping even if index is empty (terms are needed for queries)
923
+ // NOTE: termMappingFields should already be set above from config.indexes
924
+ if (parsedIdxData.termMapping && this.termManager && this.termManager.termMappingFields && this.termManager.termMappingFields.length > 0) {
925
+ await this.termManager.loadTerms(parsedIdxData.termMapping);
926
+ if (this.opts.debugMode) {
927
+ console.log(`📂 Loaded term mapping from ${idxPath} (${Object.keys(parsedIdxData.termMapping).length} terms)`);
928
+ }
929
+ }
930
+
931
+ // Load index data only if available and we have indexed fields
932
+ if (parsedIdxData && parsedIdxData.index && this.indexManager.indexedFields && this.indexManager.indexedFields.length > 0) {
933
+ this.indexManager.load(parsedIdxData.index);
934
+ if (this.opts.debugMode) {
935
+ console.log(`📂 Loaded index data from ${idxPath}`);
936
+ }
937
+
938
+ // Check if loaded index is actually empty (corrupted)
939
+ let hasAnyIndexData = false;
940
+ for (const field of this.indexManager.indexedFields) {
941
+ if (this.indexManager.hasUsableIndexData(field)) {
942
+ hasAnyIndexData = true;
943
+ break;
944
+ }
945
+ }
946
+ if (this.opts.debugMode) {
947
+ console.log(`📊 Index check: hasAnyIndexData=${hasAnyIndexData}, indexedFields=${this.indexManager.indexedFields.join(',')}`);
948
+ }
949
+
950
+ // Schedule rebuild if index is empty AND file exists with data
951
+ if (!hasAnyIndexData) {
952
+ // Check if the actual .jdb file has data
953
+ const fileExists = await this.fileHandler.exists();
954
+ if (this.opts.debugMode) {
955
+ console.log(`📊 File check: exists=${fileExists}`);
956
+ }
957
+ if (fileExists) {
958
+ const stats = await this.fileHandler.getFileStats();
959
+ if (this.opts.debugMode) {
960
+ console.log(`📊 File stats: size=${stats?.size}`);
961
+ }
962
+ if (stats && stats.size > 0) {
963
+ // File has data but index is empty - corrupted index detected
964
+ if (!this.opts.allowIndexRebuild) {
965
+ const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb');
966
+ throw new Error(`Index file is corrupted: ${idxPath} exists but contains no index data, ` + `while the data file has ${stats.size} bytes. ` + `Set allowIndexRebuild: true to automatically rebuild the index, ` + `or manually fix/delete the corrupted index file.`);
967
+ }
968
+ // Schedule rebuild if allowed
969
+ if (this.opts.debugMode) {
970
+ console.log(`⚠️ Index loaded but empty while file has ${stats.size} bytes - scheduling rebuild`);
971
+ }
972
+ this._scheduleIndexRebuild();
973
+ }
974
+ }
975
+ }
976
+ }
977
+
978
+ // Continue with remaining config loading
979
+ if (parsedIdxData.config) {
980
+ const config = parsedIdxData.config;
755
981
  if (config.originalIndexes) {
756
982
  this.opts.originalIndexes = config.originalIndexes;
757
983
  if (this.opts.debugMode) {
@@ -770,11 +996,75 @@ class Database extends _events.EventEmitter {
770
996
  }
771
997
  } catch (idxError) {
772
998
  // Index file doesn't exist or is corrupted, rebuild from data
999
+ // BUT: if error is about rebuild being disabled, re-throw it immediately
1000
+ if (idxError.message && (idxError.message.includes('allowIndexRebuild') || idxError.message.includes('corrupted'))) {
1001
+ throw idxError;
1002
+ }
1003
+
1004
+ // If error is "Index file does not exist", check if we should throw or rebuild
1005
+ if (idxError.message && idxError.message.includes('does not exist')) {
1006
+ // Check if the actual .jdb file has data that needs indexing
1007
+ try {
1008
+ const fileExists = await this.fileHandler.exists();
1009
+ if (fileExists) {
1010
+ const stats = await this.fileHandler.getFileStats();
1011
+ if (stats && stats.size > 0) {
1012
+ // File has data but index is missing
1013
+ if (!this.opts.allowIndexRebuild) {
1014
+ const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb');
1015
+ throw new Error(`Index file is missing or corrupted: ${idxPath} does not exist or is invalid, ` + `while the data file has ${stats.size} bytes. ` + `Set allowIndexRebuild: true to automatically rebuild the index, ` + `or manually create/fix the index file.`);
1016
+ }
1017
+ // Schedule rebuild if allowed
1018
+ if (this.opts.debugMode) {
1019
+ console.log(`⚠️ .jdb file has ${stats.size} bytes but index missing - scheduling rebuild`);
1020
+ }
1021
+ this._scheduleIndexRebuild();
1022
+ return; // Exit early
1023
+ }
1024
+ }
1025
+ } catch (statsError) {
1026
+ if (this.opts.debugMode) {
1027
+ console.log('⚠️ Could not check file stats:', statsError.message);
1028
+ }
1029
+ // Re-throw if it's our error about rebuild being disabled
1030
+ if (statsError.message && statsError.message.includes('allowIndexRebuild')) {
1031
+ throw statsError;
1032
+ }
1033
+ }
1034
+ // If no data file or empty, just continue (no error needed)
1035
+ return;
1036
+ }
773
1037
  if (this.opts.debugMode) {
774
- console.log('📂 No index file found, rebuilding indexes from data');
1038
+ console.log('📂 No index file found or corrupted, checking if rebuild is needed...');
1039
+ }
1040
+
1041
+ // Check if the actual .jdb file has data that needs indexing
1042
+ try {
1043
+ const fileExists = await this.fileHandler.exists();
1044
+ if (fileExists) {
1045
+ const stats = await this.fileHandler.getFileStats();
1046
+ if (stats && stats.size > 0) {
1047
+ // File has data but index is missing or corrupted
1048
+ if (!this.opts.allowIndexRebuild) {
1049
+ const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb');
1050
+ throw new Error(`Index file is missing or corrupted: ${idxPath} does not exist or is invalid, ` + `while the data file has ${stats.size} bytes. ` + `Set allowIndexRebuild: true to automatically rebuild the index, ` + `or manually create/fix the index file.`);
1051
+ }
1052
+ // Schedule rebuild if allowed
1053
+ if (this.opts.debugMode) {
1054
+ console.log(`⚠️ .jdb file has ${stats.size} bytes but index missing - scheduling rebuild`);
1055
+ }
1056
+ this._scheduleIndexRebuild();
1057
+ }
1058
+ }
1059
+ } catch (statsError) {
1060
+ if (this.opts.debugMode) {
1061
+ console.log('⚠️ Could not check file stats:', statsError.message);
1062
+ }
1063
+ // Re-throw if it's our error about rebuild being disabled
1064
+ if (statsError.message && statsError.message.includes('allowIndexRebuild')) {
1065
+ throw statsError;
1066
+ }
775
1067
  }
776
- // We can't rebuild index without violating no-memory-storage rule
777
- // Index will be rebuilt as needed during queries
778
1068
  }
779
1069
  } else {
780
1070
  // No indexed fields, no need to rebuild indexes
@@ -800,6 +1090,23 @@ class Database extends _events.EventEmitter {
800
1090
  console.log(`💾 save() called: writeBuffer.length=${this.writeBuffer.length}, offsets.length=${this.offsets.length}`);
801
1091
  }
802
1092
 
1093
+ // CRITICAL FIX: Wait for all active insert sessions to complete their auto-flushes
1094
+ // This prevents race conditions where save() writes data while auto-flushes are still adding to writeBuffer
1095
+ if (this.activeInsertSessions && this.activeInsertSessions.size > 0) {
1096
+ if (this.opts.debugMode) {
1097
+ console.log(`⏳ save(): Waiting for ${this.activeInsertSessions.size} active insert sessions to complete auto-flushes`);
1098
+ }
1099
+ const sessionPromises = Array.from(this.activeInsertSessions).map(session => session.waitForAutoFlushes().catch(err => {
1100
+ if (this.opts.debugMode) {
1101
+ console.warn(`⚠️ save(): Error waiting for insert session: ${err.message}`);
1102
+ }
1103
+ }));
1104
+ await Promise.all(sessionPromises);
1105
+ if (this.opts.debugMode) {
1106
+ console.log(`✅ save(): All insert sessions completed auto-flushes`);
1107
+ }
1108
+ }
1109
+
803
1110
  // Auto-save removed - no need to pause anything
804
1111
 
805
1112
  try {
@@ -1165,8 +1472,33 @@ class Database extends _events.EventEmitter {
1165
1472
  }
1166
1473
 
1167
1474
  // Rebuild index from the saved records
1475
+ // CRITICAL: Process term mapping for records loaded from file to ensure ${field}Ids are available
1168
1476
  for (let i = 0; i < allData.length; i++) {
1169
- const record = allData[i];
1477
+ let record = allData[i];
1478
+
1479
+ // CRITICAL FIX: Ensure records have ${field}Ids for term mapping fields
1480
+ // Records from writeBuffer already have ${field}Ids from processTermMapping
1481
+ // Records from file need to be processed to restore ${field}Ids
1482
+ const termMappingFields = this.getTermMappingFields();
1483
+ if (termMappingFields.length > 0 && this.termManager) {
1484
+ for (const field of termMappingFields) {
1485
+ if (record[field] && Array.isArray(record[field])) {
1486
+ // Check if field contains term IDs (numbers) or terms (strings)
1487
+ const firstValue = record[field][0];
1488
+ if (typeof firstValue === 'number') {
1489
+ // Already term IDs, create ${field}Ids
1490
+ record[`${field}Ids`] = record[field];
1491
+ } else if (typeof firstValue === 'string') {
1492
+ // Terms, need to convert to term IDs
1493
+ const termIds = record[field].map(term => {
1494
+ const termId = this.termManager.getTermIdWithoutIncrement(term);
1495
+ return termId !== undefined ? termId : this.termManager.getTermId(term);
1496
+ });
1497
+ record[`${field}Ids`] = termIds;
1498
+ }
1499
+ }
1500
+ }
1501
+ }
1170
1502
  await this.indexManager.add(record, i);
1171
1503
  }
1172
1504
  }
@@ -1199,6 +1531,8 @@ class Database extends _events.EventEmitter {
1199
1531
  this.writeBuffer = [];
1200
1532
  this.writeBufferOffsets = [];
1201
1533
  this.writeBufferSizes = [];
1534
+ this.writeBufferTotalSize = 0;
1535
+ this.writeBufferTotalSize = 0;
1202
1536
  }
1203
1537
 
1204
1538
  // indexOffset already set correctly to currentOffset (total file size) above
@@ -1404,18 +1738,15 @@ class Database extends _events.EventEmitter {
1404
1738
  }
1405
1739
 
1406
1740
  // OPTIMIZATION: Process records using pre-computed term IDs
1407
- return records.map(record => {
1408
- const processedRecord = {
1409
- ...record
1410
- };
1741
+ for (const record of records) {
1411
1742
  for (const field of termMappingFields) {
1412
1743
  if (record[field] && Array.isArray(record[field])) {
1413
1744
  const termIds = record[field].map(term => termIdMap.get(term));
1414
- processedRecord[`${field}Ids`] = termIds;
1745
+ record[`${field}Ids`] = termIds;
1415
1746
  }
1416
1747
  }
1417
- return processedRecord;
1418
- });
1748
+ }
1749
+ return records;
1419
1750
  }
1420
1751
 
1421
1752
  /**
@@ -1507,17 +1838,18 @@ class Database extends _events.EventEmitter {
1507
1838
  // OPTIMIZATION: Calculate and store offset and size for writeBuffer record
1508
1839
  // SPACE OPTIMIZATION: Remove term IDs before serialization
1509
1840
  const cleanRecord = this.removeTermIdsForSerialization(record);
1510
- const recordJson = this.serializer.serialize(cleanRecord).toString('utf8');
1511
- const recordSize = Buffer.byteLength(recordJson, 'utf8');
1841
+ const recordBuffer = this.serializer.serialize(cleanRecord);
1842
+ const recordSize = recordBuffer.length;
1512
1843
 
1513
1844
  // Calculate offset based on end of file + previous writeBuffer sizes
1514
- const previousWriteBufferSize = this.writeBufferSizes.reduce((sum, size) => sum + size, 0);
1845
+ const previousWriteBufferSize = this.writeBufferTotalSize;
1515
1846
  const recordOffset = this.indexOffset + previousWriteBufferSize;
1516
1847
  this.writeBufferOffsets.push(recordOffset);
1517
1848
  this.writeBufferSizes.push(recordSize);
1849
+ this.writeBufferTotalSize += recordSize;
1518
1850
 
1519
- // OPTIMIZATION: Use the current writeBuffer size as the line number (0-based index)
1520
- const lineNumber = this.writeBuffer.length - 1;
1851
+ // OPTIMIZATION: Use the absolute line number (persisted records + writeBuffer index)
1852
+ const lineNumber = this._getAbsoluteLineNumber(this.writeBuffer.length - 1);
1521
1853
 
1522
1854
  // OPTIMIZATION: Defer index updates to batch processing
1523
1855
  // Store the record for batch index processing
@@ -1587,7 +1919,7 @@ class Database extends _events.EventEmitter {
1587
1919
  console.log(`💾 _insertBatchInternal: processing size=${dataArray.length}, startWriteBuffer=${this.writeBuffer.length}`);
1588
1920
  }
1589
1921
  const records = [];
1590
- const startLineNumber = this.writeBuffer.length;
1922
+ const existingWriteBufferLength = this.writeBuffer.length;
1591
1923
 
1592
1924
  // Initialize schema if not already done (auto-detect from first record)
1593
1925
  if (this.serializer && !this.serializer.schemaManager.isInitialized && dataArray.length > 0) {
@@ -1621,25 +1953,26 @@ class Database extends _events.EventEmitter {
1621
1953
  this.writeBuffer.push(...schemaEnforcedRecords);
1622
1954
 
1623
1955
  // OPTIMIZATION: Calculate offsets and sizes in batch (O(n))
1624
- let runningTotalSize = this.writeBufferSizes.reduce((sum, size) => sum + size, 0);
1956
+ let runningTotalSize = this.writeBufferTotalSize;
1625
1957
  for (let i = 0; i < processedRecords.length; i++) {
1626
1958
  const record = processedRecords[i];
1627
1959
  // SPACE OPTIMIZATION: Remove term IDs before serialization
1628
1960
  const cleanRecord = this.removeTermIdsForSerialization(record);
1629
- const recordJson = this.serializer.serialize(cleanRecord).toString('utf8');
1630
- const recordSize = Buffer.byteLength(recordJson, 'utf8');
1961
+ const recordBuffer = this.serializer.serialize(cleanRecord);
1962
+ const recordSize = recordBuffer.length;
1631
1963
  const recordOffset = this.indexOffset + runningTotalSize;
1632
1964
  runningTotalSize += recordSize;
1633
1965
  this.writeBufferOffsets.push(recordOffset);
1634
1966
  this.writeBufferSizes.push(recordSize);
1635
1967
  }
1968
+ this.writeBufferTotalSize = runningTotalSize;
1636
1969
 
1637
1970
  // OPTIMIZATION: Batch process index updates
1638
1971
  if (!this.pendingIndexUpdates) {
1639
1972
  this.pendingIndexUpdates = [];
1640
1973
  }
1641
1974
  for (let i = 0; i < processedRecords.length; i++) {
1642
- const lineNumber = startLineNumber + i;
1975
+ const lineNumber = this._getAbsoluteLineNumber(existingWriteBufferLength + i);
1643
1976
  this.pendingIndexUpdates.push({
1644
1977
  record: processedRecords[i],
1645
1978
  lineNumber
@@ -1678,7 +2011,7 @@ class Database extends _events.EventEmitter {
1678
2011
  try {
1679
2012
  // Validate indexed query mode if enabled
1680
2013
  if (this.opts.indexedQueryMode === 'strict') {
1681
- this._validateIndexedQuery(criteria);
2014
+ this._validateIndexedQuery(criteria, options);
1682
2015
  }
1683
2016
 
1684
2017
  // Get results from file (QueryManager already handles term ID restoration)
@@ -1740,8 +2073,14 @@ class Database extends _events.EventEmitter {
1740
2073
  /**
1741
2074
  * Validate indexed query mode for strict mode
1742
2075
  * @private
2076
+ * @param {Object} criteria - Query criteria
2077
+ * @param {Object} options - Query options
1743
2078
  */
1744
- _validateIndexedQuery(criteria) {
2079
+ _validateIndexedQuery(criteria, options = {}) {
2080
+ // Allow bypassing strict mode validation with allowNonIndexed option
2081
+ if (options.allowNonIndexed === true) {
2082
+ return; // Skip validation for this query
2083
+ }
1745
2084
  if (!criteria || typeof criteria !== 'object') {
1746
2085
  return; // Allow null/undefined criteria
1747
2086
  }
@@ -2014,7 +2353,7 @@ class Database extends _events.EventEmitter {
2014
2353
  if (index !== -1) {
2015
2354
  // Record is already in writeBuffer, update it
2016
2355
  this.writeBuffer[index] = updated;
2017
- lineNumber = index;
2356
+ lineNumber = this._getAbsoluteLineNumber(index);
2018
2357
  if (this.opts.debugMode) {
2019
2358
  console.log(`🔄 UPDATE: Updated existing writeBuffer record at index ${index}`);
2020
2359
  }
@@ -2022,7 +2361,7 @@ class Database extends _events.EventEmitter {
2022
2361
  // Record is in file, add updated version to writeBuffer
2023
2362
  // This will ensure the updated record is saved and replaces the file version
2024
2363
  this.writeBuffer.push(updated);
2025
- lineNumber = this.writeBuffer.length - 1;
2364
+ lineNumber = this._getAbsoluteLineNumber(this.writeBuffer.length - 1);
2026
2365
  if (this.opts.debugMode) {
2027
2366
  console.log(`🔄 UPDATE: Added new record to writeBuffer at index ${lineNumber}`);
2028
2367
  }
@@ -2195,6 +2534,21 @@ class Database extends _events.EventEmitter {
2195
2534
  const savedRecords = this.offsets.length;
2196
2535
  const writeBufferRecords = this.writeBuffer.length;
2197
2536
 
2537
+ // CRITICAL FIX: If offsets are empty but indexOffset exists, use fallback calculation
2538
+ // This handles cases where offsets weren't loaded or were reset
2539
+ if (savedRecords === 0 && this.indexOffset > 0 && this.initialized) {
2540
+ // Try to use IndexManager totalLines if available
2541
+ if (this.indexManager && this.indexManager.totalLines > 0) {
2542
+ return this.indexManager.totalLines + writeBufferRecords;
2543
+ }
2544
+
2545
+ // Fallback: estimate from indexOffset (less accurate but better than 0)
2546
+ // This is a defensive fix for cases where offsets are missing but file has data
2547
+ if (this.opts.debugMode) {
2548
+ console.log(`⚠️ LENGTH: offsets array is empty but indexOffset=${this.indexOffset}, using IndexManager.totalLines or estimation`);
2549
+ }
2550
+ }
2551
+
2198
2552
  // CRITICAL FIX: Validate that offsets array is consistent with actual data
2199
2553
  // This prevents the bug where database reassignment causes desynchronization
2200
2554
  if (this.initialized && savedRecords > 0) {
@@ -2238,21 +2592,7 @@ class Database extends _events.EventEmitter {
2238
2592
  * Calculate current writeBuffer size in bytes (similar to published v1.1.0)
2239
2593
  */
2240
2594
  currentWriteBufferSize() {
2241
- if (!this.writeBuffer || this.writeBuffer.length === 0) {
2242
- return 0;
2243
- }
2244
-
2245
- // Calculate total size of all records in writeBuffer
2246
- let totalSize = 0;
2247
- for (const record of this.writeBuffer) {
2248
- if (record) {
2249
- // SPACE OPTIMIZATION: Remove term IDs before size calculation
2250
- const cleanRecord = this.removeTermIdsForSerialization(record);
2251
- const recordJson = JSON.stringify(cleanRecord) + '\n';
2252
- totalSize += Buffer.byteLength(recordJson, 'utf8');
2253
- }
2254
- }
2255
- return totalSize;
2595
+ return this.writeBufferTotalSize || 0;
2256
2596
  }
2257
2597
 
2258
2598
  /**
@@ -2284,91 +2624,291 @@ class Database extends _events.EventEmitter {
2284
2624
  }
2285
2625
 
2286
2626
  /**
2287
- * Destroy database - DESTRUCTIVE MODE
2288
- * Assumes save() has already been called by user
2289
- * If anything is still active, it indicates a bug - log error and force cleanup
2627
+ * Schedule index rebuild when index data is missing or corrupted
2628
+ * @private
2290
2629
  */
2291
- async destroy() {
2292
- if (this.destroyed) return;
2293
-
2294
- // Mark as destroying immediately to prevent new operations
2295
- this.destroying = true;
2296
-
2297
- // Wait for all active insert sessions to complete before destroying
2298
- if (this.activeInsertSessions.size > 0) {
2299
- if (this.opts.debugMode) {
2300
- console.log(`⏳ destroy: Waiting for ${this.activeInsertSessions.size} active insert sessions`);
2301
- }
2302
- const sessionPromises = Array.from(this.activeInsertSessions).map(session => session.waitForOperations(null) // Wait indefinitely for sessions to complete
2303
- );
2304
- try {
2305
- await Promise.all(sessionPromises);
2306
- } catch (error) {
2307
- if (this.opts.debugMode) {
2308
- console.log(`⚠️ destroy: Error waiting for sessions: ${error.message}`);
2309
- }
2310
- // Continue with destruction even if sessions have issues
2311
- }
2312
-
2313
- // Destroy all active sessions
2314
- for (const session of this.activeInsertSessions) {
2315
- session.destroy();
2316
- }
2317
- this.activeInsertSessions.clear();
2318
- }
2630
+ _scheduleIndexRebuild() {
2631
+ // Mark that rebuild is needed
2632
+ this._indexRebuildNeeded = true;
2319
2633
 
2320
- // CRITICAL FIX: Add timeout protection to prevent destroy() from hanging
2321
- const destroyPromise = this._performDestroy();
2322
- let timeoutHandle = null;
2323
- const timeoutPromise = new Promise((_, reject) => {
2324
- timeoutHandle = setTimeout(() => {
2325
- reject(new Error('Destroy operation timed out after 5 seconds'));
2326
- }, 5000);
2327
- });
2328
- try {
2329
- await Promise.race([destroyPromise, timeoutPromise]);
2330
- } catch (error) {
2331
- if (error.message === 'Destroy operation timed out after 5 seconds') {
2332
- console.error('🚨 DESTROY TIMEOUT: Force destroying database after timeout');
2333
- // Force mark as destroyed even if cleanup failed
2334
- this.destroyed = true;
2335
- this.destroying = false;
2336
- return;
2337
- }
2338
- throw error;
2339
- } finally {
2340
- // Clear the timeout to prevent Jest open handle warning
2341
- if (timeoutHandle) {
2342
- clearTimeout(timeoutHandle);
2343
- }
2344
- }
2634
+ // Rebuild will happen lazily on first query if index is empty
2635
+ // This avoids blocking init() but ensures index is available when needed
2345
2636
  }
2346
2637
 
2347
2638
  /**
2348
- * Internal destroy implementation
2639
+ * Rebuild indexes from data file if needed
2640
+ * @private
2349
2641
  */
2350
- async _performDestroy() {
2351
- try {
2352
- // CRITICAL: Check for bugs - anything active indicates save() didn't work properly
2353
- const bugs = [];
2354
-
2355
- // Check for pending data that should have been saved
2356
- if (this.writeBuffer.length > 0) {
2357
- const bug = `BUG: writeBuffer has ${this.writeBuffer.length} records - save() should have cleared this`;
2358
- bugs.push(bug);
2359
- console.error(`🚨 ${bug}`);
2360
- }
2642
+ async _rebuildIndexesIfNeeded() {
2643
+ if (this.opts.debugMode) {
2644
+ console.log(`🔍 _rebuildIndexesIfNeeded called: _indexRebuildNeeded=${this._indexRebuildNeeded}`);
2645
+ }
2646
+ if (!this._indexRebuildNeeded) return;
2647
+ if (!this.indexManager || !this.indexManager.indexedFields || this.indexManager.indexedFields.length === 0) return;
2361
2648
 
2362
- // Check for pending operations that should have completed
2363
- if (this.pendingOperations.size > 0) {
2364
- const bug = `BUG: ${this.pendingOperations.size} pending operations - save() should have completed these`;
2365
- bugs.push(bug);
2366
- console.error(`🚨 ${bug}`);
2649
+ // Check if index actually needs rebuilding
2650
+ let needsRebuild = false;
2651
+ for (const field of this.indexManager.indexedFields) {
2652
+ if (!this.indexManager.hasUsableIndexData(field)) {
2653
+ needsRebuild = true;
2654
+ break;
2367
2655
  }
2656
+ }
2657
+ if (!needsRebuild) {
2658
+ this._indexRebuildNeeded = false;
2659
+ return;
2660
+ }
2368
2661
 
2369
- // Auto-save manager removed - no cleanup needed
2662
+ // Check if rebuild is allowed
2663
+ if (!this.opts.allowIndexRebuild) {
2664
+ const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb');
2665
+ throw new Error(`Index rebuild required but disabled: Index file ${idxPath} is corrupted or missing, ` + `and allowIndexRebuild is set to false. ` + `Set allowIndexRebuild: true to automatically rebuild the index, ` + `or manually fix/delete the corrupted index file.`);
2666
+ }
2667
+ if (this.opts.debugMode) {
2668
+ console.log('🔨 Rebuilding indexes from data file...');
2669
+ }
2670
+ try {
2671
+ // Read all records and rebuild index
2672
+ let count = 0;
2673
+ const startTime = Date.now();
2370
2674
 
2371
- // Check for active save operation
2675
+ // Auto-detect schema from first line if not initialized
2676
+ if (!this.serializer.schemaManager.isInitialized) {
2677
+ const fs = await Promise.resolve().then(() => _interopRequireWildcard(require('fs')));
2678
+ const readline = await Promise.resolve().then(() => _interopRequireWildcard(require('readline')));
2679
+ const stream = fs.createReadStream(this.fileHandler.file, {
2680
+ highWaterMark: 64 * 1024,
2681
+ encoding: 'utf8'
2682
+ });
2683
+ const rl = readline.createInterface({
2684
+ input: stream,
2685
+ crlfDelay: Infinity
2686
+ });
2687
+ var _iteratorAbruptCompletion = false;
2688
+ var _didIteratorError = false;
2689
+ var _iteratorError;
2690
+ try {
2691
+ for (var _iterator = _asyncIterator(rl), _step; _iteratorAbruptCompletion = !(_step = await _iterator.next()).done; _iteratorAbruptCompletion = false) {
2692
+ const line = _step.value;
2693
+ {
2694
+ if (line && line.trim()) {
2695
+ try {
2696
+ const firstRecord = JSON.parse(line);
2697
+ if (Array.isArray(firstRecord)) {
2698
+ // Try to infer schema from opts.fields if available
2699
+ if (this.opts.fields && typeof this.opts.fields === 'object') {
2700
+ const fieldNames = Object.keys(this.opts.fields);
2701
+ if (fieldNames.length >= firstRecord.length) {
2702
+ // Use first N fields from opts.fields to match array length
2703
+ const schema = fieldNames.slice(0, firstRecord.length);
2704
+ this.serializer.initializeSchema(schema);
2705
+ if (this.opts.debugMode) {
2706
+ console.log(`🔍 Inferred schema from opts.fields: ${schema.join(', ')}`);
2707
+ }
2708
+ } else {
2709
+ throw new Error(`Cannot rebuild index: array has ${firstRecord.length} elements but opts.fields only defines ${fieldNames.length} fields. Schema must be explicitly provided.`);
2710
+ }
2711
+ } else {
2712
+ throw new Error('Cannot rebuild index: schema missing, file uses array format, and opts.fields not provided. The .idx.jdb file is corrupted.');
2713
+ }
2714
+ } else {
2715
+ // Object format, initialize from object keys
2716
+ this.serializer.initializeSchema(firstRecord, true);
2717
+ if (this.opts.debugMode) {
2718
+ console.log(`🔍 Auto-detected schema from object: ${Object.keys(firstRecord).join(', ')}`);
2719
+ }
2720
+ }
2721
+ break;
2722
+ } catch (error) {
2723
+ if (this.opts.debugMode) {
2724
+ console.error('❌ Failed to auto-detect schema:', error.message);
2725
+ }
2726
+ throw error;
2727
+ }
2728
+ }
2729
+ }
2730
+ }
2731
+ } catch (err) {
2732
+ _didIteratorError = true;
2733
+ _iteratorError = err;
2734
+ } finally {
2735
+ try {
2736
+ if (_iteratorAbruptCompletion && _iterator.return != null) {
2737
+ await _iterator.return();
2738
+ }
2739
+ } finally {
2740
+ if (_didIteratorError) {
2741
+ throw _iteratorError;
2742
+ }
2743
+ }
2744
+ }
2745
+ stream.destroy();
2746
+ }
2747
+
2748
+ // Use streaming to read records without loading everything into memory
2749
+ // Also rebuild offsets while we're at it
2750
+ const fs = await Promise.resolve().then(() => _interopRequireWildcard(require('fs')));
2751
+ const readline = await Promise.resolve().then(() => _interopRequireWildcard(require('readline')));
2752
+ this.offsets = [];
2753
+ let currentOffset = 0;
2754
+ const stream = fs.createReadStream(this.fileHandler.file, {
2755
+ highWaterMark: 64 * 1024,
2756
+ encoding: 'utf8'
2757
+ });
2758
+ const rl = readline.createInterface({
2759
+ input: stream,
2760
+ crlfDelay: Infinity
2761
+ });
2762
+ try {
2763
+ var _iteratorAbruptCompletion2 = false;
2764
+ var _didIteratorError2 = false;
2765
+ var _iteratorError2;
2766
+ try {
2767
+ for (var _iterator2 = _asyncIterator(rl), _step2; _iteratorAbruptCompletion2 = !(_step2 = await _iterator2.next()).done; _iteratorAbruptCompletion2 = false) {
2768
+ const line = _step2.value;
2769
+ {
2770
+ if (line && line.trim()) {
2771
+ try {
2772
+ // Record the offset for this line
2773
+ this.offsets.push(currentOffset);
2774
+ const record = this.serializer.deserialize(line);
2775
+ const recordWithTerms = this.restoreTermIdsAfterDeserialization(record);
2776
+ await this.indexManager.add(recordWithTerms, count);
2777
+ count++;
2778
+ } catch (error) {
2779
+ // Skip invalid lines
2780
+ if (this.opts.debugMode) {
2781
+ console.log(`⚠️ Rebuild: Failed to deserialize line ${count}:`, error.message);
2782
+ }
2783
+ }
2784
+ }
2785
+ // Update offset for next line (including newline character)
2786
+ currentOffset += Buffer.byteLength(line, 'utf8') + 1;
2787
+ }
2788
+ }
2789
+ } catch (err) {
2790
+ _didIteratorError2 = true;
2791
+ _iteratorError2 = err;
2792
+ } finally {
2793
+ try {
2794
+ if (_iteratorAbruptCompletion2 && _iterator2.return != null) {
2795
+ await _iterator2.return();
2796
+ }
2797
+ } finally {
2798
+ if (_didIteratorError2) {
2799
+ throw _iteratorError2;
2800
+ }
2801
+ }
2802
+ }
2803
+ } finally {
2804
+ stream.destroy();
2805
+ }
2806
+
2807
+ // Update indexManager totalLines
2808
+ if (this.indexManager) {
2809
+ this.indexManager.setTotalLines(this.offsets.length);
2810
+ }
2811
+ this._indexRebuildNeeded = false;
2812
+ if (this.opts.debugMode) {
2813
+ console.log(`✅ Index rebuilt from ${count} records in ${Date.now() - startTime}ms`);
2814
+ }
2815
+
2816
+ // Save the rebuilt index
2817
+ await this._saveIndexDataToFile();
2818
+ } catch (error) {
2819
+ if (this.opts.debugMode) {
2820
+ console.error('❌ Failed to rebuild indexes:', error.message);
2821
+ }
2822
+ // Don't throw - queries will fall back to streaming
2823
+ }
2824
+ }
2825
+
2826
+ /**
2827
+ * Destroy database - DESTRUCTIVE MODE
2828
+ * Assumes save() has already been called by user
2829
+ * If anything is still active, it indicates a bug - log error and force cleanup
2830
+ */
2831
+ async destroy() {
2832
+ if (this.destroyed) return;
2833
+
2834
+ // Mark as destroying immediately to prevent new operations
2835
+ this.destroying = true;
2836
+
2837
+ // Wait for all active insert sessions to complete before destroying
2838
+ if (this.activeInsertSessions.size > 0) {
2839
+ if (this.opts.debugMode) {
2840
+ console.log(`⏳ destroy: Waiting for ${this.activeInsertSessions.size} active insert sessions`);
2841
+ }
2842
+ const sessionPromises = Array.from(this.activeInsertSessions).map(session => session.waitForOperations(null) // Wait indefinitely for sessions to complete
2843
+ );
2844
+ try {
2845
+ await Promise.all(sessionPromises);
2846
+ } catch (error) {
2847
+ if (this.opts.debugMode) {
2848
+ console.log(`⚠️ destroy: Error waiting for sessions: ${error.message}`);
2849
+ }
2850
+ // Continue with destruction even if sessions have issues
2851
+ }
2852
+
2853
+ // Destroy all active sessions
2854
+ for (const session of this.activeInsertSessions) {
2855
+ session.destroy();
2856
+ }
2857
+ this.activeInsertSessions.clear();
2858
+ }
2859
+
2860
+ // CRITICAL FIX: Add timeout protection to prevent destroy() from hanging
2861
+ const destroyPromise = this._performDestroy();
2862
+ let timeoutHandle = null;
2863
+ const timeoutPromise = new Promise((_, reject) => {
2864
+ timeoutHandle = setTimeout(() => {
2865
+ reject(new Error('Destroy operation timed out after 5 seconds'));
2866
+ }, 5000);
2867
+ });
2868
+ try {
2869
+ await Promise.race([destroyPromise, timeoutPromise]);
2870
+ } catch (error) {
2871
+ if (error.message === 'Destroy operation timed out after 5 seconds') {
2872
+ console.error('🚨 DESTROY TIMEOUT: Force destroying database after timeout');
2873
+ // Force mark as destroyed even if cleanup failed
2874
+ this.destroyed = true;
2875
+ this.destroying = false;
2876
+ return;
2877
+ }
2878
+ throw error;
2879
+ } finally {
2880
+ // Clear the timeout to prevent Jest open handle warning
2881
+ if (timeoutHandle) {
2882
+ clearTimeout(timeoutHandle);
2883
+ }
2884
+ }
2885
+ }
2886
+
2887
+ /**
2888
+ * Internal destroy implementation
2889
+ */
2890
+ async _performDestroy() {
2891
+ try {
2892
+ // CRITICAL: Check for bugs - anything active indicates save() didn't work properly
2893
+ const bugs = [];
2894
+
2895
+ // Check for pending data that should have been saved
2896
+ if (this.writeBuffer.length > 0) {
2897
+ const bug = `BUG: writeBuffer has ${this.writeBuffer.length} records - save() should have cleared this`;
2898
+ bugs.push(bug);
2899
+ console.error(`🚨 ${bug}`);
2900
+ }
2901
+
2902
+ // Check for pending operations that should have completed
2903
+ if (this.pendingOperations.size > 0) {
2904
+ const bug = `BUG: ${this.pendingOperations.size} pending operations - save() should have completed these`;
2905
+ bugs.push(bug);
2906
+ console.error(`🚨 ${bug}`);
2907
+ }
2908
+
2909
+ // Auto-save manager removed - no cleanup needed
2910
+
2911
+ // Check for active save operation
2372
2912
  if (this.isSaving) {
2373
2913
  const bug = `BUG: save operation still active - previous save() should have completed`;
2374
2914
  bugs.push(bug);
@@ -2396,6 +2936,8 @@ class Database extends _events.EventEmitter {
2396
2936
  this.writeBuffer = [];
2397
2937
  this.writeBufferOffsets = [];
2398
2938
  this.writeBufferSizes = [];
2939
+ this.writeBufferTotalSize = 0;
2940
+ this.writeBufferTotalSize = 0;
2399
2941
  this.deletedIds.clear();
2400
2942
  this.pendingOperations.clear();
2401
2943
  this.pendingIndexUpdates = [];
@@ -2460,8 +3002,393 @@ class Database extends _events.EventEmitter {
2460
3002
  */
2461
3003
  async count(criteria = {}, options = {}) {
2462
3004
  this._validateInitialization('count');
2463
- const results = await this.find(criteria, options);
2464
- return results.length;
3005
+
3006
+ // OPTIMIZATION: Use queryManager.count() instead of find() for better performance
3007
+ // This is especially faster for indexed queries which can use indexManager.query().size
3008
+ const fileCount = await this.queryManager.count(criteria, options);
3009
+
3010
+ // Count matching records in writeBuffer
3011
+ const writeBufferCount = this.writeBuffer.filter(record => this.queryManager.matchesCriteria(record, criteria, options)).length;
3012
+ return fileCount + writeBufferCount;
3013
+ }
3014
+
3015
+ /**
3016
+ * Check if any records exist for given field and terms (index-only, ultra-fast)
3017
+ * Delegates to IndexManager.exists() for maximum performance
3018
+ *
3019
+ * @param {string} fieldName - Indexed field name
3020
+ * @param {string|Array<string>} terms - Single term or array of terms
3021
+ * @param {Object} options - Options: { $all: true/false, caseInsensitive: true/false, excludes: Array<string> }
3022
+ * @returns {Promise<boolean>} - True if at least one match exists
3023
+ *
3024
+ * @example
3025
+ * // Check if channel exists
3026
+ * const exists = await db.exists('nameTerms', ['a', 'e'], { $all: true });
3027
+ *
3028
+ * @example
3029
+ * // Check if 'tv' exists but not 'globo'
3030
+ * const exists = await db.exists('nameTerms', 'tv', { excludes: ['globo'] });
3031
+ */
3032
+ async exists(fieldName, terms, options = {}) {
3033
+ this._validateInitialization('exists');
3034
+ return this.indexManager.exists(fieldName, terms, options);
3035
+ }
3036
+
3037
+ /**
3038
+ * Calculate coverage for grouped include/exclude term sets
3039
+ * @param {string} fieldName - Name of the indexed field
3040
+ * @param {Array<object>} groups - Array of { terms, excludes } objects
3041
+ * @param {object} options - Optional settings
3042
+ * @returns {Promise<number>} Coverage percentage between 0 and 100
3043
+ */
3044
+ async coverage(fieldName, groups, options = {}) {
3045
+ this._validateInitialization('coverage');
3046
+ if (typeof fieldName !== 'string' || !fieldName.trim()) {
3047
+ throw new Error('fieldName must be a non-empty string');
3048
+ }
3049
+ if (!Array.isArray(groups)) {
3050
+ throw new Error('groups must be an array');
3051
+ }
3052
+ if (groups.length === 0) {
3053
+ return 0;
3054
+ }
3055
+ if (!this.opts.indexes || !this.opts.indexes[fieldName]) {
3056
+ throw new Error(`Field "${fieldName}" is not indexed`);
3057
+ }
3058
+ const fieldType = this.opts.indexes[fieldName];
3059
+ const supportedTypes = ['array:string', 'string'];
3060
+ if (!supportedTypes.includes(fieldType)) {
3061
+ throw new Error(`coverage() only supports fields of type ${supportedTypes.join(', ')} (found: ${fieldType})`);
3062
+ }
3063
+ const fieldIndex = this.indexManager?.index?.data?.[fieldName];
3064
+ if (!fieldIndex) {
3065
+ return 0;
3066
+ }
3067
+ const isTermMapped = this.termManager && this.termManager.termMappingFields && this.termManager.termMappingFields.includes(fieldName);
3068
+ const normalizeTerm = term => {
3069
+ if (term === undefined || term === null) {
3070
+ return '';
3071
+ }
3072
+ return String(term).trim();
3073
+ };
3074
+ const resolveKey = term => {
3075
+ if (isTermMapped) {
3076
+ const termId = this.termManager.getTermIdWithoutIncrement(term);
3077
+ if (termId === null || termId === undefined) {
3078
+ return null;
3079
+ }
3080
+ return String(termId);
3081
+ }
3082
+ return String(term);
3083
+ };
3084
+ let matchedGroups = 0;
3085
+ for (const group of groups) {
3086
+ if (!group || typeof group !== 'object') {
3087
+ throw new Error('Each coverage group must be an object');
3088
+ }
3089
+ const includeTermsRaw = Array.isArray(group.terms) ? group.terms : [];
3090
+ const excludeTermsRaw = Array.isArray(group.excludes) ? group.excludes : [];
3091
+ const includeTerms = Array.from(new Set(includeTermsRaw.map(normalizeTerm).filter(term => term.length > 0)));
3092
+ if (includeTerms.length === 0) {
3093
+ throw new Error('Each coverage group must define at least one term');
3094
+ }
3095
+ const excludeTerms = Array.from(new Set(excludeTermsRaw.map(normalizeTerm).filter(term => term.length > 0)));
3096
+ let candidateLines = null;
3097
+ let groupMatched = true;
3098
+ for (const term of includeTerms) {
3099
+ const key = resolveKey(term);
3100
+ if (key === null) {
3101
+ groupMatched = false;
3102
+ break;
3103
+ }
3104
+ const termData = fieldIndex[key];
3105
+ if (!termData) {
3106
+ groupMatched = false;
3107
+ break;
3108
+ }
3109
+ const lineNumbers = this.indexManager._getAllLineNumbers(termData);
3110
+ if (!lineNumbers || lineNumbers.length === 0) {
3111
+ groupMatched = false;
3112
+ break;
3113
+ }
3114
+ if (candidateLines === null) {
3115
+ candidateLines = new Set(lineNumbers);
3116
+ } else {
3117
+ const termSet = new Set(lineNumbers);
3118
+ for (const line of Array.from(candidateLines)) {
3119
+ if (!termSet.has(line)) {
3120
+ candidateLines.delete(line);
3121
+ }
3122
+ }
3123
+ }
3124
+ if (!candidateLines || candidateLines.size === 0) {
3125
+ groupMatched = false;
3126
+ break;
3127
+ }
3128
+ }
3129
+ if (!groupMatched || !candidateLines || candidateLines.size === 0) {
3130
+ continue;
3131
+ }
3132
+ for (const term of excludeTerms) {
3133
+ const key = resolveKey(term);
3134
+ if (key === null) {
3135
+ continue;
3136
+ }
3137
+ const termData = fieldIndex[key];
3138
+ if (!termData) {
3139
+ continue;
3140
+ }
3141
+ const excludeLines = this.indexManager._getAllLineNumbers(termData);
3142
+ if (!excludeLines || excludeLines.length === 0) {
3143
+ continue;
3144
+ }
3145
+ for (const line of excludeLines) {
3146
+ if (!candidateLines.size) {
3147
+ break;
3148
+ }
3149
+ candidateLines.delete(line);
3150
+ }
3151
+ if (!candidateLines.size) {
3152
+ break;
3153
+ }
3154
+ }
3155
+ if (candidateLines && candidateLines.size > 0) {
3156
+ matchedGroups++;
3157
+ }
3158
+ }
3159
+ if (matchedGroups === 0) {
3160
+ return 0;
3161
+ }
3162
+ const precision = typeof options.precision === 'number' && options.precision >= 0 ? options.precision : 2;
3163
+ const coverageValue = matchedGroups / groups.length * 100;
3164
+ return Number(coverageValue.toFixed(precision));
3165
+ }
3166
+
3167
+ /**
3168
+ * Score records based on weighted terms in an indexed array:string field
3169
+ * @param {string} fieldName - Name of indexed array:string field
3170
+ * @param {object} scores - Map of terms to numeric weights
3171
+ * @param {object} options - Query options
3172
+ * @returns {Promise<Array>} Records with scores, sorted by score
3173
+ */
3174
+ async score(fieldName, scores, options = {}) {
3175
+ // Validate initialization
3176
+ this._validateInitialization('score');
3177
+
3178
+ // Set default options
3179
+ const opts = {
3180
+ limit: options.limit ?? 100,
3181
+ sort: options.sort ?? 'desc',
3182
+ includeScore: options.includeScore !== false,
3183
+ mode: options.mode ?? 'sum'
3184
+ };
3185
+
3186
+ // Validate fieldName
3187
+ if (typeof fieldName !== 'string' || !fieldName) {
3188
+ throw new Error('fieldName must be a non-empty string');
3189
+ }
3190
+
3191
+ // Validate scores object
3192
+ if (!scores || typeof scores !== 'object' || Array.isArray(scores)) {
3193
+ throw new Error('scores must be an object');
3194
+ }
3195
+
3196
+ // Handle empty scores - return empty array as specified
3197
+ if (Object.keys(scores).length === 0) {
3198
+ return [];
3199
+ }
3200
+
3201
+ // Validate scores values are numeric
3202
+ for (const [term, weight] of Object.entries(scores)) {
3203
+ if (typeof weight !== 'number' || isNaN(weight)) {
3204
+ throw new Error(`Score value for term "${term}" must be a number`);
3205
+ }
3206
+ }
3207
+
3208
+ // Validate mode
3209
+ const allowedModes = new Set(['sum', 'max', 'avg', 'first']);
3210
+ if (!allowedModes.has(opts.mode)) {
3211
+ throw new Error(`Invalid score mode "${opts.mode}". Must be one of: ${Array.from(allowedModes).join(', ')}`);
3212
+ }
3213
+
3214
+ // Check if field is indexed and is array:string type
3215
+ if (!this.opts.indexes || !this.opts.indexes[fieldName]) {
3216
+ throw new Error(`Field "${fieldName}" is not indexed`);
3217
+ }
3218
+ const fieldType = this.opts.indexes[fieldName];
3219
+ if (fieldType !== 'array:string') {
3220
+ throw new Error(`Field "${fieldName}" must be of type "array:string" (found: ${fieldType})`);
3221
+ }
3222
+
3223
+ // Check if this is a term-mapped field
3224
+ const isTermMapped = this.termManager && this.termManager.termMappingFields && this.termManager.termMappingFields.includes(fieldName);
3225
+
3226
+ // Access the index for this field
3227
+ const fieldIndex = this.indexManager.index.data[fieldName];
3228
+ if (!fieldIndex) {
3229
+ return [];
3230
+ }
3231
+
3232
+ // Accumulate scores for each line number
3233
+ const scoreMap = new Map();
3234
+ const countMap = opts.mode === 'avg' ? new Map() : null;
3235
+
3236
+ // Iterate through each term in the scores object
3237
+ for (const [term, weight] of Object.entries(scores)) {
3238
+ // Get term ID if this is a term-mapped field
3239
+ let termKey;
3240
+ if (isTermMapped) {
3241
+ // For term-mapped fields, convert term to term ID
3242
+ const termId = this.termManager.getTermIdWithoutIncrement(term);
3243
+ if (termId === null || termId === undefined) {
3244
+ // Term doesn't exist, skip it
3245
+ continue;
3246
+ }
3247
+ termKey = String(termId);
3248
+ } else {
3249
+ termKey = String(term);
3250
+ }
3251
+
3252
+ // Look up line numbers for this term
3253
+ const termData = fieldIndex[termKey];
3254
+ if (!termData) {
3255
+ // Term doesn't exist in index, skip
3256
+ continue;
3257
+ }
3258
+
3259
+ // Get all line numbers for this term
3260
+ const lineNumbers = this.indexManager._getAllLineNumbers(termData);
3261
+
3262
+ // Add weight to score for each line number
3263
+ for (const lineNumber of lineNumbers) {
3264
+ const currentScore = scoreMap.get(lineNumber);
3265
+ switch (opts.mode) {
3266
+ case 'sum':
3267
+ {
3268
+ const nextScore = (currentScore || 0) + weight;
3269
+ scoreMap.set(lineNumber, nextScore);
3270
+ break;
3271
+ }
3272
+ case 'max':
3273
+ {
3274
+ if (currentScore === undefined) {
3275
+ scoreMap.set(lineNumber, weight);
3276
+ } else {
3277
+ scoreMap.set(lineNumber, Math.max(currentScore, weight));
3278
+ }
3279
+ break;
3280
+ }
3281
+ case 'avg':
3282
+ {
3283
+ const previous = currentScore || 0;
3284
+ scoreMap.set(lineNumber, previous + weight);
3285
+ const count = (countMap.get(lineNumber) || 0) + 1;
3286
+ countMap.set(lineNumber, count);
3287
+ break;
3288
+ }
3289
+ case 'first':
3290
+ {
3291
+ if (currentScore === undefined) {
3292
+ scoreMap.set(lineNumber, weight);
3293
+ }
3294
+ break;
3295
+ }
3296
+ }
3297
+ }
3298
+ }
3299
+
3300
+ // For average mode, divide total by count
3301
+ if (opts.mode === 'avg') {
3302
+ for (const [lineNumber, totalScore] of scoreMap.entries()) {
3303
+ const count = countMap.get(lineNumber) || 1;
3304
+ scoreMap.set(lineNumber, totalScore / count);
3305
+ }
3306
+ }
3307
+
3308
+ // Filter out zero scores and sort by score
3309
+ const scoredEntries = Array.from(scoreMap.entries()).filter(([, score]) => score > 0);
3310
+
3311
+ // Sort by score
3312
+ scoredEntries.sort((a, b) => {
3313
+ return opts.sort === 'asc' ? a[1] - b[1] : b[1] - a[1];
3314
+ });
3315
+
3316
+ // Apply limit
3317
+ const limitedEntries = opts.limit > 0 ? scoredEntries.slice(0, opts.limit) : scoredEntries;
3318
+ if (limitedEntries.length === 0) {
3319
+ return [];
3320
+ }
3321
+
3322
+ // Fetch actual records
3323
+ const lineNumbers = limitedEntries.map(([lineNumber]) => lineNumber);
3324
+ const scoresByLineNumber = new Map(limitedEntries);
3325
+
3326
+ // Use getRanges and fileHandler to read records
3327
+ const ranges = this.getRanges(lineNumbers);
3328
+ const groupedRanges = await this.fileHandler.groupedRanges(ranges);
3329
+ const fs = await Promise.resolve().then(() => _interopRequireWildcard(require('fs')));
3330
+ const fd = await fs.promises.open(this.fileHandler.file, 'r');
3331
+ const results = [];
3332
+ try {
3333
+ for (const groupedRange of groupedRanges) {
3334
+ var _iteratorAbruptCompletion3 = false;
3335
+ var _didIteratorError3 = false;
3336
+ var _iteratorError3;
3337
+ try {
3338
+ for (var _iterator3 = _asyncIterator(this.fileHandler.readGroupedRange(groupedRange, fd)), _step3; _iteratorAbruptCompletion3 = !(_step3 = await _iterator3.next()).done; _iteratorAbruptCompletion3 = false) {
3339
+ const row = _step3.value;
3340
+ {
3341
+ try {
3342
+ const record = this.serializer.deserialize(row.line);
3343
+
3344
+ // Get line number from the row
3345
+ const lineNumber = row._ || 0;
3346
+
3347
+ // Restore term IDs to terms
3348
+ const recordWithTerms = this.restoreTermIdsAfterDeserialization(record);
3349
+
3350
+ // Add line number
3351
+ recordWithTerms._ = lineNumber;
3352
+
3353
+ // Add score if includeScore is true
3354
+ if (opts.includeScore) {
3355
+ recordWithTerms.score = scoresByLineNumber.get(lineNumber) || 0;
3356
+ }
3357
+ results.push(recordWithTerms);
3358
+ } catch (error) {
3359
+ // Skip invalid lines
3360
+ if (this.opts.debugMode) {
3361
+ console.error('Error deserializing record in score():', error);
3362
+ }
3363
+ }
3364
+ }
3365
+ }
3366
+ } catch (err) {
3367
+ _didIteratorError3 = true;
3368
+ _iteratorError3 = err;
3369
+ } finally {
3370
+ try {
3371
+ if (_iteratorAbruptCompletion3 && _iterator3.return != null) {
3372
+ await _iterator3.return();
3373
+ }
3374
+ } finally {
3375
+ if (_didIteratorError3) {
3376
+ throw _iteratorError3;
3377
+ }
3378
+ }
3379
+ }
3380
+ }
3381
+ } finally {
3382
+ await fd.close();
3383
+ }
3384
+
3385
+ // Re-sort results to maintain score order (since reads might be out of order)
3386
+ results.sort((a, b) => {
3387
+ const scoreA = scoresByLineNumber.get(a._) || 0;
3388
+ const scoreB = scoresByLineNumber.get(b._) || 0;
3389
+ return opts.sort === 'asc' ? scoreA - scoreB : scoreB - scoreA;
3390
+ });
3391
+ return results;
2465
3392
  }
2466
3393
 
2467
3394
  /**
@@ -2656,10 +3583,47 @@ class Database extends _events.EventEmitter {
2656
3583
  }
2657
3584
 
2658
3585
  // CRITICAL FIX: Only remove processed items from writeBuffer after all async operations complete
2659
- // OPTIMIZATION: Use Set.has() for O(1) lookup - same Set used for processing
2660
3586
  const beforeLength = this.writeBuffer.length;
2661
- this.writeBuffer = this.writeBuffer.filter(item => !itemsToProcess.has(item));
3587
+ if (beforeLength > 0) {
3588
+ const originalRecords = this.writeBuffer;
3589
+ const originalOffsets = this.writeBufferOffsets;
3590
+ const originalSizes = this.writeBufferSizes;
3591
+ const retainedRecords = [];
3592
+ const retainedOffsets = [];
3593
+ const retainedSizes = [];
3594
+ let retainedTotal = 0;
3595
+ let removedCount = 0;
3596
+ for (let i = 0; i < originalRecords.length; i++) {
3597
+ const record = originalRecords[i];
3598
+ if (itemsToProcess.has(record)) {
3599
+ removedCount++;
3600
+ continue;
3601
+ }
3602
+ retainedRecords.push(record);
3603
+ if (originalOffsets && i < originalOffsets.length) {
3604
+ retainedOffsets.push(originalOffsets[i]);
3605
+ }
3606
+ if (originalSizes && i < originalSizes.length) {
3607
+ const size = originalSizes[i];
3608
+ if (size !== undefined) {
3609
+ retainedSizes.push(size);
3610
+ retainedTotal += size;
3611
+ }
3612
+ }
3613
+ }
3614
+ if (removedCount > 0) {
3615
+ this.writeBuffer = retainedRecords;
3616
+ this.writeBufferOffsets = retainedOffsets;
3617
+ this.writeBufferSizes = retainedSizes;
3618
+ this.writeBufferTotalSize = retainedTotal;
3619
+ }
3620
+ }
2662
3621
  const afterLength = this.writeBuffer.length;
3622
+ if (afterLength === 0) {
3623
+ this.writeBufferOffsets = [];
3624
+ this.writeBufferSizes = [];
3625
+ this.writeBufferTotalSize = 0;
3626
+ }
2663
3627
  if (this.opts.debugMode && beforeLength !== afterLength) {
2664
3628
  console.log(`💾 _processWriteBuffer: Removed ${beforeLength - afterLength} items from writeBuffer (${beforeLength} -> ${afterLength})`);
2665
3629
  }
@@ -3231,28 +4195,239 @@ class Database extends _events.EventEmitter {
3231
4195
  }
3232
4196
 
3233
4197
  /**
3234
- * Walk through records using streaming (real implementation)
4198
+ * Get the base line number for writeBuffer entries (number of persisted records)
4199
+ * @private
4200
+ */
4201
+ _getWriteBufferBaseLineNumber() {
4202
+ return Array.isArray(this.offsets) ? this.offsets.length : 0;
4203
+ }
4204
+
4205
+ /**
4206
+ * Convert a writeBuffer index into an absolute line number
4207
+ * @param {number} writeBufferIndex - Index inside writeBuffer (0-based)
4208
+ * @returns {number} Absolute line number (0-based)
4209
+ * @private
3235
4210
  */
3236
- walk(_x) {
4211
+ _getAbsoluteLineNumber(writeBufferIndex) {
4212
+ if (typeof writeBufferIndex !== 'number' || writeBufferIndex < 0) {
4213
+ throw new Error('Invalid writeBuffer index');
4214
+ }
4215
+ return this._getWriteBufferBaseLineNumber() + writeBufferIndex;
4216
+ }
4217
+ _streamingRecoveryGenerator(_x, _x2) {
3237
4218
  var _this = this;
4219
+ return _wrapAsyncGenerator(function* (criteria, options, alreadyYielded = 0, map = null, remainingSkipValue = 0) {
4220
+ if (_this._offsetRecoveryInProgress) {
4221
+ return;
4222
+ }
4223
+ if (!_this.fileHandler || !_this.fileHandler.file) {
4224
+ return;
4225
+ }
4226
+ _this._offsetRecoveryInProgress = true;
4227
+ const fsModule = _this._fsModule || (_this._fsModule = yield _awaitAsyncGenerator(Promise.resolve().then(() => _interopRequireWildcard(require('fs')))));
4228
+ let fd;
4229
+ try {
4230
+ fd = yield _awaitAsyncGenerator(fsModule.promises.open(_this.fileHandler.file, 'r'));
4231
+ } catch (error) {
4232
+ _this._offsetRecoveryInProgress = false;
4233
+ if (_this.opts.debugMode) {
4234
+ console.warn(`⚠️ Offset recovery skipped: ${error.message}`);
4235
+ }
4236
+ return;
4237
+ }
4238
+ const chunkSize = _this.opts.offsetRecoveryChunkSize || 64 * 1024;
4239
+ let buffer = Buffer.alloc(0);
4240
+ let readOffset = 0;
4241
+ const originalOffsets = Array.isArray(_this.offsets) ? [..._this.offsets] : [];
4242
+ const newOffsets = [];
4243
+ let offsetAdjusted = false;
4244
+ let limitReached = false;
4245
+ let lineIndex = 0;
4246
+ let lastLineEnd = 0;
4247
+ let producedTotal = alreadyYielded || 0;
4248
+ let remainingSkip = remainingSkipValue || 0;
4249
+ let remainingAlreadyYielded = alreadyYielded || 0;
4250
+ const limit = typeof options?.limit === 'number' ? options.limit : null;
4251
+ const includeOffsets = options?.includeOffsets === true;
4252
+ const includeLinePosition = _this.opts.includeLinePosition;
4253
+ const mapSet = map instanceof Set ? new Set(map) : Array.isArray(map) ? new Set(map) : null;
4254
+ const criteriaIsObject = criteria && typeof criteria === 'object' && !Array.isArray(criteria) && !(criteria instanceof Set);
4255
+ const hasCriteria = criteriaIsObject && Object.keys(criteria).length > 0;
4256
+ const decodeLineBuffer = lineBuffer => {
4257
+ let trimmed = lineBuffer;
4258
+ if (trimmed.length > 0 && trimmed[trimmed.length - 1] === 0x0A) {
4259
+ trimmed = trimmed.subarray(0, trimmed.length - 1);
4260
+ }
4261
+ if (trimmed.length > 0 && trimmed[trimmed.length - 1] === 0x0D) {
4262
+ trimmed = trimmed.subarray(0, trimmed.length - 1);
4263
+ }
4264
+ return trimmed;
4265
+ };
4266
+ const processLine = async (lineBuffer, lineStart) => {
4267
+ const lineLength = lineBuffer.length;
4268
+ newOffsets[lineIndex] = lineStart;
4269
+ const expected = originalOffsets[lineIndex];
4270
+ if (expected !== undefined && expected !== lineStart) {
4271
+ offsetAdjusted = true;
4272
+ if (_this.opts.debugMode) {
4273
+ console.warn(`⚠️ Offset mismatch detected at line ${lineIndex}: expected ${expected}, actual ${lineStart}`);
4274
+ }
4275
+ } else if (expected === undefined) {
4276
+ offsetAdjusted = true;
4277
+ }
4278
+ lastLineEnd = Math.max(lastLineEnd, lineStart + lineLength);
4279
+ let entryWithTerms = null;
4280
+ let shouldYield = false;
4281
+ const decodedBuffer = decodeLineBuffer(lineBuffer);
4282
+ if (decodedBuffer.length > 0) {
4283
+ let lineString;
4284
+ try {
4285
+ lineString = decodedBuffer.toString('utf8');
4286
+ } catch (error) {
4287
+ lineString = decodedBuffer.toString('utf8', {
4288
+ replacement: '?'
4289
+ });
4290
+ }
4291
+ try {
4292
+ const record = await _this.serializer.deserialize(lineString);
4293
+ if (record && typeof record === 'object') {
4294
+ entryWithTerms = _this.restoreTermIdsAfterDeserialization(record);
4295
+ if (includeLinePosition) {
4296
+ entryWithTerms._ = lineIndex;
4297
+ }
4298
+ if (mapSet) {
4299
+ shouldYield = mapSet.has(lineIndex);
4300
+ if (shouldYield) {
4301
+ mapSet.delete(lineIndex);
4302
+ }
4303
+ } else if (hasCriteria) {
4304
+ shouldYield = _this.queryManager.matchesCriteria(entryWithTerms, criteria, options);
4305
+ } else {
4306
+ shouldYield = true;
4307
+ }
4308
+ }
4309
+ } catch (error) {
4310
+ if (_this.opts.debugMode) {
4311
+ console.warn(`⚠️ Offset recovery failed to deserialize line ${lineIndex} at ${lineStart}: ${error.message}`);
4312
+ }
4313
+ }
4314
+ }
4315
+ let yieldedEntry = null;
4316
+ if (shouldYield && entryWithTerms) {
4317
+ if (remainingSkip > 0) {
4318
+ remainingSkip--;
4319
+ } else if (remainingAlreadyYielded > 0) {
4320
+ remainingAlreadyYielded--;
4321
+ } else if (!limit || producedTotal < limit) {
4322
+ producedTotal++;
4323
+ yieldedEntry = includeOffsets ? {
4324
+ entry: entryWithTerms,
4325
+ start: lineStart,
4326
+ _: lineIndex
4327
+ } : entryWithTerms;
4328
+ } else {
4329
+ limitReached = true;
4330
+ }
4331
+ }
4332
+ lineIndex++;
4333
+ if (yieldedEntry) {
4334
+ return yieldedEntry;
4335
+ }
4336
+ return null;
4337
+ };
4338
+ let recoveryFailed = false;
4339
+ try {
4340
+ while (true) {
4341
+ const tempBuffer = Buffer.allocUnsafe(chunkSize);
4342
+ const {
4343
+ bytesRead
4344
+ } = yield _awaitAsyncGenerator(fd.read(tempBuffer, 0, chunkSize, readOffset));
4345
+ if (bytesRead === 0) {
4346
+ if (buffer.length > 0) {
4347
+ const lineStart = readOffset - buffer.length;
4348
+ const yieldedEntry = yield _awaitAsyncGenerator(processLine(buffer, lineStart));
4349
+ if (yieldedEntry) {
4350
+ yield yieldedEntry;
4351
+ }
4352
+ }
4353
+ break;
4354
+ }
4355
+ readOffset += bytesRead;
4356
+ let chunk = buffer.length > 0 ? Buffer.concat([buffer, tempBuffer.subarray(0, bytesRead)]) : tempBuffer.subarray(0, bytesRead);
4357
+ let processedUpTo = 0;
4358
+ const chunkBaseOffset = readOffset - chunk.length;
4359
+ while (true) {
4360
+ const newlineIndex = chunk.indexOf(0x0A, processedUpTo);
4361
+ if (newlineIndex === -1) {
4362
+ break;
4363
+ }
4364
+ const lineBuffer = chunk.subarray(processedUpTo, newlineIndex + 1);
4365
+ const lineStart = chunkBaseOffset + processedUpTo;
4366
+ const yieldedEntry = yield _awaitAsyncGenerator(processLine(lineBuffer, lineStart));
4367
+ processedUpTo = newlineIndex + 1;
4368
+ if (yieldedEntry) {
4369
+ yield yieldedEntry;
4370
+ }
4371
+ }
4372
+ buffer = chunk.subarray(processedUpTo);
4373
+ }
4374
+ } catch (error) {
4375
+ recoveryFailed = true;
4376
+ if (_this.opts.debugMode) {
4377
+ console.warn(`⚠️ Offset recovery aborted: ${error.message}`);
4378
+ }
4379
+ } finally {
4380
+ yield _awaitAsyncGenerator(fd.close().catch(() => {}));
4381
+ _this._offsetRecoveryInProgress = false;
4382
+ if (recoveryFailed) {
4383
+ return;
4384
+ }
4385
+ _this.offsets = newOffsets;
4386
+ if (lineIndex < _this.offsets.length) {
4387
+ _this.offsets.length = lineIndex;
4388
+ }
4389
+ if (originalOffsets.length !== newOffsets.length) {
4390
+ offsetAdjusted = true;
4391
+ }
4392
+ _this.indexOffset = lastLineEnd;
4393
+ if (offsetAdjusted) {
4394
+ _this.shouldSave = true;
4395
+ try {
4396
+ yield _awaitAsyncGenerator(_this._saveIndexDataToFile());
4397
+ } catch (error) {
4398
+ if (_this.opts.debugMode) {
4399
+ console.warn(`⚠️ Failed to persist recovered offsets: ${error.message}`);
4400
+ }
4401
+ }
4402
+ }
4403
+ }
4404
+ }).apply(this, arguments);
4405
+ }
4406
+
4407
+ /**
4408
+ * Walk through records using streaming (real implementation)
4409
+ */
4410
+ walk(_x3) {
4411
+ var _this2 = this;
3238
4412
  return _wrapAsyncGenerator(function* (criteria, options = {}) {
3239
4413
  // CRITICAL FIX: Validate state before walk operation to prevent crashes
3240
- _this.validateState();
3241
- if (!_this.initialized) yield _awaitAsyncGenerator(_this.init());
4414
+ _this2.validateState();
4415
+ if (!_this2.initialized) yield _awaitAsyncGenerator(_this2.init());
3242
4416
 
3243
4417
  // If no data at all, return empty
3244
- if (_this.indexOffset === 0 && _this.writeBuffer.length === 0) return;
4418
+ if (_this2.indexOffset === 0 && _this2.writeBuffer.length === 0) return;
4419
+ let count = 0;
4420
+ let remainingSkip = options.skip || 0;
3245
4421
  let map;
3246
4422
  if (!Array.isArray(criteria)) {
3247
4423
  if (criteria instanceof Set) {
3248
4424
  map = [...criteria];
3249
4425
  } else if (criteria && typeof criteria === 'object' && Object.keys(criteria).length > 0) {
3250
4426
  // Only use indexManager.query if criteria has actual filters
3251
- map = [..._this.indexManager.query(criteria, options)];
4427
+ map = [..._this2.indexManager.query(criteria, options)];
3252
4428
  } else {
3253
4429
  // For empty criteria {} or null/undefined, get all records
3254
- // Use writeBuffer length when indexOffset is 0 (data not saved yet)
3255
- const totalRecords = _this.indexOffset > 0 ? _this.indexOffset : _this.writeBuffer.length;
4430
+ const totalRecords = _this2.offsets && _this2.offsets.length > 0 ? _this2.offsets.length : _this2.writeBuffer.length;
3256
4431
  map = [...Array(totalRecords).keys()];
3257
4432
  }
3258
4433
  } else {
@@ -3260,17 +4435,21 @@ class Database extends _events.EventEmitter {
3260
4435
  }
3261
4436
 
3262
4437
  // Use writeBuffer when available (unsaved data)
3263
- if (_this.writeBuffer.length > 0) {
4438
+ if (_this2.writeBuffer.length > 0) {
3264
4439
  let count = 0;
3265
4440
 
3266
4441
  // If map is empty (no index results) but we have criteria, filter writeBuffer directly
3267
4442
  if (map.length === 0 && criteria && typeof criteria === 'object' && Object.keys(criteria).length > 0) {
3268
- for (let i = 0; i < _this.writeBuffer.length; i++) {
4443
+ for (let i = 0; i < _this2.writeBuffer.length; i++) {
3269
4444
  if (options.limit && count >= options.limit) {
3270
4445
  break;
3271
4446
  }
3272
- const entry = _this.writeBuffer[i];
3273
- if (entry && _this.queryManager.matchesCriteria(entry, criteria, options)) {
4447
+ const entry = _this2.writeBuffer[i];
4448
+ if (entry && _this2.queryManager.matchesCriteria(entry, criteria, options)) {
4449
+ if (remainingSkip > 0) {
4450
+ remainingSkip--;
4451
+ continue;
4452
+ }
3274
4453
  count++;
3275
4454
  if (options.includeOffsets) {
3276
4455
  yield {
@@ -3279,7 +4458,7 @@ class Database extends _events.EventEmitter {
3279
4458
  _: i
3280
4459
  };
3281
4460
  } else {
3282
- if (_this.opts.includeLinePosition) {
4461
+ if (_this2.opts.includeLinePosition) {
3283
4462
  entry._ = i;
3284
4463
  }
3285
4464
  yield entry;
@@ -3292,9 +4471,13 @@ class Database extends _events.EventEmitter {
3292
4471
  if (options.limit && count >= options.limit) {
3293
4472
  break;
3294
4473
  }
3295
- if (lineNumber < _this.writeBuffer.length) {
3296
- const entry = _this.writeBuffer[lineNumber];
4474
+ if (lineNumber < _this2.writeBuffer.length) {
4475
+ const entry = _this2.writeBuffer[lineNumber];
3297
4476
  if (entry) {
4477
+ if (remainingSkip > 0) {
4478
+ remainingSkip--;
4479
+ continue;
4480
+ }
3298
4481
  count++;
3299
4482
  if (options.includeOffsets) {
3300
4483
  yield {
@@ -3303,7 +4486,7 @@ class Database extends _events.EventEmitter {
3303
4486
  _: lineNumber
3304
4487
  };
3305
4488
  } else {
3306
- if (_this.opts.includeLinePosition) {
4489
+ if (_this2.opts.includeLinePosition) {
3307
4490
  entry._ = lineNumber;
3308
4491
  }
3309
4492
  yield entry;
@@ -3316,50 +4499,151 @@ class Database extends _events.EventEmitter {
3316
4499
  }
3317
4500
 
3318
4501
  // If writeBuffer is empty but we have saved data, we need to load it from file
3319
- if (_this.writeBuffer.length === 0 && _this.indexOffset > 0) {
4502
+ if (_this2.writeBuffer.length === 0 && _this2.indexOffset > 0) {
3320
4503
  // Load data from file for querying
3321
4504
  try {
3322
4505
  let data;
3323
4506
  let lines;
3324
4507
 
3325
4508
  // Smart threshold: decide between partial reads vs full read
3326
- const resultPercentage = map ? map.length / _this.indexOffset * 100 : 100;
3327
- const threshold = _this.opts.partialReadThreshold || 60; // Default 60% threshold
4509
+ const resultPercentage = map ? map.length / _this2.indexOffset * 100 : 100;
4510
+ const threshold = _this2.opts.partialReadThreshold || 60; // Default 60% threshold
3328
4511
 
3329
4512
  // Use partial reads when:
3330
4513
  // 1. We have specific line numbers from index
3331
4514
  // 2. Results are below threshold percentage
3332
4515
  // 3. Database is large enough to benefit from partial reads
3333
- const shouldUsePartialReads = map && map.length > 0 && resultPercentage < threshold && _this.indexOffset > 100; // Only for databases with >100 records
4516
+ const shouldUsePartialReads = map && map.length > 0 && resultPercentage < threshold && _this2.indexOffset > 100; // Only for databases with >100 records
3334
4517
 
3335
4518
  if (shouldUsePartialReads) {
3336
- if (_this.opts.debugMode) {
3337
- console.log(`🔍 Using PARTIAL READS: ${map.length}/${_this.indexOffset} records (${resultPercentage.toFixed(1)}% < ${threshold}% threshold)`);
4519
+ if (_this2.opts.debugMode) {
4520
+ console.log(`🔍 Using PARTIAL READS: ${map.length}/${_this2.indexOffset} records (${resultPercentage.toFixed(1)}% < ${threshold}% threshold)`);
3338
4521
  }
3339
- // Convert 0-based line numbers to 1-based for readSpecificLines
3340
- const lineNumbers = map.map(num => num + 1);
3341
- data = yield _awaitAsyncGenerator(_this.fileHandler.readSpecificLines(lineNumbers));
3342
- lines = data ? data.split('\n') : [];
4522
+ // OPTIMIZATION: Use ranges instead of reading entire file
4523
+ const ranges = _this2.getRanges(map);
4524
+ const groupedRanges = yield _awaitAsyncGenerator(_this2.fileHandler.groupedRanges(ranges));
4525
+ const fs = yield _awaitAsyncGenerator(Promise.resolve().then(() => _interopRequireWildcard(require('fs'))));
4526
+ const fd = yield _awaitAsyncGenerator(fs.promises.open(_this2.fileHandler.file, 'r'));
4527
+ try {
4528
+ for (const groupedRange of groupedRanges) {
4529
+ var _iteratorAbruptCompletion4 = false;
4530
+ var _didIteratorError4 = false;
4531
+ var _iteratorError4;
4532
+ try {
4533
+ for (var _iterator4 = _asyncIterator(_this2.fileHandler.readGroupedRange(groupedRange, fd)), _step4; _iteratorAbruptCompletion4 = !(_step4 = yield _awaitAsyncGenerator(_iterator4.next())).done; _iteratorAbruptCompletion4 = false) {
4534
+ const row = _step4.value;
4535
+ {
4536
+ if (options.limit && count >= options.limit) {
4537
+ break;
4538
+ }
4539
+ try {
4540
+ // CRITICAL FIX: Use serializer.deserialize instead of JSON.parse to handle array format
4541
+ const record = _this2.serializer.deserialize(row.line);
4542
+ // SPACE OPTIMIZATION: Restore term IDs to terms for user
4543
+ const recordWithTerms = _this2.restoreTermIdsAfterDeserialization(record);
4544
+ if (remainingSkip > 0) {
4545
+ remainingSkip--;
4546
+ continue;
4547
+ }
4548
+ count++;
4549
+ if (options.includeOffsets) {
4550
+ yield {
4551
+ entry: recordWithTerms,
4552
+ start: row.start,
4553
+ _: row._ || 0
4554
+ };
4555
+ } else {
4556
+ if (_this2.opts.includeLinePosition) {
4557
+ recordWithTerms._ = row._ || 0;
4558
+ }
4559
+ yield recordWithTerms;
4560
+ }
4561
+ } catch (error) {
4562
+ // CRITICAL FIX: Log deserialization errors instead of silently ignoring them
4563
+ // This helps identify data corruption issues
4564
+ if (1 || _this2.opts.debugMode) {
4565
+ console.warn(`⚠️ walk(): Failed to deserialize record at offset ${row.start}: ${error.message}`);
4566
+ console.warn(`⚠️ walk(): Problematic line (first 200 chars): ${row.line.substring(0, 200)}`);
4567
+ }
4568
+ if (!_this2._offsetRecoveryInProgress) {
4569
+ var _iteratorAbruptCompletion5 = false;
4570
+ var _didIteratorError5 = false;
4571
+ var _iteratorError5;
4572
+ try {
4573
+ for (var _iterator5 = _asyncIterator(_this2._streamingRecoveryGenerator(criteria, options, count, map, remainingSkip)), _step5; _iteratorAbruptCompletion5 = !(_step5 = yield _awaitAsyncGenerator(_iterator5.next())).done; _iteratorAbruptCompletion5 = false) {
4574
+ const recoveredEntry = _step5.value;
4575
+ {
4576
+ yield recoveredEntry;
4577
+ count++;
4578
+ }
4579
+ }
4580
+ } catch (err) {
4581
+ _didIteratorError5 = true;
4582
+ _iteratorError5 = err;
4583
+ } finally {
4584
+ try {
4585
+ if (_iteratorAbruptCompletion5 && _iterator5.return != null) {
4586
+ yield _awaitAsyncGenerator(_iterator5.return());
4587
+ }
4588
+ } finally {
4589
+ if (_didIteratorError5) {
4590
+ throw _iteratorError5;
4591
+ }
4592
+ }
4593
+ }
4594
+ return;
4595
+ }
4596
+ // Skip invalid lines but continue processing
4597
+ // This prevents one corrupted record from stopping the entire walk operation
4598
+ }
4599
+ }
4600
+ }
4601
+ } catch (err) {
4602
+ _didIteratorError4 = true;
4603
+ _iteratorError4 = err;
4604
+ } finally {
4605
+ try {
4606
+ if (_iteratorAbruptCompletion4 && _iterator4.return != null) {
4607
+ yield _awaitAsyncGenerator(_iterator4.return());
4608
+ }
4609
+ } finally {
4610
+ if (_didIteratorError4) {
4611
+ throw _iteratorError4;
4612
+ }
4613
+ }
4614
+ }
4615
+ if (options.limit && count >= options.limit) {
4616
+ break;
4617
+ }
4618
+ }
4619
+ } finally {
4620
+ yield _awaitAsyncGenerator(fd.close());
4621
+ }
4622
+ return; // Exit early since we processed partial reads
3343
4623
  } else {
3344
- if (_this.opts.debugMode) {
3345
- console.log(`🔍 Using STREAMING READ: ${map?.length || 0}/${_this.indexOffset} records (${resultPercentage.toFixed(1)}% >= ${threshold}% threshold or small DB)`);
4624
+ if (_this2.opts.debugMode) {
4625
+ console.log(`🔍 Using STREAMING READ: ${map?.length || 0}/${_this2.indexOffset} records (${resultPercentage.toFixed(1)}% >= ${threshold}% threshold or small DB)`);
3346
4626
  }
3347
4627
  // Use streaming instead of loading all data in memory
3348
4628
  // This prevents memory issues with large databases
3349
- const streamingResults = yield _awaitAsyncGenerator(_this.fileHandler.readWithStreaming(criteria, {
4629
+ const streamingResults = yield _awaitAsyncGenerator(_this2.fileHandler.readWithStreaming(criteria, {
3350
4630
  limit: options.limit,
3351
4631
  skip: options.skip
3352
- }, matchesCriteria, _this.serializer));
4632
+ }, matchesCriteria, _this2.serializer));
3353
4633
 
3354
4634
  // Process streaming results directly without loading all lines
3355
4635
  for (const record of streamingResults) {
3356
4636
  if (options.limit && count >= options.limit) {
3357
4637
  break;
3358
4638
  }
4639
+ if (remainingSkip > 0) {
4640
+ remainingSkip--;
4641
+ continue;
4642
+ }
3359
4643
  count++;
3360
4644
 
3361
4645
  // SPACE OPTIMIZATION: Restore term IDs to terms for user
3362
- const recordWithTerms = _this.restoreTermIdsAfterDeserialization(record);
4646
+ const recordWithTerms = _this2.restoreTermIdsAfterDeserialization(record);
3363
4647
  if (options.includeOffsets) {
3364
4648
  yield {
3365
4649
  entry: recordWithTerms,
@@ -3367,7 +4651,7 @@ class Database extends _events.EventEmitter {
3367
4651
  _: 0
3368
4652
  };
3369
4653
  } else {
3370
- if (_this.opts.includeLinePosition) {
4654
+ if (_this2.opts.includeLinePosition) {
3371
4655
  recordWithTerms._ = 0;
3372
4656
  }
3373
4657
  yield recordWithTerms;
@@ -3375,136 +4659,108 @@ class Database extends _events.EventEmitter {
3375
4659
  }
3376
4660
  return; // Exit early since we processed streaming results
3377
4661
  }
3378
- if (lines.length > 0) {
3379
- const records = [];
3380
- for (const line of lines) {
3381
- if (line.trim()) {
3382
- try {
3383
- // CRITICAL FIX: Use serializer.deserialize instead of JSON.parse to handle array format
3384
- const record = _this.serializer.deserialize(line);
3385
- // SPACE OPTIMIZATION: Restore term IDs to terms for user
3386
- const recordWithTerms = _this.restoreTermIdsAfterDeserialization(record);
3387
- records.push(recordWithTerms);
3388
- } catch (error) {
3389
- // Skip invalid lines
3390
- }
3391
- }
3392
- }
3393
-
3394
- // Use loaded records for querying
3395
- let count = 0;
3396
-
3397
- // When using partial reads, records correspond to the requested line numbers
3398
- if (shouldUsePartialReads) {
3399
- for (let i = 0; i < Math.min(records.length, map.length); i++) {
3400
- if (options.limit && count >= options.limit) {
3401
- break;
3402
- }
3403
- const entry = records[i];
3404
- const lineNumber = map[i];
3405
- if (entry) {
3406
- count++;
3407
- if (options.includeOffsets) {
3408
- yield {
3409
- entry,
3410
- start: 0,
3411
- _: lineNumber
3412
- };
3413
- } else {
3414
- if (_this.opts.includeLinePosition) {
3415
- entry._ = lineNumber;
3416
- }
3417
- yield entry;
3418
- }
3419
- }
3420
- }
3421
- } else {
3422
- // Fallback to original logic when reading all data
3423
- for (const lineNumber of map) {
3424
- if (options.limit && count >= options.limit) {
3425
- break;
3426
- }
3427
- if (lineNumber < records.length) {
3428
- const entry = records[lineNumber];
3429
- if (entry) {
3430
- count++;
3431
- if (options.includeOffsets) {
3432
- yield {
3433
- entry,
3434
- start: 0,
3435
- _: lineNumber
3436
- };
3437
- } else {
3438
- if (_this.opts.includeLinePosition) {
3439
- entry._ = lineNumber;
3440
- }
3441
- yield entry;
3442
- }
3443
- }
3444
- }
3445
- }
3446
- }
3447
- return;
3448
- }
3449
4662
  } catch (error) {
3450
4663
  // If file reading fails, continue to file-based streaming
3451
4664
  }
3452
4665
  }
3453
4666
 
3454
4667
  // Use file-based streaming for saved data
3455
- const ranges = _this.getRanges(map);
3456
- const groupedRanges = yield _awaitAsyncGenerator(_this.fileHandler.groupedRanges(ranges));
3457
- const fd = yield _awaitAsyncGenerator(_fs.default.promises.open(_this.fileHandler.file, 'r'));
4668
+ const ranges = _this2.getRanges(map);
4669
+ const groupedRanges = yield _awaitAsyncGenerator(_this2.fileHandler.groupedRanges(ranges));
4670
+ const fd = yield _awaitAsyncGenerator(_fs.default.promises.open(_this2.fileHandler.file, 'r'));
3458
4671
  try {
3459
4672
  let count = 0;
3460
4673
  for (const groupedRange of groupedRanges) {
3461
4674
  if (options.limit && count >= options.limit) {
3462
4675
  break;
3463
4676
  }
3464
- var _iteratorAbruptCompletion = false;
3465
- var _didIteratorError = false;
3466
- var _iteratorError;
4677
+ var _iteratorAbruptCompletion6 = false;
4678
+ var _didIteratorError6 = false;
4679
+ var _iteratorError6;
3467
4680
  try {
3468
- for (var _iterator = _asyncIterator(_this.fileHandler.readGroupedRange(groupedRange, fd)), _step; _iteratorAbruptCompletion = !(_step = yield _awaitAsyncGenerator(_iterator.next())).done; _iteratorAbruptCompletion = false) {
3469
- const row = _step.value;
4681
+ for (var _iterator6 = _asyncIterator(_this2.fileHandler.readGroupedRange(groupedRange, fd)), _step6; _iteratorAbruptCompletion6 = !(_step6 = yield _awaitAsyncGenerator(_iterator6.next())).done; _iteratorAbruptCompletion6 = false) {
4682
+ const row = _step6.value;
3470
4683
  {
3471
4684
  if (options.limit && count >= options.limit) {
3472
4685
  break;
3473
4686
  }
3474
- const entry = yield _awaitAsyncGenerator(_this.serializer.deserialize(row.line, {
3475
- compress: _this.opts.compress,
3476
- v8: _this.opts.v8
3477
- }));
3478
- if (entry === null) continue;
3479
-
3480
- // SPACE OPTIMIZATION: Restore term IDs to terms for user
3481
- const entryWithTerms = _this.restoreTermIdsAfterDeserialization(entry);
3482
- count++;
3483
- if (options.includeOffsets) {
3484
- yield {
3485
- entry: entryWithTerms,
3486
- start: row.start,
3487
- _: row._ || _this.offsets.findIndex(n => n === row.start)
3488
- };
3489
- } else {
3490
- if (_this.opts.includeLinePosition) {
3491
- entryWithTerms._ = row._ || _this.offsets.findIndex(n => n === row.start);
4687
+ try {
4688
+ const entry = yield _awaitAsyncGenerator(_this2.serializer.deserialize(row.line, {
4689
+ compress: _this2.opts.compress,
4690
+ v8: _this2.opts.v8
4691
+ }));
4692
+ if (entry === null) continue;
4693
+
4694
+ // SPACE OPTIMIZATION: Restore term IDs to terms for user
4695
+ const entryWithTerms = _this2.restoreTermIdsAfterDeserialization(entry);
4696
+ if (remainingSkip > 0) {
4697
+ remainingSkip--;
4698
+ continue;
4699
+ }
4700
+ count++;
4701
+ if (options.includeOffsets) {
4702
+ yield {
4703
+ entry: entryWithTerms,
4704
+ start: row.start,
4705
+ _: row._ || _this2.offsets.findIndex(n => n === row.start)
4706
+ };
4707
+ } else {
4708
+ if (_this2.opts.includeLinePosition) {
4709
+ entryWithTerms._ = row._ || _this2.offsets.findIndex(n => n === row.start);
4710
+ }
4711
+ yield entryWithTerms;
4712
+ }
4713
+ } catch (error) {
4714
+ // CRITICAL FIX: Log deserialization errors instead of silently ignoring them
4715
+ // This helps identify data corruption issues
4716
+ if (1 || _this2.opts.debugMode) {
4717
+ console.warn(`⚠️ walk(): Failed to deserialize record at offset ${row.start}: ${error.message}`);
4718
+ console.warn(`⚠️ walk(): Problematic line (first 200 chars): ${row.line.substring(0, 200)}`);
4719
+ }
4720
+ if (!_this2._offsetRecoveryInProgress) {
4721
+ var _iteratorAbruptCompletion7 = false;
4722
+ var _didIteratorError7 = false;
4723
+ var _iteratorError7;
4724
+ try {
4725
+ for (var _iterator7 = _asyncIterator(_this2._streamingRecoveryGenerator(criteria, options, count, map, remainingSkip)), _step7; _iteratorAbruptCompletion7 = !(_step7 = yield _awaitAsyncGenerator(_iterator7.next())).done; _iteratorAbruptCompletion7 = false) {
4726
+ const recoveredEntry = _step7.value;
4727
+ {
4728
+ yield recoveredEntry;
4729
+ count++;
4730
+ }
4731
+ }
4732
+ } catch (err) {
4733
+ _didIteratorError7 = true;
4734
+ _iteratorError7 = err;
4735
+ } finally {
4736
+ try {
4737
+ if (_iteratorAbruptCompletion7 && _iterator7.return != null) {
4738
+ yield _awaitAsyncGenerator(_iterator7.return());
4739
+ }
4740
+ } finally {
4741
+ if (_didIteratorError7) {
4742
+ throw _iteratorError7;
4743
+ }
4744
+ }
4745
+ }
4746
+ return;
3492
4747
  }
3493
- yield entryWithTerms;
4748
+ // Skip invalid lines but continue processing
4749
+ // This prevents one corrupted record from stopping the entire walk operation
3494
4750
  }
3495
4751
  }
3496
4752
  }
3497
4753
  } catch (err) {
3498
- _didIteratorError = true;
3499
- _iteratorError = err;
4754
+ _didIteratorError6 = true;
4755
+ _iteratorError6 = err;
3500
4756
  } finally {
3501
4757
  try {
3502
- if (_iteratorAbruptCompletion && _iterator.return != null) {
3503
- yield _awaitAsyncGenerator(_iterator.return());
4758
+ if (_iteratorAbruptCompletion6 && _iterator6.return != null) {
4759
+ yield _awaitAsyncGenerator(_iterator6.return());
3504
4760
  }
3505
4761
  } finally {
3506
- if (_didIteratorError) {
3507
- throw _iteratorError;
4762
+ if (_didIteratorError6) {
4763
+ throw _iteratorError6;
3508
4764
  }
3509
4765
  }
3510
4766
  }
@@ -3528,12 +4784,12 @@ class Database extends _events.EventEmitter {
3528
4784
  * @param {boolean} options.detectChanges - Auto-detect changes (default: true)
3529
4785
  * @returns {AsyncGenerator} Generator yielding records for modification
3530
4786
  */
3531
- iterate(_x2) {
3532
- var _this2 = this;
4787
+ iterate(_x4) {
4788
+ var _this3 = this;
3533
4789
  return _wrapAsyncGenerator(function* (criteria, options = {}) {
3534
4790
  // CRITICAL FIX: Validate state before iterate operation
3535
- _this2.validateState();
3536
- if (!_this2.initialized) yield _awaitAsyncGenerator(_this2.init());
4791
+ _this3.validateState();
4792
+ if (!_this3.initialized) yield _awaitAsyncGenerator(_this3.init());
3537
4793
 
3538
4794
  // Set default options
3539
4795
  const opts = {
@@ -3546,7 +4802,7 @@ class Database extends _events.EventEmitter {
3546
4802
  };
3547
4803
 
3548
4804
  // If no data, return empty
3549
- if (_this2.indexOffset === 0 && _this2.writeBuffer.length === 0) return;
4805
+ if (_this3.indexOffset === 0 && _this3.writeBuffer.length === 0) return;
3550
4806
  const startTime = Date.now();
3551
4807
  let processedCount = 0;
3552
4808
  let modifiedCount = 0;
@@ -3559,24 +4815,24 @@ class Database extends _events.EventEmitter {
3559
4815
 
3560
4816
  try {
3561
4817
  // Always use walk() now that the bug is fixed - it works for both small and large datasets
3562
- var _iteratorAbruptCompletion2 = false;
3563
- var _didIteratorError2 = false;
3564
- var _iteratorError2;
4818
+ var _iteratorAbruptCompletion8 = false;
4819
+ var _didIteratorError8 = false;
4820
+ var _iteratorError8;
3565
4821
  try {
3566
- for (var _iterator2 = _asyncIterator(_this2.walk(criteria, options)), _step2; _iteratorAbruptCompletion2 = !(_step2 = yield _awaitAsyncGenerator(_iterator2.next())).done; _iteratorAbruptCompletion2 = false) {
3567
- const entry = _step2.value;
4822
+ for (var _iterator8 = _asyncIterator(_this3.walk(criteria, options)), _step8; _iteratorAbruptCompletion8 = !(_step8 = yield _awaitAsyncGenerator(_iterator8.next())).done; _iteratorAbruptCompletion8 = false) {
4823
+ const entry = _step8.value;
3568
4824
  {
3569
4825
  processedCount++;
3570
4826
 
3571
4827
  // Store original record for change detection BEFORE yielding
3572
4828
  let originalRecord = null;
3573
4829
  if (opts.detectChanges) {
3574
- originalRecord = _this2._createShallowCopy(entry);
4830
+ originalRecord = _this3._createShallowCopy(entry);
3575
4831
  originalRecords.set(entry.id, originalRecord);
3576
4832
  }
3577
4833
 
3578
4834
  // Create wrapper based on performance preference
3579
- const entryWrapper = opts.highPerformance ? _this2._createHighPerformanceWrapper(entry, originalRecord) : _this2._createEntryProxy(entry, originalRecord);
4835
+ const entryWrapper = opts.highPerformance ? _this3._createHighPerformanceWrapper(entry, originalRecord) : _this3._createEntryProxy(entry, originalRecord);
3580
4836
 
3581
4837
  // Yield the wrapper for user modification
3582
4838
  yield entryWrapper;
@@ -3590,7 +4846,7 @@ class Database extends _events.EventEmitter {
3590
4846
  }
3591
4847
  } else if (opts.detectChanges && originalRecord) {
3592
4848
  // Check if entry was modified by comparing with original (optimized comparison)
3593
- if (_this2._hasRecordChanged(entry, originalRecord)) {
4849
+ if (_this3._hasRecordChanged(entry, originalRecord)) {
3594
4850
  updateBuffer.push(entry);
3595
4851
  modifiedCount++;
3596
4852
  }
@@ -3602,7 +4858,7 @@ class Database extends _events.EventEmitter {
3602
4858
 
3603
4859
  // Process batch when chunk size is reached
3604
4860
  if (updateBuffer.length >= opts.chunkSize || deleteBuffer.size >= opts.chunkSize) {
3605
- yield _awaitAsyncGenerator(_this2._processIterateBatch(updateBuffer, deleteBuffer, opts));
4861
+ yield _awaitAsyncGenerator(_this3._processIterateBatch(updateBuffer, deleteBuffer, opts));
3606
4862
 
3607
4863
  // Clear buffers
3608
4864
  updateBuffer.length = 0;
@@ -3624,21 +4880,21 @@ class Database extends _events.EventEmitter {
3624
4880
 
3625
4881
  // Process remaining records in buffers
3626
4882
  } catch (err) {
3627
- _didIteratorError2 = true;
3628
- _iteratorError2 = err;
4883
+ _didIteratorError8 = true;
4884
+ _iteratorError8 = err;
3629
4885
  } finally {
3630
4886
  try {
3631
- if (_iteratorAbruptCompletion2 && _iterator2.return != null) {
3632
- yield _awaitAsyncGenerator(_iterator2.return());
4887
+ if (_iteratorAbruptCompletion8 && _iterator8.return != null) {
4888
+ yield _awaitAsyncGenerator(_iterator8.return());
3633
4889
  }
3634
4890
  } finally {
3635
- if (_didIteratorError2) {
3636
- throw _iteratorError2;
4891
+ if (_didIteratorError8) {
4892
+ throw _iteratorError8;
3637
4893
  }
3638
4894
  }
3639
4895
  }
3640
4896
  if (updateBuffer.length > 0 || deleteBuffer.size > 0) {
3641
- yield _awaitAsyncGenerator(_this2._processIterateBatch(updateBuffer, deleteBuffer, opts));
4897
+ yield _awaitAsyncGenerator(_this3._processIterateBatch(updateBuffer, deleteBuffer, opts));
3642
4898
  }
3643
4899
 
3644
4900
  // Final progress callback (always called)
@@ -3651,7 +4907,7 @@ class Database extends _events.EventEmitter {
3651
4907
  completed: true
3652
4908
  });
3653
4909
  }
3654
- if (_this2.opts.debugMode) {
4910
+ if (_this3.opts.debugMode) {
3655
4911
  console.log(`🔄 ITERATE COMPLETED: ${processedCount} processed, ${modifiedCount} modified, ${deletedCount} deleted in ${Date.now() - startTime}ms`);
3656
4912
  }
3657
4913
  } catch (error) {
@@ -3677,16 +4933,20 @@ class Database extends _events.EventEmitter {
3677
4933
 
3678
4934
  // Update record in writeBuffer or add to writeBuffer
3679
4935
  const index = this.writeBuffer.findIndex(r => r.id === record.id);
4936
+ let targetIndex;
3680
4937
  if (index !== -1) {
3681
4938
  // Record is already in writeBuffer, update it
3682
4939
  this.writeBuffer[index] = record;
4940
+ targetIndex = index;
3683
4941
  } else {
3684
4942
  // Record is in file, add updated version to writeBuffer
3685
4943
  this.writeBuffer.push(record);
4944
+ targetIndex = this.writeBuffer.length - 1;
3686
4945
  }
3687
4946
 
3688
4947
  // Update index
3689
- await this.indexManager.update(record, record, this.writeBuffer.length - 1);
4948
+ const absoluteLineNumber = this._getAbsoluteLineNumber(targetIndex);
4949
+ await this.indexManager.update(record, record, absoluteLineNumber);
3690
4950
  }
3691
4951
  if (this.opts.debugMode) {
3692
4952
  console.log(`🔄 ITERATE: Updated ${updateBuffer.length} records in ${Date.now() - startTime}ms`);
@@ -3755,8 +5015,24 @@ class Database extends _events.EventEmitter {
3755
5015
  this.writeBufferSizes = [];
3756
5016
  }
3757
5017
  } else {
3758
- // Even if no data to save, ensure index data is persisted
3759
- await this._saveIndexDataToFile();
5018
+ // Only save index data if it actually has content
5019
+ // Don't overwrite a valid index with an empty one
5020
+ if (this.indexManager && this.indexManager.indexedFields && this.indexManager.indexedFields.length > 0) {
5021
+ let hasIndexData = false;
5022
+ for (const field of this.indexManager.indexedFields) {
5023
+ if (this.indexManager.hasUsableIndexData(field)) {
5024
+ hasIndexData = true;
5025
+ break;
5026
+ }
5027
+ }
5028
+ // Only save if we have actual index data OR if offsets are populated
5029
+ // (offsets being populated means we've processed data)
5030
+ if (hasIndexData || this.offsets && this.offsets.length > 0) {
5031
+ await this._saveIndexDataToFile();
5032
+ } else if (this.opts.debugMode) {
5033
+ console.log('⚠️ close(): Skipping index save - index is empty and no offsets');
5034
+ }
5035
+ }
3760
5036
  }
3761
5037
 
3762
5038
  // 2. Mark as closed (but not destroyed) to allow reopening
@@ -3790,8 +5066,40 @@ class Database extends _events.EventEmitter {
3790
5066
  if (this.indexManager) {
3791
5067
  try {
3792
5068
  const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb');
5069
+ const indexJSON = this.indexManager.indexedFields && this.indexManager.indexedFields.length > 0 ? this.indexManager.toJSON() : {};
5070
+
5071
+ // Check if index is empty
5072
+ const isEmpty = !indexJSON || Object.keys(indexJSON).length === 0 || this.indexManager.indexedFields && this.indexManager.indexedFields.every(field => {
5073
+ const fieldIndex = indexJSON[field];
5074
+ return !fieldIndex || typeof fieldIndex === 'object' && Object.keys(fieldIndex).length === 0;
5075
+ });
5076
+
5077
+ // PROTECTION: Don't overwrite a valid index file with empty data
5078
+ // If the .idx.jdb file exists and has data, and we're trying to save empty index,
5079
+ // skip the save to prevent corruption
5080
+ if (isEmpty && !this.offsets?.length) {
5081
+ const fs = await Promise.resolve().then(() => _interopRequireWildcard(require('fs')));
5082
+ if (fs.existsSync(idxPath)) {
5083
+ try {
5084
+ const existingData = JSON.parse(await fs.promises.readFile(idxPath, 'utf8'));
5085
+ const existingHasData = existingData.index && Object.keys(existingData.index).length > 0;
5086
+ const existingHasOffsets = existingData.offsets && existingData.offsets.length > 0;
5087
+ if (existingHasData || existingHasOffsets) {
5088
+ if (this.opts.debugMode) {
5089
+ console.log(`⚠️ _saveIndexDataToFile: Skipping save - would overwrite valid index with empty data`);
5090
+ }
5091
+ return; // Don't overwrite valid index with empty one
5092
+ }
5093
+ } catch (error) {
5094
+ // If we can't read existing file, proceed with save (might be corrupted)
5095
+ if (this.opts.debugMode) {
5096
+ console.log(`⚠️ _saveIndexDataToFile: Could not read existing index file, proceeding with save`);
5097
+ }
5098
+ }
5099
+ }
5100
+ }
3793
5101
  const indexData = {
3794
- index: this.indexManager.indexedFields && this.indexManager.indexedFields.length > 0 ? this.indexManager.toJSON() : {},
5102
+ index: indexJSON,
3795
5103
  offsets: this.offsets,
3796
5104
  // Save actual offsets for efficient file operations
3797
5105
  indexOffset: this.indexOffset,