jexidb 2.2.3 → 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.
@@ -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
+ }