jexidb 2.1.0 → 2.1.1

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.
@@ -6,10 +6,12 @@ import pLimit from 'p-limit'
6
6
  export default class FileHandler {
7
7
  constructor(file, fileMutex = null, opts = {}) {
8
8
  this.file = file
9
- this.indexFile = file.replace(/\.jdb$/, '.idx.jdb')
9
+ this.indexFile = file ? file.replace(/\.jdb$/, '.idx.jdb') : null
10
10
  this.fileMutex = fileMutex
11
11
  this.opts = opts
12
12
  this.maxBufferSize = opts.maxBufferSize || 4 * 1024 * 1024 // 4MB default
13
+ // Global I/O limiter to prevent file descriptor exhaustion in concurrent operations
14
+ this.readLimiter = pLimit(opts.maxConcurrentReads || 4)
13
15
  }
14
16
 
15
17
  async truncate(offset) {
@@ -174,7 +176,7 @@ export default class FileHandler {
174
176
  }
175
177
 
176
178
  async readRanges(ranges, mapper) {
177
- const lines = {}, limit = pLimit(4)
179
+ const lines = {}
178
180
 
179
181
  // Check if file exists before trying to read it
180
182
  if (!await this.exists()) {
@@ -185,7 +187,7 @@ export default class FileHandler {
185
187
  const groupedRanges = await this.groupedRanges(ranges)
186
188
  try {
187
189
  await Promise.allSettled(groupedRanges.map(async (groupedRange) => {
188
- await limit(async () => {
190
+ await this.readLimiter(async () => {
189
191
  for await (const row of this.readGroupedRange(groupedRange, fd)) {
190
192
  lines[row.start] = mapper ? (await mapper(row.line, { start: row.start, end: row.start + row.line.length })) : row.line
191
193
  }
@@ -255,6 +257,10 @@ export default class FileHandler {
255
257
  lineString = actualBuffer.toString('utf8', { replacement: '?' })
256
258
  }
257
259
 
260
+ // CRITICAL FIX: Remove trailing newlines and whitespace for single range too
261
+ // Optimized: Use trimEnd() which efficiently removes all trailing whitespace (faster than manual checks)
262
+ lineString = lineString.trimEnd()
263
+
258
264
  yield {
259
265
  line: lineString,
260
266
  start: range.start,
@@ -301,14 +307,42 @@ export default class FileHandler {
301
307
  }
302
308
  }
303
309
  } else {
304
- // Original logic for non-adjacent ranges
310
+ // CRITICAL FIX: For non-adjacent ranges, use the range.end directly
311
+ // because range.end already excludes the newline (calculated as offsets[n+1] - 1)
312
+ // We just need to find the line start (beginning of the line in the buffer)
305
313
  for (let i = 0; i < groupedRange.length; i++) {
306
314
  const range = groupedRange[i]
307
315
  const relativeStart = range.start - firstRange.start
308
316
  const relativeEnd = range.end - firstRange.start
309
317
 
310
- // Extract the specific range content
311
- const rangeContent = content.substring(relativeStart, relativeEnd)
318
+ // OPTIMIZATION 2: Find line start only if necessary
319
+ // Check if we're already at a line boundary to avoid unnecessary backwards search
320
+ let lineStart = relativeStart
321
+ if (relativeStart > 0 && content[relativeStart - 1] !== '\n') {
322
+ // Only search backwards if we're not already at a line boundary
323
+ while (lineStart > 0 && content[lineStart - 1] !== '\n') {
324
+ lineStart--
325
+ }
326
+ }
327
+
328
+ // OPTIMIZATION 3: Use slice() instead of substring() for better performance
329
+ // CRITICAL FIX: range.end = offsets[n+1] - 1 points to the newline character
330
+ // slice(start, end) includes characters from start to end-1 (end is exclusive)
331
+ // So if relativeEnd points to the newline, slice will include it
332
+ let rangeContent = content.slice(lineStart, relativeEnd)
333
+
334
+ // OPTIMIZATION 4: Direct character check instead of regex/trimEnd
335
+ // Remove trailing newlines and whitespace efficiently
336
+ // trimEnd() is actually optimized in V8, but we can check if there's anything to trim first
337
+ const len = rangeContent.length
338
+ if (len > 0) {
339
+ // Quick check: if last char is not whitespace, skip trimEnd
340
+ const lastChar = rangeContent[len - 1]
341
+ if (lastChar === '\n' || lastChar === '\r' || lastChar === ' ' || lastChar === '\t') {
342
+ // Only call trimEnd if we detected trailing whitespace
343
+ rangeContent = rangeContent.trimEnd()
344
+ }
345
+ }
312
346
 
313
347
  if (rangeContent.length === 0) continue
314
348
 
@@ -543,45 +577,48 @@ export default class FileHandler {
543
577
  }
544
578
 
545
579
  async readLastLine() {
546
- // Check if file exists before trying to read it
547
- if (!await this.exists()) {
548
- return null // Return null if file doesn't exist
549
- }
550
-
551
- const reader = await fs.promises.open(this.file, 'r')
552
- try {
553
- const { size } = await reader.stat()
554
- if (size < 1) throw 'empty file'
555
- this.size = size
556
- const bufferSize = 16384
557
- let buffer, isFirstRead = true, lastReadSize, readPosition = Math.max(size - bufferSize, 0)
558
- while (readPosition >= 0) {
559
- const readSize = Math.min(bufferSize, size - readPosition)
560
- if (readSize !== lastReadSize) {
561
- lastReadSize = readSize
562
- buffer = Buffer.alloc(readSize)
563
- }
564
- const { bytesRead } = await reader.read(buffer, 0, isFirstRead ? (readSize - 1) : readSize, readPosition)
565
- if (isFirstRead) isFirstRead = false
566
- if (bytesRead === 0) break
567
- const newlineIndex = buffer.lastIndexOf(10)
568
- const start = readPosition + newlineIndex + 1
569
- if (newlineIndex !== -1) {
570
- const lastLine = Buffer.alloc(size - start)
571
- await reader.read(lastLine, 0, size - start, start)
572
- if (!lastLine || !lastLine.length) {
573
- throw 'no metadata or empty file'
580
+ // Use global read limiter to prevent file descriptor exhaustion
581
+ return this.readLimiter(async () => {
582
+ // Check if file exists before trying to read it
583
+ if (!await this.exists()) {
584
+ return null // Return null if file doesn't exist
585
+ }
586
+
587
+ const reader = await fs.promises.open(this.file, 'r')
588
+ try {
589
+ const { size } = await reader.stat()
590
+ if (size < 1) throw 'empty file'
591
+ this.size = size
592
+ const bufferSize = 16384
593
+ let buffer, isFirstRead = true, lastReadSize, readPosition = Math.max(size - bufferSize, 0)
594
+ while (readPosition >= 0) {
595
+ const readSize = Math.min(bufferSize, size - readPosition)
596
+ if (readSize !== lastReadSize) {
597
+ lastReadSize = readSize
598
+ buffer = Buffer.alloc(readSize)
599
+ }
600
+ const { bytesRead } = await reader.read(buffer, 0, isFirstRead ? (readSize - 1) : readSize, readPosition)
601
+ if (isFirstRead) isFirstRead = false
602
+ if (bytesRead === 0) break
603
+ const newlineIndex = buffer.lastIndexOf(10)
604
+ const start = readPosition + newlineIndex + 1
605
+ if (newlineIndex !== -1) {
606
+ const lastLine = Buffer.alloc(size - start)
607
+ await reader.read(lastLine, 0, size - start, start)
608
+ if (!lastLine || !lastLine.length) {
609
+ throw 'no metadata or empty file'
610
+ }
611
+ return lastLine
612
+ } else {
613
+ readPosition -= bufferSize
574
614
  }
575
- return lastLine
576
- } else {
577
- readPosition -= bufferSize
578
615
  }
616
+ } catch (e) {
617
+ String(e).includes('empty file') || console.error('Error reading last line:', e)
618
+ } finally {
619
+ reader.close()
579
620
  }
580
- } catch (e) {
581
- String(e).includes('empty file') || console.error('Error reading last line:', e)
582
- } finally {
583
- reader.close()
584
- }
621
+ })
585
622
  }
586
623
 
587
624
  /**
@@ -597,10 +634,12 @@ export default class FileHandler {
597
634
  return this.fileMutex.runExclusive(async () => {
598
635
  // Add a small delay to ensure any pending operations complete
599
636
  await new Promise(resolve => setTimeout(resolve, 5));
600
- return this._readWithStreamingInternal(criteria, options, matchesCriteria, serializer);
637
+ // Use global read limiter to prevent file descriptor exhaustion
638
+ return this.readLimiter(() => this._readWithStreamingInternal(criteria, options, matchesCriteria, serializer));
601
639
  });
602
640
  } else {
603
- return this._readWithStreamingInternal(criteria, options, matchesCriteria, serializer);
641
+ // Use global read limiter to prevent file descriptor exhaustion
642
+ return this.readLimiter(() => this._readWithStreamingInternal(criteria, options, matchesCriteria, serializer));
604
643
  }
605
644
  }
606
645
 
@@ -1,6 +1,6 @@
1
1
  /**
2
- * OperationQueue - Sistema de fila para operações do banco de dados
3
- * Resolve race conditions entre operações concorrentes
2
+ * OperationQueue - Queue system for database operations
3
+ * Resolves race conditions between concurrent operations
4
4
  */
5
5
 
6
6
  export class OperationQueue {
@@ -20,9 +20,9 @@ export class OperationQueue {
20
20
  }
21
21
 
22
22
  /**
23
- * Adiciona uma operação à fila
24
- * @param {Function} operation - Função assíncrona a ser executada
25
- * @returns {Promise} - Promise que resolve quando a operação é concluída
23
+ * Adds an operation to the queue
24
+ * @param {Function} operation - Asynchronous function to be executed
25
+ * @returns {Promise} - Promise that resolves when the operation is completed
26
26
  */
27
27
  async enqueue(operation) {
28
28
  const id = ++this.operationId
@@ -48,13 +48,13 @@ export class OperationQueue {
48
48
  startTime: Date.now()
49
49
  })
50
50
 
51
- // Processar imediatamente se não estiver processando
51
+ // Process immediately if not already processing
52
52
  this.process().catch(reject)
53
53
  })
54
54
  }
55
55
 
56
56
  /**
57
- * Processa todas as operações na fila sequencialmente
57
+ * Processes all operations in the queue sequentially
58
58
  */
59
59
  async process() {
60
60
  if (this.processing || this.queue.length === 0) {
@@ -116,9 +116,9 @@ export class OperationQueue {
116
116
  }
117
117
 
118
118
  /**
119
- * Aguarda todas as operações pendentes serem processadas
120
- * @param {number|null} maxWaitTime - Tempo máximo de espera em ms (null = wait indefinitely)
121
- * @returns {Promise<boolean>} - true se todas foram processadas, false se timeout
119
+ * Waits for all pending operations to be processed
120
+ * @param {number|null} maxWaitTime - Maximum wait time in ms (null = wait indefinitely)
121
+ * @returns {Promise<boolean>} - true if all operations were processed, false if a timeout occurred
122
122
  */
123
123
  async waitForCompletion(maxWaitTime = 5000) {
124
124
  const startTime = Date.now()
@@ -167,21 +167,21 @@ export class OperationQueue {
167
167
  }
168
168
 
169
169
  /**
170
- * Retorna o tamanho atual da fila
170
+ * Returns the current queue length
171
171
  */
172
172
  getQueueLength() {
173
173
  return this.queue.length
174
174
  }
175
175
 
176
176
  /**
177
- * Verifica se está processando operações
177
+ * Checks whether operations are currently being processed
178
178
  */
179
179
  isProcessing() {
180
180
  return this.processing
181
181
  }
182
182
 
183
183
  /**
184
- * Retorna estatísticas da fila
184
+ * Returns queue statistics
185
185
  */
186
186
  getStats() {
187
187
  return {
@@ -194,7 +194,7 @@ export class OperationQueue {
194
194
  }
195
195
 
196
196
  /**
197
- * Limpa a fila (para casos de emergência)
197
+ * Clears the queue (for emergency situations)
198
198
  */
199
199
  clear() {
200
200
  const clearedCount = this.queue.length
@@ -208,9 +208,9 @@ export class OperationQueue {
208
208
  }
209
209
 
210
210
  /**
211
- * Detecta operações travadas e retorna informações detalhadas
212
- * @param {number} stuckThreshold - Tempo em ms para considerar uma operação travada
213
- * @returns {Array} - Lista de operações travadas com stack traces
211
+ * Detects stuck operations and returns detailed information
212
+ * @param {number} stuckThreshold - Time in ms to consider an operation stuck
213
+ * @returns {Array} - List of stuck operations with stack traces
214
214
  */
215
215
  detectStuckOperations(stuckThreshold = 10000) {
216
216
  const now = Date.now()
@@ -225,15 +225,15 @@ export class OperationQueue {
225
225
  }
226
226
 
227
227
  /**
228
- * Força a limpeza de operações travadas (último recurso)
229
- * @param {number} stuckThreshold - Tempo em ms para considerar uma operação travada
230
- * @returns {number} - Número de operações removidas
228
+ * Force-cleans stuck operations (last resort)
229
+ * @param {number} stuckThreshold - Time in ms to consider an operation stuck
230
+ * @returns {number} - Number of operations removed
231
231
  */
232
232
  forceCleanupStuckOperations(stuckThreshold = 10000) {
233
233
  const stuckOps = this.detectStuckOperations(stuckThreshold)
234
234
 
235
235
  if (stuckOps.length > 0) {
236
- // Rejeitar todas as operações travadas
236
+ // Reject all stuck operations
237
237
  stuckOps.forEach(stuckOp => {
238
238
  const opIndex = this.queue.findIndex(op => op.id === stuckOp.id)
239
239
  if (opIndex !== -1) {
@@ -255,14 +255,14 @@ export class OperationQueue {
255
255
  }
256
256
 
257
257
  /**
258
- * Verifica se a fila está vazia
258
+ * Checks whether the queue is empty
259
259
  */
260
260
  isEmpty() {
261
261
  return this.queue.length === 0
262
262
  }
263
263
 
264
264
  /**
265
- * Retorna informações sobre a próxima operação na fila
265
+ * Returns information about the next operation in the queue
266
266
  */
267
267
  peekNext() {
268
268
  if (this.queue.length === 0) {
@@ -259,42 +259,69 @@ export default class Serializer {
259
259
  optimizedStringify(obj) {
260
260
  // CRITICAL: Normalize encoding for all string fields before stringify
261
261
  const normalizedObj = this.deepNormalizeEncoding(obj)
262
-
262
+ return this._stringifyNormalizedValue(normalizedObj)
263
+ }
264
+
265
+ _stringifyNormalizedValue(value) {
263
266
  // Fast path for null and undefined
264
- if (normalizedObj === null) return 'null'
265
- if (normalizedObj === undefined) return 'null'
267
+ if (value === null || value === undefined) {
268
+ return 'null'
269
+ }
270
+
271
+ const type = typeof value
266
272
 
267
273
  // Fast path for primitives
268
- if (typeof normalizedObj === 'boolean') return normalizedObj ? 'true' : 'false'
269
- if (typeof normalizedObj === 'number') return normalizedObj.toString()
270
- if (typeof normalizedObj === 'string') {
274
+ if (type === 'boolean') {
275
+ return value ? 'true' : 'false'
276
+ }
277
+
278
+ if (type === 'number') {
279
+ return Number.isFinite(value) ? value.toString() : 'null'
280
+ }
281
+
282
+ if (type === 'string') {
271
283
  // Fast path for simple strings (no escaping needed)
272
- if (!/[\\"\u0000-\u001f]/.test(normalizedObj)) {
273
- return '"' + normalizedObj + '"'
284
+ if (!/[\\"\u0000-\u001f]/.test(value)) {
285
+ return '"' + value + '"'
274
286
  }
275
287
  // Fall back to JSON.stringify for complex strings
276
- return JSON.stringify(normalizedObj)
288
+ return JSON.stringify(value)
277
289
  }
278
290
 
279
- // Fast path for arrays
280
- if (Array.isArray(normalizedObj)) {
281
- if (normalizedObj.length === 0) return '[]'
282
-
283
- // For arrays, always use JSON.stringify to avoid concatenation issues
284
- return JSON.stringify(normalizedObj)
291
+ if (Array.isArray(value)) {
292
+ return this._stringifyNormalizedArray(value)
285
293
  }
286
294
 
287
- // Fast path for objects
288
- if (typeof normalizedObj === 'object') {
289
- const keys = Object.keys(normalizedObj)
295
+ if (type === 'object') {
296
+ const keys = Object.keys(value)
290
297
  if (keys.length === 0) return '{}'
291
-
292
- // For objects, always use JSON.stringify to avoid concatenation issues
293
- return JSON.stringify(normalizedObj)
298
+ // Use native stringify for object to leverage stable handling of undefined, Dates, etc.
299
+ return JSON.stringify(value)
294
300
  }
295
301
 
296
- // Fallback to JSON.stringify for unknown types
297
- return JSON.stringify(normalizedObj)
302
+ // Fallback to JSON.stringify for unknown types (BigInt, symbols, etc.)
303
+ return JSON.stringify(value)
304
+ }
305
+
306
+ _stringifyNormalizedArray(arr) {
307
+ const length = arr.length
308
+ if (length === 0) return '[]'
309
+
310
+ let result = '['
311
+ for (let i = 0; i < length; i++) {
312
+ if (i > 0) result += ','
313
+ const element = arr[i]
314
+
315
+ // JSON spec: undefined, functions, and symbols are serialized as null within arrays
316
+ if (element === undefined || typeof element === 'function' || typeof element === 'symbol') {
317
+ result += 'null'
318
+ continue
319
+ }
320
+
321
+ result += this._stringifyNormalizedValue(element)
322
+ }
323
+ result += ']'
324
+ return result
298
325
  }
299
326
 
300
327
  /**
@@ -350,12 +377,176 @@ export default class Serializer {
350
377
  // Fast path for empty strings
351
378
  if (strLength === 0) return null
352
379
 
380
+ // CRITICAL FIX: Detect and handle multiple JSON objects in the same line
381
+ // This can happen if data was corrupted during concurrent writes or offset calculation errors
382
+ const firstBrace = str.indexOf('{')
383
+ const firstBracket = str.indexOf('[')
384
+
385
+ // Helper function to extract first complete JSON object/array from a string
386
+ // CRITICAL FIX: Must handle strings and escaped characters correctly
387
+ // to avoid counting braces/brackets that are inside string values
388
+ const extractFirstJson = (jsonStr, startChar) => {
389
+ if (startChar === '{') {
390
+ let braceCount = 0
391
+ let endPos = -1
392
+ let inString = false
393
+ let escapeNext = false
394
+
395
+ for (let i = 0; i < jsonStr.length; i++) {
396
+ const char = jsonStr[i]
397
+
398
+ if (escapeNext) {
399
+ escapeNext = false
400
+ continue
401
+ }
402
+
403
+ if (char === '\\') {
404
+ escapeNext = true
405
+ continue
406
+ }
407
+
408
+ if (char === '"' && !escapeNext) {
409
+ inString = !inString
410
+ continue
411
+ }
412
+
413
+ if (!inString) {
414
+ if (char === '{') braceCount++
415
+ if (char === '}') {
416
+ braceCount--
417
+ if (braceCount === 0) {
418
+ endPos = i + 1
419
+ break
420
+ }
421
+ }
422
+ }
423
+ }
424
+ return endPos > 0 ? jsonStr.substring(0, endPos) : null
425
+ } else if (startChar === '[') {
426
+ let bracketCount = 0
427
+ let endPos = -1
428
+ let inString = false
429
+ let escapeNext = false
430
+
431
+ for (let i = 0; i < jsonStr.length; i++) {
432
+ const char = jsonStr[i]
433
+
434
+ if (escapeNext) {
435
+ escapeNext = false
436
+ continue
437
+ }
438
+
439
+ if (char === '\\') {
440
+ escapeNext = true
441
+ continue
442
+ }
443
+
444
+ if (char === '"' && !escapeNext) {
445
+ inString = !inString
446
+ continue
447
+ }
448
+
449
+ if (!inString) {
450
+ if (char === '[') bracketCount++
451
+ if (char === ']') {
452
+ bracketCount--
453
+ if (bracketCount === 0) {
454
+ endPos = i + 1
455
+ break
456
+ }
457
+ }
458
+ }
459
+ }
460
+ return endPos > 0 ? jsonStr.substring(0, endPos) : null
461
+ }
462
+ return null
463
+ }
464
+
465
+ // Check if JSON starts at the beginning of the string
466
+ const jsonStartsAtZero = (firstBrace === 0) || (firstBracket === 0)
467
+ let hasValidJson = false
468
+
469
+ if (jsonStartsAtZero) {
470
+ // JSON starts at beginning - check for multiple JSON objects/arrays
471
+ if (firstBrace === 0) {
472
+ const secondBrace = str.indexOf('{', 1)
473
+ if (secondBrace !== -1) {
474
+ // Multiple objects detected - extract first
475
+ const extracted = extractFirstJson(str, '{')
476
+ if (extracted) {
477
+ str = extracted
478
+ hasValidJson = true
479
+ if (this.opts && this.opts.debugMode) {
480
+ console.warn(`⚠️ Deserialize: Multiple JSON objects detected, using first object only`)
481
+ }
482
+ }
483
+ } else {
484
+ hasValidJson = true // Single valid object starting at 0
485
+ }
486
+ } else if (firstBracket === 0) {
487
+ const secondBracket = str.indexOf('[', 1)
488
+ if (secondBracket !== -1) {
489
+ // Multiple arrays detected - extract first
490
+ const extracted = extractFirstJson(str, '[')
491
+ if (extracted) {
492
+ str = extracted
493
+ hasValidJson = true
494
+ if (this.opts && this.opts.debugMode) {
495
+ console.warn(`⚠️ Deserialize: Multiple JSON arrays detected, using first array only`)
496
+ }
497
+ }
498
+ } else {
499
+ hasValidJson = true // Single valid array starting at 0
500
+ }
501
+ }
502
+ } else {
503
+ // JSON doesn't start at beginning - try to find and extract first valid JSON
504
+ const jsonStart = firstBrace !== -1 ? (firstBracket !== -1 ? Math.min(firstBrace, firstBracket) : firstBrace) : firstBracket
505
+
506
+ if (jsonStart !== -1 && jsonStart > 0) {
507
+ // Found JSON but not at start - extract from that position
508
+ const jsonStr = str.substring(jsonStart)
509
+ const startChar = jsonStr[0]
510
+ const extracted = extractFirstJson(jsonStr, startChar)
511
+
512
+ if (extracted) {
513
+ str = extracted
514
+ hasValidJson = true
515
+ if (this.opts && this.opts.debugMode) {
516
+ console.warn(`⚠️ Deserialize: Found JSON after ${jsonStart} chars of invalid text, extracted first ${startChar === '{' ? 'object' : 'array'}`)
517
+ }
518
+ }
519
+ }
520
+ }
521
+
522
+ // CRITICAL FIX: If no valid JSON structure found, throw error before attempting parse
523
+ // This allows walk() and other callers to catch and skip invalid lines
524
+ if (!hasValidJson && firstBrace === -1 && firstBracket === -1) {
525
+ const errorStr = Buffer.isBuffer(data) ? data.toString('utf8').trim() : data.trim()
526
+ const error = new Error(`Failed to deserialize JSON data: No valid JSON structure found in "${errorStr.substring(0, 100)}..."`)
527
+ // Mark this as a "no valid JSON" error so it can be handled appropriately
528
+ error.noValidJson = true
529
+ throw error
530
+ }
531
+
532
+ // If we tried to extract but got nothing valid, also throw error
533
+ if (hasValidJson && (!str || str.trim().length === 0)) {
534
+ const error = new Error(`Failed to deserialize JSON data: Extracted JSON is empty`)
535
+ error.noValidJson = true
536
+ throw error
537
+ }
538
+
353
539
  // Parse JSON data
354
540
  const parsedData = JSON.parse(str)
355
541
 
356
542
  // Convert from array format back to object if needed
357
543
  return this.convertFromArrayFormat(parsedData)
358
544
  } catch (e) {
545
+ // If error was already formatted with noValidJson flag, re-throw as-is
546
+ if (e.noValidJson) {
547
+ throw e
548
+ }
549
+ // Otherwise, format the error message
359
550
  const str = Buffer.isBuffer(data) ? data.toString('utf8').trim() : data.trim()
360
551
  throw new Error(`Failed to deserialize JSON data: "${str.substring(0, 100)}..." - ${e.message}`)
361
552
  }