jexidb 2.1.1 → 2.1.3

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.
Files changed (47) hide show
  1. package/dist/Database.cjs +7981 -231
  2. package/package.json +9 -2
  3. package/src/Database.mjs +372 -154
  4. package/src/SchemaManager.mjs +325 -268
  5. package/src/Serializer.mjs +20 -1
  6. package/src/managers/QueryManager.mjs +74 -18
  7. package/.babelrc +0 -13
  8. package/.gitattributes +0 -2
  9. package/CHANGELOG.md +0 -140
  10. package/babel.config.json +0 -5
  11. package/docs/API.md +0 -1057
  12. package/docs/EXAMPLES.md +0 -701
  13. package/docs/README.md +0 -194
  14. package/examples/iterate-usage-example.js +0 -157
  15. package/examples/simple-iterate-example.js +0 -115
  16. package/jest.config.js +0 -24
  17. package/scripts/README.md +0 -47
  18. package/scripts/benchmark-array-serialization.js +0 -108
  19. package/scripts/clean-test-files.js +0 -75
  20. package/scripts/prepare.js +0 -31
  21. package/scripts/run-tests.js +0 -80
  22. package/scripts/score-mode-demo.js +0 -45
  23. package/test/$not-operator-with-and.test.js +0 -282
  24. package/test/README.md +0 -8
  25. package/test/close-init-cycle.test.js +0 -256
  26. package/test/coverage-method.test.js +0 -93
  27. package/test/critical-bugs-fixes.test.js +0 -1069
  28. package/test/deserialize-corruption-fixes.test.js +0 -296
  29. package/test/exists-method.test.js +0 -318
  30. package/test/explicit-indexes-comparison.test.js +0 -219
  31. package/test/filehandler-non-adjacent-ranges-bug.test.js +0 -175
  32. package/test/index-line-number-regression.test.js +0 -100
  33. package/test/index-missing-index-data.test.js +0 -91
  34. package/test/index-persistence.test.js +0 -491
  35. package/test/index-serialization.test.js +0 -314
  36. package/test/indexed-query-mode.test.js +0 -360
  37. package/test/insert-session-auto-flush.test.js +0 -353
  38. package/test/iterate-method.test.js +0 -272
  39. package/test/legacy-operator-compat.test.js +0 -154
  40. package/test/query-operators.test.js +0 -238
  41. package/test/regex-array-fields.test.js +0 -129
  42. package/test/score-method.test.js +0 -298
  43. package/test/setup.js +0 -17
  44. package/test/term-mapping-minimal.test.js +0 -154
  45. package/test/term-mapping-simple.test.js +0 -257
  46. package/test/term-mapping.test.js +0 -514
  47. package/test/writebuffer-flush-resilience.test.js +0 -204
package/src/Database.mjs CHANGED
@@ -545,6 +545,32 @@ class Database extends EventEmitter {
545
545
  return
546
546
  }
547
547
 
548
+ // Handle legacy 'schema' option migration
549
+ if (this.opts.schema) {
550
+ // If fields is already provided and valid, ignore schema
551
+ if (this.opts.fields && typeof this.opts.fields === 'object' && Object.keys(this.opts.fields).length > 0) {
552
+ if (this.opts.debugMode) {
553
+ console.log(`⚠️ Both 'schema' and 'fields' options provided. Ignoring 'schema' and using 'fields'. [${this.instanceId}]`)
554
+ }
555
+ } else if (Array.isArray(this.opts.schema)) {
556
+ // Schema as array is no longer supported
557
+ throw new Error('The "schema" option as an array is no longer supported. Please use "fields" as an object instead. Example: { fields: { id: "number", name: "string" } }')
558
+ } else if (typeof this.opts.schema === 'object' && this.opts.schema !== null) {
559
+ // Schema as object - migrate to fields
560
+ this.opts.fields = { ...this.opts.schema }
561
+ if (this.opts.debugMode) {
562
+ console.log(`⚠️ Migrated 'schema' option to 'fields'. Please update your code to use 'fields' instead of 'schema'. [${this.instanceId}]`)
563
+ }
564
+ } else {
565
+ throw new Error('The "schema" option must be an object. Example: { schema: { id: "number", name: "string" } }')
566
+ }
567
+ }
568
+
569
+ // Validate that fields is provided (mandatory)
570
+ if (!this.opts.fields || typeof this.opts.fields !== 'object' || Object.keys(this.opts.fields).length === 0) {
571
+ throw new Error('The "fields" option is mandatory and must be an object with at least one field definition. Example: { fields: { id: "number", name: "string" } }')
572
+ }
573
+
548
574
  // CRITICAL FIX: Initialize serializer first - this was missing and causing crashes
549
575
  this.serializer = new Serializer(this.opts)
550
576
 
@@ -1027,12 +1053,22 @@ class Database extends EventEmitter {
1027
1053
  }
1028
1054
  }
1029
1055
 
1030
- // Reinitialize schema from saved configuration
1031
- if (config.schema && this.serializer) {
1056
+ // Reinitialize schema from saved configuration (only if fields not provided)
1057
+ // Note: fields option takes precedence over saved schema
1058
+ if (!this.opts.fields && config.schema && this.serializer) {
1032
1059
  this.serializer.initializeSchema(config.schema)
1033
1060
  if (this.opts.debugMode) {
1034
1061
  console.log(`📂 Loaded schema from ${idxPath}:`, config.schema.join(', '))
1035
1062
  }
1063
+ } else if (this.opts.fields && this.serializer) {
1064
+ // Use fields option instead of saved schema
1065
+ const fieldNames = Object.keys(this.opts.fields)
1066
+ if (fieldNames.length > 0) {
1067
+ this.serializer.initializeSchema(fieldNames)
1068
+ if (this.opts.debugMode) {
1069
+ console.log(`📂 Schema initialized from fields option:`, fieldNames.join(', '))
1070
+ }
1071
+ }
1036
1072
  }
1037
1073
  }
1038
1074
  }
@@ -1263,7 +1299,8 @@ class Database extends EventEmitter {
1263
1299
 
1264
1300
  // CRITICAL FIX: Capture writeBuffer and deletedIds at the start to prevent race conditions
1265
1301
  const writeBufferSnapshot = [...this.writeBuffer]
1266
- const deletedIdsSnapshot = new Set(this.deletedIds)
1302
+ // CRITICAL FIX: Normalize deleted IDs to strings for consistent comparison
1303
+ const deletedIdsSnapshot = new Set(Array.from(this.deletedIds).map(id => String(id)))
1267
1304
 
1268
1305
  // OPTIMIZATION: Process pending index updates in batch before save
1269
1306
  if (this.pendingIndexUpdates && this.pendingIndexUpdates.length > 0) {
@@ -1282,40 +1319,25 @@ class Database extends EventEmitter {
1282
1319
  this.pendingIndexUpdates = []
1283
1320
  }
1284
1321
 
1285
- // CRITICAL FIX: Flush write buffer completely after capturing snapshot
1286
- await this._flushWriteBufferCompletely()
1287
-
1288
- // CRITICAL FIX: Wait for all I/O operations to complete before clearing writeBuffer
1289
- await this._waitForIOCompletion()
1290
-
1291
- // CRITICAL FIX: Verify write buffer is empty after I/O completion
1292
- // But allow for ongoing insertions during high-volume scenarios
1293
- if (this.writeBuffer.length > 0) {
1294
- if (this.opts.debugMode) {
1295
- console.log(`💾 Save: WriteBuffer still has ${this.writeBuffer.length} items after flush - this may indicate ongoing insertions`)
1296
- }
1297
-
1298
- // If we have a reasonable number of items, continue processing
1299
- if (this.writeBuffer.length < 10000) { // Reasonable threshold
1300
- if (this.opts.debugMode) {
1301
- console.log(`💾 Save: Continuing to process remaining ${this.writeBuffer.length} items`)
1302
- }
1303
- // Continue with the save process - the remaining items will be included in the final save
1304
- } else {
1305
- // Too many items remaining - likely a real problem
1306
- throw new Error(`WriteBuffer has too many items after flush: ${this.writeBuffer.length} items remaining (threshold: 10000)`)
1307
- }
1322
+ // CRITICAL FIX: DO NOT flush writeBuffer before processing existing records
1323
+ // This prevents duplicating updated records in the file.
1324
+ // The _streamExistingRecords() will handle replacing old records with updated ones from writeBufferSnapshot.
1325
+ // After processing, all records (existing + updated + new) will be written to file in one operation.
1326
+ if (this.opts.debugMode) {
1327
+ console.log(`💾 Save: writeBufferSnapshot captured with ${writeBufferSnapshot.length} records (will be processed with existing records)`)
1308
1328
  }
1309
1329
 
1310
1330
  // OPTIMIZATION: Parallel operations - cleanup and data preparation
1311
1331
  let allData = []
1312
1332
  let orphanedCount = 0
1313
1333
 
1314
- // Check if there are new records to save (after flush, writeBuffer should be empty)
1334
+ // Check if there are records to save from writeBufferSnapshot
1335
+ // CRITICAL FIX: Process writeBufferSnapshot records (both new and updated) with existing records
1336
+ // Updated records will replace old ones via _streamExistingRecords, new records will be added
1315
1337
  if (this.opts.debugMode) {
1316
1338
  console.log(`💾 Save: writeBuffer.length=${this.writeBuffer.length}, writeBufferSnapshot.length=${writeBufferSnapshot.length}`)
1317
1339
  }
1318
- if (this.writeBuffer.length > 0) {
1340
+ if (this.writeBuffer.length > 0 || writeBufferSnapshot.length > 0) {
1319
1341
  if (this.opts.debugMode) {
1320
1342
  console.log(`💾 Save: WriteBuffer has ${writeBufferSnapshot.length} records, using streaming approach`)
1321
1343
  }
@@ -1349,20 +1371,57 @@ class Database extends EventEmitter {
1349
1371
  // Add streaming operation
1350
1372
  parallelOperations.push(
1351
1373
  this._streamExistingRecords(deletedIdsSnapshot, writeBufferSnapshot).then(existingRecords => {
1374
+ // CRITICAL FIX: _streamExistingRecords already handles updates via updatedRecordsMap
1375
+ // So existingRecords already contains updated records from writeBufferSnapshot
1376
+ // We only need to add records from writeBufferSnapshot that are NEW (not updates)
1352
1377
  allData = [...existingRecords]
1353
1378
 
1354
- // OPTIMIZATION: Use Map for faster lookups
1355
- const existingRecordMap = new Map(existingRecords.filter(r => r && r.id).map(r => [r.id, r]))
1379
+ // OPTIMIZATION: Use Set for faster lookups of existing record IDs
1380
+ // CRITICAL FIX: Normalize IDs to strings for consistent comparison
1381
+ const existingRecordIds = new Set(existingRecords.filter(r => r && r.id).map(r => String(r.id)))
1356
1382
 
1383
+ // CRITICAL FIX: Create a map of records in existingRecords by ID for comparison
1384
+ const existingRecordsById = new Map()
1385
+ existingRecords.forEach(r => {
1386
+ if (r && r.id) {
1387
+ existingRecordsById.set(String(r.id), r)
1388
+ }
1389
+ })
1390
+
1391
+ // Add only NEW records from writeBufferSnapshot (not updates, as those are already in existingRecords)
1392
+ // CRITICAL FIX: Also ensure that if an updated record wasn't properly replaced, we replace it now
1357
1393
  for (const record of writeBufferSnapshot) {
1358
- if (!deletedIdsSnapshot.has(record.id)) {
1359
- if (existingRecordMap.has(record.id)) {
1360
- // Replace existing record
1361
- const existingIndex = allData.findIndex(r => r.id === record.id)
1362
- allData[existingIndex] = record
1363
- } else {
1364
- // Add new record
1365
- allData.push(record)
1394
+ if (!record || !record.id) continue
1395
+ if (deletedIdsSnapshot.has(String(record.id))) continue
1396
+
1397
+ const recordIdStr = String(record.id)
1398
+ const existingRecord = existingRecordsById.get(recordIdStr)
1399
+
1400
+ if (!existingRecord) {
1401
+ // This is a new record, not an update
1402
+ allData.push(record)
1403
+ if (this.opts.debugMode) {
1404
+ console.log(`💾 Save: Adding NEW record to allData:`, { id: recordIdStr, price: record.price, app_id: record.app_id, currency: record.currency })
1405
+ }
1406
+ } else {
1407
+ // This is an update - verify that existingRecords contains the updated version
1408
+ // If not, replace it (this handles edge cases where substitution might have failed)
1409
+ const existingIndex = allData.findIndex(r => r && r.id && String(r.id) === recordIdStr)
1410
+ if (existingIndex !== -1) {
1411
+ // Verify if the existing record is actually the updated one
1412
+ // Compare key fields to detect if replacement is needed
1413
+ const needsReplacement = JSON.stringify(allData[existingIndex]) !== JSON.stringify(record)
1414
+ if (needsReplacement) {
1415
+ if (this.opts.debugMode) {
1416
+ console.log(`💾 Save: REPLACING existing record with updated version in allData:`, {
1417
+ old: { id: String(allData[existingIndex].id), price: allData[existingIndex].price },
1418
+ new: { id: recordIdStr, price: record.price }
1419
+ })
1420
+ }
1421
+ allData[existingIndex] = record
1422
+ } else if (this.opts.debugMode) {
1423
+ console.log(`💾 Save: Record already correctly updated in allData:`, { id: recordIdStr })
1424
+ }
1366
1425
  }
1367
1426
  }
1368
1427
  }
@@ -1408,15 +1467,83 @@ class Database extends EventEmitter {
1408
1467
  console.log(`💾 Save: _streamExistingRecords returned ${existingRecords.length} records`)
1409
1468
  console.log(`💾 Save: existingRecords:`, existingRecords)
1410
1469
  }
1411
- // Combine existing records with new records from writeBuffer
1412
- allData = [...existingRecords, ...writeBufferSnapshot.filter(record => !deletedIdsSnapshot.has(record.id))]
1470
+ // CRITICAL FIX: _streamExistingRecords already handles updates via updatedRecordsMap
1471
+ // So existingRecords already contains updated records from writeBufferSnapshot
1472
+ // We only need to add records from writeBufferSnapshot that are NEW (not updates)
1473
+ allData = [...existingRecords]
1474
+
1475
+ // OPTIMIZATION: Use Set for faster lookups of existing record IDs
1476
+ // CRITICAL FIX: Normalize IDs to strings for consistent comparison
1477
+ const existingRecordIds = new Set(existingRecords.filter(r => r && r.id).map(r => String(r.id)))
1478
+
1479
+ // CRITICAL FIX: Create a map of records in existingRecords by ID for comparison
1480
+ const existingRecordsById = new Map()
1481
+ existingRecords.forEach(r => {
1482
+ if (r && r.id) {
1483
+ existingRecordsById.set(String(r.id), r)
1484
+ }
1485
+ })
1486
+
1487
+ // Add only NEW records from writeBufferSnapshot (not updates, as those are already in existingRecords)
1488
+ // CRITICAL FIX: Also ensure that if an updated record wasn't properly replaced, we replace it now
1489
+ for (const record of writeBufferSnapshot) {
1490
+ if (!record || !record.id) continue
1491
+ if (deletedIdsSnapshot.has(String(record.id))) continue
1492
+
1493
+ const recordIdStr = String(record.id)
1494
+ const existingRecord = existingRecordsById.get(recordIdStr)
1495
+
1496
+ if (!existingRecord) {
1497
+ // This is a new record, not an update
1498
+ allData.push(record)
1499
+ if (this.opts.debugMode) {
1500
+ console.log(`💾 Save: Adding NEW record to allData:`, { id: recordIdStr, price: record.price, app_id: record.app_id, currency: record.currency })
1501
+ }
1502
+ } else {
1503
+ // This is an update - verify that existingRecords contains the updated version
1504
+ // If not, replace it (this handles edge cases where substitution might have failed)
1505
+ const existingIndex = allData.findIndex(r => r && r.id && String(r.id) === recordIdStr)
1506
+ if (existingIndex !== -1) {
1507
+ // Verify if the existing record is actually the updated one
1508
+ // Compare key fields to detect if replacement is needed
1509
+ const needsReplacement = JSON.stringify(allData[existingIndex]) !== JSON.stringify(record)
1510
+ if (needsReplacement) {
1511
+ if (this.opts.debugMode) {
1512
+ console.log(`💾 Save: REPLACING existing record with updated version in allData:`, {
1513
+ old: { id: String(allData[existingIndex].id), price: allData[existingIndex].price },
1514
+ new: { id: recordIdStr, price: record.price }
1515
+ })
1516
+ }
1517
+ allData[existingIndex] = record
1518
+ } else if (this.opts.debugMode) {
1519
+ console.log(`💾 Save: Record already correctly updated in allData:`, { id: recordIdStr })
1520
+ }
1521
+ }
1522
+ }
1523
+ }
1524
+
1525
+ if (this.opts.debugMode) {
1526
+ const updatedCount = writeBufferSnapshot.filter(r => r && r.id && existingRecordIds.has(String(r.id))).length
1527
+ const newCount = writeBufferSnapshot.filter(r => r && r.id && !existingRecordIds.has(String(r.id))).length
1528
+ console.log(`💾 Save: Combined data - existingRecords: ${existingRecords.length}, updatedFromBuffer: ${updatedCount}, newFromBuffer: ${newCount}, total: ${allData.length}`)
1529
+ console.log(`💾 Save: WriteBuffer record IDs:`, writeBufferSnapshot.map(r => r && r.id ? String(r.id) : 'no-id'))
1530
+ console.log(`💾 Save: Existing record IDs:`, Array.from(existingRecordIds))
1531
+ console.log(`💾 Save: All records in allData:`, allData.map(r => r && r.id ? { id: String(r.id), price: r.price, app_id: r.app_id, currency: r.currency } : 'no-id'))
1532
+ console.log(`💾 Save: Sample existing record:`, existingRecords[0] ? { id: String(existingRecords[0].id), price: existingRecords[0].price, app_id: existingRecords[0].app_id, currency: existingRecords[0].currency } : 'null')
1533
+ console.log(`💾 Save: Sample writeBuffer record:`, writeBufferSnapshot[0] ? { id: String(writeBufferSnapshot[0].id), price: writeBufferSnapshot[0].price, app_id: writeBufferSnapshot[0].app_id, currency: writeBufferSnapshot[0].currency } : 'null')
1534
+ }
1413
1535
  }).catch(error => {
1414
1536
  if (this.opts.debugMode) {
1415
1537
  console.log(`💾 Save: _streamExistingRecords failed:`, error.message)
1416
1538
  }
1417
1539
  // CRITICAL FIX: Use safe fallback to preserve existing data instead of losing it
1418
1540
  return this._loadExistingRecordsFallback(deletedIdsSnapshot, writeBufferSnapshot).then(fallbackRecords => {
1419
- allData = [...fallbackRecords, ...writeBufferSnapshot.filter(record => !deletedIdsSnapshot.has(record.id))]
1541
+ // CRITICAL FIX: Avoid duplicating updated records
1542
+ const fallbackRecordIds = new Set(fallbackRecords.map(r => r.id))
1543
+ const newRecordsFromBuffer = writeBufferSnapshot.filter(record =>
1544
+ !deletedIdsSnapshot.has(String(record.id)) && !fallbackRecordIds.has(record.id)
1545
+ )
1546
+ allData = [...fallbackRecords, ...newRecordsFromBuffer]
1420
1547
  if (this.opts.debugMode) {
1421
1548
  console.log(`💾 Save: Fallback preserved ${fallbackRecords.length} existing records, total: ${allData.length}`)
1422
1549
  }
@@ -1426,7 +1553,7 @@ class Database extends EventEmitter {
1426
1553
  console.log(`💾 Save: CRITICAL - Data loss may occur, only writeBuffer will be saved`)
1427
1554
  }
1428
1555
  // Last resort: at least save what we have in writeBuffer
1429
- allData = writeBufferSnapshot.filter(record => !deletedIdsSnapshot.has(record.id))
1556
+ allData = writeBufferSnapshot.filter(record => !deletedIdsSnapshot.has(String(record.id)))
1430
1557
  })
1431
1558
  })
1432
1559
  )
@@ -1440,7 +1567,12 @@ class Database extends EventEmitter {
1440
1567
  // CRITICAL FIX: Use safe fallback to preserve existing data instead of losing it
1441
1568
  try {
1442
1569
  const fallbackRecords = await this._loadExistingRecordsFallback(deletedIdsSnapshot, writeBufferSnapshot)
1443
- allData = [...fallbackRecords, ...writeBufferSnapshot.filter(record => !deletedIdsSnapshot.has(record.id))]
1570
+ // CRITICAL FIX: Avoid duplicating updated records
1571
+ const fallbackRecordIds = new Set(fallbackRecords.map(r => r.id))
1572
+ const newRecordsFromBuffer = writeBufferSnapshot.filter(record =>
1573
+ !deletedIdsSnapshot.has(String(record.id)) && !fallbackRecordIds.has(record.id)
1574
+ )
1575
+ allData = [...fallbackRecords, ...newRecordsFromBuffer]
1444
1576
  if (this.opts.debugMode) {
1445
1577
  console.log(`💾 Save: Fallback preserved ${fallbackRecords.length} existing records, total: ${allData.length}`)
1446
1578
  }
@@ -1450,23 +1582,48 @@ class Database extends EventEmitter {
1450
1582
  console.log(`💾 Save: CRITICAL - Data loss may occur, only writeBuffer will be saved`)
1451
1583
  }
1452
1584
  // Last resort: at least save what we have in writeBuffer
1453
- allData = writeBufferSnapshot.filter(record => !deletedIdsSnapshot.has(record.id))
1585
+ allData = writeBufferSnapshot.filter(record => !deletedIdsSnapshot.has(String(record.id)))
1454
1586
  }
1455
1587
  }
1456
1588
  } else {
1457
1589
  // No existing data, use only writeBuffer
1458
- allData = writeBufferSnapshot.filter(record => !deletedIdsSnapshot.has(record.id))
1590
+ allData = writeBufferSnapshot.filter(record => !deletedIdsSnapshot.has(String(record.id)))
1459
1591
  }
1460
1592
  }
1461
1593
 
1462
1594
  // CRITICAL FIX: Calculate offsets based on actual serialized data that will be written
1463
1595
  // This ensures consistency between offset calculation and file writing
1464
- const jsonlData = allData.length > 0
1465
- ? this.serializer.serializeBatch(allData)
1596
+ // CRITICAL FIX: Remove term IDs before serialization to ensure proper serialization
1597
+ const cleanedData = allData.map(record => {
1598
+ if (!record || typeof record !== 'object') {
1599
+ if (this.opts.debugMode) {
1600
+ console.log(`💾 Save: WARNING - Invalid record in allData:`, record)
1601
+ }
1602
+ return record
1603
+ }
1604
+ return this.removeTermIdsForSerialization(record)
1605
+ })
1606
+
1607
+ if (this.opts.debugMode) {
1608
+ console.log(`💾 Save: allData.length=${allData.length}, cleanedData.length=${cleanedData.length}`)
1609
+ console.log(`💾 Save: All records in allData before serialization:`, allData.map(r => r && r.id ? { id: String(r.id), price: r.price, app_id: r.app_id, currency: r.currency } : 'no-id'))
1610
+ console.log(`💾 Save: Sample cleaned record:`, cleanedData[0] ? Object.keys(cleanedData[0]) : 'null')
1611
+ }
1612
+
1613
+ const jsonlData = cleanedData.length > 0
1614
+ ? this.serializer.serializeBatch(cleanedData)
1466
1615
  : ''
1467
1616
  const jsonlString = jsonlData.toString('utf8')
1468
1617
  const lines = jsonlString.split('\n').filter(line => line.trim())
1469
1618
 
1619
+ if (this.opts.debugMode) {
1620
+ console.log(`💾 Save: Serialized ${lines.length} lines`)
1621
+ console.log(`💾 Save: All records in allData after serialization check:`, allData.map(r => r && r.id ? { id: String(r.id), price: r.price, app_id: r.app_id, currency: r.currency } : 'no-id'))
1622
+ if (lines.length > 0) {
1623
+ console.log(`💾 Save: First line (first 200 chars):`, lines[0].substring(0, 200))
1624
+ }
1625
+ }
1626
+
1470
1627
  this.offsets = []
1471
1628
  let currentOffset = 0
1472
1629
  for (let i = 0; i < lines.length; i++) {
@@ -1484,56 +1641,9 @@ class Database extends EventEmitter {
1484
1641
  console.log(`💾 Save: Calculated indexOffset: ${this.indexOffset}, allData.length: ${allData.length}`)
1485
1642
  }
1486
1643
 
1487
- // OPTIMIZATION: Parallel operations - file writing and index data preparation
1488
- const parallelWriteOperations = []
1489
-
1490
- // Add main file write operation
1491
- parallelWriteOperations.push(
1492
- this.fileHandler.writeBatch([jsonlData])
1493
- )
1494
-
1495
- // Add index file operations - ALWAYS save offsets, even without indexed fields
1496
- if (this.indexManager) {
1497
- const idxPath = this.normalizedFile.replace('.jdb', '.idx.jdb')
1498
-
1499
- // OPTIMIZATION: Parallel data preparation
1500
- const indexDataPromise = Promise.resolve({
1501
- index: this.indexManager.indexedFields && this.indexManager.indexedFields.length > 0 ? this.indexManager.toJSON() : {},
1502
- offsets: this.offsets, // Save actual offsets for efficient file operations
1503
- indexOffset: this.indexOffset // Save file size for proper range calculations
1504
- })
1505
-
1506
- // Add term mapping data if needed
1507
- const termMappingFields = this.getTermMappingFields()
1508
- if (termMappingFields.length > 0 && this.termManager) {
1509
- const termDataPromise = this.termManager.saveTerms()
1510
-
1511
- // Combine index data and term data
1512
- const combinedDataPromise = Promise.all([indexDataPromise, termDataPromise]).then(([indexData, termData]) => {
1513
- indexData.termMapping = termData
1514
- return indexData
1515
- })
1516
-
1517
- // Add index file write operation
1518
- parallelWriteOperations.push(
1519
- combinedDataPromise.then(indexData => {
1520
- const idxFileHandler = new FileHandler(idxPath, this.fileMutex, this.opts)
1521
- return idxFileHandler.writeAll(JSON.stringify(indexData, null, 2))
1522
- })
1523
- )
1524
- } else {
1525
- // Add index file write operation without term mapping
1526
- parallelWriteOperations.push(
1527
- indexDataPromise.then(indexData => {
1528
- const idxFileHandler = new FileHandler(idxPath, this.fileMutex, this.opts)
1529
- return idxFileHandler.writeAll(JSON.stringify(indexData, null, 2))
1530
- })
1531
- )
1532
- }
1533
- }
1534
-
1535
- // Execute parallel write operations
1536
- await Promise.all(parallelWriteOperations)
1644
+ // CRITICAL FIX: Write main data file first
1645
+ // Index will be saved AFTER reconstruction to ensure it contains correct data
1646
+ await this.fileHandler.writeBatch([jsonlData])
1537
1647
 
1538
1648
  if (this.opts.debugMode) {
1539
1649
  console.log(`💾 Saved ${allData.length} records to ${this.normalizedFile}`)
@@ -1547,21 +1657,36 @@ class Database extends EventEmitter {
1547
1657
 
1548
1658
  // Clear writeBuffer and deletedIds after successful save only if we had data to save
1549
1659
  if (allData.length > 0) {
1550
- // Rebuild index when records were deleted to maintain consistency
1660
+ // Rebuild index when records were deleted or updated to maintain consistency
1551
1661
  const hadDeletedRecords = deletedIdsSnapshot.size > 0
1662
+ const hadUpdatedRecords = writeBufferSnapshot.length > 0
1552
1663
  if (this.indexManager && this.indexManager.indexedFields && this.indexManager.indexedFields.length > 0) {
1553
- if (hadDeletedRecords) {
1554
- // Clear the index and rebuild it from the remaining records
1664
+ if (hadDeletedRecords || hadUpdatedRecords) {
1665
+ // Clear the index and rebuild it from the saved records
1666
+ // This ensures that lineNumbers point to the correct positions in the file
1555
1667
  this.indexManager.clear()
1556
1668
  if (this.opts.debugMode) {
1557
- console.log(`🧹 Rebuilding index after removing ${deletedIdsSnapshot.size} deleted records`)
1669
+ if (hadDeletedRecords && hadUpdatedRecords) {
1670
+ console.log(`🧹 Rebuilding index after removing ${deletedIdsSnapshot.size} deleted records and updating ${writeBufferSnapshot.length} records`)
1671
+ } else if (hadDeletedRecords) {
1672
+ console.log(`🧹 Rebuilding index after removing ${deletedIdsSnapshot.size} deleted records`)
1673
+ } else {
1674
+ console.log(`🧹 Rebuilding index after updating ${writeBufferSnapshot.length} records`)
1675
+ }
1558
1676
  }
1559
1677
 
1560
1678
  // Rebuild index from the saved records
1561
1679
  // CRITICAL: Process term mapping for records loaded from file to ensure ${field}Ids are available
1680
+ if (this.opts.debugMode) {
1681
+ console.log(`💾 Save: Rebuilding index from ${allData.length} records in allData`)
1682
+ }
1562
1683
  for (let i = 0; i < allData.length; i++) {
1563
1684
  let record = allData[i]
1564
1685
 
1686
+ if (this.opts.debugMode && i < 3) {
1687
+ console.log(`💾 Save: Rebuilding index record[${i}]:`, { id: String(record.id), price: record.price, app_id: record.app_id, currency: record.currency })
1688
+ }
1689
+
1565
1690
  // CRITICAL FIX: Ensure records have ${field}Ids for term mapping fields
1566
1691
  // Records from writeBuffer already have ${field}Ids from processTermMapping
1567
1692
  // Records from file need to be processed to restore ${field}Ids
@@ -1588,6 +1713,9 @@ class Database extends EventEmitter {
1588
1713
 
1589
1714
  await this.indexManager.add(record, i)
1590
1715
  }
1716
+ if (this.opts.debugMode) {
1717
+ console.log(`💾 Save: Index rebuilt with ${allData.length} records`)
1718
+ }
1591
1719
  }
1592
1720
  }
1593
1721
 
@@ -1675,12 +1803,21 @@ class Database extends EventEmitter {
1675
1803
  this.termManager.decrementTermCount(termId)
1676
1804
  }
1677
1805
  } else if (oldRecord[field] && Array.isArray(oldRecord[field])) {
1678
- // Use terms to decrement (fallback for backward compatibility)
1679
- for (const term of oldRecord[field]) {
1680
- const termId = this.termManager.termToId.get(term)
1681
- if (termId) {
1806
+ // Check if field contains term IDs (numbers) or terms (strings)
1807
+ const firstValue = oldRecord[field][0]
1808
+ if (typeof firstValue === 'number') {
1809
+ // Field contains term IDs (from find with restoreTerms: false)
1810
+ for (const termId of oldRecord[field]) {
1682
1811
  this.termManager.decrementTermCount(termId)
1683
1812
  }
1813
+ } else if (typeof firstValue === 'string') {
1814
+ // Field contains terms (strings) - convert to term IDs
1815
+ for (const term of oldRecord[field]) {
1816
+ const termId = this.termManager.termToId.get(term)
1817
+ if (termId) {
1818
+ this.termManager.decrementTermCount(termId)
1819
+ }
1820
+ }
1684
1821
  }
1685
1822
  }
1686
1823
  }
@@ -1933,6 +2070,7 @@ class Database extends EventEmitter {
1933
2070
  }
1934
2071
 
1935
2072
  // Apply schema enforcement - convert to array format and back to enforce schema
2073
+ // This will discard any fields not in the schema
1936
2074
  const schemaEnforcedRecord = this.applySchemaEnforcement(record)
1937
2075
 
1938
2076
  // Don't store in this.data - only use writeBuffer and index
@@ -2462,11 +2600,20 @@ class Database extends EventEmitter {
2462
2600
 
2463
2601
  const updated = { ...record, ...updateData }
2464
2602
 
2603
+ // DEBUG: Log the update operation details
2604
+ if (this.opts.debugMode) {
2605
+ console.log(`🔄 UPDATE: Original record ID: ${record.id}, type: ${typeof record.id}`)
2606
+ console.log(`🔄 UPDATE: Updated record ID: ${updated.id}, type: ${typeof updated.id}`)
2607
+ console.log(`🔄 UPDATE: Update data keys:`, Object.keys(updateData))
2608
+ console.log(`🔄 UPDATE: Updated record keys:`, Object.keys(updated))
2609
+ }
2610
+
2465
2611
  // Process term mapping for update
2466
2612
  const termMappingStart = Date.now()
2467
2613
  this.processTermMapping(updated, true, record)
2468
2614
  if (this.opts.debugMode) {
2469
2615
  console.log(`🔄 UPDATE: Term mapping completed in ${Date.now() - termMappingStart}ms`)
2616
+ console.log(`🔄 UPDATE: After term mapping - ID: ${updated.id}, type: ${typeof updated.id}`)
2470
2617
  }
2471
2618
 
2472
2619
  // CRITICAL FIX: Remove old terms from index before adding new ones
@@ -2477,9 +2624,11 @@ class Database extends EventEmitter {
2477
2624
  }
2478
2625
  }
2479
2626
 
2480
- // Update record in writeBuffer or add to writeBuffer if not present
2627
+ // CRITICAL FIX: Update record in writeBuffer or add to writeBuffer if not present
2628
+ // For records in the file, we need to ensure they are properly marked for replacement
2481
2629
  const index = this.writeBuffer.findIndex(r => r.id === record.id)
2482
2630
  let lineNumber = null
2631
+
2483
2632
  if (index !== -1) {
2484
2633
  // Record is already in writeBuffer, update it
2485
2634
  this.writeBuffer[index] = updated
@@ -2489,11 +2638,12 @@ class Database extends EventEmitter {
2489
2638
  }
2490
2639
  } else {
2491
2640
  // Record is in file, add updated version to writeBuffer
2492
- // This will ensure the updated record is saved and replaces the file version
2641
+ // CRITICAL FIX: Ensure the old record in file will be replaced by checking if it exists in offsets
2642
+ // The save() method will handle replacement via _streamExistingRecords which checks updatedRecordsMap
2493
2643
  this.writeBuffer.push(updated)
2494
2644
  lineNumber = this._getAbsoluteLineNumber(this.writeBuffer.length - 1)
2495
2645
  if (this.opts.debugMode) {
2496
- console.log(`🔄 UPDATE: Added new record to writeBuffer at index ${lineNumber}`)
2646
+ console.log(`🔄 UPDATE: Added updated record to writeBuffer (will replace file record ${record.id})`)
2497
2647
  }
2498
2648
  }
2499
2649
 
@@ -2628,16 +2778,7 @@ class Database extends EventEmitter {
2628
2778
  return
2629
2779
  }
2630
2780
 
2631
- // Try to get schema from options first
2632
- if (this.opts.schema && Array.isArray(this.opts.schema)) {
2633
- this.serializer.initializeSchema(this.opts.schema)
2634
- if (this.opts.debugMode) {
2635
- console.log(`🔍 Schema initialized from options: ${this.opts.schema.join(', ')} [${this.instanceId}]`)
2636
- }
2637
- return
2638
- }
2639
-
2640
- // Try to initialize from fields configuration (new format)
2781
+ // Initialize from fields configuration (mandatory)
2641
2782
  if (this.opts.fields && typeof this.opts.fields === 'object') {
2642
2783
  const fieldNames = Object.keys(this.opts.fields)
2643
2784
  if (fieldNames.length > 0) {
@@ -2649,7 +2790,7 @@ class Database extends EventEmitter {
2649
2790
  }
2650
2791
  }
2651
2792
 
2652
- // Try to auto-detect schema from existing data
2793
+ // Try to auto-detect schema from existing data (fallback for migration scenarios)
2653
2794
  if (this.data && this.data.length > 0) {
2654
2795
  this.serializer.initializeSchema(this.data, true) // autoDetect = true
2655
2796
  if (this.opts.debugMode) {
@@ -2658,10 +2799,6 @@ class Database extends EventEmitter {
2658
2799
  return
2659
2800
  }
2660
2801
 
2661
- // CRITICAL FIX: Don't initialize schema from indexes
2662
- // This was causing data loss because only indexed fields were preserved
2663
- // Let schema be auto-detected from actual data instead
2664
-
2665
2802
  if (this.opts.debugMode) {
2666
2803
  console.log(`🔍 No schema initialization possible - will auto-detect on first insert [${this.instanceId}]`)
2667
2804
  }
@@ -3499,24 +3636,83 @@ class Database extends EventEmitter {
3499
3636
  const lineNumbers = limitedEntries.map(([lineNumber]) => lineNumber)
3500
3637
  const scoresByLineNumber = new Map(limitedEntries)
3501
3638
 
3502
- // Use getRanges and fileHandler to read records
3503
- const ranges = this.getRanges(lineNumbers)
3504
- const groupedRanges = await this.fileHandler.groupedRanges(ranges)
3639
+ const persistedCount = Array.isArray(this.offsets) ? this.offsets.length : 0
3505
3640
 
3506
- const fs = await import('fs')
3507
- const fd = await fs.promises.open(this.fileHandler.file, 'r')
3641
+ // Separate lineNumbers into file records and writeBuffer records
3642
+ const fileLineNumbers = []
3643
+ const writeBufferLineNumbers = []
3644
+
3645
+ for (const lineNumber of lineNumbers) {
3646
+ if (lineNumber >= persistedCount) {
3647
+ // This lineNumber points to writeBuffer
3648
+ writeBufferLineNumbers.push(lineNumber)
3649
+ } else {
3650
+ // This lineNumber points to file
3651
+ fileLineNumbers.push(lineNumber)
3652
+ }
3653
+ }
3508
3654
 
3509
3655
  const results = []
3510
3656
 
3511
- try {
3512
- for (const groupedRange of groupedRanges) {
3513
- for await (const row of this.fileHandler.readGroupedRange(groupedRange, fd)) {
3514
- try {
3515
- const record = this.serializer.deserialize(row.line)
3516
-
3517
- // Get line number from the row
3518
- const lineNumber = row._ || 0
3519
-
3657
+ // Read records from file
3658
+ if (fileLineNumbers.length > 0) {
3659
+ const ranges = this.getRanges(fileLineNumbers)
3660
+ if (ranges.length > 0) {
3661
+ // Create a map from start offset to lineNumber for accurate mapping
3662
+ const startToLineNumber = new Map()
3663
+ for (const range of ranges) {
3664
+ if (range.index !== undefined) {
3665
+ startToLineNumber.set(range.start, range.index)
3666
+ }
3667
+ }
3668
+
3669
+ const groupedRanges = await this.fileHandler.groupedRanges(ranges)
3670
+
3671
+ const fs = await import('fs')
3672
+ const fd = await fs.promises.open(this.fileHandler.file, 'r')
3673
+
3674
+ try {
3675
+ for (const groupedRange of groupedRanges) {
3676
+ for await (const row of this.fileHandler.readGroupedRange(groupedRange, fd)) {
3677
+ try {
3678
+ const record = this.serializer.deserialize(row.line)
3679
+
3680
+ // Get line number from the row, fallback to start offset mapping
3681
+ let lineNumber = row._ !== null && row._ !== undefined ? row._ : (startToLineNumber.get(row.start) ?? 0)
3682
+
3683
+ // Restore term IDs to terms
3684
+ const recordWithTerms = this.restoreTermIdsAfterDeserialization(record)
3685
+
3686
+ // Add line number
3687
+ recordWithTerms._ = lineNumber
3688
+
3689
+ // Add score if includeScore is true (default is true)
3690
+ if (opts.includeScore !== false) {
3691
+ recordWithTerms.score = scoresByLineNumber.get(lineNumber) || 0
3692
+ }
3693
+
3694
+ results.push(recordWithTerms)
3695
+ } catch (error) {
3696
+ // Skip invalid lines
3697
+ if (this.opts.debugMode) {
3698
+ console.error('Error deserializing record in score():', error)
3699
+ }
3700
+ }
3701
+ }
3702
+ }
3703
+ } finally {
3704
+ await fd.close()
3705
+ }
3706
+ }
3707
+ }
3708
+
3709
+ // Read records from writeBuffer
3710
+ if (writeBufferLineNumbers.length > 0 && this.writeBuffer) {
3711
+ for (const lineNumber of writeBufferLineNumbers) {
3712
+ const writeBufferIndex = lineNumber - persistedCount
3713
+ if (writeBufferIndex >= 0 && writeBufferIndex < this.writeBuffer.length) {
3714
+ const record = this.writeBuffer[writeBufferIndex]
3715
+ if (record) {
3520
3716
  // Restore term IDs to terms
3521
3717
  const recordWithTerms = this.restoreTermIdsAfterDeserialization(record)
3522
3718
 
@@ -3529,16 +3725,9 @@ class Database extends EventEmitter {
3529
3725
  }
3530
3726
 
3531
3727
  results.push(recordWithTerms)
3532
- } catch (error) {
3533
- // Skip invalid lines
3534
- if (this.opts.debugMode) {
3535
- console.error('Error deserializing record in score():', error)
3536
- }
3537
3728
  }
3538
3729
  }
3539
3730
  }
3540
- } finally {
3541
- await fd.close()
3542
3731
  }
3543
3732
 
3544
3733
  // Re-sort results to maintain score order (since reads might be out of order)
@@ -3880,9 +4069,11 @@ class Database extends EventEmitter {
3880
4069
  for (let i = 0; i < lines.length && i < this.offsets.length; i++) {
3881
4070
  try {
3882
4071
  const record = this.serializer.deserialize(lines[i])
3883
- if (record && !deletedIdsSnapshot.has(record.id)) {
4072
+ if (record && !deletedIdsSnapshot.has(String(record.id))) {
3884
4073
  // Check if this record is not being updated in writeBuffer
3885
- const updatedRecord = writeBufferSnapshot.find(r => r.id === record.id)
4074
+ // CRITICAL FIX: Normalize IDs to strings for consistent comparison
4075
+ const normalizedRecordId = String(record.id)
4076
+ const updatedRecord = writeBufferSnapshot.find(r => r && r.id && String(r.id) === normalizedRecordId)
3886
4077
  if (!updatedRecord) {
3887
4078
  existingRecords.push(record)
3888
4079
  }
@@ -3926,11 +4117,25 @@ class Database extends EventEmitter {
3926
4117
  // existingRecords.length = this.offsets.length
3927
4118
 
3928
4119
  // Create a map of updated records for quick lookup
4120
+ // CRITICAL FIX: Normalize IDs to strings for consistent comparison
3929
4121
  const updatedRecordsMap = new Map()
3930
- writeBufferSnapshot.forEach(record => {
3931
- updatedRecordsMap.set(record.id, record)
4122
+ writeBufferSnapshot.forEach((record, index) => {
4123
+ if (record && record.id !== undefined && record.id !== null) {
4124
+ // Normalize ID to string for consistent comparison
4125
+ const normalizedId = String(record.id)
4126
+ updatedRecordsMap.set(normalizedId, record)
4127
+ if (this.opts.debugMode) {
4128
+ console.log(`💾 Save: Added to updatedRecordsMap: ID=${normalizedId} (original: ${record.id}, type: ${typeof record.id}), index=${index}`)
4129
+ }
4130
+ } else if (this.opts.debugMode) {
4131
+ console.log(`⚠️ Save: Skipped record in writeBufferSnapshot[${index}] - missing or invalid ID:`, record ? { id: record.id, keys: Object.keys(record) } : 'null')
4132
+ }
3932
4133
  })
3933
4134
 
4135
+ if (this.opts.debugMode) {
4136
+ console.log(`💾 Save: updatedRecordsMap size: ${updatedRecordsMap.size}, keys:`, Array.from(updatedRecordsMap.keys()))
4137
+ }
4138
+
3934
4139
  // OPTIMIZATION: Cache file stats to avoid repeated stat() calls
3935
4140
  let fileSize = 0
3936
4141
  if (this._cachedFileStats && this._cachedFileStats.timestamp > Date.now() - 1000) {
@@ -4096,7 +4301,8 @@ class Database extends EventEmitter {
4096
4301
  if (recordId !== undefined && recordId !== null) {
4097
4302
  recordId = String(recordId)
4098
4303
  // Check if this record needs full parsing (updated or deleted)
4099
- needsFullParse = updatedRecordsMap.has(recordId) || deletedIdsSnapshot.has(recordId)
4304
+ // CRITICAL FIX: Normalize ID to string for consistent comparison
4305
+ needsFullParse = updatedRecordsMap.has(recordId) || deletedIdsSnapshot.has(String(recordId))
4100
4306
  } else {
4101
4307
  needsFullParse = true
4102
4308
  }
@@ -4111,7 +4317,8 @@ class Database extends EventEmitter {
4111
4317
  const idMatch = trimmedLine.match(/"id"\s*:\s*"([^"]+)"|"id"\s*:\s*(\d+)/)
4112
4318
  if (idMatch) {
4113
4319
  recordId = idMatch[1] || idMatch[2]
4114
- needsFullParse = updatedRecordsMap.has(recordId) || deletedIdsSnapshot.has(recordId)
4320
+ // CRITICAL FIX: Normalize ID to string for consistent comparison
4321
+ needsFullParse = updatedRecordsMap.has(String(recordId)) || deletedIdsSnapshot.has(String(recordId))
4115
4322
  } else {
4116
4323
  needsFullParse = true
4117
4324
  }
@@ -4136,11 +4343,22 @@ class Database extends EventEmitter {
4136
4343
  // Use record directly (no need to restore term IDs)
4137
4344
  const recordWithIds = record
4138
4345
 
4139
- if (updatedRecordsMap.has(recordWithIds.id)) {
4346
+ // CRITICAL FIX: Normalize ID to string for consistent comparison
4347
+ const normalizedId = String(recordWithIds.id)
4348
+ if (this.opts.debugMode) {
4349
+ console.log(`💾 Save: Checking record ID=${normalizedId} (original: ${recordWithIds.id}, type: ${typeof recordWithIds.id}) in updatedRecordsMap`)
4350
+ console.log(`💾 Save: updatedRecordsMap.has(${normalizedId}): ${updatedRecordsMap.has(normalizedId)}`)
4351
+ if (!updatedRecordsMap.has(normalizedId)) {
4352
+ console.log(`💾 Save: Record ${normalizedId} NOT found in updatedRecordsMap. Available keys:`, Array.from(updatedRecordsMap.keys()))
4353
+ }
4354
+ }
4355
+ if (updatedRecordsMap.has(normalizedId)) {
4140
4356
  // Replace with updated version
4141
- const updatedRecord = updatedRecordsMap.get(recordWithIds.id)
4357
+ const updatedRecord = updatedRecordsMap.get(normalizedId)
4142
4358
  if (this.opts.debugMode) {
4143
- console.log(`💾 Save: Updated record ${recordWithIds.id} (${recordWithIds.name || 'Unnamed'})`)
4359
+ console.log(`💾 Save: REPLACING record ${recordWithIds.id} with updated version`)
4360
+ console.log(`💾 Save: Old record:`, { id: recordWithIds.id, price: recordWithIds.price, app_id: recordWithIds.app_id, currency: recordWithIds.currency })
4361
+ console.log(`💾 Save: New record:`, { id: updatedRecord.id, price: updatedRecord.price, app_id: updatedRecord.app_id, currency: updatedRecord.currency })
4144
4362
  }
4145
4363
  return {
4146
4364
  type: 'updated',
@@ -4148,7 +4366,7 @@ class Database extends EventEmitter {
4148
4366
  id: recordWithIds.id,
4149
4367
  needsParse: false
4150
4368
  }
4151
- } else if (!deletedIdsSnapshot.has(recordWithIds.id)) {
4369
+ } else if (!deletedIdsSnapshot.has(String(recordWithIds.id))) {
4152
4370
  // Keep existing record if not deleted
4153
4371
  if (this.opts.debugMode) {
4154
4372
  console.log(`💾 Save: Kept record ${recordWithIds.id} (${recordWithIds.name || 'Unnamed'})`)