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.
- package/README.md +50 -0
- package/dist/Database.cjs +538 -56
- package/package.json +1 -1
- package/src/Database.mjs +274 -28
- package/src/FileHandler.mjs +88 -1
- package/src/managers/IndexManager.mjs +66 -7
- package/src/managers/QueryManager.mjs +69 -14
- package/src/utils/ioTimeout.mjs +36 -0
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
331
|
-
|
|
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
|
-
//
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1286
|
-
|
|
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
|
|
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
|
+
}
|