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