jexidb 2.2.2 → 2.2.5

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.
@@ -1074,18 +1074,44 @@ export default class IndexManager {
1074
1074
  if (typeof data[field] === 'undefined') continue;
1075
1075
 
1076
1076
  const originalCriteriaValue = criteria[field];
1077
- const criteriaValue = normalizeCriteriaOperators(originalCriteriaValue, { target: 'legacy', preserveOriginal: true });
1077
+ // INTENTIONAL (CHANGELOG [2.2.5]): RegExp is not an operator map - never normalize it
1078
+ // (spreading a RegExp yields {} and would make the regex match every record).
1079
+ const criteriaValue = originalCriteriaValue instanceof RegExp
1080
+ ? originalCriteriaValue
1081
+ : normalizeCriteriaOperators(originalCriteriaValue, { target: 'legacy', preserveOriginal: true });
1078
1082
  let lineNumbersForField = new Set();
1079
1083
  const isNumericField = this.opts.indexes[field] === 'number';
1080
1084
 
1081
- // Handle RegExp values directly (MUST check before object check since RegExp is an object)
1085
+ // INTENTIONAL OPTIMIZATION (see CHANGELOG [2.2.5]): raw RegExp on string/term
1086
+ // indexes is answered via the index keys instead of full-file streaming. For
1087
+ // term-mapped fields the keys are numeric term IDs, translated back to words with
1088
+ // termManager.getTerm() before testing the regex. DO NOT change this back to
1089
+ // "RegExp => streaming" - it regresses partial search to a full scan per database.
1082
1090
  if (criteriaValue instanceof RegExp) {
1083
- // RegExp cannot be efficiently queried using indices - fall back to streaming
1084
- // This will be handled by the QueryManager's streaming strategy
1085
- continue;
1091
+ const fieldIndex = data[field];
1092
+ const indexType = this.opts.indexes && this.opts.indexes[field];
1093
+ const isNumericIndexType = indexType === 'number' || indexType === 'array:number';
1094
+ if (!fieldIndex || isNumericIndexType) {
1095
+ // Not index-answerable (numeric index) - handled by streaming fallback
1096
+ continue;
1097
+ }
1098
+ const termManager = this.database && this.database.termManager;
1099
+ const isTermField = !!(termManager && termManager.termMappingFields &&
1100
+ termManager.termMappingFields.includes(field));
1101
+ const regex = criteriaValue;
1102
+ for (const key of Object.keys(fieldIndex)) {
1103
+ const word = isTermField ? termManager.getTerm(Number(key)) : key;
1104
+ if (word === null || word === undefined) continue;
1105
+ regex.lastIndex = 0;
1106
+ if (!regex.test(String(word))) continue;
1107
+ const numbers = this._getAllLineNumbers(fieldIndex[key]);
1108
+ for (const lineNumber of numbers) lineNumbersForField.add(lineNumber);
1109
+ }
1110
+ // Fall through to field consolidation below (RegExp is an object, so it is
1111
+ // excluded from the object/operator branch that follows).
1086
1112
  }
1087
1113
 
1088
- if (typeof criteriaValue === 'object' && !Array.isArray(criteriaValue) && criteriaValue !== null) {
1114
+ if (typeof criteriaValue === 'object' && !(criteriaValue instanceof RegExp) && !Array.isArray(criteriaValue) && criteriaValue !== null) {
1089
1115
  const fieldIndex = data[field];
1090
1116
 
1091
1117
  // Handle $in operator for array queries
@@ -2351,6 +2377,11 @@ export default class IndexManager {
2351
2377
  * on-disk index after an unload must first ensure it is loaded again, e.g.
2352
2378
  * `await database._ensureLazyIndexLoaded()`.
2353
2379
  *
2380
+ * The return format is ALWAYS the actual term strings (words/values), never the
2381
+ * internal numeric term IDs. For term-mapped columns the index stores term IDs as
2382
+ * keys, so they are translated back to words through the TermManager before being
2383
+ * returned. This keeps the API consistent across indexed fields.
2384
+ *
2354
2385
  * @param {string} column - Indexed field name (e.g. 'nameTerms', 'groupTerms', 'category')
2355
2386
  * @returns {Set<string>} Terms currently indexed for the column (empty Set if the
2356
2387
  * column is not indexed or the index is not loaded)
@@ -2359,7 +2390,35 @@ export default class IndexManager {
2359
2390
  if (!this.index || !this.index.data || !this.index.data[column]) {
2360
2391
  return new Set()
2361
2392
  }
2362
- return new Set(Object.keys(this.index.data[column]))
2393
+
2394
+ const keys = Object.keys(this.index.data[column])
2395
+
2396
+ // Consistent contract: always return term strings, never numeric term IDs.
2397
+ const termManager = this.database?.termManager
2398
+ const isTermMappingField = Boolean(
2399
+ termManager &&
2400
+ termManager.termMappingFields &&
2401
+ termManager.termMappingFields.includes(column)
2402
+ )
2403
+
2404
+ if (!isTermMappingField) {
2405
+ return new Set(keys)
2406
+ }
2407
+
2408
+ // Term-mapped column: index keys are numeric term IDs -> translate back to words.
2409
+ const terms = new Set()
2410
+ for (const key of keys) {
2411
+ if (/^\d+$/.test(key)) {
2412
+ const word = termManager.getTerm?.(Number(key))
2413
+ if (word !== null && word !== undefined) {
2414
+ terms.add(word)
2415
+ }
2416
+ } else {
2417
+ // Defensive: key is already a term string (legacy/in-memory data)
2418
+ terms.add(key)
2419
+ }
2420
+ }
2421
+ return terms
2363
2422
  }
2364
2423
 
2365
2424
  /**
@@ -304,6 +304,29 @@ export class QueryManager {
304
304
  return this._streamAllRecords(criteria, streamingOptions);
305
305
  }
306
306
 
307
+ /**
308
+ * Whether a single field condition can/should be answered by the index during
309
+ * pre-filtering (so the LEAST number of entries is streamed).
310
+ *
311
+ * - Raw RegExp is pre-filterable only on string/term indexes (IndexManager.query
312
+ * resolves it via the index keys). Regex on numeric indexes -> residual.
313
+ * - Complement operators ($ne/$nin) expand to nearly every record, so they are
314
+ * NOT pre-filtered: they are applied LAST, on the minimal candidate set, by the
315
+ * streaming matcher. (CHANGELOG [2.2.5])
316
+ */
317
+ _canPrefilterFieldCondition(field, condition) {
318
+ if (!this.indexManager?.opts?.indexes?.[field]) return false
319
+ if (condition instanceof RegExp) {
320
+ const idxType = this.indexManager.opts.indexes[field]
321
+ return idxType !== 'number' && idxType !== 'array:number'
322
+ }
323
+ if (condition && typeof condition === 'object' && !Array.isArray(condition)) {
324
+ const ops = Object.keys(condition).map(op => normalizeOperator(op))
325
+ if (ops.some(op => op === '$ne' || op === '$nin')) return false
326
+ }
327
+ return true
328
+ }
329
+
307
330
  /**
308
331
  * Get indexable fields from criteria
309
332
  * @param {Object} criteria - Query criteria
@@ -327,8 +350,9 @@ export class QueryManager {
327
350
  for (const [field, condition] of Object.entries(criteria)) {
328
351
  if (field.startsWith('$')) continue; // Skip logical operators
329
352
 
330
- // RegExp conditions cannot be pre-filtered using indices
331
- if (condition instanceof RegExp) {
353
+ // Pre-filter with the index only what it can answer minimally; everything
354
+ // else is applied LAST over the streamed candidates (filter last, stream least).
355
+ if (!this._canPrefilterFieldCondition(field, condition)) {
332
356
  continue;
333
357
  }
334
358
 
@@ -384,8 +408,9 @@ export class QueryManager {
384
408
  for (const [field, condition] of Object.entries(criteria)) {
385
409
  if (field.startsWith('$')) continue; // Skip logical operators (already handled above)
386
410
 
387
- // RegExp conditions cannot be pre-filtered using indices
388
- if (condition instanceof RegExp) {
411
+ // Pre-filter with the index only what it can answer minimally; complement/
412
+ // numeric-regex conditions are left residual and applied LAST on the candidates.
413
+ if (!this._canPrefilterFieldCondition(field, condition)) {
389
414
  continue;
390
415
  }
391
416
 
@@ -728,6 +753,19 @@ export class QueryManager {
728
753
  }
729
754
  return this.findWithStreaming(criteria, { ...options, forceFullScan: true })
730
755
  }
756
+
757
+ // Swap detection: if another process atomically replaced the data file, the
758
+ // in-memory offsets/index describe the previous file. Reload cleanly, or -
759
+ // when a writer sentinel is present / the clean reload fails - fall back to
760
+ // a full streaming scan so stale byte offsets are never used on the new file.
761
+ const refreshStatus = await this.database._refreshFromDiskIfChanged()
762
+ if (refreshStatus === 'busy') {
763
+ if (this.opts.debugMode) {
764
+ console.log('⚠️ findWithIndexed: file busy (writer active) or clean reload failed, falling back to full streaming')
765
+ }
766
+ return this.findWithStreaming(criteria, { ...options, forceFullScan: true })
767
+ }
768
+
731
769
  let results = []
732
770
  const limit = options.limit // No default limit - return all results unless explicitly limited
733
771
 
@@ -944,15 +982,25 @@ export class QueryManager {
944
982
  matchesFieldCondition(record, field, condition, options = {}) {
945
983
  const value = record[field];
946
984
 
947
- // Debug logging for all field conditions
985
+ // Debug logging for all field conditions - SAMPLED: only the first record of
986
+ // each query logs per field, so a full streaming scan (which calls this for
987
+ // every record) does not spam the console with one line per record.
948
988
  if (this.database.opts.debugMode) {
949
- console.log(`🔍 Checking field '${field}':`, { value, condition, record: record.name || record.id });
989
+ if (!options._dbFieldLog) options._dbFieldLog = {};
990
+ if (!options._dbFieldLog[field]) {
991
+ options._dbFieldLog[field] = true;
992
+ console.log(`🔍 Checking field '${field}':`, { value, condition, record: record.name || record.id });
993
+ }
950
994
  }
951
995
 
952
- // Debug logging for term mapping fields
996
+ // Debug logging for term mapping fields (sampled the same way)
953
997
  if (this.database.opts.termMapping && Object.keys(this.database.opts.indexes || {}).includes(field)) {
954
998
  if (this.database.opts.debugMode) {
955
- console.log(`🔍 Checking term mapping field '${field}':`, { value, condition, record: record.name || record.id });
999
+ if (!options._dbFieldLog) options._dbFieldLog = {};
1000
+ if (!options._dbFieldLog[`tm:${field}`]) {
1001
+ options._dbFieldLog[`tm:${field}`] = true;
1002
+ console.log(`🔍 Checking term mapping field '${field}':`, { value, condition, record: record.name || record.id });
1003
+ }
956
1004
  }
957
1005
  }
958
1006
 
@@ -1229,9 +1277,11 @@ export class QueryManager {
1229
1277
 
1230
1278
  const condition = andCondition[field];
1231
1279
 
1232
- // RegExp cannot be efficiently queried using indices - must use streaming
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".
1233
1282
  if (condition instanceof RegExp) {
1234
- return false;
1283
+ const idxType = this.indexManager?.opts?.indexes?.[field];
1284
+ return idxType !== 'number' && idxType !== 'array:number';
1235
1285
  }
1236
1286
 
1237
1287
  if (typeof condition === 'object' && !Array.isArray(condition)) {
@@ -1280,12 +1330,17 @@ export class QueryManager {
1280
1330
  // Check if the field uses operators that are supported by IndexManager
1281
1331
  const condition = criteria[field];
1282
1332
 
1283
- // RegExp cannot be efficiently queried using indices - must use streaming
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".
1284
1335
  if (condition instanceof RegExp) {
1285
- if (this.opts.debugMode) {
1286
- console.log(`🔍 Field '${field}' uses RegExp - requires streaming strategy`)
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;
1287
1342
  }
1288
- return false;
1343
+ return true;
1289
1344
  }
1290
1345
 
1291
1346
  if (typeof condition === 'object' && !Array.isArray(condition) && condition !== null) {
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Shared helpers to normalize and detect I/O timeout / abort errors.
3
+ *
4
+ * Node surfaces read timeouts in several inconsistent shapes depending on the
5
+ * path used (`error.name`, `error.code` or `error.message`). These helpers
6
+ * centralize creation and detection so every retry branch across Database.mjs
7
+ * and FileHandler.mjs agrees, and timeouts are always delivered as normal
8
+ * promise rejections - never as an `uncaughtException`.
9
+ */
10
+
11
+ /**
12
+ * Create a normalized I/O timeout error that every retry branch recognizes.
13
+ * @param {number} timeoutMs - Timeout that elapsed, in milliseconds.
14
+ * @returns {Error}
15
+ */
16
+ export function createIoTimeoutError(timeoutMs) {
17
+ const err = new Error(`I/O timeout after ${timeoutMs}ms`)
18
+ err.name = 'AbortError'
19
+ err.code = 'ETIMEDOUT'
20
+ return err
21
+ }
22
+
23
+ /**
24
+ * Detect whether an error represents an I/O timeout or abort, regardless of the
25
+ * shape it was produced in.
26
+ * @param {*} error - Error to inspect (may be null/undefined).
27
+ * @returns {boolean}
28
+ */
29
+ export function isIoTimeoutError(error) {
30
+ if (!error) return false
31
+ return (
32
+ error.name === 'AbortError' ||
33
+ error.code === 'ETIMEDOUT' ||
34
+ error.message === 'AbortError'
35
+ )
36
+ }