jexidb 2.2.5 → 2.2.6

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.
package/dist/Database.cjs CHANGED
@@ -1406,6 +1406,28 @@ class IndexManager {
1406
1406
  return result;
1407
1407
  }
1408
1408
 
1409
+ // Handle $or queries - union of the branch results
1410
+ if (criteria.$or && Array.isArray(criteria.$or)) {
1411
+ const orResult = new Set();
1412
+ for (const orCondition of criteria.$or) {
1413
+ const branchLines = this.query(orCondition, options);
1414
+ for (const lineNumber of branchLines) {
1415
+ orResult.add(lineNumber);
1416
+ }
1417
+ }
1418
+
1419
+ // Other fields at the root level still narrow the union (AND semantics)
1420
+ const otherCriteria = {
1421
+ ...criteria
1422
+ };
1423
+ delete otherCriteria.$or;
1424
+ if (Object.keys(otherCriteria).length > 0) {
1425
+ const otherResults = this.query(otherCriteria, options);
1426
+ return new Set([...orResult].filter(x => otherResults.has(x)));
1427
+ }
1428
+ return orResult;
1429
+ }
1430
+
1409
1431
  // Handle $and queries with parallel processing optimization
1410
1432
  if (criteria.$and && Array.isArray(criteria.$and)) {
1411
1433
  // OPTIMIZATION: Process conditions in parallel for better performance
@@ -6349,10 +6371,18 @@ class QueryManager {
6349
6371
  }
6350
6372
 
6351
6373
  // Use indices to pre-filter and reduce streaming scope
6352
- const preFilteredLines = this.indexManager.query(this._extractIndexableCriteria(criteria), streamingOptions);
6374
+ const preFilterCriteria = this._extractIndexableCriteria(criteria);
6375
+ const preFilteredLines = this.indexManager.query(preFilterCriteria, streamingOptions);
6376
+
6377
+ // Fields answered by the pre-filter already match, so the streaming matcher
6378
+ // only evaluates the residual conditions (instead of re-checking everything).
6379
+ const preFilteredFields = this._collectCriteriaFields(preFilterCriteria);
6353
6380
 
6354
6381
  // Stream only the pre-filtered records
6355
- return this._streamPreFilteredRecords(preFilteredLines, criteria, streamingOptions);
6382
+ return this._streamPreFilteredRecords(preFilteredLines, criteria, {
6383
+ ...streamingOptions,
6384
+ _prefilteredFields: preFilteredFields
6385
+ });
6356
6386
  }
6357
6387
  }
6358
6388
 
@@ -6404,6 +6434,17 @@ class QueryManager {
6404
6434
  }
6405
6435
  }
6406
6436
 
6437
+ // Handle $or conditions - only when EVERY branch is pre-filterable, otherwise
6438
+ // the union would narrow the candidate set and lose rows.
6439
+ if (criteria.$or && Array.isArray(criteria.$or) && criteria.$or.length > 0) {
6440
+ const orCriteria = criteria.$or.map(condition => this._extractIndexableCriteria(condition));
6441
+ if (orCriteria.every(condition => Object.keys(condition).length > 0)) {
6442
+ for (const condition of orCriteria) {
6443
+ indexableFields.push(...this._getIndexableFields(condition));
6444
+ }
6445
+ }
6446
+ }
6447
+
6407
6448
  // Handle regular field conditions
6408
6449
  for (const [field, condition] of Object.entries(criteria)) {
6409
6450
  if (field.startsWith('$')) continue; // Skip logical operators
@@ -6439,6 +6480,15 @@ class QueryManager {
6439
6480
  }
6440
6481
  }
6441
6482
 
6483
+ // Handle $or conditions - a branch that cannot be pre-filtered would make the
6484
+ // union too narrow, so $or is only pre-filterable when every branch is.
6485
+ if (criteria.$or && Array.isArray(criteria.$or) && criteria.$or.length > 0) {
6486
+ const indexableOrConditions = criteria.$or.map(orCondition => this._extractIndexableCriteria(orCondition));
6487
+ if (indexableOrConditions.every(condition => Object.keys(condition).length > 0)) {
6488
+ indexableCriteria.$or = indexableOrConditions;
6489
+ }
6490
+ }
6491
+
6442
6492
  // Handle $not operator - include it if it can be processed by IndexManager
6443
6493
  if (criteria.$not && typeof criteria.$not === 'object') {
6444
6494
  // Check if $not condition contains only indexable fields
@@ -6469,6 +6519,37 @@ class QueryManager {
6469
6519
  return indexableCriteria;
6470
6520
  }
6471
6521
 
6522
+ /**
6523
+ * Collect every field referenced by criteria, including inside $and/$or/$not
6524
+ * @param {Object} criteria - Criteria to inspect
6525
+ * @param {Set<string>} accumulator - Fields collected so far
6526
+ * @returns {Set<string>} - Field names
6527
+ */
6528
+ _collectCriteriaFields(criteria, accumulator = new Set()) {
6529
+ if (!criteria || typeof criteria !== 'object') {
6530
+ return accumulator;
6531
+ }
6532
+ if (Array.isArray(criteria)) {
6533
+ for (const condition of criteria) {
6534
+ this._collectCriteriaFields(condition, accumulator);
6535
+ }
6536
+ return accumulator;
6537
+ }
6538
+ for (const [field, value] of Object.entries(criteria)) {
6539
+ if (field === '$and' || field === '$or') {
6540
+ this._collectCriteriaFields(value, accumulator);
6541
+ continue;
6542
+ }
6543
+ if (field === '$not') {
6544
+ // $not is negated, so its fields are not "already matched" by the pre-filter
6545
+ continue;
6546
+ }
6547
+ if (field.startsWith('$')) continue;
6548
+ accumulator.add(field);
6549
+ }
6550
+ return accumulator;
6551
+ }
6552
+
6472
6553
  /**
6473
6554
  * Determine whether the database currently has any records (persisted or pending)
6474
6555
  * @returns {boolean}
@@ -6674,8 +6755,8 @@ class QueryManager {
6674
6755
  return !notResult;
6675
6756
  }
6676
6757
 
6677
- // OPTIMIZATION 4: Skip indexable fields since they were already pre-filtered
6678
- const indexableFields = this._getIndexableFields(criteria);
6758
+ // OPTIMIZATION 4: Skip only the fields that the pre-filter actually answered
6759
+ const prefilteredFields = options._prefilteredFields instanceof Set ? options._prefilteredFields : new Set(this._getIndexableFields(criteria));
6679
6760
 
6680
6761
  // Handle explicit logical operators at the top level
6681
6762
  if (criteria.$or && Array.isArray(criteria.$or)) {
@@ -6701,8 +6782,8 @@ class QueryManager {
6701
6782
  for (const [field, condition] of Object.entries(criteria)) {
6702
6783
  if (field.startsWith('$')) continue;
6703
6784
 
6704
- // OPTIMIZATION 4: Skip indexable fields that were already pre-filtered
6705
- if (indexableFields.includes(field)) {
6785
+ // OPTIMIZATION 4: Skip fields that were already pre-filtered
6786
+ if (prefilteredFields.has(field)) {
6706
6787
  continue;
6707
6788
  }
6708
6789
  if (!this.matchesFieldCondition(record, field, condition, options)) {
@@ -7029,7 +7110,7 @@ class QueryManager {
7029
7110
  * @returns {boolean} - True if matches
7030
7111
  */
7031
7112
  matchesFieldCondition(record, field, condition, options = {}) {
7032
- const value = record[field];
7113
+ let value = record[field];
7033
7114
 
7034
7115
  // Debug logging for all field conditions - SAMPLED: only the first record of
7035
7116
  // each query logs per field, so a full streaming scan (which calls this for
@@ -7061,6 +7142,13 @@ class QueryManager {
7061
7142
  }
7062
7143
  }
7063
7144
 
7145
+ // Term-mapped array fields store numeric term IDs, so both sides are translated
7146
+ // to terms here; otherwise string queries would never match on the streaming path.
7147
+ if (Array.isArray(value) && this._isTermMappingField(field)) {
7148
+ value = this._termIdsToTerms(value);
7149
+ condition = this._normalizeTermCondition(condition);
7150
+ }
7151
+
7064
7152
  // Handle null/undefined values
7065
7153
  if (value === null || value === undefined) {
7066
7154
  return condition === null || condition === undefined;
@@ -7183,17 +7271,87 @@ class QueryManager {
7183
7271
  }
7184
7272
  }
7185
7273
 
7274
+ /**
7275
+ * Whether a field stores its values as numeric term IDs (term mapping)
7276
+ * @param {string} field
7277
+ * @returns {boolean}
7278
+ */
7279
+ _isTermMappingField(field) {
7280
+ const termManager = this.database && this.database.termManager;
7281
+ return !!(termManager && Array.isArray(termManager.termMappingFields) && termManager.termMappingFields.includes(field));
7282
+ }
7283
+
7284
+ /**
7285
+ * Translate stored term IDs back to terms for matching
7286
+ * @param {Array} ids
7287
+ * @returns {Array}
7288
+ */
7289
+ _termIdsToTerms(ids) {
7290
+ const termManager = this.database.termManager;
7291
+ return ids.map(id => {
7292
+ if (typeof id !== 'number') return id;
7293
+ const term = termManager.getTerm(id);
7294
+ return term === null ? id : term;
7295
+ });
7296
+ }
7297
+
7298
+ /**
7299
+ * Translate numeric term IDs used in a query condition to their terms
7300
+ * @param {*} condition
7301
+ * @returns {*}
7302
+ */
7303
+ _normalizeTermCondition(condition) {
7304
+ const toTerm = value => {
7305
+ if (typeof value !== 'number') return value;
7306
+ const term = this.database.termManager.getTerm(value);
7307
+ return term === null ? value : term;
7308
+ };
7309
+ if (typeof condition === 'number') {
7310
+ return toTerm(condition);
7311
+ }
7312
+ if (condition instanceof RegExp || condition === null || typeof condition !== 'object') {
7313
+ return condition;
7314
+ }
7315
+ if (Array.isArray(condition)) {
7316
+ return condition.map(toTerm);
7317
+ }
7318
+ const normalized = {};
7319
+ for (const [operator, operatorValue] of Object.entries(condition)) {
7320
+ const canonical = normalizeOperator(operator);
7321
+ if (canonical === '$in' || canonical === '$all' || canonical === '$nin') {
7322
+ const values = Array.isArray(operatorValue) ? operatorValue : [operatorValue];
7323
+ normalized[operator] = values.map(toTerm);
7324
+ } else if (canonical === '$eq') {
7325
+ normalized[operator] = toTerm(operatorValue);
7326
+ } else {
7327
+ normalized[operator] = operatorValue;
7328
+ }
7329
+ }
7330
+ return normalized;
7331
+ }
7332
+
7186
7333
  /**
7187
7334
  * Preprocess query to handle array field syntax automatically
7188
7335
  * @param {Object} criteria - Query criteria
7189
7336
  * @returns {Object} - Processed criteria
7190
7337
  */
7191
- preprocessQuery(criteria) {
7192
- if (!criteria || typeof criteria !== 'object') {
7338
+ preprocessQuery(criteria, nested = false) {
7339
+ if (!criteria || typeof criteria !== 'object' || Array.isArray(criteria)) {
7193
7340
  return criteria;
7194
7341
  }
7195
7342
  const processed = {};
7196
7343
  for (const [field, value] of Object.entries(criteria)) {
7344
+ // $and / $or / $not hold nested criteria and must be processed recursively,
7345
+ // otherwise conditions inside logical operators never reach the $in form.
7346
+ if (field === '$and' || field === '$or') {
7347
+ processed[field] = Array.isArray(value) ? value.map(condition => this.preprocessQuery(condition, true)) : value;
7348
+ continue;
7349
+ }
7350
+ if (field === '$not') {
7351
+ processed[field] = value && typeof value === 'object' && !Array.isArray(value) ? this.preprocessQuery(value, true) : value;
7352
+ continue;
7353
+ }
7354
+
7197
7355
  // Check if this is a term mapping field
7198
7356
  const isTermMappingField = this.database.opts.termMapping && this.database.termManager && this.database.termManager.termMappingFields && this.database.termManager.termMappingFields.includes(field);
7199
7357
  if (isTermMappingField) {
@@ -7221,6 +7379,10 @@ class QueryManager {
7221
7379
  } else {
7222
7380
  processed[field] = value;
7223
7381
  }
7382
+ } else if (nested && (value === null || value === undefined)) {
7383
+ // Nested branches may test for a missing value (e.g. $not: {field: null});
7384
+ // only top-level null queries are rejected as invalid.
7385
+ processed[field] = value;
7224
7386
  } else {
7225
7387
  // Invalid value for term mapping field
7226
7388
  throw new Error(`Invalid query for array field '${field}'. Use { $in: [value] } syntax or direct value.`);
@@ -7308,117 +7470,18 @@ class QueryManager {
7308
7470
  }
7309
7471
  }
7310
7472
 
7311
- // Handle $and queries - check if all conditions in $and are indexable
7312
- if (criteria.$and && Array.isArray(criteria.$and)) {
7313
- const allAndConditionsIndexed = criteria.$and.every(andCondition => {
7314
- // Handle $not conditions within $and
7315
- if (andCondition.$not) {
7316
- const notFields = Object.keys(andCondition.$not);
7317
- return notFields.every(field => {
7318
- if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
7319
- return false;
7320
- }
7321
- // For term mapping, $not can be processed with indices
7322
- return this.opts.termMapping && Object.keys(this.opts.indexes || {}).includes(field);
7323
- });
7324
- }
7325
-
7326
- // Handle regular field conditions
7327
- return Object.keys(andCondition).every(field => {
7328
- if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
7329
- return false;
7330
- }
7331
- const condition = andCondition[field];
7332
-
7333
- // INTENTIONAL (CHANGELOG [2.2.5]): raw RegExp on string/term indexes is resolved
7334
- // via index keys (not streaming). Do not revert to "regex => streaming".
7335
- if (condition instanceof RegExp) {
7336
- const idxType = this.indexManager?.opts?.indexes?.[field];
7337
- return idxType !== 'number' && idxType !== 'array:number';
7338
- }
7339
- if (typeof condition === 'object' && !Array.isArray(condition)) {
7340
- const operators = Object.keys(condition).map(op => normalizeOperator(op));
7341
- const indexType = this.indexManager?.opts?.indexes?.[field];
7342
- const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number';
7343
- const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size'];
7344
- const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size'];
7345
-
7346
- // Check if this is a term mapping field (array:string or string fields with term mapping)
7347
- const isTermMappingField = this.database.termManager && this.database.termManager.termMappingFields && this.database.termManager.termMappingFields.includes(field);
7348
- if (isTermMappingField) {
7349
- const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size'];
7350
- return operators.every(op => !termMappingDisallowed.includes(op));
7351
- } else {
7352
- const disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault;
7353
- return operators.every(op => !disallowed.includes(op));
7354
- }
7355
- }
7356
- return true;
7357
- });
7358
- });
7359
- if (!allAndConditionsIndexed) {
7473
+ // Handle $and / $or queries - every branch must be indexable to use indices
7474
+ for (const logicalOperator of ['$and', '$or']) {
7475
+ const branches = criteria[logicalOperator];
7476
+ if (!Array.isArray(branches)) continue;
7477
+ if (!branches.every(branch => this._isCriteriaIndexable(branch))) {
7360
7478
  if (this.opts.debugMode) {
7361
- console.log('📊 QueryStrategy: STREAMING - Some $and conditions not indexed or operators not supported');
7479
+ console.log(`📊 QueryStrategy: STREAMING - Some ${logicalOperator} conditions not indexed or operators not supported`);
7362
7480
  }
7363
7481
  return 'streaming';
7364
7482
  }
7365
7483
  }
7366
- const allFieldsIndexed = Object.keys(criteria).every(field => {
7367
- // Skip $and and $not as they're handled separately above
7368
- if (field === '$and' || field === '$not') return true;
7369
- if (!this.opts.indexes || !this.opts.indexes[field]) {
7370
- if (this.opts.debugMode) {
7371
- console.log(`🔍 Field '${field}' not indexed. Available indexes:`, Object.keys(this.opts.indexes || {}));
7372
- }
7373
- return false;
7374
- }
7375
-
7376
- // Check if the field uses operators that are supported by IndexManager
7377
- const condition = criteria[field];
7378
-
7379
- // INTENTIONAL (CHANGELOG [2.2.5]): raw RegExp on string/term indexes is resolved
7380
- // via index keys (not streaming). Do not revert to "regex => streaming".
7381
- if (condition instanceof RegExp) {
7382
- const idxType = this.indexManager?.opts?.indexes?.[field];
7383
- if (idxType === 'number' || idxType === 'array:number') {
7384
- if (this.opts.debugMode) {
7385
- console.log(`🔍 Field '${field}' uses RegExp on a numeric index - requires streaming strategy`);
7386
- }
7387
- return false;
7388
- }
7389
- return true;
7390
- }
7391
- if (typeof condition === 'object' && !Array.isArray(condition) && condition !== null) {
7392
- const operators = Object.keys(condition).map(op => normalizeOperator(op));
7393
- if (this.opts.debugMode) {
7394
- console.log(`🔍 Field '${field}' has operators:`, operators);
7395
- }
7396
- const indexType = this.indexManager?.opts?.indexes?.[field];
7397
- const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number';
7398
- const isArrayStringIndex = indexType === 'array:string';
7399
- const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size'];
7400
- const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size'];
7401
-
7402
- // Check if this is a term mapping field (array:string or string fields with term mapping)
7403
- const isTermMappingField = this.database.termManager && this.database.termManager.termMappingFields && this.database.termManager.termMappingFields.includes(field);
7404
-
7405
- // With term mapping enabled on THIS FIELD, we can support complex operators via partial reads
7406
- // Also support $all for array:string indexed fields (IndexManager.query supports it via Set intersection)
7407
- if (isTermMappingField) {
7408
- const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size'];
7409
- return operators.every(op => !termMappingDisallowed.includes(op));
7410
- } else {
7411
- let disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault;
7412
- // Remove $all from disallowed if field is array:string (IndexManager supports $all via Set intersection)
7413
- if (isArrayStringIndex) {
7414
- disallowed = disallowed.filter(op => op !== '$all');
7415
- }
7416
- return operators.every(op => !disallowed.includes(op));
7417
- }
7418
- }
7419
- return true;
7420
- });
7421
- if (!allFieldsIndexed) {
7484
+ if (!this._isCriteriaIndexable(criteria)) {
7422
7485
  if (this.opts.debugMode) {
7423
7486
  console.log('📊 QueryStrategy: STREAMING - Some fields not indexed or operators not supported');
7424
7487
  }
@@ -7457,6 +7520,76 @@ class QueryManager {
7457
7520
  return 'indexed';
7458
7521
  }
7459
7522
 
7523
+ /**
7524
+ * Whether a criteria object can be fully resolved through indexes
7525
+ * @param {Object} criteria - Criteria or a single logical branch
7526
+ * @returns {boolean} - True if every condition is index-backed
7527
+ */
7528
+ _isCriteriaIndexable(criteria) {
7529
+ if (!criteria || typeof criteria !== 'object' || Array.isArray(criteria)) {
7530
+ return false;
7531
+ }
7532
+ const fields = Object.keys(criteria);
7533
+ if (fields.length === 0) {
7534
+ return false;
7535
+ }
7536
+ return fields.every(field => {
7537
+ const value = criteria[field];
7538
+ if (field === '$and' || field === '$or') {
7539
+ return Array.isArray(value) && value.length > 0 && value.every(branch => this._isCriteriaIndexable(branch));
7540
+ }
7541
+ if (field === '$not') {
7542
+ // $not is only index-supported when term mapping is enabled
7543
+ if (!this.opts.termMapping || !value || typeof value !== 'object' || Array.isArray(value)) {
7544
+ return false;
7545
+ }
7546
+ const notFields = Object.keys(value);
7547
+ return notFields.length > 0 && notFields.every(notField => !!(this.indexManager.opts.indexes && this.indexManager.opts.indexes[notField]));
7548
+ }
7549
+ if (field.startsWith('$')) {
7550
+ return false;
7551
+ }
7552
+ return this._isFieldConditionIndexable(field, value);
7553
+ });
7554
+ }
7555
+
7556
+ /**
7557
+ * Whether a single field condition is supported by that field's index
7558
+ * @param {string} field - Field name
7559
+ * @param {*} condition - Condition value
7560
+ * @returns {boolean} - True if the index can answer this condition
7561
+ */
7562
+ _isFieldConditionIndexable(field, condition) {
7563
+ if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
7564
+ return false;
7565
+ }
7566
+ const indexType = this.indexManager.opts.indexes[field];
7567
+
7568
+ // INTENTIONAL (CHANGELOG [2.2.5]): raw RegExp on string/term indexes is resolved
7569
+ // via index keys (not streaming). Do not revert to "regex => streaming".
7570
+ if (condition instanceof RegExp) {
7571
+ return indexType !== 'number' && indexType !== 'array:number';
7572
+ }
7573
+
7574
+ // Index lookups cannot express "is null/undefined" reliably
7575
+ if (condition === null || condition === undefined) {
7576
+ return false;
7577
+ }
7578
+ if (typeof condition !== 'object' || Array.isArray(condition)) {
7579
+ return true;
7580
+ }
7581
+ const operators = Object.keys(condition).map(op => normalizeOperator(op));
7582
+ const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number';
7583
+ const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size'];
7584
+ const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size'];
7585
+ if (this._isTermMappingField(field)) {
7586
+ const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size'];
7587
+ return operators.every(op => !termMappingDisallowed.includes(op));
7588
+ }
7589
+ const disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault;
7590
+ return operators.every(op => !disallowed.includes(op));
7591
+ }
7592
+
7460
7593
  /**
7461
7594
  * Estimate number of results for a query
7462
7595
  * @param {Object} criteria - Query criteria
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jexidb",
3
- "version": "2.2.5",
3
+ "version": "2.2.6",
4
4
  "type": "module",
5
5
  "description": "JexiDB is a pure JS NPM library for managing data on disk efficiently, without the need for a server.",
6
6
  "main": "./dist/Database.cjs",
@@ -1016,6 +1016,29 @@ export default class IndexManager {
1016
1016
  return result;
1017
1017
  }
1018
1018
 
1019
+ // Handle $or queries - union of the branch results
1020
+ if (criteria.$or && Array.isArray(criteria.$or)) {
1021
+ const orResult = new Set();
1022
+
1023
+ for (const orCondition of criteria.$or) {
1024
+ const branchLines = this.query(orCondition, options);
1025
+ for (const lineNumber of branchLines) {
1026
+ orResult.add(lineNumber);
1027
+ }
1028
+ }
1029
+
1030
+ // Other fields at the root level still narrow the union (AND semantics)
1031
+ const otherCriteria = { ...criteria };
1032
+ delete otherCriteria.$or;
1033
+
1034
+ if (Object.keys(otherCriteria).length > 0) {
1035
+ const otherResults = this.query(otherCriteria, options);
1036
+ return new Set([...orResult].filter(x => otherResults.has(x)));
1037
+ }
1038
+
1039
+ return orResult;
1040
+ }
1041
+
1019
1042
  // Handle $and queries with parallel processing optimization
1020
1043
  if (criteria.$and && Array.isArray(criteria.$and)) {
1021
1044
  // OPTIMIZATION: Process conditions in parallel for better performance
@@ -286,13 +286,18 @@ export class QueryManager {
286
286
  }
287
287
 
288
288
  // Use indices to pre-filter and reduce streaming scope
289
- const preFilteredLines = this.indexManager.query(
290
- this._extractIndexableCriteria(criteria),
291
- streamingOptions
292
- );
293
-
289
+ const preFilterCriteria = this._extractIndexableCriteria(criteria);
290
+ const preFilteredLines = this.indexManager.query(preFilterCriteria, streamingOptions);
291
+
292
+ // Fields answered by the pre-filter already match, so the streaming matcher
293
+ // only evaluates the residual conditions (instead of re-checking everything).
294
+ const preFilteredFields = this._collectCriteriaFields(preFilterCriteria);
295
+
294
296
  // Stream only the pre-filtered records
295
- return this._streamPreFilteredRecords(preFilteredLines, criteria, streamingOptions);
297
+ return this._streamPreFilteredRecords(preFilteredLines, criteria, {
298
+ ...streamingOptions,
299
+ _prefilteredFields: preFilteredFields
300
+ });
296
301
  }
297
302
  }
298
303
 
@@ -345,6 +350,17 @@ export class QueryManager {
345
350
  indexableFields.push(...this._getIndexableFields(andCondition));
346
351
  }
347
352
  }
353
+
354
+ // Handle $or conditions - only when EVERY branch is pre-filterable, otherwise
355
+ // the union would narrow the candidate set and lose rows.
356
+ if (criteria.$or && Array.isArray(criteria.$or) && criteria.$or.length > 0) {
357
+ const orCriteria = criteria.$or.map(condition => this._extractIndexableCriteria(condition));
358
+ if (orCriteria.every(condition => Object.keys(condition).length > 0)) {
359
+ for (const condition of orCriteria) {
360
+ indexableFields.push(...this._getIndexableFields(condition));
361
+ }
362
+ }
363
+ }
348
364
 
349
365
  // Handle regular field conditions
350
366
  for (const [field, condition] of Object.entries(criteria)) {
@@ -386,6 +402,17 @@ export class QueryManager {
386
402
  indexableCriteria.$and = indexableAndConditions;
387
403
  }
388
404
  }
405
+
406
+ // Handle $or conditions - a branch that cannot be pre-filtered would make the
407
+ // union too narrow, so $or is only pre-filterable when every branch is.
408
+ if (criteria.$or && Array.isArray(criteria.$or) && criteria.$or.length > 0) {
409
+ const indexableOrConditions = criteria.$or
410
+ .map(orCondition => this._extractIndexableCriteria(orCondition));
411
+
412
+ if (indexableOrConditions.every(condition => Object.keys(condition).length > 0)) {
413
+ indexableCriteria.$or = indexableOrConditions;
414
+ }
415
+ }
389
416
 
390
417
  // Handle $not operator - include it if it can be processed by IndexManager
391
418
  if (criteria.$not && typeof criteria.$not === 'object') {
@@ -422,6 +449,43 @@ export class QueryManager {
422
449
  return indexableCriteria;
423
450
  }
424
451
 
452
+ /**
453
+ * Collect every field referenced by criteria, including inside $and/$or/$not
454
+ * @param {Object} criteria - Criteria to inspect
455
+ * @param {Set<string>} accumulator - Fields collected so far
456
+ * @returns {Set<string>} - Field names
457
+ */
458
+ _collectCriteriaFields(criteria, accumulator = new Set()) {
459
+ if (!criteria || typeof criteria !== 'object') {
460
+ return accumulator;
461
+ }
462
+
463
+ if (Array.isArray(criteria)) {
464
+ for (const condition of criteria) {
465
+ this._collectCriteriaFields(condition, accumulator);
466
+ }
467
+ return accumulator;
468
+ }
469
+
470
+ for (const [field, value] of Object.entries(criteria)) {
471
+ if (field === '$and' || field === '$or') {
472
+ this._collectCriteriaFields(value, accumulator);
473
+ continue;
474
+ }
475
+
476
+ if (field === '$not') {
477
+ // $not is negated, so its fields are not "already matched" by the pre-filter
478
+ continue;
479
+ }
480
+
481
+ if (field.startsWith('$')) continue;
482
+
483
+ accumulator.add(field);
484
+ }
485
+
486
+ return accumulator;
487
+ }
488
+
425
489
  /**
426
490
  * Determine whether the database currently has any records (persisted or pending)
427
491
  * @returns {boolean}
@@ -638,8 +702,10 @@ export class QueryManager {
638
702
  return !notResult;
639
703
  }
640
704
 
641
- // OPTIMIZATION 4: Skip indexable fields since they were already pre-filtered
642
- const indexableFields = this._getIndexableFields(criteria);
705
+ // OPTIMIZATION 4: Skip only the fields that the pre-filter actually answered
706
+ const prefilteredFields = options._prefilteredFields instanceof Set
707
+ ? options._prefilteredFields
708
+ : new Set(this._getIndexableFields(criteria));
643
709
 
644
710
  // Handle explicit logical operators at the top level
645
711
  if (criteria.$or && Array.isArray(criteria.$or)) {
@@ -666,8 +732,8 @@ export class QueryManager {
666
732
  for (const [field, condition] of Object.entries(criteria)) {
667
733
  if (field.startsWith('$')) continue;
668
734
 
669
- // OPTIMIZATION 4: Skip indexable fields that were already pre-filtered
670
- if (indexableFields.includes(field)) {
735
+ // OPTIMIZATION 4: Skip fields that were already pre-filtered
736
+ if (prefilteredFields.has(field)) {
671
737
  continue;
672
738
  }
673
739
 
@@ -980,7 +1046,7 @@ export class QueryManager {
980
1046
  * @returns {boolean} - True if matches
981
1047
  */
982
1048
  matchesFieldCondition(record, field, condition, options = {}) {
983
- const value = record[field];
1049
+ let value = record[field];
984
1050
 
985
1051
  // Debug logging for all field conditions - SAMPLED: only the first record of
986
1052
  // each query logs per field, so a full streaming scan (which calls this for
@@ -1004,6 +1070,13 @@ export class QueryManager {
1004
1070
  }
1005
1071
  }
1006
1072
 
1073
+ // Term-mapped array fields store numeric term IDs, so both sides are translated
1074
+ // to terms here; otherwise string queries would never match on the streaming path.
1075
+ if (Array.isArray(value) && this._isTermMappingField(field)) {
1076
+ value = this._termIdsToTerms(value);
1077
+ condition = this._normalizeTermCondition(condition);
1078
+ }
1079
+
1007
1080
  // Handle null/undefined values
1008
1081
  if (value === null || value === undefined) {
1009
1082
  return condition === null || condition === undefined;
@@ -1127,19 +1200,100 @@ export class QueryManager {
1127
1200
  }
1128
1201
 
1129
1202
 
1203
+ /**
1204
+ * Whether a field stores its values as numeric term IDs (term mapping)
1205
+ * @param {string} field
1206
+ * @returns {boolean}
1207
+ */
1208
+ _isTermMappingField(field) {
1209
+ const termManager = this.database && this.database.termManager
1210
+ return !!(termManager &&
1211
+ Array.isArray(termManager.termMappingFields) &&
1212
+ termManager.termMappingFields.includes(field))
1213
+ }
1214
+
1215
+ /**
1216
+ * Translate stored term IDs back to terms for matching
1217
+ * @param {Array} ids
1218
+ * @returns {Array}
1219
+ */
1220
+ _termIdsToTerms(ids) {
1221
+ const termManager = this.database.termManager
1222
+ return ids.map(id => {
1223
+ if (typeof id !== 'number') return id
1224
+ const term = termManager.getTerm(id)
1225
+ return term === null ? id : term
1226
+ })
1227
+ }
1228
+
1229
+ /**
1230
+ * Translate numeric term IDs used in a query condition to their terms
1231
+ * @param {*} condition
1232
+ * @returns {*}
1233
+ */
1234
+ _normalizeTermCondition(condition) {
1235
+ const toTerm = value => {
1236
+ if (typeof value !== 'number') return value
1237
+ const term = this.database.termManager.getTerm(value)
1238
+ return term === null ? value : term
1239
+ }
1240
+
1241
+ if (typeof condition === 'number') {
1242
+ return toTerm(condition)
1243
+ }
1244
+
1245
+ if (condition instanceof RegExp || condition === null || typeof condition !== 'object') {
1246
+ return condition
1247
+ }
1248
+
1249
+ if (Array.isArray(condition)) {
1250
+ return condition.map(toTerm)
1251
+ }
1252
+
1253
+ const normalized = {}
1254
+ for (const [operator, operatorValue] of Object.entries(condition)) {
1255
+ const canonical = normalizeOperator(operator)
1256
+ if (canonical === '$in' || canonical === '$all' || canonical === '$nin') {
1257
+ const values = Array.isArray(operatorValue) ? operatorValue : [operatorValue]
1258
+ normalized[operator] = values.map(toTerm)
1259
+ } else if (canonical === '$eq') {
1260
+ normalized[operator] = toTerm(operatorValue)
1261
+ } else {
1262
+ normalized[operator] = operatorValue
1263
+ }
1264
+ }
1265
+ return normalized
1266
+ }
1267
+
1130
1268
  /**
1131
1269
  * Preprocess query to handle array field syntax automatically
1132
1270
  * @param {Object} criteria - Query criteria
1133
1271
  * @returns {Object} - Processed criteria
1134
1272
  */
1135
- preprocessQuery(criteria) {
1136
- if (!criteria || typeof criteria !== 'object') {
1273
+ preprocessQuery(criteria, nested = false) {
1274
+ if (!criteria || typeof criteria !== 'object' || Array.isArray(criteria)) {
1137
1275
  return criteria
1138
1276
  }
1139
1277
 
1140
1278
  const processed = {}
1141
1279
 
1142
1280
  for (const [field, value] of Object.entries(criteria)) {
1281
+ // $and / $or / $not hold nested criteria and must be processed recursively,
1282
+ // otherwise conditions inside logical operators never reach the $in form.
1283
+ if (field === '$and' || field === '$or') {
1284
+ processed[field] = Array.isArray(value)
1285
+ ? value.map(condition => this.preprocessQuery(condition, true))
1286
+ : value
1287
+ continue
1288
+ }
1289
+
1290
+ if (field === '$not') {
1291
+ processed[field] = value && typeof value === 'object' && !Array.isArray(value)
1292
+ ? this.preprocessQuery(value, true)
1293
+ : value
1294
+ continue
1295
+ }
1296
+
1143
1297
  // Check if this is a term mapping field
1144
1298
  const isTermMappingField = this.database.opts.termMapping &&
1145
1299
  this.database.termManager &&
@@ -1163,6 +1317,10 @@ export class QueryManager {
1163
1317
  } else {
1164
1318
  processed[field] = value
1165
1319
  }
1320
+ } else if (nested && (value === null || value === undefined)) {
1321
+ // Nested branches may test for a missing value (e.g. $not: {field: null});
1322
+ // only top-level null queries are rejected as invalid.
1323
+ processed[field] = value
1166
1324
  } else {
1167
1325
  // Invalid value for term mapping field
1168
1326
  throw new Error(`Invalid query for array field '${field}'. Use { $in: [value] } syntax or direct value.`)
@@ -1254,130 +1412,20 @@ export class QueryManager {
1254
1412
  }
1255
1413
  }
1256
1414
 
1257
- // Handle $and queries - check if all conditions in $and are indexable
1258
- if (criteria.$and && Array.isArray(criteria.$and)) {
1259
- const allAndConditionsIndexed = criteria.$and.every(andCondition => {
1260
- // Handle $not conditions within $and
1261
- if (andCondition.$not) {
1262
- const notFields = Object.keys(andCondition.$not);
1263
- return notFields.every(field => {
1264
- if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
1265
- return false;
1266
- }
1267
- // For term mapping, $not can be processed with indices
1268
- return this.opts.termMapping && Object.keys(this.opts.indexes || {}).includes(field);
1269
- });
1270
- }
1271
-
1272
- // Handle regular field conditions
1273
- return Object.keys(andCondition).every(field => {
1274
- if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
1275
- return false;
1276
- }
1277
-
1278
- const condition = andCondition[field];
1279
-
1280
- // INTENTIONAL (CHANGELOG [2.2.5]): raw RegExp on string/term indexes is resolved
1281
- // via index keys (not streaming). Do not revert to "regex => streaming".
1282
- if (condition instanceof RegExp) {
1283
- const idxType = this.indexManager?.opts?.indexes?.[field];
1284
- return idxType !== 'number' && idxType !== 'array:number';
1285
- }
1286
-
1287
- if (typeof condition === 'object' && !Array.isArray(condition)) {
1288
- const operators = Object.keys(condition).map(op => normalizeOperator(op));
1289
- const indexType = this.indexManager?.opts?.indexes?.[field]
1290
- const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number'
1291
- const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size']
1292
- const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size']
1293
-
1294
- // Check if this is a term mapping field (array:string or string fields with term mapping)
1295
- const isTermMappingField = this.database.termManager &&
1296
- this.database.termManager.termMappingFields &&
1297
- this.database.termManager.termMappingFields.includes(field)
1298
-
1299
- if (isTermMappingField) {
1300
- const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size']
1301
- return operators.every(op => !termMappingDisallowed.includes(op));
1302
- } else {
1303
- const disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault
1304
- return operators.every(op => !disallowed.includes(op));
1305
- }
1306
- }
1307
- return true;
1308
- });
1309
- });
1310
-
1311
- if (!allAndConditionsIndexed) {
1415
+ // Handle $and / $or queries - every branch must be indexable to use indices
1416
+ for (const logicalOperator of ['$and', '$or']) {
1417
+ const branches = criteria[logicalOperator];
1418
+ if (!Array.isArray(branches)) continue;
1419
+
1420
+ if (!branches.every(branch => this._isCriteriaIndexable(branch))) {
1312
1421
  if (this.opts.debugMode) {
1313
- console.log('📊 QueryStrategy: STREAMING - Some $and conditions not indexed or operators not supported');
1422
+ console.log(`📊 QueryStrategy: STREAMING - Some ${logicalOperator} conditions not indexed or operators not supported`);
1314
1423
  }
1315
1424
  return 'streaming';
1316
1425
  }
1317
1426
  }
1318
-
1319
- const allFieldsIndexed = Object.keys(criteria).every(field => {
1320
- // Skip $and and $not as they're handled separately above
1321
- if (field === '$and' || field === '$not') return true;
1322
-
1323
- if (!this.opts.indexes || !this.opts.indexes[field]) {
1324
- if (this.opts.debugMode) {
1325
- console.log(`🔍 Field '${field}' not indexed. Available indexes:`, Object.keys(this.opts.indexes || {}))
1326
- }
1327
- return false;
1328
- }
1329
-
1330
- // Check if the field uses operators that are supported by IndexManager
1331
- const condition = criteria[field];
1332
-
1333
- // INTENTIONAL (CHANGELOG [2.2.5]): raw RegExp on string/term indexes is resolved
1334
- // via index keys (not streaming). Do not revert to "regex => streaming".
1335
- if (condition instanceof RegExp) {
1336
- const idxType = this.indexManager?.opts?.indexes?.[field];
1337
- if (idxType === 'number' || idxType === 'array:number') {
1338
- if (this.opts.debugMode) {
1339
- console.log(`🔍 Field '${field}' uses RegExp on a numeric index - requires streaming strategy`)
1340
- }
1341
- return false;
1342
- }
1343
- return true;
1344
- }
1345
-
1346
- if (typeof condition === 'object' && !Array.isArray(condition) && condition !== null) {
1347
- const operators = Object.keys(condition).map(op => normalizeOperator(op));
1348
- if (this.opts.debugMode) {
1349
- console.log(`🔍 Field '${field}' has operators:`, operators)
1350
- }
1351
-
1352
- const indexType = this.indexManager?.opts?.indexes?.[field]
1353
- const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number'
1354
- const isArrayStringIndex = indexType === 'array:string'
1355
- const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size']
1356
- const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size']
1357
-
1358
- // Check if this is a term mapping field (array:string or string fields with term mapping)
1359
- const isTermMappingField = this.database.termManager &&
1360
- this.database.termManager.termMappingFields &&
1361
- this.database.termManager.termMappingFields.includes(field)
1362
-
1363
- // With term mapping enabled on THIS FIELD, we can support complex operators via partial reads
1364
- // Also support $all for array:string indexed fields (IndexManager.query supports it via Set intersection)
1365
- if (isTermMappingField) {
1366
- const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size']
1367
- return operators.every(op => !termMappingDisallowed.includes(op));
1368
- } else {
1369
- let disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault
1370
- // Remove $all from disallowed if field is array:string (IndexManager supports $all via Set intersection)
1371
- if (isArrayStringIndex) {
1372
- disallowed = disallowed.filter(op => op !== '$all')
1373
- }
1374
- return operators.every(op => !disallowed.includes(op));
1375
- }
1376
- }
1377
- return true;
1378
- });
1379
1427
 
1380
- if (!allFieldsIndexed) {
1428
+ if (!this._isCriteriaIndexable(criteria)) {
1381
1429
  if (this.opts.debugMode) {
1382
1430
  console.log('📊 QueryStrategy: STREAMING - Some fields not indexed or operators not supported');
1383
1431
  }
@@ -1417,6 +1465,91 @@ export class QueryManager {
1417
1465
  return 'indexed';
1418
1466
  }
1419
1467
 
1468
+ /**
1469
+ * Whether a criteria object can be fully resolved through indexes
1470
+ * @param {Object} criteria - Criteria or a single logical branch
1471
+ * @returns {boolean} - True if every condition is index-backed
1472
+ */
1473
+ _isCriteriaIndexable(criteria) {
1474
+ if (!criteria || typeof criteria !== 'object' || Array.isArray(criteria)) {
1475
+ return false;
1476
+ }
1477
+
1478
+ const fields = Object.keys(criteria);
1479
+ if (fields.length === 0) {
1480
+ return false;
1481
+ }
1482
+
1483
+ return fields.every(field => {
1484
+ const value = criteria[field];
1485
+
1486
+ if (field === '$and' || field === '$or') {
1487
+ return Array.isArray(value) &&
1488
+ value.length > 0 &&
1489
+ value.every(branch => this._isCriteriaIndexable(branch));
1490
+ }
1491
+
1492
+ if (field === '$not') {
1493
+ // $not is only index-supported when term mapping is enabled
1494
+ if (!this.opts.termMapping || !value || typeof value !== 'object' || Array.isArray(value)) {
1495
+ return false;
1496
+ }
1497
+ const notFields = Object.keys(value);
1498
+ return notFields.length > 0 && notFields.every(notField =>
1499
+ !!(this.indexManager.opts.indexes && this.indexManager.opts.indexes[notField])
1500
+ );
1501
+ }
1502
+
1503
+ if (field.startsWith('$')) {
1504
+ return false;
1505
+ }
1506
+
1507
+ return this._isFieldConditionIndexable(field, value);
1508
+ });
1509
+ }
1510
+
1511
+ /**
1512
+ * Whether a single field condition is supported by that field's index
1513
+ * @param {string} field - Field name
1514
+ * @param {*} condition - Condition value
1515
+ * @returns {boolean} - True if the index can answer this condition
1516
+ */
1517
+ _isFieldConditionIndexable(field, condition) {
1518
+ if (!this.indexManager.opts.indexes || !this.indexManager.opts.indexes[field]) {
1519
+ return false;
1520
+ }
1521
+
1522
+ const indexType = this.indexManager.opts.indexes[field];
1523
+
1524
+ // INTENTIONAL (CHANGELOG [2.2.5]): raw RegExp on string/term indexes is resolved
1525
+ // via index keys (not streaming). Do not revert to "regex => streaming".
1526
+ if (condition instanceof RegExp) {
1527
+ return indexType !== 'number' && indexType !== 'array:number';
1528
+ }
1529
+
1530
+ // Index lookups cannot express "is null/undefined" reliably
1531
+ if (condition === null || condition === undefined) {
1532
+ return false;
1533
+ }
1534
+
1535
+ if (typeof condition !== 'object' || Array.isArray(condition)) {
1536
+ return true;
1537
+ }
1538
+
1539
+ const operators = Object.keys(condition).map(op => normalizeOperator(op));
1540
+ const isNumericIndex = indexType === 'number' || indexType === 'auto' || indexType === 'array:number';
1541
+ const disallowedForNumeric = ['$all', '$in', '$not', '$regex', '$contains', '$exists', '$size'];
1542
+ const disallowedDefault = ['$all', '$in', '$gt', '$gte', '$lt', '$lte', '$ne', '$not', '$regex', '$contains', '$exists', '$size'];
1543
+
1544
+ if (this._isTermMappingField(field)) {
1545
+ const termMappingDisallowed = ['$gt', '$gte', '$lt', '$lte', '$ne', '$regex', '$contains', '$exists', '$size'];
1546
+ return operators.every(op => !termMappingDisallowed.includes(op));
1547
+ }
1548
+
1549
+ const disallowed = isNumericIndex ? disallowedForNumeric : disallowedDefault;
1550
+ return operators.every(op => !disallowed.includes(op));
1551
+ }
1552
+
1420
1553
  /**
1421
1554
  * Estimate number of results for a query
1422
1555
  * @param {Object} criteria - Query criteria