jexidb 2.0.2 → 2.1.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.
Files changed (55) hide show
  1. package/.babelrc +13 -0
  2. package/.gitattributes +2 -0
  3. package/CHANGELOG.md +140 -0
  4. package/LICENSE +21 -21
  5. package/README.md +301 -527
  6. package/babel.config.json +5 -0
  7. package/dist/Database.cjs +3896 -0
  8. package/docs/API.md +1051 -0
  9. package/docs/EXAMPLES.md +701 -0
  10. package/docs/README.md +194 -0
  11. package/examples/iterate-usage-example.js +157 -0
  12. package/examples/simple-iterate-example.js +115 -0
  13. package/jest.config.js +24 -0
  14. package/package.json +63 -51
  15. package/scripts/README.md +47 -0
  16. package/scripts/clean-test-files.js +75 -0
  17. package/scripts/prepare.js +31 -0
  18. package/scripts/run-tests.js +80 -0
  19. package/src/Database.mjs +4130 -0
  20. package/src/FileHandler.mjs +1101 -0
  21. package/src/OperationQueue.mjs +279 -0
  22. package/src/SchemaManager.mjs +268 -0
  23. package/src/Serializer.mjs +511 -0
  24. package/src/managers/ConcurrencyManager.mjs +257 -0
  25. package/src/managers/IndexManager.mjs +1403 -0
  26. package/src/managers/QueryManager.mjs +1273 -0
  27. package/src/managers/StatisticsManager.mjs +262 -0
  28. package/src/managers/StreamingProcessor.mjs +429 -0
  29. package/src/managers/TermManager.mjs +278 -0
  30. package/test/$not-operator-with-and.test.js +282 -0
  31. package/test/README.md +8 -0
  32. package/test/close-init-cycle.test.js +256 -0
  33. package/test/critical-bugs-fixes.test.js +1069 -0
  34. package/test/index-persistence.test.js +306 -0
  35. package/test/index-serialization.test.js +314 -0
  36. package/test/indexed-query-mode.test.js +360 -0
  37. package/test/iterate-method.test.js +272 -0
  38. package/test/query-operators.test.js +238 -0
  39. package/test/regex-array-fields.test.js +129 -0
  40. package/test/score-method.test.js +238 -0
  41. package/test/setup.js +17 -0
  42. package/test/term-mapping-minimal.test.js +154 -0
  43. package/test/term-mapping-simple.test.js +257 -0
  44. package/test/term-mapping.test.js +514 -0
  45. package/test/writebuffer-flush-resilience.test.js +204 -0
  46. package/dist/FileHandler.js +0 -688
  47. package/dist/IndexManager.js +0 -353
  48. package/dist/IntegrityChecker.js +0 -364
  49. package/dist/JSONLDatabase.js +0 -1194
  50. package/dist/index.js +0 -617
  51. package/src/FileHandler.js +0 -674
  52. package/src/IndexManager.js +0 -363
  53. package/src/IntegrityChecker.js +0 -379
  54. package/src/JSONLDatabase.js +0 -1248
  55. package/src/index.js +0 -608
@@ -0,0 +1,1273 @@
1
+ /**
2
+ * QueryManager - Handles all query operations and strategies
3
+ *
4
+ * Responsibilities:
5
+ * - find(), findOne(), count(), query()
6
+ * - findWithStreaming(), findWithIndexed()
7
+ * - matchesCriteria(), extractQueryFields()
8
+ * - Query strategies (INDEXED vs STREAMING)
9
+ * - Result estimation
10
+ */
11
+
12
+ export class QueryManager {
13
+ constructor(database) {
14
+ this.database = database
15
+ this.opts = database.opts
16
+ this.indexManager = database.indexManager
17
+ this.fileHandler = database.fileHandler
18
+ this.serializer = database.serializer
19
+ this.usageStats = database.usageStats || {
20
+ totalQueries: 0,
21
+ indexedQueries: 0,
22
+ streamingQueries: 0,
23
+ indexedAverageTime: 0,
24
+ streamingAverageTime: 0
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Main find method with strategy selection
30
+ * @param {Object} criteria - Query criteria
31
+ * @param {Object} options - Query options
32
+ * @returns {Promise<Array>} - Query results
33
+ */
34
+ async find(criteria, options = {}) {
35
+ if (this.database.destroyed) throw new Error('Database is destroyed')
36
+ if (!this.database.initialized) await this.database.init()
37
+
38
+ // Manual save is now the responsibility of the application
39
+
40
+ // Preprocess query to handle array field syntax automatically
41
+ const processedCriteria = this.preprocessQuery(criteria)
42
+
43
+ const finalCriteria = processedCriteria
44
+
45
+ // Validate strict indexed mode before processing
46
+ if (this.opts.indexedQueryMode === 'strict') {
47
+ this.validateStrictQuery(finalCriteria);
48
+ }
49
+
50
+ const startTime = Date.now();
51
+ this.usageStats.totalQueries++;
52
+
53
+ try {
54
+ // Decide which strategy to use
55
+ const strategy = this.shouldUseStreaming(finalCriteria, options);
56
+
57
+ let results = [];
58
+
59
+ if (strategy === 'streaming') {
60
+ results = await this.findWithStreaming(finalCriteria, options);
61
+ this.usageStats.streamingQueries++;
62
+ this.updateAverageTime('streaming', Date.now() - startTime);
63
+ } else {
64
+ results = await this.findWithIndexed(finalCriteria, options);
65
+ this.usageStats.indexedQueries++;
66
+ this.updateAverageTime('indexed', Date.now() - startTime);
67
+ }
68
+
69
+ if (this.opts.debugMode) {
70
+ const time = Date.now() - startTime;
71
+ console.log(`⏱️ Query completed in ${time}ms using ${strategy} strategy`);
72
+ console.log(`📊 Results: ${results.length} records`);
73
+ console.log(`📊 Results type: ${typeof results}, isArray: ${Array.isArray(results)}`);
74
+ }
75
+
76
+ return results;
77
+
78
+ } catch (error) {
79
+ if (this.opts.debugMode) {
80
+ console.error('❌ Query failed:', error);
81
+ }
82
+ throw error;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Find one record
88
+ * @param {Object} criteria - Query criteria
89
+ * @param {Object} options - Query options
90
+ * @returns {Promise<Object|null>} - First matching record or null
91
+ */
92
+ async findOne(criteria, options = {}) {
93
+ if (this.database.destroyed) throw new Error('Database is destroyed')
94
+ if (!this.database.initialized) await this.database.init()
95
+ // Manual save is now the responsibility of the application
96
+
97
+ // Preprocess query to handle array field syntax automatically
98
+ const processedCriteria = this.preprocessQuery(criteria)
99
+
100
+ // Validate strict indexed mode before processing
101
+ if (this.opts.indexedQueryMode === 'strict') {
102
+ this.validateStrictQuery(processedCriteria);
103
+ }
104
+
105
+ const startTime = Date.now();
106
+ this.usageStats.totalQueries++;
107
+
108
+ try {
109
+ // Decide which strategy to use
110
+ const strategy = this.shouldUseStreaming(processedCriteria, options);
111
+
112
+ let results = [];
113
+
114
+ if (strategy === 'streaming') {
115
+ results = await this.findWithStreaming(processedCriteria, { ...options, limit: 1 });
116
+ this.usageStats.streamingQueries++;
117
+ this.updateAverageTime('streaming', Date.now() - startTime);
118
+ } else {
119
+ results = await this.findWithIndexed(processedCriteria, { ...options, limit: 1 });
120
+ this.usageStats.indexedQueries++;
121
+ this.updateAverageTime('indexed', Date.now() - startTime);
122
+ }
123
+
124
+ if (this.opts.debugMode) {
125
+ const time = Date.now() - startTime;
126
+ console.log(`⏱️ findOne completed in ${time}ms using ${strategy} strategy`);
127
+ console.log(`📊 Results: ${results.length} record(s)`);
128
+ }
129
+
130
+ // Return the first result or null if no results found
131
+ return results.length > 0 ? results[0] : null;
132
+
133
+ } catch (error) {
134
+ if (this.opts.debugMode) {
135
+ console.error('❌ findOne failed:', error);
136
+ }
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Count records matching criteria
143
+ * @param {Object} criteria - Query criteria
144
+ * @param {Object} options - Query options
145
+ * @returns {Promise<number>} - Count of matching records
146
+ */
147
+ async count(criteria, options = {}) {
148
+ if (this.database.destroyed) throw new Error('Database is destroyed')
149
+ if (!this.database.initialized) await this.database.init()
150
+ // Manual save is now the responsibility of the application
151
+
152
+ // Validate strict indexed mode before processing
153
+ if (this.opts.indexedQueryMode === 'strict') {
154
+ this.validateStrictQuery(criteria);
155
+ }
156
+
157
+ // Use the same strategy as find method
158
+ const strategy = this.shouldUseStreaming(criteria, options);
159
+
160
+ let count = 0;
161
+
162
+ if (strategy === 'streaming') {
163
+ // Use streaming approach for non-indexed fields or large result sets
164
+ const results = await this.findWithStreaming(criteria, options);
165
+ count = results.length;
166
+ } else {
167
+ // Use indexed approach for indexed fields
168
+ const results = await this.findWithIndexed(criteria, options);
169
+ count = results.length;
170
+ }
171
+
172
+ return count;
173
+ }
174
+
175
+ /**
176
+ * Compatibility method that redirects to find
177
+ * @param {Object} criteria - Query criteria
178
+ * @param {Object} options - Query options
179
+ * @returns {Promise<Array>} - Query results
180
+ */
181
+ async query(criteria, options = {}) {
182
+ return this.find(criteria, options)
183
+ }
184
+
185
+ /**
186
+ * Find using streaming strategy with pre-filtering optimization
187
+ * @param {Object} criteria - Query criteria
188
+ * @param {Object} options - Query options
189
+ * @returns {Promise<Array>} - Query results
190
+ */
191
+ async findWithStreaming(criteria, options = {}) {
192
+ if (this.opts.debugMode) {
193
+ console.log('🌊 Using streaming strategy');
194
+ }
195
+
196
+ // OPTIMIZATION: Try to use indices for pre-filtering when possible
197
+ const indexableFields = this._getIndexableFields(criteria);
198
+ if (indexableFields.length > 0) {
199
+ if (this.opts.debugMode) {
200
+ console.log(`🌊 Using pre-filtered streaming with ${indexableFields.length} indexable fields`);
201
+ }
202
+
203
+ // Use indices to pre-filter and reduce streaming scope
204
+ const preFilteredLines = this.indexManager.query(
205
+ this._extractIndexableCriteria(criteria),
206
+ options
207
+ );
208
+
209
+ // Stream only the pre-filtered records
210
+ return this._streamPreFilteredRecords(preFilteredLines, criteria, options);
211
+ }
212
+
213
+ // Fallback to full streaming
214
+ if (this.opts.debugMode) {
215
+ console.log('🌊 Using full streaming (no indexable fields found)');
216
+ }
217
+
218
+ return this._streamAllRecords(criteria, options);
219
+ }
220
+
221
+ /**
222
+ * Get indexable fields from criteria
223
+ * @param {Object} criteria - Query criteria
224
+ * @returns {Array} - Array of indexable field names
225
+ */
226
+ _getIndexableFields(criteria) {
227
+ const indexableFields = [];
228
+
229
+ if (!criteria || typeof criteria !== 'object') {
230
+ return indexableFields;
231
+ }
232
+
233
+ // Handle $and conditions
234
+ if (criteria.$and && Array.isArray(criteria.$and)) {
235
+ for (const andCondition of criteria.$and) {
236
+ indexableFields.push(...this._getIndexableFields(andCondition));
237
+ }
238
+ }
239
+
240
+ // Handle regular field conditions
241
+ for (const [field, condition] of Object.entries(criteria)) {
242
+ if (field.startsWith('$')) continue; // Skip logical operators
243
+
244
+ // RegExp conditions cannot be pre-filtered using indices
245
+ if (condition instanceof RegExp) {
246
+ continue;
247
+ }
248
+
249
+ if (this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]) {
250
+ indexableFields.push(field);
251
+ }
252
+ }
253
+
254
+ return [...new Set(indexableFields)]; // Remove duplicates
255
+ }
256
+
257
+ /**
258
+ * Extract indexable criteria for pre-filtering
259
+ * @param {Object} criteria - Full query criteria
260
+ * @returns {Object} - Criteria with only indexable fields
261
+ */
262
+ _extractIndexableCriteria(criteria) {
263
+ if (!criteria || typeof criteria !== 'object') {
264
+ return {};
265
+ }
266
+
267
+ const indexableCriteria = {};
268
+
269
+ // Handle $and conditions
270
+ if (criteria.$and && Array.isArray(criteria.$and)) {
271
+ const indexableAndConditions = criteria.$and
272
+ .map(andCondition => this._extractIndexableCriteria(andCondition))
273
+ .filter(condition => Object.keys(condition).length > 0);
274
+
275
+ if (indexableAndConditions.length > 0) {
276
+ indexableCriteria.$and = indexableAndConditions;
277
+ }
278
+ }
279
+
280
+ // Handle regular field conditions
281
+ for (const [field, condition] of Object.entries(criteria)) {
282
+ if (field.startsWith('$')) continue; // Skip logical operators
283
+
284
+ // RegExp conditions cannot be pre-filtered using indices
285
+ if (condition instanceof RegExp) {
286
+ continue;
287
+ }
288
+
289
+ if (this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]) {
290
+ indexableCriteria[field] = condition;
291
+ }
292
+ }
293
+
294
+ return indexableCriteria;
295
+ }
296
+
297
+ /**
298
+ * OPTIMIZATION 4: Stream pre-filtered records using line numbers from indices with partial index optimization
299
+ * @param {Set} preFilteredLines - Line numbers from index query
300
+ * @param {Object} criteria - Full query criteria
301
+ * @param {Object} options - Query options
302
+ * @returns {Promise<Array>} - Query results
303
+ */
304
+ async _streamPreFilteredRecords(preFilteredLines, criteria, options = {}) {
305
+ if (preFilteredLines.size === 0) {
306
+ return [];
307
+ }
308
+
309
+ const results = [];
310
+ const lineNumbers = Array.from(preFilteredLines);
311
+
312
+ // OPTIMIZATION 4: Sort line numbers for efficient file reading
313
+ lineNumbers.sort((a, b) => a - b);
314
+
315
+ // OPTIMIZATION 4: Use batch reading for better performance
316
+ const batchSize = Math.min(1000, lineNumbers.length); // Read in batches of 1000
317
+ const batches = [];
318
+
319
+ for (let i = 0; i < lineNumbers.length; i += batchSize) {
320
+ batches.push(lineNumbers.slice(i, i + batchSize));
321
+ }
322
+
323
+ for (const batch of batches) {
324
+ // OPTIMIZATION: Use ranges instead of reading entire file
325
+ const ranges = this.database.getRanges(batch);
326
+ const groupedRanges = await this.fileHandler.groupedRanges(ranges);
327
+
328
+ const fs = await import('fs');
329
+ const fd = await fs.promises.open(this.fileHandler.file, 'r');
330
+
331
+ try {
332
+ for (const groupedRange of groupedRanges) {
333
+ for await (const row of this.fileHandler.readGroupedRange(groupedRange, fd)) {
334
+ if (row.line && row.line.trim()) {
335
+ try {
336
+ // CRITICAL FIX: Use serializer.deserialize instead of JSON.parse to handle array format
337
+ const record = this.database.serializer.deserialize(row.line);
338
+
339
+ // OPTIMIZATION 4: Use optimized criteria matching for pre-filtered records
340
+ if (this._matchesCriteriaOptimized(record, criteria, options)) {
341
+ // SPACE OPTIMIZATION: Restore term IDs to terms for user (unless disabled)
342
+ const recordWithTerms = options.restoreTerms !== false ?
343
+ this.database.restoreTermIdsAfterDeserialization(record) :
344
+ record
345
+ results.push(recordWithTerms);
346
+
347
+ // Check limit
348
+ if (options.limit && results.length >= options.limit) {
349
+ return this._applyOrdering(results, options);
350
+ }
351
+ }
352
+ } catch (error) {
353
+ // Skip invalid lines
354
+ continue;
355
+ }
356
+ }
357
+ }
358
+ }
359
+ } finally {
360
+ await fd.close();
361
+ }
362
+ }
363
+
364
+ return this._applyOrdering(results, options);
365
+ }
366
+
367
+ /**
368
+ * OPTIMIZATION 4: Optimized criteria matching for pre-filtered records
369
+ * @param {Object} record - Record to check
370
+ * @param {Object} criteria - Filter criteria
371
+ * @param {Object} options - Query options
372
+ * @returns {boolean} - True if matches
373
+ */
374
+ _matchesCriteriaOptimized(record, criteria, options = {}) {
375
+ if (!criteria || Object.keys(criteria).length === 0) {
376
+ return true;
377
+ }
378
+
379
+ // Handle $not operator at the top level
380
+ if (criteria.$not && typeof criteria.$not === 'object') {
381
+ // For $not conditions, we need to negate the result
382
+ // IMPORTANT: For $not conditions, we should NOT skip pre-filtered fields
383
+ // because we need to evaluate the actual field values to determine exclusion
384
+
385
+ // Use the regular matchesCriteria method for $not conditions to ensure proper field evaluation
386
+ const notResult = this.matchesCriteria(record, criteria.$not, options);
387
+ return !notResult;
388
+ }
389
+
390
+ // OPTIMIZATION 4: Skip indexable fields since they were already pre-filtered
391
+ const indexableFields = this._getIndexableFields(criteria);
392
+
393
+ // Handle explicit logical operators at the top level
394
+ if (criteria.$or && Array.isArray(criteria.$or)) {
395
+ let orMatches = false;
396
+ for (const orCondition of criteria.$or) {
397
+ if (this._matchesCriteriaOptimized(record, orCondition, options)) {
398
+ orMatches = true;
399
+ break;
400
+ }
401
+ }
402
+
403
+ if (!orMatches) {
404
+ return false;
405
+ }
406
+ } else if (criteria.$and && Array.isArray(criteria.$and)) {
407
+ for (const andCondition of criteria.$and) {
408
+ if (!this._matchesCriteriaOptimized(record, andCondition, options)) {
409
+ return false;
410
+ }
411
+ }
412
+ }
413
+
414
+ // Handle individual field conditions (exclude logical operators and pre-filtered fields)
415
+ for (const [field, condition] of Object.entries(criteria)) {
416
+ if (field.startsWith('$')) continue;
417
+
418
+ // OPTIMIZATION 4: Skip indexable fields that were already pre-filtered
419
+ if (indexableFields.includes(field)) {
420
+ continue;
421
+ }
422
+
423
+ if (!this.matchesFieldCondition(record, field, condition, options)) {
424
+ return false;
425
+ }
426
+ }
427
+
428
+ if (criteria.$or && Array.isArray(criteria.$or)) {
429
+ return true;
430
+ }
431
+
432
+ return true;
433
+ }
434
+
435
+ /**
436
+ * OPTIMIZATION 4: Apply ordering to results
437
+ * @param {Array} results - Results to order
438
+ * @param {Object} options - Query options
439
+ * @returns {Array} - Ordered results
440
+ */
441
+ _applyOrdering(results, options) {
442
+ if (options.orderBy) {
443
+ const [field, direction = 'asc'] = options.orderBy.split(' ');
444
+ results.sort((a, b) => {
445
+ if (a[field] > b[field]) return direction === 'asc' ? 1 : -1;
446
+ if (a[field] < b[field]) return direction === 'asc' ? -1 : 1;
447
+ return 0;
448
+ });
449
+ }
450
+
451
+ return results;
452
+ }
453
+
454
+ /**
455
+ * Stream all records (fallback method)
456
+ * @param {Object} criteria - Query criteria
457
+ * @param {Object} options - Query options
458
+ * @returns {Promise<Array>} - Query results
459
+ */
460
+ async _streamAllRecords(criteria, options = {}) {
461
+ const memoryLimit = options.limit || undefined;
462
+ const streamingOptions = { ...options, limit: memoryLimit };
463
+
464
+ const results = await this.fileHandler.readWithStreaming(criteria, streamingOptions, (record, criteria) => {
465
+ return this.matchesCriteria(record, criteria, options);
466
+ }, this.serializer || null);
467
+
468
+ // Apply ordering if specified
469
+ if (options.orderBy) {
470
+ const [field, direction = 'asc'] = options.orderBy.split(' ');
471
+ results.sort((a, b) => {
472
+ if (a[field] > b[field]) return direction === 'asc' ? 1 : -1;
473
+ if (a[field] < b[field]) return direction === 'asc' ? -1 : 1;
474
+ return 0;
475
+ });
476
+ }
477
+
478
+ return results;
479
+ }
480
+
481
+ /**
482
+ * Find using indexed search strategy with real streaming
483
+ * @param {Object} criteria - Query criteria
484
+ * @param {Object} options - Query options
485
+ * @returns {Promise<Array>} - Query results
486
+ */
487
+ async findWithIndexed(criteria, options = {}) {
488
+ if (this.opts.debugMode) {
489
+ console.log('📊 Using indexed strategy with real streaming');
490
+ }
491
+
492
+ let results = []
493
+ const limit = options.limit // No default limit - return all results unless explicitly limited
494
+
495
+ // Use IndexManager to get line numbers, then read specific records
496
+ const lineNumbers = this.indexManager.query(criteria, options)
497
+ if (this.opts.debugMode) {
498
+ console.log(`🔍 IndexManager returned ${lineNumbers.size} line numbers:`, Array.from(lineNumbers))
499
+ }
500
+
501
+ // Read specific records using the line numbers
502
+ if (lineNumbers.size > 0) {
503
+ const lineNumbersArray = Array.from(lineNumbers)
504
+ const ranges = this.database.getRanges(lineNumbersArray)
505
+ const groupedRanges = await this.database.fileHandler.groupedRanges(ranges)
506
+
507
+ const fs = await import('fs')
508
+ const fd = await fs.promises.open(this.database.fileHandler.file, 'r')
509
+
510
+ try {
511
+ for (const groupedRange of groupedRanges) {
512
+ for await (const row of this.database.fileHandler.readGroupedRange(groupedRange, fd)) {
513
+ try {
514
+ const record = this.database.serializer.deserialize(row.line)
515
+ const recordWithTerms = options.restoreTerms !== false ?
516
+ this.database.restoreTermIdsAfterDeserialization(record) :
517
+ record
518
+ results.push(recordWithTerms)
519
+ if (limit && results.length >= limit) break
520
+ } catch (error) {
521
+ // Skip invalid lines
522
+ }
523
+ }
524
+ if (limit && results.length >= limit) break
525
+ }
526
+ } finally {
527
+ await fd.close()
528
+ }
529
+ }
530
+
531
+ if (options.orderBy) {
532
+ const [field, direction = 'asc'] = options.orderBy.split(' ')
533
+ results.sort((a, b) => {
534
+ if (a[field] > b[field]) return direction === 'asc' ? 1 : -1
535
+ if (a[field] < b[field]) return direction === 'asc' ? -1 : 1
536
+ return 0;
537
+ })
538
+ }
539
+ return results
540
+ }
541
+
542
+ /**
543
+ * Check if a record matches criteria
544
+ * @param {Object} record - Record to check
545
+ * @param {Object} criteria - Filter criteria
546
+ * @param {Object} options - Query options (for caseInsensitive, etc.)
547
+ * @returns {boolean} - True if matches
548
+ */
549
+ matchesCriteria(record, criteria, options = {}) {
550
+
551
+ if (!criteria || Object.keys(criteria).length === 0) {
552
+ return true;
553
+ }
554
+
555
+ // Handle explicit logical operators at the top level
556
+ if (criteria.$or && Array.isArray(criteria.$or)) {
557
+ let orMatches = false;
558
+ for (const orCondition of criteria.$or) {
559
+ if (this.matchesCriteria(record, orCondition, options)) {
560
+ orMatches = true;
561
+ break;
562
+ }
563
+ }
564
+
565
+ // If $or doesn't match, return false immediately
566
+ if (!orMatches) {
567
+ return false;
568
+ }
569
+
570
+ // If $or matches, continue to check other conditions if they exist
571
+ // Don't return true yet - we need to check other conditions
572
+ } else if (criteria.$and && Array.isArray(criteria.$and)) {
573
+ for (const andCondition of criteria.$and) {
574
+ if (!this.matchesCriteria(record, andCondition, options)) {
575
+ return false;
576
+ }
577
+ }
578
+ // $and matches, continue to check other conditions if they exist
579
+ }
580
+
581
+ // Handle individual field conditions and $not operator
582
+ for (const [field, condition] of Object.entries(criteria)) {
583
+ // Skip logical operators that are handled above
584
+ if (field.startsWith('$') && field !== '$not') {
585
+ continue;
586
+ }
587
+
588
+ if (field === '$not') {
589
+ // Handle $not operator - it should negate the result of its condition
590
+ if (typeof condition === 'object' && condition !== null) {
591
+ // Empty $not condition should not exclude anything
592
+ if (Object.keys(condition).length === 0) {
593
+ continue; // Don't exclude anything
594
+ }
595
+
596
+ // Check if the $not condition matches - if it does, this record should be excluded
597
+ if (this.matchesCriteria(record, condition, options)) {
598
+ return false; // Exclude this record
599
+ }
600
+ }
601
+ } else {
602
+ // Handle regular field conditions
603
+ if (!this.matchesFieldCondition(record, field, condition, options)) {
604
+ return false;
605
+ }
606
+ }
607
+ }
608
+
609
+ // If we have $or conditions and they matched, return true
610
+ if (criteria.$or && Array.isArray(criteria.$or)) {
611
+ return true;
612
+ }
613
+
614
+ // For other cases (no $or, or $and, or just field conditions), return true if we got this far
615
+ return true;
616
+ }
617
+
618
+ /**
619
+ * Check if a field matches a condition
620
+ * @param {Object} record - Record to check
621
+ * @param {string} field - Field name
622
+ * @param {*} condition - Condition to match
623
+ * @param {Object} options - Query options
624
+ * @returns {boolean} - True if matches
625
+ */
626
+ matchesFieldCondition(record, field, condition, options = {}) {
627
+ const value = record[field];
628
+
629
+ // Debug logging for all field conditions
630
+ if (this.database.opts.debugMode) {
631
+ console.log(`🔍 Checking field '${field}':`, { value, condition, record: record.name || record.id });
632
+ }
633
+
634
+ // Debug logging for term mapping fields
635
+ if (this.database.opts.termMapping && Object.keys(this.database.opts.indexes || {}).includes(field)) {
636
+ if (this.database.opts.debugMode) {
637
+ console.log(`🔍 Checking term mapping field '${field}':`, { value, condition, record: record.name || record.id });
638
+ }
639
+ }
640
+
641
+ // Handle null/undefined values
642
+ if (value === null || value === undefined) {
643
+ return condition === null || condition === undefined;
644
+ }
645
+
646
+ // Handle regex conditions (MUST come before object check since RegExp is an object)
647
+ if (condition instanceof RegExp) {
648
+ // For array fields, test regex against each element
649
+ if (Array.isArray(value)) {
650
+ return value.some(element => condition.test(String(element)));
651
+ }
652
+ // For non-array fields, test regex against the value directly
653
+ return condition.test(String(value));
654
+ }
655
+
656
+ // Handle array conditions
657
+ if (Array.isArray(condition)) {
658
+ // For array fields, check if any element in the field matches any element in the condition
659
+ if (Array.isArray(value)) {
660
+ return condition.some(condVal => value.includes(condVal));
661
+ }
662
+ // For non-array fields, check if value is in condition
663
+ return condition.includes(value);
664
+ }
665
+
666
+ // Handle object conditions (operators)
667
+ if (typeof condition === 'object' && !Array.isArray(condition)) {
668
+ for (const [operator, operatorValue] of Object.entries(condition)) {
669
+ if (!this.matchesOperator(value, operator, operatorValue, options)) {
670
+ return false;
671
+ }
672
+ }
673
+ return true;
674
+ }
675
+
676
+ // Handle case-insensitive string comparison
677
+ if (options.caseInsensitive && typeof value === 'string' && typeof condition === 'string') {
678
+ return value.toLowerCase() === condition.toLowerCase();
679
+ }
680
+
681
+ // Handle direct array field search (e.g., { nameTerms: 'channel' })
682
+ if (Array.isArray(value) && typeof condition === 'string') {
683
+ return value.includes(condition);
684
+ }
685
+
686
+ // Simple equality
687
+ return value === condition;
688
+ }
689
+
690
+ /**
691
+ * Check if a value matches an operator condition
692
+ * @param {*} value - Value to check
693
+ * @param {string} operator - Operator
694
+ * @param {*} operatorValue - Operator value
695
+ * @param {Object} options - Query options
696
+ * @returns {boolean} - True if matches
697
+ */
698
+ matchesOperator(value, operator, operatorValue, options = {}) {
699
+ switch (operator) {
700
+ case '$gt':
701
+ return value > operatorValue;
702
+ case '$gte':
703
+ return value >= operatorValue;
704
+ case '$lt':
705
+ return value < operatorValue;
706
+ case '$lte':
707
+ return value <= operatorValue;
708
+ case '$ne':
709
+ return value !== operatorValue;
710
+ case '$not':
711
+ // $not operator should be handled at the criteria level, not field level
712
+ // This is a fallback for backward compatibility
713
+ return value !== operatorValue;
714
+ case '$in':
715
+ if (Array.isArray(value)) {
716
+ // For array fields, check if any element in the array matches any value in operatorValue
717
+ return Array.isArray(operatorValue) && operatorValue.some(opVal => value.includes(opVal));
718
+ } else {
719
+ // For non-array fields, check if value is in operatorValue
720
+ return Array.isArray(operatorValue) && operatorValue.includes(value);
721
+ }
722
+ case '$nin':
723
+ if (Array.isArray(value)) {
724
+ // For array fields, check if NO elements in the array match any value in operatorValue
725
+ return Array.isArray(operatorValue) && !operatorValue.some(opVal => value.includes(opVal));
726
+ } else {
727
+ // For non-array fields, check if value is not in operatorValue
728
+ return Array.isArray(operatorValue) && !operatorValue.includes(value);
729
+ }
730
+ case '$regex':
731
+ const regex = new RegExp(operatorValue, options.caseInsensitive ? 'i' : '');
732
+ // For array fields, test regex against each element
733
+ if (Array.isArray(value)) {
734
+ return value.some(element => regex.test(String(element)));
735
+ }
736
+ // For non-array fields, test regex against the value directly
737
+ return regex.test(String(value));
738
+ case '$contains':
739
+ if (Array.isArray(value)) {
740
+ return value.includes(operatorValue);
741
+ }
742
+ return String(value).includes(String(operatorValue));
743
+ case '$all':
744
+ if (!Array.isArray(value) || !Array.isArray(operatorValue)) {
745
+ return false;
746
+ }
747
+ return operatorValue.every(item => value.includes(item));
748
+ case '$exists':
749
+ return operatorValue ? (value !== undefined && value !== null) : (value === undefined || value === null);
750
+ case '$size':
751
+ if (Array.isArray(value)) {
752
+ return value.length === operatorValue;
753
+ }
754
+ return false;
755
+ default:
756
+ return false;
757
+ }
758
+ }
759
+
760
+ /**
761
+ * Preprocess query to handle array field syntax automatically
762
+ * @param {Object} criteria - Query criteria
763
+ * @returns {Object} - Processed criteria
764
+ */
765
+ preprocessQuery(criteria) {
766
+ if (!criteria || typeof criteria !== 'object') {
767
+ return criteria
768
+ }
769
+
770
+ const processed = {}
771
+
772
+ for (const [field, value] of Object.entries(criteria)) {
773
+ // Check if this is a term mapping field
774
+ const isTermMappingField = this.database.opts.termMapping &&
775
+ this.database.termManager &&
776
+ this.database.termManager.termMappingFields &&
777
+ this.database.termManager.termMappingFields.includes(field)
778
+
779
+ if (isTermMappingField) {
780
+ // Handle term mapping field queries
781
+ if (typeof value === 'string') {
782
+ // Convert term to $in query for term mapping fields
783
+ processed[field] = { $in: [value] }
784
+ } else if (Array.isArray(value)) {
785
+ // Convert array to $in query
786
+ processed[field] = { $in: value }
787
+ } else if (value && typeof value === 'object') {
788
+ // Handle special query operators for term mapping
789
+ if (value.$in) {
790
+ processed[field] = { $in: value.$in }
791
+ } else if (value.$all) {
792
+ processed[field] = { $all: value.$all }
793
+ } else {
794
+ processed[field] = value
795
+ }
796
+ } else {
797
+ // Invalid value for term mapping field
798
+ throw new Error(`Invalid query for array field '${field}'. Use { $in: [value] } syntax or direct value.`)
799
+ }
800
+
801
+ if (this.database.opts.debugMode) {
802
+ console.log(`🔍 Processed term mapping query for field '${field}':`, processed[field])
803
+ }
804
+ } else {
805
+ // Check if this field is defined as an array in the schema
806
+ const indexes = this.opts.indexes || {}
807
+ const fieldConfig = indexes[field]
808
+ const isArrayField = fieldConfig &&
809
+ (Array.isArray(fieldConfig) && fieldConfig.includes('array') ||
810
+ fieldConfig === 'array:string' ||
811
+ fieldConfig === 'array:number' ||
812
+ fieldConfig === 'array:boolean')
813
+
814
+ if (isArrayField) {
815
+ // Handle array field queries
816
+ if (typeof value === 'string' || typeof value === 'number') {
817
+ // Convert direct value to $in query for array fields
818
+ processed[field] = { $in: [value] }
819
+ } else if (Array.isArray(value)) {
820
+ // Convert array to $in query
821
+ processed[field] = { $in: value }
822
+ } else if (value && typeof value === 'object') {
823
+ // Already properly formatted query object
824
+ processed[field] = value
825
+ } else {
826
+ // Invalid value for array field
827
+ throw new Error(`Invalid query for array field '${field}'. Use { $in: [value] } syntax or direct value.`)
828
+ }
829
+ } else {
830
+ // Non-array field, keep as is
831
+ processed[field] = value
832
+ }
833
+ }
834
+ }
835
+
836
+ return processed
837
+ }
838
+
839
+ /**
840
+ * Determine which query strategy to use
841
+ * @param {Object} criteria - Query criteria
842
+ * @param {Object} options - Query options
843
+ * @returns {string} - 'streaming' or 'indexed'
844
+ */
845
+ shouldUseStreaming(criteria, options = {}) {
846
+ const { limit } = options; // No default limit
847
+ const totalRecords = this.database.length || 0;
848
+
849
+ // Strategy 1: Always streaming for queries without criteria
850
+ if (!criteria || Object.keys(criteria).length === 0) {
851
+ if (this.opts.debugMode) {
852
+ console.log('📊 QueryStrategy: STREAMING - No criteria provided');
853
+ }
854
+ return 'streaming';
855
+ }
856
+
857
+ // Strategy 2: Check if all fields are indexed and support the operators used
858
+ // First, check if $not is present at root level - if so, we need to use streaming for proper $not handling
859
+ if (criteria.$not && !this.opts.termMapping) {
860
+ if (this.opts.debugMode) {
861
+ console.log('📊 QueryStrategy: STREAMING - $not operator requires streaming mode');
862
+ }
863
+ return 'streaming';
864
+ }
865
+
866
+ // OPTIMIZATION: For term mapping, we can process $not using indices
867
+ if (criteria.$not && this.opts.termMapping) {
868
+ // Check if all $not fields are indexed
869
+ const notFields = Object.keys(criteria.$not)
870
+ const allNotFieldsIndexed = notFields.every(field =>
871
+ this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]
872
+ )
873
+
874
+ if (allNotFieldsIndexed) {
875
+ if (this.opts.debugMode) {
876
+ console.log('📊 QueryStrategy: INDEXED - $not with term mapping can use indexed strategy');
877
+ }
878
+ // Continue to check other conditions instead of forcing streaming
879
+ } else {
880
+ if (this.opts.debugMode) {
881
+ console.log('📊 QueryStrategy: STREAMING - $not fields not all indexed');
882
+ }
883
+ return 'streaming';
884
+ }
885
+ }
886
+
887
+ // Handle $and queries - check if all conditions in $and are indexable
888
+ if (criteria.$and && Array.isArray(criteria.$and)) {
889
+ const allAndConditionsIndexed = criteria.$and.every(andCondition => {
890
+ // Handle $not conditions within $and
891
+ if (andCondition.$not) {
892
+ const notFields = Object.keys(andCondition.$not);
893
+ return notFields.every(field => {
894
+ if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
895
+ return false;
896
+ }
897
+ // For term mapping, $not can be processed with indices
898
+ return this.opts.termMapping && Object.keys(this.opts.indexes || {}).includes(field);
899
+ });
900
+ }
901
+
902
+ // Handle regular field conditions
903
+ return Object.keys(andCondition).every(field => {
904
+ if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
905
+ return false;
906
+ }
907
+
908
+ const condition = andCondition[field];
909
+
910
+ // RegExp cannot be efficiently queried using indices - must use streaming
911
+ if (condition instanceof RegExp) {
912
+ return false;
913
+ }
914
+
915
+ if (typeof condition === 'object' && !Array.isArray(condition)) {
916
+ const operators = Object.keys(condition);
917
+
918
+ if (this.opts.termMapping && Object.keys(this.opts.indexes || {}).includes(field)) {
919
+ return operators.every(op => {
920
+ return !['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size'].includes(op);
921
+ });
922
+ } else {
923
+ return operators.every(op => {
924
+ return !['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size'].includes(op);
925
+ });
926
+ }
927
+ }
928
+ return true;
929
+ });
930
+ });
931
+
932
+ if (!allAndConditionsIndexed) {
933
+ if (this.opts.debugMode) {
934
+ console.log('📊 QueryStrategy: STREAMING - Some $and conditions not indexed or operators not supported');
935
+ }
936
+ return 'streaming';
937
+ }
938
+ }
939
+
940
+ const allFieldsIndexed = Object.keys(criteria).every(field => {
941
+ // Skip $and as it's handled separately above
942
+ if (field === '$and') return true;
943
+
944
+ if (!this.opts.indexes || !this.opts.indexes[field]) {
945
+ if (this.opts.debugMode) {
946
+ console.log(`🔍 Field '${field}' not indexed. Available indexes:`, Object.keys(this.opts.indexes || {}))
947
+ }
948
+ return false;
949
+ }
950
+
951
+ // Check if the field uses operators that are supported by IndexManager
952
+ const condition = criteria[field];
953
+
954
+ // RegExp cannot be efficiently queried using indices - must use streaming
955
+ if (condition instanceof RegExp) {
956
+ if (this.opts.debugMode) {
957
+ console.log(`🔍 Field '${field}' uses RegExp - requires streaming strategy`)
958
+ }
959
+ return false;
960
+ }
961
+
962
+ if (typeof condition === 'object' && !Array.isArray(condition)) {
963
+ const operators = Object.keys(condition);
964
+ if (this.opts.debugMode) {
965
+ console.log(`🔍 Field '${field}' has operators:`, operators)
966
+ }
967
+
968
+ // With term mapping enabled, we can support complex operators via partial reads
969
+ if (this.opts.termMapping && Object.keys(this.opts.indexes || {}).includes(field)) {
970
+ // Term mapping fields can use complex operators with partial reads
971
+ return operators.every(op => {
972
+ // Support $in, $nin, $all, $not for term mapping fields (converted to multiple equality checks)
973
+ return !['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size'].includes(op);
974
+ });
975
+ } else {
976
+ // Non-term-mapping fields only support simple equality operations
977
+ return operators.every(op => {
978
+ return !['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size'].includes(op);
979
+ });
980
+ }
981
+ }
982
+ return true;
983
+ });
984
+
985
+ if (!allFieldsIndexed) {
986
+ if (this.opts.debugMode) {
987
+ console.log('📊 QueryStrategy: STREAMING - Some fields not indexed or operators not supported');
988
+ }
989
+ return 'streaming';
990
+ }
991
+
992
+ // OPTIMIZATION 2: Hybrid strategy - use pre-filtered streaming when index is empty
993
+ const indexData = this.indexManager.index.data || {};
994
+ const hasIndexData = Object.keys(indexData).length > 0;
995
+ if (!hasIndexData) {
996
+ // Check if we can use pre-filtered streaming with term mapping
997
+ if (this.opts.termMapping && this._canUsePreFilteredStreaming(criteria)) {
998
+ if (this.opts.debugMode) {
999
+ console.log('📊 QueryStrategy: HYBRID - Using pre-filtered streaming with term mapping');
1000
+ }
1001
+ return 'streaming'; // Will use pre-filtered streaming in findWithStreaming
1002
+ }
1003
+
1004
+ if (this.opts.debugMode) {
1005
+ console.log('📊 QueryStrategy: STREAMING - Index is empty and no pre-filtering available');
1006
+ }
1007
+ return 'streaming';
1008
+ }
1009
+
1010
+ // Strategy 3: Streaming if limit is very high (only if database has records)
1011
+ if (totalRecords > 0 && limit > totalRecords * this.opts.streamingThreshold) {
1012
+ if (this.opts.debugMode) {
1013
+ console.log(`📊 QueryStrategy: STREAMING - High limit (${limit} > ${Math.round(totalRecords * this.opts.streamingThreshold)})`);
1014
+ }
1015
+ return 'streaming';
1016
+ }
1017
+
1018
+ // Strategy 4: Use indexed strategy when all fields are indexed and streamingThreshold is respected
1019
+ if (this.opts.debugMode) {
1020
+ console.log(`📊 QueryStrategy: INDEXED - All fields indexed, using indexed strategy`);
1021
+ }
1022
+ return 'indexed';
1023
+ }
1024
+
1025
+ /**
1026
+ * Estimate number of results for a query
1027
+ * @param {Object} criteria - Query criteria
1028
+ * @param {number} totalRecords - Total records in database
1029
+ * @returns {number} - Estimated results
1030
+ */
1031
+ estimateQueryResults(criteria, totalRecords) {
1032
+ // If database is empty, return 0
1033
+ if (totalRecords === 0) {
1034
+ if (this.opts.debugMode) {
1035
+ console.log(`📊 Estimation: Database empty → 0 results`);
1036
+ }
1037
+ return 0;
1038
+ }
1039
+
1040
+ let minResults = Infinity;
1041
+
1042
+ for (const [field, condition] of Object.entries(criteria)) {
1043
+ // Check if field is indexed
1044
+ if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
1045
+ // Non-indexed field - assume it could match any record
1046
+ if (this.opts.debugMode) {
1047
+ console.log(`📊 Estimation: ${field} = non-indexed → ~${totalRecords} results`);
1048
+ }
1049
+ return totalRecords;
1050
+ }
1051
+
1052
+ const fieldIndex = this.indexManager.index.data[field];
1053
+
1054
+ if (!fieldIndex) {
1055
+ // Non-indexed field - assume it could match any record
1056
+ if (this.opts.debugMode) {
1057
+ console.log(`📊 Estimation: ${field} = non-indexed → ~${totalRecords} results`);
1058
+ }
1059
+ return totalRecords;
1060
+ }
1061
+
1062
+ let fieldEstimate = 0;
1063
+
1064
+ if (typeof condition === 'object' && !Array.isArray(condition)) {
1065
+ // Handle different types of operators
1066
+ for (const [operator, value] of Object.entries(condition)) {
1067
+ if (operator === '$all') {
1068
+ // Special handling for $all operator
1069
+ fieldEstimate = this.estimateAllOperator(fieldIndex, value);
1070
+ } else if (['$gt', '$gte', '$lt', '$lte', '$in', '$regex'].includes(operator)) {
1071
+ // Numeric and other operators
1072
+ fieldEstimate = this.estimateOperatorResults(fieldIndex, operator, value, totalRecords);
1073
+ } else {
1074
+ // Unknown operator, assume it could match any record
1075
+ fieldEstimate = totalRecords;
1076
+ }
1077
+ }
1078
+ } else {
1079
+ // Simple equality
1080
+ const recordIds = fieldIndex[condition];
1081
+ fieldEstimate = recordIds ? recordIds.length : 0;
1082
+ }
1083
+
1084
+ if (this.opts.debugMode) {
1085
+ console.log(`📊 Estimation: ${field} = ${fieldEstimate} results`);
1086
+ }
1087
+
1088
+ minResults = Math.min(minResults, fieldEstimate);
1089
+ }
1090
+
1091
+ return minResults === Infinity ? 0 : minResults;
1092
+ }
1093
+
1094
+ /**
1095
+ * Estimate results for $all operator
1096
+ * @param {Object} fieldIndex - Field index
1097
+ * @param {Array} values - Values to match
1098
+ * @returns {number} - Estimated results
1099
+ */
1100
+ estimateAllOperator(fieldIndex, values) {
1101
+ if (!Array.isArray(values) || values.length === 0) {
1102
+ return 0;
1103
+ }
1104
+
1105
+ let minCount = Infinity;
1106
+ for (const value of values) {
1107
+ const recordIds = fieldIndex[value];
1108
+ const count = recordIds ? recordIds.length : 0;
1109
+ minCount = Math.min(minCount, count);
1110
+ }
1111
+
1112
+ return minCount === Infinity ? 0 : minCount;
1113
+ }
1114
+
1115
+ /**
1116
+ * Estimate results for operators
1117
+ * @param {Object} fieldIndex - Field index
1118
+ * @param {string} operator - Operator
1119
+ * @param {*} value - Value
1120
+ * @param {number} totalRecords - Total records
1121
+ * @returns {number} - Estimated results
1122
+ */
1123
+ estimateOperatorResults(fieldIndex, operator, value, totalRecords) {
1124
+ // This is a simplified estimation - in practice, you might want more sophisticated logic
1125
+ switch (operator) {
1126
+ case '$in':
1127
+ if (Array.isArray(value)) {
1128
+ let total = 0;
1129
+ for (const v of value) {
1130
+ const recordIds = fieldIndex[v];
1131
+ if (recordIds) total += recordIds.length;
1132
+ }
1133
+ return total;
1134
+ }
1135
+ break;
1136
+ case '$gt':
1137
+ case '$gte':
1138
+ case '$lt':
1139
+ case '$lte':
1140
+ // For range queries, estimate based on data distribution
1141
+ // This is a simplified approach - real implementation would be more sophisticated
1142
+ return Math.floor(totalRecords * 0.1); // Assume 10% of records match
1143
+ case '$regex':
1144
+ // Regex is hard to estimate without scanning
1145
+ return Math.floor(totalRecords * 0.05); // Assume 5% of records match
1146
+ }
1147
+ return 0;
1148
+ }
1149
+
1150
+ /**
1151
+ * Validate strict query mode
1152
+ * @param {Object} criteria - Query criteria
1153
+ */
1154
+ validateStrictQuery(criteria) {
1155
+ if (!criteria || Object.keys(criteria).length === 0) {
1156
+ return; // Empty criteria are always allowed
1157
+ }
1158
+
1159
+ // Handle logical operators at the top level
1160
+ if (criteria.$not) {
1161
+ this.validateStrictQuery(criteria.$not);
1162
+ return;
1163
+ }
1164
+
1165
+ if (criteria.$or && Array.isArray(criteria.$or)) {
1166
+ for (const orCondition of criteria.$or) {
1167
+ this.validateStrictQuery(orCondition);
1168
+ }
1169
+ return;
1170
+ }
1171
+
1172
+ if (criteria.$and && Array.isArray(criteria.$and)) {
1173
+ for (const andCondition of criteria.$and) {
1174
+ this.validateStrictQuery(andCondition);
1175
+ }
1176
+ return;
1177
+ }
1178
+
1179
+ // Get available indexed fields
1180
+ const indexedFields = Object.keys(this.indexManager.opts.indexes || {});
1181
+ const availableFields = indexedFields.length > 0 ? indexedFields.join(', ') : 'none';
1182
+
1183
+ // Check each field
1184
+ const nonIndexedFields = [];
1185
+ for (const [field, condition] of Object.entries(criteria)) {
1186
+ // Skip logical operators
1187
+ if (field.startsWith('$')) {
1188
+ continue;
1189
+ }
1190
+
1191
+ // Check if field is indexed
1192
+ if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
1193
+ nonIndexedFields.push(field);
1194
+ }
1195
+
1196
+ // Check if condition uses supported operators
1197
+ if (typeof condition === 'object' && !Array.isArray(condition)) {
1198
+ const operators = Object.keys(condition);
1199
+ for (const op of operators) {
1200
+ if (!['$in', '$nin', '$contains', '$all', '>', '>=', '<', '<=', '!=', 'contains', 'regex'].includes(op)) {
1201
+ throw new Error(`Operator '${op}' is not supported in strict mode for field '${field}'.`);
1202
+ }
1203
+ }
1204
+ }
1205
+ }
1206
+
1207
+ // Generate appropriate error message
1208
+ if (nonIndexedFields.length > 0) {
1209
+ if (nonIndexedFields.length === 1) {
1210
+ throw new Error(`Strict indexed mode: Field '${nonIndexedFields[0]}' is not indexed. Available indexed fields: ${availableFields}`);
1211
+ } else {
1212
+ throw new Error(`Strict indexed mode: Fields '${nonIndexedFields.join("', '")}' are not indexed. Available indexed fields: ${availableFields}`);
1213
+ }
1214
+ }
1215
+ }
1216
+
1217
+ /**
1218
+ * Update average time for performance tracking
1219
+ * @param {string} type - Type of operation ('streaming' or 'indexed')
1220
+ * @param {number} time - Time taken
1221
+ */
1222
+ updateAverageTime(type, time) {
1223
+ if (!this.usageStats[`${type}AverageTime`]) {
1224
+ this.usageStats[`${type}AverageTime`] = 0;
1225
+ }
1226
+
1227
+ const currentAverage = this.usageStats[`${type}AverageTime`];
1228
+ const count = this.usageStats[`${type}Queries`] || 1;
1229
+
1230
+ // Calculate running average
1231
+ this.usageStats[`${type}AverageTime`] = (currentAverage * (count - 1) + time) / count;
1232
+ }
1233
+
1234
+ /**
1235
+ * OPTIMIZATION 2: Check if we can use pre-filtered streaming with term mapping
1236
+ * @param {Object} criteria - Query criteria
1237
+ * @returns {boolean} - True if pre-filtered streaming can be used
1238
+ */
1239
+ _canUsePreFilteredStreaming(criteria) {
1240
+ if (!criteria || typeof criteria !== 'object') {
1241
+ return false;
1242
+ }
1243
+
1244
+ // Check if we have term mapping fields in the query
1245
+ const termMappingFields = Object.keys(this.opts.indexes || {});
1246
+ const queryFields = Object.keys(criteria).filter(field => !field.startsWith('$'));
1247
+
1248
+ // Check if any query field is a term mapping field
1249
+ const hasTermMappingFields = queryFields.some(field => termMappingFields.includes(field));
1250
+
1251
+ if (!hasTermMappingFields) {
1252
+ return false;
1253
+ }
1254
+
1255
+ // Check if the query is simple enough for pre-filtering
1256
+ // Simple equality queries on term mapping fields work well with pre-filtering
1257
+ for (const [field, condition] of Object.entries(criteria)) {
1258
+ if (field.startsWith('$')) continue;
1259
+
1260
+ if (termMappingFields.includes(field)) {
1261
+ // For term mapping fields, simple equality or $in queries work well
1262
+ if (typeof condition === 'string' ||
1263
+ (typeof condition === 'object' && condition.$in && Array.isArray(condition.$in))) {
1264
+ return true;
1265
+ }
1266
+ }
1267
+ }
1268
+
1269
+ return false;
1270
+ }
1271
+
1272
+ // Simplified term mapping - handled in TermManager
1273
+ }