jexidb 2.1.0 → 2.1.2

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 (41) hide show
  1. package/dist/Database.cjs +9253 -437
  2. package/package.json +9 -2
  3. package/src/Database.mjs +1572 -212
  4. package/src/FileHandler.mjs +83 -44
  5. package/src/OperationQueue.mjs +23 -23
  6. package/src/SchemaManager.mjs +325 -268
  7. package/src/Serializer.mjs +234 -24
  8. package/src/managers/IndexManager.mjs +778 -87
  9. package/src/managers/QueryManager.mjs +340 -67
  10. package/src/managers/TermManager.mjs +7 -7
  11. package/src/utils/operatorNormalizer.mjs +116 -0
  12. package/.babelrc +0 -13
  13. package/.gitattributes +0 -2
  14. package/CHANGELOG.md +0 -140
  15. package/babel.config.json +0 -5
  16. package/docs/API.md +0 -1051
  17. package/docs/EXAMPLES.md +0 -701
  18. package/docs/README.md +0 -194
  19. package/examples/iterate-usage-example.js +0 -157
  20. package/examples/simple-iterate-example.js +0 -115
  21. package/jest.config.js +0 -24
  22. package/scripts/README.md +0 -47
  23. package/scripts/clean-test-files.js +0 -75
  24. package/scripts/prepare.js +0 -31
  25. package/scripts/run-tests.js +0 -80
  26. package/test/$not-operator-with-and.test.js +0 -282
  27. package/test/README.md +0 -8
  28. package/test/close-init-cycle.test.js +0 -256
  29. package/test/critical-bugs-fixes.test.js +0 -1069
  30. package/test/index-persistence.test.js +0 -306
  31. package/test/index-serialization.test.js +0 -314
  32. package/test/indexed-query-mode.test.js +0 -360
  33. package/test/iterate-method.test.js +0 -272
  34. package/test/query-operators.test.js +0 -238
  35. package/test/regex-array-fields.test.js +0 -129
  36. package/test/score-method.test.js +0 -238
  37. package/test/setup.js +0 -17
  38. package/test/term-mapping-minimal.test.js +0 -154
  39. package/test/term-mapping-simple.test.js +0 -257
  40. package/test/term-mapping.test.js +0 -514
  41. package/test/writebuffer-flush-resilience.test.js +0 -204
@@ -1,3 +1,5 @@
1
+ import { normalizeOperator } from '../utils/operatorNormalizer.mjs'
2
+
1
3
  /**
2
4
  * QueryManager - Handles all query operations and strategies
3
5
  *
@@ -35,6 +37,9 @@ export class QueryManager {
35
37
  if (this.database.destroyed) throw new Error('Database is destroyed')
36
38
  if (!this.database.initialized) await this.database.init()
37
39
 
40
+ // Rebuild indexes if needed (when index was corrupted/missing)
41
+ await this.database._rebuildIndexesIfNeeded()
42
+
38
43
  // Manual save is now the responsibility of the application
39
44
 
40
45
  // Preprocess query to handle array field syntax automatically
@@ -44,8 +49,8 @@ export class QueryManager {
44
49
 
45
50
  // Validate strict indexed mode before processing
46
51
  if (this.opts.indexedQueryMode === 'strict') {
47
- this.validateStrictQuery(finalCriteria);
48
- }
52
+ this.validateStrictQuery(finalCriteria, options);
53
+ }
49
54
 
50
55
  const startTime = Date.now();
51
56
  this.usageStats.totalQueries++;
@@ -99,7 +104,7 @@ export class QueryManager {
99
104
 
100
105
  // Validate strict indexed mode before processing
101
106
  if (this.opts.indexedQueryMode === 'strict') {
102
- this.validateStrictQuery(processedCriteria);
107
+ this.validateStrictQuery(processedCriteria, options);
103
108
  }
104
109
 
105
110
  const startTime = Date.now();
@@ -147,11 +152,15 @@ export class QueryManager {
147
152
  async count(criteria, options = {}) {
148
153
  if (this.database.destroyed) throw new Error('Database is destroyed')
149
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
+
150
159
  // Manual save is now the responsibility of the application
151
160
 
152
161
  // Validate strict indexed mode before processing
153
162
  if (this.opts.indexedQueryMode === 'strict') {
154
- this.validateStrictQuery(criteria);
163
+ this.validateStrictQuery(criteria, options);
155
164
  }
156
165
 
157
166
  // Use the same strategy as find method
@@ -164,9 +173,50 @@ export class QueryManager {
164
173
  const results = await this.findWithStreaming(criteria, options);
165
174
  count = results.length;
166
175
  } else {
167
- // Use indexed approach for indexed fields
168
- const results = await this.findWithIndexed(criteria, options);
169
- count = results.length;
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
+ }
170
220
  }
171
221
 
172
222
  return count;
@@ -189,33 +239,43 @@ export class QueryManager {
189
239
  * @returns {Promise<Array>} - Query results
190
240
  */
191
241
  async findWithStreaming(criteria, options = {}) {
242
+ const streamingOptions = { ...options }
243
+ const forceFullScan = streamingOptions.forceFullScan === true
244
+ delete streamingOptions.forceFullScan
245
+
192
246
  if (this.opts.debugMode) {
193
- console.log('🌊 Using streaming strategy');
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
+ }
194
252
  }
195
253
 
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`);
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);
201
270
  }
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
271
  }
212
272
 
213
273
  // Fallback to full streaming
214
274
  if (this.opts.debugMode) {
215
- console.log('🌊 Using full streaming (no indexable fields found)');
275
+ console.log('🌊 Using full streaming (no indexable fields found or forced)');
216
276
  }
217
277
 
218
- return this._streamAllRecords(criteria, options);
278
+ return this._streamAllRecords(criteria, streamingOptions);
219
279
  }
220
280
 
221
281
  /**
@@ -277,9 +337,26 @@ export class QueryManager {
277
337
  }
278
338
  }
279
339
 
340
+ // Handle $not operator - include it if it can be processed by IndexManager
341
+ if (criteria.$not && typeof criteria.$not === 'object') {
342
+ // Check if $not condition contains only indexable fields
343
+ const notFields = Object.keys(criteria.$not);
344
+ const allNotFieldsIndexed = notFields.every(field =>
345
+ this.indexManager.opts.indexes && this.indexManager.opts.indexes[field]
346
+ );
347
+
348
+ if (allNotFieldsIndexed && notFields.length > 0) {
349
+ // Extract indexable criteria from $not condition
350
+ const indexableNotCriteria = this._extractIndexableCriteria(criteria.$not);
351
+ if (Object.keys(indexableNotCriteria).length > 0) {
352
+ indexableCriteria.$not = indexableNotCriteria;
353
+ }
354
+ }
355
+ }
356
+
280
357
  // Handle regular field conditions
281
358
  for (const [field, condition] of Object.entries(criteria)) {
282
- if (field.startsWith('$')) continue; // Skip logical operators
359
+ if (field.startsWith('$')) continue; // Skip logical operators (already handled above)
283
360
 
284
361
  // RegExp conditions cannot be pre-filtered using indices
285
362
  if (condition instanceof RegExp) {
@@ -294,6 +371,96 @@ export class QueryManager {
294
371
  return indexableCriteria;
295
372
  }
296
373
 
374
+ /**
375
+ * Determine whether the database currently has any records (persisted or pending)
376
+ * @returns {boolean}
377
+ */
378
+ _hasAnyRecords() {
379
+ if (!this.database) {
380
+ return false
381
+ }
382
+
383
+ if (Array.isArray(this.database.offsets) && this.database.offsets.length > 0) {
384
+ return true
385
+ }
386
+
387
+ if (Array.isArray(this.database.writeBuffer) && this.database.writeBuffer.length > 0) {
388
+ return true
389
+ }
390
+
391
+ if (typeof this.database.length === 'number' && this.database.length > 0) {
392
+ return true
393
+ }
394
+
395
+ return false
396
+ }
397
+
398
+ /**
399
+ * Extract all indexed fields referenced in the criteria
400
+ * @param {Object} criteria
401
+ * @param {Set<string>} accumulator
402
+ * @returns {Array<string>}
403
+ */
404
+ _extractIndexedFields(criteria, accumulator = new Set()) {
405
+ if (!criteria) {
406
+ return Array.from(accumulator)
407
+ }
408
+
409
+ if (Array.isArray(criteria)) {
410
+ for (const item of criteria) {
411
+ this._extractIndexedFields(item, accumulator)
412
+ }
413
+ return Array.from(accumulator)
414
+ }
415
+
416
+ if (typeof criteria !== 'object') {
417
+ return Array.from(accumulator)
418
+ }
419
+
420
+ for (const [key, value] of Object.entries(criteria)) {
421
+ if (key.startsWith('$')) {
422
+ this._extractIndexedFields(value, accumulator)
423
+ continue
424
+ }
425
+
426
+ accumulator.add(key)
427
+
428
+ if (Array.isArray(value)) {
429
+ for (const nested of value) {
430
+ this._extractIndexedFields(nested, accumulator)
431
+ }
432
+ }
433
+ }
434
+
435
+ return Array.from(accumulator)
436
+ }
437
+
438
+ /**
439
+ * Identify indexed fields present in criteria whose index data is missing
440
+ * @param {Object} criteria
441
+ * @returns {Array<string>}
442
+ */
443
+ _getIndexedFieldsWithMissingData(criteria) {
444
+ if (!this.indexManager || !criteria) {
445
+ return []
446
+ }
447
+
448
+ const indexedFields = this._extractIndexedFields(criteria)
449
+ const missing = []
450
+
451
+ for (const field of indexedFields) {
452
+ if (!this.indexManager.isFieldIndexed(field)) {
453
+ continue
454
+ }
455
+
456
+ if (!this.indexManager.hasUsableIndexData(field)) {
457
+ missing.push(field)
458
+ }
459
+ }
460
+
461
+ return missing
462
+ }
463
+
297
464
  /**
298
465
  * OPTIMIZATION 4: Stream pre-filtered records using line numbers from indices with partial index optimization
299
466
  * @param {Set} preFilteredLines - Line numbers from index query
@@ -498,33 +665,111 @@ export class QueryManager {
498
665
  console.log(`🔍 IndexManager returned ${lineNumbers.size} line numbers:`, Array.from(lineNumbers))
499
666
  }
500
667
 
668
+ if (lineNumbers.size === 0) {
669
+ const missingIndexedFields = this._getIndexedFieldsWithMissingData(criteria)
670
+ if (missingIndexedFields.length > 0 && this._hasAnyRecords()) {
671
+ // Try to rebuild index before falling back to streaming (only if allowIndexRebuild is true)
672
+ if (this.database.opts.allowIndexRebuild) {
673
+ if (this.opts.debugMode) {
674
+ console.log(`⚠️ Indexed query returned no results because index data is missing for: ${missingIndexedFields.join(', ')}. Attempting index rebuild...`)
675
+ }
676
+ this.database._indexRebuildNeeded = true
677
+ await this.database._rebuildIndexesIfNeeded()
678
+
679
+ // Retry indexed query after rebuild
680
+ const retryLineNumbers = this.indexManager.query(criteria, options)
681
+ if (retryLineNumbers.size > 0) {
682
+ if (this.opts.debugMode) {
683
+ console.log(`✅ Index rebuild successful, using indexed strategy.`)
684
+ }
685
+ // Update lineNumbers to use rebuilt index results
686
+ lineNumbers.clear()
687
+ for (const lineNumber of retryLineNumbers) {
688
+ lineNumbers.add(lineNumber)
689
+ }
690
+ } else {
691
+ // Still no results after rebuild, fall back to streaming
692
+ if (this.opts.debugMode) {
693
+ console.log(`⚠️ Index rebuild did not help, falling back to streaming.`)
694
+ }
695
+ return this.findWithStreaming(criteria, { ...options, forceFullScan: true })
696
+ }
697
+ } else {
698
+ // allowIndexRebuild is false, fall back to streaming
699
+ if (this.opts.debugMode) {
700
+ console.log(`⚠️ Indexed query returned no results because index data is missing for: ${missingIndexedFields.join(', ')}. Falling back to streaming.`)
701
+ }
702
+ return this.findWithStreaming(criteria, { ...options, forceFullScan: true })
703
+ }
704
+ }
705
+ }
706
+
501
707
  // Read specific records using the line numbers
502
708
  if (lineNumbers.size > 0) {
503
709
  const lineNumbersArray = Array.from(lineNumbers)
504
- const ranges = this.database.getRanges(lineNumbersArray)
505
- const groupedRanges = await this.database.fileHandler.groupedRanges(ranges)
710
+ const persistedCount = Array.isArray(this.database.offsets) ? this.database.offsets.length : 0
506
711
 
507
- const fs = await import('fs')
508
- const fd = await fs.promises.open(this.database.fileHandler.file, 'r')
712
+ // Separate lineNumbers into file records and writeBuffer records
713
+ const fileLineNumbers = []
714
+ const writeBufferLineNumbers = []
509
715
 
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)
716
+ for (const lineNumber of lineNumbersArray) {
717
+ if (lineNumber >= persistedCount) {
718
+ // This lineNumber points to writeBuffer
719
+ writeBufferLineNumbers.push(lineNumber)
720
+ } else {
721
+ // This lineNumber points to file
722
+ fileLineNumbers.push(lineNumber)
723
+ }
724
+ }
725
+
726
+ // Read records from file
727
+ if (fileLineNumbers.length > 0) {
728
+ const ranges = this.database.getRanges(fileLineNumbers)
729
+ if (ranges.length > 0) {
730
+ const groupedRanges = await this.database.fileHandler.groupedRanges(ranges)
731
+
732
+ const fs = await import('fs')
733
+ const fd = await fs.promises.open(this.database.fileHandler.file, 'r')
734
+
735
+ try {
736
+ for (const groupedRange of groupedRanges) {
737
+ for await (const row of this.database.fileHandler.readGroupedRange(groupedRange, fd)) {
738
+ try {
739
+ const record = this.database.serializer.deserialize(row.line)
740
+ const recordWithTerms = options.restoreTerms !== false ?
741
+ this.database.restoreTermIdsAfterDeserialization(record) :
742
+ record
743
+ results.push(recordWithTerms)
744
+ if (limit && results.length >= limit) break
745
+ } catch (error) {
746
+ // Skip invalid lines
747
+ }
748
+ }
749
+ if (limit && results.length >= limit) break
750
+ }
751
+ } finally {
752
+ await fd.close()
753
+ }
754
+ }
755
+ }
756
+
757
+ // Read records from writeBuffer
758
+ if (writeBufferLineNumbers.length > 0 && this.database.writeBuffer) {
759
+ for (const lineNumber of writeBufferLineNumbers) {
760
+ if (limit && results.length >= limit) break
761
+
762
+ const writeBufferIndex = lineNumber - persistedCount
763
+ if (writeBufferIndex >= 0 && writeBufferIndex < this.database.writeBuffer.length) {
764
+ const record = this.database.writeBuffer[writeBufferIndex]
765
+ if (record) {
515
766
  const recordWithTerms = options.restoreTerms !== false ?
516
767
  this.database.restoreTermIdsAfterDeserialization(record) :
517
768
  record
518
769
  results.push(recordWithTerms)
519
- if (limit && results.length >= limit) break
520
- } catch (error) {
521
- // Skip invalid lines
522
770
  }
523
771
  }
524
- if (limit && results.length >= limit) break
525
772
  }
526
- } finally {
527
- await fd.close()
528
773
  }
529
774
  }
530
775
 
@@ -666,7 +911,8 @@ export class QueryManager {
666
911
  // Handle object conditions (operators)
667
912
  if (typeof condition === 'object' && !Array.isArray(condition)) {
668
913
  for (const [operator, operatorValue] of Object.entries(condition)) {
669
- if (!this.matchesOperator(value, operator, operatorValue, options)) {
914
+ const normalizedOperator = normalizeOperator(operator);
915
+ if (!this.matchesOperator(value, normalizedOperator, operatorValue, options)) {
670
916
  return false;
671
917
  }
672
918
  }
@@ -697,6 +943,8 @@ export class QueryManager {
697
943
  */
698
944
  matchesOperator(value, operator, operatorValue, options = {}) {
699
945
  switch (operator) {
946
+ case '$eq':
947
+ return value === operatorValue;
700
948
  case '$gt':
701
949
  return value > operatorValue;
702
950
  case '$gte':
@@ -757,6 +1005,7 @@ export class QueryManager {
757
1005
  }
758
1006
  }
759
1007
 
1008
+
760
1009
  /**
761
1010
  * Preprocess query to handle array field syntax automatically
762
1011
  * @param {Object} criteria - Query criteria
@@ -913,16 +1162,23 @@ export class QueryManager {
913
1162
  }
914
1163
 
915
1164
  if (typeof condition === 'object' && !Array.isArray(condition)) {
916
- const operators = Object.keys(condition);
1165
+ const operators = Object.keys(condition).map(op => normalizeOperator(op));
1166
+ const indexType = this.indexManager?.opts?.indexes?.[field]
1167
+ const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number'
1168
+ const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size']
1169
+ const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size']
917
1170
 
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
- });
1171
+ // Check if this is a term mapping field (array:string or string fields with term mapping)
1172
+ const isTermMappingField = this.database.termManager &&
1173
+ this.database.termManager.termMappingFields &&
1174
+ this.database.termManager.termMappingFields.includes(field)
1175
+
1176
+ if (isTermMappingField) {
1177
+ const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size']
1178
+ return operators.every(op => !termMappingDisallowed.includes(op));
922
1179
  } else {
923
- return operators.every(op => {
924
- return !['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size'].includes(op);
925
- });
1180
+ const disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault
1181
+ return operators.every(op => !disallowed.includes(op));
926
1182
  }
927
1183
  }
928
1184
  return true;
@@ -938,8 +1194,8 @@ export class QueryManager {
938
1194
  }
939
1195
 
940
1196
  const allFieldsIndexed = Object.keys(criteria).every(field => {
941
- // Skip $and as it's handled separately above
942
- if (field === '$and') return true;
1197
+ // Skip $and and $not as they're handled separately above
1198
+ if (field === '$and' || field === '$not') return true;
943
1199
 
944
1200
  if (!this.opts.indexes || !this.opts.indexes[field]) {
945
1201
  if (this.opts.debugMode) {
@@ -960,23 +1216,34 @@ export class QueryManager {
960
1216
  }
961
1217
 
962
1218
  if (typeof condition === 'object' && !Array.isArray(condition)) {
963
- const operators = Object.keys(condition);
1219
+ const operators = Object.keys(condition).map(op => normalizeOperator(op));
964
1220
  if (this.opts.debugMode) {
965
1221
  console.log(`🔍 Field '${field}' has operators:`, operators)
966
1222
  }
967
1223
 
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
- });
1224
+ const indexType = this.indexManager?.opts?.indexes?.[field]
1225
+ const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number'
1226
+ const isArrayStringIndex = indexType === 'array:string'
1227
+ const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size']
1228
+ const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size']
1229
+
1230
+ // Check if this is a term mapping field (array:string or string fields with term mapping)
1231
+ const isTermMappingField = this.database.termManager &&
1232
+ this.database.termManager.termMappingFields &&
1233
+ this.database.termManager.termMappingFields.includes(field)
1234
+
1235
+ // With term mapping enabled on THIS FIELD, we can support complex operators via partial reads
1236
+ // Also support $all for array:string indexed fields (IndexManager.query supports it via Set intersection)
1237
+ if (isTermMappingField) {
1238
+ const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size']
1239
+ return operators.every(op => !termMappingDisallowed.includes(op));
975
1240
  } 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
- });
1241
+ let disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault
1242
+ // Remove $all from disallowed if field is array:string (IndexManager supports $all via Set intersection)
1243
+ if (isArrayStringIndex) {
1244
+ disallowed = disallowed.filter(op => op !== '$all')
1245
+ }
1246
+ return operators.every(op => !disallowed.includes(op));
980
1247
  }
981
1248
  }
982
1249
  return true;
@@ -1150,28 +1417,34 @@ export class QueryManager {
1150
1417
  /**
1151
1418
  * Validate strict query mode
1152
1419
  * @param {Object} criteria - Query criteria
1420
+ * @param {Object} options - Query options
1153
1421
  */
1154
- validateStrictQuery(criteria) {
1422
+ validateStrictQuery(criteria, options = {}) {
1423
+ // Allow bypassing strict mode validation with allowNonIndexed option
1424
+ if (options.allowNonIndexed === true) {
1425
+ return; // Skip validation for this query
1426
+ }
1427
+
1155
1428
  if (!criteria || Object.keys(criteria).length === 0) {
1156
1429
  return; // Empty criteria are always allowed
1157
1430
  }
1158
1431
 
1159
1432
  // Handle logical operators at the top level
1160
1433
  if (criteria.$not) {
1161
- this.validateStrictQuery(criteria.$not);
1434
+ this.validateStrictQuery(criteria.$not, options);
1162
1435
  return;
1163
1436
  }
1164
1437
 
1165
1438
  if (criteria.$or && Array.isArray(criteria.$or)) {
1166
1439
  for (const orCondition of criteria.$or) {
1167
- this.validateStrictQuery(orCondition);
1440
+ this.validateStrictQuery(orCondition, options);
1168
1441
  }
1169
1442
  return;
1170
1443
  }
1171
1444
 
1172
1445
  if (criteria.$and && Array.isArray(criteria.$and)) {
1173
1446
  for (const andCondition of criteria.$and) {
1174
- this.validateStrictQuery(andCondition);
1447
+ this.validateStrictQuery(andCondition, options);
1175
1448
  }
1176
1449
  return;
1177
1450
  }
@@ -38,19 +38,19 @@ export default class TermManager {
38
38
  /**
39
39
  * Get term ID without incrementing count (for IndexManager use)
40
40
  * @param {string} term - Term to get ID for
41
- * @returns {number} - Numeric ID for the term
41
+ * @returns {number|undefined} - Numeric ID for the term, or undefined if not found
42
+ * CRITICAL: Does NOT create new IDs - only returns existing ones
43
+ * This prevents creating invalid term IDs during queries when terms haven't been loaded yet
42
44
  */
43
45
  getTermIdWithoutIncrement(term) {
44
46
  if (this.termToId.has(term)) {
45
47
  return this.termToId.get(term)
46
48
  }
47
49
 
48
- const id = this.nextId++
49
- this.termToId.set(term, id)
50
- this.idToTerm.set(id, term)
51
- this.termCounts.set(id, 0) // Start with 0 count
52
-
53
- return id
50
+ // CRITICAL FIX: Don't create new IDs during queries
51
+ // If term doesn't exist, return undefined
52
+ // This ensures queries only work with terms that were actually saved to the database
53
+ return undefined
54
54
  }
55
55
 
56
56
  /**