jexidb 2.0.3 → 2.1.1

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