jexidb 2.1.9 → 2.2.0

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,1552 +1,1619 @@
1
- import { normalizeOperator } from '../utils/operatorNormalizer.mjs'
2
- import { promises as fs } from 'fs'
3
-
4
- /**
5
- * QueryManager - Handles all query operations and strategies
6
- *
7
- * Responsibilities:
8
- * - find(), findOne(), count(), query()
9
- * - findWithStreaming(), findWithIndexed()
10
- * - matchesCriteria(), extractQueryFields()
11
- * - Query strategies (INDEXED vs STREAMING)
12
- * - Result estimation
13
- */
14
-
15
- export class QueryManager {
16
- constructor(database) {
17
- this.database = database
18
- this.opts = database.opts
19
- this.indexManager = database.indexManager
20
- this.fileHandler = database.fileHandler
21
- this.serializer = database.serializer
22
- this.usageStats = database.usageStats || {
23
- totalQueries: 0,
24
- indexedQueries: 0,
25
- streamingQueries: 0,
26
- indexedAverageTime: 0,
27
- streamingAverageTime: 0
28
- }
29
- }
30
-
31
- /**
32
- * Main find method with strategy selection
33
- * @param {Object} criteria - Query criteria
34
- * @param {Object} options - Query options
35
- * @returns {Promise<Array>} - Query results
36
- */
37
- async find(criteria, options = {}) {
38
- if (this.database.destroyed) throw new Error('Database is destroyed')
39
- if (!this.database.initialized) await this.database.init()
40
-
41
- // Rebuild indexes if needed (when index was corrupted/missing)
42
- await this.database._rebuildIndexesIfNeeded()
43
-
44
- // Manual save is now the responsibility of the application
45
-
46
- // Preprocess query to handle array field syntax automatically
47
- const processedCriteria = this.preprocessQuery(criteria)
48
-
49
- const finalCriteria = processedCriteria
50
-
51
- // Validate strict indexed mode before processing
52
- if (this.opts.indexedQueryMode === 'strict') {
53
- this.validateStrictQuery(finalCriteria, options);
54
- }
55
-
56
- const startTime = Date.now();
57
- this.usageStats.totalQueries++;
58
-
59
- try {
60
- // Decide which strategy to use
61
- const strategy = this.shouldUseStreaming(finalCriteria, options);
62
-
63
- let results = [];
64
-
65
- if (strategy === 'streaming') {
66
- results = await this.findWithStreaming(finalCriteria, options);
67
- this.usageStats.streamingQueries++;
68
- this.updateAverageTime('streaming', Date.now() - startTime);
69
- } else {
70
- results = await this.findWithIndexed(finalCriteria, options);
71
- this.usageStats.indexedQueries++;
72
- this.updateAverageTime('indexed', Date.now() - startTime);
73
- }
74
-
75
- if (this.opts.debugMode) {
76
- const time = Date.now() - startTime;
77
- console.log(`⏱️ Query completed in ${time}ms using ${strategy} strategy`);
78
- console.log(`📊 Results: ${results.length} records`);
79
- console.log(`📊 Results type: ${typeof results}, isArray: ${Array.isArray(results)}`);
80
- }
81
-
82
- return results;
83
-
84
- } catch (error) {
85
- if (this.opts.debugMode) {
86
- console.error('❌ Query failed:', error);
87
- }
88
- throw error;
89
- }
90
- }
91
-
92
- /**
93
- * Find one record
94
- * @param {Object} criteria - Query criteria
95
- * @param {Object} options - Query options
96
- * @returns {Promise<Object|null>} - First matching record or null
97
- */
98
- async findOne(criteria, options = {}) {
99
- if (this.database.destroyed) throw new Error('Database is destroyed')
100
- if (!this.database.initialized) await this.database.init()
101
- // Manual save is now the responsibility of the application
102
-
103
- // Preprocess query to handle array field syntax automatically
104
- const processedCriteria = this.preprocessQuery(criteria)
105
-
106
- // Validate strict indexed mode before processing
107
- if (this.opts.indexedQueryMode === 'strict') {
108
- this.validateStrictQuery(processedCriteria, options);
109
- }
110
-
111
- const startTime = Date.now();
112
- this.usageStats.totalQueries++;
113
-
114
- try {
115
- // Decide which strategy to use
116
- const strategy = this.shouldUseStreaming(processedCriteria, options);
117
-
118
- let results = [];
119
-
120
- if (strategy === 'streaming') {
121
- results = await this.findWithStreaming(processedCriteria, { ...options, limit: 1 });
122
- this.usageStats.streamingQueries++;
123
- this.updateAverageTime('streaming', Date.now() - startTime);
124
- } else {
125
- results = await this.findWithIndexed(processedCriteria, { ...options, limit: 1 });
126
- this.usageStats.indexedQueries++;
127
- this.updateAverageTime('indexed', Date.now() - startTime);
128
- }
129
-
130
- if (this.opts.debugMode) {
131
- const time = Date.now() - startTime;
132
- console.log(`⏱️ findOne completed in ${time}ms using ${strategy} strategy`);
133
- console.log(`📊 Results: ${results.length} record(s)`);
134
- }
135
-
136
- // Return the first result or null if no results found
137
- return results.length > 0 ? results[0] : null;
138
-
139
- } catch (error) {
140
- if (this.opts.debugMode) {
141
- console.error('❌ findOne failed:', error);
142
- }
143
- throw error;
144
- }
145
- }
146
-
147
- /**
148
- * Count records matching criteria
149
- * @param {Object} criteria - Query criteria
150
- * @param {Object} options - Query options
151
- * @returns {Promise<number>} - Count of matching records
152
- */
153
- async count(criteria, options = {}) {
154
- if (this.database.destroyed) throw new Error('Database is destroyed')
155
- if (!this.database.initialized) await this.database.init()
156
-
157
- // Rebuild indexes if needed (when index was corrupted/missing)
158
- await this.database._rebuildIndexesIfNeeded()
159
-
160
- // Manual save is now the responsibility of the application
161
-
162
- // Validate strict indexed mode before processing
163
- if (this.opts.indexedQueryMode === 'strict') {
164
- this.validateStrictQuery(criteria, options);
165
- }
166
-
167
- // Use the same strategy as find method
168
- const strategy = this.shouldUseStreaming(criteria, options);
169
-
170
- let count = 0;
171
-
172
- if (strategy === 'streaming') {
173
- // Use streaming approach for non-indexed fields or large result sets
174
- const results = await this.findWithStreaming(criteria, options);
175
- count = results.length;
176
- } else {
177
- // OPTIMIZATION: For indexed strategy, use indexManager.query().size directly
178
- // This avoids reading actual records from the file - much faster!
179
- const lineNumbers = this.indexManager.query(criteria, options);
180
-
181
- if (lineNumbers.size === 0) {
182
- const missingIndexedFields = this._getIndexedFieldsWithMissingData(criteria)
183
- if (missingIndexedFields.length > 0 && this._hasAnyRecords()) {
184
- // Try to rebuild index before falling back to streaming (only if allowIndexRebuild is true)
185
- if (this.database.opts.allowIndexRebuild) {
186
- if (this.opts.debugMode) {
187
- console.log(`⚠️ Indexed count returned 0 because index data is missing for: ${missingIndexedFields.join(', ')}. Attempting index rebuild...`);
188
- }
189
- this.database._indexRebuildNeeded = true
190
- await this.database._rebuildIndexesIfNeeded()
191
-
192
- // Retry indexed query after rebuild
193
- const retryLineNumbers = this.indexManager.query(criteria, options)
194
- if (retryLineNumbers.size > 0) {
195
- if (this.opts.debugMode) {
196
- console.log(`✅ Index rebuild successful, using indexed strategy.`);
197
- }
198
- count = retryLineNumbers.size
199
- } else {
200
- // Still no results after rebuild, fall back to streaming
201
- if (this.opts.debugMode) {
202
- console.log(`⚠️ Index rebuild did not help, falling back to streaming count.`);
203
- }
204
- const streamingResults = await this.findWithStreaming(criteria, { ...options, forceFullScan: true })
205
- count = streamingResults.length
206
- }
207
- } else {
208
- // allowIndexRebuild is false, fall back to streaming
209
- if (this.opts.debugMode) {
210
- console.log(`⚠️ Indexed count returned 0 because index data is missing for: ${missingIndexedFields.join(', ')}. Falling back to streaming count.`);
211
- }
212
- const streamingResults = await this.findWithStreaming(criteria, { ...options, forceFullScan: true })
213
- count = streamingResults.length
214
- }
215
- } else {
216
- count = 0
217
- }
218
- } else {
219
- count = lineNumbers.size;
220
- }
221
- }
222
-
223
- return count;
224
- }
225
-
226
- /**
227
- * Compatibility method that redirects to find
228
- * @param {Object} criteria - Query criteria
229
- * @param {Object} options - Query options
230
- * @returns {Promise<Array>} - Query results
231
- */
232
- async query(criteria, options = {}) {
233
- return this.find(criteria, options)
234
- }
235
-
236
- /**
237
- * Find using streaming strategy with pre-filtering optimization
238
- * @param {Object} criteria - Query criteria
239
- * @param {Object} options - Query options
240
- * @returns {Promise<Array>} - Query results
241
- */
242
- async findWithStreaming(criteria, options = {}) {
243
- const streamingOptions = { ...options }
244
- const forceFullScan = streamingOptions.forceFullScan === true
245
- delete streamingOptions.forceFullScan
246
-
247
- if (this.opts.debugMode) {
248
- if (forceFullScan) {
249
- console.log('🌊 Using streaming strategy (forced full scan to bypass missing index data)');
250
- } else {
251
- console.log('🌊 Using streaming strategy');
252
- }
253
- }
254
-
255
- if (!forceFullScan) {
256
- // OPTIMIZATION: Try to use indices for pre-filtering when possible
257
- const indexableFields = this._getIndexableFields(criteria);
258
- if (indexableFields.length > 0) {
259
- if (this.opts.debugMode) {
260
- console.log(`🌊 Using pre-filtered streaming with ${indexableFields.length} indexable fields`);
261
- }
262
-
263
- // Use indices to pre-filter and reduce streaming scope
264
- const preFilteredLines = this.indexManager.query(
265
- this._extractIndexableCriteria(criteria),
266
- streamingOptions
267
- );
268
-
269
- // Stream only the pre-filtered records
270
- return this._streamPreFilteredRecords(preFilteredLines, criteria, streamingOptions);
271
- }
272
- }
273
-
274
- // Fallback to full streaming
275
- if (this.opts.debugMode) {
276
- console.log('🌊 Using full streaming (no indexable fields found or forced)');
277
- }
278
-
279
- return this._streamAllRecords(criteria, streamingOptions);
280
- }
281
-
282
- /**
283
- * Get indexable fields from criteria
284
- * @param {Object} criteria - Query criteria
285
- * @returns {Array} - Array of indexable field names
286
- */
287
- _getIndexableFields(criteria) {
288
- const indexableFields = [];
289
-
290
- if (!criteria || typeof criteria !== 'object') {
291
- return indexableFields;
292
- }
293
-
294
- // Handle $and conditions
295
- if (criteria.$and && Array.isArray(criteria.$and)) {
296
- for (const andCondition of criteria.$and) {
297
- indexableFields.push(...this._getIndexableFields(andCondition));
298
- }
299
- }
300
-
301
- // Handle regular field conditions
302
- for (const [field, condition] of Object.entries(criteria)) {
303
- if (field.startsWith('$')) continue; // Skip logical operators
304
-
305
- // RegExp conditions cannot be pre-filtered using indices
306
- if (condition instanceof RegExp) {
307
- continue;
308
- }
309
-
310
- if (this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]) {
311
- indexableFields.push(field);
312
- }
313
- }
314
-
315
- return [...new Set(indexableFields)]; // Remove duplicates
316
- }
317
-
318
- /**
319
- * Extract indexable criteria for pre-filtering
320
- * @param {Object} criteria - Full query criteria
321
- * @returns {Object} - Criteria with only indexable fields
322
- */
323
- _extractIndexableCriteria(criteria) {
324
- if (!criteria || typeof criteria !== 'object') {
325
- return {};
326
- }
327
-
328
- const indexableCriteria = {};
329
-
330
- // Handle $and conditions
331
- if (criteria.$and && Array.isArray(criteria.$and)) {
332
- const indexableAndConditions = criteria.$and
333
- .map(andCondition => this._extractIndexableCriteria(andCondition))
334
- .filter(condition => Object.keys(condition).length > 0);
335
-
336
- if (indexableAndConditions.length > 0) {
337
- indexableCriteria.$and = indexableAndConditions;
338
- }
339
- }
340
-
341
- // Handle $not operator - include it if it can be processed by IndexManager
342
- if (criteria.$not && typeof criteria.$not === 'object') {
343
- // Check if $not condition contains only indexable fields
344
- const notFields = Object.keys(criteria.$not);
345
- const allNotFieldsIndexed = notFields.every(field =>
346
- this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]
347
- );
348
-
349
- if (allNotFieldsIndexed && notFields.length > 0) {
350
- // Extract indexable criteria from $not condition
351
- const indexableNotCriteria = this._extractIndexableCriteria(criteria.$not);
352
- if (Object.keys(indexableNotCriteria).length > 0) {
353
- indexableCriteria.$not = indexableNotCriteria;
354
- }
355
- }
356
- }
357
-
358
- // Handle regular field conditions
359
- for (const [field, condition] of Object.entries(criteria)) {
360
- if (field.startsWith('$')) continue; // Skip logical operators (already handled above)
361
-
362
- // RegExp conditions cannot be pre-filtered using indices
363
- if (condition instanceof RegExp) {
364
- continue;
365
- }
366
-
367
- if (this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]) {
368
- indexableCriteria[field] = condition;
369
- }
370
- }
371
-
372
- return indexableCriteria;
373
- }
374
-
375
- /**
376
- * Determine whether the database currently has any records (persisted or pending)
377
- * @returns {boolean}
378
- */
379
- _hasAnyRecords() {
380
- if (!this.database) {
381
- return false
382
- }
383
-
384
- if (Array.isArray(this.database.offsets) && this.database.offsets.length > 0) {
385
- return true
386
- }
387
-
388
- if (Array.isArray(this.database.writeBuffer) && this.database.writeBuffer.length > 0) {
389
- return true
390
- }
391
-
392
- if (typeof this.database.length === 'number' && this.database.length > 0) {
393
- return true
394
- }
395
-
396
- return false
397
- }
398
-
399
- /**
400
- * Extract all indexed fields referenced in the criteria
401
- * @param {Object} criteria
402
- * @param {Set<string>} accumulator
403
- * @returns {Array<string>}
404
- */
405
- _extractIndexedFields(criteria, accumulator = new Set()) {
406
- if (!criteria) {
407
- return Array.from(accumulator)
408
- }
409
-
410
- if (Array.isArray(criteria)) {
411
- for (const item of criteria) {
412
- this._extractIndexedFields(item, accumulator)
413
- }
414
- return Array.from(accumulator)
415
- }
416
-
417
- if (typeof criteria !== 'object') {
418
- return Array.from(accumulator)
419
- }
420
-
421
- for (const [key, value] of Object.entries(criteria)) {
422
- if (key.startsWith('$')) {
423
- this._extractIndexedFields(value, accumulator)
424
- continue
425
- }
426
-
427
- accumulator.add(key)
428
-
429
- if (Array.isArray(value)) {
430
- for (const nested of value) {
431
- this._extractIndexedFields(nested, accumulator)
432
- }
433
- }
434
- }
435
-
436
- return Array.from(accumulator)
437
- }
438
-
439
- /**
440
- * Identify indexed fields present in criteria whose index data is missing
441
- * @param {Object} criteria
442
- * @returns {Array<string>}
443
- */
444
- _getIndexedFieldsWithMissingData(criteria) {
445
- if (!this.indexManager || !criteria) {
446
- return []
447
- }
448
-
449
- const indexedFields = this._extractIndexedFields(criteria)
450
- const missing = []
451
-
452
- for (const field of indexedFields) {
453
- if (!this.indexManager.isFieldIndexed(field)) {
454
- continue
455
- }
456
-
457
- if (!this.indexManager.hasUsableIndexData(field)) {
458
- missing.push(field)
459
- }
460
- }
461
-
462
- return missing
463
- }
464
-
465
- /**
466
- * OPTIMIZATION 4: Stream pre-filtered records using line numbers from indices with partial index optimization
467
- * @param {Set} preFilteredLines - Line numbers from index query
468
- * @param {Object} criteria - Full query criteria
469
- * @param {Object} options - Query options
470
- * @returns {Promise<Array>} - Query results
471
- */
472
- async _streamPreFilteredRecords(preFilteredLines, criteria, options = {}) {
473
- if (preFilteredLines.size === 0) {
474
- return [];
475
- }
476
-
477
- const results = [];
478
- const lineNumbers = Array.from(preFilteredLines);
479
-
480
- // OPTIMIZATION 4: Sort line numbers for efficient file reading
481
- lineNumbers.sort((a, b) => a - b);
482
-
483
- // OPTIMIZATION 4: Use batch reading for better performance
484
- const batchSize = Math.min(1000, lineNumbers.length); // Read in batches of 1000
485
- const batches = [];
486
-
487
- for (let i = 0; i < lineNumbers.length; i += batchSize) {
488
- batches.push(lineNumbers.slice(i, i + batchSize));
489
- }
490
-
491
- for (const batch of batches) {
492
- // OPTIMIZATION: Use ranges instead of reading entire file
493
- const ranges = this.database.getRanges(batch);
494
- const groupedRanges = await this.fileHandler.groupedRanges(ranges);
495
-
496
- const fd = await fs.open(this.fileHandler.file, 'r');
497
-
498
- try {
499
- for (const groupedRange of groupedRanges) {
500
- for await (const row of this.fileHandler.readGroupedRange(groupedRange, fd)) {
501
- if (row.line && row.line.trim()) {
502
- try {
503
- // CRITICAL FIX: Use serializer.deserialize instead of JSON.parse to handle array format
504
- const record = this.database.serializer.deserialize(row.line);
505
-
506
- // OPTIMIZATION 4: Use optimized criteria matching for pre-filtered records
507
- if (this._matchesCriteriaOptimized(record, criteria, options)) {
508
- // SPACE OPTIMIZATION: Restore term IDs to terms for user (unless disabled)
509
- const recordWithTerms = options.restoreTerms !== false ?
510
- this.database.restoreTermIdsAfterDeserialization(record) :
511
- record
512
- results.push(recordWithTerms);
513
-
514
- // Check limit
515
- if (options.limit && results.length >= options.limit) {
516
- return this._applyOrdering(results, options);
517
- }
518
- }
519
- } catch (error) {
520
- // Skip invalid lines
521
- continue;
522
- }
523
- }
524
- }
525
- }
526
- } finally {
527
- await fd.close();
528
- }
529
- }
530
-
531
- return this._applyOrdering(results, options);
532
- }
533
-
534
- /**
535
- * OPTIMIZATION 4: Optimized criteria matching for pre-filtered records
536
- * @param {Object} record - Record to check
537
- * @param {Object} criteria - Filter criteria
538
- * @param {Object} options - Query options
539
- * @returns {boolean} - True if matches
540
- */
541
- _matchesCriteriaOptimized(record, criteria, options = {}) {
542
- if (!criteria || Object.keys(criteria).length === 0) {
543
- return true;
544
- }
545
-
546
- // Handle $not operator at the top level
547
- if (criteria.$not && typeof criteria.$not === 'object') {
548
- // For $not conditions, we need to negate the result
549
- // IMPORTANT: For $not conditions, we should NOT skip pre-filtered fields
550
- // because we need to evaluate the actual field values to determine exclusion
551
-
552
- // Use the regular matchesCriteria method for $not conditions to ensure proper field evaluation
553
- const notResult = this.matchesCriteria(record, criteria.$not, options);
554
- return !notResult;
555
- }
556
-
557
- // OPTIMIZATION 4: Skip indexable fields since they were already pre-filtered
558
- const indexableFields = this._getIndexableFields(criteria);
559
-
560
- // Handle explicit logical operators at the top level
561
- if (criteria.$or && Array.isArray(criteria.$or)) {
562
- let orMatches = false;
563
- for (const orCondition of criteria.$or) {
564
- if (this._matchesCriteriaOptimized(record, orCondition, options)) {
565
- orMatches = true;
566
- break;
567
- }
568
- }
569
-
570
- if (!orMatches) {
571
- return false;
572
- }
573
- } else if (criteria.$and && Array.isArray(criteria.$and)) {
574
- for (const andCondition of criteria.$and) {
575
- if (!this._matchesCriteriaOptimized(record, andCondition, options)) {
576
- return false;
577
- }
578
- }
579
- }
580
-
581
- // Handle individual field conditions (exclude logical operators and pre-filtered fields)
582
- for (const [field, condition] of Object.entries(criteria)) {
583
- if (field.startsWith('$')) continue;
584
-
585
- // OPTIMIZATION 4: Skip indexable fields that were already pre-filtered
586
- if (indexableFields.includes(field)) {
587
- continue;
588
- }
589
-
590
- if (!this.matchesFieldCondition(record, field, condition, options)) {
591
- return false;
592
- }
593
- }
594
-
595
- if (criteria.$or && Array.isArray(criteria.$or)) {
596
- return true;
597
- }
598
-
599
- return true;
600
- }
601
-
602
- /**
603
- * OPTIMIZATION 4: Apply ordering to results
604
- * @param {Array} results - Results to order
605
- * @param {Object} options - Query options
606
- * @returns {Array} - Ordered results
607
- */
608
- _applyOrdering(results, options) {
609
- if (options.orderBy) {
610
- const [field, direction = 'asc'] = options.orderBy.split(' ');
611
- results.sort((a, b) => {
612
- if (a[field] > b[field]) return direction === 'asc' ? 1 : -1;
613
- if (a[field] < b[field]) return direction === 'asc' ? -1 : 1;
614
- return 0;
615
- });
616
- }
617
-
618
- return results;
619
- }
620
-
621
- /**
622
- * Stream all records (fallback method)
623
- * @param {Object} criteria - Query criteria
624
- * @param {Object} options - Query options
625
- * @returns {Promise<Array>} - Query results
626
- */
627
- async _streamAllRecords(criteria, options = {}) {
628
- const memoryLimit = options.limit || undefined;
629
- const streamingOptions = { ...options, limit: memoryLimit };
630
-
631
- const results = await this.fileHandler.readWithStreaming(criteria, streamingOptions, (record, criteria) => {
632
- return this.matchesCriteria(record, criteria, options);
633
- }, this.serializer || null);
634
-
635
- // SPACE OPTIMIZATION: Restore term IDs to terms for user (unless disabled)
636
- const resultsWithTerms = options.restoreTerms !== false ?
637
- results.map(record => this.database.restoreTermIdsAfterDeserialization(record)) :
638
- results;
639
-
640
- // Apply ordering if specified
641
- if (options.orderBy) {
642
- const [field, direction = 'asc'] = options.orderBy.split(' ');
643
- resultsWithTerms.sort((a, b) => {
644
- if (a[field] > b[field]) return direction === 'asc' ? 1 : -1;
645
- if (a[field] < b[field]) return direction === 'asc' ? -1 : 1;
646
- return 0;
647
- });
648
- }
649
-
650
- return resultsWithTerms;
651
- }
652
-
653
- /**
654
- * Find using indexed search strategy with real streaming
655
- * @param {Object} criteria - Query criteria
656
- * @param {Object} options - Query options
657
- * @returns {Promise<Array>} - Query results
658
- */
659
- async findWithIndexed(criteria, options = {}) {
660
- if (this.opts.debugMode) {
661
- console.log('📊 Using indexed strategy with real streaming');
662
- }
663
-
664
- let results = []
665
- const limit = options.limit // No default limit - return all results unless explicitly limited
666
-
667
- // Use IndexManager to get line numbers, then read specific records
668
- const lineNumbers = this.indexManager.query(criteria, options)
669
- if (this.opts.debugMode) {
670
- console.log(`🔍 IndexManager returned ${lineNumbers.size} line numbers:`, Array.from(lineNumbers))
671
- }
672
-
673
- if (lineNumbers.size === 0) {
674
- const missingIndexedFields = this._getIndexedFieldsWithMissingData(criteria)
675
- if (missingIndexedFields.length > 0 && this._hasAnyRecords()) {
676
- // Try to rebuild index before falling back to streaming (only if allowIndexRebuild is true)
677
- if (this.database.opts.allowIndexRebuild) {
678
- if (this.opts.debugMode) {
679
- console.log(`⚠️ Indexed query returned no results because index data is missing for: ${missingIndexedFields.join(', ')}. Attempting index rebuild...`)
680
- }
681
- this.database._indexRebuildNeeded = true
682
- await this.database._rebuildIndexesIfNeeded()
683
-
684
- // Retry indexed query after rebuild
685
- const retryLineNumbers = this.indexManager.query(criteria, options)
686
- if (retryLineNumbers.size > 0) {
687
- if (this.opts.debugMode) {
688
- console.log(`✅ Index rebuild successful, using indexed strategy.`)
689
- }
690
- // Update lineNumbers to use rebuilt index results
691
- lineNumbers.clear()
692
- for (const lineNumber of retryLineNumbers) {
693
- lineNumbers.add(lineNumber)
694
- }
695
- } else {
696
- // Still no results after rebuild, fall back to streaming
697
- if (this.opts.debugMode) {
698
- console.log(`⚠️ Index rebuild did not help, falling back to streaming.`)
699
- }
700
- return this.findWithStreaming(criteria, { ...options, forceFullScan: true })
701
- }
702
- } else {
703
- // allowIndexRebuild is false, fall back to streaming
704
- if (this.opts.debugMode) {
705
- console.log(`⚠️ Indexed query returned no results because index data is missing for: ${missingIndexedFields.join(', ')}. Falling back to streaming.`)
706
- }
707
- return this.findWithStreaming(criteria, { ...options, forceFullScan: true })
708
- }
709
- }
710
- }
711
-
712
- // Read specific records using the line numbers
713
- if (lineNumbers.size > 0) {
714
- const lineNumbersArray = Array.from(lineNumbers)
715
- const persistedCount = Array.isArray(this.database.offsets) ? this.database.offsets.length : 0
716
-
717
- // Separate lineNumbers into file records and writeBuffer records
718
- const fileLineNumbers = []
719
- const writeBufferLineNumbers = []
720
-
721
- for (const lineNumber of lineNumbersArray) {
722
- if (lineNumber >= persistedCount) {
723
- // This lineNumber points to writeBuffer
724
- writeBufferLineNumbers.push(lineNumber)
725
- } else {
726
- // This lineNumber points to file
727
- fileLineNumbers.push(lineNumber)
728
- }
729
- }
730
-
731
- // Read records from file
732
- if (fileLineNumbers.length > 0) {
733
- const ranges = this.database.getRanges(fileLineNumbers)
734
- if (ranges.length > 0) {
735
- const groupedRanges = await this.database.fileHandler.groupedRanges(ranges)
736
-
737
- const fd = await fs.open(this.database.fileHandler.file, 'r')
738
-
739
- try {
740
- for (const groupedRange of groupedRanges) {
741
- for await (const row of this.database.fileHandler.readGroupedRange(groupedRange, fd)) {
742
- try {
743
- const record = this.database.serializer.deserialize(row.line)
744
- const recordWithTerms = options.restoreTerms !== false ?
745
- this.database.restoreTermIdsAfterDeserialization(record) :
746
- record
747
- recordWithTerms._ = row._
748
- results.push(recordWithTerms)
749
- if (limit && results.length >= limit) break
750
- } catch (error) {
751
- // Skip invalid lines
752
- }
753
- }
754
- if (limit && results.length >= limit) break
755
- }
756
- } finally {
757
- await fd.close()
758
- }
759
- }
760
- }
761
-
762
- // Read records from writeBuffer
763
- if (writeBufferLineNumbers.length > 0 && this.database.writeBuffer) {
764
- for (const lineNumber of writeBufferLineNumbers) {
765
- if (limit && results.length >= limit) break
766
-
767
- const writeBufferIndex = lineNumber - persistedCount
768
- if (writeBufferIndex >= 0 && writeBufferIndex < this.database.writeBuffer.length) {
769
- const record = this.database.writeBuffer[writeBufferIndex]
770
- if (record) {
771
- const recordWithTerms = options.restoreTerms !== false ?
772
- this.database.restoreTermIdsAfterDeserialization(record) :
773
- record
774
- recordWithTerms._ = lineNumber
775
- results.push(recordWithTerms)
776
- }
777
- }
778
- }
779
- }
780
- }
781
-
782
- if (options.orderBy) {
783
- const [field, direction = 'asc'] = options.orderBy.split(' ')
784
- results.sort((a, b) => {
785
- if (a[field] > b[field]) return direction === 'asc' ? 1 : -1
786
- if (a[field] < b[field]) return direction === 'asc' ? -1 : 1
787
- return 0;
788
- })
789
- }
790
- return results
791
- }
792
-
793
- /**
794
- * Check if a record matches criteria
795
- * @param {Object} record - Record to check
796
- * @param {Object} criteria - Filter criteria
797
- * @param {Object} options - Query options (for caseInsensitive, etc.)
798
- * @returns {boolean} - True if matches
799
- */
800
- matchesCriteria(record, criteria, options = {}) {
801
-
802
- if (!criteria || Object.keys(criteria).length === 0) {
803
- return true;
804
- }
805
-
806
- // Handle explicit logical operators at the top level
807
- if (criteria.$or && Array.isArray(criteria.$or)) {
808
- let orMatches = false;
809
- for (const orCondition of criteria.$or) {
810
- if (this.matchesCriteria(record, orCondition, options)) {
811
- orMatches = true;
812
- break;
813
- }
814
- }
815
-
816
- // If $or doesn't match, return false immediately
817
- if (!orMatches) {
818
- return false;
819
- }
820
-
821
- // If $or matches, continue to check other conditions if they exist
822
- // Don't return true yet - we need to check other conditions
823
- } else if (criteria.$and && Array.isArray(criteria.$and)) {
824
- for (const andCondition of criteria.$and) {
825
- if (!this.matchesCriteria(record, andCondition, options)) {
826
- return false;
827
- }
828
- }
829
- // $and matches, continue to check other conditions if they exist
830
- }
831
-
832
- // Handle individual field conditions and $not operator
833
- for (const [field, condition] of Object.entries(criteria)) {
834
- // Skip logical operators that are handled above
835
- if (field.startsWith('$') && field !== '$not') {
836
- continue;
837
- }
838
-
839
- if (field === '$not') {
840
- // Handle $not operator - it should negate the result of its condition
841
- if (typeof condition === 'object' && condition !== null) {
842
- // Empty $not condition should not exclude anything
843
- if (Object.keys(condition).length === 0) {
844
- continue; // Don't exclude anything
845
- }
846
-
847
- // Check if the $not condition matches - if it does, this record should be excluded
848
- if (this.matchesCriteria(record, condition, options)) {
849
- return false; // Exclude this record
850
- }
851
- }
852
- } else {
853
- // Handle regular field conditions
854
- if (!this.matchesFieldCondition(record, field, condition, options)) {
855
- return false;
856
- }
857
- }
858
- }
859
-
860
- // If we have $or conditions and they matched, return true
861
- if (criteria.$or && Array.isArray(criteria.$or)) {
862
- return true;
863
- }
864
-
865
- // For other cases (no $or, or $and, or just field conditions), return true if we got this far
866
- return true;
867
- }
868
-
869
- /**
870
- * Check if a field matches a condition
871
- * @param {Object} record - Record to check
872
- * @param {string} field - Field name
873
- * @param {*} condition - Condition to match
874
- * @param {Object} options - Query options
875
- * @returns {boolean} - True if matches
876
- */
877
- matchesFieldCondition(record, field, condition, options = {}) {
878
- const value = record[field];
879
-
880
- // Debug logging for all field conditions
881
- if (this.database.opts.debugMode) {
882
- console.log(`🔍 Checking field '${field}':`, { value, condition, record: record.name || record.id });
883
- }
884
-
885
- // Debug logging for term mapping fields
886
- if (this.database.opts.termMapping && Object.keys(this.database.opts.indexes || {}).includes(field)) {
887
- if (this.database.opts.debugMode) {
888
- console.log(`🔍 Checking term mapping field '${field}':`, { value, condition, record: record.name || record.id });
889
- }
890
- }
891
-
892
- // Handle null/undefined values
893
- if (value === null || value === undefined) {
894
- return condition === null || condition === undefined;
895
- }
896
-
897
- // Handle regex conditions (MUST come before object check since RegExp is an object)
898
- if (condition instanceof RegExp) {
899
- // For array fields, test regex against each element
900
- if (Array.isArray(value)) {
901
- return value.some(element => condition.test(String(element)));
902
- }
903
- // For non-array fields, test regex against the value directly
904
- return condition.test(String(value));
905
- }
906
-
907
- // Handle array conditions
908
- if (Array.isArray(condition)) {
909
- // For array fields, check if any element in the field matches any element in the condition
910
- if (Array.isArray(value)) {
911
- return condition.some(condVal => value.includes(condVal));
912
- }
913
- // For non-array fields, check if value is in condition
914
- return condition.includes(value);
915
- }
916
-
917
- // Handle object conditions (operators)
918
- if (typeof condition === 'object' && !Array.isArray(condition)) {
919
- for (const [operator, operatorValue] of Object.entries(condition)) {
920
- const normalizedOperator = normalizeOperator(operator);
921
- if (!this.matchesOperator(value, normalizedOperator, operatorValue, options)) {
922
- return false;
923
- }
924
- }
925
- return true;
926
- }
927
-
928
- // Handle case-insensitive string comparison
929
- if (options.caseInsensitive && typeof value === 'string' && typeof condition === 'string') {
930
- return value.toLowerCase() === condition.toLowerCase();
931
- }
932
-
933
- // Handle direct array field search (e.g., { nameTerms: 'channel' })
934
- if (Array.isArray(value) && typeof condition === 'string') {
935
- return value.includes(condition);
936
- }
937
-
938
- // Simple equality
939
- return value === condition;
940
- }
941
-
942
- /**
943
- * Check if a value matches an operator condition
944
- * @param {*} value - Value to check
945
- * @param {string} operator - Operator
946
- * @param {*} operatorValue - Operator value
947
- * @param {Object} options - Query options
948
- * @returns {boolean} - True if matches
949
- */
950
- matchesOperator(value, operator, operatorValue, options = {}) {
951
- switch (operator) {
952
- case '$eq':
953
- return value === operatorValue;
954
- case '$gt':
955
- return value > operatorValue;
956
- case '$gte':
957
- return value >= operatorValue;
958
- case '$lt':
959
- return value < operatorValue;
960
- case '$lte':
961
- return value <= operatorValue;
962
- case '$ne':
963
- return value !== operatorValue;
964
- case '$not':
965
- // $not operator should be handled at the criteria level, not field level
966
- // This is a fallback for backward compatibility
967
- return value !== operatorValue;
968
- case '$in':
969
- if (Array.isArray(value)) {
970
- // For array fields, check if any element in the array matches any value in operatorValue
971
- return Array.isArray(operatorValue) && operatorValue.some(opVal => value.includes(opVal));
972
- } else {
973
- // For non-array fields, check if value is in operatorValue
974
- return Array.isArray(operatorValue) && operatorValue.includes(value);
975
- }
976
- case '$nin':
977
- if (Array.isArray(value)) {
978
- // For array fields, check if NO elements in the array match any value in operatorValue
979
- return Array.isArray(operatorValue) && !operatorValue.some(opVal => value.includes(opVal));
980
- } else {
981
- // For non-array fields, check if value is not in operatorValue
982
- return Array.isArray(operatorValue) && !operatorValue.includes(value);
983
- }
984
- case '$regex':
985
- const regex = new RegExp(operatorValue, options.caseInsensitive ? 'i' : '');
986
- // For array fields, test regex against each element
987
- if (Array.isArray(value)) {
988
- return value.some(element => regex.test(String(element)));
989
- }
990
- // For non-array fields, test regex against the value directly
991
- return regex.test(String(value));
992
- case '$contains':
993
- if (Array.isArray(value)) {
994
- return value.includes(operatorValue);
995
- }
996
- return String(value).includes(String(operatorValue));
997
- case '$all':
998
- if (!Array.isArray(value) || !Array.isArray(operatorValue)) {
999
- return false;
1000
- }
1001
- return operatorValue.every(item => value.includes(item));
1002
- case '$exists':
1003
- return operatorValue ? (value !== undefined && value !== null) : (value === undefined || value === null);
1004
- case '$size':
1005
- if (Array.isArray(value)) {
1006
- return value.length === operatorValue;
1007
- }
1008
- return false;
1009
- default:
1010
- return false;
1011
- }
1012
- }
1013
-
1014
-
1015
- /**
1016
- * Preprocess query to handle array field syntax automatically
1017
- * @param {Object} criteria - Query criteria
1018
- * @returns {Object} - Processed criteria
1019
- */
1020
- preprocessQuery(criteria) {
1021
- if (!criteria || typeof criteria !== 'object') {
1022
- return criteria
1023
- }
1024
-
1025
- const processed = {}
1026
-
1027
- for (const [field, value] of Object.entries(criteria)) {
1028
- // Check if this is a term mapping field
1029
- const isTermMappingField = this.database.opts.termMapping &&
1030
- this.database.termManager &&
1031
- this.database.termManager.termMappingFields &&
1032
- this.database.termManager.termMappingFields.includes(field)
1033
-
1034
- if (isTermMappingField) {
1035
- // Handle term mapping field queries
1036
- if (typeof value === 'string') {
1037
- // Convert term to $in query for term mapping fields
1038
- processed[field] = { $in: [value] }
1039
- } else if (Array.isArray(value)) {
1040
- // Convert array to $in query
1041
- processed[field] = { $in: value }
1042
- } else if (value && typeof value === 'object') {
1043
- // Handle special query operators for term mapping
1044
- if (value.$in) {
1045
- processed[field] = { $in: value.$in }
1046
- } else if (value.$all) {
1047
- processed[field] = { $all: value.$all }
1048
- } else {
1049
- processed[field] = value
1050
- }
1051
- } else {
1052
- // Invalid value for term mapping field
1053
- throw new Error(`Invalid query for array field '${field}'. Use { $in: [value] } syntax or direct value.`)
1054
- }
1055
-
1056
- if (this.database.opts.debugMode) {
1057
- console.log(`🔍 Processed term mapping query for field '${field}':`, processed[field])
1058
- }
1059
- } else {
1060
- // Check if this field is defined as an array in the schema
1061
- const indexes = this.opts.indexes || {}
1062
- const fieldConfig = indexes[field]
1063
- const isArrayField = fieldConfig &&
1064
- (Array.isArray(fieldConfig) && fieldConfig.includes('array') ||
1065
- fieldConfig === 'array:string' ||
1066
- fieldConfig === 'array:number' ||
1067
- fieldConfig === 'array:boolean')
1068
-
1069
- if (isArrayField) {
1070
- // Handle array field queries
1071
- if (typeof value === 'string' || typeof value === 'number') {
1072
- // Convert direct value to $in query for array fields
1073
- processed[field] = { $in: [value] }
1074
- } else if (Array.isArray(value)) {
1075
- // Convert array to $in query
1076
- processed[field] = { $in: value }
1077
- } else if (value && typeof value === 'object') {
1078
- // Already properly formatted query object
1079
- processed[field] = value
1080
- } else {
1081
- // Invalid value for array field
1082
- throw new Error(`Invalid query for array field '${field}'. Use { $in: [value] } syntax or direct value.`)
1083
- }
1084
- } else {
1085
- // Non-array field, keep as is
1086
- processed[field] = value
1087
- }
1088
- }
1089
- }
1090
-
1091
- return processed
1092
- }
1093
-
1094
- /**
1095
- * Determine which query strategy to use
1096
- * @param {Object} criteria - Query criteria
1097
- * @param {Object} options - Query options
1098
- * @returns {string} - 'streaming' or 'indexed'
1099
- */
1100
- shouldUseStreaming(criteria, options = {}) {
1101
- const { limit } = options; // No default limit
1102
- const totalRecords = this.database.length || 0;
1103
-
1104
- // Strategy 1: Always streaming for queries without criteria
1105
- if (!criteria || Object.keys(criteria).length === 0) {
1106
- if (this.opts.debugMode) {
1107
- console.log('📊 QueryStrategy: STREAMING - No criteria provided');
1108
- }
1109
- return 'streaming';
1110
- }
1111
-
1112
- // Strategy 2: Check if all fields are indexed and support the operators used
1113
- // First, check if $not is present at root level - if so, we need to use streaming for proper $not handling
1114
- if (criteria.$not && !this.opts.termMapping) {
1115
- if (this.opts.debugMode) {
1116
- console.log('📊 QueryStrategy: STREAMING - $not operator requires streaming mode');
1117
- }
1118
- return 'streaming';
1119
- }
1120
-
1121
- // OPTIMIZATION: For term mapping, we can process $not using indices
1122
- if (criteria.$not && this.opts.termMapping) {
1123
- // Check if all $not fields are indexed
1124
- const notFields = Object.keys(criteria.$not)
1125
- const allNotFieldsIndexed = notFields.every(field =>
1126
- this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]
1127
- )
1128
-
1129
- if (allNotFieldsIndexed) {
1130
- if (this.opts.debugMode) {
1131
- console.log('📊 QueryStrategy: INDEXED - $not with term mapping can use indexed strategy');
1132
- }
1133
- // Continue to check other conditions instead of forcing streaming
1134
- } else {
1135
- if (this.opts.debugMode) {
1136
- console.log('📊 QueryStrategy: STREAMING - $not fields not all indexed');
1137
- }
1138
- return 'streaming';
1139
- }
1140
- }
1141
-
1142
- // Handle $and queries - check if all conditions in $and are indexable
1143
- if (criteria.$and && Array.isArray(criteria.$and)) {
1144
- const allAndConditionsIndexed = criteria.$and.every(andCondition => {
1145
- // Handle $not conditions within $and
1146
- if (andCondition.$not) {
1147
- const notFields = Object.keys(andCondition.$not);
1148
- return notFields.every(field => {
1149
- if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
1150
- return false;
1151
- }
1152
- // For term mapping, $not can be processed with indices
1153
- return this.opts.termMapping && Object.keys(this.opts.indexes || {}).includes(field);
1154
- });
1155
- }
1156
-
1157
- // Handle regular field conditions
1158
- return Object.keys(andCondition).every(field => {
1159
- if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
1160
- return false;
1161
- }
1162
-
1163
- const condition = andCondition[field];
1164
-
1165
- // RegExp cannot be efficiently queried using indices - must use streaming
1166
- if (condition instanceof RegExp) {
1167
- return false;
1168
- }
1169
-
1170
- if (typeof condition === 'object' && !Array.isArray(condition)) {
1171
- const operators = Object.keys(condition).map(op => normalizeOperator(op));
1172
- const indexType = this.indexManager?.opts?.indexes?.[field]
1173
- const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number'
1174
- const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size']
1175
- const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size']
1176
-
1177
- // Check if this is a term mapping field (array:string or string fields with term mapping)
1178
- const isTermMappingField = this.database.termManager &&
1179
- this.database.termManager.termMappingFields &&
1180
- this.database.termManager.termMappingFields.includes(field)
1181
-
1182
- if (isTermMappingField) {
1183
- const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size']
1184
- return operators.every(op => !termMappingDisallowed.includes(op));
1185
- } else {
1186
- const disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault
1187
- return operators.every(op => !disallowed.includes(op));
1188
- }
1189
- }
1190
- return true;
1191
- });
1192
- });
1193
-
1194
- if (!allAndConditionsIndexed) {
1195
- if (this.opts.debugMode) {
1196
- console.log('📊 QueryStrategy: STREAMING - Some $and conditions not indexed or operators not supported');
1197
- }
1198
- return 'streaming';
1199
- }
1200
- }
1201
-
1202
- const allFieldsIndexed = Object.keys(criteria).every(field => {
1203
- // Skip $and and $not as they're handled separately above
1204
- if (field === '$and' || field === '$not') return true;
1205
-
1206
- if (!this.opts.indexes || !this.opts.indexes[field]) {
1207
- if (this.opts.debugMode) {
1208
- console.log(`🔍 Field '${field}' not indexed. Available indexes:`, Object.keys(this.opts.indexes || {}))
1209
- }
1210
- return false;
1211
- }
1212
-
1213
- // Check if the field uses operators that are supported by IndexManager
1214
- const condition = criteria[field];
1215
-
1216
- // RegExp cannot be efficiently queried using indices - must use streaming
1217
- if (condition instanceof RegExp) {
1218
- if (this.opts.debugMode) {
1219
- console.log(`🔍 Field '${field}' uses RegExp - requires streaming strategy`)
1220
- }
1221
- return false;
1222
- }
1223
-
1224
- if (typeof condition === 'object' && !Array.isArray(condition) && condition !== null) {
1225
- const operators = Object.keys(condition).map(op => normalizeOperator(op));
1226
- if (this.opts.debugMode) {
1227
- console.log(`🔍 Field '${field}' has operators:`, operators)
1228
- }
1229
-
1230
- const indexType = this.indexManager?.opts?.indexes?.[field]
1231
- const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number'
1232
- const isArrayStringIndex = indexType === 'array:string'
1233
- const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size']
1234
- const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size']
1235
-
1236
- // Check if this is a term mapping field (array:string or string fields with term mapping)
1237
- const isTermMappingField = this.database.termManager &&
1238
- this.database.termManager.termMappingFields &&
1239
- this.database.termManager.termMappingFields.includes(field)
1240
-
1241
- // With term mapping enabled on THIS FIELD, we can support complex operators via partial reads
1242
- // Also support $all for array:string indexed fields (IndexManager.query supports it via Set intersection)
1243
- if (isTermMappingField) {
1244
- const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size']
1245
- return operators.every(op => !termMappingDisallowed.includes(op));
1246
- } else {
1247
- let disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault
1248
- // Remove $all from disallowed if field is array:string (IndexManager supports $all via Set intersection)
1249
- if (isArrayStringIndex) {
1250
- disallowed = disallowed.filter(op => op !== '$all')
1251
- }
1252
- return operators.every(op => !disallowed.includes(op));
1253
- }
1254
- }
1255
- return true;
1256
- });
1257
-
1258
- if (!allFieldsIndexed) {
1259
- if (this.opts.debugMode) {
1260
- console.log('📊 QueryStrategy: STREAMING - Some fields not indexed or operators not supported');
1261
- }
1262
- return 'streaming';
1263
- }
1264
-
1265
- // OPTIMIZATION 2: Hybrid strategy - use pre-filtered streaming when index is empty
1266
- const indexData = this.indexManager.index.data || {};
1267
- const hasIndexData = Object.keys(indexData).length > 0;
1268
- if (!hasIndexData) {
1269
- // Check if we can use pre-filtered streaming with term mapping
1270
- if (this.opts.termMapping && this._canUsePreFilteredStreaming(criteria)) {
1271
- if (this.opts.debugMode) {
1272
- console.log('📊 QueryStrategy: HYBRID - Using pre-filtered streaming with term mapping');
1273
- }
1274
- return 'streaming'; // Will use pre-filtered streaming in findWithStreaming
1275
- }
1276
-
1277
- if (this.opts.debugMode) {
1278
- console.log('📊 QueryStrategy: STREAMING - Index is empty and no pre-filtering available');
1279
- }
1280
- return 'streaming';
1281
- }
1282
-
1283
- // Strategy 3: Streaming if limit is very high (only if database has records)
1284
- if (totalRecords > 0 && limit > totalRecords * this.opts.streamingThreshold) {
1285
- if (this.opts.debugMode) {
1286
- console.log(`📊 QueryStrategy: STREAMING - High limit (${limit} > ${Math.round(totalRecords * this.opts.streamingThreshold)})`);
1287
- }
1288
- return 'streaming';
1289
- }
1290
-
1291
- // Strategy 4: Use indexed strategy when all fields are indexed and streamingThreshold is respected
1292
- if (this.opts.debugMode) {
1293
- console.log(`📊 QueryStrategy: INDEXED - All fields indexed, using indexed strategy`);
1294
- }
1295
- return 'indexed';
1296
- }
1297
-
1298
- /**
1299
- * Estimate number of results for a query
1300
- * @param {Object} criteria - Query criteria
1301
- * @param {number} totalRecords - Total records in database
1302
- * @returns {number} - Estimated results
1303
- */
1304
- estimateQueryResults(criteria, totalRecords) {
1305
- // If database is empty, return 0
1306
- if (totalRecords === 0) {
1307
- if (this.opts.debugMode) {
1308
- console.log(`📊 Estimation: Database empty 0 results`);
1309
- }
1310
- return 0;
1311
- }
1312
-
1313
- let minResults = Infinity;
1314
-
1315
- for (const [field, condition] of Object.entries(criteria)) {
1316
- // Check if field is indexed
1317
- if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
1318
- // Non-indexed field - assume it could match any record
1319
- if (this.opts.debugMode) {
1320
- console.log(`📊 Estimation: ${field} = non-indexed → ~${totalRecords} results`);
1321
- }
1322
- return totalRecords;
1323
- }
1324
-
1325
- const fieldIndex = this.indexManager.index.data[field];
1326
-
1327
- if (!fieldIndex) {
1328
- // Non-indexed field - assume it could match any record
1329
- if (this.opts.debugMode) {
1330
- console.log(`📊 Estimation: ${field} = non-indexed → ~${totalRecords} results`);
1331
- }
1332
- return totalRecords;
1333
- }
1334
-
1335
- let fieldEstimate = 0;
1336
-
1337
- if (typeof condition === 'object' && !Array.isArray(condition)) {
1338
- // Handle different types of operators
1339
- for (const [operator, value] of Object.entries(condition)) {
1340
- if (operator === '$all') {
1341
- // Special handling for $all operator
1342
- fieldEstimate = this.estimateAllOperator(fieldIndex, value);
1343
- } else if (['$gt', '$gte', '$lt', '$lte', '$in', '$regex'].includes(operator)) {
1344
- // Numeric and other operators
1345
- fieldEstimate = this.estimateOperatorResults(fieldIndex, operator, value, totalRecords);
1346
- } else {
1347
- // Unknown operator, assume it could match any record
1348
- fieldEstimate = totalRecords;
1349
- }
1350
- }
1351
- } else {
1352
- // Simple equality
1353
- const recordIds = fieldIndex[condition];
1354
- fieldEstimate = recordIds ? recordIds.length : 0;
1355
- }
1356
-
1357
- if (this.opts.debugMode) {
1358
- console.log(`📊 Estimation: ${field} = ${fieldEstimate} results`);
1359
- }
1360
-
1361
- minResults = Math.min(minResults, fieldEstimate);
1362
- }
1363
-
1364
- return minResults === Infinity ? 0 : minResults;
1365
- }
1366
-
1367
- /**
1368
- * Estimate results for $all operator
1369
- * @param {Object} fieldIndex - Field index
1370
- * @param {Array} values - Values to match
1371
- * @returns {number} - Estimated results
1372
- */
1373
- estimateAllOperator(fieldIndex, values) {
1374
- if (!Array.isArray(values) || values.length === 0) {
1375
- return 0;
1376
- }
1377
-
1378
- let minCount = Infinity;
1379
- for (const value of values) {
1380
- const recordIds = fieldIndex[value];
1381
- const count = recordIds ? recordIds.length : 0;
1382
- minCount = Math.min(minCount, count);
1383
- }
1384
-
1385
- return minCount === Infinity ? 0 : minCount;
1386
- }
1387
-
1388
- /**
1389
- * Estimate results for operators
1390
- * @param {Object} fieldIndex - Field index
1391
- * @param {string} operator - Operator
1392
- * @param {*} value - Value
1393
- * @param {number} totalRecords - Total records
1394
- * @returns {number} - Estimated results
1395
- */
1396
- estimateOperatorResults(fieldIndex, operator, value, totalRecords) {
1397
- // This is a simplified estimation - in practice, you might want more sophisticated logic
1398
- switch (operator) {
1399
- case '$in':
1400
- if (Array.isArray(value)) {
1401
- let total = 0;
1402
- for (const v of value) {
1403
- const recordIds = fieldIndex[v];
1404
- if (recordIds) total += recordIds.length;
1405
- }
1406
- return total;
1407
- }
1408
- break;
1409
- case '$gt':
1410
- case '$gte':
1411
- case '$lt':
1412
- case '$lte':
1413
- // For range queries, estimate based on data distribution
1414
- // This is a simplified approach - real implementation would be more sophisticated
1415
- return Math.floor(totalRecords * 0.1); // Assume 10% of records match
1416
- case '$regex':
1417
- // Regex is hard to estimate without scanning
1418
- return Math.floor(totalRecords * 0.05); // Assume 5% of records match
1419
- }
1420
- return 0;
1421
- }
1422
-
1423
- /**
1424
- * Validate strict query mode
1425
- * @param {Object} criteria - Query criteria
1426
- * @param {Object} options - Query options
1427
- */
1428
- validateStrictQuery(criteria, options = {}) {
1429
- // Allow bypassing strict mode validation with allowNonIndexed option
1430
- if (options.allowNonIndexed === true) {
1431
- return; // Skip validation for this query
1432
- }
1433
-
1434
- if (!criteria || Object.keys(criteria).length === 0) {
1435
- return; // Empty criteria are always allowed
1436
- }
1437
-
1438
- // Handle logical operators at the top level
1439
- if (criteria.$not) {
1440
- this.validateStrictQuery(criteria.$not, options);
1441
- return;
1442
- }
1443
-
1444
- if (criteria.$or && Array.isArray(criteria.$or)) {
1445
- for (const orCondition of criteria.$or) {
1446
- this.validateStrictQuery(orCondition, options);
1447
- }
1448
- return;
1449
- }
1450
-
1451
- if (criteria.$and && Array.isArray(criteria.$and)) {
1452
- for (const andCondition of criteria.$and) {
1453
- this.validateStrictQuery(andCondition, options);
1454
- }
1455
- return;
1456
- }
1457
-
1458
- // Get available indexed fields
1459
- const indexedFields = Object.keys(this.indexManager.opts.indexes || {});
1460
- const availableFields = indexedFields.length > 0 ? indexedFields.join(', ') : 'none';
1461
-
1462
- // Check each field
1463
- const nonIndexedFields = [];
1464
- for (const [field, condition] of Object.entries(criteria)) {
1465
- // Skip logical operators
1466
- if (field.startsWith('$')) {
1467
- continue;
1468
- }
1469
-
1470
- // Check if field is indexed
1471
- if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
1472
- nonIndexedFields.push(field);
1473
- }
1474
-
1475
- // Check if condition uses supported operators
1476
- if (typeof condition === 'object' && !Array.isArray(condition)) {
1477
- const operators = Object.keys(condition);
1478
- for (const op of operators) {
1479
- if (!['$in', '$nin', '$contains', '$all', '$exists', '>', '>=', '<', '<=', '!=', 'contains', 'regex'].includes(op)) {
1480
- throw new Error(`Operator '${op}' is not supported in strict mode for field '${field}'.`);
1481
- }
1482
- }
1483
- }
1484
- }
1485
-
1486
- // Generate appropriate error message
1487
- if (nonIndexedFields.length > 0) {
1488
- if (nonIndexedFields.length === 1) {
1489
- throw new Error(`Strict indexed mode: Field '${nonIndexedFields[0]}' is not indexed. Available indexed fields: ${availableFields}`);
1490
- } else {
1491
- throw new Error(`Strict indexed mode: Fields '${nonIndexedFields.join("', '")}' are not indexed. Available indexed fields: ${availableFields}`);
1492
- }
1493
- }
1494
- }
1495
-
1496
- /**
1497
- * Update average time for performance tracking
1498
- * @param {string} type - Type of operation ('streaming' or 'indexed')
1499
- * @param {number} time - Time taken
1500
- */
1501
- updateAverageTime(type, time) {
1502
- if (!this.usageStats[`${type}AverageTime`]) {
1503
- this.usageStats[`${type}AverageTime`] = 0;
1504
- }
1505
-
1506
- const currentAverage = this.usageStats[`${type}AverageTime`];
1507
- const count = this.usageStats[`${type}Queries`] || 1;
1508
-
1509
- // Calculate running average
1510
- this.usageStats[`${type}AverageTime`] = (currentAverage * (count - 1) + time) / count;
1511
- }
1512
-
1513
- /**
1514
- * OPTIMIZATION 2: Check if we can use pre-filtered streaming with term mapping
1515
- * @param {Object} criteria - Query criteria
1516
- * @returns {boolean} - True if pre-filtered streaming can be used
1517
- */
1518
- _canUsePreFilteredStreaming(criteria) {
1519
- if (!criteria || typeof criteria !== 'object') {
1520
- return false;
1521
- }
1522
-
1523
- // Check if we have term mapping fields in the query
1524
- const termMappingFields = Object.keys(this.opts.indexes || {});
1525
- const queryFields = Object.keys(criteria).filter(field => !field.startsWith('$'));
1526
-
1527
- // Check if any query field is a term mapping field
1528
- const hasTermMappingFields = queryFields.some(field => termMappingFields.includes(field));
1529
-
1530
- if (!hasTermMappingFields) {
1531
- return false;
1532
- }
1533
-
1534
- // Check if the query is simple enough for pre-filtering
1535
- // Simple equality queries on term mapping fields work well with pre-filtering
1536
- for (const [field, condition] of Object.entries(criteria)) {
1537
- if (field.startsWith('$')) continue;
1538
-
1539
- if (termMappingFields.includes(field)) {
1540
- // For term mapping fields, simple equality or $in queries work well
1541
- if (typeof condition === 'string' ||
1542
- (typeof condition === 'object' && condition !== null && condition.$in && Array.isArray(condition.$in))) {
1543
- return true;
1544
- }
1545
- }
1546
- }
1547
-
1548
- return false;
1549
- }
1550
-
1551
- // Simplified term mapping - handled in TermManager
1552
- }
1
+ import { normalizeOperator } from '../utils/operatorNormalizer.mjs'
2
+ import { promises as fs } from 'fs'
3
+
4
+ /**
5
+ * QueryManager - Handles all query operations and strategies
6
+ *
7
+ * Responsibilities:
8
+ * - find(), findOne(), count(), query()
9
+ * - findWithStreaming(), findWithIndexed()
10
+ * - matchesCriteria(), extractQueryFields()
11
+ * - Query strategies (INDEXED vs STREAMING)
12
+ * - Result estimation
13
+ */
14
+
15
+ export class QueryManager {
16
+ constructor(database) {
17
+ this.database = database
18
+ this.opts = database.opts
19
+ this.indexManager = database.indexManager
20
+ this.fileHandler = database.fileHandler
21
+ this.serializer = database.serializer
22
+ this.usageStats = database.usageStats || {
23
+ totalQueries: 0,
24
+ indexedQueries: 0,
25
+ streamingQueries: 0,
26
+ indexedAverageTime: 0,
27
+ streamingAverageTime: 0
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Main find method with strategy selection
33
+ * @param {Object} criteria - Query criteria
34
+ * @param {Object} options - Query options
35
+ * @returns {Promise<Array>} - Query results
36
+ */
37
+ async find(criteria, options = {}) {
38
+ if (this.database.destroyed) throw new Error('Database is destroyed')
39
+ if (!this.database.initialized) await this.database.init()
40
+
41
+ // Rebuild indexes if needed (when index was corrupted/missing)
42
+ await this.database._rebuildIndexesIfNeeded()
43
+
44
+ // Manual save is now the responsibility of the application
45
+
46
+ // Preprocess query to handle array field syntax automatically
47
+ const processedCriteria = this.preprocessQuery(criteria)
48
+
49
+ const finalCriteria = processedCriteria
50
+
51
+ // Validate strict indexed mode before processing
52
+ if (this.opts.indexedQueryMode === 'strict') {
53
+ this.validateStrictQuery(finalCriteria, options);
54
+ }
55
+
56
+ const startTime = Date.now();
57
+ this.usageStats.totalQueries++;
58
+
59
+ try {
60
+ // Decide which strategy to use
61
+ const strategy = this.shouldUseStreaming(finalCriteria, options);
62
+
63
+ let results = [];
64
+
65
+ if (strategy === 'streaming') {
66
+ results = await this.findWithStreaming(finalCriteria, options);
67
+ this.usageStats.streamingQueries++;
68
+ this.updateAverageTime('streaming', Date.now() - startTime);
69
+ } else {
70
+ results = await this.findWithIndexed(finalCriteria, options);
71
+ this.usageStats.indexedQueries++;
72
+ this.updateAverageTime('indexed', Date.now() - startTime);
73
+ }
74
+
75
+ if (this.opts.debugMode) {
76
+ const time = Date.now() - startTime;
77
+ console.log(`⏱️ Query completed in ${time}ms using ${strategy} strategy`);
78
+ console.log(`📊 Results: ${results.length} records`);
79
+ console.log(`📊 Results type: ${typeof results}, isArray: ${Array.isArray(results)}`);
80
+ }
81
+
82
+ return results;
83
+
84
+ } catch (error) {
85
+ if (this.opts.debugMode) {
86
+ console.error('❌ Query failed:', error);
87
+ }
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Find one record
94
+ * @param {Object} criteria - Query criteria
95
+ * @param {Object} options - Query options
96
+ * @returns {Promise<Object|null>} - First matching record or null
97
+ */
98
+ async findOne(criteria, options = {}) {
99
+ if (this.database.destroyed) throw new Error('Database is destroyed')
100
+ if (!this.database.initialized) await this.database.init()
101
+ // Manual save is now the responsibility of the application
102
+
103
+ // Preprocess query to handle array field syntax automatically
104
+ const processedCriteria = this.preprocessQuery(criteria)
105
+
106
+ // Validate strict indexed mode before processing
107
+ if (this.opts.indexedQueryMode === 'strict') {
108
+ this.validateStrictQuery(processedCriteria, options);
109
+ }
110
+
111
+ const startTime = Date.now();
112
+ this.usageStats.totalQueries++;
113
+
114
+ try {
115
+ // Decide which strategy to use
116
+ const strategy = this.shouldUseStreaming(processedCriteria, options);
117
+
118
+ let results = [];
119
+
120
+ if (strategy === 'streaming') {
121
+ results = await this.findWithStreaming(processedCriteria, { ...options, limit: 1 });
122
+ this.usageStats.streamingQueries++;
123
+ this.updateAverageTime('streaming', Date.now() - startTime);
124
+ } else {
125
+ results = await this.findWithIndexed(processedCriteria, { ...options, limit: 1 });
126
+ this.usageStats.indexedQueries++;
127
+ this.updateAverageTime('indexed', Date.now() - startTime);
128
+ }
129
+
130
+ if (this.opts.debugMode) {
131
+ const time = Date.now() - startTime;
132
+ console.log(`⏱️ findOne completed in ${time}ms using ${strategy} strategy`);
133
+ console.log(`📊 Results: ${results.length} record(s)`);
134
+ }
135
+
136
+ // Return the first result or null if no results found
137
+ return results.length > 0 ? results[0] : null;
138
+
139
+ } catch (error) {
140
+ if (this.opts.debugMode) {
141
+ console.error('❌ findOne failed:', error);
142
+ }
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Count records matching criteria
149
+ * @param {Object} criteria - Query criteria
150
+ * @param {Object} options - Query options
151
+ * @returns {Promise<number>} - Count of matching records
152
+ */
153
+ async count(criteria, options = {}) {
154
+ if (this.database.destroyed) throw new Error('Database is destroyed')
155
+ if (!this.database.initialized) await this.database.init()
156
+
157
+ // Rebuild indexes if needed (when index was corrupted/missing)
158
+ await this.database._rebuildIndexesIfNeeded()
159
+
160
+ // Manual save is now the responsibility of the application
161
+
162
+ // Validate strict indexed mode before processing
163
+ if (this.opts.indexedQueryMode === 'strict') {
164
+ this.validateStrictQuery(criteria, options);
165
+ }
166
+
167
+ // Use the same strategy as find method
168
+ const strategy = this.shouldUseStreaming(criteria, options);
169
+
170
+ let count = 0;
171
+
172
+ if (strategy === 'streaming') {
173
+ // Use streaming approach for non-indexed fields or large result sets
174
+ const results = await this.findWithStreaming(criteria, options);
175
+ count = results.length;
176
+ } else {
177
+ try {
178
+ await this.database._ensureLazyIndexLoaded()
179
+ } catch (error) {
180
+ if (this.opts.debugMode) {
181
+ console.log('⚠️ _ensureLazyIndexLoaded failed in count, falling back to streaming', error.message || error)
182
+ }
183
+ const streamingResults = await this.findWithStreaming(criteria, { ...options, forceFullScan: true })
184
+ return streamingResults.length
185
+ }
186
+ // This avoids reading actual records from the file - much faster!
187
+ const lineNumbers = this.indexManager.query(criteria, options);
188
+
189
+ if (lineNumbers.size === 0) {
190
+ const missingIndexedFields = this._getIndexedFieldsWithMissingData(criteria)
191
+ if (missingIndexedFields.length > 0 && this._hasAnyRecords()) {
192
+ // Try to rebuild index before falling back to streaming (only if allowIndexRebuild is true)
193
+ if (this.database.opts.allowIndexRebuild) {
194
+ if (this.opts.debugMode) {
195
+ console.log(`⚠️ Indexed count returned 0 because index data is missing for: ${missingIndexedFields.join(', ')}. Attempting index rebuild...`);
196
+ }
197
+ this.database._indexRebuildNeeded = true
198
+ await this.database._rebuildIndexesIfNeeded()
199
+
200
+ // Retry indexed query after rebuild
201
+ const retryLineNumbers = this.indexManager.query(criteria, options)
202
+ if (retryLineNumbers.size > 0) {
203
+ if (this.opts.debugMode) {
204
+ console.log(`✅ Index rebuild successful, using indexed strategy.`);
205
+ }
206
+ count = retryLineNumbers.size
207
+ } else {
208
+ // Still no results after rebuild, fall back to streaming
209
+ if (this.opts.debugMode) {
210
+ console.log(`⚠️ Index rebuild did not help, falling back to streaming count.`);
211
+ }
212
+ const streamingResults = await this.findWithStreaming(criteria, { ...options, forceFullScan: true })
213
+ count = streamingResults.length
214
+ }
215
+ } else {
216
+ // allowIndexRebuild is false, fall back to streaming
217
+ if (this.opts.debugMode) {
218
+ console.log(`⚠️ Indexed count returned 0 because index data is missing for: ${missingIndexedFields.join(', ')}. Falling back to streaming count.`);
219
+ }
220
+ const streamingResults = await this.findWithStreaming(criteria, { ...options, forceFullScan: true })
221
+ count = streamingResults.length
222
+ }
223
+ } else {
224
+ count = 0
225
+ }
226
+ } else {
227
+ count = lineNumbers.size;
228
+ }
229
+ }
230
+
231
+ return count;
232
+ }
233
+
234
+ /**
235
+ * Compatibility method that redirects to find
236
+ * @param {Object} criteria - Query criteria
237
+ * @param {Object} options - Query options
238
+ * @returns {Promise<Array>} - Query results
239
+ */
240
+ async query(criteria, options = {}) {
241
+ return this.find(criteria, options)
242
+ }
243
+
244
+ /**
245
+ * Find using streaming strategy with pre-filtering optimization
246
+ * @param {Object} criteria - Query criteria
247
+ * @param {Object} options - Query options
248
+ * @returns {Promise<Array>} - Query results
249
+ */
250
+ async findWithStreaming(criteria, options = {}) {
251
+ const streamingOptions = { ...options }
252
+ const forceFullScan = streamingOptions.forceFullScan === true
253
+ delete streamingOptions.forceFullScan
254
+
255
+ if (this.opts.debugMode) {
256
+ if (forceFullScan) {
257
+ console.log('🌊 Using streaming strategy (forced full scan to bypass missing index data)');
258
+ } else {
259
+ console.log('🌊 Using streaming strategy');
260
+ }
261
+ }
262
+
263
+ if (!forceFullScan) {
264
+ // OPTIMIZATION: Try to use indices for pre-filtering when possible
265
+ const indexableFields = this._getIndexableFields(criteria);
266
+ if (indexableFields.length > 0) {
267
+ try {
268
+ await this.database._ensureLazyIndexLoaded()
269
+ } catch (error) {
270
+ if (this.opts.debugMode) {
271
+ console.log('⚠️ _ensureLazyIndexLoaded failed, falling back to full streaming', error.message || error)
272
+ }
273
+ return this._streamAllRecords(criteria, streamingOptions)
274
+ }
275
+
276
+ const usableIndexableFields = indexableFields.filter(field => this.indexManager.hasUsableIndexData(field));
277
+ if (usableIndexableFields.length !== indexableFields.length) {
278
+ if (this.opts.debugMode) {
279
+ console.log('🌊 Falling back to full streaming because some indexed fields lack usable index data');
280
+ }
281
+ return this._streamAllRecords(criteria, streamingOptions)
282
+ }
283
+
284
+ if (this.opts.debugMode) {
285
+ console.log(`🌊 Using pre-filtered streaming with ${indexableFields.length} indexable fields`);
286
+ }
287
+
288
+ // Use indices to pre-filter and reduce streaming scope
289
+ const preFilteredLines = this.indexManager.query(
290
+ this._extractIndexableCriteria(criteria),
291
+ streamingOptions
292
+ );
293
+
294
+ // Stream only the pre-filtered records
295
+ return this._streamPreFilteredRecords(preFilteredLines, criteria, streamingOptions);
296
+ }
297
+ }
298
+
299
+ // Fallback to full streaming
300
+ if (this.opts.debugMode) {
301
+ console.log('🌊 Using full streaming (no indexable fields found or forced)');
302
+ }
303
+
304
+ return this._streamAllRecords(criteria, streamingOptions);
305
+ }
306
+
307
+ /**
308
+ * Get indexable fields from criteria
309
+ * @param {Object} criteria - Query criteria
310
+ * @returns {Array} - Array of indexable field names
311
+ */
312
+ _getIndexableFields(criteria) {
313
+ const indexableFields = [];
314
+
315
+ if (!criteria || typeof criteria !== 'object') {
316
+ return indexableFields;
317
+ }
318
+
319
+ // Handle $and conditions
320
+ if (criteria.$and && Array.isArray(criteria.$and)) {
321
+ for (const andCondition of criteria.$and) {
322
+ indexableFields.push(...this._getIndexableFields(andCondition));
323
+ }
324
+ }
325
+
326
+ // Handle regular field conditions
327
+ for (const [field, condition] of Object.entries(criteria)) {
328
+ if (field.startsWith('$')) continue; // Skip logical operators
329
+
330
+ // RegExp conditions cannot be pre-filtered using indices
331
+ if (condition instanceof RegExp) {
332
+ continue;
333
+ }
334
+
335
+ if (this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]) {
336
+ indexableFields.push(field);
337
+ }
338
+ }
339
+
340
+ return [...new Set(indexableFields)]; // Remove duplicates
341
+ }
342
+
343
+ /**
344
+ * Extract indexable criteria for pre-filtering
345
+ * @param {Object} criteria - Full query criteria
346
+ * @returns {Object} - Criteria with only indexable fields
347
+ */
348
+ _extractIndexableCriteria(criteria) {
349
+ if (!criteria || typeof criteria !== 'object') {
350
+ return {};
351
+ }
352
+
353
+ const indexableCriteria = {};
354
+
355
+ // Handle $and conditions
356
+ if (criteria.$and && Array.isArray(criteria.$and)) {
357
+ const indexableAndConditions = criteria.$and
358
+ .map(andCondition => this._extractIndexableCriteria(andCondition))
359
+ .filter(condition => Object.keys(condition).length > 0);
360
+
361
+ if (indexableAndConditions.length > 0) {
362
+ indexableCriteria.$and = indexableAndConditions;
363
+ }
364
+ }
365
+
366
+ // Handle $not operator - include it if it can be processed by IndexManager
367
+ if (criteria.$not && typeof criteria.$not === 'object') {
368
+ // Check if $not condition contains only indexable fields
369
+ const notFields = Object.keys(criteria.$not);
370
+ const allNotFieldsIndexed = notFields.every(field =>
371
+ this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]
372
+ );
373
+
374
+ if (allNotFieldsIndexed && notFields.length > 0) {
375
+ // Extract indexable criteria from $not condition
376
+ const indexableNotCriteria = this._extractIndexableCriteria(criteria.$not);
377
+ if (Object.keys(indexableNotCriteria).length > 0) {
378
+ indexableCriteria.$not = indexableNotCriteria;
379
+ }
380
+ }
381
+ }
382
+
383
+ // Handle regular field conditions
384
+ for (const [field, condition] of Object.entries(criteria)) {
385
+ if (field.startsWith('$')) continue; // Skip logical operators (already handled above)
386
+
387
+ // RegExp conditions cannot be pre-filtered using indices
388
+ if (condition instanceof RegExp) {
389
+ continue;
390
+ }
391
+
392
+ if (this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]) {
393
+ indexableCriteria[field] = condition;
394
+ }
395
+ }
396
+
397
+ return indexableCriteria;
398
+ }
399
+
400
+ /**
401
+ * Determine whether the database currently has any records (persisted or pending)
402
+ * @returns {boolean}
403
+ */
404
+ _hasAnyRecords() {
405
+ if (!this.database) {
406
+ return false
407
+ }
408
+
409
+ if (Array.isArray(this.database.offsets) && this.database.offsets.length > 0) {
410
+ return true
411
+ }
412
+
413
+ if (Array.isArray(this.database.writeBuffer) && this.database.writeBuffer.length > 0) {
414
+ return true
415
+ }
416
+
417
+ if (typeof this.database.length === 'number' && this.database.length > 0) {
418
+ return true
419
+ }
420
+
421
+ return false
422
+ }
423
+
424
+ /**
425
+ * Extract all indexed fields referenced in the criteria
426
+ * @param {Object} criteria
427
+ * @param {Set<string>} accumulator
428
+ * @returns {Array<string>}
429
+ */
430
+ _extractIndexedFields(criteria, accumulator = new Set()) {
431
+ if (!criteria) {
432
+ return Array.from(accumulator)
433
+ }
434
+
435
+ if (Array.isArray(criteria)) {
436
+ for (const item of criteria) {
437
+ this._extractIndexedFields(item, accumulator)
438
+ }
439
+ return Array.from(accumulator)
440
+ }
441
+
442
+ if (typeof criteria !== 'object') {
443
+ return Array.from(accumulator)
444
+ }
445
+
446
+ for (const [key, value] of Object.entries(criteria)) {
447
+ if (key.startsWith('$')) {
448
+ this._extractIndexedFields(value, accumulator)
449
+ continue
450
+ }
451
+
452
+ accumulator.add(key)
453
+
454
+ if (Array.isArray(value)) {
455
+ for (const nested of value) {
456
+ this._extractIndexedFields(nested, accumulator)
457
+ }
458
+ }
459
+ }
460
+
461
+ return Array.from(accumulator)
462
+ }
463
+
464
+ /**
465
+ * Identify indexed fields present in criteria whose index data is missing
466
+ * @param {Object} criteria
467
+ * @returns {Array<string>}
468
+ */
469
+ _getIndexedFieldsWithMissingData(criteria) {
470
+ if (!this.indexManager || !criteria) {
471
+ return []
472
+ }
473
+
474
+ const indexedFields = this._extractIndexedFields(criteria)
475
+ const missing = []
476
+
477
+ for (const field of indexedFields) {
478
+ if (!this.indexManager.isFieldIndexed(field)) {
479
+ continue
480
+ }
481
+
482
+ if (!this.indexManager.hasUsableIndexData(field)) {
483
+ missing.push(field)
484
+ }
485
+ }
486
+
487
+ return missing
488
+ }
489
+
490
+ /**
491
+ * OPTIMIZATION 4: Stream pre-filtered records using line numbers from indices with partial index optimization
492
+ * @param {Set} preFilteredLines - Line numbers from index query
493
+ * @param {Object} criteria - Full query criteria
494
+ * @param {Object} options - Query options
495
+ * @returns {Promise<Array>} - Query results
496
+ */
497
+ async _streamPreFilteredRecords(preFilteredLines, criteria, options = {}) {
498
+ if (preFilteredLines.size === 0) {
499
+ return [];
500
+ }
501
+
502
+ const results = [];
503
+ const lineNumbers = Array.from(preFilteredLines);
504
+
505
+ // OPTIMIZATION 4: Sort line numbers for efficient file reading
506
+ lineNumbers.sort((a, b) => a - b);
507
+
508
+ // OPTIMIZATION 4: Use batch reading for better performance
509
+ const batchSize = Math.min(1000, lineNumbers.length); // Read in batches of 1000
510
+ const batches = [];
511
+
512
+ for (let i = 0; i < lineNumbers.length; i += batchSize) {
513
+ batches.push(lineNumbers.slice(i, i + batchSize));
514
+ }
515
+
516
+ const persistedCount = Array.isArray(this.database.offsets) ? this.database.offsets.length : 0
517
+ const fileLineNumbers = []
518
+ const writeBufferLineNumbers = []
519
+
520
+ for (const lineNumber of lineNumbers) {
521
+ if (lineNumber >= persistedCount) {
522
+ writeBufferLineNumbers.push(lineNumber)
523
+ } else {
524
+ fileLineNumbers.push(lineNumber)
525
+ }
526
+ }
527
+
528
+ if (fileLineNumbers.length > 0) {
529
+ const fileBatches = []
530
+ for (let i = 0; i < fileLineNumbers.length; i += batchSize) {
531
+ fileBatches.push(fileLineNumbers.slice(i, i + batchSize));
532
+ }
533
+
534
+ for (const batch of fileBatches) {
535
+ // OPTIMIZATION: Use ranges instead of reading entire file
536
+ const ranges = this.database.getRanges(batch);
537
+ const groupedRanges = await this.fileHandler.groupedRanges(ranges);
538
+
539
+ const fd = await fs.open(this.fileHandler.file, 'r');
540
+
541
+ try {
542
+ for (const groupedRange of groupedRanges) {
543
+ for await (const row of this.fileHandler.readGroupedRange(groupedRange, fd)) {
544
+ if (row.line && row.line.trim()) {
545
+ try {
546
+ const record = this.database.serializer.deserialize(row.line);
547
+
548
+ if (this._matchesCriteriaOptimized(record, criteria, options)) {
549
+ const recordWithTerms = options.restoreTerms !== false ?
550
+ this.database.restoreTermIdsAfterDeserialization(record) :
551
+ record
552
+ results.push(recordWithTerms);
553
+
554
+ if (options.limit && results.length >= options.limit) {
555
+ return this._applyOrdering(results, options);
556
+ }
557
+ }
558
+ } catch (error) {
559
+ continue;
560
+ }
561
+ }
562
+ }
563
+ }
564
+ } finally {
565
+ await fd.close();
566
+ }
567
+ }
568
+ }
569
+
570
+ if (writeBufferLineNumbers.length > 0) {
571
+ writeBufferLineNumbers.sort((a, b) => a - b)
572
+ for (const lineNumber of writeBufferLineNumbers) {
573
+ const writeBufferIndex = lineNumber - persistedCount
574
+ const record = this.database.writeBuffer[writeBufferIndex]
575
+ if (!record) continue
576
+
577
+ if (this._matchesCriteriaOptimized(record, criteria, options)) {
578
+ const recordWithTerms = options.restoreTerms !== false ?
579
+ this.database.restoreTermIdsAfterDeserialization(record) :
580
+ record
581
+ results.push(recordWithTerms)
582
+
583
+ if (options.limit && results.length >= options.limit) {
584
+ return this._applyOrdering(results, options);
585
+ }
586
+ }
587
+ }
588
+ }
589
+
590
+ return this._applyOrdering(results, options);
591
+ }
592
+
593
+ /**
594
+ * OPTIMIZATION 4: Optimized criteria matching for pre-filtered records
595
+ * @param {Object} record - Record to check
596
+ * @param {Object} criteria - Filter criteria
597
+ * @param {Object} options - Query options
598
+ * @returns {boolean} - True if matches
599
+ */
600
+ _matchesCriteriaOptimized(record, criteria, options = {}) {
601
+ if (!criteria || Object.keys(criteria).length === 0) {
602
+ return true;
603
+ }
604
+
605
+ // Handle $not operator at the top level
606
+ if (criteria.$not && typeof criteria.$not === 'object') {
607
+ // For $not conditions, we need to negate the result
608
+ // IMPORTANT: For $not conditions, we should NOT skip pre-filtered fields
609
+ // because we need to evaluate the actual field values to determine exclusion
610
+
611
+ // Use the regular matchesCriteria method for $not conditions to ensure proper field evaluation
612
+ const notResult = this.matchesCriteria(record, criteria.$not, options);
613
+ return !notResult;
614
+ }
615
+
616
+ // OPTIMIZATION 4: Skip indexable fields since they were already pre-filtered
617
+ const indexableFields = this._getIndexableFields(criteria);
618
+
619
+ // Handle explicit logical operators at the top level
620
+ if (criteria.$or && Array.isArray(criteria.$or)) {
621
+ let orMatches = false;
622
+ for (const orCondition of criteria.$or) {
623
+ if (this._matchesCriteriaOptimized(record, orCondition, options)) {
624
+ orMatches = true;
625
+ break;
626
+ }
627
+ }
628
+
629
+ if (!orMatches) {
630
+ return false;
631
+ }
632
+ } else if (criteria.$and && Array.isArray(criteria.$and)) {
633
+ for (const andCondition of criteria.$and) {
634
+ if (!this._matchesCriteriaOptimized(record, andCondition, options)) {
635
+ return false;
636
+ }
637
+ }
638
+ }
639
+
640
+ // Handle individual field conditions (exclude logical operators and pre-filtered fields)
641
+ for (const [field, condition] of Object.entries(criteria)) {
642
+ if (field.startsWith('$')) continue;
643
+
644
+ // OPTIMIZATION 4: Skip indexable fields that were already pre-filtered
645
+ if (indexableFields.includes(field)) {
646
+ continue;
647
+ }
648
+
649
+ if (!this.matchesFieldCondition(record, field, condition, options)) {
650
+ return false;
651
+ }
652
+ }
653
+
654
+ if (criteria.$or && Array.isArray(criteria.$or)) {
655
+ return true;
656
+ }
657
+
658
+ return true;
659
+ }
660
+
661
+ /**
662
+ * OPTIMIZATION 4: Apply ordering to results
663
+ * @param {Array} results - Results to order
664
+ * @param {Object} options - Query options
665
+ * @returns {Array} - Ordered results
666
+ */
667
+ _applyOrdering(results, options) {
668
+ if (options.orderBy) {
669
+ const [field, direction = 'asc'] = options.orderBy.split(' ');
670
+ results.sort((a, b) => {
671
+ if (a[field] > b[field]) return direction === 'asc' ? 1 : -1;
672
+ if (a[field] < b[field]) return direction === 'asc' ? -1 : 1;
673
+ return 0;
674
+ });
675
+ }
676
+
677
+ return results;
678
+ }
679
+
680
+ /**
681
+ * Stream all records (fallback method)
682
+ * @param {Object} criteria - Query criteria
683
+ * @param {Object} options - Query options
684
+ * @returns {Promise<Array>} - Query results
685
+ */
686
+ async _streamAllRecords(criteria, options = {}) {
687
+ const memoryLimit = options.limit || undefined;
688
+ const streamingOptions = { ...options, limit: memoryLimit };
689
+
690
+ const results = await this.fileHandler.readWithStreaming(criteria, streamingOptions, (record, criteria) => {
691
+ return this.matchesCriteria(record, criteria, options);
692
+ }, this.serializer || null);
693
+
694
+ // SPACE OPTIMIZATION: Restore term IDs to terms for user (unless disabled)
695
+ const resultsWithTerms = options.restoreTerms !== false ?
696
+ results.map(record => this.database.restoreTermIdsAfterDeserialization(record)) :
697
+ results;
698
+
699
+ // Apply ordering if specified
700
+ if (options.orderBy) {
701
+ const [field, direction = 'asc'] = options.orderBy.split(' ');
702
+ resultsWithTerms.sort((a, b) => {
703
+ if (a[field] > b[field]) return direction === 'asc' ? 1 : -1;
704
+ if (a[field] < b[field]) return direction === 'asc' ? -1 : 1;
705
+ return 0;
706
+ });
707
+ }
708
+
709
+ return resultsWithTerms;
710
+ }
711
+
712
+ /**
713
+ * Find using indexed search strategy with real streaming
714
+ * @param {Object} criteria - Query criteria
715
+ * @param {Object} options - Query options
716
+ * @returns {Promise<Array>} - Query results
717
+ */
718
+ async findWithIndexed(criteria, options = {}) {
719
+ if (this.opts.debugMode) {
720
+ console.log('📊 Using indexed strategy with real streaming');
721
+ }
722
+
723
+ try {
724
+ await this.database._ensureLazyIndexLoaded()
725
+ } catch (error) {
726
+ if (this.opts.debugMode) {
727
+ console.log('⚠️ _ensureLazyIndexLoaded failed, falling back to full streaming', error.message || error)
728
+ }
729
+ return this.findWithStreaming(criteria, { ...options, forceFullScan: true })
730
+ }
731
+ let results = []
732
+ const limit = options.limit // No default limit - return all results unless explicitly limited
733
+
734
+ // Use IndexManager to get line numbers, then read specific records
735
+ const lineNumbers = this.indexManager.query(criteria, options)
736
+ if (this.opts.debugMode) {
737
+ console.log(`🔍 IndexManager returned ${lineNumbers.size} line numbers:`, Array.from(lineNumbers))
738
+ }
739
+
740
+ if (lineNumbers.size === 0) {
741
+ const missingIndexedFields = this._getIndexedFieldsWithMissingData(criteria)
742
+ if (missingIndexedFields.length > 0 && this._hasAnyRecords()) {
743
+ // Try to rebuild index before falling back to streaming (only if allowIndexRebuild is true)
744
+ if (this.database.opts.allowIndexRebuild) {
745
+ if (this.opts.debugMode) {
746
+ console.log(`⚠️ Indexed query returned no results because index data is missing for: ${missingIndexedFields.join(', ')}. Attempting index rebuild...`)
747
+ }
748
+ this.database._indexRebuildNeeded = true
749
+ await this.database._rebuildIndexesIfNeeded()
750
+
751
+ // Retry indexed query after rebuild
752
+ const retryLineNumbers = this.indexManager.query(criteria, options)
753
+ if (retryLineNumbers.size > 0) {
754
+ if (this.opts.debugMode) {
755
+ console.log(`✅ Index rebuild successful, using indexed strategy.`)
756
+ }
757
+ // Update lineNumbers to use rebuilt index results
758
+ lineNumbers.clear()
759
+ for (const lineNumber of retryLineNumbers) {
760
+ lineNumbers.add(lineNumber)
761
+ }
762
+ } else {
763
+ // Still no results after rebuild, fall back to streaming
764
+ if (this.opts.debugMode) {
765
+ console.log(`⚠️ Index rebuild did not help, falling back to streaming.`)
766
+ }
767
+ return this.findWithStreaming(criteria, { ...options, forceFullScan: true })
768
+ }
769
+ } else {
770
+ // allowIndexRebuild is false, fall back to streaming
771
+ if (this.opts.debugMode) {
772
+ console.log(`⚠️ Indexed query returned no results because index data is missing for: ${missingIndexedFields.join(', ')}. Falling back to streaming.`)
773
+ }
774
+ return this.findWithStreaming(criteria, { ...options, forceFullScan: true })
775
+ }
776
+ }
777
+ }
778
+
779
+ // Read specific records using the line numbers
780
+ if (lineNumbers.size > 0) {
781
+ const lineNumbersArray = Array.from(lineNumbers)
782
+ const persistedCount = Array.isArray(this.database.offsets) ? this.database.offsets.length : 0
783
+
784
+ // Separate lineNumbers into file records and writeBuffer records
785
+ const fileLineNumbers = []
786
+ const writeBufferLineNumbers = []
787
+
788
+ for (const lineNumber of lineNumbersArray) {
789
+ if (lineNumber >= persistedCount) {
790
+ // This lineNumber points to writeBuffer
791
+ writeBufferLineNumbers.push(lineNumber)
792
+ } else {
793
+ // This lineNumber points to file
794
+ fileLineNumbers.push(lineNumber)
795
+ }
796
+ }
797
+
798
+ // Read records from file
799
+ if (fileLineNumbers.length > 0) {
800
+ const ranges = this.database.getRanges(fileLineNumbers)
801
+ if (ranges.length > 0) {
802
+ const groupedRanges = await this.database.fileHandler.groupedRanges(ranges)
803
+
804
+ const fd = await fs.open(this.database.fileHandler.file, 'r')
805
+
806
+ try {
807
+ for (const groupedRange of groupedRanges) {
808
+ for await (const row of this.database.fileHandler.readGroupedRange(groupedRange, fd)) {
809
+ try {
810
+ const record = this.database.serializer.deserialize(row.line)
811
+ const recordWithTerms = options.restoreTerms !== false ?
812
+ this.database.restoreTermIdsAfterDeserialization(record) :
813
+ record
814
+ recordWithTerms._ = row._
815
+ results.push(recordWithTerms)
816
+ if (limit && results.length >= limit) break
817
+ } catch (error) {
818
+ // Skip invalid lines
819
+ }
820
+ }
821
+ if (limit && results.length >= limit) break
822
+ }
823
+ } finally {
824
+ await fd.close()
825
+ }
826
+ }
827
+ }
828
+
829
+ // Read records from writeBuffer
830
+ if (writeBufferLineNumbers.length > 0 && this.database.writeBuffer) {
831
+ for (const lineNumber of writeBufferLineNumbers) {
832
+ if (limit && results.length >= limit) break
833
+
834
+ const writeBufferIndex = lineNumber - persistedCount
835
+ if (writeBufferIndex >= 0 && writeBufferIndex < this.database.writeBuffer.length) {
836
+ const record = this.database.writeBuffer[writeBufferIndex]
837
+ if (record) {
838
+ const recordWithTerms = options.restoreTerms !== false ?
839
+ this.database.restoreTermIdsAfterDeserialization(record) :
840
+ record
841
+ recordWithTerms._ = lineNumber
842
+ results.push(recordWithTerms)
843
+ }
844
+ }
845
+ }
846
+ }
847
+ }
848
+
849
+ if (options.orderBy) {
850
+ const [field, direction = 'asc'] = options.orderBy.split(' ')
851
+ results.sort((a, b) => {
852
+ if (a[field] > b[field]) return direction === 'asc' ? 1 : -1
853
+ if (a[field] < b[field]) return direction === 'asc' ? -1 : 1
854
+ return 0;
855
+ })
856
+ }
857
+ return results
858
+ }
859
+
860
+ /**
861
+ * Check if a record matches criteria
862
+ * @param {Object} record - Record to check
863
+ * @param {Object} criteria - Filter criteria
864
+ * @param {Object} options - Query options (for caseInsensitive, etc.)
865
+ * @returns {boolean} - True if matches
866
+ */
867
+ matchesCriteria(record, criteria, options = {}) {
868
+
869
+ if (!criteria || Object.keys(criteria).length === 0) {
870
+ return true;
871
+ }
872
+
873
+ // Handle explicit logical operators at the top level
874
+ if (criteria.$or && Array.isArray(criteria.$or)) {
875
+ let orMatches = false;
876
+ for (const orCondition of criteria.$or) {
877
+ if (this.matchesCriteria(record, orCondition, options)) {
878
+ orMatches = true;
879
+ break;
880
+ }
881
+ }
882
+
883
+ // If $or doesn't match, return false immediately
884
+ if (!orMatches) {
885
+ return false;
886
+ }
887
+
888
+ // If $or matches, continue to check other conditions if they exist
889
+ // Don't return true yet - we need to check other conditions
890
+ } else if (criteria.$and && Array.isArray(criteria.$and)) {
891
+ for (const andCondition of criteria.$and) {
892
+ if (!this.matchesCriteria(record, andCondition, options)) {
893
+ return false;
894
+ }
895
+ }
896
+ // $and matches, continue to check other conditions if they exist
897
+ }
898
+
899
+ // Handle individual field conditions and $not operator
900
+ for (const [field, condition] of Object.entries(criteria)) {
901
+ // Skip logical operators that are handled above
902
+ if (field.startsWith('$') && field !== '$not') {
903
+ continue;
904
+ }
905
+
906
+ if (field === '$not') {
907
+ // Handle $not operator - it should negate the result of its condition
908
+ if (typeof condition === 'object' && condition !== null) {
909
+ // Empty $not condition should not exclude anything
910
+ if (Object.keys(condition).length === 0) {
911
+ continue; // Don't exclude anything
912
+ }
913
+
914
+ // Check if the $not condition matches - if it does, this record should be excluded
915
+ if (this.matchesCriteria(record, condition, options)) {
916
+ return false; // Exclude this record
917
+ }
918
+ }
919
+ } else {
920
+ // Handle regular field conditions
921
+ if (!this.matchesFieldCondition(record, field, condition, options)) {
922
+ return false;
923
+ }
924
+ }
925
+ }
926
+
927
+ // If we have $or conditions and they matched, return true
928
+ if (criteria.$or && Array.isArray(criteria.$or)) {
929
+ return true;
930
+ }
931
+
932
+ // For other cases (no $or, or $and, or just field conditions), return true if we got this far
933
+ return true;
934
+ }
935
+
936
+ /**
937
+ * Check if a field matches a condition
938
+ * @param {Object} record - Record to check
939
+ * @param {string} field - Field name
940
+ * @param {*} condition - Condition to match
941
+ * @param {Object} options - Query options
942
+ * @returns {boolean} - True if matches
943
+ */
944
+ matchesFieldCondition(record, field, condition, options = {}) {
945
+ const value = record[field];
946
+
947
+ // Debug logging for all field conditions
948
+ if (this.database.opts.debugMode) {
949
+ console.log(`🔍 Checking field '${field}':`, { value, condition, record: record.name || record.id });
950
+ }
951
+
952
+ // Debug logging for term mapping fields
953
+ if (this.database.opts.termMapping && Object.keys(this.database.opts.indexes || {}).includes(field)) {
954
+ if (this.database.opts.debugMode) {
955
+ console.log(`🔍 Checking term mapping field '${field}':`, { value, condition, record: record.name || record.id });
956
+ }
957
+ }
958
+
959
+ // Handle null/undefined values
960
+ if (value === null || value === undefined) {
961
+ return condition === null || condition === undefined;
962
+ }
963
+
964
+ // Handle regex conditions (MUST come before object check since RegExp is an object)
965
+ if (condition instanceof RegExp) {
966
+ // For array fields, test regex against each element
967
+ if (Array.isArray(value)) {
968
+ return value.some(element => condition.test(String(element)));
969
+ }
970
+ // For non-array fields, test regex against the value directly
971
+ return condition.test(String(value));
972
+ }
973
+
974
+ // Handle array conditions
975
+ if (Array.isArray(condition)) {
976
+ // For array fields, check if any element in the field matches any element in the condition
977
+ if (Array.isArray(value)) {
978
+ return condition.some(condVal => value.includes(condVal));
979
+ }
980
+ // For non-array fields, check if value is in condition
981
+ return condition.includes(value);
982
+ }
983
+
984
+ // Handle object conditions (operators)
985
+ if (typeof condition === 'object' && !Array.isArray(condition)) {
986
+ for (const [operator, operatorValue] of Object.entries(condition)) {
987
+ const normalizedOperator = normalizeOperator(operator);
988
+ if (!this.matchesOperator(value, normalizedOperator, operatorValue, options)) {
989
+ return false;
990
+ }
991
+ }
992
+ return true;
993
+ }
994
+
995
+ // Handle case-insensitive string comparison
996
+ if (options.caseInsensitive && typeof value === 'string' && typeof condition === 'string') {
997
+ return value.toLowerCase() === condition.toLowerCase();
998
+ }
999
+
1000
+ // Handle direct array field search (e.g., { nameTerms: 'channel' })
1001
+ if (Array.isArray(value) && typeof condition === 'string') {
1002
+ return value.includes(condition);
1003
+ }
1004
+
1005
+ // Simple equality
1006
+ return value === condition;
1007
+ }
1008
+
1009
+ /**
1010
+ * Check if a value matches an operator condition
1011
+ * @param {*} value - Value to check
1012
+ * @param {string} operator - Operator
1013
+ * @param {*} operatorValue - Operator value
1014
+ * @param {Object} options - Query options
1015
+ * @returns {boolean} - True if matches
1016
+ */
1017
+ matchesOperator(value, operator, operatorValue, options = {}) {
1018
+ switch (operator) {
1019
+ case '$eq':
1020
+ return value === operatorValue;
1021
+ case '$gt':
1022
+ return value > operatorValue;
1023
+ case '$gte':
1024
+ return value >= operatorValue;
1025
+ case '$lt':
1026
+ return value < operatorValue;
1027
+ case '$lte':
1028
+ return value <= operatorValue;
1029
+ case '$ne':
1030
+ return value !== operatorValue;
1031
+ case '$not':
1032
+ // $not operator should be handled at the criteria level, not field level
1033
+ // This is a fallback for backward compatibility
1034
+ return value !== operatorValue;
1035
+ case '$in':
1036
+ if (Array.isArray(value)) {
1037
+ // For array fields, check if any element in the array matches any value in operatorValue
1038
+ return Array.isArray(operatorValue) && operatorValue.some(opVal => value.includes(opVal));
1039
+ } else {
1040
+ // For non-array fields, check if value is in operatorValue
1041
+ return Array.isArray(operatorValue) && operatorValue.includes(value);
1042
+ }
1043
+ case '$nin':
1044
+ if (Array.isArray(value)) {
1045
+ // For array fields, check if NO elements in the array match any value in operatorValue
1046
+ return Array.isArray(operatorValue) && !operatorValue.some(opVal => value.includes(opVal));
1047
+ } else {
1048
+ // For non-array fields, check if value is not in operatorValue
1049
+ return Array.isArray(operatorValue) && !operatorValue.includes(value);
1050
+ }
1051
+ case '$regex':
1052
+ const regex = new RegExp(operatorValue, options.caseInsensitive ? 'i' : '');
1053
+ // For array fields, test regex against each element
1054
+ if (Array.isArray(value)) {
1055
+ return value.some(element => regex.test(String(element)));
1056
+ }
1057
+ // For non-array fields, test regex against the value directly
1058
+ return regex.test(String(value));
1059
+ case '$contains':
1060
+ if (Array.isArray(value)) {
1061
+ return value.includes(operatorValue);
1062
+ }
1063
+ return String(value).includes(String(operatorValue));
1064
+ case '$all':
1065
+ if (!Array.isArray(value) || !Array.isArray(operatorValue)) {
1066
+ return false;
1067
+ }
1068
+ return operatorValue.every(item => value.includes(item));
1069
+ case '$exists':
1070
+ return operatorValue ? (value !== undefined && value !== null) : (value === undefined || value === null);
1071
+ case '$size':
1072
+ if (Array.isArray(value)) {
1073
+ return value.length === operatorValue;
1074
+ }
1075
+ return false;
1076
+ default:
1077
+ return false;
1078
+ }
1079
+ }
1080
+
1081
+
1082
+ /**
1083
+ * Preprocess query to handle array field syntax automatically
1084
+ * @param {Object} criteria - Query criteria
1085
+ * @returns {Object} - Processed criteria
1086
+ */
1087
+ preprocessQuery(criteria) {
1088
+ if (!criteria || typeof criteria !== 'object') {
1089
+ return criteria
1090
+ }
1091
+
1092
+ const processed = {}
1093
+
1094
+ for (const [field, value] of Object.entries(criteria)) {
1095
+ // Check if this is a term mapping field
1096
+ const isTermMappingField = this.database.opts.termMapping &&
1097
+ this.database.termManager &&
1098
+ this.database.termManager.termMappingFields &&
1099
+ this.database.termManager.termMappingFields.includes(field)
1100
+
1101
+ if (isTermMappingField) {
1102
+ // Handle term mapping field queries
1103
+ if (typeof value === 'string') {
1104
+ // Convert term to $in query for term mapping fields
1105
+ processed[field] = { $in: [value] }
1106
+ } else if (Array.isArray(value)) {
1107
+ // Convert array to $in query
1108
+ processed[field] = { $in: value }
1109
+ } else if (value && typeof value === 'object') {
1110
+ // Handle special query operators for term mapping
1111
+ if (value.$in) {
1112
+ processed[field] = { $in: value.$in }
1113
+ } else if (value.$all) {
1114
+ processed[field] = { $all: value.$all }
1115
+ } else {
1116
+ processed[field] = value
1117
+ }
1118
+ } else {
1119
+ // Invalid value for term mapping field
1120
+ throw new Error(`Invalid query for array field '${field}'. Use { $in: [value] } syntax or direct value.`)
1121
+ }
1122
+
1123
+ if (this.database.opts.debugMode) {
1124
+ console.log(`🔍 Processed term mapping query for field '${field}':`, processed[field])
1125
+ }
1126
+ } else {
1127
+ // Check if this field is defined as an array in the schema
1128
+ const indexes = this.opts.indexes || {}
1129
+ const fieldConfig = indexes[field]
1130
+ const isArrayField = fieldConfig &&
1131
+ (Array.isArray(fieldConfig) && fieldConfig.includes('array') ||
1132
+ fieldConfig === 'array:string' ||
1133
+ fieldConfig === 'array:number' ||
1134
+ fieldConfig === 'array:boolean')
1135
+
1136
+ if (isArrayField) {
1137
+ // Handle array field queries
1138
+ if (typeof value === 'string' || typeof value === 'number') {
1139
+ // Convert direct value to $in query for array fields
1140
+ processed[field] = { $in: [value] }
1141
+ } else if (Array.isArray(value)) {
1142
+ // Convert array to $in query
1143
+ processed[field] = { $in: value }
1144
+ } else if (value && typeof value === 'object') {
1145
+ // Already properly formatted query object
1146
+ processed[field] = value
1147
+ } else {
1148
+ // Invalid value for array field
1149
+ throw new Error(`Invalid query for array field '${field}'. Use { $in: [value] } syntax or direct value.`)
1150
+ }
1151
+ } else {
1152
+ // Non-array field, keep as is
1153
+ processed[field] = value
1154
+ }
1155
+ }
1156
+ }
1157
+
1158
+ return processed
1159
+ }
1160
+
1161
+ /**
1162
+ * Determine which query strategy to use
1163
+ * @param {Object} criteria - Query criteria
1164
+ * @param {Object} options - Query options
1165
+ * @returns {string} - 'streaming' or 'indexed'
1166
+ */
1167
+ shouldUseStreaming(criteria, options = {}) {
1168
+ const { limit } = options; // No default limit
1169
+ const totalRecords = this.database.length || 0;
1170
+
1171
+ // Strategy 1: Always streaming for queries without criteria
1172
+ if (!criteria || Object.keys(criteria).length === 0) {
1173
+ if (this.opts.debugMode) {
1174
+ console.log('📊 QueryStrategy: STREAMING - No criteria provided');
1175
+ }
1176
+ return 'streaming';
1177
+ }
1178
+
1179
+ // Strategy 2: Check if all fields are indexed and support the operators used
1180
+ // First, check if $not is present at root level - if so, we need to use streaming for proper $not handling
1181
+ if (criteria.$not && !this.opts.termMapping) {
1182
+ if (this.opts.debugMode) {
1183
+ console.log('📊 QueryStrategy: STREAMING - $not operator requires streaming mode');
1184
+ }
1185
+ return 'streaming';
1186
+ }
1187
+
1188
+ // OPTIMIZATION: For term mapping, we can process $not using indices
1189
+ if (criteria.$not && this.opts.termMapping) {
1190
+ // Check if all $not fields are indexed
1191
+ const notFields = Object.keys(criteria.$not)
1192
+ const allNotFieldsIndexed = notFields.every(field =>
1193
+ this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]
1194
+ )
1195
+
1196
+ if (allNotFieldsIndexed) {
1197
+ if (this.opts.debugMode) {
1198
+ console.log('📊 QueryStrategy: INDEXED - $not with term mapping can use indexed strategy');
1199
+ }
1200
+ // Continue to check other conditions instead of forcing streaming
1201
+ } else {
1202
+ if (this.opts.debugMode) {
1203
+ console.log('📊 QueryStrategy: STREAMING - $not fields not all indexed');
1204
+ }
1205
+ return 'streaming';
1206
+ }
1207
+ }
1208
+
1209
+ // Handle $and queries - check if all conditions in $and are indexable
1210
+ if (criteria.$and && Array.isArray(criteria.$and)) {
1211
+ const allAndConditionsIndexed = criteria.$and.every(andCondition => {
1212
+ // Handle $not conditions within $and
1213
+ if (andCondition.$not) {
1214
+ const notFields = Object.keys(andCondition.$not);
1215
+ return notFields.every(field => {
1216
+ if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
1217
+ return false;
1218
+ }
1219
+ // For term mapping, $not can be processed with indices
1220
+ return this.opts.termMapping && Object.keys(this.opts.indexes || {}).includes(field);
1221
+ });
1222
+ }
1223
+
1224
+ // Handle regular field conditions
1225
+ return Object.keys(andCondition).every(field => {
1226
+ if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
1227
+ return false;
1228
+ }
1229
+
1230
+ const condition = andCondition[field];
1231
+
1232
+ // RegExp cannot be efficiently queried using indices - must use streaming
1233
+ if (condition instanceof RegExp) {
1234
+ return false;
1235
+ }
1236
+
1237
+ if (typeof condition === 'object' && !Array.isArray(condition)) {
1238
+ const operators = Object.keys(condition).map(op => normalizeOperator(op));
1239
+ const indexType = this.indexManager?.opts?.indexes?.[field]
1240
+ const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number'
1241
+ const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size']
1242
+ const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size']
1243
+
1244
+ // Check if this is a term mapping field (array:string or string fields with term mapping)
1245
+ const isTermMappingField = this.database.termManager &&
1246
+ this.database.termManager.termMappingFields &&
1247
+ this.database.termManager.termMappingFields.includes(field)
1248
+
1249
+ if (isTermMappingField) {
1250
+ const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size']
1251
+ return operators.every(op => !termMappingDisallowed.includes(op));
1252
+ } else {
1253
+ const disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault
1254
+ return operators.every(op => !disallowed.includes(op));
1255
+ }
1256
+ }
1257
+ return true;
1258
+ });
1259
+ });
1260
+
1261
+ if (!allAndConditionsIndexed) {
1262
+ if (this.opts.debugMode) {
1263
+ console.log('📊 QueryStrategy: STREAMING - Some $and conditions not indexed or operators not supported');
1264
+ }
1265
+ return 'streaming';
1266
+ }
1267
+ }
1268
+
1269
+ const allFieldsIndexed = Object.keys(criteria).every(field => {
1270
+ // Skip $and and $not as they're handled separately above
1271
+ if (field === '$and' || field === '$not') return true;
1272
+
1273
+ if (!this.opts.indexes || !this.opts.indexes[field]) {
1274
+ if (this.opts.debugMode) {
1275
+ console.log(`🔍 Field '${field}' not indexed. Available indexes:`, Object.keys(this.opts.indexes || {}))
1276
+ }
1277
+ return false;
1278
+ }
1279
+
1280
+ // Check if the field uses operators that are supported by IndexManager
1281
+ const condition = criteria[field];
1282
+
1283
+ // RegExp cannot be efficiently queried using indices - must use streaming
1284
+ if (condition instanceof RegExp) {
1285
+ if (this.opts.debugMode) {
1286
+ console.log(`🔍 Field '${field}' uses RegExp - requires streaming strategy`)
1287
+ }
1288
+ return false;
1289
+ }
1290
+
1291
+ if (typeof condition === 'object' && !Array.isArray(condition) && condition !== null) {
1292
+ const operators = Object.keys(condition).map(op => normalizeOperator(op));
1293
+ if (this.opts.debugMode) {
1294
+ console.log(`🔍 Field '${field}' has operators:`, operators)
1295
+ }
1296
+
1297
+ const indexType = this.indexManager?.opts?.indexes?.[field]
1298
+ const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number'
1299
+ const isArrayStringIndex = indexType === 'array:string'
1300
+ const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size']
1301
+ const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size']
1302
+
1303
+ // Check if this is a term mapping field (array:string or string fields with term mapping)
1304
+ const isTermMappingField = this.database.termManager &&
1305
+ this.database.termManager.termMappingFields &&
1306
+ this.database.termManager.termMappingFields.includes(field)
1307
+
1308
+ // With term mapping enabled on THIS FIELD, we can support complex operators via partial reads
1309
+ // Also support $all for array:string indexed fields (IndexManager.query supports it via Set intersection)
1310
+ if (isTermMappingField) {
1311
+ const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size']
1312
+ return operators.every(op => !termMappingDisallowed.includes(op));
1313
+ } else {
1314
+ let disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault
1315
+ // Remove $all from disallowed if field is array:string (IndexManager supports $all via Set intersection)
1316
+ if (isArrayStringIndex) {
1317
+ disallowed = disallowed.filter(op => op !== '$all')
1318
+ }
1319
+ return operators.every(op => !disallowed.includes(op));
1320
+ }
1321
+ }
1322
+ return true;
1323
+ });
1324
+
1325
+ if (!allFieldsIndexed) {
1326
+ if (this.opts.debugMode) {
1327
+ console.log('📊 QueryStrategy: STREAMING - Some fields not indexed or operators not supported');
1328
+ }
1329
+ return 'streaming';
1330
+ }
1331
+
1332
+ // OPTIMIZATION 2: Hybrid strategy - use pre-filtered streaming when index is empty
1333
+ const indexData = this.indexManager.index.data || {};
1334
+ const hasIndexData = Object.keys(indexData).some(field => this.indexManager.hasUsableIndexData(field));
1335
+ if (!hasIndexData) {
1336
+ // Check if we can use pre-filtered streaming with term mapping
1337
+ if (this.opts.termMapping && this._canUsePreFilteredStreaming(criteria)) {
1338
+ if (this.opts.debugMode) {
1339
+ console.log('📊 QueryStrategy: HYBRID - Using pre-filtered streaming with term mapping');
1340
+ }
1341
+ return 'streaming'; // Will use pre-filtered streaming in findWithStreaming
1342
+ }
1343
+
1344
+ if (this.opts.debugMode) {
1345
+ console.log('📊 QueryStrategy: STREAMING - Index is empty and no pre-filtering available');
1346
+ }
1347
+ return 'streaming';
1348
+ }
1349
+
1350
+ // Strategy 3: Streaming if limit is very high (only if database has records)
1351
+ if (totalRecords > 0 && limit > totalRecords * this.opts.streamingThreshold) {
1352
+ if (this.opts.debugMode) {
1353
+ console.log(`📊 QueryStrategy: STREAMING - High limit (${limit} > ${Math.round(totalRecords * this.opts.streamingThreshold)})`);
1354
+ }
1355
+ return 'streaming';
1356
+ }
1357
+
1358
+ // Strategy 4: Use indexed strategy when all fields are indexed and streamingThreshold is respected
1359
+ if (this.opts.debugMode) {
1360
+ console.log(`📊 QueryStrategy: INDEXED - All fields indexed, using indexed strategy`);
1361
+ }
1362
+ return 'indexed';
1363
+ }
1364
+
1365
+ /**
1366
+ * Estimate number of results for a query
1367
+ * @param {Object} criteria - Query criteria
1368
+ * @param {number} totalRecords - Total records in database
1369
+ * @returns {number} - Estimated results
1370
+ */
1371
+ estimateQueryResults(criteria, totalRecords) {
1372
+ // If database is empty, return 0
1373
+ if (totalRecords === 0) {
1374
+ if (this.opts.debugMode) {
1375
+ console.log(`📊 Estimation: Database empty → 0 results`);
1376
+ }
1377
+ return 0;
1378
+ }
1379
+
1380
+ let minResults = Infinity;
1381
+
1382
+ for (const [field, condition] of Object.entries(criteria)) {
1383
+ // Check if field is indexed
1384
+ if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
1385
+ // Non-indexed field - assume it could match any record
1386
+ if (this.opts.debugMode) {
1387
+ console.log(`📊 Estimation: ${field} = non-indexed → ~${totalRecords} results`);
1388
+ }
1389
+ return totalRecords;
1390
+ }
1391
+
1392
+ const fieldIndex = this.indexManager.index.data[field];
1393
+
1394
+ if (!fieldIndex) {
1395
+ // Non-indexed field - assume it could match any record
1396
+ if (this.opts.debugMode) {
1397
+ console.log(`📊 Estimation: ${field} = non-indexed ~${totalRecords} results`);
1398
+ }
1399
+ return totalRecords;
1400
+ }
1401
+
1402
+ let fieldEstimate = 0;
1403
+
1404
+ if (typeof condition === 'object' && !Array.isArray(condition)) {
1405
+ // Handle different types of operators
1406
+ for (const [operator, value] of Object.entries(condition)) {
1407
+ if (operator === '$all') {
1408
+ // Special handling for $all operator
1409
+ fieldEstimate = this.estimateAllOperator(fieldIndex, value);
1410
+ } else if (['$gt', '$gte', '$lt', '$lte', '$in', '$regex'].includes(operator)) {
1411
+ // Numeric and other operators
1412
+ fieldEstimate = this.estimateOperatorResults(fieldIndex, operator, value, totalRecords);
1413
+ } else {
1414
+ // Unknown operator, assume it could match any record
1415
+ fieldEstimate = totalRecords;
1416
+ }
1417
+ }
1418
+ } else {
1419
+ // Simple equality
1420
+ const recordIds = fieldIndex[condition];
1421
+ fieldEstimate = recordIds ? recordIds.length : 0;
1422
+ }
1423
+
1424
+ if (this.opts.debugMode) {
1425
+ console.log(`📊 Estimation: ${field} = ${fieldEstimate} results`);
1426
+ }
1427
+
1428
+ minResults = Math.min(minResults, fieldEstimate);
1429
+ }
1430
+
1431
+ return minResults === Infinity ? 0 : minResults;
1432
+ }
1433
+
1434
+ /**
1435
+ * Estimate results for $all operator
1436
+ * @param {Object} fieldIndex - Field index
1437
+ * @param {Array} values - Values to match
1438
+ * @returns {number} - Estimated results
1439
+ */
1440
+ estimateAllOperator(fieldIndex, values) {
1441
+ if (!Array.isArray(values) || values.length === 0) {
1442
+ return 0;
1443
+ }
1444
+
1445
+ let minCount = Infinity;
1446
+ for (const value of values) {
1447
+ const recordIds = fieldIndex[value];
1448
+ const count = recordIds ? recordIds.length : 0;
1449
+ minCount = Math.min(minCount, count);
1450
+ }
1451
+
1452
+ return minCount === Infinity ? 0 : minCount;
1453
+ }
1454
+
1455
+ /**
1456
+ * Estimate results for operators
1457
+ * @param {Object} fieldIndex - Field index
1458
+ * @param {string} operator - Operator
1459
+ * @param {*} value - Value
1460
+ * @param {number} totalRecords - Total records
1461
+ * @returns {number} - Estimated results
1462
+ */
1463
+ estimateOperatorResults(fieldIndex, operator, value, totalRecords) {
1464
+ // This is a simplified estimation - in practice, you might want more sophisticated logic
1465
+ switch (operator) {
1466
+ case '$in':
1467
+ if (Array.isArray(value)) {
1468
+ let total = 0;
1469
+ for (const v of value) {
1470
+ const recordIds = fieldIndex[v];
1471
+ if (recordIds) total += recordIds.length;
1472
+ }
1473
+ return total;
1474
+ }
1475
+ break;
1476
+ case '$gt':
1477
+ case '$gte':
1478
+ case '$lt':
1479
+ case '$lte':
1480
+ // For range queries, estimate based on data distribution
1481
+ // This is a simplified approach - real implementation would be more sophisticated
1482
+ return Math.floor(totalRecords * 0.1); // Assume 10% of records match
1483
+ case '$regex':
1484
+ // Regex is hard to estimate without scanning
1485
+ return Math.floor(totalRecords * 0.05); // Assume 5% of records match
1486
+ }
1487
+ return 0;
1488
+ }
1489
+
1490
+ /**
1491
+ * Validate strict query mode
1492
+ * @param {Object} criteria - Query criteria
1493
+ * @param {Object} options - Query options
1494
+ */
1495
+ validateStrictQuery(criteria, options = {}) {
1496
+ // Allow bypassing strict mode validation with allowNonIndexed option
1497
+ if (options.allowNonIndexed === true) {
1498
+ return; // Skip validation for this query
1499
+ }
1500
+
1501
+ if (!criteria || Object.keys(criteria).length === 0) {
1502
+ return; // Empty criteria are always allowed
1503
+ }
1504
+
1505
+ // Handle logical operators at the top level
1506
+ if (criteria.$not) {
1507
+ this.validateStrictQuery(criteria.$not, options);
1508
+ return;
1509
+ }
1510
+
1511
+ if (criteria.$or && Array.isArray(criteria.$or)) {
1512
+ for (const orCondition of criteria.$or) {
1513
+ this.validateStrictQuery(orCondition, options);
1514
+ }
1515
+ return;
1516
+ }
1517
+
1518
+ if (criteria.$and && Array.isArray(criteria.$and)) {
1519
+ for (const andCondition of criteria.$and) {
1520
+ this.validateStrictQuery(andCondition, options);
1521
+ }
1522
+ return;
1523
+ }
1524
+
1525
+ // Get available indexed fields
1526
+ const indexedFields = Object.keys(this.indexManager.opts.indexes || {});
1527
+ const availableFields = indexedFields.length > 0 ? indexedFields.join(', ') : 'none';
1528
+
1529
+ // Check each field
1530
+ const nonIndexedFields = [];
1531
+ for (const [field, condition] of Object.entries(criteria)) {
1532
+ // Skip logical operators
1533
+ if (field.startsWith('$')) {
1534
+ continue;
1535
+ }
1536
+
1537
+ // Check if field is indexed
1538
+ if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
1539
+ nonIndexedFields.push(field);
1540
+ }
1541
+
1542
+ // Check if condition uses supported operators
1543
+ if (typeof condition === 'object' && !Array.isArray(condition)) {
1544
+ const operators = Object.keys(condition);
1545
+ for (const op of operators) {
1546
+ if (!['$in', '$nin', '$contains', '$all', '$exists', '>', '>=', '<', '<=', '!=', 'contains', 'regex'].includes(op)) {
1547
+ throw new Error(`Operator '${op}' is not supported in strict mode for field '${field}'.`);
1548
+ }
1549
+ }
1550
+ }
1551
+ }
1552
+
1553
+ // Generate appropriate error message
1554
+ if (nonIndexedFields.length > 0) {
1555
+ if (nonIndexedFields.length === 1) {
1556
+ throw new Error(`Strict indexed mode: Field '${nonIndexedFields[0]}' is not indexed. Available indexed fields: ${availableFields}`);
1557
+ } else {
1558
+ throw new Error(`Strict indexed mode: Fields '${nonIndexedFields.join("', '")}' are not indexed. Available indexed fields: ${availableFields}`);
1559
+ }
1560
+ }
1561
+ }
1562
+
1563
+ /**
1564
+ * Update average time for performance tracking
1565
+ * @param {string} type - Type of operation ('streaming' or 'indexed')
1566
+ * @param {number} time - Time taken
1567
+ */
1568
+ updateAverageTime(type, time) {
1569
+ if (!this.usageStats[`${type}AverageTime`]) {
1570
+ this.usageStats[`${type}AverageTime`] = 0;
1571
+ }
1572
+
1573
+ const currentAverage = this.usageStats[`${type}AverageTime`];
1574
+ const count = this.usageStats[`${type}Queries`] || 1;
1575
+
1576
+ // Calculate running average
1577
+ this.usageStats[`${type}AverageTime`] = (currentAverage * (count - 1) + time) / count;
1578
+ }
1579
+
1580
+ /**
1581
+ * OPTIMIZATION 2: Check if we can use pre-filtered streaming with term mapping
1582
+ * @param {Object} criteria - Query criteria
1583
+ * @returns {boolean} - True if pre-filtered streaming can be used
1584
+ */
1585
+ _canUsePreFilteredStreaming(criteria) {
1586
+ if (!criteria || typeof criteria !== 'object') {
1587
+ return false;
1588
+ }
1589
+
1590
+ // Check if we have term mapping fields in the query
1591
+ const termMappingFields = Object.keys(this.opts.indexes || {});
1592
+ const queryFields = Object.keys(criteria).filter(field => !field.startsWith('$'));
1593
+
1594
+ // Check if any query field is a term mapping field
1595
+ const hasTermMappingFields = queryFields.some(field => termMappingFields.includes(field));
1596
+
1597
+ if (!hasTermMappingFields) {
1598
+ return false;
1599
+ }
1600
+
1601
+ // Check if the query is simple enough for pre-filtering
1602
+ // Simple equality queries on term mapping fields work well with pre-filtering
1603
+ for (const [field, condition] of Object.entries(criteria)) {
1604
+ if (field.startsWith('$')) continue;
1605
+
1606
+ if (termMappingFields.includes(field)) {
1607
+ // For term mapping fields, simple equality or $in queries work well
1608
+ if (typeof condition === 'string' ||
1609
+ (typeof condition === 'object' && condition !== null && condition.$in && Array.isArray(condition.$in))) {
1610
+ return true;
1611
+ }
1612
+ }
1613
+ }
1614
+
1615
+ return false;
1616
+ }
1617
+
1618
+ // Simplified term mapping - handled in TermManager
1619
+ }