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.
- package/dist/Database.cjs +1642 -334
- package/docs/API.md +1057 -1051
- package/package.json +1 -1
- package/scripts/benchmark-array-serialization.js +108 -0
- package/scripts/score-mode-demo.js +45 -0
- package/src/Database.mjs +1362 -167
- package/src/FileHandler.mjs +83 -44
- package/src/OperationQueue.mjs +23 -23
- package/src/Serializer.mjs +214 -23
- package/src/managers/IndexManager.mjs +778 -87
- package/src/managers/QueryManager.mjs +266 -49
- package/src/managers/TermManager.mjs +7 -7
- package/src/utils/operatorNormalizer.mjs +116 -0
- package/test/coverage-method.test.js +93 -0
- package/test/deserialize-corruption-fixes.test.js +296 -0
- package/test/exists-method.test.js +318 -0
- package/test/explicit-indexes-comparison.test.js +219 -0
- package/test/filehandler-non-adjacent-ranges-bug.test.js +175 -0
- package/test/index-line-number-regression.test.js +100 -0
- package/test/index-missing-index-data.test.js +91 -0
- package/test/index-persistence.test.js +205 -20
- package/test/insert-session-auto-flush.test.js +353 -0
- package/test/legacy-operator-compat.test.js +154 -0
- package/test/score-method.test.js +60 -0
package/src/FileHandler.mjs
CHANGED
|
@@ -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 = {}
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
311
|
-
|
|
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
|
-
//
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/OperationQueue.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OperationQueue -
|
|
3
|
-
*
|
|
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
|
-
*
|
|
24
|
-
* @param {Function} operation -
|
|
25
|
-
* @returns {Promise} - Promise
|
|
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
|
-
//
|
|
51
|
+
// Process immediately if not already processing
|
|
52
52
|
this.process().catch(reject)
|
|
53
53
|
})
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
*
|
|
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
|
-
*
|
|
120
|
-
* @param {number|null} maxWaitTime -
|
|
121
|
-
* @returns {Promise<boolean>} - true
|
|
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
|
-
*
|
|
170
|
+
* Returns the current queue length
|
|
171
171
|
*/
|
|
172
172
|
getQueueLength() {
|
|
173
173
|
return this.queue.length
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
/**
|
|
177
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
212
|
-
* @param {number} stuckThreshold -
|
|
213
|
-
* @returns {Array} -
|
|
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
|
-
*
|
|
229
|
-
* @param {number} stuckThreshold -
|
|
230
|
-
* @returns {number} -
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
265
|
+
* Returns information about the next operation in the queue
|
|
266
266
|
*/
|
|
267
267
|
peekNext() {
|
|
268
268
|
if (this.queue.length === 0) {
|
package/src/Serializer.mjs
CHANGED
|
@@ -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 (
|
|
265
|
-
|
|
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 (
|
|
269
|
-
|
|
270
|
-
|
|
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(
|
|
273
|
-
return '"' +
|
|
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(
|
|
288
|
+
return JSON.stringify(value)
|
|
277
289
|
}
|
|
278
290
|
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
}
|