jexidb 2.1.9 → 2.2.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.
@@ -1,2371 +1,2435 @@
1
- import { Mutex } from 'async-mutex'
2
- import { normalizeCriteriaOperators } from '../utils/operatorNormalizer.mjs'
3
-
4
- export default class IndexManager {
5
- constructor(opts, databaseMutex = null, database = null) {
6
- this.opts = Object.assign({}, opts)
7
- this.index = Object.assign({data: {}}, this.opts.index)
8
- this.totalLines = 0
9
- this.rangeThreshold = 10 // Sensible threshold: 10+ consecutive numbers justify ranges
10
- this.binarySearchThreshold = 32 // Much higher for better performance
11
- this.database = database // Reference to database for term manager access
12
-
13
- // CRITICAL: Use database mutex to prevent deadlocks
14
- // If no database mutex provided, create a local one (for backward compatibility)
15
- this.mutex = databaseMutex || new Mutex()
16
-
17
- this.indexedFields = []
18
- this.setIndexesConfig(this.opts.indexes)
19
- }
20
-
21
- setTotalLines(total) {
22
- this.totalLines = total
23
- }
24
-
25
- /**
26
- * Update indexes configuration and ensure internal structures stay in sync
27
- * @param {Object|Array<string>} indexes
28
- */
29
- setIndexesConfig(indexes) {
30
- if (!indexes) {
31
- this.opts.indexes = undefined
32
- this.indexedFields = []
33
- return
34
- }
35
-
36
- if (Array.isArray(indexes)) {
37
- const fields = indexes.map(field => String(field))
38
- this.indexedFields = fields
39
-
40
- const normalizedConfig = {}
41
- for (const field of fields) {
42
- const existingConfig = (!Array.isArray(this.opts.indexes) && typeof this.opts.indexes === 'object') ? this.opts.indexes[field] : undefined
43
- normalizedConfig[field] = existingConfig ?? 'auto'
44
- if (!this.index.data[field]) {
45
- this.index.data[field] = {}
46
- }
47
- }
48
- this.opts.indexes = normalizedConfig
49
- return
50
- }
51
-
52
- if (typeof indexes === 'object') {
53
- this.opts.indexes = Object.assign({}, indexes)
54
- this.indexedFields = Object.keys(this.opts.indexes)
55
-
56
- for (const field of this.indexedFields) {
57
- if (!this.index.data[field]) {
58
- this.index.data[field] = {}
59
- }
60
- }
61
- }
62
- }
63
-
64
- /**
65
- * Check if a field is configured as an index
66
- * @param {string} field - Field name
67
- * @returns {boolean}
68
- */
69
- isFieldIndexed(field) {
70
- if (!field) return false
71
- if (!Array.isArray(this.indexedFields)) {
72
- return false
73
- }
74
- return this.indexedFields.includes(field)
75
- }
76
-
77
- /**
78
- * Determine whether the index has usable data for a given field
79
- * @param {string} field - Field name
80
- * @returns {boolean}
81
- */
82
- hasUsableIndexData(field) {
83
- if (!field) return false
84
- const fieldData = this.index?.data?.[field]
85
- if (!fieldData || typeof fieldData !== 'object') {
86
- return false
87
- }
88
-
89
- for (const key in fieldData) {
90
- if (!Object.prototype.hasOwnProperty.call(fieldData, key)) continue
91
- const entry = fieldData[key]
92
- if (!entry) continue
93
-
94
- if (entry.set && typeof entry.set.size === 'number' && entry.set.size > 0) {
95
- return true
96
- }
97
-
98
- if (Array.isArray(entry.ranges) && entry.ranges.length > 0) {
99
- const hasRangeData = entry.ranges.some(range => {
100
- if (range === null || typeof range === 'undefined') {
101
- return false
102
- }
103
- if (typeof range === 'object') {
104
- const count = typeof range.count === 'number' ? range.count : 0
105
- return count > 0
106
- }
107
- // When ranges are stored as individual numbers
108
- return true
109
- })
110
-
111
- if (hasRangeData) {
112
- return true
113
- }
114
- }
115
- }
116
-
117
- return false
118
- }
119
-
120
- // Ultra-fast range conversion - only for very large datasets
121
- _toRanges(numbers) {
122
- if (numbers.length === 0) return []
123
- if (numbers.length < this.rangeThreshold) return numbers // Keep as-is for small arrays
124
-
125
- const sorted = numbers.sort((a, b) => a - b) // Sort in-place
126
- const ranges = []
127
- let start = sorted[0]
128
- let count = 1
129
-
130
- for (let i = 1; i < sorted.length; i++) {
131
- if (sorted[i] === sorted[i-1] + 1) {
132
- count++
133
- } else {
134
- // End of consecutive sequence
135
- if (count >= this.rangeThreshold) {
136
- ranges.push({start, count})
137
- } else {
138
- // Add individual numbers for small sequences
139
- for (let j = start; j < start + count; j++) {
140
- ranges.push(j)
141
- }
142
- }
143
- start = sorted[i]
144
- count = 1
145
- }
146
- }
147
-
148
- // Handle last sequence
149
- if (count >= this.rangeThreshold) {
150
- ranges.push({start, count})
151
- } else {
152
- for (let j = start; j < start + count; j++) {
153
- ranges.push(j)
154
- }
155
- }
156
-
157
- return ranges
158
- }
159
-
160
- // Ultra-fast range expansion
161
- _fromRanges(ranges) {
162
- if (!ranges || ranges.length === 0) return []
163
-
164
- const numbers = []
165
- for (const item of ranges) {
166
- if (typeof item === 'object' && item.start !== undefined) {
167
- // It's a range - use direct loop for maximum speed
168
- const end = item.start + item.count
169
- for (let i = item.start; i < end; i++) {
170
- numbers.push(i)
171
- }
172
- } else {
173
- // It's an individual number
174
- numbers.push(item)
175
- }
176
- }
177
- return numbers
178
- }
179
-
180
- // Ultra-fast lookup - optimized for Set operations
181
- _hasLineNumber(hybridData, lineNumber) {
182
- if (!hybridData) return false
183
-
184
- // Check in Set first (O(1)) - most common case
185
- if (hybridData.set && hybridData.set.has(lineNumber)) {
186
- return true
187
- }
188
-
189
- // Check in ranges only if necessary
190
- if (hybridData.ranges && hybridData.ranges.length > 0) {
191
- return this._searchInRanges(hybridData.ranges, lineNumber)
192
- }
193
-
194
- return false
195
- }
196
-
197
- // Optimized search strategy
198
- _searchInRanges(ranges, lineNumber) {
199
- if (ranges.length < this.binarySearchThreshold) {
200
- // Linear search for small ranges
201
- return this._linearSearchRanges(ranges, lineNumber)
202
- } else {
203
- // Binary search for large ranges
204
- return this._binarySearchRanges(ranges, lineNumber)
205
- }
206
- }
207
-
208
- // Ultra-fast linear search
209
- _linearSearchRanges(ranges, lineNumber) {
210
- for (const item of ranges) {
211
- if (typeof item === 'object' && item.start !== undefined) {
212
- // It's a range
213
- if (lineNumber >= item.start && lineNumber < item.start + item.count) {
214
- return true
215
- }
216
- } else if (item === lineNumber) {
217
- // It's an individual number
218
- return true
219
- }
220
- }
221
- return false
222
- }
223
-
224
- // Optimized binary search
225
- _binarySearchRanges(ranges, lineNumber) {
226
- let left = 0
227
- let right = ranges.length - 1
228
-
229
- while (left <= right) {
230
- const mid = Math.floor((left + right) / 2)
231
- const range = ranges[mid]
232
-
233
- if (typeof range === 'object' && range.start !== undefined) {
234
- // It's a range
235
- if (lineNumber >= range.start && lineNumber < range.start + range.count) {
236
- return true
237
- } else if (lineNumber < range.start) {
238
- right = mid - 1
239
- } else {
240
- left = mid + 1
241
- }
242
- } else {
243
- // It's an individual number
244
- if (range === lineNumber) {
245
- return true
246
- } else if (range < lineNumber) {
247
- left = mid + 1
248
- } else {
249
- right = mid - 1
250
- }
251
- }
252
- }
253
-
254
- return false
255
- }
256
-
257
- // Ultra-fast add operation - minimal overhead
258
- _addLineNumber(hybridData, lineNumber) {
259
- // Initialize structure if needed
260
- if (!hybridData) {
261
- hybridData = { set: new Set(), ranges: [] }
262
- }
263
-
264
- // Add to Set directly (fastest path)
265
- if (!hybridData.set) {
266
- hybridData.set = new Set()
267
- }
268
- hybridData.set.add(lineNumber)
269
-
270
- // Optimize to ranges when Set gets reasonably large
271
- if (hybridData.set.size >= this.rangeThreshold * 2) { // 20 elements
272
- if (this.opts.debugMode) {
273
- console.log(`🔧 Triggering range optimization: Set size ${hybridData.set.size} >= threshold ${this.rangeThreshold * 2}`)
274
- }
275
- this._optimizeToRanges(hybridData)
276
- }
277
-
278
- return hybridData
279
- }
280
-
281
- // Ultra-fast remove operation
282
- _removeLineNumber(hybridData, lineNumber) {
283
- if (!hybridData) {
284
- return hybridData
285
- }
286
-
287
- // Remove from Set (fast path)
288
- if (hybridData.set) {
289
- hybridData.set.delete(lineNumber)
290
- }
291
-
292
- // Remove from ranges (less common)
293
- if (hybridData.ranges) {
294
- hybridData.ranges = this._removeFromRanges(hybridData.ranges, lineNumber)
295
- }
296
-
297
- return hybridData
298
- }
299
-
300
- // Optimized range removal
301
- _removeFromRanges(ranges, lineNumber) {
302
- if (!ranges || ranges.length === 0) return ranges
303
-
304
- const newRanges = []
305
-
306
- for (const item of ranges) {
307
- if (typeof item === 'object' && item.start !== undefined) {
308
- // It's a range
309
- if (lineNumber >= item.start && lineNumber < item.start + item.count) {
310
- // Split range if needed
311
- if (lineNumber === item.start) {
312
- // Remove first element
313
- if (item.count > 1) {
314
- newRanges.push({ start: item.start + 1, count: item.count - 1 })
315
- }
316
- } else if (lineNumber === item.start + item.count - 1) {
317
- // Remove last element
318
- if (item.count > 1) {
319
- newRanges.push({ start: item.start, count: item.count - 1 })
320
- }
321
- } else {
322
- // Remove from middle - split into two ranges
323
- const beforeCount = lineNumber - item.start
324
- const afterCount = item.count - beforeCount - 1
325
-
326
- if (beforeCount >= this.rangeThreshold) {
327
- newRanges.push({ start: item.start, count: beforeCount })
328
- } else {
329
- // Add individual numbers for small sequences
330
- for (let i = item.start; i < lineNumber; i++) {
331
- newRanges.push(i)
332
- }
333
- }
334
-
335
- if (afterCount >= this.rangeThreshold) {
336
- newRanges.push({ start: lineNumber + 1, count: afterCount })
337
- } else {
338
- // Add individual numbers for small sequences
339
- for (let i = lineNumber + 1; i < item.start + item.count; i++) {
340
- newRanges.push(i)
341
- }
342
- }
343
- }
344
- } else {
345
- newRanges.push(item)
346
- }
347
- } else if (item !== lineNumber) {
348
- // It's an individual number
349
- newRanges.push(item)
350
- }
351
- }
352
-
353
- return newRanges
354
- }
355
-
356
- // Ultra-lazy range conversion - only when absolutely necessary
357
- _optimizeToRanges(hybridData) {
358
- if (!hybridData.set || hybridData.set.size === 0) {
359
- return
360
- }
361
-
362
- if (this.opts.debugMode) {
363
- console.log(`🔧 Starting range optimization for Set with ${hybridData.set.size} elements`)
364
- }
365
-
366
- // Only convert if we have enough data to make it worthwhile
367
- if (hybridData.set.size < this.rangeThreshold) {
368
- return
369
- }
370
-
371
- // Convert Set to array and find consecutive sequences
372
- const numbers = Array.from(hybridData.set).sort((a, b) => a - b)
373
- const ranges = []
374
-
375
- let start = numbers[0]
376
- let count = 1
377
-
378
- for (let i = 1; i < numbers.length; i++) {
379
- if (numbers[i] === numbers[i-1] + 1) {
380
- count++
381
- } else {
382
- // End of consecutive sequence
383
- if (count >= this.rangeThreshold) {
384
- ranges.push({start, count})
385
- // Remove these numbers from Set
386
- for (let j = start; j < start + count; j++) {
387
- hybridData.set.delete(j)
388
- }
389
- }
390
- start = numbers[i]
391
- count = 1
392
- }
393
- }
394
-
395
- // Handle last sequence
396
- if (count >= this.rangeThreshold) {
397
- ranges.push({start, count})
398
- for (let j = start; j < start + count; j++) {
399
- hybridData.set.delete(j)
400
- }
401
- }
402
-
403
- // Add new ranges to existing ranges
404
- if (ranges.length > 0) {
405
- if (!hybridData.ranges) {
406
- hybridData.ranges = []
407
- }
408
- hybridData.ranges.push(...ranges)
409
- // Keep ranges sorted for efficient binary search
410
- hybridData.ranges.sort((a, b) => {
411
- const aStart = typeof a === 'object' ? a.start : a
412
- const bStart = typeof b === 'object' ? b.start : b
413
- return aStart - bStart
414
- })
415
- }
416
- }
417
-
418
- // Ultra-fast get all line numbers
419
- _getAllLineNumbers(hybridData) {
420
- if (!hybridData) return []
421
-
422
- // Use generator for lazy evaluation and better memory efficiency
423
- return Array.from(this._getAllLineNumbersGenerator(hybridData))
424
- }
425
-
426
- // OPTIMIZATION: Generator-based approach for better memory efficiency
427
- *_getAllLineNumbersGenerator(hybridData) {
428
- const normalizeLineNumber = (value) => {
429
- if (typeof value === 'number') {
430
- return value
431
- }
432
- if (typeof value === 'string') {
433
- const parsed = Number(value)
434
- return Number.isNaN(parsed) ? value : parsed
435
- }
436
- if (typeof value === 'bigint') {
437
- const maxSafe = BigInt(Number.MAX_SAFE_INTEGER)
438
- return value <= maxSafe ? Number(value) : value
439
- }
440
- return value
441
- }
442
-
443
- // Yield from Set (fastest path)
444
- if (hybridData.set) {
445
- for (const num of hybridData.set) {
446
- yield normalizeLineNumber(num)
447
- }
448
- }
449
-
450
- // Yield from ranges (optimized)
451
- if (hybridData.ranges) {
452
- for (const item of hybridData.ranges) {
453
- if (typeof item === 'object' && item.start !== undefined) {
454
- // It's a range - use direct loop for better performance
455
- const end = item.start + item.count
456
- for (let i = item.start; i < end; i++) {
457
- yield normalizeLineNumber(i)
458
- }
459
- } else {
460
- // It's an individual number
461
- yield normalizeLineNumber(item)
462
- }
463
- }
464
- }
465
- }
466
-
467
- // OPTIMIZATION 6: Ultra-fast add operation with incremental index updates
468
- async add(row, lineNumber) {
469
- if (typeof row !== 'object' || !row) {
470
- throw new Error('Invalid \'row\' parameter, it must be an object')
471
- }
472
- if (typeof lineNumber !== 'number') {
473
- throw new Error('Invalid line number')
474
- }
475
-
476
- // OPTIMIZATION 6: Use direct field access with minimal operations
477
- const data = this.index.data
478
-
479
- // OPTIMIZATION 6: Pre-allocate field structures for better performance
480
- const fields = Object.keys(this.opts.indexes || {})
481
- for (const field of fields) {
482
- // PERFORMANCE: Check if this is a term mapping field once
483
- const isTermMappingField = this.database?.termManager &&
484
- this.database.termManager.termMappingFields &&
485
- this.database.termManager.termMappingFields.includes(field)
486
-
487
- // CRITICAL FIX: For term mapping fields, prefer ${field}Ids if available
488
- // Records processed by processTermMapping have term IDs in ${field}Ids
489
- // Records loaded from file have term IDs directly in ${field} (after restoreTermIdsAfterDeserialization)
490
- let value
491
- if (isTermMappingField) {
492
- const termIdsField = `${field}Ids`
493
- const termIds = row[termIdsField]
494
- if (termIds && Array.isArray(termIds) && termIds.length > 0) {
495
- // Use term IDs from ${field}Ids (preferred - from processTermMapping)
496
- value = termIds
497
- } else {
498
- // Fallback: use field directly (for records loaded from file that have term IDs in field)
499
- value = row[field]
500
- }
501
- } else {
502
- value = row[field]
503
- }
504
-
505
- if (value !== undefined && value !== null) {
506
- // OPTIMIZATION 6: Initialize field structure if it doesn't exist
507
- if (!data[field]) {
508
- data[field] = {}
509
- }
510
-
511
- const values = Array.isArray(value) ? value : [value]
512
- for (const val of values) {
513
- let key
514
-
515
- if (isTermMappingField && typeof val === 'number') {
516
- // For term mapping fields, values are already term IDs
517
- key = String(val)
518
- } else if (isTermMappingField && typeof val === 'string') {
519
- // Fallback: convert string to term ID
520
- // CRITICAL: During indexing (add), we should use getTermId() to create IDs if needed
521
- // This is different from queries where we use getTermIdWithoutIncrement() to avoid creating new IDs
522
- const termId = this.database.termManager.getTermId(val)
523
- key = String(termId)
524
- } else {
525
- // For non-term-mapping fields (including array:number), use values directly
526
- key = String(val)
527
- }
528
-
529
- // OPTIMIZATION 6: Use direct assignment for better performance
530
- if (!data[field][key]) {
531
- data[field][key] = { set: new Set(), ranges: [] }
532
- }
533
-
534
- // OPTIMIZATION 6: Direct Set operation - fastest possible
535
- data[field][key].set.add(lineNumber)
536
-
537
- // OPTIMIZATION 6: Lazy range optimization - only when beneficial
538
- if (data[field][key].set.size >= this.rangeThreshold * 3) {
539
- this._optimizeToRanges(data[field][key])
540
- }
541
- }
542
- }
543
- }
544
- }
545
-
546
- /**
547
- * OPTIMIZATION 6: Add multiple records to the index in batch with optimized operations
548
- * @param {Array} records - Records to add
549
- * @param {number} startLineNumber - Starting line number
550
- */
551
- async addBatch(records, startLineNumber) {
552
- if (!records || !records.length) return
553
-
554
- // OPTIMIZATION 6: Pre-allocate index structures for better performance
555
- const data = this.index.data
556
- const fields = Object.keys(this.opts.indexes || {})
557
-
558
- for (const field of fields) {
559
- if (!data[field]) {
560
- data[field] = {}
561
- }
562
- }
563
-
564
- // OPTIMIZATION 6: Use Map for batch processing to reduce lookups
565
- const fieldUpdates = new Map()
566
-
567
- // OPTIMIZATION 6: Process all records in batch with optimized data structures
568
- for (let i = 0; i < records.length; i++) {
569
- const row = records[i]
570
- const lineNumber = startLineNumber + i
571
-
572
- for (const field of fields) {
573
- // PERFORMANCE: Check if this is a term mapping field once
574
- const isTermMappingField = this.database?.termManager &&
575
- this.database.termManager.termMappingFields &&
576
- this.database.termManager.termMappingFields.includes(field)
577
-
578
- // CRITICAL FIX: For term mapping fields, prefer ${field}Ids if available
579
- // Records processed by processTermMapping have term IDs in ${field}Ids
580
- // Records loaded from file have term IDs directly in ${field} (after restoreTermIdsAfterDeserialization)
581
- let value
582
- if (isTermMappingField) {
583
- const termIdsField = `${field}Ids`
584
- const termIds = row[termIdsField]
585
- if (termIds && Array.isArray(termIds) && termIds.length > 0) {
586
- // Use term IDs from ${field}Ids (preferred - from processTermMapping)
587
- value = termIds
588
- } else {
589
- // Fallback: use field directly (for records loaded from file that have term IDs in field)
590
- value = row[field]
591
- }
592
- } else {
593
- value = row[field]
594
- }
595
-
596
- if (value !== undefined && value !== null) {
597
- const values = Array.isArray(value) ? value : [value]
598
- for (const val of values) {
599
- let key
600
-
601
- if (isTermMappingField && typeof val === 'number') {
602
- // For term mapping fields, values are already term IDs
603
- key = String(val)
604
- } else if (isTermMappingField && typeof val === 'string') {
605
- // Fallback: convert string to term ID
606
- // CRITICAL: During indexing (addBatch), we should use getTermId() to create IDs if needed
607
- // This is different from queries where we use getTermIdWithoutIncrement() to avoid creating new IDs
608
- const termId = this.database.termManager.getTermId(val)
609
- key = String(termId)
610
- } else {
611
- // For non-term-mapping fields (including array:number), use values directly
612
- key = String(val)
613
- }
614
-
615
- // OPTIMIZATION 6: Use Map for efficient batch updates
616
- if (!fieldUpdates.has(field)) {
617
- fieldUpdates.set(field, new Map())
618
- }
619
-
620
- const fieldMap = fieldUpdates.get(field)
621
- if (!fieldMap.has(key)) {
622
- fieldMap.set(key, new Set())
623
- }
624
-
625
- fieldMap.get(key).add(lineNumber)
626
- }
627
- }
628
- }
629
- }
630
-
631
- // OPTIMIZATION 6: Apply all updates in batch for better performance
632
- for (const [field, fieldMap] of fieldUpdates) {
633
- for (const [key, lineNumbers] of fieldMap) {
634
- if (!data[field][key]) {
635
- data[field][key] = { set: new Set(), ranges: [] }
636
- }
637
-
638
- // OPTIMIZATION 6: Add all line numbers at once
639
- for (const lineNumber of lineNumbers) {
640
- data[field][key].set.add(lineNumber)
641
- }
642
-
643
- // OPTIMIZATION 6: Lazy range optimization - only when beneficial
644
- if (data[field][key].set.size >= this.rangeThreshold * 3) {
645
- this._optimizeToRanges(data[field][key])
646
- }
647
- }
648
- }
649
- }
650
-
651
- // Ultra-fast dry remove
652
- dryRemove(ln) {
653
- const data = this.index.data
654
- for (const field in data) {
655
- for (const value in data[field]) {
656
- // Direct Set operation - fastest possible
657
- if (data[field][value].set) {
658
- data[field][value].set.delete(ln)
659
- }
660
- if (data[field][value].ranges) {
661
- data[field][value].ranges = this._removeFromRanges(data[field][value].ranges, ln)
662
- }
663
- // Remove empty entries
664
- if ((!data[field][value].set || data[field][value].set.size === 0) &&
665
- (!data[field][value].ranges || data[field][value].ranges.length === 0)) {
666
- delete data[field][value]
667
- }
668
- }
669
- }
670
- }
671
-
672
-
673
- // Cleanup method to free memory
674
- cleanup() {
675
- const data = this.index.data
676
- for (const field in data) {
677
- for (const value in data[field]) {
678
- if (data[field][value].set) {
679
- if (typeof data[field][value].set.clearAll === 'function') {
680
- data[field][value].set.clearAll()
681
- } else if (typeof data[field][value].set.clear === 'function') {
682
- data[field][value].set.clear()
683
- }
684
- }
685
- if (data[field][value].ranges) {
686
- data[field][value].ranges.length = 0
687
- }
688
- }
689
- // Clear the entire field
690
- data[field] = {}
691
- }
692
- // Clear all data
693
- this.index.data = {}
694
- this.totalLines = 0
695
- }
696
-
697
- // Clear all indexes
698
- clear() {
699
- this.index.data = {}
700
- this.totalLines = 0
701
- }
702
-
703
-
704
-
705
- // Update a record in the index
706
- async update(oldRecord, newRecord, lineNumber = null) {
707
- if (!oldRecord || !newRecord) return
708
-
709
- // Remove old record by ID
710
- await this.remove(oldRecord)
711
-
712
- // Add new record with provided line number or use hash of the ID
713
- const actualLineNumber = lineNumber !== null ? lineNumber : this._getIdAsNumber(newRecord.id)
714
- await this.add(newRecord, actualLineNumber)
715
- }
716
-
717
- // Convert string ID to number for line number
718
- _getIdAsNumber(id) {
719
- if (typeof id === 'number') return id
720
- if (typeof id === 'string') {
721
- // Simple hash function to convert string to number
722
- let hash = 0
723
- for (let i = 0; i < id.length; i++) {
724
- const char = id.charCodeAt(i)
725
- hash = ((hash << 5) - hash) + char
726
- hash = hash & hash // Convert to 32-bit integer
727
- }
728
- return Math.abs(hash)
729
- }
730
- return 0
731
- }
732
-
733
- // Remove a record from the index
734
- async remove(record) {
735
- if (!record) return
736
-
737
- // If record is an array of line numbers, use the original method
738
- if (Array.isArray(record)) {
739
- return this._removeLineNumbers(record)
740
- }
741
-
742
- // If record is an object, remove by record data
743
- if (typeof record === 'object' && record.id) {
744
- return await this._removeRecord(record)
745
- }
746
- }
747
-
748
- // Remove a specific record from the index
749
- async _removeRecord(record) {
750
- if (!record) return
751
-
752
- const data = this.index.data
753
- const database = this.database
754
- const persistedCount = Array.isArray(database?.offsets) ? database.offsets.length : 0
755
- const lineMatchCache = new Map()
756
-
757
- const doesLineNumberBelongToRecord = async (lineNumber) => {
758
- if (lineMatchCache.has(lineNumber)) {
759
- return lineMatchCache.get(lineNumber)
760
- }
761
-
762
- let belongs = false
763
-
764
- try {
765
- if (lineNumber >= persistedCount) {
766
- const writeBufferIndex = lineNumber - persistedCount
767
- const candidate = database?.writeBuffer?.[writeBufferIndex]
768
- belongs = !!candidate && candidate.id === record.id
769
- } else if (lineNumber >= 0) {
770
- const range = database?.locate?.(lineNumber)
771
- if (range && database.fileHandler && database.serializer) {
772
- const [start, end] = range
773
- const buffer = await database.fileHandler.readRange(start, end)
774
- if (buffer && buffer.length > 0) {
775
- let line = buffer.toString('utf8')
776
- if (line) {
777
- line = line.trim()
778
- if (line.length > 0) {
779
- const storedRecord = database.serializer.deserialize(line)
780
- belongs = storedRecord && storedRecord.id === record.id
781
- }
782
- }
783
- }
784
- }
785
- }
786
- } catch (error) {
787
- belongs = false
788
- }
789
-
790
- lineMatchCache.set(lineNumber, belongs)
791
- return belongs
792
- }
793
-
794
- for (const field in data) {
795
- if (record[field] !== undefined && record[field] !== null) {
796
- const values = Array.isArray(record[field]) ? record[field] : [record[field]]
797
- for (const val of values) {
798
- let key
799
-
800
- // Check if this is a term mapping field (array:string fields only)
801
- const isTermMappingField = this.database?.termManager &&
802
- this.database.termManager.termMappingFields &&
803
- this.database.termManager.termMappingFields.includes(field)
804
-
805
- if (isTermMappingField && typeof val === 'number') {
806
- // For term mapping fields (array:string), the values are already term IDs
807
- key = String(val)
808
- if (this.database.opts.debugMode) {
809
- console.log(`🔍 IndexManager._removeRecord: Using term ID ${val} directly for field "${field}"`)
810
- }
811
- } else if (isTermMappingField && typeof val === 'string') {
812
- // For term mapping fields (array:string), convert string to term ID
813
- const termId = this.database.termManager.getTermIdWithoutIncrement(val)
814
- key = String(termId)
815
- if (this.database.opts.debugMode) {
816
- console.log(`🔍 IndexManager._removeRecord: Using term ID ${termId} for term "${val}"`)
817
- }
818
- } else {
819
- // For non-term-mapping fields (including array:number), use values directly
820
- key = String(val)
821
- if (this.database?.opts?.debugMode) {
822
- console.log(`🔍 IndexManager._removeRecord: Using value "${val}" directly for field "${field}"`)
823
- }
824
- }
825
-
826
- // Note: TermManager notification is handled by Database.mjs
827
- // to avoid double decrementation during updates
828
-
829
- const indexEntry = data[field][key]
830
- if (indexEntry) {
831
- const lineNumbers = this._getAllLineNumbers(indexEntry)
832
- const filteredLineNumbers = []
833
-
834
- for (const lineNumber of lineNumbers) {
835
- if (!(await doesLineNumberBelongToRecord(lineNumber))) {
836
- filteredLineNumbers.push(lineNumber)
837
- }
838
- }
839
-
840
- if (filteredLineNumbers.length === 0) {
841
- delete data[field][key]
842
- } else {
843
- // Rebuild the index value with filtered line numbers
844
- data[field][key].set = new Set(filteredLineNumbers)
845
- data[field][key].ranges = []
846
- }
847
- }
848
- }
849
- }
850
- }
851
- }
852
-
853
- // Ultra-fast remove with batch processing (renamed from remove)
854
- _removeLineNumbers(lineNumbers) {
855
- if (!lineNumbers || lineNumbers.length === 0) return
856
-
857
- lineNumbers.sort((a, b) => a - b) // Sort ascending for efficient processing
858
-
859
- const data = this.index.data
860
- for (const field in data) {
861
- for (const value in data[field]) {
862
- const numbers = this._getAllLineNumbers(data[field][value])
863
- const newNumbers = []
864
-
865
- for (const ln of numbers) {
866
- let offset = 0
867
- for (const lineNumber of lineNumbers) {
868
- if (lineNumber < ln) {
869
- offset++
870
- } else if (lineNumber === ln) {
871
- offset = -1 // Mark for removal
872
- break
873
- }
874
- }
875
- if (offset >= 0) {
876
- newNumbers.push(ln - offset) // Update the value
877
- }
878
- }
879
-
880
- if (newNumbers.length > 0) {
881
- // Rebuild hybrid structure with new numbers
882
- data[field][value] = { set: new Set(), ranges: [] }
883
- for (const num of newNumbers) {
884
- data[field][value] = this._addLineNumber(data[field][value], num)
885
- }
886
- } else {
887
- delete data[field][value]
888
- }
889
- }
890
- }
891
- }
892
-
893
- // Ultra-fast replace with batch processing
894
- replace(map) {
895
- if (!map || map.size === 0) return
896
-
897
- const data = this.index.data
898
- for (const field in data) {
899
- for (const value in data[field]) {
900
- const numbers = this._getAllLineNumbers(data[field][value])
901
- const newNumbers = []
902
-
903
- for (const lineNumber of numbers) {
904
- if (map.has(lineNumber)) {
905
- newNumbers.push(map.get(lineNumber))
906
- } else {
907
- newNumbers.push(lineNumber)
908
- }
909
- }
910
-
911
- // Rebuild hybrid structure with new numbers
912
- data[field][value] = { set: new Set(), ranges: [] }
913
- for (const num of newNumbers) {
914
- data[field][value] = this._addLineNumber(data[field][value], num)
915
- }
916
- }
917
- }
918
- }
919
-
920
- // Ultra-fast query with early exit and smart processing
921
- query(criteria, options = {}) {
922
- if (typeof options === 'boolean') {
923
- options = { matchAny: options };
924
- }
925
- const { matchAny = false, caseInsensitive = false } = options;
926
-
927
- if (!criteria) {
928
- // Return all line numbers when no criteria provided
929
- return new Set(Array.from({ length: this.totalLines || 0 }, (_, i) => i));
930
- }
931
-
932
- // Handle $not operator
933
- if (criteria.$not && typeof criteria.$not === 'object') {
934
- // Get all possible line numbers from database offsets or totalLines
935
- const totalRecords = this.database?.offsets?.length || this.totalLines || 0;
936
- const allLines = new Set(Array.from({ length: totalRecords }, (_, i) => i));
937
-
938
- // Get line numbers matching the $not condition
939
- const notLines = this.query(criteria.$not, options);
940
-
941
- // Return complement (all lines except those matching $not condition)
942
- const result = new Set([...allLines].filter(x => !notLines.has(x)));
943
-
944
- // If there are other conditions besides $not, we need to intersect with them
945
- const otherCriteria = { ...criteria };
946
- delete otherCriteria.$not;
947
-
948
- if (Object.keys(otherCriteria).length > 0) {
949
- const otherResults = this.query(otherCriteria, options);
950
- return new Set([...result].filter(x => otherResults.has(x)));
951
- }
952
-
953
- return result;
954
- }
955
-
956
- // Handle $and queries with parallel processing optimization
957
- if (criteria.$and && Array.isArray(criteria.$and)) {
958
- // OPTIMIZATION: Process conditions in parallel for better performance
959
- if (criteria.$and.length > 1) {
960
- // Process all conditions in parallel (synchronous since query is not async)
961
- const conditionResults = criteria.$and.map(andCondition =>
962
- this.query(andCondition, options)
963
- );
964
-
965
- // Intersect all results for AND logic
966
- let result = conditionResults[0];
967
- for (let i = 1; i < conditionResults.length; i++) {
968
- result = new Set([...result].filter(x => conditionResults[i].has(x)));
969
- }
970
-
971
- // IMPORTANT: Check if there are other fields besides $and at the root level
972
- // If so, we need to intersect with them too
973
- const otherCriteria = { ...criteria };
974
- delete otherCriteria.$and;
975
-
976
- if (Object.keys(otherCriteria).length > 0) {
977
- const otherResults = this.query(otherCriteria, options);
978
- result = new Set([...result].filter(x => otherResults.has(x)));
979
- }
980
-
981
- return result || new Set();
982
- } else {
983
- // Single condition - check for other criteria at root level
984
- const andResult = this.query(criteria.$and[0], options);
985
-
986
- const otherCriteria = { ...criteria };
987
- delete otherCriteria.$and;
988
-
989
- if (Object.keys(otherCriteria).length > 0) {
990
- const otherResults = this.query(otherCriteria, options);
991
- return new Set([...andResult].filter(x => otherResults.has(x)));
992
- }
993
-
994
- return andResult;
995
- }
996
- }
997
-
998
- const fields = Object.keys(criteria);
999
- if (!fields.length) {
1000
- // Return all line numbers when criteria is empty object
1001
- return new Set(Array.from({ length: this.totalLines || 0 }, (_, i) => i));
1002
- }
1003
-
1004
- let matchingLines = matchAny ? new Set() : null;
1005
- const data = this.index.data
1006
-
1007
- for (const field of fields) {
1008
- // Skip logical operators - they are handled separately
1009
- if (field.startsWith('$')) continue;
1010
-
1011
- if (typeof data[field] === 'undefined') continue;
1012
-
1013
- const originalCriteriaValue = criteria[field];
1014
- const criteriaValue = normalizeCriteriaOperators(originalCriteriaValue, { target: 'legacy', preserveOriginal: true });
1015
- let lineNumbersForField = new Set();
1016
- const isNumericField = this.opts.indexes[field] === 'number';
1017
-
1018
- // Handle RegExp values directly (MUST check before object check since RegExp is an object)
1019
- if (criteriaValue instanceof RegExp) {
1020
- // RegExp cannot be efficiently queried using indices - fall back to streaming
1021
- // This will be handled by the QueryManager's streaming strategy
1022
- continue;
1023
- }
1024
-
1025
- if (typeof criteriaValue === 'object' && !Array.isArray(criteriaValue) && criteriaValue !== null) {
1026
- const fieldIndex = data[field];
1027
-
1028
- // Handle $in operator for array queries
1029
- if (criteriaValue.$in !== undefined && criteriaValue.$in !== null) {
1030
- const inValues = Array.isArray(criteriaValue.$in) ? criteriaValue.$in : [criteriaValue.$in];
1031
-
1032
- // PERFORMANCE: Cache term mapping field check once
1033
- const isTermMappingField = this.database?.termManager &&
1034
- this.database.termManager.termMappingFields &&
1035
- this.database.termManager.termMappingFields.includes(field)
1036
-
1037
- // PERFORMANCE: Track if any term was found and matched
1038
- let foundAnyMatch = false
1039
-
1040
- for (const inValue of inValues) {
1041
- // SPACE OPTIMIZATION: Convert search term to term ID for lookup
1042
- let searchTermId
1043
-
1044
- if (isTermMappingField && typeof inValue === 'number') {
1045
- // For term mapping fields (array:string), the search value is already a term ID
1046
- searchTermId = String(inValue)
1047
- } else if (isTermMappingField && typeof inValue === 'string') {
1048
- // For term mapping fields (array:string), convert string to term ID
1049
- const termId = this.database?.termManager?.getTermIdWithoutIncrement(String(inValue))
1050
- if (termId === undefined) {
1051
- // Term not found in termManager - skip this search value
1052
- // This means the term was never saved to the database
1053
- if (this.opts?.debugMode) {
1054
- console.log(`⚠️ Term "${inValue}" not found in termManager for field "${field}" - skipping`)
1055
- }
1056
- continue // Skip this value, no matches possible
1057
- }
1058
- searchTermId = String(termId)
1059
- } else {
1060
- // For non-term-mapping fields (including array:number), use values directly
1061
- searchTermId = String(inValue)
1062
- }
1063
-
1064
- // PERFORMANCE: Direct lookup instead of iteration
1065
- let matched = false
1066
- if (caseInsensitive && typeof inValue === 'string') {
1067
- const searchLower = searchTermId.toLowerCase()
1068
- for (const value in fieldIndex) {
1069
- if (value.toLowerCase() === searchLower) {
1070
- const numbers = this._getAllLineNumbers(fieldIndex[value]);
1071
- for (const lineNumber of numbers) {
1072
- lineNumbersForField.add(lineNumber);
1073
- }
1074
- matched = true
1075
- foundAnyMatch = true
1076
- }
1077
- }
1078
- } else {
1079
- const indexData = fieldIndex[searchTermId]
1080
- if (indexData) {
1081
- const numbers = this._getAllLineNumbers(indexData);
1082
- for (const lineNumber of numbers) {
1083
- lineNumbersForField.add(lineNumber);
1084
- }
1085
- matched = true
1086
- foundAnyMatch = true
1087
- }
1088
- }
1089
- }
1090
-
1091
- // CRITICAL FIX: If no matches found at all (all terms were unknown or not in index),
1092
- // lineNumbersForField remains empty which is correct (no results for $in)
1093
- // This is handled correctly by the caller - empty Set means no matches
1094
- }
1095
- // Handle $nin operator (not in) - returns complement of $in
1096
- else if (criteriaValue.$nin !== undefined) {
1097
- const ninValues = Array.isArray(criteriaValue.$nin) ? criteriaValue.$nin : [criteriaValue.$nin];
1098
-
1099
- // Get all possible line numbers
1100
- const totalRecords = this.database?.offsets?.length || this.totalLines || 0;
1101
- const allLines = new Set(Array.from({ length: totalRecords }, (_, i) => i));
1102
-
1103
- // Get line numbers that match any of the $nin values
1104
- const matchingLines = new Set();
1105
-
1106
- // PERFORMANCE: Cache term mapping field check once
1107
- const isTermMappingField = this.database?.termManager &&
1108
- this.database.termManager.termMappingFields &&
1109
- this.database.termManager.termMappingFields.includes(field)
1110
-
1111
- for (const ninValue of ninValues) {
1112
- // SPACE OPTIMIZATION: Convert search term to term ID for lookup
1113
- let searchTermId
1114
-
1115
- if (isTermMappingField && typeof ninValue === 'number') {
1116
- // For term mapping fields (array:string), the search value is already a term ID
1117
- searchTermId = String(ninValue)
1118
- } else if (isTermMappingField && typeof ninValue === 'string') {
1119
- // For term mapping fields (array:string), convert string to term ID
1120
- const termId = this.database?.termManager?.getTermIdWithoutIncrement(String(ninValue))
1121
- if (termId === undefined) {
1122
- // Term not found - skip this value (can't exclude what doesn't exist)
1123
- if (this.opts?.debugMode) {
1124
- console.log(`⚠️ Term "${ninValue}" not found in termManager for field "${field}" - skipping`)
1125
- }
1126
- continue
1127
- }
1128
- searchTermId = String(termId)
1129
- } else {
1130
- // For non-term-mapping fields (including array:number), use values directly
1131
- searchTermId = String(ninValue)
1132
- }
1133
-
1134
- // PERFORMANCE: Direct lookup instead of iteration
1135
- if (caseInsensitive && typeof ninValue === 'string') {
1136
- const searchLower = searchTermId.toLowerCase()
1137
- for (const value in fieldIndex) {
1138
- if (value.toLowerCase() === searchLower) {
1139
- const numbers = this._getAllLineNumbers(fieldIndex[value]);
1140
- for (const lineNumber of numbers) {
1141
- matchingLines.add(lineNumber);
1142
- }
1143
- }
1144
- }
1145
- } else {
1146
- const indexData = fieldIndex[searchTermId]
1147
- if (indexData) {
1148
- const numbers = this._getAllLineNumbers(indexData);
1149
- for (const lineNumber of numbers) {
1150
- matchingLines.add(lineNumber);
1151
- }
1152
- }
1153
- }
1154
- }
1155
-
1156
- // Return complement: all lines EXCEPT those matching $nin values
1157
- lineNumbersForField = new Set([...allLines].filter(x => !matchingLines.has(x)));
1158
- }
1159
- // Handle $contains operator for array queries
1160
- else if (criteriaValue.$contains !== undefined) {
1161
- const containsValue = criteriaValue.$contains;
1162
- // Handle case-insensitive for $contains
1163
- if (caseInsensitive && typeof containsValue === 'string') {
1164
- for (const value in fieldIndex) {
1165
- if (value.toLowerCase() === containsValue.toLowerCase()) {
1166
- const numbers = this._getAllLineNumbers(fieldIndex[value]);
1167
- for (const lineNumber of numbers) {
1168
- lineNumbersForField.add(lineNumber);
1169
- }
1170
- }
1171
- }
1172
- } else {
1173
- if (fieldIndex[containsValue]) {
1174
- const numbers = this._getAllLineNumbers(fieldIndex[containsValue]);
1175
- for (const lineNumber of numbers) {
1176
- lineNumbersForField.add(lineNumber);
1177
- }
1178
- }
1179
- }
1180
- }
1181
- // Handle $all operator for array queries - FIXED FOR TERM MAPPING
1182
- else if (criteriaValue.$all !== undefined) {
1183
- const allValues = Array.isArray(criteriaValue.$all) ? criteriaValue.$all : [criteriaValue.$all];
1184
-
1185
- const isTermMappingField = this.database?.termManager &&
1186
- this.database.termManager.termMappingFields &&
1187
- this.database.termManager.termMappingFields.includes(field)
1188
-
1189
- const normalizeValue = (value) => {
1190
- if (isTermMappingField) {
1191
- if (typeof value === 'number') {
1192
- return String(value)
1193
- }
1194
- if (typeof value === 'string') {
1195
- const termId = this.database?.termManager?.getTermIdWithoutIncrement(value)
1196
- if (termId !== undefined) {
1197
- return String(termId)
1198
- }
1199
- return null
1200
- }
1201
- return null
1202
- }
1203
- return String(value)
1204
- }
1205
-
1206
- const normalizedValues = []
1207
- for (const value of allValues) {
1208
- const normalized = normalizeValue(value)
1209
- if (normalized === null) {
1210
- // Term not found in term manager, no matches possible
1211
- return lineNumbersForField
1212
- }
1213
- normalizedValues.push(normalized)
1214
- }
1215
-
1216
- // Early exit optimization
1217
- if (normalizedValues.length === 0) {
1218
- // Empty $all matches everything
1219
- for (const value in fieldIndex) {
1220
- const numbers = this._getAllLineNumbers(fieldIndex[value]);
1221
- for (const lineNumber of numbers) {
1222
- lineNumbersForField.add(lineNumber);
1223
- }
1224
- }
1225
- } else {
1226
- // For term mapping, we need to find records that contain ALL specified terms
1227
- // This requires a different approach than simple field matching
1228
-
1229
- // First, get all line numbers that contain each individual term
1230
- const termLineNumbers = new Map();
1231
- for (const term of normalizedValues) {
1232
- if (fieldIndex[term]) {
1233
- termLineNumbers.set(term, new Set(this._getAllLineNumbers(fieldIndex[term])));
1234
- } else {
1235
- // If any term doesn't exist, no records can match $all
1236
- termLineNumbers.set(term, new Set());
1237
- }
1238
- }
1239
-
1240
- // Find intersection of all term line numbers
1241
- if (termLineNumbers.size > 0) {
1242
- const allTermSets = Array.from(termLineNumbers.values());
1243
- let intersection = allTermSets[0];
1244
-
1245
- for (let i = 1; i < allTermSets.length; i++) {
1246
- intersection = new Set([...intersection].filter(x => allTermSets[i].has(x)));
1247
- }
1248
-
1249
- // Add all line numbers from intersection
1250
- for (const lineNumber of intersection) {
1251
- lineNumbersForField.add(lineNumber);
1252
- }
1253
- }
1254
- }
1255
- }
1256
- // Handle other operators
1257
- else {
1258
- for (const value in fieldIndex) {
1259
- let includeValue = true;
1260
- if (isNumericField) {
1261
- const numericValue = parseFloat(value);
1262
- if (!isNaN(numericValue)) {
1263
- if (criteriaValue['>'] !== undefined && numericValue <= criteriaValue['>']) {
1264
- includeValue = false;
1265
- }
1266
- if (criteriaValue['>='] !== undefined && numericValue < criteriaValue['>=']) {
1267
- includeValue = false;
1268
- }
1269
- if (criteriaValue['<'] !== undefined && numericValue >= criteriaValue['<']) {
1270
- includeValue = false;
1271
- }
1272
- if (criteriaValue['<='] !== undefined && numericValue > criteriaValue['<=']) {
1273
- includeValue = false;
1274
- }
1275
- if (criteriaValue['!='] !== undefined) {
1276
- const excludeValues = Array.isArray(criteriaValue['!='])
1277
- ? criteriaValue['!=']
1278
- : [criteriaValue['!=']];
1279
- if (excludeValues.includes(numericValue)) {
1280
- includeValue = false;
1281
- }
1282
- }
1283
- }
1284
- } else {
1285
- if (criteriaValue['contains'] !== undefined && typeof value === 'string') {
1286
- const term = String(criteriaValue['contains']);
1287
- if (caseInsensitive) {
1288
- if (!value.toLowerCase().includes(term.toLowerCase())) {
1289
- includeValue = false;
1290
- }
1291
- } else {
1292
- if (!value.includes(term)) {
1293
- includeValue = false;
1294
- }
1295
- }
1296
- }
1297
- if (criteriaValue['regex'] !== undefined) {
1298
- let regex;
1299
- if (typeof criteriaValue['regex'] === 'string') {
1300
- regex = new RegExp(criteriaValue['regex'], caseInsensitive ? 'i' : '');
1301
- } else if (criteriaValue['regex'] instanceof RegExp) {
1302
- if (caseInsensitive && !criteriaValue['regex'].ignoreCase) {
1303
- const flags = criteriaValue['regex'].flags.includes('i')
1304
- ? criteriaValue['regex'].flags
1305
- : criteriaValue['regex'].flags + 'i';
1306
- regex = new RegExp(criteriaValue['regex'].source, flags);
1307
- } else {
1308
- regex = criteriaValue['regex'];
1309
- }
1310
- }
1311
- if (regex) {
1312
- // For array fields, test regex against each element
1313
- if (Array.isArray(value)) {
1314
- if (!value.some(element => regex.test(String(element)))) {
1315
- includeValue = false;
1316
- }
1317
- } else {
1318
- // For non-array fields, test regex against the value directly
1319
- if (!regex.test(String(value))) {
1320
- includeValue = false;
1321
- }
1322
- }
1323
- }
1324
- }
1325
- if (criteriaValue['!='] !== undefined) {
1326
- const excludeValues = Array.isArray(criteriaValue['!='])
1327
- ? criteriaValue['!=']
1328
- : [criteriaValue['!=']];
1329
- if (excludeValues.includes(value)) {
1330
- includeValue = false;
1331
- }
1332
- }
1333
- }
1334
-
1335
- if (includeValue) {
1336
- const numbers = this._getAllLineNumbers(fieldIndex[value]);
1337
- for (const lineNumber of numbers) {
1338
- lineNumbersForField.add(lineNumber);
1339
- }
1340
- }
1341
- }
1342
- }
1343
- } else {
1344
- // Simple equality comparison - handle array queries
1345
- const values = Array.isArray(criteriaValue) ? criteriaValue : [criteriaValue];
1346
- const fieldData = data[field];
1347
- for (const searchValue of values) {
1348
- // SPACE OPTIMIZATION: Convert search term to term ID for lookup
1349
- let searchTermId
1350
-
1351
- // PERFORMANCE: Cache term mapping field check once per field
1352
- const isTermMappingField = this.database?.termManager &&
1353
- this.database.termManager.termMappingFields &&
1354
- this.database.termManager.termMappingFields.includes(field)
1355
-
1356
- if (isTermMappingField && typeof searchValue === 'number') {
1357
- // For term mapping fields (array:string), the search value is already a term ID
1358
- searchTermId = String(searchValue)
1359
- } else if (isTermMappingField && typeof searchValue === 'string') {
1360
- // For term mapping fields (array:string), convert string to term ID
1361
- const termId = this.database?.termManager?.getTermIdWithoutIncrement(String(searchValue))
1362
- if (termId === undefined) {
1363
- // Term not found - skip this value
1364
- if (this.opts?.debugMode) {
1365
- console.log(`⚠️ Term "${searchValue}" not found in termManager for field "${field}" - skipping`)
1366
- }
1367
- continue // Skip this value, no matches possible
1368
- }
1369
- searchTermId = String(termId)
1370
- } else {
1371
- // For non-term-mapping fields (including array:number), use values directly
1372
- searchTermId = String(searchValue)
1373
- }
1374
-
1375
- for (const key in fieldData) {
1376
- let match = false;
1377
- if (isNumericField) {
1378
- // Convert both parts to number
1379
- match = Number(key) === Number(searchValue);
1380
- } else {
1381
- // SPACE OPTIMIZATION: Compare term IDs instead of full terms
1382
- if (caseInsensitive) {
1383
- // For case-insensitive, we need to check if the search term ID matches any key
1384
- match = key === String(searchTermId);
1385
- } else {
1386
- match = key === String(searchTermId);
1387
- }
1388
- }
1389
- if (match) {
1390
- const numbers = this._getAllLineNumbers(fieldData[key]);
1391
- for (const lineNumber of numbers) {
1392
- lineNumbersForField.add(lineNumber);
1393
- }
1394
- }
1395
- }
1396
- }
1397
- }
1398
-
1399
- // Consolidate results from each field
1400
- if (matchAny) {
1401
- matchingLines = new Set([...matchingLines, ...lineNumbersForField]);
1402
- } else {
1403
- if (matchingLines === null) {
1404
- matchingLines = lineNumbersForField;
1405
- } else {
1406
- matchingLines = new Set([...matchingLines].filter(n => lineNumbersForField.has(n)));
1407
- }
1408
- if (!matchingLines.size) {
1409
- return new Set();
1410
- }
1411
- }
1412
- }
1413
- return matchingLines || new Set();
1414
- }
1415
-
1416
- /**
1417
- * Check if any records exist for given field and terms (index-only, ultra-fast)
1418
- * Stops at first match for maximum performance - no disk I/O required
1419
- *
1420
- * @param {string} fieldName - Indexed field name (e.g., 'nameTerms', 'groupTerms')
1421
- * @param {string|Array<string>} terms - Single term or array of terms to check
1422
- * @param {Object} options - Options: { $all: true/false, caseInsensitive: true/false, excludes: Array<string> }
1423
- * @returns {boolean} - True if at least one match exists
1424
- *
1425
- * @example
1426
- * // Check if any record has 'channel' in nameTerms
1427
- * indexManager.exists('nameTerms', 'channel')
1428
- *
1429
- * @example
1430
- * // Check if any record has ALL terms ['a', 'e'] in nameTerms ($all)
1431
- * indexManager.exists('nameTerms', ['a', 'e'], { $all: true })
1432
- *
1433
- * @example
1434
- * // Check if any record has ANY of the terms ['channel', 'tv'] in nameTerms
1435
- * indexManager.exists('nameTerms', ['channel', 'tv'], { $all: false })
1436
- *
1437
- * @example
1438
- * // Check if any record has 'tv' but NOT 'globo' in nameTerms
1439
- * indexManager.exists('nameTerms', 'tv', { excludes: ['globo'] })
1440
- *
1441
- * @example
1442
- * // Check if any record has ['tv', 'news'] but NOT 'sports' in nameTerms
1443
- * indexManager.exists('nameTerms', ['tv', 'news'], { $all: true, excludes: ['sports'] })
1444
- */
1445
- exists(fieldName, terms, options = {}) {
1446
- // Early exit: validate fieldName
1447
- if (!fieldName || typeof fieldName !== 'string') {
1448
- return false;
1449
- }
1450
-
1451
- // Early exit: check if field is indexed
1452
- if (!this.isFieldIndexed(fieldName)) {
1453
- return false;
1454
- }
1455
-
1456
- const fieldIndex = this.index.data[fieldName];
1457
- if (!fieldIndex || typeof fieldIndex !== 'object') {
1458
- return false;
1459
- }
1460
-
1461
- // Normalize terms to array
1462
- const termsArray = Array.isArray(terms) ? terms : [terms];
1463
- if (termsArray.length === 0) {
1464
- return false;
1465
- }
1466
-
1467
- const { $all = false, caseInsensitive = false, excludes = [] } = options;
1468
- const hasExcludes = Array.isArray(excludes) && excludes.length > 0;
1469
- const isTermMappingField = this.database?.termManager &&
1470
- this.database.termManager.termMappingFields &&
1471
- this.database.termManager.termMappingFields.includes(fieldName);
1472
-
1473
- // Helper: check if termData has any line numbers (ULTRA LIGHT - no expansion)
1474
- const hasData = (termData) => {
1475
- if (!termData) return false;
1476
- // Check Set size (O(1))
1477
- if (termData.set && termData.set.size > 0) {
1478
- return true;
1479
- }
1480
- // Check ranges length (O(1))
1481
- if (termData.ranges && termData.ranges.length > 0) {
1482
- return true;
1483
- }
1484
- return false;
1485
- };
1486
-
1487
- // Helper: get term key with term mapping and case-insensitive support
1488
- const getTermKey = (term, useCaseInsensitive = false) => {
1489
- if (isTermMappingField && typeof term === 'string') {
1490
- let termId;
1491
- if (useCaseInsensitive) {
1492
- // For case-insensitive, search termManager for case-insensitive match
1493
- const searchLower = String(term).toLowerCase();
1494
- termId = null;
1495
- if (this.database?.termManager?.termToId) {
1496
- for (const [termStr, id] of this.database.termManager.termToId.entries()) {
1497
- if (termStr.toLowerCase() === searchLower) {
1498
- termId = id;
1499
- break;
1500
- }
1501
- }
1502
- }
1503
- } else {
1504
- termId = this.database?.termManager?.getTermIdWithoutIncrement(String(term));
1505
- }
1506
-
1507
- if (termId === undefined || termId === null) {
1508
- return null;
1509
- }
1510
- return String(termId);
1511
- }
1512
-
1513
- // For non-term-mapping fields
1514
- if (useCaseInsensitive && typeof term === 'string') {
1515
- const searchLower = String(term).toLowerCase();
1516
- for (const key in fieldIndex) {
1517
- if (key.toLowerCase() === searchLower) {
1518
- return key;
1519
- }
1520
- }
1521
- return null;
1522
- }
1523
-
1524
- return String(term);
1525
- };
1526
-
1527
- // Handle $all (all terms must exist and have intersection)
1528
- if ($all) {
1529
- // Collect term data for all terms first (with early exit)
1530
- const termDataArray = [];
1531
-
1532
- for (const term of termsArray) {
1533
- // Get term key (with term mapping if applicable)
1534
- let termKey;
1535
- if (isTermMappingField && typeof term === 'string') {
1536
- let termId;
1537
- if (caseInsensitive) {
1538
- // For case-insensitive, search termManager for case-insensitive match
1539
- const searchLower = String(term).toLowerCase();
1540
- termId = null;
1541
- for (const [termStr, id] of this.database.termManager.termToId.entries()) {
1542
- if (termStr.toLowerCase() === searchLower) {
1543
- termId = id;
1544
- break;
1545
- }
1546
- }
1547
- } else {
1548
- termId = this.database?.termManager?.getTermIdWithoutIncrement(String(term));
1549
- }
1550
-
1551
- if (termId === undefined || termId === null) {
1552
- return false; // Early exit: term doesn't exist in mapping
1553
- }
1554
- termKey = String(termId);
1555
- } else {
1556
- termKey = String(term);
1557
- // For non-term-mapping fields with case-insensitive, search index keys
1558
- if (caseInsensitive && typeof term === 'string') {
1559
- const searchLower = termKey.toLowerCase();
1560
- let foundKey = null;
1561
- for (const key in fieldIndex) {
1562
- if (key.toLowerCase() === searchLower) {
1563
- foundKey = key;
1564
- break;
1565
- }
1566
- }
1567
- if (foundKey === null) {
1568
- return false; // Early exit: term doesn't exist
1569
- }
1570
- termKey = foundKey;
1571
- }
1572
- }
1573
-
1574
- // Check if term exists in index
1575
- const termData = fieldIndex[termKey];
1576
- if (!termData || !hasData(termData)) {
1577
- return false; // Early exit: term doesn't exist or has no data
1578
- }
1579
-
1580
- termDataArray.push(termData);
1581
- }
1582
-
1583
- // If we got here, all terms exist and have data
1584
- // Now check if there's intersection (only if more than one term)
1585
- if (termDataArray.length === 1) {
1586
- // Single term - check excludes if any
1587
- if (!hasExcludes) {
1588
- return true; // Single term, already verified it has data, no excludes
1589
- }
1590
- // Need to check excludes - expand line numbers
1591
- const lineNumbers = this._getAllLineNumbers(termDataArray[0]);
1592
- const candidateLines = new Set(lineNumbers);
1593
-
1594
- // Remove lines that have exclude terms
1595
- for (const excludeTerm of excludes) {
1596
- const excludeKey = getTermKey(excludeTerm, caseInsensitive);
1597
- if (excludeKey === null) continue;
1598
-
1599
- const excludeData = fieldIndex[excludeKey];
1600
- if (!excludeData) continue;
1601
-
1602
- const excludeLines = this._getAllLineNumbers(excludeData);
1603
- for (const line of excludeLines) {
1604
- candidateLines.delete(line);
1605
- }
1606
-
1607
- // Early exit if all candidates excluded
1608
- if (candidateLines.size === 0) {
1609
- return false;
1610
- }
1611
- }
1612
-
1613
- return candidateLines.size > 0;
1614
- }
1615
-
1616
- // For multiple terms, we need to check intersection
1617
- // But we want to do this as lightly as possible
1618
- // Get line numbers only for intersection check (unavoidable for $all)
1619
- const termLineNumberSets = [];
1620
- for (const termData of termDataArray) {
1621
- const lineNumbers = this._getAllLineNumbers(termData);
1622
- if (lineNumbers.length === 0) {
1623
- return false; // Early exit: no line numbers (shouldn't happen, but safety check)
1624
- }
1625
- termLineNumberSets.push(new Set(lineNumbers));
1626
- }
1627
-
1628
- // Calculate intersection incrementally with early exit
1629
- let intersection = termLineNumberSets[0];
1630
- for (let i = 1; i < termLineNumberSets.length; i++) {
1631
- // Filter intersection to only include items in current set
1632
- intersection = new Set([...intersection].filter(x => termLineNumberSets[i].has(x)));
1633
- if (intersection.size === 0) {
1634
- return false; // Early exit: intersection is empty
1635
- }
1636
- }
1637
-
1638
- // Apply excludes if any
1639
- if (hasExcludes) {
1640
- for (const excludeTerm of excludes) {
1641
- const excludeKey = getTermKey(excludeTerm, caseInsensitive);
1642
- if (excludeKey === null) continue;
1643
-
1644
- const excludeData = fieldIndex[excludeKey];
1645
- if (!excludeData) continue;
1646
-
1647
- const excludeLines = this._getAllLineNumbers(excludeData);
1648
- for (const line of excludeLines) {
1649
- intersection.delete(line);
1650
- }
1651
-
1652
- // Early exit if all candidates excluded
1653
- if (intersection.size === 0) {
1654
- return false;
1655
- }
1656
- }
1657
- }
1658
-
1659
- return intersection.size > 0;
1660
- }
1661
-
1662
- // Handle $in behavior (any term exists) - default - ULTRA LIGHT
1663
- // If no excludes, use ultra-fast path (no expansion needed)
1664
- if (!hasExcludes) {
1665
- for (const term of termsArray) {
1666
- // Handle case-insensitive FIRST (before normal conversion)
1667
- if (caseInsensitive && typeof term === 'string') {
1668
- if (isTermMappingField && this.database?.termManager?.termToId) {
1669
- // For term mapping fields, we need to find the term in termManager first
1670
- // (case-insensitive), then convert to ID
1671
- const searchLower = String(term).toLowerCase();
1672
- let foundTermId = null;
1673
-
1674
- // Search termManager for case-insensitive match
1675
- for (const [termStr, termId] of this.database.termManager.termToId.entries()) {
1676
- if (termStr.toLowerCase() === searchLower) {
1677
- foundTermId = termId;
1678
- break;
1679
- }
1680
- }
1681
-
1682
- if (foundTermId !== null) {
1683
- const termData = fieldIndex[String(foundTermId)];
1684
- if (hasData(termData)) {
1685
- return true; // Early exit: found a match
1686
- }
1687
- }
1688
- // If not found, continue to next term
1689
- continue;
1690
- } else {
1691
- // For non-term-mapping fields, search index keys directly
1692
- const searchLower = String(term).toLowerCase();
1693
- for (const key in fieldIndex) {
1694
- if (key.toLowerCase() === searchLower) {
1695
- const termData = fieldIndex[key];
1696
- if (hasData(termData)) {
1697
- return true; // Early exit: found a match
1698
- }
1699
- }
1700
- }
1701
- // If not found, continue to next term
1702
- continue;
1703
- }
1704
- }
1705
-
1706
- // Normal (case-sensitive) lookup
1707
- const termKey = getTermKey(term, false);
1708
- if (termKey === null) {
1709
- continue; // Term not in mapping, try next
1710
- }
1711
-
1712
- // Direct lookup (fastest path) - O(1) hash lookup
1713
- const termData = fieldIndex[termKey];
1714
- if (hasData(termData)) {
1715
- return true; // Early exit: found a match
1716
- }
1717
- }
1718
-
1719
- return false;
1720
- }
1721
-
1722
- // With excludes, we need to collect candidates and filter
1723
- const candidateLines = new Set();
1724
-
1725
- for (const term of termsArray) {
1726
- // Handle case-insensitive FIRST (before normal conversion)
1727
- if (caseInsensitive && typeof term === 'string') {
1728
- if (isTermMappingField && this.database?.termManager?.termToId) {
1729
- // For term mapping fields, we need to find the term in termManager first
1730
- // (case-insensitive), then convert to ID
1731
- const searchLower = String(term).toLowerCase();
1732
- let foundTermId = null;
1733
-
1734
- // Search termManager for case-insensitive match
1735
- for (const [termStr, termId] of this.database.termManager.termToId.entries()) {
1736
- if (termStr.toLowerCase() === searchLower) {
1737
- foundTermId = termId;
1738
- break;
1739
- }
1740
- }
1741
-
1742
- if (foundTermId !== null) {
1743
- const termData = fieldIndex[String(foundTermId)];
1744
- if (hasData(termData)) {
1745
- // Add line numbers to candidates (need to expand for excludes check)
1746
- const lineNumbers = this._getAllLineNumbers(termData);
1747
- for (const line of lineNumbers) {
1748
- candidateLines.add(line);
1749
- }
1750
- }
1751
- }
1752
- continue;
1753
- } else {
1754
- // For non-term-mapping fields, search index keys directly
1755
- const searchLower = String(term).toLowerCase();
1756
- for (const key in fieldIndex) {
1757
- if (key.toLowerCase() === searchLower) {
1758
- const termData = fieldIndex[key];
1759
- if (hasData(termData)) {
1760
- // Add line numbers to candidates
1761
- const lineNumbers = this._getAllLineNumbers(termData);
1762
- for (const line of lineNumbers) {
1763
- candidateLines.add(line);
1764
- }
1765
- }
1766
- }
1767
- }
1768
- continue;
1769
- }
1770
- }
1771
-
1772
- // Normal (case-sensitive) lookup
1773
- const termKey = getTermKey(term, false);
1774
- if (termKey === null) {
1775
- continue; // Term not in mapping, try next
1776
- }
1777
-
1778
- // Direct lookup
1779
- const termData = fieldIndex[termKey];
1780
- if (hasData(termData)) {
1781
- // Add line numbers to candidates (need to expand for excludes check)
1782
- const lineNumbers = this._getAllLineNumbers(termData);
1783
- for (const line of lineNumbers) {
1784
- candidateLines.add(line);
1785
- }
1786
- }
1787
- }
1788
-
1789
- // If no candidates found, return false
1790
- if (candidateLines.size === 0) {
1791
- return false;
1792
- }
1793
-
1794
- // Apply excludes
1795
- for (const excludeTerm of excludes) {
1796
- const excludeKey = getTermKey(excludeTerm, caseInsensitive);
1797
- if (excludeKey === null) continue;
1798
-
1799
- const excludeData = fieldIndex[excludeKey];
1800
- if (!excludeData) continue;
1801
-
1802
- const excludeLines = this._getAllLineNumbers(excludeData);
1803
- for (const line of excludeLines) {
1804
- candidateLines.delete(line);
1805
- }
1806
-
1807
- // Early exit if all candidates excluded
1808
- if (candidateLines.size === 0) {
1809
- return false;
1810
- }
1811
- }
1812
-
1813
- return candidateLines.size > 0;
1814
- }
1815
-
1816
- /**
1817
- * Evaluate many index-only existence checks while sharing the same field data.
1818
- * Returns a map keyed by the criteria id so callers can reuse a single scan per field.
1819
- *
1820
- * @param {string} fieldName - Indexed field name
1821
- * @param {Array<Object>} criteriaArray - Array of { id, terms, options }
1822
- * @param {Object} opts - { limit, allowPartial } (limit only applies when allowPartial is true)
1823
- * @returns {Object<string, boolean>} - Map of criteria id to boolean existence
1824
- */
1825
- multiExists(fieldName, criteriaArray, opts = {}) {
1826
- const results = {}
1827
- if (!Array.isArray(criteriaArray) || criteriaArray.length === 0) {
1828
- return results
1829
- }
1830
-
1831
- const prepared = []
1832
- for (let i = 0; i < criteriaArray.length; i++) {
1833
- const entry = criteriaArray[i] || {}
1834
- const key = entry.id !== undefined && entry.id !== null ? String(entry.id) : `criteria-${i}`
1835
- results[key] = false
1836
- prepared.push({
1837
- id: key,
1838
- terms: entry.terms,
1839
- options: entry.options || {}
1840
- })
1841
- }
1842
-
1843
- if (!fieldName || typeof fieldName !== 'string') {
1844
- return results
1845
- }
1846
-
1847
- const fieldIndex = this.index?.data?.[fieldName]
1848
- if (!fieldIndex || typeof fieldIndex !== 'object') {
1849
- return results
1850
- }
1851
-
1852
- const termManager = this.database?.termManager
1853
- const isTermMappingField = Boolean(termManager && termManager.termMappingFields && termManager.termMappingFields.includes(fieldName))
1854
-
1855
- const normalizedLimit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.floor(opts.limit) : Infinity
1856
- const allowPartial = Boolean(opts.allowPartial)
1857
- const positiveTarget = allowPartial ? Math.max(1, normalizedLimit) : Infinity
1858
-
1859
- const termCache = new Map()
1860
- let caseInsensitiveLookup = null
1861
- let termMappingLookup = null
1862
-
1863
- const normalizeValues = (value) => {
1864
- if (value === null || value === undefined) {
1865
- return []
1866
- }
1867
- if (Array.isArray(value)) {
1868
- return value.slice()
1869
- }
1870
- return [value]
1871
- }
1872
-
1873
- const buildCaseInsensitiveLookup = () => {
1874
- const map = new Map()
1875
- for (const key in fieldIndex) {
1876
- const lowerKey = key.toLowerCase()
1877
- if (!map.has(lowerKey)) {
1878
- map.set(lowerKey, key)
1879
- }
1880
- }
1881
- return map
1882
- }
1883
-
1884
- const buildTermMappingLookup = () => {
1885
- const map = new Map()
1886
- const termToId = termManager?.termToId
1887
- if (termToId) {
1888
- for (const [termStr, id] of termToId.entries()) {
1889
- const lower = termStr.toLowerCase()
1890
- if (!map.has(lower)) {
1891
- map.set(lower, String(id))
1892
- }
1893
- }
1894
- }
1895
- return map
1896
- }
1897
-
1898
- const resolveTermKey = (value, caseInsensitive) => {
1899
- if (value === null || value === undefined) {
1900
- return null
1901
- }
1902
-
1903
- if (isTermMappingField) {
1904
- if (typeof value === 'string') {
1905
- if (caseInsensitive) {
1906
- if (!termMappingLookup) {
1907
- termMappingLookup = buildTermMappingLookup()
1908
- }
1909
- return termMappingLookup.get(value.toLowerCase()) ?? null
1910
- }
1911
- const termId = termManager?.getTermIdWithoutIncrement(String(value))
1912
- if (termId === undefined || termId === null) {
1913
- return null
1914
- }
1915
- return String(termId)
1916
- }
1917
- return String(value)
1918
- }
1919
-
1920
- if (caseInsensitive && typeof value === 'string') {
1921
- if (!caseInsensitiveLookup) {
1922
- caseInsensitiveLookup = buildCaseInsensitiveLookup()
1923
- }
1924
- return caseInsensitiveLookup.get(value.toLowerCase()) ?? null
1925
- }
1926
-
1927
- return String(value)
1928
- }
1929
-
1930
- const ensureTermEntry = (termKey) => {
1931
- if (!termCache.has(termKey)) {
1932
- const data = fieldIndex[termKey]
1933
- const entry = {
1934
- data,
1935
- hasData: Boolean(data && ((data.set && data.set.size > 0) || (data.ranges && data.ranges.length > 0))),
1936
- lineArray: null
1937
- }
1938
- termCache.set(termKey, entry)
1939
- }
1940
- return termCache.get(termKey)
1941
- }
1942
-
1943
- const getLineArray = (entry) => {
1944
- if (!entry.hasData || !entry.data) return []
1945
- if (!entry.lineArray) {
1946
- entry.lineArray = this._getAllLineNumbers(entry.data)
1947
- }
1948
- return entry.lineArray
1949
- }
1950
-
1951
- const applyExcludes = (lineSet, excludeKeys) => {
1952
- if (excludeKeys.length === 0) {
1953
- return lineSet.size > 0
1954
- }
1955
- for (const excludeKey of excludeKeys) {
1956
- const excludeEntry = ensureTermEntry(excludeKey)
1957
- if (!excludeEntry.hasData) continue
1958
- const excludeLines = getLineArray(excludeEntry)
1959
- for (const line of excludeLines) {
1960
- lineSet.delete(line)
1961
- }
1962
- if (lineSet.size === 0) {
1963
- return false
1964
- }
1965
- }
1966
- return lineSet.size > 0
1967
- }
1968
-
1969
- let successes = 0
1970
-
1971
- for (const criterion of prepared) {
1972
- if (successes >= positiveTarget) {
1973
- break
1974
- }
1975
-
1976
- const { id, terms, options } = criterion
1977
- const { $all = false, caseInsensitive = false, excludes = [] } = options
1978
-
1979
- const normalizedTerms = normalizeValues(terms)
1980
- if (normalizedTerms.length === 0) {
1981
- continue
1982
- }
1983
-
1984
- const normalizedExcludes = normalizeValues(excludes)
1985
-
1986
- const resolvedKeys = []
1987
- const seenKeys = new Set()
1988
- let missingRequiredTerm = false
1989
-
1990
- for (const term of normalizedTerms) {
1991
- const termKey = resolveTermKey(term, caseInsensitive)
1992
- if (termKey === null) {
1993
- if ($all) {
1994
- missingRequiredTerm = true
1995
- break
1996
- }
1997
- continue
1998
- }
1999
- if (!seenKeys.has(termKey)) {
2000
- seenKeys.add(termKey)
2001
- resolvedKeys.push(termKey)
2002
- }
2003
- }
2004
-
2005
- if ($all && missingRequiredTerm) {
2006
- continue
2007
- }
2008
-
2009
- if (resolvedKeys.length === 0) {
2010
- continue
2011
- }
2012
-
2013
- const excludeKeySet = []
2014
- const seenExcludeKeys = new Set()
2015
- for (const excludeTerm of normalizedExcludes) {
2016
- const excludeKey = resolveTermKey(excludeTerm, caseInsensitive)
2017
- if (excludeKey && !seenExcludeKeys.has(excludeKey)) {
2018
- seenExcludeKeys.add(excludeKey)
2019
- excludeKeySet.push(excludeKey)
2020
- }
2021
- }
2022
-
2023
- let match = false
2024
-
2025
- if ($all) {
2026
- let intersection = null
2027
- let failed = false
2028
-
2029
- for (const termKey of resolvedKeys) {
2030
- const entry = ensureTermEntry(termKey)
2031
- if (!entry.hasData) {
2032
- failed = true
2033
- break
2034
- }
2035
- const lines = getLineArray(entry)
2036
- if (lines.length === 0) {
2037
- failed = true
2038
- break
2039
- }
2040
-
2041
- const currentSet = new Set(lines)
2042
- if (intersection === null) {
2043
- intersection = currentSet
2044
- } else {
2045
- intersection = new Set([...intersection].filter(value => currentSet.has(value)))
2046
- if (intersection.size === 0) {
2047
- failed = true
2048
- break
2049
- }
2050
- }
2051
- }
2052
-
2053
- if (!failed && intersection && intersection.size > 0) {
2054
- match = applyExcludes(intersection, excludeKeySet)
2055
- }
2056
- } else if (excludeKeySet.length === 0) {
2057
- match = resolvedKeys.some(termKey => ensureTermEntry(termKey).hasData)
2058
- } else {
2059
- const candidates = new Set()
2060
- for (const termKey of resolvedKeys) {
2061
- const entry = ensureTermEntry(termKey)
2062
- if (!entry.hasData) continue
2063
- const lines = getLineArray(entry)
2064
- for (const line of lines) {
2065
- candidates.add(line)
2066
- }
2067
- }
2068
- if (candidates.size > 0) {
2069
- match = applyExcludes(candidates, excludeKeySet)
2070
- }
2071
- }
2072
-
2073
- results[id] = Boolean(match)
2074
- if (match) {
2075
- successes += 1
2076
- }
2077
- }
2078
-
2079
- return results
2080
- }
2081
-
2082
- // Ultra-fast load with minimal conversions
2083
- load(index) {
2084
- // CRITICAL FIX: Check if index is already loaded by looking for actual data, not just empty field structures
2085
- if (this.index && this.index.data) {
2086
- let hasActualData = false
2087
- for (const field in this.index.data) {
2088
- const fieldData = this.index.data[field]
2089
- if (fieldData && Object.keys(fieldData).length > 0) {
2090
- // Check if any field has actual index entries with data
2091
- for (const key in fieldData) {
2092
- const entry = fieldData[key]
2093
- if (entry && ((entry.set && entry.set.size > 0) || (entry.ranges && entry.ranges.length > 0))) {
2094
- hasActualData = true
2095
- break
2096
- }
2097
- }
2098
- if (hasActualData) break
2099
- }
2100
- }
2101
-
2102
- if (hasActualData) {
2103
- if (this.opts.debugMode) {
2104
- console.log('🔍 IndexManager.load: Index already loaded with actual data, skipping')
2105
- }
2106
- return
2107
- }
2108
- }
2109
-
2110
- // CRITICAL FIX: Add comprehensive null/undefined validation
2111
- if (!index || typeof index !== 'object') {
2112
- if (this.opts.debugMode) {
2113
- console.log(`🔍 IndexManager.load: Invalid index data provided (${typeof index}), using defaults`)
2114
- }
2115
- return this._initializeDefaults()
2116
- }
2117
-
2118
- if (!index.data || typeof index.data !== 'object') {
2119
- if (this.opts.debugMode) {
2120
- console.log(`🔍 IndexManager.load: Invalid index.data provided (${typeof index.data}), using defaults`)
2121
- }
2122
- return this._initializeDefaults()
2123
- }
2124
-
2125
- // CRITICAL FIX: Only log if there are actual fields to load
2126
- if (this.opts.debugMode && Object.keys(index.data).length > 0) {
2127
- console.log(`🔍 IndexManager.load: Loading index with fields: ${Object.keys(index.data).join(', ')}`)
2128
- }
2129
-
2130
- // Create a deep copy to avoid reference issues
2131
- const processedIndex = {
2132
- data: {}
2133
- }
2134
-
2135
- // CRITICAL FIX: Add null/undefined checks for field iteration
2136
- const fields = Object.keys(index.data)
2137
- for(const field of fields) {
2138
- if (!field || typeof field !== 'string') {
2139
- continue // Skip invalid field names
2140
- }
2141
-
2142
- const fieldData = index.data[field]
2143
- if (!fieldData || typeof fieldData !== 'object') {
2144
- continue // Skip invalid field data
2145
- }
2146
-
2147
- processedIndex.data[field] = {}
2148
-
2149
- // CRITICAL FIX: Check if this is a term mapping field for conversion
2150
- const isTermMappingField = this.database?.termManager &&
2151
- this.database.termManager.termMappingFields &&
2152
- this.database.termManager.termMappingFields.includes(field)
2153
-
2154
- const terms = Object.keys(fieldData)
2155
- for(const term of terms) {
2156
- if (!term || typeof term !== 'string') {
2157
- continue // Skip invalid term names
2158
- }
2159
-
2160
- const termData = fieldData[term]
2161
-
2162
- // CRITICAL FIX: Convert term strings to term IDs for term mapping fields
2163
- // If the key is a string term (not a numeric ID), convert it to term ID
2164
- let termKey = term
2165
- if (isTermMappingField && typeof term === 'string' && !/^\d+$/.test(term)) {
2166
- // Key is a term string, convert to term ID
2167
- const termId = this.database?.termManager?.getTermIdWithoutIncrement(term)
2168
- if (termId !== undefined) {
2169
- termKey = String(termId)
2170
- } else {
2171
- // Term not found in termManager - skip this key (orphaned term from old index)
2172
- // This can happen if termMapping wasn't loaded yet or term was removed
2173
- if (this.opts?.debugMode) {
2174
- console.log(`⚠️ IndexManager.load: Term "${term}" not found in termManager for field "${field}" - skipping (orphaned from old index)`)
2175
- }
2176
- continue
2177
- }
2178
- }
2179
-
2180
- // Convert various formats to new hybrid format
2181
- if (Array.isArray(termData)) {
2182
- // Check if it's the new compact format [setArray, rangesArray]
2183
- if (termData.length === 2 && Array.isArray(termData[0]) && Array.isArray(termData[1])) {
2184
- // New compact format: [setArray, rangesArray]
2185
- // Convert ultra-compact ranges [start, count] back to {start, count}
2186
- const ranges = termData[1].map(range => {
2187
- if (Array.isArray(range) && range.length === 2) {
2188
- // Ultra-compact format: [start, count]
2189
- return { start: range[0], count: range[1] }
2190
- } else {
2191
- // Legacy format: {start, count}
2192
- return range
2193
- }
2194
- })
2195
- processedIndex.data[field][termKey] = {
2196
- set: new Set(termData[0]),
2197
- ranges: ranges
2198
- }
2199
- } else {
2200
- // Legacy array format (just set data)
2201
- processedIndex.data[field][termKey] = { set: new Set(termData), ranges: [] }
2202
- }
2203
- } else if (termData && typeof termData === 'object') {
2204
- if (termData.set || termData.ranges) {
2205
- // Legacy hybrid format - convert set array back to Set
2206
- const hybridData = termData
2207
- let setObject
2208
- if (Array.isArray(hybridData.set)) {
2209
- // Convert array back to Set
2210
- setObject = new Set(hybridData.set)
2211
- } else {
2212
- // Fallback to empty Set
2213
- setObject = new Set()
2214
- }
2215
- processedIndex.data[field][termKey] = {
2216
- set: setObject,
2217
- ranges: hybridData.ranges || []
2218
- }
2219
- } else {
2220
- // Convert from Set format to hybrid
2221
- const numbers = Array.from(termData || [])
2222
- processedIndex.data[field][termKey] = { set: new Set(numbers), ranges: [] }
2223
- }
2224
- }
2225
- }
2226
- }
2227
-
2228
- // Preserve initialized fields if no data was loaded
2229
- if (!processedIndex.data || Object.keys(processedIndex.data).length === 0) {
2230
- // CRITICAL FIX: Only log if debug mode is enabled and there are actual fields
2231
- if (this.opts.debugMode && this.index.data && Object.keys(this.index.data).length > 0) {
2232
- console.log(`🔍 IndexManager.load: No data loaded, preserving initialized fields: ${Object.keys(this.index.data).join(', ')}`)
2233
- }
2234
- // Keep the current index with initialized fields
2235
- return
2236
- }
2237
-
2238
- // Restore totalLines from saved data
2239
- if (index.totalLines !== undefined) {
2240
- this.totalLines = index.totalLines
2241
- if (this.opts.debugMode) {
2242
- console.log(`🔍 IndexManager.load: Restored totalLines=${this.totalLines}`)
2243
- }
2244
- }
2245
-
2246
- this.index = processedIndex
2247
- }
2248
-
2249
- /**
2250
- * CRITICAL FIX: Initialize default index structure when invalid data is provided
2251
- * This prevents TypeError when Object.keys() is called on null/undefined
2252
- */
2253
- _initializeDefaults() {
2254
- if (this.opts.debugMode) {
2255
- console.log(`🔍 IndexManager._initializeDefaults: Initializing default index structure`)
2256
- }
2257
-
2258
- // Initialize empty index structure
2259
- this.index = { data: {} }
2260
-
2261
- // Initialize fields from options if available
2262
- if (this.opts.indexes && typeof this.opts.indexes === 'object') {
2263
- const fields = Object.keys(this.opts.indexes)
2264
- for (const field of fields) {
2265
- if (field && typeof field === 'string') {
2266
- this.index.data[field] = {}
2267
- }
2268
- }
2269
- }
2270
-
2271
- if (this.opts.debugMode) {
2272
- console.log(`🔍 IndexManager._initializeDefaults: Initialized with fields: ${Object.keys(this.index.data).join(', ')}`)
2273
- }
2274
- }
2275
-
2276
- readColumnIndex(column) {
2277
- return new Set((this.index.data && this.index.data[column]) ? Object.keys(this.index.data[column]) : [])
2278
- }
2279
-
2280
- /**
2281
- * Convert index to JSON-serializable format for debugging and export
2282
- * This resolves the issue where Sets appear as empty objects in JSON.stringify
2283
- */
2284
- toJSON() {
2285
- const serializable = {
2286
- data: {},
2287
- totalLines: this.totalLines
2288
- }
2289
-
2290
- // Check if this is a term mapping field for conversion
2291
- const isTermMappingField = (field) => {
2292
- return this.database?.termManager &&
2293
- this.database.termManager.termMappingFields &&
2294
- this.database.termManager.termMappingFields.includes(field)
2295
- }
2296
-
2297
- for (const field in this.index.data) {
2298
- serializable.data[field] = {}
2299
- const isTermField = isTermMappingField(field)
2300
-
2301
- for (const term in this.index.data[field]) {
2302
- const hybridData = this.index.data[field][term]
2303
-
2304
- // CRITICAL FIX: Convert term strings to term IDs for term mapping fields
2305
- // If the key is a string term (not a numeric ID), convert it to term ID
2306
- let termKey = term
2307
- if (isTermField && typeof term === 'string' && !/^\d+$/.test(term)) {
2308
- // Key is a term string, convert to term ID
2309
- const termId = this.database?.termManager?.getTermIdWithoutIncrement(term)
2310
- if (termId !== undefined) {
2311
- termKey = String(termId)
2312
- } else {
2313
- // Term not found in termManager, keep original key
2314
- // This prevents data loss when term mapping is incomplete
2315
- termKey = term
2316
- if (this.opts?.debugMode) {
2317
- console.log(`⚠️ IndexManager.toJSON: Term "${term}" not found in termManager for field "${field}" - using original key`)
2318
- }
2319
- }
2320
- }
2321
-
2322
- // OPTIMIZATION: Create ranges before serialization if beneficial
2323
- if (hybridData.set && hybridData.set.size >= this.rangeThreshold) {
2324
- this._optimizeToRanges(hybridData)
2325
- }
2326
-
2327
- // Convert hybrid structure to serializable format
2328
- let setArray = []
2329
- if (hybridData.set) {
2330
- if (typeof hybridData.set.size !== 'undefined') {
2331
- // Regular Set
2332
- setArray = Array.from(hybridData.set)
2333
- }
2334
- }
2335
-
2336
- // Use ultra-compact format: [setArray, rangesArray] to save space
2337
- const ranges = hybridData.ranges || []
2338
- if (ranges.length > 0) {
2339
- // Convert ranges to ultra-compact format: [start, count] instead of {start, count}
2340
- const compactRanges = ranges.map(range => [range.start, range.count])
2341
- serializable.data[field][termKey] = [setArray, compactRanges]
2342
- } else {
2343
- // CRITICAL FIX: Always use the [setArray, []] format for consistency
2344
- // This ensures the load() method can properly deserialize the data
2345
- serializable.data[field][termKey] = [setArray, []]
2346
- }
2347
- }
2348
- }
2349
-
2350
- return serializable
2351
- }
2352
-
2353
- /**
2354
- * Get a JSON string representation of the index
2355
- * This properly handles Sets unlike the default JSON.stringify
2356
- */
2357
- toString() {
2358
- return JSON.stringify(this.toJSON(), null, 2)
2359
- }
2360
-
2361
- // Simplified term mapping methods - just basic functionality
2362
-
2363
- /**
2364
- * Rebuild index (stub for compatibility)
2365
- */
2366
- async rebuild() {
2367
- // Stub implementation for compatibility
2368
- return Promise.resolve()
2369
- }
2370
- }
2371
-
1
+ import { Mutex } from 'async-mutex'
2
+ import { normalizeCriteriaOperators } from '../utils/operatorNormalizer.mjs'
3
+
4
+ export default class IndexManager {
5
+ constructor(opts, databaseMutex = null, database = null) {
6
+ this.opts = Object.assign({}, opts)
7
+ this.index = Object.assign({data: {}}, this.opts.index)
8
+ this.totalLines = 0
9
+ this.rangeThreshold = 10 // Sensible threshold: 10+ consecutive numbers justify ranges
10
+ this.binarySearchThreshold = 32 // Much higher for better performance
11
+ this.database = database // Reference to database for term manager access
12
+ this.indexLoaded = false
13
+ this.indexIdleUnloadMs = typeof this.opts.indexIdleUnloadMs === 'number' ? this.opts.indexIdleUnloadMs : 30000
14
+ this._idleUnloadTimer = null
15
+ this._indexLoadPromise = null
16
+
17
+ // CRITICAL: Use database mutex to prevent deadlocks
18
+ // If no database mutex provided, create a local one (for backward compatibility)
19
+ this.mutex = databaseMutex || new Mutex()
20
+
21
+ this.indexedFields = []
22
+ this.setIndexesConfig(this.opts.indexes)
23
+ }
24
+
25
+ setTotalLines(total) {
26
+ this.totalLines = total
27
+ }
28
+
29
+ /**
30
+ * Update indexes configuration and ensure internal structures stay in sync
31
+ * @param {Object|Array<string>} indexes
32
+ */
33
+ setIndexesConfig(indexes) {
34
+ if (!indexes) {
35
+ this.opts.indexes = undefined
36
+ this.indexedFields = []
37
+ return
38
+ }
39
+
40
+ if (Array.isArray(indexes)) {
41
+ const fields = indexes.map(field => String(field))
42
+ this.indexedFields = fields
43
+
44
+ const normalizedConfig = {}
45
+ for (const field of fields) {
46
+ const existingConfig = (!Array.isArray(this.opts.indexes) && typeof this.opts.indexes === 'object') ? this.opts.indexes[field] : undefined
47
+ normalizedConfig[field] = existingConfig ?? 'auto'
48
+ if (!this.index.data[field]) {
49
+ this.index.data[field] = {}
50
+ }
51
+ }
52
+ this.opts.indexes = normalizedConfig
53
+ return
54
+ }
55
+
56
+ if (typeof indexes === 'object') {
57
+ this.opts.indexes = Object.assign({}, indexes)
58
+ this.indexedFields = Object.keys(this.opts.indexes)
59
+
60
+ for (const field of this.indexedFields) {
61
+ if (!this.index.data[field]) {
62
+ this.index.data[field] = {}
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ _clearIdleUnloadTimer() {
69
+ if (this._idleUnloadTimer) {
70
+ clearTimeout(this._idleUnloadTimer)
71
+ this._idleUnloadTimer = null
72
+ }
73
+ }
74
+
75
+ cancelIdleUnloadTimer() {
76
+ this._clearIdleUnloadTimer()
77
+ }
78
+
79
+ markIndexUsed() {
80
+ if (!this.indexLoaded) return
81
+
82
+ this._clearIdleUnloadTimer()
83
+ if (this.indexIdleUnloadMs > 0) {
84
+ const timer = setTimeout(() => {
85
+ try {
86
+ this.unload()
87
+ } catch (error) {
88
+ if (this.opts.debugMode) {
89
+ console.error('⚠️ IndexManager.markIndexUsed: Failed to unload index', error)
90
+ }
91
+ }
92
+ }, this.indexIdleUnloadMs)
93
+ if (timer && typeof timer.unref === 'function') {
94
+ timer.unref()
95
+ }
96
+ this._idleUnloadTimer = timer
97
+ }
98
+ }
99
+
100
+ unload() {
101
+ if (!this.indexLoaded) return
102
+ if (this.opts.debugMode) {
103
+ console.log(`🧹 IndexManager.unload: Unloading index data after ${this.indexIdleUnloadMs}ms idle`)
104
+ }
105
+
106
+ // Mark rebuild as needed so score() can trigger lazy reload
107
+ if (this.database) {
108
+ this.database._indexRebuildNeeded = true
109
+ }
110
+
111
+ this._clearIdleUnloadTimer()
112
+ const newData = {}
113
+ for (const field of this.indexedFields) {
114
+ newData[field] = {}
115
+ }
116
+ this.index.data = newData
117
+ this.indexLoaded = false
118
+ }
119
+
120
+ /**
121
+ * Check if a field is configured as an index
122
+ * @param {string} field - Field name
123
+ * @returns {boolean}
124
+ */
125
+ isFieldIndexed(field) {
126
+ if (!field) return false
127
+ if (!Array.isArray(this.indexedFields)) {
128
+ return false
129
+ }
130
+ return this.indexedFields.includes(field)
131
+ }
132
+
133
+ /**
134
+ * Determine whether the index has usable data for a given field
135
+ * @param {string} field - Field name
136
+ * @returns {boolean}
137
+ */
138
+ hasUsableIndexData(field) {
139
+ if (!field) return false
140
+ const fieldData = this.index?.data?.[field]
141
+ if (!fieldData || typeof fieldData !== 'object') {
142
+ return false
143
+ }
144
+
145
+ for (const key in fieldData) {
146
+ if (!Object.prototype.hasOwnProperty.call(fieldData, key)) continue
147
+ const entry = fieldData[key]
148
+ if (!entry) continue
149
+
150
+ if (entry.set && typeof entry.set.size === 'number' && entry.set.size > 0) {
151
+ return true
152
+ }
153
+
154
+ if (Array.isArray(entry.ranges) && entry.ranges.length > 0) {
155
+ const hasRangeData = entry.ranges.some(range => {
156
+ if (range === null || typeof range === 'undefined') {
157
+ return false
158
+ }
159
+ if (typeof range === 'object') {
160
+ const count = typeof range.count === 'number' ? range.count : 0
161
+ return count > 0
162
+ }
163
+ // When ranges are stored as individual numbers
164
+ return true
165
+ })
166
+
167
+ if (hasRangeData) {
168
+ return true
169
+ }
170
+ }
171
+ }
172
+
173
+ return false
174
+ }
175
+
176
+ // Ultra-fast range conversion - only for very large datasets
177
+ _toRanges(numbers) {
178
+ if (numbers.length === 0) return []
179
+ if (numbers.length < this.rangeThreshold) return numbers // Keep as-is for small arrays
180
+
181
+ const sorted = numbers.sort((a, b) => a - b) // Sort in-place
182
+ const ranges = []
183
+ let start = sorted[0]
184
+ let count = 1
185
+
186
+ for (let i = 1; i < sorted.length; i++) {
187
+ if (sorted[i] === sorted[i-1] + 1) {
188
+ count++
189
+ } else {
190
+ // End of consecutive sequence
191
+ if (count >= this.rangeThreshold) {
192
+ ranges.push({start, count})
193
+ } else {
194
+ // Add individual numbers for small sequences
195
+ for (let j = start; j < start + count; j++) {
196
+ ranges.push(j)
197
+ }
198
+ }
199
+ start = sorted[i]
200
+ count = 1
201
+ }
202
+ }
203
+
204
+ // Handle last sequence
205
+ if (count >= this.rangeThreshold) {
206
+ ranges.push({start, count})
207
+ } else {
208
+ for (let j = start; j < start + count; j++) {
209
+ ranges.push(j)
210
+ }
211
+ }
212
+
213
+ return ranges
214
+ }
215
+
216
+ // Ultra-fast range expansion
217
+ _fromRanges(ranges) {
218
+ if (!ranges || ranges.length === 0) return []
219
+
220
+ const numbers = []
221
+ for (const item of ranges) {
222
+ if (typeof item === 'object' && item.start !== undefined) {
223
+ // It's a range - use direct loop for maximum speed
224
+ const end = item.start + item.count
225
+ for (let i = item.start; i < end; i++) {
226
+ numbers.push(i)
227
+ }
228
+ } else {
229
+ // It's an individual number
230
+ numbers.push(item)
231
+ }
232
+ }
233
+ return numbers
234
+ }
235
+
236
+ // Ultra-fast lookup - optimized for Set operations
237
+ _hasLineNumber(hybridData, lineNumber) {
238
+ if (!hybridData) return false
239
+
240
+ // Check in Set first (O(1)) - most common case
241
+ if (hybridData.set && hybridData.set.has(lineNumber)) {
242
+ return true
243
+ }
244
+
245
+ // Check in ranges only if necessary
246
+ if (hybridData.ranges && hybridData.ranges.length > 0) {
247
+ return this._searchInRanges(hybridData.ranges, lineNumber)
248
+ }
249
+
250
+ return false
251
+ }
252
+
253
+ // Optimized search strategy
254
+ _searchInRanges(ranges, lineNumber) {
255
+ if (ranges.length < this.binarySearchThreshold) {
256
+ // Linear search for small ranges
257
+ return this._linearSearchRanges(ranges, lineNumber)
258
+ } else {
259
+ // Binary search for large ranges
260
+ return this._binarySearchRanges(ranges, lineNumber)
261
+ }
262
+ }
263
+
264
+ // Ultra-fast linear search
265
+ _linearSearchRanges(ranges, lineNumber) {
266
+ for (const item of ranges) {
267
+ if (typeof item === 'object' && item.start !== undefined) {
268
+ // It's a range
269
+ if (lineNumber >= item.start && lineNumber < item.start + item.count) {
270
+ return true
271
+ }
272
+ } else if (item === lineNumber) {
273
+ // It's an individual number
274
+ return true
275
+ }
276
+ }
277
+ return false
278
+ }
279
+
280
+ // Optimized binary search
281
+ _binarySearchRanges(ranges, lineNumber) {
282
+ let left = 0
283
+ let right = ranges.length - 1
284
+
285
+ while (left <= right) {
286
+ const mid = Math.floor((left + right) / 2)
287
+ const range = ranges[mid]
288
+
289
+ if (typeof range === 'object' && range.start !== undefined) {
290
+ // It's a range
291
+ if (lineNumber >= range.start && lineNumber < range.start + range.count) {
292
+ return true
293
+ } else if (lineNumber < range.start) {
294
+ right = mid - 1
295
+ } else {
296
+ left = mid + 1
297
+ }
298
+ } else {
299
+ // It's an individual number
300
+ if (range === lineNumber) {
301
+ return true
302
+ } else if (range < lineNumber) {
303
+ left = mid + 1
304
+ } else {
305
+ right = mid - 1
306
+ }
307
+ }
308
+ }
309
+
310
+ return false
311
+ }
312
+
313
+ // Ultra-fast add operation - minimal overhead
314
+ _addLineNumber(hybridData, lineNumber) {
315
+ // Initialize structure if needed
316
+ if (!hybridData) {
317
+ hybridData = { set: new Set(), ranges: [] }
318
+ }
319
+
320
+ // Add to Set directly (fastest path)
321
+ if (!hybridData.set) {
322
+ hybridData.set = new Set()
323
+ }
324
+ hybridData.set.add(lineNumber)
325
+
326
+ // Optimize to ranges when Set gets reasonably large
327
+ if (hybridData.set.size >= this.rangeThreshold * 2) { // 20 elements
328
+ if (this.opts.debugMode) {
329
+ console.log(`🔧 Triggering range optimization: Set size ${hybridData.set.size} >= threshold ${this.rangeThreshold * 2}`)
330
+ }
331
+ this._optimizeToRanges(hybridData)
332
+ }
333
+
334
+ return hybridData
335
+ }
336
+
337
+ // Ultra-fast remove operation
338
+ _removeLineNumber(hybridData, lineNumber) {
339
+ if (!hybridData) {
340
+ return hybridData
341
+ }
342
+
343
+ // Remove from Set (fast path)
344
+ if (hybridData.set) {
345
+ hybridData.set.delete(lineNumber)
346
+ }
347
+
348
+ // Remove from ranges (less common)
349
+ if (hybridData.ranges) {
350
+ hybridData.ranges = this._removeFromRanges(hybridData.ranges, lineNumber)
351
+ }
352
+
353
+ return hybridData
354
+ }
355
+
356
+ // Optimized range removal
357
+ _removeFromRanges(ranges, lineNumber) {
358
+ if (!ranges || ranges.length === 0) return ranges
359
+
360
+ const newRanges = []
361
+
362
+ for (const item of ranges) {
363
+ if (typeof item === 'object' && item.start !== undefined) {
364
+ // It's a range
365
+ if (lineNumber >= item.start && lineNumber < item.start + item.count) {
366
+ // Split range if needed
367
+ if (lineNumber === item.start) {
368
+ // Remove first element
369
+ if (item.count > 1) {
370
+ newRanges.push({ start: item.start + 1, count: item.count - 1 })
371
+ }
372
+ } else if (lineNumber === item.start + item.count - 1) {
373
+ // Remove last element
374
+ if (item.count > 1) {
375
+ newRanges.push({ start: item.start, count: item.count - 1 })
376
+ }
377
+ } else {
378
+ // Remove from middle - split into two ranges
379
+ const beforeCount = lineNumber - item.start
380
+ const afterCount = item.count - beforeCount - 1
381
+
382
+ if (beforeCount >= this.rangeThreshold) {
383
+ newRanges.push({ start: item.start, count: beforeCount })
384
+ } else {
385
+ // Add individual numbers for small sequences
386
+ for (let i = item.start; i < lineNumber; i++) {
387
+ newRanges.push(i)
388
+ }
389
+ }
390
+
391
+ if (afterCount >= this.rangeThreshold) {
392
+ newRanges.push({ start: lineNumber + 1, count: afterCount })
393
+ } else {
394
+ // Add individual numbers for small sequences
395
+ for (let i = lineNumber + 1; i < item.start + item.count; i++) {
396
+ newRanges.push(i)
397
+ }
398
+ }
399
+ }
400
+ } else {
401
+ newRanges.push(item)
402
+ }
403
+ } else if (item !== lineNumber) {
404
+ // It's an individual number
405
+ newRanges.push(item)
406
+ }
407
+ }
408
+
409
+ return newRanges
410
+ }
411
+
412
+ // Ultra-lazy range conversion - only when absolutely necessary
413
+ _optimizeToRanges(hybridData) {
414
+ if (!hybridData.set || hybridData.set.size === 0) {
415
+ return
416
+ }
417
+
418
+ if (this.opts.debugMode) {
419
+ console.log(`🔧 Starting range optimization for Set with ${hybridData.set.size} elements`)
420
+ }
421
+
422
+ // Only convert if we have enough data to make it worthwhile
423
+ if (hybridData.set.size < this.rangeThreshold) {
424
+ return
425
+ }
426
+
427
+ // Convert Set to array and find consecutive sequences
428
+ const numbers = Array.from(hybridData.set).sort((a, b) => a - b)
429
+ const ranges = []
430
+
431
+ let start = numbers[0]
432
+ let count = 1
433
+
434
+ for (let i = 1; i < numbers.length; i++) {
435
+ if (numbers[i] === numbers[i-1] + 1) {
436
+ count++
437
+ } else {
438
+ // End of consecutive sequence
439
+ if (count >= this.rangeThreshold) {
440
+ ranges.push({start, count})
441
+ // Remove these numbers from Set
442
+ for (let j = start; j < start + count; j++) {
443
+ hybridData.set.delete(j)
444
+ }
445
+ }
446
+ start = numbers[i]
447
+ count = 1
448
+ }
449
+ }
450
+
451
+ // Handle last sequence
452
+ if (count >= this.rangeThreshold) {
453
+ ranges.push({start, count})
454
+ for (let j = start; j < start + count; j++) {
455
+ hybridData.set.delete(j)
456
+ }
457
+ }
458
+
459
+ // Add new ranges to existing ranges
460
+ if (ranges.length > 0) {
461
+ if (!hybridData.ranges) {
462
+ hybridData.ranges = []
463
+ }
464
+ hybridData.ranges.push(...ranges)
465
+ // Keep ranges sorted for efficient binary search
466
+ hybridData.ranges.sort((a, b) => {
467
+ const aStart = typeof a === 'object' ? a.start : a
468
+ const bStart = typeof b === 'object' ? b.start : b
469
+ return aStart - bStart
470
+ })
471
+ }
472
+ }
473
+
474
+ // Ultra-fast get all line numbers
475
+ _getAllLineNumbers(hybridData) {
476
+ if (!hybridData) return []
477
+
478
+ // Use generator for lazy evaluation and better memory efficiency
479
+ return Array.from(this._getAllLineNumbersGenerator(hybridData))
480
+ }
481
+
482
+ // OPTIMIZATION: Generator-based approach for better memory efficiency
483
+ *_getAllLineNumbersGenerator(hybridData) {
484
+ const normalizeLineNumber = (value) => {
485
+ if (typeof value === 'number') {
486
+ return value
487
+ }
488
+ if (typeof value === 'string') {
489
+ const parsed = Number(value)
490
+ return Number.isNaN(parsed) ? value : parsed
491
+ }
492
+ if (typeof value === 'bigint') {
493
+ const maxSafe = BigInt(Number.MAX_SAFE_INTEGER)
494
+ return value <= maxSafe ? Number(value) : value
495
+ }
496
+ return value
497
+ }
498
+
499
+ // Yield from Set (fastest path)
500
+ if (hybridData.set) {
501
+ for (const num of hybridData.set) {
502
+ yield normalizeLineNumber(num)
503
+ }
504
+ }
505
+
506
+ // Yield from ranges (optimized)
507
+ if (hybridData.ranges) {
508
+ for (const item of hybridData.ranges) {
509
+ if (typeof item === 'object' && item.start !== undefined) {
510
+ // It's a range - use direct loop for better performance
511
+ const end = item.start + item.count
512
+ for (let i = item.start; i < end; i++) {
513
+ yield normalizeLineNumber(i)
514
+ }
515
+ } else {
516
+ // It's an individual number
517
+ yield normalizeLineNumber(item)
518
+ }
519
+ }
520
+ }
521
+ }
522
+
523
+ // OPTIMIZATION 6: Ultra-fast add operation with incremental index updates
524
+ async add(row, lineNumber) {
525
+ this.markIndexUsed()
526
+ if (typeof row !== 'object' || !row) {
527
+ throw new Error('Invalid \'row\' parameter, it must be an object')
528
+ }
529
+ if (typeof lineNumber !== 'number') {
530
+ throw new Error('Invalid line number')
531
+ }
532
+
533
+ // OPTIMIZATION 6: Use direct field access with minimal operations
534
+ const data = this.index.data
535
+
536
+ // OPTIMIZATION 6: Pre-allocate field structures for better performance
537
+ const fields = Object.keys(this.opts.indexes || {})
538
+ for (const field of fields) {
539
+ // PERFORMANCE: Check if this is a term mapping field once
540
+ const isTermMappingField = this.database?.termManager &&
541
+ this.database.termManager.termMappingFields &&
542
+ this.database.termManager.termMappingFields.includes(field)
543
+
544
+ // CRITICAL FIX: For term mapping fields, prefer ${field}Ids if available
545
+ // Records processed by processTermMapping have term IDs in ${field}Ids
546
+ // Records loaded from file have term IDs directly in ${field} (after restoreTermIdsAfterDeserialization)
547
+ let value
548
+ if (isTermMappingField) {
549
+ const termIdsField = `${field}Ids`
550
+ const termIds = row[termIdsField]
551
+ if (termIds && Array.isArray(termIds) && termIds.length > 0) {
552
+ // Use term IDs from ${field}Ids (preferred - from processTermMapping)
553
+ value = termIds
554
+ } else {
555
+ // Fallback: use field directly (for records loaded from file that have term IDs in field)
556
+ value = row[field]
557
+ }
558
+ } else {
559
+ value = row[field]
560
+ }
561
+
562
+ if (value !== undefined && value !== null) {
563
+ // OPTIMIZATION 6: Initialize field structure if it doesn't exist
564
+ if (!data[field]) {
565
+ data[field] = {}
566
+ }
567
+
568
+ const values = Array.isArray(value) ? value : [value]
569
+ for (const val of values) {
570
+ let key
571
+
572
+ if (isTermMappingField && typeof val === 'number') {
573
+ // For term mapping fields, values are already term IDs
574
+ key = String(val)
575
+ } else if (isTermMappingField && typeof val === 'string') {
576
+ // Fallback: convert string to term ID
577
+ // CRITICAL: During indexing (add), we should use getTermId() to create IDs if needed
578
+ // This is different from queries where we use getTermIdWithoutIncrement() to avoid creating new IDs
579
+ const termId = this.database.termManager.getTermId(val)
580
+ key = String(termId)
581
+ } else {
582
+ // For non-term-mapping fields (including array:number), use values directly
583
+ key = String(val)
584
+ }
585
+
586
+ // OPTIMIZATION 6: Use direct assignment for better performance
587
+ if (!data[field][key]) {
588
+ data[field][key] = { set: new Set(), ranges: [] }
589
+ }
590
+
591
+ // OPTIMIZATION 6: Direct Set operation - fastest possible
592
+ data[field][key].set.add(lineNumber)
593
+
594
+ // OPTIMIZATION 6: Lazy range optimization - only when beneficial
595
+ if (data[field][key].set.size >= this.rangeThreshold * 3) {
596
+ this._optimizeToRanges(data[field][key])
597
+ }
598
+ }
599
+ }
600
+ }
601
+ }
602
+
603
+ /**
604
+ * OPTIMIZATION 6: Add multiple records to the index in batch with optimized operations
605
+ * @param {Array} records - Records to add
606
+ * @param {number} startLineNumber - Starting line number
607
+ */
608
+ async addBatch(records, startLineNumber) {
609
+ this.markIndexUsed()
610
+ if (!records || !records.length) return
611
+
612
+ // OPTIMIZATION 6: Pre-allocate index structures for better performance
613
+ const data = this.index.data
614
+ const fields = Object.keys(this.opts.indexes || {})
615
+
616
+ for (const field of fields) {
617
+ if (!data[field]) {
618
+ data[field] = {}
619
+ }
620
+ }
621
+
622
+ // OPTIMIZATION 6: Use Map for batch processing to reduce lookups
623
+ const fieldUpdates = new Map()
624
+
625
+ // OPTIMIZATION 6: Process all records in batch with optimized data structures
626
+ for (let i = 0; i < records.length; i++) {
627
+ const row = records[i]
628
+ const lineNumber = startLineNumber + i
629
+
630
+ for (const field of fields) {
631
+ // PERFORMANCE: Check if this is a term mapping field once
632
+ const isTermMappingField = this.database?.termManager &&
633
+ this.database.termManager.termMappingFields &&
634
+ this.database.termManager.termMappingFields.includes(field)
635
+
636
+ // CRITICAL FIX: For term mapping fields, prefer ${field}Ids if available
637
+ // Records processed by processTermMapping have term IDs in ${field}Ids
638
+ // Records loaded from file have term IDs directly in ${field} (after restoreTermIdsAfterDeserialization)
639
+ let value
640
+ if (isTermMappingField) {
641
+ const termIdsField = `${field}Ids`
642
+ const termIds = row[termIdsField]
643
+ if (termIds && Array.isArray(termIds) && termIds.length > 0) {
644
+ // Use term IDs from ${field}Ids (preferred - from processTermMapping)
645
+ value = termIds
646
+ } else {
647
+ // Fallback: use field directly (for records loaded from file that have term IDs in field)
648
+ value = row[field]
649
+ }
650
+ } else {
651
+ value = row[field]
652
+ }
653
+
654
+ if (value !== undefined && value !== null) {
655
+ const values = Array.isArray(value) ? value : [value]
656
+ for (const val of values) {
657
+ let key
658
+
659
+ if (isTermMappingField && typeof val === 'number') {
660
+ // For term mapping fields, values are already term IDs
661
+ key = String(val)
662
+ } else if (isTermMappingField && typeof val === 'string') {
663
+ // Fallback: convert string to term ID
664
+ // CRITICAL: During indexing (addBatch), we should use getTermId() to create IDs if needed
665
+ // This is different from queries where we use getTermIdWithoutIncrement() to avoid creating new IDs
666
+ const termId = this.database.termManager.getTermId(val)
667
+ key = String(termId)
668
+ } else {
669
+ // For non-term-mapping fields (including array:number), use values directly
670
+ key = String(val)
671
+ }
672
+
673
+ // OPTIMIZATION 6: Use Map for efficient batch updates
674
+ if (!fieldUpdates.has(field)) {
675
+ fieldUpdates.set(field, new Map())
676
+ }
677
+
678
+ const fieldMap = fieldUpdates.get(field)
679
+ if (!fieldMap.has(key)) {
680
+ fieldMap.set(key, new Set())
681
+ }
682
+
683
+ fieldMap.get(key).add(lineNumber)
684
+ }
685
+ }
686
+ }
687
+ }
688
+
689
+ // OPTIMIZATION 6: Apply all updates in batch for better performance
690
+ for (const [field, fieldMap] of fieldUpdates) {
691
+ for (const [key, lineNumbers] of fieldMap) {
692
+ if (!data[field][key]) {
693
+ data[field][key] = { set: new Set(), ranges: [] }
694
+ }
695
+
696
+ // OPTIMIZATION 6: Add all line numbers at once
697
+ for (const lineNumber of lineNumbers) {
698
+ data[field][key].set.add(lineNumber)
699
+ }
700
+
701
+ // OPTIMIZATION 6: Lazy range optimization - only when beneficial
702
+ if (data[field][key].set.size >= this.rangeThreshold * 3) {
703
+ this._optimizeToRanges(data[field][key])
704
+ }
705
+ }
706
+ }
707
+ }
708
+
709
+ // Ultra-fast dry remove
710
+ dryRemove(ln) {
711
+ const data = this.index.data
712
+ for (const field in data) {
713
+ for (const value in data[field]) {
714
+ // Direct Set operation - fastest possible
715
+ if (data[field][value].set) {
716
+ data[field][value].set.delete(ln)
717
+ }
718
+ if (data[field][value].ranges) {
719
+ data[field][value].ranges = this._removeFromRanges(data[field][value].ranges, ln)
720
+ }
721
+ // Remove empty entries
722
+ if ((!data[field][value].set || data[field][value].set.size === 0) &&
723
+ (!data[field][value].ranges || data[field][value].ranges.length === 0)) {
724
+ delete data[field][value]
725
+ }
726
+ }
727
+ }
728
+ }
729
+
730
+
731
+ // Cleanup method to free memory
732
+ cleanup() {
733
+ this._clearIdleUnloadTimer()
734
+ const data = this.index.data
735
+ for (const field in data) {
736
+ for (const value in data[field]) {
737
+ if (data[field][value].set) {
738
+ if (typeof data[field][value].set.clearAll === 'function') {
739
+ data[field][value].set.clearAll()
740
+ } else if (typeof data[field][value].set.clear === 'function') {
741
+ data[field][value].set.clear()
742
+ }
743
+ }
744
+ if (data[field][value].ranges) {
745
+ data[field][value].ranges.length = 0
746
+ }
747
+ }
748
+ // Clear the entire field
749
+ data[field] = {}
750
+ }
751
+ // Clear all data
752
+ this.index.data = {}
753
+ this.totalLines = 0
754
+ }
755
+
756
+ // Clear all indexes
757
+ clear() {
758
+ this._clearIdleUnloadTimer()
759
+ this.index.data = {}
760
+ this.totalLines = 0
761
+ }
762
+
763
+
764
+
765
+ // Update a record in the index
766
+ async update(oldRecord, newRecord, lineNumber = null) {
767
+ this.markIndexUsed()
768
+ if (!oldRecord || !newRecord) return
769
+
770
+ // Remove old record by ID
771
+ await this.remove(oldRecord)
772
+
773
+ // Add new record with provided line number or use hash of the ID
774
+ const actualLineNumber = lineNumber !== null ? lineNumber : this._getIdAsNumber(newRecord.id)
775
+ await this.add(newRecord, actualLineNumber)
776
+ }
777
+
778
+ // Convert string ID to number for line number
779
+ _getIdAsNumber(id) {
780
+ if (typeof id === 'number') return id
781
+ if (typeof id === 'string') {
782
+ // Simple hash function to convert string to number
783
+ let hash = 0
784
+ for (let i = 0; i < id.length; i++) {
785
+ const char = id.charCodeAt(i)
786
+ hash = ((hash << 5) - hash) + char
787
+ hash = hash & hash // Convert to 32-bit integer
788
+ }
789
+ return Math.abs(hash)
790
+ }
791
+ return 0
792
+ }
793
+
794
+ // Remove a record from the index
795
+ async remove(record) {
796
+ this.markIndexUsed()
797
+ if (!record) return
798
+
799
+ // If record is an array of line numbers, use the original method
800
+ if (Array.isArray(record)) {
801
+ return this._removeLineNumbers(record)
802
+ }
803
+
804
+ // If record is an object, remove by record data
805
+ if (typeof record === 'object' && record.id) {
806
+ return await this._removeRecord(record)
807
+ }
808
+ }
809
+
810
+ // Remove a specific record from the index
811
+ async _removeRecord(record) {
812
+ if (!record) return
813
+
814
+ const data = this.index.data
815
+ const database = this.database
816
+ const persistedCount = Array.isArray(database?.offsets) ? database.offsets.length : 0
817
+ const lineMatchCache = new Map()
818
+
819
+ const doesLineNumberBelongToRecord = async (lineNumber) => {
820
+ if (lineMatchCache.has(lineNumber)) {
821
+ return lineMatchCache.get(lineNumber)
822
+ }
823
+
824
+ let belongs = false
825
+
826
+ try {
827
+ if (lineNumber >= persistedCount) {
828
+ const writeBufferIndex = lineNumber - persistedCount
829
+ const candidate = database?.writeBuffer?.[writeBufferIndex]
830
+ belongs = !!candidate && candidate.id === record.id
831
+ } else if (lineNumber >= 0) {
832
+ const range = database?.locate?.(lineNumber)
833
+ if (range && database.fileHandler && database.serializer) {
834
+ const [start, end] = range
835
+ const buffer = await database.fileHandler.readRange(start, end)
836
+ if (buffer && buffer.length > 0) {
837
+ let line = buffer.toString('utf8')
838
+ if (line) {
839
+ line = line.trim()
840
+ if (line.length > 0) {
841
+ const storedRecord = database.serializer.deserialize(line)
842
+ belongs = storedRecord && storedRecord.id === record.id
843
+ }
844
+ }
845
+ }
846
+ }
847
+ }
848
+ } catch (error) {
849
+ belongs = false
850
+ }
851
+
852
+ lineMatchCache.set(lineNumber, belongs)
853
+ return belongs
854
+ }
855
+
856
+ for (const field in data) {
857
+ if (record[field] !== undefined && record[field] !== null) {
858
+ const values = Array.isArray(record[field]) ? record[field] : [record[field]]
859
+ for (const val of values) {
860
+ let key
861
+
862
+ // Check if this is a term mapping field (array:string fields only)
863
+ const isTermMappingField = this.database?.termManager &&
864
+ this.database.termManager.termMappingFields &&
865
+ this.database.termManager.termMappingFields.includes(field)
866
+
867
+ if (isTermMappingField && typeof val === 'number') {
868
+ // For term mapping fields (array:string), the values are already term IDs
869
+ key = String(val)
870
+ if (this.database.opts.debugMode) {
871
+ console.log(`🔍 IndexManager._removeRecord: Using term ID ${val} directly for field "${field}"`)
872
+ }
873
+ } else if (isTermMappingField && typeof val === 'string') {
874
+ // For term mapping fields (array:string), convert string to term ID
875
+ const termId = this.database.termManager.getTermIdWithoutIncrement(val)
876
+ key = String(termId)
877
+ if (this.database.opts.debugMode) {
878
+ console.log(`🔍 IndexManager._removeRecord: Using term ID ${termId} for term "${val}"`)
879
+ }
880
+ } else {
881
+ // For non-term-mapping fields (including array:number), use values directly
882
+ key = String(val)
883
+ if (this.database?.opts?.debugMode) {
884
+ console.log(`🔍 IndexManager._removeRecord: Using value "${val}" directly for field "${field}"`)
885
+ }
886
+ }
887
+
888
+ // Note: TermManager notification is handled by Database.mjs
889
+ // to avoid double decrementation during updates
890
+
891
+ const indexEntry = data[field][key]
892
+ if (indexEntry) {
893
+ const lineNumbers = this._getAllLineNumbers(indexEntry)
894
+ const filteredLineNumbers = []
895
+
896
+ for (const lineNumber of lineNumbers) {
897
+ if (!(await doesLineNumberBelongToRecord(lineNumber))) {
898
+ filteredLineNumbers.push(lineNumber)
899
+ }
900
+ }
901
+
902
+ if (filteredLineNumbers.length === 0) {
903
+ delete data[field][key]
904
+ } else {
905
+ // Rebuild the index value with filtered line numbers
906
+ data[field][key].set = new Set(filteredLineNumbers)
907
+ data[field][key].ranges = []
908
+ }
909
+ }
910
+ }
911
+ }
912
+ }
913
+ }
914
+
915
+ // Ultra-fast remove with batch processing (renamed from remove)
916
+ _removeLineNumbers(lineNumbers) {
917
+ if (!lineNumbers || lineNumbers.length === 0) return
918
+
919
+ lineNumbers.sort((a, b) => a - b) // Sort ascending for efficient processing
920
+
921
+ const data = this.index.data
922
+ for (const field in data) {
923
+ for (const value in data[field]) {
924
+ const numbers = this._getAllLineNumbers(data[field][value])
925
+ const newNumbers = []
926
+
927
+ for (const ln of numbers) {
928
+ let offset = 0
929
+ for (const lineNumber of lineNumbers) {
930
+ if (lineNumber < ln) {
931
+ offset++
932
+ } else if (lineNumber === ln) {
933
+ offset = -1 // Mark for removal
934
+ break
935
+ }
936
+ }
937
+ if (offset >= 0) {
938
+ newNumbers.push(ln - offset) // Update the value
939
+ }
940
+ }
941
+
942
+ if (newNumbers.length > 0) {
943
+ // Rebuild hybrid structure with new numbers
944
+ data[field][value] = { set: new Set(), ranges: [] }
945
+ for (const num of newNumbers) {
946
+ data[field][value] = this._addLineNumber(data[field][value], num)
947
+ }
948
+ } else {
949
+ delete data[field][value]
950
+ }
951
+ }
952
+ }
953
+ }
954
+
955
+ // Ultra-fast replace with batch processing
956
+ replace(map) {
957
+ if (!map || map.size === 0) return
958
+
959
+ const data = this.index.data
960
+ for (const field in data) {
961
+ for (const value in data[field]) {
962
+ const numbers = this._getAllLineNumbers(data[field][value])
963
+ const newNumbers = []
964
+
965
+ for (const lineNumber of numbers) {
966
+ if (map.has(lineNumber)) {
967
+ newNumbers.push(map.get(lineNumber))
968
+ } else {
969
+ newNumbers.push(lineNumber)
970
+ }
971
+ }
972
+
973
+ // Rebuild hybrid structure with new numbers
974
+ data[field][value] = { set: new Set(), ranges: [] }
975
+ for (const num of newNumbers) {
976
+ data[field][value] = this._addLineNumber(data[field][value], num)
977
+ }
978
+ }
979
+ }
980
+ }
981
+
982
+ // Ultra-fast query with early exit and smart processing
983
+ query(criteria, options = {}) {
984
+ this.markIndexUsed()
985
+ if (typeof options === 'boolean') {
986
+ options = { matchAny: options };
987
+ }
988
+ const { matchAny = false, caseInsensitive = false } = options;
989
+
990
+ if (!criteria) {
991
+ // Return all line numbers when no criteria provided
992
+ return new Set(Array.from({ length: this.totalLines || 0 }, (_, i) => i));
993
+ }
994
+
995
+ // Handle $not operator
996
+ if (criteria.$not && typeof criteria.$not === 'object') {
997
+ // Get all possible line numbers from database offsets or totalLines
998
+ const totalRecords = this.database?.offsets?.length || this.totalLines || 0;
999
+ const allLines = new Set(Array.from({ length: totalRecords }, (_, i) => i));
1000
+
1001
+ // Get line numbers matching the $not condition
1002
+ const notLines = this.query(criteria.$not, options);
1003
+
1004
+ // Return complement (all lines except those matching $not condition)
1005
+ const result = new Set([...allLines].filter(x => !notLines.has(x)));
1006
+
1007
+ // If there are other conditions besides $not, we need to intersect with them
1008
+ const otherCriteria = { ...criteria };
1009
+ delete otherCriteria.$not;
1010
+
1011
+ if (Object.keys(otherCriteria).length > 0) {
1012
+ const otherResults = this.query(otherCriteria, options);
1013
+ return new Set([...result].filter(x => otherResults.has(x)));
1014
+ }
1015
+
1016
+ return result;
1017
+ }
1018
+
1019
+ // Handle $and queries with parallel processing optimization
1020
+ if (criteria.$and && Array.isArray(criteria.$and)) {
1021
+ // OPTIMIZATION: Process conditions in parallel for better performance
1022
+ if (criteria.$and.length > 1) {
1023
+ // Process all conditions in parallel (synchronous since query is not async)
1024
+ const conditionResults = criteria.$and.map(andCondition =>
1025
+ this.query(andCondition, options)
1026
+ );
1027
+
1028
+ // Intersect all results for AND logic
1029
+ let result = conditionResults[0];
1030
+ for (let i = 1; i < conditionResults.length; i++) {
1031
+ result = new Set([...result].filter(x => conditionResults[i].has(x)));
1032
+ }
1033
+
1034
+ // IMPORTANT: Check if there are other fields besides $and at the root level
1035
+ // If so, we need to intersect with them too
1036
+ const otherCriteria = { ...criteria };
1037
+ delete otherCriteria.$and;
1038
+
1039
+ if (Object.keys(otherCriteria).length > 0) {
1040
+ const otherResults = this.query(otherCriteria, options);
1041
+ result = new Set([...result].filter(x => otherResults.has(x)));
1042
+ }
1043
+
1044
+ return result || new Set();
1045
+ } else {
1046
+ // Single condition - check for other criteria at root level
1047
+ const andResult = this.query(criteria.$and[0], options);
1048
+
1049
+ const otherCriteria = { ...criteria };
1050
+ delete otherCriteria.$and;
1051
+
1052
+ if (Object.keys(otherCriteria).length > 0) {
1053
+ const otherResults = this.query(otherCriteria, options);
1054
+ return new Set([...andResult].filter(x => otherResults.has(x)));
1055
+ }
1056
+
1057
+ return andResult;
1058
+ }
1059
+ }
1060
+
1061
+ const fields = Object.keys(criteria);
1062
+ if (!fields.length) {
1063
+ // Return all line numbers when criteria is empty object
1064
+ return new Set(Array.from({ length: this.totalLines || 0 }, (_, i) => i));
1065
+ }
1066
+
1067
+ let matchingLines = matchAny ? new Set() : null;
1068
+ const data = this.index.data
1069
+
1070
+ for (const field of fields) {
1071
+ // Skip logical operators - they are handled separately
1072
+ if (field.startsWith('$')) continue;
1073
+
1074
+ if (typeof data[field] === 'undefined') continue;
1075
+
1076
+ const originalCriteriaValue = criteria[field];
1077
+ const criteriaValue = normalizeCriteriaOperators(originalCriteriaValue, { target: 'legacy', preserveOriginal: true });
1078
+ let lineNumbersForField = new Set();
1079
+ const isNumericField = this.opts.indexes[field] === 'number';
1080
+
1081
+ // Handle RegExp values directly (MUST check before object check since RegExp is an object)
1082
+ if (criteriaValue instanceof RegExp) {
1083
+ // RegExp cannot be efficiently queried using indices - fall back to streaming
1084
+ // This will be handled by the QueryManager's streaming strategy
1085
+ continue;
1086
+ }
1087
+
1088
+ if (typeof criteriaValue === 'object' && !Array.isArray(criteriaValue) && criteriaValue !== null) {
1089
+ const fieldIndex = data[field];
1090
+
1091
+ // Handle $in operator for array queries
1092
+ if (criteriaValue.$in !== undefined && criteriaValue.$in !== null) {
1093
+ const inValues = Array.isArray(criteriaValue.$in) ? criteriaValue.$in : [criteriaValue.$in];
1094
+
1095
+ // PERFORMANCE: Cache term mapping field check once
1096
+ const isTermMappingField = this.database?.termManager &&
1097
+ this.database.termManager.termMappingFields &&
1098
+ this.database.termManager.termMappingFields.includes(field)
1099
+
1100
+ // PERFORMANCE: Track if any term was found and matched
1101
+ let foundAnyMatch = false
1102
+
1103
+ for (const inValue of inValues) {
1104
+ // SPACE OPTIMIZATION: Convert search term to term ID for lookup
1105
+ let searchTermId
1106
+
1107
+ if (isTermMappingField && typeof inValue === 'number') {
1108
+ // For term mapping fields (array:string), the search value is already a term ID
1109
+ searchTermId = String(inValue)
1110
+ } else if (isTermMappingField && typeof inValue === 'string') {
1111
+ // For term mapping fields (array:string), convert string to term ID
1112
+ const termId = this.database?.termManager?.getTermIdWithoutIncrement(String(inValue))
1113
+ if (termId === undefined) {
1114
+ // Term not found in termManager - skip this search value
1115
+ // This means the term was never saved to the database
1116
+ if (this.opts?.debugMode) {
1117
+ console.log(`⚠️ Term "${inValue}" not found in termManager for field "${field}" - skipping`)
1118
+ }
1119
+ continue // Skip this value, no matches possible
1120
+ }
1121
+ searchTermId = String(termId)
1122
+ } else {
1123
+ // For non-term-mapping fields (including array:number), use values directly
1124
+ searchTermId = String(inValue)
1125
+ }
1126
+
1127
+ // PERFORMANCE: Direct lookup instead of iteration
1128
+ let matched = false
1129
+ if (caseInsensitive && typeof inValue === 'string') {
1130
+ const searchLower = searchTermId.toLowerCase()
1131
+ for (const value in fieldIndex) {
1132
+ if (value.toLowerCase() === searchLower) {
1133
+ const numbers = this._getAllLineNumbers(fieldIndex[value]);
1134
+ for (const lineNumber of numbers) {
1135
+ lineNumbersForField.add(lineNumber);
1136
+ }
1137
+ matched = true
1138
+ foundAnyMatch = true
1139
+ }
1140
+ }
1141
+ } else {
1142
+ const indexData = fieldIndex[searchTermId]
1143
+ if (indexData) {
1144
+ const numbers = this._getAllLineNumbers(indexData);
1145
+ for (const lineNumber of numbers) {
1146
+ lineNumbersForField.add(lineNumber);
1147
+ }
1148
+ matched = true
1149
+ foundAnyMatch = true
1150
+ }
1151
+ }
1152
+ }
1153
+
1154
+ // CRITICAL FIX: If no matches found at all (all terms were unknown or not in index),
1155
+ // lineNumbersForField remains empty which is correct (no results for $in)
1156
+ // This is handled correctly by the caller - empty Set means no matches
1157
+ }
1158
+ // Handle $nin operator (not in) - returns complement of $in
1159
+ else if (criteriaValue.$nin !== undefined) {
1160
+ const ninValues = Array.isArray(criteriaValue.$nin) ? criteriaValue.$nin : [criteriaValue.$nin];
1161
+
1162
+ // Get all possible line numbers
1163
+ const totalRecords = this.database?.offsets?.length || this.totalLines || 0;
1164
+ const allLines = new Set(Array.from({ length: totalRecords }, (_, i) => i));
1165
+
1166
+ // Get line numbers that match any of the $nin values
1167
+ const matchingLines = new Set();
1168
+
1169
+ // PERFORMANCE: Cache term mapping field check once
1170
+ const isTermMappingField = this.database?.termManager &&
1171
+ this.database.termManager.termMappingFields &&
1172
+ this.database.termManager.termMappingFields.includes(field)
1173
+
1174
+ for (const ninValue of ninValues) {
1175
+ // SPACE OPTIMIZATION: Convert search term to term ID for lookup
1176
+ let searchTermId
1177
+
1178
+ if (isTermMappingField && typeof ninValue === 'number') {
1179
+ // For term mapping fields (array:string), the search value is already a term ID
1180
+ searchTermId = String(ninValue)
1181
+ } else if (isTermMappingField && typeof ninValue === 'string') {
1182
+ // For term mapping fields (array:string), convert string to term ID
1183
+ const termId = this.database?.termManager?.getTermIdWithoutIncrement(String(ninValue))
1184
+ if (termId === undefined) {
1185
+ // Term not found - skip this value (can't exclude what doesn't exist)
1186
+ if (this.opts?.debugMode) {
1187
+ console.log(`⚠️ Term "${ninValue}" not found in termManager for field "${field}" - skipping`)
1188
+ }
1189
+ continue
1190
+ }
1191
+ searchTermId = String(termId)
1192
+ } else {
1193
+ // For non-term-mapping fields (including array:number), use values directly
1194
+ searchTermId = String(ninValue)
1195
+ }
1196
+
1197
+ // PERFORMANCE: Direct lookup instead of iteration
1198
+ if (caseInsensitive && typeof ninValue === 'string') {
1199
+ const searchLower = searchTermId.toLowerCase()
1200
+ for (const value in fieldIndex) {
1201
+ if (value.toLowerCase() === searchLower) {
1202
+ const numbers = this._getAllLineNumbers(fieldIndex[value]);
1203
+ for (const lineNumber of numbers) {
1204
+ matchingLines.add(lineNumber);
1205
+ }
1206
+ }
1207
+ }
1208
+ } else {
1209
+ const indexData = fieldIndex[searchTermId]
1210
+ if (indexData) {
1211
+ const numbers = this._getAllLineNumbers(indexData);
1212
+ for (const lineNumber of numbers) {
1213
+ matchingLines.add(lineNumber);
1214
+ }
1215
+ }
1216
+ }
1217
+ }
1218
+
1219
+ // Return complement: all lines EXCEPT those matching $nin values
1220
+ lineNumbersForField = new Set([...allLines].filter(x => !matchingLines.has(x)));
1221
+ }
1222
+ // Handle $contains operator for array queries
1223
+ else if (criteriaValue.$contains !== undefined) {
1224
+ const containsValue = criteriaValue.$contains;
1225
+ // Handle case-insensitive for $contains
1226
+ if (caseInsensitive && typeof containsValue === 'string') {
1227
+ for (const value in fieldIndex) {
1228
+ if (value.toLowerCase() === containsValue.toLowerCase()) {
1229
+ const numbers = this._getAllLineNumbers(fieldIndex[value]);
1230
+ for (const lineNumber of numbers) {
1231
+ lineNumbersForField.add(lineNumber);
1232
+ }
1233
+ }
1234
+ }
1235
+ } else {
1236
+ if (fieldIndex[containsValue]) {
1237
+ const numbers = this._getAllLineNumbers(fieldIndex[containsValue]);
1238
+ for (const lineNumber of numbers) {
1239
+ lineNumbersForField.add(lineNumber);
1240
+ }
1241
+ }
1242
+ }
1243
+ }
1244
+ // Handle $all operator for array queries - FIXED FOR TERM MAPPING
1245
+ else if (criteriaValue.$all !== undefined) {
1246
+ const allValues = Array.isArray(criteriaValue.$all) ? criteriaValue.$all : [criteriaValue.$all];
1247
+
1248
+ const isTermMappingField = this.database?.termManager &&
1249
+ this.database.termManager.termMappingFields &&
1250
+ this.database.termManager.termMappingFields.includes(field)
1251
+
1252
+ const normalizeValue = (value) => {
1253
+ if (isTermMappingField) {
1254
+ if (typeof value === 'number') {
1255
+ return String(value)
1256
+ }
1257
+ if (typeof value === 'string') {
1258
+ const termId = this.database?.termManager?.getTermIdWithoutIncrement(value)
1259
+ if (termId !== undefined) {
1260
+ return String(termId)
1261
+ }
1262
+ return null
1263
+ }
1264
+ return null
1265
+ }
1266
+ return String(value)
1267
+ }
1268
+
1269
+ const normalizedValues = []
1270
+ for (const value of allValues) {
1271
+ const normalized = normalizeValue(value)
1272
+ if (normalized === null) {
1273
+ // Term not found in term manager, no matches possible
1274
+ return lineNumbersForField
1275
+ }
1276
+ normalizedValues.push(normalized)
1277
+ }
1278
+
1279
+ // Early exit optimization
1280
+ if (normalizedValues.length === 0) {
1281
+ // Empty $all matches everything
1282
+ for (const value in fieldIndex) {
1283
+ const numbers = this._getAllLineNumbers(fieldIndex[value]);
1284
+ for (const lineNumber of numbers) {
1285
+ lineNumbersForField.add(lineNumber);
1286
+ }
1287
+ }
1288
+ } else {
1289
+ // For term mapping, we need to find records that contain ALL specified terms
1290
+ // This requires a different approach than simple field matching
1291
+
1292
+ // First, get all line numbers that contain each individual term
1293
+ const termLineNumbers = new Map();
1294
+ for (const term of normalizedValues) {
1295
+ if (fieldIndex[term]) {
1296
+ termLineNumbers.set(term, new Set(this._getAllLineNumbers(fieldIndex[term])));
1297
+ } else {
1298
+ // If any term doesn't exist, no records can match $all
1299
+ termLineNumbers.set(term, new Set());
1300
+ }
1301
+ }
1302
+
1303
+ // Find intersection of all term line numbers
1304
+ if (termLineNumbers.size > 0) {
1305
+ const allTermSets = Array.from(termLineNumbers.values());
1306
+ let intersection = allTermSets[0];
1307
+
1308
+ for (let i = 1; i < allTermSets.length; i++) {
1309
+ intersection = new Set([...intersection].filter(x => allTermSets[i].has(x)));
1310
+ }
1311
+
1312
+ // Add all line numbers from intersection
1313
+ for (const lineNumber of intersection) {
1314
+ lineNumbersForField.add(lineNumber);
1315
+ }
1316
+ }
1317
+ }
1318
+ }
1319
+ // Handle other operators
1320
+ else {
1321
+ for (const value in fieldIndex) {
1322
+ let includeValue = true;
1323
+ if (isNumericField) {
1324
+ const numericValue = parseFloat(value);
1325
+ if (!isNaN(numericValue)) {
1326
+ if (criteriaValue['>'] !== undefined && numericValue <= criteriaValue['>']) {
1327
+ includeValue = false;
1328
+ }
1329
+ if (criteriaValue['>='] !== undefined && numericValue < criteriaValue['>=']) {
1330
+ includeValue = false;
1331
+ }
1332
+ if (criteriaValue['<'] !== undefined && numericValue >= criteriaValue['<']) {
1333
+ includeValue = false;
1334
+ }
1335
+ if (criteriaValue['<='] !== undefined && numericValue > criteriaValue['<=']) {
1336
+ includeValue = false;
1337
+ }
1338
+ if (criteriaValue['!='] !== undefined) {
1339
+ const excludeValues = Array.isArray(criteriaValue['!='])
1340
+ ? criteriaValue['!=']
1341
+ : [criteriaValue['!=']];
1342
+ if (excludeValues.includes(numericValue)) {
1343
+ includeValue = false;
1344
+ }
1345
+ }
1346
+ }
1347
+ } else {
1348
+ if (criteriaValue['contains'] !== undefined && typeof value === 'string') {
1349
+ const term = String(criteriaValue['contains']);
1350
+ if (caseInsensitive) {
1351
+ if (!value.toLowerCase().includes(term.toLowerCase())) {
1352
+ includeValue = false;
1353
+ }
1354
+ } else {
1355
+ if (!value.includes(term)) {
1356
+ includeValue = false;
1357
+ }
1358
+ }
1359
+ }
1360
+ if (criteriaValue['regex'] !== undefined) {
1361
+ let regex;
1362
+ if (typeof criteriaValue['regex'] === 'string') {
1363
+ regex = new RegExp(criteriaValue['regex'], caseInsensitive ? 'i' : '');
1364
+ } else if (criteriaValue['regex'] instanceof RegExp) {
1365
+ if (caseInsensitive && !criteriaValue['regex'].ignoreCase) {
1366
+ const flags = criteriaValue['regex'].flags.includes('i')
1367
+ ? criteriaValue['regex'].flags
1368
+ : criteriaValue['regex'].flags + 'i';
1369
+ regex = new RegExp(criteriaValue['regex'].source, flags);
1370
+ } else {
1371
+ regex = criteriaValue['regex'];
1372
+ }
1373
+ }
1374
+ if (regex) {
1375
+ // For array fields, test regex against each element
1376
+ if (Array.isArray(value)) {
1377
+ if (!value.some(element => regex.test(String(element)))) {
1378
+ includeValue = false;
1379
+ }
1380
+ } else {
1381
+ // For non-array fields, test regex against the value directly
1382
+ if (!regex.test(String(value))) {
1383
+ includeValue = false;
1384
+ }
1385
+ }
1386
+ }
1387
+ }
1388
+ if (criteriaValue['!='] !== undefined) {
1389
+ const excludeValues = Array.isArray(criteriaValue['!='])
1390
+ ? criteriaValue['!=']
1391
+ : [criteriaValue['!=']];
1392
+ if (excludeValues.includes(value)) {
1393
+ includeValue = false;
1394
+ }
1395
+ }
1396
+ }
1397
+
1398
+ if (includeValue) {
1399
+ const numbers = this._getAllLineNumbers(fieldIndex[value]);
1400
+ for (const lineNumber of numbers) {
1401
+ lineNumbersForField.add(lineNumber);
1402
+ }
1403
+ }
1404
+ }
1405
+ }
1406
+ } else {
1407
+ // Simple equality comparison - handle array queries
1408
+ const values = Array.isArray(criteriaValue) ? criteriaValue : [criteriaValue];
1409
+ const fieldData = data[field];
1410
+ for (const searchValue of values) {
1411
+ // SPACE OPTIMIZATION: Convert search term to term ID for lookup
1412
+ let searchTermId
1413
+
1414
+ // PERFORMANCE: Cache term mapping field check once per field
1415
+ const isTermMappingField = this.database?.termManager &&
1416
+ this.database.termManager.termMappingFields &&
1417
+ this.database.termManager.termMappingFields.includes(field)
1418
+
1419
+ if (isTermMappingField && typeof searchValue === 'number') {
1420
+ // For term mapping fields (array:string), the search value is already a term ID
1421
+ searchTermId = String(searchValue)
1422
+ } else if (isTermMappingField && typeof searchValue === 'string') {
1423
+ // For term mapping fields (array:string), convert string to term ID
1424
+ const termId = this.database?.termManager?.getTermIdWithoutIncrement(String(searchValue))
1425
+ if (termId === undefined) {
1426
+ // Term not found - skip this value
1427
+ if (this.opts?.debugMode) {
1428
+ console.log(`⚠️ Term "${searchValue}" not found in termManager for field "${field}" - skipping`)
1429
+ }
1430
+ continue // Skip this value, no matches possible
1431
+ }
1432
+ searchTermId = String(termId)
1433
+ } else {
1434
+ // For non-term-mapping fields (including array:number), use values directly
1435
+ searchTermId = String(searchValue)
1436
+ }
1437
+
1438
+ for (const key in fieldData) {
1439
+ let match = false;
1440
+ if (isNumericField) {
1441
+ // Convert both parts to number
1442
+ match = Number(key) === Number(searchValue);
1443
+ } else {
1444
+ // SPACE OPTIMIZATION: Compare term IDs instead of full terms
1445
+ if (caseInsensitive) {
1446
+ // For case-insensitive, we need to check if the search term ID matches any key
1447
+ match = key === String(searchTermId);
1448
+ } else {
1449
+ match = key === String(searchTermId);
1450
+ }
1451
+ }
1452
+ if (match) {
1453
+ const numbers = this._getAllLineNumbers(fieldData[key]);
1454
+ for (const lineNumber of numbers) {
1455
+ lineNumbersForField.add(lineNumber);
1456
+ }
1457
+ }
1458
+ }
1459
+ }
1460
+ }
1461
+
1462
+ // Consolidate results from each field
1463
+ if (matchAny) {
1464
+ matchingLines = new Set([...matchingLines, ...lineNumbersForField]);
1465
+ } else {
1466
+ if (matchingLines === null) {
1467
+ matchingLines = lineNumbersForField;
1468
+ } else {
1469
+ matchingLines = new Set([...matchingLines].filter(n => lineNumbersForField.has(n)));
1470
+ }
1471
+ if (!matchingLines.size) {
1472
+ return new Set();
1473
+ }
1474
+ }
1475
+ }
1476
+ return matchingLines || new Set();
1477
+ }
1478
+
1479
+ /**
1480
+ * Check if any records exist for given field and terms (index-only, ultra-fast)
1481
+ * Stops at first match for maximum performance - no disk I/O required
1482
+ *
1483
+ * @param {string} fieldName - Indexed field name (e.g., 'nameTerms', 'groupTerms')
1484
+ * @param {string|Array<string>} terms - Single term or array of terms to check
1485
+ * @param {Object} options - Options: { $all: true/false, caseInsensitive: true/false, excludes: Array<string> }
1486
+ * @returns {boolean} - True if at least one match exists
1487
+ *
1488
+ * @example
1489
+ * // Check if any record has 'channel' in nameTerms
1490
+ * indexManager.exists('nameTerms', 'channel')
1491
+ *
1492
+ * @example
1493
+ * // Check if any record has ALL terms ['a', 'e'] in nameTerms ($all)
1494
+ * indexManager.exists('nameTerms', ['a', 'e'], { $all: true })
1495
+ *
1496
+ * @example
1497
+ * // Check if any record has ANY of the terms ['channel', 'tv'] in nameTerms
1498
+ * indexManager.exists('nameTerms', ['channel', 'tv'], { $all: false })
1499
+ *
1500
+ * @example
1501
+ * // Check if any record has 'tv' but NOT 'globo' in nameTerms
1502
+ * indexManager.exists('nameTerms', 'tv', { excludes: ['globo'] })
1503
+ *
1504
+ * @example
1505
+ * // Check if any record has ['tv', 'news'] but NOT 'sports' in nameTerms
1506
+ * indexManager.exists('nameTerms', ['tv', 'news'], { $all: true, excludes: ['sports'] })
1507
+ */
1508
+ exists(fieldName, terms, options = {}) {
1509
+ this.markIndexUsed()
1510
+ // Early exit: validate fieldName
1511
+ if (!fieldName || typeof fieldName !== 'string') {
1512
+ return false;
1513
+ }
1514
+
1515
+ // Early exit: check if field is indexed
1516
+ if (!this.isFieldIndexed(fieldName)) {
1517
+ return false;
1518
+ }
1519
+
1520
+ const fieldIndex = this.index.data[fieldName];
1521
+ if (!fieldIndex || typeof fieldIndex !== 'object') {
1522
+ return false;
1523
+ }
1524
+
1525
+ // Normalize terms to array
1526
+ const termsArray = Array.isArray(terms) ? terms : [terms];
1527
+ if (termsArray.length === 0) {
1528
+ return false;
1529
+ }
1530
+
1531
+ const { $all = false, caseInsensitive = false, excludes = [] } = options;
1532
+ const hasExcludes = Array.isArray(excludes) && excludes.length > 0;
1533
+ const isTermMappingField = this.database?.termManager &&
1534
+ this.database.termManager.termMappingFields &&
1535
+ this.database.termManager.termMappingFields.includes(fieldName);
1536
+
1537
+ // Helper: check if termData has any line numbers (ULTRA LIGHT - no expansion)
1538
+ const hasData = (termData) => {
1539
+ if (!termData) return false;
1540
+ // Check Set size (O(1))
1541
+ if (termData.set && termData.set.size > 0) {
1542
+ return true;
1543
+ }
1544
+ // Check ranges length (O(1))
1545
+ if (termData.ranges && termData.ranges.length > 0) {
1546
+ return true;
1547
+ }
1548
+ return false;
1549
+ };
1550
+
1551
+ // Helper: get term key with term mapping and case-insensitive support
1552
+ const getTermKey = (term, useCaseInsensitive = false) => {
1553
+ if (isTermMappingField && typeof term === 'string') {
1554
+ let termId;
1555
+ if (useCaseInsensitive) {
1556
+ // For case-insensitive, search termManager for case-insensitive match
1557
+ const searchLower = String(term).toLowerCase();
1558
+ termId = null;
1559
+ if (this.database?.termManager?.termToId) {
1560
+ for (const [termStr, id] of this.database.termManager.termToId.entries()) {
1561
+ if (termStr.toLowerCase() === searchLower) {
1562
+ termId = id;
1563
+ break;
1564
+ }
1565
+ }
1566
+ }
1567
+ } else {
1568
+ termId = this.database?.termManager?.getTermIdWithoutIncrement(String(term));
1569
+ }
1570
+
1571
+ if (termId === undefined || termId === null) {
1572
+ return null;
1573
+ }
1574
+ return String(termId);
1575
+ }
1576
+
1577
+ // For non-term-mapping fields
1578
+ if (useCaseInsensitive && typeof term === 'string') {
1579
+ const searchLower = String(term).toLowerCase();
1580
+ for (const key in fieldIndex) {
1581
+ if (key.toLowerCase() === searchLower) {
1582
+ return key;
1583
+ }
1584
+ }
1585
+ return null;
1586
+ }
1587
+
1588
+ return String(term);
1589
+ };
1590
+
1591
+ // Handle $all (all terms must exist and have intersection)
1592
+ if ($all) {
1593
+ // Collect term data for all terms first (with early exit)
1594
+ const termDataArray = [];
1595
+
1596
+ for (const term of termsArray) {
1597
+ // Get term key (with term mapping if applicable)
1598
+ let termKey;
1599
+ if (isTermMappingField && typeof term === 'string') {
1600
+ let termId;
1601
+ if (caseInsensitive) {
1602
+ // For case-insensitive, search termManager for case-insensitive match
1603
+ const searchLower = String(term).toLowerCase();
1604
+ termId = null;
1605
+ for (const [termStr, id] of this.database.termManager.termToId.entries()) {
1606
+ if (termStr.toLowerCase() === searchLower) {
1607
+ termId = id;
1608
+ break;
1609
+ }
1610
+ }
1611
+ } else {
1612
+ termId = this.database?.termManager?.getTermIdWithoutIncrement(String(term));
1613
+ }
1614
+
1615
+ if (termId === undefined || termId === null) {
1616
+ return false; // Early exit: term doesn't exist in mapping
1617
+ }
1618
+ termKey = String(termId);
1619
+ } else {
1620
+ termKey = String(term);
1621
+ // For non-term-mapping fields with case-insensitive, search index keys
1622
+ if (caseInsensitive && typeof term === 'string') {
1623
+ const searchLower = termKey.toLowerCase();
1624
+ let foundKey = null;
1625
+ for (const key in fieldIndex) {
1626
+ if (key.toLowerCase() === searchLower) {
1627
+ foundKey = key;
1628
+ break;
1629
+ }
1630
+ }
1631
+ if (foundKey === null) {
1632
+ return false; // Early exit: term doesn't exist
1633
+ }
1634
+ termKey = foundKey;
1635
+ }
1636
+ }
1637
+
1638
+ // Check if term exists in index
1639
+ const termData = fieldIndex[termKey];
1640
+ if (!termData || !hasData(termData)) {
1641
+ return false; // Early exit: term doesn't exist or has no data
1642
+ }
1643
+
1644
+ termDataArray.push(termData);
1645
+ }
1646
+
1647
+ // If we got here, all terms exist and have data
1648
+ // Now check if there's intersection (only if more than one term)
1649
+ if (termDataArray.length === 1) {
1650
+ // Single term - check excludes if any
1651
+ if (!hasExcludes) {
1652
+ return true; // Single term, already verified it has data, no excludes
1653
+ }
1654
+ // Need to check excludes - expand line numbers
1655
+ const lineNumbers = this._getAllLineNumbers(termDataArray[0]);
1656
+ const candidateLines = new Set(lineNumbers);
1657
+
1658
+ // Remove lines that have exclude terms
1659
+ for (const excludeTerm of excludes) {
1660
+ const excludeKey = getTermKey(excludeTerm, caseInsensitive);
1661
+ if (excludeKey === null) continue;
1662
+
1663
+ const excludeData = fieldIndex[excludeKey];
1664
+ if (!excludeData) continue;
1665
+
1666
+ const excludeLines = this._getAllLineNumbers(excludeData);
1667
+ for (const line of excludeLines) {
1668
+ candidateLines.delete(line);
1669
+ }
1670
+
1671
+ // Early exit if all candidates excluded
1672
+ if (candidateLines.size === 0) {
1673
+ return false;
1674
+ }
1675
+ }
1676
+
1677
+ return candidateLines.size > 0;
1678
+ }
1679
+
1680
+ // For multiple terms, we need to check intersection
1681
+ // But we want to do this as lightly as possible
1682
+ // Get line numbers only for intersection check (unavoidable for $all)
1683
+ const termLineNumberSets = [];
1684
+ for (const termData of termDataArray) {
1685
+ const lineNumbers = this._getAllLineNumbers(termData);
1686
+ if (lineNumbers.length === 0) {
1687
+ return false; // Early exit: no line numbers (shouldn't happen, but safety check)
1688
+ }
1689
+ termLineNumberSets.push(new Set(lineNumbers));
1690
+ }
1691
+
1692
+ // Calculate intersection incrementally with early exit
1693
+ let intersection = termLineNumberSets[0];
1694
+ for (let i = 1; i < termLineNumberSets.length; i++) {
1695
+ // Filter intersection to only include items in current set
1696
+ intersection = new Set([...intersection].filter(x => termLineNumberSets[i].has(x)));
1697
+ if (intersection.size === 0) {
1698
+ return false; // Early exit: intersection is empty
1699
+ }
1700
+ }
1701
+
1702
+ // Apply excludes if any
1703
+ if (hasExcludes) {
1704
+ for (const excludeTerm of excludes) {
1705
+ const excludeKey = getTermKey(excludeTerm, caseInsensitive);
1706
+ if (excludeKey === null) continue;
1707
+
1708
+ const excludeData = fieldIndex[excludeKey];
1709
+ if (!excludeData) continue;
1710
+
1711
+ const excludeLines = this._getAllLineNumbers(excludeData);
1712
+ for (const line of excludeLines) {
1713
+ intersection.delete(line);
1714
+ }
1715
+
1716
+ // Early exit if all candidates excluded
1717
+ if (intersection.size === 0) {
1718
+ return false;
1719
+ }
1720
+ }
1721
+ }
1722
+
1723
+ return intersection.size > 0;
1724
+ }
1725
+
1726
+ // Handle $in behavior (any term exists) - default - ULTRA LIGHT
1727
+ // If no excludes, use ultra-fast path (no expansion needed)
1728
+ if (!hasExcludes) {
1729
+ for (const term of termsArray) {
1730
+ // Handle case-insensitive FIRST (before normal conversion)
1731
+ if (caseInsensitive && typeof term === 'string') {
1732
+ if (isTermMappingField && this.database?.termManager?.termToId) {
1733
+ // For term mapping fields, we need to find the term in termManager first
1734
+ // (case-insensitive), then convert to ID
1735
+ const searchLower = String(term).toLowerCase();
1736
+ let foundTermId = null;
1737
+
1738
+ // Search termManager for case-insensitive match
1739
+ for (const [termStr, termId] of this.database.termManager.termToId.entries()) {
1740
+ if (termStr.toLowerCase() === searchLower) {
1741
+ foundTermId = termId;
1742
+ break;
1743
+ }
1744
+ }
1745
+
1746
+ if (foundTermId !== null) {
1747
+ const termData = fieldIndex[String(foundTermId)];
1748
+ if (hasData(termData)) {
1749
+ return true; // Early exit: found a match
1750
+ }
1751
+ }
1752
+ // If not found, continue to next term
1753
+ continue;
1754
+ } else {
1755
+ // For non-term-mapping fields, search index keys directly
1756
+ const searchLower = String(term).toLowerCase();
1757
+ for (const key in fieldIndex) {
1758
+ if (key.toLowerCase() === searchLower) {
1759
+ const termData = fieldIndex[key];
1760
+ if (hasData(termData)) {
1761
+ return true; // Early exit: found a match
1762
+ }
1763
+ }
1764
+ }
1765
+ // If not found, continue to next term
1766
+ continue;
1767
+ }
1768
+ }
1769
+
1770
+ // Normal (case-sensitive) lookup
1771
+ const termKey = getTermKey(term, false);
1772
+ if (termKey === null) {
1773
+ continue; // Term not in mapping, try next
1774
+ }
1775
+
1776
+ // Direct lookup (fastest path) - O(1) hash lookup
1777
+ const termData = fieldIndex[termKey];
1778
+ if (hasData(termData)) {
1779
+ return true; // Early exit: found a match
1780
+ }
1781
+ }
1782
+
1783
+ return false;
1784
+ }
1785
+
1786
+ // With excludes, we need to collect candidates and filter
1787
+ const candidateLines = new Set();
1788
+
1789
+ for (const term of termsArray) {
1790
+ // Handle case-insensitive FIRST (before normal conversion)
1791
+ if (caseInsensitive && typeof term === 'string') {
1792
+ if (isTermMappingField && this.database?.termManager?.termToId) {
1793
+ // For term mapping fields, we need to find the term in termManager first
1794
+ // (case-insensitive), then convert to ID
1795
+ const searchLower = String(term).toLowerCase();
1796
+ let foundTermId = null;
1797
+
1798
+ // Search termManager for case-insensitive match
1799
+ for (const [termStr, termId] of this.database.termManager.termToId.entries()) {
1800
+ if (termStr.toLowerCase() === searchLower) {
1801
+ foundTermId = termId;
1802
+ break;
1803
+ }
1804
+ }
1805
+
1806
+ if (foundTermId !== null) {
1807
+ const termData = fieldIndex[String(foundTermId)];
1808
+ if (hasData(termData)) {
1809
+ // Add line numbers to candidates (need to expand for excludes check)
1810
+ const lineNumbers = this._getAllLineNumbers(termData);
1811
+ for (const line of lineNumbers) {
1812
+ candidateLines.add(line);
1813
+ }
1814
+ }
1815
+ }
1816
+ continue;
1817
+ } else {
1818
+ // For non-term-mapping fields, search index keys directly
1819
+ const searchLower = String(term).toLowerCase();
1820
+ for (const key in fieldIndex) {
1821
+ if (key.toLowerCase() === searchLower) {
1822
+ const termData = fieldIndex[key];
1823
+ if (hasData(termData)) {
1824
+ // Add line numbers to candidates
1825
+ const lineNumbers = this._getAllLineNumbers(termData);
1826
+ for (const line of lineNumbers) {
1827
+ candidateLines.add(line);
1828
+ }
1829
+ }
1830
+ }
1831
+ }
1832
+ continue;
1833
+ }
1834
+ }
1835
+
1836
+ // Normal (case-sensitive) lookup
1837
+ const termKey = getTermKey(term, false);
1838
+ if (termKey === null) {
1839
+ continue; // Term not in mapping, try next
1840
+ }
1841
+
1842
+ // Direct lookup
1843
+ const termData = fieldIndex[termKey];
1844
+ if (hasData(termData)) {
1845
+ // Add line numbers to candidates (need to expand for excludes check)
1846
+ const lineNumbers = this._getAllLineNumbers(termData);
1847
+ for (const line of lineNumbers) {
1848
+ candidateLines.add(line);
1849
+ }
1850
+ }
1851
+ }
1852
+
1853
+ // If no candidates found, return false
1854
+ if (candidateLines.size === 0) {
1855
+ return false;
1856
+ }
1857
+
1858
+ // Apply excludes
1859
+ for (const excludeTerm of excludes) {
1860
+ const excludeKey = getTermKey(excludeTerm, caseInsensitive);
1861
+ if (excludeKey === null) continue;
1862
+
1863
+ const excludeData = fieldIndex[excludeKey];
1864
+ if (!excludeData) continue;
1865
+
1866
+ const excludeLines = this._getAllLineNumbers(excludeData);
1867
+ for (const line of excludeLines) {
1868
+ candidateLines.delete(line);
1869
+ }
1870
+
1871
+ // Early exit if all candidates excluded
1872
+ if (candidateLines.size === 0) {
1873
+ return false;
1874
+ }
1875
+ }
1876
+
1877
+ return candidateLines.size > 0;
1878
+ }
1879
+
1880
+ /**
1881
+ * Evaluate many index-only existence checks while sharing the same field data.
1882
+ * Returns a map keyed by the criteria id so callers can reuse a single scan per field.
1883
+ *
1884
+ * @param {string} fieldName - Indexed field name
1885
+ * @param {Array<Object>} criteriaArray - Array of { id, terms, options }
1886
+ * @param {Object} opts - { limit, allowPartial } (limit only applies when allowPartial is true)
1887
+ * @returns {Object<string, boolean>} - Map of criteria id to boolean existence
1888
+ */
1889
+ multiExists(fieldName, criteriaArray, opts = {}) {
1890
+ this.markIndexUsed()
1891
+ const results = {}
1892
+ if (!Array.isArray(criteriaArray) || criteriaArray.length === 0) {
1893
+ return results
1894
+ }
1895
+
1896
+ const prepared = []
1897
+ for (let i = 0; i < criteriaArray.length; i++) {
1898
+ const entry = criteriaArray[i] || {}
1899
+ const key = entry.id !== undefined && entry.id !== null ? String(entry.id) : `criteria-${i}`
1900
+ results[key] = false
1901
+ prepared.push({
1902
+ id: key,
1903
+ terms: entry.terms,
1904
+ options: entry.options || {}
1905
+ })
1906
+ }
1907
+
1908
+ if (!fieldName || typeof fieldName !== 'string') {
1909
+ return results
1910
+ }
1911
+
1912
+ const fieldIndex = this.index?.data?.[fieldName]
1913
+ if (!fieldIndex || typeof fieldIndex !== 'object') {
1914
+ return results
1915
+ }
1916
+
1917
+ const termManager = this.database?.termManager
1918
+ const isTermMappingField = Boolean(termManager && termManager.termMappingFields && termManager.termMappingFields.includes(fieldName))
1919
+
1920
+ const normalizedLimit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.floor(opts.limit) : Infinity
1921
+ const allowPartial = Boolean(opts.allowPartial)
1922
+ const positiveTarget = allowPartial ? Math.max(1, normalizedLimit) : Infinity
1923
+
1924
+ const termCache = new Map()
1925
+ let caseInsensitiveLookup = null
1926
+ let termMappingLookup = null
1927
+
1928
+ const normalizeValues = (value) => {
1929
+ if (value === null || value === undefined) {
1930
+ return []
1931
+ }
1932
+ if (Array.isArray(value)) {
1933
+ return value.slice()
1934
+ }
1935
+ return [value]
1936
+ }
1937
+
1938
+ const buildCaseInsensitiveLookup = () => {
1939
+ const map = new Map()
1940
+ for (const key in fieldIndex) {
1941
+ const lowerKey = key.toLowerCase()
1942
+ if (!map.has(lowerKey)) {
1943
+ map.set(lowerKey, key)
1944
+ }
1945
+ }
1946
+ return map
1947
+ }
1948
+
1949
+ const buildTermMappingLookup = () => {
1950
+ const map = new Map()
1951
+ const termToId = termManager?.termToId
1952
+ if (termToId) {
1953
+ for (const [termStr, id] of termToId.entries()) {
1954
+ const lower = termStr.toLowerCase()
1955
+ if (!map.has(lower)) {
1956
+ map.set(lower, String(id))
1957
+ }
1958
+ }
1959
+ }
1960
+ return map
1961
+ }
1962
+
1963
+ const resolveTermKey = (value, caseInsensitive) => {
1964
+ if (value === null || value === undefined) {
1965
+ return null
1966
+ }
1967
+
1968
+ if (isTermMappingField) {
1969
+ if (typeof value === 'string') {
1970
+ if (caseInsensitive) {
1971
+ if (!termMappingLookup) {
1972
+ termMappingLookup = buildTermMappingLookup()
1973
+ }
1974
+ return termMappingLookup.get(value.toLowerCase()) ?? null
1975
+ }
1976
+ const termId = termManager?.getTermIdWithoutIncrement(String(value))
1977
+ if (termId === undefined || termId === null) {
1978
+ return null
1979
+ }
1980
+ return String(termId)
1981
+ }
1982
+ return String(value)
1983
+ }
1984
+
1985
+ if (caseInsensitive && typeof value === 'string') {
1986
+ if (!caseInsensitiveLookup) {
1987
+ caseInsensitiveLookup = buildCaseInsensitiveLookup()
1988
+ }
1989
+ return caseInsensitiveLookup.get(value.toLowerCase()) ?? null
1990
+ }
1991
+
1992
+ return String(value)
1993
+ }
1994
+
1995
+ const ensureTermEntry = (termKey) => {
1996
+ if (!termCache.has(termKey)) {
1997
+ const data = fieldIndex[termKey]
1998
+ const entry = {
1999
+ data,
2000
+ hasData: Boolean(data && ((data.set && data.set.size > 0) || (data.ranges && data.ranges.length > 0))),
2001
+ lineArray: null
2002
+ }
2003
+ termCache.set(termKey, entry)
2004
+ }
2005
+ return termCache.get(termKey)
2006
+ }
2007
+
2008
+ const getLineArray = (entry) => {
2009
+ if (!entry.hasData || !entry.data) return []
2010
+ if (!entry.lineArray) {
2011
+ entry.lineArray = this._getAllLineNumbers(entry.data)
2012
+ }
2013
+ return entry.lineArray
2014
+ }
2015
+
2016
+ const applyExcludes = (lineSet, excludeKeys) => {
2017
+ if (excludeKeys.length === 0) {
2018
+ return lineSet.size > 0
2019
+ }
2020
+ for (const excludeKey of excludeKeys) {
2021
+ const excludeEntry = ensureTermEntry(excludeKey)
2022
+ if (!excludeEntry.hasData) continue
2023
+ const excludeLines = getLineArray(excludeEntry)
2024
+ for (const line of excludeLines) {
2025
+ lineSet.delete(line)
2026
+ }
2027
+ if (lineSet.size === 0) {
2028
+ return false
2029
+ }
2030
+ }
2031
+ return lineSet.size > 0
2032
+ }
2033
+
2034
+ let successes = 0
2035
+
2036
+ for (const criterion of prepared) {
2037
+ if (successes >= positiveTarget) {
2038
+ break
2039
+ }
2040
+
2041
+ const { id, terms, options } = criterion
2042
+ const { $all = false, caseInsensitive = false, excludes = [] } = options
2043
+
2044
+ const normalizedTerms = normalizeValues(terms)
2045
+ if (normalizedTerms.length === 0) {
2046
+ continue
2047
+ }
2048
+
2049
+ const normalizedExcludes = normalizeValues(excludes)
2050
+
2051
+ const resolvedKeys = []
2052
+ const seenKeys = new Set()
2053
+ let missingRequiredTerm = false
2054
+
2055
+ for (const term of normalizedTerms) {
2056
+ const termKey = resolveTermKey(term, caseInsensitive)
2057
+ if (termKey === null) {
2058
+ if ($all) {
2059
+ missingRequiredTerm = true
2060
+ break
2061
+ }
2062
+ continue
2063
+ }
2064
+ if (!seenKeys.has(termKey)) {
2065
+ seenKeys.add(termKey)
2066
+ resolvedKeys.push(termKey)
2067
+ }
2068
+ }
2069
+
2070
+ if ($all && missingRequiredTerm) {
2071
+ continue
2072
+ }
2073
+
2074
+ if (resolvedKeys.length === 0) {
2075
+ continue
2076
+ }
2077
+
2078
+ const excludeKeySet = []
2079
+ const seenExcludeKeys = new Set()
2080
+ for (const excludeTerm of normalizedExcludes) {
2081
+ const excludeKey = resolveTermKey(excludeTerm, caseInsensitive)
2082
+ if (excludeKey && !seenExcludeKeys.has(excludeKey)) {
2083
+ seenExcludeKeys.add(excludeKey)
2084
+ excludeKeySet.push(excludeKey)
2085
+ }
2086
+ }
2087
+
2088
+ let match = false
2089
+
2090
+ if ($all) {
2091
+ let intersection = null
2092
+ let failed = false
2093
+
2094
+ for (const termKey of resolvedKeys) {
2095
+ const entry = ensureTermEntry(termKey)
2096
+ if (!entry.hasData) {
2097
+ failed = true
2098
+ break
2099
+ }
2100
+ const lines = getLineArray(entry)
2101
+ if (lines.length === 0) {
2102
+ failed = true
2103
+ break
2104
+ }
2105
+
2106
+ const currentSet = new Set(lines)
2107
+ if (intersection === null) {
2108
+ intersection = currentSet
2109
+ } else {
2110
+ intersection = new Set([...intersection].filter(value => currentSet.has(value)))
2111
+ if (intersection.size === 0) {
2112
+ failed = true
2113
+ break
2114
+ }
2115
+ }
2116
+ }
2117
+
2118
+ if (!failed && intersection && intersection.size > 0) {
2119
+ match = applyExcludes(intersection, excludeKeySet)
2120
+ }
2121
+ } else if (excludeKeySet.length === 0) {
2122
+ match = resolvedKeys.some(termKey => ensureTermEntry(termKey).hasData)
2123
+ } else {
2124
+ const candidates = new Set()
2125
+ for (const termKey of resolvedKeys) {
2126
+ const entry = ensureTermEntry(termKey)
2127
+ if (!entry.hasData) continue
2128
+ const lines = getLineArray(entry)
2129
+ for (const line of lines) {
2130
+ candidates.add(line)
2131
+ }
2132
+ }
2133
+ if (candidates.size > 0) {
2134
+ match = applyExcludes(candidates, excludeKeySet)
2135
+ }
2136
+ }
2137
+
2138
+ results[id] = Boolean(match)
2139
+ if (match) {
2140
+ successes += 1
2141
+ }
2142
+ }
2143
+
2144
+ return results
2145
+ }
2146
+
2147
+ // Ultra-fast load with minimal conversions
2148
+ load(index) {
2149
+ // CRITICAL FIX: Check if index is already loaded by looking for actual data, not just empty field structures
2150
+ if (this.index && this.index.data) {
2151
+ let hasActualData = false
2152
+ for (const field in this.index.data) {
2153
+ const fieldData = this.index.data[field]
2154
+ if (fieldData && Object.keys(fieldData).length > 0) {
2155
+ // Check if any field has actual index entries with data
2156
+ for (const key in fieldData) {
2157
+ const entry = fieldData[key]
2158
+ if (entry && ((entry.set && entry.set.size > 0) || (entry.ranges && entry.ranges.length > 0))) {
2159
+ hasActualData = true
2160
+ break
2161
+ }
2162
+ }
2163
+ if (hasActualData) break
2164
+ }
2165
+ }
2166
+
2167
+ if (hasActualData) {
2168
+ if (this.opts.debugMode) {
2169
+ console.log('🔍 IndexManager.load: Index already loaded with actual data, skipping')
2170
+ }
2171
+ return
2172
+ }
2173
+ }
2174
+
2175
+ // CRITICAL FIX: Add comprehensive null/undefined validation
2176
+ if (!index || typeof index !== 'object') {
2177
+ if (this.opts.debugMode) {
2178
+ console.log(`🔍 IndexManager.load: Invalid index data provided (${typeof index}), using defaults`)
2179
+ }
2180
+ return this._initializeDefaults()
2181
+ }
2182
+
2183
+ if (!index.data || typeof index.data !== 'object') {
2184
+ if (this.opts.debugMode) {
2185
+ console.log(`🔍 IndexManager.load: Invalid index.data provided (${typeof index.data}), using defaults`)
2186
+ }
2187
+ return this._initializeDefaults()
2188
+ }
2189
+
2190
+ // CRITICAL FIX: Only log if there are actual fields to load
2191
+ if (this.opts.debugMode && Object.keys(index.data).length > 0) {
2192
+ console.log(`🔍 IndexManager.load: Loading index with fields: ${Object.keys(index.data).join(', ')}`)
2193
+ }
2194
+
2195
+ // Create a deep copy to avoid reference issues
2196
+ const processedIndex = {
2197
+ data: {}
2198
+ }
2199
+
2200
+ // CRITICAL FIX: Add null/undefined checks for field iteration
2201
+ const fields = Object.keys(index.data)
2202
+ for(const field of fields) {
2203
+ if (!field || typeof field !== 'string') {
2204
+ continue // Skip invalid field names
2205
+ }
2206
+
2207
+ const fieldData = index.data[field]
2208
+ if (!fieldData || typeof fieldData !== 'object') {
2209
+ continue // Skip invalid field data
2210
+ }
2211
+
2212
+ processedIndex.data[field] = {}
2213
+
2214
+ // CRITICAL FIX: Check if this is a term mapping field for conversion
2215
+ const isTermMappingField = this.database?.termManager &&
2216
+ this.database.termManager.termMappingFields &&
2217
+ this.database.termManager.termMappingFields.includes(field)
2218
+
2219
+ const terms = Object.keys(fieldData)
2220
+ for(const term of terms) {
2221
+ if (!term || typeof term !== 'string') {
2222
+ continue // Skip invalid term names
2223
+ }
2224
+
2225
+ const termData = fieldData[term]
2226
+
2227
+ // CRITICAL FIX: Convert term strings to term IDs for term mapping fields
2228
+ // If the key is a string term (not a numeric ID), convert it to term ID
2229
+ let termKey = term
2230
+ if (isTermMappingField && typeof term === 'string' && !/^\d+$/.test(term)) {
2231
+ // Key is a term string, convert to term ID
2232
+ const termId = this.database?.termManager?.getTermIdWithoutIncrement(term)
2233
+ if (termId !== undefined) {
2234
+ termKey = String(termId)
2235
+ } else {
2236
+ // Term not found in termManager - skip this key (orphaned term from old index)
2237
+ // This can happen if termMapping wasn't loaded yet or term was removed
2238
+ if (this.opts?.debugMode) {
2239
+ console.log(`⚠️ IndexManager.load: Term "${term}" not found in termManager for field "${field}" - skipping (orphaned from old index)`)
2240
+ }
2241
+ continue
2242
+ }
2243
+ }
2244
+
2245
+ // Convert various formats to new hybrid format
2246
+ if (Array.isArray(termData)) {
2247
+ // Check if it's the new compact format [setArray, rangesArray]
2248
+ if (termData.length === 2 && Array.isArray(termData[0]) && Array.isArray(termData[1])) {
2249
+ // New compact format: [setArray, rangesArray]
2250
+ // Convert ultra-compact ranges [start, count] back to {start, count}
2251
+ const ranges = termData[1].map(range => {
2252
+ if (Array.isArray(range) && range.length === 2) {
2253
+ // Ultra-compact format: [start, count]
2254
+ return { start: range[0], count: range[1] }
2255
+ } else {
2256
+ // Legacy format: {start, count}
2257
+ return range
2258
+ }
2259
+ })
2260
+ processedIndex.data[field][termKey] = {
2261
+ set: new Set(termData[0]),
2262
+ ranges: ranges
2263
+ }
2264
+ } else {
2265
+ // Legacy array format (just set data)
2266
+ processedIndex.data[field][termKey] = { set: new Set(termData), ranges: [] }
2267
+ }
2268
+ } else if (termData && typeof termData === 'object') {
2269
+ if (termData.set || termData.ranges) {
2270
+ // Legacy hybrid format - convert set array back to Set
2271
+ const hybridData = termData
2272
+ let setObject
2273
+ if (Array.isArray(hybridData.set)) {
2274
+ // Convert array back to Set
2275
+ setObject = new Set(hybridData.set)
2276
+ } else {
2277
+ // Fallback to empty Set
2278
+ setObject = new Set()
2279
+ }
2280
+ processedIndex.data[field][termKey] = {
2281
+ set: setObject,
2282
+ ranges: hybridData.ranges || []
2283
+ }
2284
+ } else {
2285
+ // Convert from Set format to hybrid
2286
+ const numbers = Array.from(termData || [])
2287
+ processedIndex.data[field][termKey] = { set: new Set(numbers), ranges: [] }
2288
+ }
2289
+ }
2290
+ }
2291
+ }
2292
+
2293
+ // Preserve initialized fields if no data was loaded
2294
+ if (!processedIndex.data || Object.keys(processedIndex.data).length === 0) {
2295
+ // CRITICAL FIX: Only log if debug mode is enabled and there are actual fields
2296
+ if (this.opts.debugMode && this.index.data && Object.keys(this.index.data).length > 0) {
2297
+ console.log(`🔍 IndexManager.load: No data loaded, preserving initialized fields: ${Object.keys(this.index.data).join(', ')}`)
2298
+ }
2299
+ // Keep the current index with initialized fields
2300
+ this.indexLoaded = true
2301
+ this.markIndexUsed()
2302
+ return
2303
+ }
2304
+
2305
+ // Restore totalLines from saved data
2306
+ if (index.totalLines !== undefined) {
2307
+ this.totalLines = index.totalLines
2308
+ if (this.opts.debugMode) {
2309
+ console.log(`🔍 IndexManager.load: Restored totalLines=${this.totalLines}`)
2310
+ }
2311
+ }
2312
+
2313
+ this.index = processedIndex
2314
+ this.indexLoaded = true
2315
+ this.markIndexUsed()
2316
+ }
2317
+
2318
+ /**
2319
+ * CRITICAL FIX: Initialize default index structure when invalid data is provided
2320
+ * This prevents TypeError when Object.keys() is called on null/undefined
2321
+ */
2322
+ _initializeDefaults() {
2323
+ if (this.opts.debugMode) {
2324
+ console.log(`🔍 IndexManager._initializeDefaults: Initializing default index structure`)
2325
+ }
2326
+
2327
+ // Initialize empty index structure
2328
+ this.index = { data: {} }
2329
+
2330
+ // Initialize fields from options if available
2331
+ if (this.opts.indexes && typeof this.opts.indexes === 'object') {
2332
+ const fields = Object.keys(this.opts.indexes)
2333
+ for (const field of fields) {
2334
+ if (field && typeof field === 'string') {
2335
+ this.index.data[field] = {}
2336
+ }
2337
+ }
2338
+ }
2339
+
2340
+ this.indexLoaded = true
2341
+ this.markIndexUsed()
2342
+ }
2343
+
2344
+ /**
2345
+ * Convert index to JSON-serializable format for debugging and export
2346
+ * This resolves the issue where Sets appear as empty objects in JSON.stringify
2347
+ */
2348
+ toJSON() {
2349
+ const serializable = {
2350
+ data: {},
2351
+ totalLines: this.totalLines
2352
+ }
2353
+
2354
+ // Check if this is a term mapping field for conversion
2355
+ const isTermMappingField = (field) => {
2356
+ return this.database?.termManager &&
2357
+ this.database.termManager.termMappingFields &&
2358
+ this.database.termManager.termMappingFields.includes(field)
2359
+ }
2360
+
2361
+ for (const field in this.index.data) {
2362
+ serializable.data[field] = {}
2363
+ const isTermField = isTermMappingField(field)
2364
+
2365
+ for (const term in this.index.data[field]) {
2366
+ const hybridData = this.index.data[field][term]
2367
+
2368
+ // CRITICAL FIX: Convert term strings to term IDs for term mapping fields
2369
+ // If the key is a string term (not a numeric ID), convert it to term ID
2370
+ let termKey = term
2371
+ if (isTermField && typeof term === 'string' && !/^\d+$/.test(term)) {
2372
+ // Key is a term string, convert to term ID
2373
+ const termId = this.database?.termManager?.getTermIdWithoutIncrement(term)
2374
+ if (termId !== undefined) {
2375
+ termKey = String(termId)
2376
+ } else {
2377
+ // Term not found in termManager, keep original key
2378
+ // This prevents data loss when term mapping is incomplete
2379
+ termKey = term
2380
+ if (this.opts?.debugMode) {
2381
+ console.log(`⚠️ IndexManager.toJSON: Term "${term}" not found in termManager for field "${field}" - using original key`)
2382
+ }
2383
+ }
2384
+ }
2385
+
2386
+ // OPTIMIZATION: Create ranges before serialization if beneficial
2387
+ if (hybridData.set && hybridData.set.size >= this.rangeThreshold) {
2388
+ this._optimizeToRanges(hybridData)
2389
+ }
2390
+
2391
+ // Convert hybrid structure to serializable format
2392
+ let setArray = []
2393
+ if (hybridData.set) {
2394
+ if (typeof hybridData.set.size !== 'undefined') {
2395
+ // Regular Set
2396
+ setArray = Array.from(hybridData.set)
2397
+ }
2398
+ }
2399
+
2400
+ // Use ultra-compact format: [setArray, rangesArray] to save space
2401
+ const ranges = hybridData.ranges || []
2402
+ if (ranges.length > 0) {
2403
+ // Convert ranges to ultra-compact format: [start, count] instead of {start, count}
2404
+ const compactRanges = ranges.map(range => [range.start, range.count])
2405
+ serializable.data[field][termKey] = [setArray, compactRanges]
2406
+ } else {
2407
+ // CRITICAL FIX: Always use the [setArray, []] format for consistency
2408
+ // This ensures the load() method can properly deserialize the data
2409
+ serializable.data[field][termKey] = [setArray, []]
2410
+ }
2411
+ }
2412
+ }
2413
+
2414
+ return serializable
2415
+ }
2416
+
2417
+ /**
2418
+ * Get a JSON string representation of the index
2419
+ * This properly handles Sets unlike the default JSON.stringify
2420
+ */
2421
+ toString() {
2422
+ return JSON.stringify(this.toJSON(), null, 2)
2423
+ }
2424
+
2425
+ // Simplified term mapping methods - just basic functionality
2426
+
2427
+ /**
2428
+ * Rebuild index (stub for compatibility)
2429
+ */
2430
+ async rebuild() {
2431
+ // Stub implementation for compatibility
2432
+ return Promise.resolve()
2433
+ }
2434
+ }
2435
+