@photostructure/sqlite 0.2.1 → 0.4.0

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 (43) hide show
  1. package/CHANGELOG.md +61 -15
  2. package/README.md +5 -4
  3. package/binding.gyp +2 -2
  4. package/dist/index.cjs +159 -12
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +286 -91
  7. package/dist/index.d.mts +286 -91
  8. package/dist/index.d.ts +286 -91
  9. package/dist/index.mjs +156 -11
  10. package/dist/index.mjs.map +1 -1
  11. package/package.json +74 -65
  12. package/prebuilds/darwin-arm64/@photostructure+sqlite.glibc.node +0 -0
  13. package/prebuilds/darwin-x64/@photostructure+sqlite.glibc.node +0 -0
  14. package/prebuilds/linux-arm64/@photostructure+sqlite.glibc.node +0 -0
  15. package/prebuilds/linux-arm64/@photostructure+sqlite.musl.node +0 -0
  16. package/prebuilds/linux-x64/@photostructure+sqlite.glibc.node +0 -0
  17. package/prebuilds/linux-x64/@photostructure+sqlite.musl.node +0 -0
  18. package/prebuilds/test_extension.so +0 -0
  19. package/prebuilds/win32-arm64/@photostructure+sqlite.glibc.node +0 -0
  20. package/prebuilds/win32-x64/@photostructure+sqlite.glibc.node +0 -0
  21. package/scripts/prebuild-linux-glibc.sh +6 -4
  22. package/src/aggregate_function.cpp +222 -114
  23. package/src/aggregate_function.h +5 -6
  24. package/src/binding.cpp +30 -21
  25. package/src/enhance.ts +228 -0
  26. package/src/index.ts +83 -9
  27. package/src/shims/node_errors.h +34 -15
  28. package/src/shims/sqlite_errors.h +34 -8
  29. package/src/sql-tag-store.ts +7 -10
  30. package/src/sqlite_impl.cpp +1044 -394
  31. package/src/sqlite_impl.h +46 -7
  32. package/src/transaction.ts +178 -0
  33. package/src/types/database-sync-instance.ts +6 -40
  34. package/src/types/pragma-options.ts +23 -0
  35. package/src/types/sql-tag-store-instance.ts +1 -1
  36. package/src/types/statement-sync-instance.ts +38 -12
  37. package/src/types/transaction.ts +72 -0
  38. package/src/upstream/node_sqlite.cc +143 -43
  39. package/src/upstream/node_sqlite.h +15 -11
  40. package/src/upstream/sqlite3.c +102 -58
  41. package/src/upstream/sqlite3.h +5 -5
  42. package/src/user_function.cpp +138 -141
  43. package/src/user_function.h +3 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  #include <algorithm>
4
4
  #include <cctype>
5
+ #include <cinttypes>
5
6
  #include <climits>
6
7
  #include <cmath>
7
8
  #include <limits>
@@ -34,7 +35,7 @@ inline void ThrowErrSqliteErrorWithDb(Napi::Env env,
34
35
 
35
36
  inline void ThrowEnhancedSqliteErrorWithDB(
36
37
  Napi::Env env, photostructure::sqlite::DatabaseSync *db_sync, sqlite3 *db,
37
- int sqlite_code, const std::string &message) {
38
+ int /*sqlite_code*/, const std::string &message) {
38
39
  // Check if we should ignore this SQLite error due to pending JavaScript
39
40
  // exception (e.g., from authorizer callback)
40
41
  if (db_sync != nullptr && db_sync->ShouldIgnoreSQLiteError()) {
@@ -49,8 +50,11 @@ inline void ThrowEnhancedSqliteErrorWithDB(
49
50
  return; // Don't throw SQLite error, JavaScript exception takes precedence
50
51
  }
51
52
 
52
- // Call the original function
53
- node::ThrowEnhancedSqliteError(env, db, sqlite_code, message);
53
+ // Use extended error code from db handle (e.g., 1555 for
54
+ // SQLITE_CONSTRAINT_PRIMARYKEY) instead of basic code (e.g., 19 for
55
+ // SQLITE_CONSTRAINT) to match Node.js behavior
56
+ int extended_code = db ? sqlite3_extended_errcode(db) : SQLITE_ERROR;
57
+ node::ThrowEnhancedSqliteError(env, db, extended_code, message);
54
58
  }
55
59
  } // namespace
56
60
  #include "sqlite_exception.h"
@@ -94,6 +98,14 @@ std::optional<std::string> ValidateDatabasePath(Napi::Env env, Napi::Value path,
94
98
  if (!has_null_bytes(location)) {
95
99
  // Check if it's a file:// URL
96
100
  if (location.compare(0, 7, "file://") == 0) {
101
+ // Check if URL has query parameters - if so, return full URI
102
+ // for SQLite URI mode (e.g., file:///path/to/db?mode=ro)
103
+ size_t query_pos = location.find('?');
104
+ if (query_pos != std::string::npos) {
105
+ // Return full URI for SQLite to parse with SQLITE_OPEN_URI
106
+ return location;
107
+ }
108
+
97
109
  // Convert file:// URL to file path with proper validation
98
110
  std::string file_path = location.substr(7);
99
111
 
@@ -265,10 +277,11 @@ std::optional<std::string> ValidateDatabasePath(Napi::Env env, Napi::Value path,
265
277
  }
266
278
  }
267
279
 
268
- node::THROW_ERR_INVALID_ARG_TYPE(env, ("The \"" + field_name +
269
- "\" argument must be a string, "
270
- "Buffer, or URL without null bytes.")
271
- .c_str());
280
+ node::THROW_ERR_INVALID_ARG_TYPE(env,
281
+ ("The \"" + field_name +
282
+ "\" argument must be a string, "
283
+ "Uint8Array, or URL without null bytes.")
284
+ .c_str());
272
285
  return std::nullopt;
273
286
  }
274
287
 
@@ -278,6 +291,21 @@ std::optional<std::string> ValidateDatabasePath(Napi::Env env, Napi::Value path,
278
291
  // Forward declarations for addon data access
279
292
  extern AddonData *GetAddonData(napi_env env);
280
293
 
294
+ // Helper to create an object with null prototype (matches Node.js behavior)
295
+ // Node.js uses Object::New(isolate, Null(isolate), ...) but N-API doesn't have
296
+ // this capability, so we use cached Object.create(null) instead.
297
+ Napi::Object CreateObjectWithNullPrototype(Napi::Env env) {
298
+ AddonData *addon_data = GetAddonData(env);
299
+ if (addon_data && !addon_data->objectCreateFn.IsEmpty()) {
300
+ // Call Object.create(null) to create object with null prototype
301
+ return addon_data->objectCreateFn.Value()
302
+ .Call({env.Null()})
303
+ .As<Napi::Object>();
304
+ }
305
+ // Fallback to regular object if Object.create not available
306
+ return Napi::Object::New(env);
307
+ }
308
+
281
309
  // DatabaseSync Implementation
282
310
  Napi::Object DatabaseSync::Init(Napi::Env env, Napi::Object exports) {
283
311
  Napi::Function func = DefineClass(
@@ -295,8 +323,8 @@ Napi::Object DatabaseSync::Init(Napi::Env env, Napi::Object exports) {
295
323
  InstanceMethod("enableDefensive", &DatabaseSync::EnableDefensive),
296
324
  InstanceMethod("createSession", &DatabaseSync::CreateSession),
297
325
  InstanceMethod("applyChangeset", &DatabaseSync::ApplyChangeset),
298
- InstanceMethod("backup", &DatabaseSync::Backup),
299
326
  InstanceMethod("setAuthorizer", &DatabaseSync::SetAuthorizer),
327
+ InstanceMethod("backup", &DatabaseSync::Backup),
300
328
  InstanceMethod("location", &DatabaseSync::LocationMethod),
301
329
  InstanceAccessor("isOpen", &DatabaseSync::IsOpenGetter, nullptr),
302
330
  InstanceAccessor("isTransaction", &DatabaseSync::IsTransactionGetter,
@@ -346,8 +374,15 @@ DatabaseSync::DatabaseSync(const Napi::CallbackInfo &info)
346
374
  // Register this instance for cleanup tracking
347
375
  RegisterDatabaseInstance(info.Env(), this);
348
376
 
349
- // If no arguments, create but don't open (for manual open() call)
350
- if (info.Length() == 0) {
377
+ // Register cleanup hook to clean up references before environment teardown
378
+ napi_add_env_cleanup_hook(env_, CleanupHook, this);
379
+
380
+ // Node.js requires a path argument - throw if missing
381
+ if (info.Length() == 0 || info[0].IsUndefined()) {
382
+ node::THROW_ERR_INVALID_ARG_TYPE(
383
+ info.Env(),
384
+ "The \"path\" argument must be a string, Uint8Array, or URL without "
385
+ "null bytes.");
351
386
  return;
352
387
  }
353
388
 
@@ -359,100 +394,172 @@ DatabaseSync::DatabaseSync(const Napi::CallbackInfo &info)
359
394
  }
360
395
 
361
396
  try {
362
- DatabaseOpenConfiguration config(std::move(location.value()));
397
+ std::string loc = std::move(*location);
398
+ // Check if this is a file:// URI (for SQLite URI mode)
399
+ bool is_uri = (loc.compare(0, 7, "file://") == 0);
400
+ DatabaseOpenConfiguration config(std::move(loc));
401
+ if (is_uri) {
402
+ config.set_open_uri(true);
403
+ }
363
404
 
364
405
  // Track whether to open immediately (default: true)
365
406
  bool should_open = true;
366
407
 
367
408
  // Handle options object if provided as second argument
368
- if (info.Length() > 1 && info[1].IsObject()) {
409
+ if (info.Length() > 1) {
410
+ if (!info[1].IsObject()) {
411
+ node::THROW_ERR_INVALID_ARG_TYPE(
412
+ info.Env(), "The \"options\" argument must be an object.");
413
+ return;
414
+ }
415
+
369
416
  Napi::Object options = info[1].As<Napi::Object>();
370
417
 
371
- if (options.Has("readOnly") && options.Get("readOnly").IsBoolean()) {
372
- config.set_read_only(
373
- options.Get("readOnly").As<Napi::Boolean>().Value());
418
+ // Validate and parse 'open' option
419
+ Napi::Value open_val = options.Get("open");
420
+ if (!open_val.IsUndefined()) {
421
+ if (!open_val.IsBoolean()) {
422
+ node::THROW_ERR_INVALID_ARG_TYPE(
423
+ info.Env(), "The \"options.open\" argument must be a boolean.");
424
+ return;
425
+ }
426
+ should_open = open_val.As<Napi::Boolean>().Value();
374
427
  }
375
428
 
376
- // Support both old and new naming for backwards compatibility
377
- if (options.Has("enableForeignKeyConstraints") &&
378
- options.Get("enableForeignKeyConstraints").IsBoolean()) {
379
- config.set_enable_foreign_keys(
380
- options.Get("enableForeignKeyConstraints")
381
- .As<Napi::Boolean>()
382
- .Value());
383
- } else if (options.Has("enableForeignKeys") &&
384
- options.Get("enableForeignKeys").IsBoolean()) {
385
- config.set_enable_foreign_keys(
386
- options.Get("enableForeignKeys").As<Napi::Boolean>().Value());
429
+ // Validate and parse 'readOnly' option
430
+ Napi::Value read_only_val = options.Get("readOnly");
431
+ if (!read_only_val.IsUndefined()) {
432
+ if (!read_only_val.IsBoolean()) {
433
+ node::THROW_ERR_INVALID_ARG_TYPE(
434
+ info.Env(),
435
+ "The \"options.readOnly\" argument must be a boolean.");
436
+ return;
437
+ }
438
+ config.set_read_only(read_only_val.As<Napi::Boolean>().Value());
387
439
  }
388
440
 
389
- if (options.Has("timeout") && options.Get("timeout").IsNumber()) {
390
- config.set_timeout(
391
- options.Get("timeout").As<Napi::Number>().Int32Value());
441
+ // Validate and parse 'enableForeignKeyConstraints' option
442
+ Napi::Value enable_fk_val = options.Get("enableForeignKeyConstraints");
443
+ if (!enable_fk_val.IsUndefined()) {
444
+ if (!enable_fk_val.IsBoolean()) {
445
+ node::THROW_ERR_INVALID_ARG_TYPE(
446
+ info.Env(),
447
+ "The \"options.enableForeignKeyConstraints\" argument must be a "
448
+ "boolean.");
449
+ return;
450
+ }
451
+ config.set_enable_foreign_keys(
452
+ enable_fk_val.As<Napi::Boolean>().Value());
392
453
  }
393
454
 
394
- if (options.Has("enableDoubleQuotedStringLiterals") &&
395
- options.Get("enableDoubleQuotedStringLiterals").IsBoolean()) {
396
- config.set_enable_dqs(options.Get("enableDoubleQuotedStringLiterals")
397
- .As<Napi::Boolean>()
398
- .Value());
455
+ // Validate and parse 'enableDoubleQuotedStringLiterals' option
456
+ Napi::Value enable_dqs_val =
457
+ options.Get("enableDoubleQuotedStringLiterals");
458
+ if (!enable_dqs_val.IsUndefined()) {
459
+ if (!enable_dqs_val.IsBoolean()) {
460
+ node::THROW_ERR_INVALID_ARG_TYPE(
461
+ info.Env(),
462
+ "The \"options.enableDoubleQuotedStringLiterals\" argument must "
463
+ "be a boolean.");
464
+ return;
465
+ }
466
+ config.set_enable_dqs(enable_dqs_val.As<Napi::Boolean>().Value());
399
467
  }
400
468
 
401
- if (options.Has("allowExtension") &&
402
- options.Get("allowExtension").IsBoolean()) {
403
- allow_load_extension_ =
404
- options.Get("allowExtension").As<Napi::Boolean>().Value();
469
+ // Validate and parse 'allowExtension' option
470
+ Napi::Value allow_ext_val = options.Get("allowExtension");
471
+ if (!allow_ext_val.IsUndefined()) {
472
+ if (!allow_ext_val.IsBoolean()) {
473
+ node::THROW_ERR_INVALID_ARG_TYPE(
474
+ info.Env(),
475
+ "The \"options.allowExtension\" argument must be a boolean.");
476
+ return;
477
+ }
478
+ allow_load_extension_ = allow_ext_val.As<Napi::Boolean>().Value();
405
479
  }
406
480
 
407
- if (options.Has("readBigInts") &&
408
- options.Get("readBigInts").IsBoolean()) {
409
- config.set_read_big_ints(
410
- options.Get("readBigInts").As<Napi::Boolean>().Value());
481
+ // Validate and parse 'timeout' option
482
+ Napi::Value timeout_val = options.Get("timeout");
483
+ if (!timeout_val.IsUndefined()) {
484
+ if (!timeout_val.IsNumber()) {
485
+ node::THROW_ERR_INVALID_ARG_TYPE(
486
+ info.Env(),
487
+ "The \"options.timeout\" argument must be an integer.");
488
+ return;
489
+ }
490
+ double timeout_double = timeout_val.As<Napi::Number>().DoubleValue();
491
+ if (timeout_double != std::trunc(timeout_double)) {
492
+ node::THROW_ERR_INVALID_ARG_TYPE(
493
+ info.Env(),
494
+ "The \"options.timeout\" argument must be an integer.");
495
+ return;
496
+ }
497
+ config.set_timeout(timeout_val.As<Napi::Number>().Int32Value());
411
498
  }
412
499
 
413
- if (options.Has("returnArrays") &&
414
- options.Get("returnArrays").IsBoolean()) {
415
- config.set_return_arrays(
416
- options.Get("returnArrays").As<Napi::Boolean>().Value());
500
+ // Validate and parse 'readBigInts' option
501
+ Napi::Value read_bigints_val = options.Get("readBigInts");
502
+ if (!read_bigints_val.IsUndefined()) {
503
+ if (!read_bigints_val.IsBoolean()) {
504
+ node::THROW_ERR_INVALID_ARG_TYPE(
505
+ info.Env(),
506
+ "The \"options.readBigInts\" argument must be a boolean.");
507
+ return;
508
+ }
509
+ config.set_read_big_ints(read_bigints_val.As<Napi::Boolean>().Value());
417
510
  }
418
511
 
419
- if (options.Has("allowBareNamedParameters") &&
420
- options.Get("allowBareNamedParameters").IsBoolean()) {
421
- config.set_allow_bare_named_params(
422
- options.Get("allowBareNamedParameters")
423
- .As<Napi::Boolean>()
424
- .Value());
512
+ // Validate and parse 'returnArrays' option
513
+ Napi::Value return_arrays_val = options.Get("returnArrays");
514
+ if (!return_arrays_val.IsUndefined()) {
515
+ if (!return_arrays_val.IsBoolean()) {
516
+ node::THROW_ERR_INVALID_ARG_TYPE(
517
+ info.Env(),
518
+ "The \"options.returnArrays\" argument must be a boolean.");
519
+ return;
520
+ }
521
+ config.set_return_arrays(return_arrays_val.As<Napi::Boolean>().Value());
425
522
  }
426
523
 
427
- if (options.Has("allowUnknownNamedParameters") &&
428
- options.Get("allowUnknownNamedParameters").IsBoolean()) {
429
- config.set_allow_unknown_named_params(
430
- options.Get("allowUnknownNamedParameters")
431
- .As<Napi::Boolean>()
432
- .Value());
524
+ // Validate and parse 'allowBareNamedParameters' option
525
+ Napi::Value allow_bare_val = options.Get("allowBareNamedParameters");
526
+ if (!allow_bare_val.IsUndefined()) {
527
+ if (!allow_bare_val.IsBoolean()) {
528
+ node::THROW_ERR_INVALID_ARG_TYPE(
529
+ info.Env(),
530
+ "The \"options.allowBareNamedParameters\" argument must be a "
531
+ "boolean.");
532
+ return;
533
+ }
534
+ config.set_allow_bare_named_params(
535
+ allow_bare_val.As<Napi::Boolean>().Value());
433
536
  }
434
537
 
435
- if (options.Has("defensive")) {
436
- Napi::Value defensive_val = options.Get("defensive");
437
- if (!defensive_val.IsUndefined()) {
438
- if (!defensive_val.IsBoolean()) {
439
- node::THROW_ERR_INVALID_ARG_TYPE(
440
- info.Env(),
441
- "The \"options.defensive\" argument must be a boolean.");
442
- return;
443
- }
444
- config.set_enable_defensive(
445
- defensive_val.As<Napi::Boolean>().Value());
538
+ // Validate and parse 'allowUnknownNamedParameters' option
539
+ Napi::Value allow_unknown_val =
540
+ options.Get("allowUnknownNamedParameters");
541
+ if (!allow_unknown_val.IsUndefined()) {
542
+ if (!allow_unknown_val.IsBoolean()) {
543
+ node::THROW_ERR_INVALID_ARG_TYPE(
544
+ info.Env(),
545
+ "The \"options.allowUnknownNamedParameters\" argument must be a "
546
+ "boolean.");
547
+ return;
446
548
  }
549
+ config.set_allow_unknown_named_params(
550
+ allow_unknown_val.As<Napi::Boolean>().Value());
447
551
  }
448
552
 
449
- // Handle the open option
450
- if (options.Has("open")) {
451
- Napi::Value open_val = options.Get("open");
452
- if (open_val.IsBoolean()) {
453
- should_open = open_val.As<Napi::Boolean>().Value();
553
+ // Validate and parse 'defensive' option
554
+ Napi::Value defensive_val = options.Get("defensive");
555
+ if (!defensive_val.IsUndefined()) {
556
+ if (!defensive_val.IsBoolean()) {
557
+ node::THROW_ERR_INVALID_ARG_TYPE(
558
+ info.Env(),
559
+ "The \"options.defensive\" argument must be a boolean.");
560
+ return;
454
561
  }
455
- // For non-boolean values, default to true (existing behavior)
562
+ config.set_enable_defensive(defensive_val.As<Napi::Boolean>().Value());
456
563
  }
457
564
  }
458
565
 
@@ -471,6 +578,9 @@ DatabaseSync::DatabaseSync(const Napi::CallbackInfo &info)
471
578
  }
472
579
 
473
580
  DatabaseSync::~DatabaseSync() {
581
+ // Remove cleanup hook if still registered
582
+ napi_remove_env_cleanup_hook(env_, CleanupHook, this);
583
+
474
584
  // Unregister this instance
475
585
  UnregisterDatabaseInstance(env_, this);
476
586
 
@@ -479,6 +589,16 @@ DatabaseSync::~DatabaseSync() {
479
589
  }
480
590
  }
481
591
 
592
+ void DatabaseSync::CleanupHook(void *arg) {
593
+ // Called before environment teardown - safe to Reset() references here
594
+ auto *self = static_cast<DatabaseSync *>(arg);
595
+
596
+ // Clean up authorizer callback if it exists
597
+ if (self->authorizer_callback_) {
598
+ self->authorizer_callback_->Reset();
599
+ }
600
+ }
601
+
482
602
  Napi::Value DatabaseSync::Open(const Napi::CallbackInfo &info) {
483
603
  Napi::Env env = info.Env();
484
604
 
@@ -506,7 +626,7 @@ Napi::Value DatabaseSync::Close(const Napi::CallbackInfo &info) {
506
626
  }
507
627
 
508
628
  if (!IsOpen()) {
509
- node::THROW_ERR_INVALID_STATE(env, "Database is not open");
629
+ node::THROW_ERR_INVALID_STATE(env, "database is not open");
510
630
  return env.Undefined();
511
631
  }
512
632
 
@@ -547,17 +667,82 @@ Napi::Value DatabaseSync::Prepare(const Napi::CallbackInfo &info) {
547
667
  }
548
668
 
549
669
  if (!IsOpen()) {
550
- node::THROW_ERR_INVALID_STATE(env, "Database is not open");
670
+ node::THROW_ERR_INVALID_STATE(env, "database is not open");
551
671
  return env.Undefined();
552
672
  }
553
673
 
554
674
  if (info.Length() < 1 || !info[0].IsString()) {
555
- node::THROW_ERR_INVALID_ARG_TYPE(env, "Expected SQL string");
675
+ node::THROW_ERR_INVALID_ARG_TYPE(env,
676
+ "The \"sql\" argument must be a string.");
556
677
  return env.Undefined();
557
678
  }
558
679
 
559
680
  std::string sql = info[0].As<Napi::String>().Utf8Value();
560
681
 
682
+ // Parse optional second argument (options object)
683
+ // Node.js v25+ supports per-statement options that override database defaults
684
+ std::optional<bool> opt_read_big_ints;
685
+ std::optional<bool> opt_return_arrays;
686
+ std::optional<bool> opt_allow_bare_named_params;
687
+ std::optional<bool> opt_allow_unknown_named_params;
688
+
689
+ if (info.Length() > 1 && !info[1].IsUndefined()) {
690
+ if (!info[1].IsObject()) {
691
+ node::THROW_ERR_INVALID_ARG_TYPE(
692
+ env, "The \"options\" argument must be an object.");
693
+ return env.Undefined();
694
+ }
695
+
696
+ Napi::Object options = info[1].As<Napi::Object>();
697
+
698
+ // Parse readBigInts option
699
+ Napi::Value read_big_ints_val = options.Get("readBigInts");
700
+ if (!read_big_ints_val.IsUndefined()) {
701
+ if (!read_big_ints_val.IsBoolean()) {
702
+ node::THROW_ERR_INVALID_ARG_TYPE(
703
+ env, "The \"options.readBigInts\" argument must be a boolean.");
704
+ return env.Undefined();
705
+ }
706
+ opt_read_big_ints = read_big_ints_val.As<Napi::Boolean>().Value();
707
+ }
708
+
709
+ // Parse returnArrays option
710
+ Napi::Value return_arrays_val = options.Get("returnArrays");
711
+ if (!return_arrays_val.IsUndefined()) {
712
+ if (!return_arrays_val.IsBoolean()) {
713
+ node::THROW_ERR_INVALID_ARG_TYPE(
714
+ env, "The \"options.returnArrays\" argument must be a boolean.");
715
+ return env.Undefined();
716
+ }
717
+ opt_return_arrays = return_arrays_val.As<Napi::Boolean>().Value();
718
+ }
719
+
720
+ // Parse allowBareNamedParameters option
721
+ Napi::Value allow_bare_val = options.Get("allowBareNamedParameters");
722
+ if (!allow_bare_val.IsUndefined()) {
723
+ if (!allow_bare_val.IsBoolean()) {
724
+ node::THROW_ERR_INVALID_ARG_TYPE(
725
+ env, "The \"options.allowBareNamedParameters\" argument must be a "
726
+ "boolean.");
727
+ return env.Undefined();
728
+ }
729
+ opt_allow_bare_named_params = allow_bare_val.As<Napi::Boolean>().Value();
730
+ }
731
+
732
+ // Parse allowUnknownNamedParameters option
733
+ Napi::Value allow_unknown_val = options.Get("allowUnknownNamedParameters");
734
+ if (!allow_unknown_val.IsUndefined()) {
735
+ if (!allow_unknown_val.IsBoolean()) {
736
+ node::THROW_ERR_INVALID_ARG_TYPE(
737
+ env, "The \"options.allowUnknownNamedParameters\" argument must be "
738
+ "a boolean.");
739
+ return env.Undefined();
740
+ }
741
+ opt_allow_unknown_named_params =
742
+ allow_unknown_val.As<Napi::Boolean>().Value();
743
+ }
744
+ }
745
+
561
746
  // Clear any stale deferred exception from a previous operation
562
747
  ClearDeferredAuthorizerException();
563
748
  SetIgnoreNextSQLiteError(false);
@@ -573,10 +758,24 @@ Napi::Value DatabaseSync::Prepare(const Napi::CallbackInfo &info) {
573
758
  Napi::Object stmt_obj =
574
759
  addon_data->statementSyncConstructor.New({}).As<Napi::Object>();
575
760
 
576
- // Initialize the statement
761
+ // Initialize the statement (applies database-level defaults)
577
762
  StatementSync *stmt = StatementSync::Unwrap(stmt_obj);
578
763
  stmt->InitStatement(this, sql);
579
764
 
765
+ // Apply per-statement option overrides (if explicitly provided)
766
+ if (opt_read_big_ints.has_value()) {
767
+ stmt->use_big_ints_ = *opt_read_big_ints;
768
+ }
769
+ if (opt_return_arrays.has_value()) {
770
+ stmt->return_arrays_ = *opt_return_arrays;
771
+ }
772
+ if (opt_allow_bare_named_params.has_value()) {
773
+ stmt->allow_bare_named_params_ = *opt_allow_bare_named_params;
774
+ }
775
+ if (opt_allow_unknown_named_params.has_value()) {
776
+ stmt->allow_unknown_named_params_ = *opt_allow_unknown_named_params;
777
+ }
778
+
580
779
  return stmt_obj;
581
780
  } catch (const SqliteException &e) {
582
781
  // SqliteException stores message in std::string, avoiding Windows ARM ABI
@@ -627,12 +826,13 @@ Napi::Value DatabaseSync::Exec(const Napi::CallbackInfo &info) {
627
826
  }
628
827
 
629
828
  if (!IsOpen()) {
630
- node::THROW_ERR_INVALID_STATE(env, "Database is not open");
829
+ node::THROW_ERR_INVALID_STATE(env, "database is not open");
631
830
  return env.Undefined();
632
831
  }
633
832
 
634
833
  if (info.Length() < 1 || !info[0].IsString()) {
635
- node::THROW_ERR_INVALID_ARG_TYPE(env, "Expected SQL string");
834
+ node::THROW_ERR_INVALID_ARG_TYPE(env,
835
+ "The \"sql\" argument must be a string.");
636
836
  return env.Undefined();
637
837
  }
638
838
 
@@ -672,13 +872,18 @@ Napi::Value DatabaseSync::LocationMethod(const Napi::CallbackInfo &info) {
672
872
  Napi::Env env = info.Env();
673
873
 
674
874
  if (!IsOpen()) {
675
- node::THROW_ERR_INVALID_STATE(env, "Database is not open");
875
+ node::THROW_ERR_INVALID_STATE(env, "database is not open");
676
876
  return env.Undefined();
677
877
  }
678
878
 
679
879
  // Default to "main" if no dbName provided
680
880
  std::string db_name = "main";
681
- if (info.Length() > 0 && info[0].IsString()) {
881
+ if (info.Length() > 0 && !info[0].IsUndefined()) {
882
+ if (!info[0].IsString()) {
883
+ node::THROW_ERR_INVALID_ARG_TYPE(
884
+ env, "The \"dbName\" argument must be a string.");
885
+ return env.Undefined();
886
+ }
682
887
  db_name = info[0].As<Napi::String>().Utf8Value();
683
888
  }
684
889
 
@@ -699,9 +904,16 @@ Napi::Value DatabaseSync::IsOpenGetter(const Napi::CallbackInfo &info) {
699
904
  }
700
905
 
701
906
  Napi::Value DatabaseSync::IsTransactionGetter(const Napi::CallbackInfo &info) {
907
+ Napi::Env env = info.Env();
908
+
909
+ if (!IsOpen()) {
910
+ node::THROW_ERR_INVALID_STATE(env, "database is not open");
911
+ return env.Undefined();
912
+ }
913
+
702
914
  // Check if we're in a transaction
703
- bool in_transaction = IsOpen() && !sqlite3_get_autocommit(connection());
704
- return Napi::Boolean::New(info.Env(), in_transaction);
915
+ bool in_transaction = !sqlite3_get_autocommit(connection());
916
+ return Napi::Boolean::New(env, in_transaction);
705
917
  }
706
918
 
707
919
  void DatabaseSync::InternalOpen(DatabaseOpenConfiguration config) {
@@ -717,6 +929,11 @@ void DatabaseSync::InternalOpen(DatabaseOpenConfiguration config) {
717
929
  flags |= SQLITE_OPEN_READWRITE;
718
930
  }
719
931
 
932
+ // Add URI flag when location contains URI parameters
933
+ if (config_.get_open_uri()) {
934
+ flags |= SQLITE_OPEN_URI;
935
+ }
936
+
720
937
  int result = sqlite3_open_v2(location_.c_str(), &connection_, flags, nullptr);
721
938
 
722
939
  if (result != SQLITE_OK) {
@@ -731,10 +948,19 @@ void DatabaseSync::InternalOpen(DatabaseOpenConfiguration config) {
731
948
  throw ex;
732
949
  }
733
950
 
734
- // Configure database
735
- if (config_.get_enable_foreign_keys()) {
736
- sqlite3_exec(connection(), "PRAGMA foreign_keys = ON", nullptr, nullptr,
737
- nullptr);
951
+ // Configure foreign keys using sqlite3_db_config (matches Node.js behavior)
952
+ // This properly handles both enabling and disabling FK constraints
953
+ int fk_enabled;
954
+ result =
955
+ sqlite3_db_config(connection(), SQLITE_DBCONFIG_ENABLE_FKEY,
956
+ config_.get_enable_foreign_keys() ? 1 : 0, &fk_enabled);
957
+ if (result != SQLITE_OK) {
958
+ std::string error = sqlite3_errmsg(connection());
959
+ SqliteException ex(connection_, result,
960
+ "Failed to configure foreign keys: " + error);
961
+ sqlite3_close(connection_);
962
+ connection_ = nullptr;
963
+ throw ex;
738
964
  }
739
965
 
740
966
  if (config_.get_timeout() > 0) {
@@ -785,6 +1011,10 @@ void DatabaseSync::InternalOpen(DatabaseOpenConfiguration config) {
785
1011
 
786
1012
  void DatabaseSync::InternalClose() {
787
1013
  if (connection_) {
1014
+ // Finalize any active backup jobs first
1015
+ // This prevents use-after-free if backup is running on worker thread
1016
+ FinalizeBackups();
1017
+
788
1018
  // Finalize all prepared statements
789
1019
  prepared_statements_.clear();
790
1020
 
@@ -808,18 +1038,13 @@ Napi::Value DatabaseSync::CustomFunction(const Napi::CallbackInfo &info) {
808
1038
  Napi::Env env = info.Env();
809
1039
 
810
1040
  if (!IsOpen()) {
811
- node::THROW_ERR_INVALID_STATE(env, "Database is not open");
812
- return env.Undefined();
813
- }
814
-
815
- if (info.Length() < 2) {
816
- node::THROW_ERR_INVALID_ARG_TYPE(
817
- env, "Expected at least 2 arguments: name and function");
1041
+ node::THROW_ERR_INVALID_STATE(env, "database is not open");
818
1042
  return env.Undefined();
819
1043
  }
820
1044
 
821
1045
  if (!info[0].IsString()) {
822
- node::THROW_ERR_INVALID_ARG_TYPE(env, "Function name must be a string");
1046
+ node::THROW_ERR_INVALID_ARG_TYPE(env,
1047
+ "The \"name\" argument must be a string.");
823
1048
  return env.Undefined();
824
1049
  }
825
1050
 
@@ -831,31 +1056,60 @@ Napi::Value DatabaseSync::CustomFunction(const Napi::CallbackInfo &info) {
831
1056
  bool direct_only = false;
832
1057
 
833
1058
  // Parse options object if provided
834
- if (fn_index > 1 && info[1].IsObject()) {
1059
+ if (fn_index > 1) {
1060
+ if (!info[1].IsObject()) {
1061
+ node::THROW_ERR_INVALID_ARG_TYPE(
1062
+ env, "The \"options\" argument must be an object.");
1063
+ return env.Undefined();
1064
+ }
1065
+
835
1066
  Napi::Object options = info[1].As<Napi::Object>();
836
1067
 
837
- if (options.Has("useBigIntArguments") &&
838
- options.Get("useBigIntArguments").IsBoolean()) {
839
- use_bigint_args =
840
- options.Get("useBigIntArguments").As<Napi::Boolean>().Value();
1068
+ Napi::Value use_bigint_args_v = options.Get("useBigIntArguments");
1069
+ if (!use_bigint_args_v.IsUndefined()) {
1070
+ if (!use_bigint_args_v.IsBoolean()) {
1071
+ node::THROW_ERR_INVALID_ARG_TYPE(
1072
+ env,
1073
+ "The \"options.useBigIntArguments\" argument must be a boolean.");
1074
+ return env.Undefined();
1075
+ }
1076
+ use_bigint_args = use_bigint_args_v.As<Napi::Boolean>().Value();
841
1077
  }
842
1078
 
843
- if (options.Has("varargs") && options.Get("varargs").IsBoolean()) {
844
- varargs = options.Get("varargs").As<Napi::Boolean>().Value();
1079
+ Napi::Value varargs_v = options.Get("varargs");
1080
+ if (!varargs_v.IsUndefined()) {
1081
+ if (!varargs_v.IsBoolean()) {
1082
+ node::THROW_ERR_INVALID_ARG_TYPE(
1083
+ env, "The \"options.varargs\" argument must be a boolean.");
1084
+ return env.Undefined();
1085
+ }
1086
+ varargs = varargs_v.As<Napi::Boolean>().Value();
845
1087
  }
846
1088
 
847
- if (options.Has("deterministic") &&
848
- options.Get("deterministic").IsBoolean()) {
849
- deterministic = options.Get("deterministic").As<Napi::Boolean>().Value();
1089
+ Napi::Value deterministic_v = options.Get("deterministic");
1090
+ if (!deterministic_v.IsUndefined()) {
1091
+ if (!deterministic_v.IsBoolean()) {
1092
+ node::THROW_ERR_INVALID_ARG_TYPE(
1093
+ env, "The \"options.deterministic\" argument must be a boolean.");
1094
+ return env.Undefined();
1095
+ }
1096
+ deterministic = deterministic_v.As<Napi::Boolean>().Value();
850
1097
  }
851
1098
 
852
- if (options.Has("directOnly") && options.Get("directOnly").IsBoolean()) {
853
- direct_only = options.Get("directOnly").As<Napi::Boolean>().Value();
1099
+ Napi::Value direct_only_v = options.Get("directOnly");
1100
+ if (!direct_only_v.IsUndefined()) {
1101
+ if (!direct_only_v.IsBoolean()) {
1102
+ node::THROW_ERR_INVALID_ARG_TYPE(
1103
+ env, "The \"options.directOnly\" argument must be a boolean.");
1104
+ return env.Undefined();
1105
+ }
1106
+ direct_only = direct_only_v.As<Napi::Boolean>().Value();
854
1107
  }
855
1108
  }
856
1109
 
857
1110
  if (!info[fn_index].IsFunction()) {
858
- node::THROW_ERR_INVALID_ARG_TYPE(env, "Callback must be a function");
1111
+ node::THROW_ERR_INVALID_ARG_TYPE(
1112
+ env, "The \"function\" argument must be a function.");
859
1113
  return env.Undefined();
860
1114
  }
861
1115
 
@@ -907,74 +1161,96 @@ Napi::Value DatabaseSync::AggregateFunction(const Napi::CallbackInfo &info) {
907
1161
  Napi::Env env = info.Env();
908
1162
 
909
1163
  if (!IsOpen()) {
910
- node::THROW_ERR_INVALID_STATE(env, "Database is not open");
1164
+ node::THROW_ERR_INVALID_STATE(env, "database is not open");
911
1165
  return env.Undefined();
912
1166
  }
913
1167
 
914
- if (info.Length() < 2) {
915
- node::THROW_ERR_INVALID_ARG_TYPE(
916
- env, "Expected at least 2 arguments: name and options");
917
- return env.Undefined();
918
- }
1168
+ // Node.js doesn't check argument count - it just accesses args directly
1169
+ std::string name = info[0].IsString() ? info[0].As<Napi::String>().Utf8Value()
1170
+ : std::string();
1171
+ Napi::Object options =
1172
+ info[1].IsObject() ? info[1].As<Napi::Object>() : Napi::Object();
919
1173
 
920
- if (!info[0].IsString()) {
921
- node::THROW_ERR_INVALID_ARG_TYPE(env, "Function name must be a string");
922
- return env.Undefined();
1174
+ if (options.IsEmpty() || options.IsNull() || options.IsUndefined()) {
1175
+ // If options isn't an object, trying to access its properties will fail
1176
+ // Node.js would throw from the property access, we mimic that
1177
+ options = Napi::Object::New(env);
923
1178
  }
924
1179
 
925
- if (!info[1].IsObject()) {
926
- node::THROW_ERR_INVALID_ARG_TYPE(env, "Options must be an object");
1180
+ // Parse start value
1181
+ Napi::Value start = options.Get("start");
1182
+ if (start.IsUndefined()) {
1183
+ node::THROW_ERR_INVALID_ARG_TYPE(
1184
+ env, "The \"options.start\" argument must be a function or a primitive "
1185
+ "value.");
927
1186
  return env.Undefined();
928
1187
  }
929
1188
 
930
- std::string name = info[0].As<Napi::String>().Utf8Value();
931
- Napi::Object options = info[1].As<Napi::Object>();
932
-
933
- // Parse required options - start can be undefined, will default to null
934
- Napi::Value start = env.Null();
935
- if (options.Has("start") && !options.Get("start").IsUndefined()) {
936
- start = options.Get("start");
937
- }
938
-
939
- if (!options.Has("step") || !options.Get("step").IsFunction()) {
940
- node::THROW_ERR_INVALID_ARG_TYPE(env, "options.step must be a function");
1189
+ // Parse step function
1190
+ Napi::Value step_v = options.Get("step");
1191
+ if (!step_v.IsFunction()) {
1192
+ node::THROW_ERR_INVALID_ARG_TYPE(
1193
+ env, "The \"options.step\" argument must be a function.");
941
1194
  return env.Undefined();
942
1195
  }
1196
+ Napi::Function step_fn = step_v.As<Napi::Function>();
943
1197
 
944
- Napi::Function step_fn = options.Get("step").As<Napi::Function>();
945
-
946
- // Parse optional options
947
- Napi::Function inverse_fn;
948
- if (options.Has("inverse") && options.Get("inverse").IsFunction()) {
949
- inverse_fn = options.Get("inverse").As<Napi::Function>();
950
- }
951
-
1198
+ // Parse result function (optional)
952
1199
  Napi::Function result_fn;
953
- if (options.Has("result") && options.Get("result").IsFunction()) {
954
- result_fn = options.Get("result").As<Napi::Function>();
1200
+ Napi::Value result_v = options.Get("result");
1201
+ if (!result_v.IsUndefined()) {
1202
+ result_fn = result_v.As<Napi::Function>();
955
1203
  }
956
1204
 
1205
+ // Parse boolean options with validation
957
1206
  bool use_bigint_args = false;
958
- if (options.Has("useBigIntArguments") &&
959
- options.Get("useBigIntArguments").IsBoolean()) {
960
- use_bigint_args =
961
- options.Get("useBigIntArguments").As<Napi::Boolean>().Value();
1207
+ Napi::Value use_bigint_args_v = options.Get("useBigIntArguments");
1208
+ if (!use_bigint_args_v.IsUndefined()) {
1209
+ if (!use_bigint_args_v.IsBoolean()) {
1210
+ node::THROW_ERR_INVALID_ARG_TYPE(
1211
+ env,
1212
+ "The \"options.useBigIntArguments\" argument must be a boolean.");
1213
+ return env.Undefined();
1214
+ }
1215
+ use_bigint_args = use_bigint_args_v.As<Napi::Boolean>().Value();
962
1216
  }
963
1217
 
964
1218
  bool varargs = false;
965
- if (options.Has("varargs") && options.Get("varargs").IsBoolean()) {
966
- varargs = options.Get("varargs").As<Napi::Boolean>().Value();
1219
+ Napi::Value varargs_v = options.Get("varargs");
1220
+ if (!varargs_v.IsUndefined()) {
1221
+ if (!varargs_v.IsBoolean()) {
1222
+ node::THROW_ERR_INVALID_ARG_TYPE(
1223
+ env, "The \"options.varargs\" argument must be a boolean.");
1224
+ return env.Undefined();
1225
+ }
1226
+ varargs = varargs_v.As<Napi::Boolean>().Value();
967
1227
  }
968
1228
 
969
1229
  bool deterministic = false;
970
- if (options.Has("deterministic") &&
971
- options.Get("deterministic").IsBoolean()) {
972
- deterministic = options.Get("deterministic").As<Napi::Boolean>().Value();
973
- }
1230
+ // Note: deterministic is handled via sqlite flags but Node.js
1231
+ // doesn't seem to validate it separately for aggregates
974
1232
 
975
1233
  bool direct_only = false;
976
- if (options.Has("directOnly") && options.Get("directOnly").IsBoolean()) {
977
- direct_only = options.Get("directOnly").As<Napi::Boolean>().Value();
1234
+ Napi::Value direct_only_v = options.Get("directOnly");
1235
+ if (!direct_only_v.IsUndefined()) {
1236
+ if (!direct_only_v.IsBoolean()) {
1237
+ node::THROW_ERR_INVALID_ARG_TYPE(
1238
+ env, "The \"options.directOnly\" argument must be a boolean.");
1239
+ return env.Undefined();
1240
+ }
1241
+ direct_only = direct_only_v.As<Napi::Boolean>().Value();
1242
+ }
1243
+
1244
+ // Parse inverse function (optional)
1245
+ Napi::Function inverse_fn;
1246
+ Napi::Value inverse_v = options.Get("inverse");
1247
+ if (!inverse_v.IsUndefined()) {
1248
+ if (!inverse_v.IsFunction()) {
1249
+ node::THROW_ERR_INVALID_ARG_TYPE(
1250
+ env, "The \"options.inverse\" argument must be a function.");
1251
+ return env.Undefined();
1252
+ }
1253
+ inverse_fn = inverse_v.As<Napi::Function>();
978
1254
  }
979
1255
 
980
1256
  // Determine argument count
@@ -1048,7 +1324,7 @@ Napi::Value DatabaseSync::EnableLoadExtension(const Napi::CallbackInfo &info) {
1048
1324
  Napi::Env env = info.Env();
1049
1325
 
1050
1326
  if (!IsOpen()) {
1051
- node::THROW_ERR_INVALID_STATE(env, "Database is not open");
1327
+ node::THROW_ERR_INVALID_STATE(env, "database is not open");
1052
1328
  return env.Undefined();
1053
1329
  }
1054
1330
 
@@ -1088,7 +1364,7 @@ Napi::Value DatabaseSync::LoadExtension(const Napi::CallbackInfo &info) {
1088
1364
  Napi::Env env = info.Env();
1089
1365
 
1090
1366
  if (!IsOpen()) {
1091
- node::THROW_ERR_INVALID_STATE(env, "Database is not open");
1367
+ node::THROW_ERR_INVALID_STATE(env, "database is not open");
1092
1368
  return env.Undefined();
1093
1369
  }
1094
1370
 
@@ -1263,26 +1539,108 @@ void DatabaseSync::DeleteAllSessions() {
1263
1539
  // Direct SQLite cleanup since we're in database destruction
1264
1540
  if (session->GetSession()) {
1265
1541
  sqlite3session_delete(session->GetSession());
1266
- // Clear the session's internal pointers
1542
+ // Clear the session pointer but KEEP database_ so we can detect
1543
+ // "database closed" vs "session closed" in Session methods
1267
1544
  session->session_ = nullptr;
1268
- session->database_ = nullptr;
1545
+ // Note: Don't null database_ - we need it to check IsOpen()
1269
1546
  }
1270
1547
  }
1271
1548
  }
1272
1549
 
1550
+ void DatabaseSync::AddBackup(BackupJob *backup) {
1551
+ std::lock_guard<std::mutex> lock(backups_mutex_);
1552
+ backups_.insert(backup);
1553
+ }
1554
+
1555
+ void DatabaseSync::RemoveBackup(BackupJob *backup) {
1556
+ std::lock_guard<std::mutex> lock(backups_mutex_);
1557
+ backups_.erase(backup);
1558
+ }
1559
+
1560
+ void DatabaseSync::FinalizeBackups() {
1561
+ // Copy the set while holding the lock, then release before cleanup
1562
+ // This prevents deadlock if destructor calls RemoveBackup
1563
+ std::set<BackupJob *> backups_copy;
1564
+ {
1565
+ std::lock_guard<std::mutex> lock(backups_mutex_);
1566
+ backups_copy = backups_;
1567
+ backups_.clear(); // Clear now to prevent destructor issues
1568
+ }
1569
+
1570
+ // Clean up each active backup without holding the lock
1571
+ // We clear source_ to prevent the destructor from trying to
1572
+ // RemoveBackup (the set is already empty anyway)
1573
+ for (auto *backup : backups_copy) {
1574
+ backup->ClearSource(); // Prevent RemoveBackup call in destructor
1575
+ backup->Cleanup();
1576
+ }
1577
+ }
1578
+
1273
1579
  // Context structure for changeset callbacks to avoid global state
1274
1580
  struct ChangesetCallbacks {
1275
1581
  std::function<int(int)> conflictCallback;
1276
1582
  std::function<bool(std::string)> filterCallback;
1277
1583
  Napi::Env env;
1584
+ // Store pending exception for re-throwing after SQLite call
1585
+ std::string pendingExceptionMessage;
1586
+ bool hasPendingException = false;
1278
1587
  };
1279
1588
 
1589
+ // Helper to extract error message from Napi::Error
1590
+ // Handles both Error objects (use .message property) and primitive throws.
1591
+ // For GetAndClearPendingException(), err.Value() returns the ORIGINAL thrown
1592
+ // value - if JS throws "string", Value() IS the string, not a wrapper.
1593
+ // See: node-addon-api/test/error.cc line 306-310 (CatchError function)
1594
+ static std::string GetErrorMessage(const Napi::Error &err,
1595
+ const char *fallback) {
1596
+ // Try 1: Message() works for Error objects with .message property
1597
+ try {
1598
+ std::string msg = err.Message();
1599
+ if (!msg.empty()) {
1600
+ return msg;
1601
+ }
1602
+ } catch (...) {
1603
+ // Message() failed, continue trying
1604
+ }
1605
+
1606
+ // Try 2: For primitives or when Message() is empty, err.Value() returns
1607
+ // the original thrown value. Just convert it to string directly.
1608
+ // This works for: throw "string", throw 42, throw new Error("msg")
1609
+ try {
1610
+ Napi::Value val = err.Value();
1611
+ if (!val.IsEmpty() && !val.IsUndefined() && !val.IsNull()) {
1612
+ return val.ToString().Utf8Value();
1613
+ }
1614
+ } catch (...) {
1615
+ // Value() or ToString() failed, continue
1616
+ }
1617
+
1618
+ // Try 3: For C++ catch blocks, check ERROR_WRAP_VALUE property
1619
+ // (only relevant when catching Napi::Error as C++ exception)
1620
+ try {
1621
+ Napi::Value val = err.Value();
1622
+ if (val.IsObject()) {
1623
+ Napi::Object errObj = val.As<Napi::Object>();
1624
+ static const char *ERROR_WRAP_VALUE =
1625
+ "4bda9e7e-4913-4dbc-95de-891cbf66598e-errorVal";
1626
+ Napi::Value wrapped = errObj.Get(ERROR_WRAP_VALUE);
1627
+ if (!wrapped.IsUndefined()) {
1628
+ return wrapped.ToString().Utf8Value();
1629
+ }
1630
+ }
1631
+ } catch (...) {
1632
+ // Property access failed, continue
1633
+ }
1634
+
1635
+ return fallback;
1636
+ }
1637
+
1280
1638
  static int xConflict(void *pCtx, int eConflict, sqlite3_changeset_iter *pIter) {
1281
1639
  if (!pCtx)
1282
- return SQLITE_CHANGESET_OMIT;
1640
+ return SQLITE_CHANGESET_ABORT;
1283
1641
  ChangesetCallbacks *callbacks = static_cast<ChangesetCallbacks *>(pCtx);
1284
1642
  if (!callbacks->conflictCallback)
1285
- return SQLITE_CHANGESET_OMIT;
1643
+ return SQLITE_CHANGESET_ABORT;
1286
1644
  return callbacks->conflictCallback(eConflict);
1287
1645
  }
1288
1646
 
@@ -1290,6 +1648,9 @@ static int xFilter(void *pCtx, const char *zTab) {
1290
1648
  if (!pCtx)
1291
1649
  return 1;
1292
1650
  ChangesetCallbacks *callbacks = static_cast<ChangesetCallbacks *>(pCtx);
1651
+ // Skip filter callback if we already have a pending exception
1652
+ if (callbacks->hasPendingException)
1653
+ return 0;
1293
1654
  if (!callbacks->filterCallback)
1294
1655
  return 1;
1295
1656
  return callbacks->filterCallback(zTab) ? 1 : 0;
@@ -1303,14 +1664,14 @@ Napi::Value DatabaseSync::ApplyChangeset(const Napi::CallbackInfo &info) {
1303
1664
  return env.Undefined();
1304
1665
  }
1305
1666
 
1306
- if (info.Length() < 1 || !info[0].IsBuffer()) {
1667
+ if (info.Length() < 1 || !info[0].IsTypedArray()) {
1307
1668
  node::THROW_ERR_INVALID_ARG_TYPE(
1308
- env, "The \"changeset\" argument must be a Buffer.");
1669
+ env, "The \"changeset\" argument must be a Uint8Array.");
1309
1670
  return env.Undefined();
1310
1671
  }
1311
1672
 
1312
1673
  // Create callback context to avoid global state
1313
- ChangesetCallbacks callbacks{nullptr, nullptr, env};
1674
+ ChangesetCallbacks callbacks{nullptr, nullptr, env, "", false};
1314
1675
 
1315
1676
  // Parse options if provided
1316
1677
  if (info.Length() > 1 && !info[1].IsUndefined()) {
@@ -1333,31 +1694,65 @@ Napi::Value DatabaseSync::ApplyChangeset(const Napi::CallbackInfo &info) {
1333
1694
  }
1334
1695
 
1335
1696
  Napi::Function conflictFunc = conflictValue.As<Napi::Function>();
1336
- callbacks.conflictCallback = [env,
1697
+ callbacks.conflictCallback = [&callbacks, env,
1337
1698
  conflictFunc](int conflictType) -> int {
1338
- Napi::HandleScope scope(env);
1699
+ // Wrap in try-catch to prevent C++ exceptions from propagating
1700
+ // through C callback boundary into SQLite (causes SIGSEGV)
1339
1701
  try {
1702
+ // Skip callback if we already have a pending exception
1703
+ if (callbacks.hasPendingException)
1704
+ return SQLITE_CHANGESET_ABORT;
1705
+
1706
+ Napi::HandleScope scope(env);
1340
1707
  Napi::Value result =
1341
1708
  conflictFunc.Call({Napi::Number::New(env, conflictType)});
1342
1709
 
1710
+ // Check for exception - Call() may have thrown
1343
1711
  if (env.IsExceptionPending()) {
1344
- // Clear the exception to prevent propagation
1345
- env.GetAndClearPendingException();
1346
- // If callback threw, abort the changeset apply
1712
+ Napi::Error err = env.GetAndClearPendingException();
1713
+ callbacks.pendingExceptionMessage = GetErrorMessage(
1714
+ err, "onConflict callback threw an exception");
1715
+ callbacks.hasPendingException = true;
1347
1716
  return SQLITE_CHANGESET_ABORT;
1348
1717
  }
1349
1718
 
1350
- if (!result.IsNumber()) {
1351
- // If the callback returns a non-numeric value, treat it as ABORT
1719
+ // Check for empty result (another exception indicator)
1720
+ if (result.IsEmpty()) {
1721
+ callbacks.pendingExceptionMessage = "Callback threw an exception";
1722
+ callbacks.hasPendingException = true;
1352
1723
  return SQLITE_CHANGESET_ABORT;
1353
1724
  }
1354
1725
 
1726
+ // Return -1 (invalid value) for non-integer results
1727
+ // This makes SQLite return SQLITE_MISUSE
1728
+ if (!result.IsNumber()) {
1729
+ return -1;
1730
+ }
1731
+
1355
1732
  return result.As<Napi::Number>().Int32Value();
1733
+ } catch (const Napi::Error &e) {
1734
+ // Catch Napi::Error specifically (inherits from std::exception)
1735
+ callbacks.pendingExceptionMessage =
1736
+ GetErrorMessage(e, "onConflict callback threw an exception");
1737
+ callbacks.hasPendingException = true;
1738
+ return SQLITE_CHANGESET_ABORT;
1739
+ } catch (const std::exception &e) {
1740
+ // Catch non-Napi C++ exceptions (e.g., SqliteException)
1741
+ callbacks.pendingExceptionMessage =
1742
+ std::string("C++ exception in onConflict: ") + e.what();
1743
+ callbacks.hasPendingException = true;
1744
+ return SQLITE_CHANGESET_ABORT;
1356
1745
  } catch (...) {
1357
- // Catch any C++ exceptions
1746
+ // Catch all other exceptions
1358
1747
  if (env.IsExceptionPending()) {
1359
- env.GetAndClearPendingException();
1748
+ Napi::Error err = env.GetAndClearPendingException();
1749
+ callbacks.pendingExceptionMessage =
1750
+ GetErrorMessage(err, "Exception in onConflict callback");
1751
+ } else {
1752
+ callbacks.pendingExceptionMessage =
1753
+ "Unknown exception in onConflict callback";
1360
1754
  }
1755
+ callbacks.hasPendingException = true;
1361
1756
  return SQLITE_CHANGESET_ABORT;
1362
1757
  }
1363
1758
  };
@@ -1374,38 +1769,83 @@ Napi::Value DatabaseSync::ApplyChangeset(const Napi::CallbackInfo &info) {
1374
1769
  }
1375
1770
 
1376
1771
  Napi::Function filterFunc = filterValue.As<Napi::Function>();
1377
- callbacks.filterCallback = [env,
1772
+ callbacks.filterCallback = [&callbacks, env,
1378
1773
  filterFunc](std::string tableName) -> bool {
1379
- Napi::HandleScope scope(env);
1774
+ // Wrap in try-catch to prevent C++ exceptions from propagating
1775
+ // through C callback boundary into SQLite (causes SIGSEGV)
1380
1776
  try {
1777
+ // Skip callback if we already have a pending exception
1778
+ if (callbacks.hasPendingException)
1779
+ return false;
1780
+
1781
+ Napi::HandleScope scope(env);
1381
1782
  Napi::Value result =
1382
1783
  filterFunc.Call({Napi::String::New(env, tableName)});
1383
1784
 
1785
+ // Check for exception - Call() may have thrown
1384
1786
  if (env.IsExceptionPending()) {
1385
- // Clear the exception to prevent propagation
1386
- env.GetAndClearPendingException();
1387
- // If callback threw, exclude the table
1787
+ Napi::Error err = env.GetAndClearPendingException();
1788
+ callbacks.pendingExceptionMessage =
1789
+ GetErrorMessage(err, "Filter callback threw an exception");
1790
+ callbacks.hasPendingException = true;
1791
+ return false;
1792
+ }
1793
+
1794
+ // Check for empty result (another exception indicator)
1795
+ if (result.IsEmpty()) {
1796
+ callbacks.pendingExceptionMessage =
1797
+ "Filter callback threw an exception";
1798
+ callbacks.hasPendingException = true;
1388
1799
  return false;
1389
1800
  }
1390
1801
 
1391
1802
  return result.ToBoolean().Value();
1803
+ } catch (const Napi::Error &e) {
1804
+ // Catch Napi::Error specifically (inherits from std::exception)
1805
+ callbacks.pendingExceptionMessage =
1806
+ GetErrorMessage(e, "Filter callback threw an exception");
1807
+ callbacks.hasPendingException = true;
1808
+ return false;
1809
+ } catch (const std::exception &e) {
1810
+ // Catch non-Napi C++ exceptions (e.g., SqliteException)
1811
+ callbacks.pendingExceptionMessage =
1812
+ std::string("C++ exception in filter: ") + e.what();
1813
+ callbacks.hasPendingException = true;
1814
+ return false;
1392
1815
  } catch (...) {
1393
- // Catch any C++ exceptions
1816
+ // Catch all other exceptions
1394
1817
  if (env.IsExceptionPending()) {
1395
- env.GetAndClearPendingException();
1818
+ Napi::Error err = env.GetAndClearPendingException();
1819
+ callbacks.pendingExceptionMessage =
1820
+ GetErrorMessage(err, "Exception in filter callback");
1821
+ } else {
1822
+ callbacks.pendingExceptionMessage =
1823
+ "Unknown exception in filter callback";
1396
1824
  }
1825
+ callbacks.hasPendingException = true;
1397
1826
  return false;
1398
1827
  }
1399
1828
  };
1400
1829
  }
1401
1830
  }
1402
1831
 
1403
- // Get the changeset buffer
1404
- Napi::Buffer<uint8_t> buffer = info[0].As<Napi::Buffer<uint8_t>>();
1832
+ // Get the changeset data from TypedArray (Uint8Array or Buffer)
1833
+ Napi::TypedArray typed_array = info[0].As<Napi::TypedArray>();
1834
+ Napi::ArrayBuffer array_buffer = typed_array.ArrayBuffer();
1835
+ size_t byte_offset = typed_array.ByteOffset();
1836
+ size_t byte_length = typed_array.ByteLength();
1837
+ uint8_t *data = static_cast<uint8_t *>(array_buffer.Data()) + byte_offset;
1405
1838
 
1406
1839
  // Apply the changeset with context instead of global state
1407
- int r = sqlite3changeset_apply(connection(), buffer.Length(), buffer.Data(),
1408
- xFilter, xConflict, &callbacks);
1840
+ int r = sqlite3changeset_apply(connection(), static_cast<int>(byte_length),
1841
+ data, xFilter, xConflict, &callbacks);
1842
+
1843
+ // Check for pending exception from callbacks - re-throw it
1844
+ if (callbacks.hasPendingException) {
1845
+ Napi::Error::New(env, callbacks.pendingExceptionMessage)
1846
+ .ThrowAsJavaScriptException();
1847
+ return env.Undefined();
1848
+ }
1409
1849
 
1410
1850
  if (r == SQLITE_OK) {
1411
1851
  return Napi::Boolean::New(env, true);
@@ -1416,10 +1856,9 @@ Napi::Value DatabaseSync::ApplyChangeset(const Napi::CallbackInfo &info) {
1416
1856
  return Napi::Boolean::New(env, false);
1417
1857
  }
1418
1858
 
1419
- // Other errors
1420
- std::string error = "Failed to apply changeset: ";
1421
- error += sqlite3_errmsg(connection());
1422
- node::THROW_ERR_SQLITE_ERROR(env, error.c_str());
1859
+ // Other errors - use enhanced error with errcode property
1860
+ const char *errMsg = sqlite3_errmsg(connection());
1861
+ node::ThrowEnhancedSqliteError(env, connection(), r, errMsg);
1423
1862
  return env.Undefined();
1424
1863
  }
1425
1864
 
@@ -1431,8 +1870,6 @@ Napi::Object StatementSync::Init(Napi::Env env, Napi::Object exports) {
1431
1870
  InstanceMethod("get", &StatementSync::Get),
1432
1871
  InstanceMethod("all", &StatementSync::All),
1433
1872
  InstanceMethod("iterate", &StatementSync::Iterate),
1434
- InstanceMethod("finalize", &StatementSync::FinalizeStatement),
1435
- InstanceMethod("dispose", &StatementSync::Dispose),
1436
1873
  InstanceMethod("setReadBigInts", &StatementSync::SetReadBigInts),
1437
1874
  InstanceMethod("setReturnArrays", &StatementSync::SetReturnArrays),
1438
1875
  InstanceMethod("setAllowBareNamedParameters",
@@ -1442,8 +1879,6 @@ Napi::Object StatementSync::Init(Napi::Env env, Napi::Object exports) {
1442
1879
  InstanceMethod("columns", &StatementSync::Columns),
1443
1880
  InstanceAccessor("sourceSQL", &StatementSync::SourceSQLGetter, nullptr),
1444
1881
  InstanceAccessor("expandedSQL", &StatementSync::ExpandedSQLGetter,
1445
- nullptr),
1446
- InstanceAccessor("finalized", &StatementSync::FinalizedGetter,
1447
1882
  nullptr)});
1448
1883
 
1449
1884
  // Store constructor in per-instance addon data instead of static variable
@@ -1453,21 +1888,6 @@ Napi::Object StatementSync::Init(Napi::Env env, Napi::Object exports) {
1453
1888
  Napi::Reference<Napi::Function>::New(func);
1454
1889
  }
1455
1890
 
1456
- // Add Symbol.dispose to the prototype (Node.js v25+ compatibility)
1457
- Napi::Value symbolDispose =
1458
- env.Global().Get("Symbol").As<Napi::Object>().Get("dispose");
1459
- if (!symbolDispose.IsUndefined()) {
1460
- func.Get("prototype")
1461
- .As<Napi::Object>()
1462
- .Set(symbolDispose,
1463
- Napi::Function::New(
1464
- env, [](const Napi::CallbackInfo &info) -> Napi::Value {
1465
- StatementSync *stmt =
1466
- StatementSync::Unwrap(info.This().As<Napi::Object>());
1467
- return stmt->Dispose(info);
1468
- }));
1469
- }
1470
-
1471
1891
  exports.Set("StatementSync", func);
1472
1892
  return exports;
1473
1893
  }
@@ -1481,15 +1901,10 @@ StatementSync::StatementSync(const Napi::CallbackInfo &info)
1481
1901
  void StatementSync::InitStatement(DatabaseSync *database,
1482
1902
  const std::string &sql) {
1483
1903
  if (!database || !database->IsOpen()) {
1484
- throw std::runtime_error("Database is not open");
1904
+ throw std::runtime_error("database is not open");
1485
1905
  }
1486
1906
 
1487
1907
  database_ = database;
1488
- // Create a strong reference to the database object to prevent it from being
1489
- // garbage collected while this statement exists. This fixes use-after-free
1490
- // when the database is GC'd before its statements.
1491
- // See: https://github.com/nodejs/node/pull/56840
1492
- database_ref_ = Napi::Persistent(database->Value());
1493
1908
  source_sql_ = sql;
1494
1909
 
1495
1910
  // Apply database-level defaults
@@ -1524,8 +1939,8 @@ void StatementSync::InitStatement(DatabaseSync *database,
1524
1939
  // object and will be retrieved by the caller.
1525
1940
  throw std::runtime_error("");
1526
1941
  }
1527
- std::string error = "Failed to prepare statement: ";
1528
- error += sqlite3_errmsg(database->connection());
1942
+ // Use sqlite3_errmsg directly without prefix - matches Node.js error format
1943
+ std::string error = sqlite3_errmsg(database->connection());
1529
1944
  // Use SqliteException to capture error info - avoids Windows ARM ABI issues
1530
1945
  // with std::runtime_error::what() returning corrupted strings
1531
1946
  throw SqliteException(database->connection(), result, error);
@@ -1536,10 +1951,10 @@ StatementSync::~StatementSync() {
1536
1951
  if (statement_ && !finalized_) {
1537
1952
  sqlite3_finalize(statement_);
1538
1953
  }
1539
- // Release the strong reference to the database object
1540
- if (!database_ref_.IsEmpty()) {
1541
- database_ref_.Reset();
1542
- }
1954
+ // Raw pointer to database is managed by DatabaseSync::FinalizeStatements()
1955
+ // which is called before DatabaseSync destructor. This avoids N-API
1956
+ // Reset() calls during GC which cause JIT corruption on Alpine/musl.
1957
+ // See: commit 4da0638, nodejs/node-addon-api#660
1543
1958
  }
1544
1959
 
1545
1960
  Napi::Value StatementSync::Run(const Napi::CallbackInfo &info) {
@@ -1590,17 +2005,26 @@ Napi::Value StatementSync::Run(const Napi::CallbackInfo &info) {
1590
2005
 
1591
2006
  // Create result object
1592
2007
  Napi::Object result_obj = Napi::Object::New(env);
1593
- result_obj.Set(
1594
- "changes",
1595
- Napi::Number::New(env, sqlite3_changes(database_->connection())));
1596
2008
 
2009
+ // Get changes and lastInsertRowid
2010
+ int changes = sqlite3_changes(database_->connection());
1597
2011
  sqlite3_int64 last_rowid =
1598
2012
  sqlite3_last_insert_rowid(database_->connection());
1599
- // Use JavaScript's safe integer limits (2^53 - 1)
1600
- if (last_rowid > JS_MAX_SAFE_INTEGER || last_rowid < JS_MIN_SAFE_INTEGER) {
2013
+
2014
+ // When readBigInts is true, return BigInt for both (matches Node.js)
2015
+ if (use_big_ints_) {
2016
+ result_obj.Set("changes",
2017
+ Napi::BigInt::New(env, static_cast<int64_t>(changes)));
2018
+ result_obj.Set("lastInsertRowid",
2019
+ Napi::BigInt::New(env, static_cast<int64_t>(last_rowid)));
2020
+ } else if (last_rowid > JS_MAX_SAFE_INTEGER ||
2021
+ last_rowid < JS_MIN_SAFE_INTEGER) {
2022
+ // Use JavaScript's safe integer limits (2^53 - 1)
2023
+ result_obj.Set("changes", Napi::Number::New(env, changes));
1601
2024
  result_obj.Set("lastInsertRowid",
1602
2025
  Napi::BigInt::New(env, static_cast<int64_t>(last_rowid)));
1603
2026
  } else {
2027
+ result_obj.Set("changes", Napi::Number::New(env, changes));
1604
2028
  result_obj.Set("lastInsertRowid",
1605
2029
  Napi::Number::New(env, static_cast<double>(last_rowid)));
1606
2030
  }
@@ -1646,16 +2070,26 @@ Napi::Value StatementSync::Get(const Napi::CallbackInfo &info) {
1646
2070
  int result = sqlite3_step(statement_);
1647
2071
 
1648
2072
  if (result == SQLITE_ROW) {
1649
- return CreateResult();
2073
+ Napi::Value value = CreateResult();
2074
+ // Reset statement after fetching result to release locks (like Node.js
2075
+ // OnScopeLeave)
2076
+ sqlite3_reset(statement_);
2077
+ return value;
1650
2078
  } else if (result == SQLITE_DONE) {
2079
+ // Reset statement to release locks even when no rows returned
2080
+ sqlite3_reset(statement_);
1651
2081
  return env.Undefined();
1652
2082
  } else {
2083
+ // Reset statement before throwing to release locks
2084
+ sqlite3_reset(statement_);
1653
2085
  std::string error = sqlite3_errmsg(database_->connection());
1654
2086
  ThrowEnhancedSqliteErrorWithDB(env, database_, database_->connection(),
1655
2087
  result, error);
1656
2088
  return env.Undefined();
1657
2089
  }
1658
2090
  } catch (const std::exception &e) {
2091
+ // Reset statement on exception to release locks
2092
+ sqlite3_reset(statement_);
1659
2093
  ThrowErrSqliteErrorWithDb(env, database_, e.what());
1660
2094
  return env.Undefined();
1661
2095
  }
@@ -1697,8 +2131,12 @@ Napi::Value StatementSync::All(const Napi::CallbackInfo &info) {
1697
2131
  if (result == SQLITE_ROW) {
1698
2132
  results.Set(index++, CreateResult());
1699
2133
  } else if (result == SQLITE_DONE) {
2134
+ // Reset statement to release locks (like Node.js OnScopeLeave)
2135
+ sqlite3_reset(statement_);
1700
2136
  break;
1701
2137
  } else {
2138
+ // Reset statement before throwing to release locks
2139
+ sqlite3_reset(statement_);
1702
2140
  std::string error = sqlite3_errmsg(database_->connection());
1703
2141
  node::THROW_ERR_SQLITE_ERROR(env, error.c_str());
1704
2142
  return env.Undefined();
@@ -1707,6 +2145,8 @@ Napi::Value StatementSync::All(const Napi::CallbackInfo &info) {
1707
2145
 
1708
2146
  return results;
1709
2147
  } catch (const std::exception &e) {
2148
+ // Reset statement on exception to release locks
2149
+ sqlite3_reset(statement_);
1710
2150
  node::THROW_ERR_SQLITE_ERROR(env, e.what());
1711
2151
  return env.Undefined();
1712
2152
  }
@@ -1902,13 +2342,9 @@ StatementSync::SetAllowUnknownNamedParameters(const Napi::CallbackInfo &info) {
1902
2342
  Napi::Value StatementSync::Columns(const Napi::CallbackInfo &info) {
1903
2343
  Napi::Env env = info.Env();
1904
2344
 
1905
- if (finalized_) {
1906
- node::THROW_ERR_INVALID_STATE(env, "The statement has been finalized");
1907
- return env.Undefined();
1908
- }
1909
-
1910
- if (!database_ || !database_->IsOpen()) {
1911
- node::THROW_ERR_INVALID_STATE(env, "Database connection is closed");
2345
+ // When database is closed, statement is implicitly finalized by SQLite
2346
+ if (finalized_ || !database_ || !database_->IsOpen()) {
2347
+ node::THROW_ERR_INVALID_STATE(env, "statement has been finalized");
1912
2348
  return env.Undefined();
1913
2349
  }
1914
2350
 
@@ -1921,7 +2357,7 @@ Napi::Value StatementSync::Columns(const Napi::CallbackInfo &info) {
1921
2357
  Napi::Array columns = Napi::Array::New(env, column_count);
1922
2358
 
1923
2359
  for (int i = 0; i < column_count; i++) {
1924
- Napi::Object column_info = Napi::Object::New(env);
2360
+ Napi::Object column_info = CreateObjectWithNullPrototype(env);
1925
2361
 
1926
2362
  // column: The original column name (sqlite3_column_origin_name)
1927
2363
  const char *origin_name = sqlite3_column_origin_name(statement_, i);
@@ -1989,11 +2425,18 @@ void StatementSync::BindParameters(const Napi::CallbackInfo &info,
1989
2425
  return;
1990
2426
  }
1991
2427
 
1992
- // Check if we have a single object for named parameters
1993
- if (info.Length() == start_index + 1 && info[start_index].IsObject() &&
1994
- !info[start_index].IsBuffer() && !info[start_index].IsArray()) {
1995
- // Named parameters binding
2428
+ // Track where positional parameters start
2429
+ size_t positional_start = start_index;
2430
+
2431
+ // Check if first argument is an object for named parameters
2432
+ // (not a Buffer, TypedArray, or Array - those are positional values)
2433
+ if (info.Length() > start_index && info[start_index].IsObject() &&
2434
+ !info[start_index].IsBuffer() && !info[start_index].IsArray() &&
2435
+ !info[start_index].IsTypedArray()) {
2436
+ // Named parameters binding from the object
1996
2437
  Napi::Object obj = info[start_index].As<Napi::Object>();
2438
+ positional_start =
2439
+ start_index + 1; // Positional args start after the object
1997
2440
 
1998
2441
  // Build bare named params map if needed
1999
2442
  if (allow_bare_named_params_ && !bare_named_params_.has_value()) {
@@ -2046,13 +2489,11 @@ void StatementSync::BindParameters(const Napi::CallbackInfo &info,
2046
2489
 
2047
2490
  if (param_index > 0) {
2048
2491
  Napi::Value value = obj.Get(key_str);
2049
- try {
2050
- BindSingleParameter(param_index, value);
2051
- } catch (const Napi::Error &e) {
2052
- // Re-throw with parameter info
2053
- std::string msg =
2054
- "Error binding parameter '" + key_str + "': " + e.Message();
2055
- node::THROW_ERR_INVALID_ARG_VALUE(env, msg.c_str());
2492
+ BindSingleParameter(param_index, value);
2493
+ // Check for pending exceptions set by THROW_ERR_* macros
2494
+ // (they use ThrowAsJavaScriptException which doesn't throw C++
2495
+ // exceptions)
2496
+ if (env.IsExceptionPending()) {
2056
2497
  return;
2057
2498
  }
2058
2499
  } else {
@@ -2063,24 +2504,41 @@ void StatementSync::BindParameters(const Napi::CallbackInfo &info,
2063
2504
  } else {
2064
2505
  // Throw error when not allowed (default behavior)
2065
2506
  std::string msg = "Unknown named parameter '" + key_str + "'";
2066
- node::THROW_ERR_INVALID_ARG_VALUE(env, msg.c_str());
2507
+ node::THROW_ERR_INVALID_STATE(env, msg.c_str());
2067
2508
  return;
2068
2509
  }
2069
2510
  }
2070
2511
  }
2071
- } else {
2072
- // Positional parameters binding
2073
- for (size_t i = start_index; i < info.Length(); i++) {
2074
- int param_index = static_cast<int>(i - start_index + 1);
2075
- try {
2076
- BindSingleParameter(param_index, info[i]);
2077
- } catch (const Napi::Error &e) {
2078
- // Re-throw with parameter info
2079
- std::string msg = "Error binding parameter " +
2080
- std::to_string(param_index) + ": " + e.Message();
2081
- node::THROW_ERR_INVALID_ARG_VALUE(env, msg.c_str());
2512
+ }
2513
+
2514
+ // Bind remaining positional parameters to anonymous placeholders (?)
2515
+ // This handles both: (a) all args are positional, (b) first arg was named
2516
+ // params object and remaining are positional
2517
+ if (positional_start < info.Length()) {
2518
+ int anon_idx = 1;
2519
+ int param_count = sqlite3_bind_parameter_count(statement_);
2520
+
2521
+ for (size_t i = positional_start; i < info.Length(); i++) {
2522
+ // Skip to the next anonymous placeholder (unnamed or ?NNN)
2523
+ while (anon_idx <= param_count) {
2524
+ const char *param_name =
2525
+ sqlite3_bind_parameter_name(statement_, anon_idx);
2526
+ // Anonymous placeholders have nullptr name or start with '?'
2527
+ if (param_name == nullptr || param_name[0] == '?') {
2528
+ break;
2529
+ }
2530
+ anon_idx++;
2531
+ }
2532
+
2533
+ // When anon_idx > param_count, SQLite will return SQLITE_RANGE
2534
+ // and we'll throw an appropriate ERR_SQLITE_ERROR
2535
+
2536
+ BindSingleParameter(anon_idx, info[i]);
2537
+ // Check for pending exceptions set by THROW_ERR_* macros
2538
+ if (env.IsExceptionPending()) {
2082
2539
  return;
2083
2540
  }
2541
+ anon_idx++;
2084
2542
  }
2085
2543
  }
2086
2544
  }
@@ -2091,41 +2549,49 @@ void StatementSync::BindSingleParameter(int param_index, Napi::Value param) {
2091
2549
  return; // Silent return since error was already thrown by caller
2092
2550
  }
2093
2551
 
2552
+ int rc = SQLITE_OK;
2553
+
2094
2554
  try {
2095
- if (param.IsNull() || param.IsUndefined()) {
2096
- sqlite3_bind_null(statement_, param_index);
2555
+ if (param.IsNull()) {
2556
+ rc = sqlite3_bind_null(statement_, param_index);
2557
+ } else if (param.IsUndefined()) {
2558
+ // Node.js throws for undefined (unlike null which binds as SQL NULL)
2559
+ node::THROW_ERR_INVALID_ARG_TYPE(
2560
+ Env(), ("Provided value cannot be bound to SQLite parameter " +
2561
+ std::to_string(param_index) + ".")
2562
+ .c_str());
2563
+ return;
2097
2564
  } else if (param.IsBigInt()) {
2098
2565
  // Handle BigInt before IsNumber since BigInt values should bind as int64
2099
2566
  bool lossless;
2100
2567
  int64_t bigint_val = param.As<Napi::BigInt>().Int64Value(&lossless);
2101
2568
  if (lossless) {
2102
- sqlite3_bind_int64(statement_, param_index,
2103
- static_cast<sqlite3_int64>(bigint_val));
2569
+ rc = sqlite3_bind_int64(statement_, param_index,
2570
+ static_cast<sqlite3_int64>(bigint_val));
2104
2571
  } else {
2105
- // BigInt too large, convert to text
2106
- std::string bigint_str =
2107
- param.As<Napi::BigInt>().ToString().Utf8Value();
2108
- sqlite3_bind_text(statement_, param_index, bigint_str.c_str(), -1,
2109
- SQLITE_TRANSIENT);
2572
+ // BigInt too large for SQLite int64 - throw error (matches Node.js)
2573
+ node::THROW_ERR_INVALID_ARG_VALUE(Env(),
2574
+ "BigInt value is too large to bind.");
2575
+ return;
2110
2576
  }
2111
2577
  } else if (param.IsNumber()) {
2112
2578
  double val = param.As<Napi::Number>().DoubleValue();
2113
2579
  if (std::abs(val - std::floor(val)) <
2114
2580
  std::numeric_limits<double>::epsilon() &&
2115
2581
  val >= INT32_MIN && val <= INT32_MAX) {
2116
- sqlite3_bind_int(statement_, param_index,
2117
- param.As<Napi::Number>().Int32Value());
2582
+ rc = sqlite3_bind_int(statement_, param_index,
2583
+ param.As<Napi::Number>().Int32Value());
2118
2584
  } else {
2119
- sqlite3_bind_double(statement_, param_index,
2120
- param.As<Napi::Number>().DoubleValue());
2585
+ rc = sqlite3_bind_double(statement_, param_index,
2586
+ param.As<Napi::Number>().DoubleValue());
2121
2587
  }
2122
2588
  } else if (param.IsString()) {
2123
2589
  std::string str = param.As<Napi::String>().Utf8Value();
2124
- sqlite3_bind_text(statement_, param_index, str.c_str(), -1,
2125
- SQLITE_TRANSIENT);
2590
+ rc = sqlite3_bind_text(statement_, param_index, str.c_str(), -1,
2591
+ SQLITE_TRANSIENT);
2126
2592
  } else if (param.IsBoolean()) {
2127
- sqlite3_bind_int(statement_, param_index,
2128
- param.As<Napi::Boolean>().Value() ? 1 : 0);
2593
+ rc = sqlite3_bind_int(statement_, param_index,
2594
+ param.As<Napi::Boolean>().Value() ? 1 : 0);
2129
2595
  } else if (param.IsDataView()) {
2130
2596
  // IMPORTANT: Check DataView BEFORE IsBuffer() because N-API's IsBuffer()
2131
2597
  // returns true for ALL ArrayBufferViews (including DataView), but
@@ -2134,47 +2600,66 @@ void StatementSync::BindSingleParameter(int param_index, Napi::Value param) {
2134
2600
  Napi::ArrayBuffer arrayBuffer = dataView.ArrayBuffer();
2135
2601
  size_t byteOffset = dataView.ByteOffset();
2136
2602
  size_t byteLength = dataView.ByteLength();
2137
-
2603
+ // Use non-NULL pointer for zero-length blobs to preserve BLOB vs NULL
2604
+ // distinction. See: https://sqlite.org/c3ref/bind_blob.html
2605
+ const void *data = nullptr;
2138
2606
  if (arrayBuffer.Data() != nullptr && byteLength > 0) {
2139
- const uint8_t *data =
2140
- static_cast<const uint8_t *>(arrayBuffer.Data()) + byteOffset;
2141
- sqlite3_bind_blob(statement_, param_index, data,
2142
- SafeCastToInt(byteLength), SQLITE_TRANSIENT);
2143
- } else {
2144
- sqlite3_bind_null(statement_, param_index);
2607
+ data = static_cast<const uint8_t *>(arrayBuffer.Data()) + byteOffset;
2145
2608
  }
2609
+ rc = sqlite3_bind_blob(statement_, param_index, data ? data : "",
2610
+ SafeCastToInt(byteLength), SQLITE_TRANSIENT);
2146
2611
  } else if (param.IsBuffer()) {
2147
2612
  // Handles Buffer and TypedArray (both are ArrayBufferViews that work
2148
2613
  // correctly with Buffer cast - Buffer::Data() handles byte offsets
2149
2614
  // internally)
2150
2615
  Napi::Buffer<uint8_t> buffer = param.As<Napi::Buffer<uint8_t>>();
2151
- sqlite3_bind_blob(statement_, param_index, buffer.Data(),
2152
- SafeCastToInt(buffer.Length()), SQLITE_TRANSIENT);
2616
+ // Use non-NULL pointer for zero-length blobs to preserve BLOB vs NULL
2617
+ // distinction. See: https://sqlite.org/c3ref/bind_blob.html
2618
+ const void *data = buffer.Data();
2619
+ rc = sqlite3_bind_blob(statement_, param_index, data ? data : "",
2620
+ SafeCastToInt(buffer.Length()), SQLITE_TRANSIENT);
2153
2621
  } else if (param.IsFunction()) {
2154
- // Functions cannot be stored in SQLite - bind as NULL
2155
- sqlite3_bind_null(statement_, param_index);
2622
+ // Functions cannot be bound to SQLite parameters - throw error
2623
+ node::THROW_ERR_INVALID_ARG_TYPE(
2624
+ Env(), ("Provided value cannot be bound to SQLite parameter " +
2625
+ std::to_string(param_index) + ".")
2626
+ .c_str());
2627
+ return;
2156
2628
  } else if (param.IsArrayBuffer()) {
2157
2629
  // Handle ArrayBuffer as binary data
2158
2630
  Napi::ArrayBuffer arrayBuffer = param.As<Napi::ArrayBuffer>();
2159
- if (!arrayBuffer.IsEmpty() && arrayBuffer.Data() != nullptr) {
2160
- sqlite3_bind_blob(statement_, param_index, arrayBuffer.Data(),
2161
- SafeCastToInt(arrayBuffer.ByteLength()),
2162
- SQLITE_TRANSIENT);
2163
- } else {
2164
- sqlite3_bind_null(statement_, param_index);
2165
- }
2631
+ // Use non-NULL pointer for zero-length blobs to preserve BLOB vs NULL
2632
+ // distinction. See: https://sqlite.org/c3ref/bind_blob.html
2633
+ const void *data = arrayBuffer.Data();
2634
+ rc = sqlite3_bind_blob(statement_, param_index, data ? data : "",
2635
+ SafeCastToInt(arrayBuffer.ByteLength()),
2636
+ SQLITE_TRANSIENT);
2166
2637
  } else if (param.IsObject()) {
2167
2638
  // Objects and arrays cannot be bound to SQLite parameters (same as
2168
2639
  // Node.js behavior). Note: DataView, Buffer, TypedArray, and ArrayBuffer
2169
2640
  // are handled above and don't reach this branch.
2170
- throw Napi::Error::New(
2171
- Env(), "Provided value cannot be bound to SQLite parameter " +
2172
- std::to_string(param_index) + ".");
2641
+ node::THROW_ERR_INVALID_ARG_TYPE(
2642
+ Env(), ("Provided value cannot be bound to SQLite parameter " +
2643
+ std::to_string(param_index) + ".")
2644
+ .c_str());
2645
+ return;
2173
2646
  } else {
2174
- // For any other type, throw error like Node.js does
2175
- throw Napi::Error::New(
2176
- Env(), "Provided value cannot be bound to SQLite parameter " +
2177
- std::to_string(param_index) + ".");
2647
+ // For any other type (Symbol, etc.), throw error like Node.js does
2648
+ node::THROW_ERR_INVALID_ARG_TYPE(
2649
+ Env(), ("Provided value cannot be bound to SQLite parameter " +
2650
+ std::to_string(param_index) + ".")
2651
+ .c_str());
2652
+ return;
2653
+ }
2654
+
2655
+ // Check the result of sqlite3_bind_*
2656
+ if (rc != SQLITE_OK) {
2657
+ sqlite3 *db_handle = database_ ? database_->connection() : nullptr;
2658
+ // Get the error message from SQLite
2659
+ const char *err_msg = sqlite3_errstr(rc);
2660
+ node::ThrowEnhancedSqliteError(Env(), db_handle, rc,
2661
+ err_msg ? err_msg : "SQLite error");
2662
+ return;
2178
2663
  }
2179
2664
  } catch (const Napi::Error &e) {
2180
2665
  // Re-throw Napi errors
@@ -2220,8 +2705,15 @@ Napi::Value StatementSync::CreateResult() {
2220
2705
  value = Napi::BigInt::New(env, static_cast<int64_t>(int_val));
2221
2706
  } else if (int_val > JS_MAX_SAFE_INTEGER ||
2222
2707
  int_val < JS_MIN_SAFE_INTEGER) {
2223
- // Return BigInt for values outside JavaScript's safe integer range
2224
- value = Napi::BigInt::New(env, static_cast<int64_t>(int_val));
2708
+ // Throw ERR_OUT_OF_RANGE for values outside safe integer range
2709
+ // (matches Node.js behavior)
2710
+ char error_msg[128];
2711
+ snprintf(error_msg, sizeof(error_msg),
2712
+ "Value is too large to be represented as a JavaScript "
2713
+ "number: %" PRId64,
2714
+ static_cast<int64_t>(int_val));
2715
+ node::THROW_ERR_OUT_OF_RANGE(env, error_msg);
2716
+ return env.Undefined();
2225
2717
  } else {
2226
2718
  value = Napi::Number::New(env, static_cast<double>(int_val));
2227
2719
  }
@@ -2244,12 +2736,15 @@ Napi::Value StatementSync::CreateResult() {
2244
2736
  const void *blob_data = sqlite3_column_blob(statement_, i);
2245
2737
  int blob_size = sqlite3_column_bytes(statement_, i);
2246
2738
  // sqlite3_column_blob() can return NULL for zero-length BLOBs or on OOM
2739
+ // Return Uint8Array to match Node.js node:sqlite behavior
2247
2740
  if (!blob_data || blob_size == 0) {
2248
- // Handle empty/NULL blob - create empty buffer
2249
- value = Napi::Buffer<uint8_t>::New(env, 0);
2741
+ // Handle empty/NULL blob - create empty Uint8Array
2742
+ auto array_buffer = Napi::ArrayBuffer::New(env, 0);
2743
+ value = Napi::Uint8Array::New(env, 0, array_buffer, 0);
2250
2744
  } else {
2251
- value = Napi::Buffer<uint8_t>::Copy(
2252
- env, static_cast<const uint8_t *>(blob_data), blob_size);
2745
+ auto array_buffer = Napi::ArrayBuffer::New(env, blob_size);
2746
+ memcpy(array_buffer.Data(), blob_data, blob_size);
2747
+ value = Napi::Uint8Array::New(env, blob_size, array_buffer, 0);
2253
2748
  }
2254
2749
  break;
2255
2750
  }
@@ -2263,8 +2758,8 @@ Napi::Value StatementSync::CreateResult() {
2263
2758
 
2264
2759
  return result;
2265
2760
  } else {
2266
- // Return result as object (default behavior)
2267
- Napi::Object result = Napi::Object::New(env);
2761
+ // Return result as object with null prototype (matches Node.js behavior)
2762
+ Napi::Object result = CreateObjectWithNullPrototype(env);
2268
2763
 
2269
2764
  for (int i = 0; i < column_count; i++) {
2270
2765
  const char *column_name = sqlite3_column_name(statement_, i);
@@ -2283,8 +2778,15 @@ Napi::Value StatementSync::CreateResult() {
2283
2778
  value = Napi::BigInt::New(env, static_cast<int64_t>(int_val));
2284
2779
  } else if (int_val > JS_MAX_SAFE_INTEGER ||
2285
2780
  int_val < JS_MIN_SAFE_INTEGER) {
2286
- // Return BigInt for values outside JavaScript's safe integer range
2287
- value = Napi::BigInt::New(env, static_cast<int64_t>(int_val));
2781
+ // Throw ERR_OUT_OF_RANGE for values outside safe integer range
2782
+ // (matches Node.js behavior)
2783
+ char error_msg[128];
2784
+ snprintf(error_msg, sizeof(error_msg),
2785
+ "Value is too large to be represented as a JavaScript "
2786
+ "number: %" PRId64,
2787
+ static_cast<int64_t>(int_val));
2788
+ node::THROW_ERR_OUT_OF_RANGE(env, error_msg);
2789
+ return env.Undefined();
2288
2790
  } else {
2289
2791
  value = Napi::Number::New(env, static_cast<double>(int_val));
2290
2792
  }
@@ -2307,12 +2809,15 @@ Napi::Value StatementSync::CreateResult() {
2307
2809
  const void *blob_data = sqlite3_column_blob(statement_, i);
2308
2810
  int blob_size = sqlite3_column_bytes(statement_, i);
2309
2811
  // sqlite3_column_blob() can return NULL for zero-length BLOBs or on OOM
2812
+ // Return Uint8Array to match Node.js node:sqlite behavior
2310
2813
  if (!blob_data || blob_size == 0) {
2311
- // Handle empty/NULL blob - create empty buffer
2312
- value = Napi::Buffer<uint8_t>::New(env, 0);
2814
+ // Handle empty/NULL blob - create empty Uint8Array
2815
+ auto array_buffer = Napi::ArrayBuffer::New(env, 0);
2816
+ value = Napi::Uint8Array::New(env, 0, array_buffer, 0);
2313
2817
  } else {
2314
- value = Napi::Buffer<uint8_t>::Copy(
2315
- env, static_cast<const uint8_t *>(blob_data), blob_size);
2818
+ auto array_buffer = Napi::ArrayBuffer::New(env, blob_size);
2819
+ memcpy(array_buffer.Data(), blob_data, blob_size);
2820
+ value = Napi::Uint8Array::New(env, blob_size, array_buffer, 0);
2316
2821
  }
2317
2822
  break;
2318
2823
  }
@@ -2346,7 +2851,8 @@ Napi::Object StatementSyncIterator::Init(Napi::Env env, Napi::Object exports) {
2346
2851
  Napi::Function func =
2347
2852
  DefineClass(env, "StatementSyncIterator",
2348
2853
  {InstanceMethod("next", &StatementSyncIterator::Next),
2349
- InstanceMethod("return", &StatementSyncIterator::Return)});
2854
+ InstanceMethod("return", &StatementSyncIterator::Return),
2855
+ InstanceMethod("toArray", &StatementSyncIterator::ToArray)});
2350
2856
 
2351
2857
  // Set up Symbol.iterator on the prototype to make it properly iterable
2352
2858
  Napi::Object prototype = func.Get("prototype").As<Napi::Object>();
@@ -2358,6 +2864,20 @@ Napi::Object StatementSyncIterator::Init(Napi::Env env, Napi::Object exports) {
2358
2864
  return info.This();
2359
2865
  }));
2360
2866
 
2867
+ // Make our iterator inherit from Iterator.prototype so it's an instanceof
2868
+ // Iterator and gets Iterator Helper methods (map, filter, etc.)
2869
+ Napi::Object global = env.Global();
2870
+ Napi::Value iteratorValue = global.Get("Iterator");
2871
+ if (iteratorValue.IsFunction()) {
2872
+ Napi::Object iteratorProto =
2873
+ iteratorValue.As<Napi::Function>().Get("prototype").As<Napi::Object>();
2874
+ // Use Object.setPrototypeOf to set Iterator.prototype as prototype
2875
+ Napi::Object objectCtor = global.Get("Object").As<Napi::Object>();
2876
+ Napi::Function setPrototypeOf =
2877
+ objectCtor.Get("setPrototypeOf").As<Napi::Function>();
2878
+ setPrototypeOf.Call({prototype, iteratorProto});
2879
+ }
2880
+
2361
2881
  // Store constructor in per-instance addon data instead of static variable
2362
2882
  AddonData *addon_data = GetAddonData(env);
2363
2883
  if (addon_data) {
@@ -2408,7 +2928,7 @@ Napi::Value StatementSyncIterator::Next(const Napi::CallbackInfo &info) {
2408
2928
  }
2409
2929
 
2410
2930
  if (done_) {
2411
- Napi::Object result = Napi::Object::New(env);
2931
+ Napi::Object result = CreateObjectWithNullPrototype(env);
2412
2932
  result.Set("done", true);
2413
2933
  result.Set("value", env.Null());
2414
2934
  return result;
@@ -2427,7 +2947,7 @@ Napi::Value StatementSyncIterator::Next(const Napi::CallbackInfo &info) {
2427
2947
  sqlite3_reset(stmt_->statement_);
2428
2948
  done_ = true;
2429
2949
 
2430
- Napi::Object result = Napi::Object::New(env);
2950
+ Napi::Object result = CreateObjectWithNullPrototype(env);
2431
2951
  result.Set("done", true);
2432
2952
  result.Set("value", env.Null());
2433
2953
  return result;
@@ -2436,7 +2956,7 @@ Napi::Value StatementSyncIterator::Next(const Napi::CallbackInfo &info) {
2436
2956
  // Create row object using existing CreateResult method
2437
2957
  Napi::Value row_value = stmt_->CreateResult();
2438
2958
 
2439
- Napi::Object result = Napi::Object::New(env);
2959
+ Napi::Object result = CreateObjectWithNullPrototype(env);
2440
2960
  result.Set("done", false);
2441
2961
  result.Set("value", row_value);
2442
2962
  return result;
@@ -2459,12 +2979,58 @@ Napi::Value StatementSyncIterator::Return(const Napi::CallbackInfo &info) {
2459
2979
  sqlite3_reset(stmt_->statement_);
2460
2980
  done_ = true;
2461
2981
 
2462
- Napi::Object result = Napi::Object::New(env);
2982
+ Napi::Object result = CreateObjectWithNullPrototype(env);
2463
2983
  result.Set("done", true);
2464
2984
  result.Set("value", env.Null());
2465
2985
  return result;
2466
2986
  }
2467
2987
 
2988
+ Napi::Value StatementSyncIterator::ToArray(const Napi::CallbackInfo &info) {
2989
+ Napi::Env env = info.Env();
2990
+
2991
+ if (!stmt_ || stmt_->finalized_) {
2992
+ node::THROW_ERR_INVALID_STATE(env, "statement has been finalized");
2993
+ return env.Undefined();
2994
+ }
2995
+
2996
+ if (!stmt_->database_ || !stmt_->database_->IsOpen()) {
2997
+ node::THROW_ERR_INVALID_STATE(env, "Database connection is closed");
2998
+ return env.Undefined();
2999
+ }
3000
+
3001
+ Napi::Array arr = Napi::Array::New(env);
3002
+ uint32_t idx = 0;
3003
+
3004
+ while (!done_) {
3005
+ int r = sqlite3_step(stmt_->statement_);
3006
+
3007
+ if (r != SQLITE_ROW) {
3008
+ if (r != SQLITE_DONE) {
3009
+ node::THROW_ERR_SQLITE_ERROR(
3010
+ env, sqlite3_errmsg(stmt_->database_->connection()));
3011
+ return env.Undefined();
3012
+ }
3013
+
3014
+ // End of results
3015
+ sqlite3_reset(stmt_->statement_);
3016
+ done_ = true;
3017
+ break;
3018
+ }
3019
+
3020
+ // Create row object using existing CreateResult method
3021
+ Napi::Value row_value = stmt_->CreateResult();
3022
+
3023
+ // Check if CreateResult threw an error
3024
+ if (env.IsExceptionPending()) {
3025
+ return env.Undefined();
3026
+ }
3027
+
3028
+ arr.Set(idx++, row_value);
3029
+ }
3030
+
3031
+ return arr;
3032
+ }
3033
+
2468
3034
  // Session Implementation
2469
3035
  Napi::Object Session::Init(Napi::Env env, Napi::Object exports) {
2470
3036
  Napi::Function func =
@@ -2537,11 +3103,10 @@ void Session::Delete() {
2537
3103
 
2538
3104
  // Remove ourselves from the database's session list BEFORE deleting
2539
3105
  // to avoid any potential issues with the database trying to access us
2540
- DatabaseSync *database = database_;
2541
- database_ = nullptr;
2542
-
2543
- if (database) {
2544
- database->RemoveSession(this);
3106
+ // Note: Keep database_ non-null so we can check if db is open vs session
3107
+ // closed
3108
+ if (database_) {
3109
+ database_->RemoveSession(this);
2545
3110
  }
2546
3111
 
2547
3112
  // Now it's safe to delete the SQLite session
@@ -2552,12 +3117,20 @@ template <int (*sqliteChangesetFunc)(sqlite3_session *, int *, void **)>
2552
3117
  Napi::Value Session::GenericChangeset(const Napi::CallbackInfo &info) {
2553
3118
  Napi::Env env = info.Env();
2554
3119
 
3120
+ // Check database first - if db was closed, that's the primary error
3121
+ // Note: database_ is preserved in Delete(), so we can check IsOpen()
3122
+ if (database_ && !database_->IsOpen()) {
3123
+ node::THROW_ERR_INVALID_STATE(env, "database is not open");
3124
+ return env.Undefined();
3125
+ }
3126
+
3127
+ // Then check if session was explicitly closed
2555
3128
  if (session_ == nullptr) {
2556
3129
  node::THROW_ERR_INVALID_STATE(env, "session is not open");
2557
3130
  return env.Undefined();
2558
3131
  }
2559
3132
 
2560
- if (!database_ || !database_->IsOpen()) {
3133
+ if (!database_) {
2561
3134
  node::THROW_ERR_INVALID_STATE(env, "database is not open");
2562
3135
  return env.Undefined();
2563
3136
  }
@@ -2567,21 +3140,24 @@ Napi::Value Session::GenericChangeset(const Napi::CallbackInfo &info) {
2567
3140
  int r = sqliteChangesetFunc(session_, &nChangeset, &pChangeset);
2568
3141
 
2569
3142
  if (r != SQLITE_OK) {
2570
- const char *errMsg = sqlite3_errmsg(database_->connection());
3143
+ // Use sqlite3_errstr(r) to get a description of the error code,
3144
+ // rather than sqlite3_errmsg() which returns the last error on the
3145
+ // connection (which may not be related to this session operation)
3146
+ const char *errStr = sqlite3_errstr(r);
2571
3147
  Napi::Error::New(env,
2572
- std::string("Failed to generate changeset: ") + errMsg)
3148
+ std::string("Failed to generate changeset: ") + errStr)
2573
3149
  .ThrowAsJavaScriptException();
2574
3150
  return env.Undefined();
2575
3151
  }
2576
3152
 
2577
- // Create a Buffer from the changeset data
2578
- Napi::Buffer<uint8_t> buffer = Napi::Buffer<uint8_t>::New(env, nChangeset);
2579
- std::memcpy(buffer.Data(), pChangeset, nChangeset);
3153
+ // Create a Uint8Array from the changeset data (matches node:sqlite API)
3154
+ Napi::ArrayBuffer arrayBuffer = Napi::ArrayBuffer::New(env, nChangeset);
3155
+ std::memcpy(arrayBuffer.Data(), pChangeset, nChangeset);
2580
3156
 
2581
3157
  // Free the changeset allocated by SQLite
2582
3158
  sqlite3_free(pChangeset);
2583
3159
 
2584
- return buffer;
3160
+ return Napi::Uint8Array::New(env, nChangeset, arrayBuffer, 0);
2585
3161
  }
2586
3162
 
2587
3163
  Napi::Value Session::Changeset(const Napi::CallbackInfo &info) {
@@ -2595,11 +3171,24 @@ Napi::Value Session::Patchset(const Napi::CallbackInfo &info) {
2595
3171
  Napi::Value Session::Close(const Napi::CallbackInfo &info) {
2596
3172
  Napi::Env env = info.Env();
2597
3173
 
3174
+ // Check database first - if db was closed, that's the primary error
3175
+ // Note: database_ is preserved in Delete(), so we can check IsOpen()
3176
+ if (database_ && !database_->IsOpen()) {
3177
+ node::THROW_ERR_INVALID_STATE(env, "database is not open");
3178
+ return env.Undefined();
3179
+ }
3180
+
3181
+ // Then check if session was explicitly closed
2598
3182
  if (session_ == nullptr) {
2599
3183
  node::THROW_ERR_INVALID_STATE(env, "session is not open");
2600
3184
  return env.Undefined();
2601
3185
  }
2602
3186
 
3187
+ if (!database_) {
3188
+ node::THROW_ERR_INVALID_STATE(env, "database is not open");
3189
+ return env.Undefined();
3190
+ }
3191
+
2603
3192
  Delete();
2604
3193
  return env.Undefined();
2605
3194
  }
@@ -2622,6 +3211,14 @@ std::atomic<int> BackupJob::active_jobs_(0);
2622
3211
  std::mutex BackupJob::active_jobs_mutex_;
2623
3212
  std::set<BackupJob *> BackupJob::active_job_instances_;
2624
3213
 
3214
+ void BackupJob::CleanupHook(void *arg) {
3215
+ // Called before environment teardown - safe to Reset() references here
3216
+ auto *self = static_cast<BackupJob *>(arg);
3217
+ if (!self->progress_func_.IsEmpty()) {
3218
+ self->progress_func_.Reset();
3219
+ }
3220
+ }
3221
+
2625
3222
  // BackupJob Implementation
2626
3223
  BackupJob::BackupJob(Napi::Env env, DatabaseSync *source,
2627
3224
  std::string destination_path, std::string source_db,
@@ -2632,16 +3229,42 @@ BackupJob::BackupJob(Napi::Env env, DatabaseSync *source,
2632
3229
  !progress_func.IsEmpty() && !progress_func.IsUndefined()
2633
3230
  ? progress_func
2634
3231
  : Napi::Function::New(env, [](const Napi::CallbackInfo &) {})),
2635
- source_(source), destination_path_(std::move(destination_path)),
3232
+ source_(source),
3233
+ // Capture connection pointer now while we know it's valid
3234
+ // This prevents use-after-free if database is closed during backup
3235
+ source_connection_(source->connection()),
3236
+ destination_path_(std::move(destination_path)),
2636
3237
  source_db_(std::move(source_db)), dest_db_(std::move(dest_db)),
2637
- pages_(pages), deferred_(deferred) {
3238
+ pages_(pages), deferred_(deferred), env_(env) {
2638
3239
  if (!progress_func.IsEmpty() && !progress_func.IsUndefined()) {
2639
3240
  progress_func_ = Napi::Reference<Napi::Function>::New(progress_func);
2640
3241
  }
3242
+
3243
+ // Register cleanup hook to Reset() reference before environment teardown.
3244
+ // This is required for worker thread support per Node-API best practices.
3245
+ // See:
3246
+ // https://nodejs.github.io/node-addon-examples/special-topics/context-awareness/
3247
+ napi_add_env_cleanup_hook(env_, CleanupHook, this);
3248
+
2641
3249
  active_jobs_++;
3250
+ // Register with database for proper cleanup coordination
3251
+ source_->AddBackup(this);
2642
3252
  }
2643
3253
 
2644
- BackupJob::~BackupJob() { active_jobs_--; }
3254
+ BackupJob::~BackupJob() {
3255
+ // Remove cleanup hook if still registered
3256
+ napi_remove_env_cleanup_hook(env_, CleanupHook, this);
3257
+
3258
+ // Don't call progress_func_.Reset() here - CleanupHook already handled it,
3259
+ // or the environment is being torn down and Reset() would be unsafe.
3260
+
3261
+ active_jobs_--;
3262
+ // Unregister from database
3263
+ // Note: source_ may be null if FinalizeBackups was called
3264
+ if (source_) {
3265
+ source_->RemoveBackup(this);
3266
+ }
3267
+ }
2645
3268
 
2646
3269
  void BackupJob::Execute(const ExecutionProgress &progress) {
2647
3270
  // This method is executed on a worker thread, not the main thread
@@ -2654,48 +3277,53 @@ void BackupJob::Execute(const ExecutionProgress &progress) {
2654
3277
  SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_URI, nullptr);
2655
3278
 
2656
3279
  if (backup_status_ != SQLITE_OK) {
2657
- SetError("Failed to open destination database");
3280
+ // Use SQLite's actual error message
3281
+ SetError(sqlite3_errmsg(dest_));
2658
3282
  return;
2659
3283
  }
2660
3284
 
2661
- // Initialize backup
2662
- backup_ = sqlite3_backup_init(dest_, dest_db_.c_str(), source_->connection(),
3285
+ // Initialize backup using the connection pointer captured at construction
3286
+ // This prevents use-after-free if database is closed during backup
3287
+ backup_ = sqlite3_backup_init(dest_, dest_db_.c_str(), source_connection_,
2663
3288
  source_db_.c_str());
2664
3289
 
2665
3290
  if (!backup_) {
2666
- SetError("Failed to initialize backup");
3291
+ // Use SQLite's actual error message from destination db
3292
+ // (sqlite3_backup_init errors are stored in the dest db handle)
3293
+ SetError(sqlite3_errmsg(dest_));
2667
3294
  return;
2668
3295
  }
2669
3296
 
2670
3297
  // Initial page count may be 0 until first step
2671
- int remaining_pages = sqlite3_backup_remaining(backup_);
2672
3298
  total_pages_ = 0; // Will be updated after first step
3299
+ bool is_first_step = true;
2673
3300
 
2674
- while ((remaining_pages > 0 || total_pages_ == 0) &&
2675
- backup_status_ == SQLITE_OK) {
3301
+ while (backup_status_ == SQLITE_OK) {
2676
3302
  // If pages_ is negative, use -1 to copy all remaining pages
2677
3303
  int pages_to_copy = pages_ < 0 ? -1 : pages_;
2678
3304
  backup_status_ = sqlite3_backup_step(backup_, pages_to_copy);
2679
3305
 
2680
3306
  // Update total pages after first step (when SQLite knows the actual count)
2681
- if (total_pages_ == 0) {
3307
+ if (is_first_step) {
2682
3308
  total_pages_ = sqlite3_backup_pagecount(backup_);
3309
+ is_first_step = false;
2683
3310
  }
2684
3311
 
2685
- if (backup_status_ == SQLITE_OK || backup_status_ == SQLITE_DONE) {
2686
- remaining_pages = sqlite3_backup_remaining(backup_);
3312
+ if (backup_status_ == SQLITE_OK) {
3313
+ // More steps remaining - send progress update
3314
+ int remaining_pages = sqlite3_backup_remaining(backup_);
2687
3315
  int current_page = total_pages_ - remaining_pages;
2688
3316
 
2689
3317
  // Send progress update to main thread
2690
- if (!progress_func_.IsEmpty() && total_pages_ > 0) {
3318
+ // Node.js only calls progress when there are still pages remaining
3319
+ if (!progress_func_.IsEmpty() && total_pages_ > 0 &&
3320
+ remaining_pages > 0) {
2691
3321
  BackupProgress prog = {current_page, total_pages_};
2692
3322
  progress.Send(&prog, 1);
2693
3323
  }
2694
-
2695
- // Check if we're done
2696
- if (backup_status_ == SQLITE_DONE) {
2697
- break;
2698
- }
3324
+ } else if (backup_status_ == SQLITE_DONE) {
3325
+ // Backup complete - don't send progress for remaining:0
3326
+ break;
2699
3327
  } else if (backup_status_ == SQLITE_BUSY ||
2700
3328
  backup_status_ == SQLITE_LOCKED) {
2701
3329
  // These are retryable errors - continue
@@ -2708,15 +3336,14 @@ void BackupJob::Execute(const ExecutionProgress &progress) {
2708
3336
 
2709
3337
  // Store final status for use in OnOK/OnError
2710
3338
  if (backup_status_ != SQLITE_DONE) {
2711
- std::string error = "Backup failed with SQLite error: ";
2712
- error += sqlite3_errmsg(dest_);
2713
- SetError(error);
3339
+ // Use SQLite's actual error message (matches Node.js behavior)
3340
+ SetError(sqlite3_errmsg(dest_));
2714
3341
  }
2715
3342
  }
2716
3343
 
2717
3344
  void BackupJob::OnProgress(const BackupProgress *data, size_t count) {
2718
3345
  // This runs on the main thread
2719
- if (!progress_func_.IsEmpty() && count > 0) {
3346
+ if (!progress_func_.IsEmpty() && count > 0 && !progress_error_.has_value()) {
2720
3347
  Napi::HandleScope scope(Env());
2721
3348
  Napi::Function progress_fn = progress_func_.Value();
2722
3349
  Napi::Object progress_info = Napi::Object::New(Env());
@@ -2726,8 +3353,12 @@ void BackupJob::OnProgress(const BackupProgress *data, size_t count) {
2726
3353
 
2727
3354
  try {
2728
3355
  progress_fn.Call(Env().Null(), {progress_info});
3356
+ } catch (const Napi::Error &e) {
3357
+ // Capture error from progress callback - backup should fail with this
3358
+ progress_error_ = e.Message();
2729
3359
  } catch (...) {
2730
- // Ignore errors in progress callback
3360
+ // Unknown error
3361
+ progress_error_ = "Unknown error in progress callback";
2731
3362
  }
2732
3363
  }
2733
3364
  }
@@ -2739,6 +3370,13 @@ void BackupJob::OnOK() {
2739
3370
  // Cleanup SQLite resources
2740
3371
  Cleanup();
2741
3372
 
3373
+ // If progress callback threw an error, reject with that error
3374
+ if (progress_error_.has_value()) {
3375
+ Napi::Error error = Napi::Error::New(Env(), *progress_error_);
3376
+ deferred_.Reject(error.Value());
3377
+ return;
3378
+ }
3379
+
2742
3380
  // Resolve the promise with the total number of pages
2743
3381
  deferred_.Resolve(Napi::Number::New(Env(), total_pages_));
2744
3382
  }
@@ -2764,7 +3402,19 @@ void BackupJob::OnError(const Napi::Error &error) {
2764
3402
 
2765
3403
  // Use saved values for error details (matching node:sqlite property names)
2766
3404
  if (saved_status != SQLITE_OK && saved_status != SQLITE_DONE) {
2767
- Napi::Error detailed_error = Napi::Error::New(Env(), error.Message());
3405
+ // Prefer the detailed error message from sqlite3_errmsg(dest_) if it's
3406
+ // useful, otherwise fall back to sqlite3_errstr(status) which is the
3407
+ // generic message. sqlite3_errmsg can return "not an error" if the error
3408
+ // wasn't stored in dest.
3409
+ std::string err_message;
3410
+ if (!saved_errmsg.empty() && saved_errmsg != "not an error") {
3411
+ err_message = saved_errmsg;
3412
+ } else {
3413
+ err_message = sqlite3_errstr(saved_status);
3414
+ }
3415
+
3416
+ Napi::Error detailed_error = Napi::Error::New(Env(), err_message);
3417
+ detailed_error.Set("code", Napi::String::New(Env(), "ERR_SQLITE_ERROR"));
2768
3418
  detailed_error.Set("errcode", Napi::Number::New(Env(), saved_status));
2769
3419
  detailed_error.Set("errstr",
2770
3420
  Napi::String::New(Env(), sqlite3_errstr(saved_status)));
@@ -2790,29 +3440,24 @@ void BackupJob::Cleanup() {
2790
3440
  }
2791
3441
 
2792
3442
  // DatabaseSync::Backup implementation
3443
+ // Note: Validation errors are thrown synchronously (matching Node.js behavior).
3444
+ // Only the actual backup operation returns a promise.
2793
3445
  Napi::Value DatabaseSync::Backup(const Napi::CallbackInfo &info) {
2794
3446
  Napi::Env env = info.Env();
2795
3447
 
2796
- // Create a promise early for error handling
2797
- Napi::Promise::Deferred deferred = Napi::Promise::Deferred::New(env);
2798
-
3448
+ // Validation errors throw synchronously (matching Node.js behavior)
2799
3449
  if (!IsOpen()) {
2800
- deferred.Reject(Napi::Error::New(env, "database is not open").Value());
2801
- return deferred.Promise();
2802
- }
2803
-
2804
- if (info.Length() < 1) {
2805
- deferred.Reject(
2806
- Napi::TypeError::New(env, "The \"destination\" argument is required")
2807
- .Value());
2808
- return deferred.Promise();
3450
+ node::THROW_ERR_INVALID_STATE(env, "database is not open");
3451
+ return env.Undefined();
2809
3452
  }
2810
3453
 
2811
- std::optional<std::string> destination_path =
2812
- ValidateDatabasePath(env, info[0], "destination");
2813
- if (!destination_path.has_value()) {
2814
- deferred.Reject(Napi::Error::New(env, "Invalid destination path").Value());
2815
- return deferred.Promise();
3454
+ // ValidateDatabasePath throws synchronously with ERR_INVALID_ARG_TYPE
3455
+ // Use "path" as argument name to match Node.js
3456
+ std::optional<std::string> dest_path =
3457
+ ValidateDatabasePath(env, info[0], "path");
3458
+ if (!dest_path.has_value()) {
3459
+ // Exception already thrown by ValidateDatabasePath
3460
+ return env.Undefined();
2816
3461
  }
2817
3462
 
2818
3463
  // Default options matching Node.js API
@@ -2824,10 +3469,9 @@ Napi::Value DatabaseSync::Backup(const Napi::CallbackInfo &info) {
2824
3469
  // Parse options if provided
2825
3470
  if (info.Length() > 1) {
2826
3471
  if (!info[1].IsObject()) {
2827
- deferred.Reject(Napi::TypeError::New(
2828
- env, "The \"options\" argument must be an object")
2829
- .Value());
2830
- return deferred.Promise();
3472
+ node::THROW_ERR_INVALID_ARG_TYPE(
3473
+ env, "The \"options\" argument must be an object.");
3474
+ return env.Undefined();
2831
3475
  }
2832
3476
 
2833
3477
  Napi::Object options = info[1].As<Napi::Object>();
@@ -2835,11 +3479,17 @@ Napi::Value DatabaseSync::Backup(const Napi::CallbackInfo &info) {
2835
3479
  // Get rate option (number of pages per step)
2836
3480
  Napi::Value rate_value = options.Get("rate");
2837
3481
  if (!rate_value.IsUndefined()) {
3482
+ // Check if it's a number and an integer (not fractional)
2838
3483
  if (!rate_value.IsNumber()) {
2839
- deferred.Reject(
2840
- Napi::TypeError::New(env, "The \"options.rate\" must be a number")
2841
- .Value());
2842
- return deferred.Promise();
3484
+ node::THROW_ERR_INVALID_ARG_TYPE(
3485
+ env, "The \"options.rate\" argument must be an integer.");
3486
+ return env.Undefined();
3487
+ }
3488
+ double rate_double = rate_value.As<Napi::Number>().DoubleValue();
3489
+ if (rate_double != std::trunc(rate_double)) {
3490
+ node::THROW_ERR_INVALID_ARG_TYPE(
3491
+ env, "The \"options.rate\" argument must be an integer.");
3492
+ return env.Undefined();
2843
3493
  }
2844
3494
  rate = rate_value.As<Napi::Number>().Int32Value();
2845
3495
  // Note: Node.js allows negative values for rate
@@ -2849,10 +3499,9 @@ Napi::Value DatabaseSync::Backup(const Napi::CallbackInfo &info) {
2849
3499
  Napi::Value source_value = options.Get("source");
2850
3500
  if (!source_value.IsUndefined()) {
2851
3501
  if (!source_value.IsString()) {
2852
- deferred.Reject(
2853
- Napi::TypeError::New(env, "The \"options.source\" must be a string")
2854
- .Value());
2855
- return deferred.Promise();
3502
+ node::THROW_ERR_INVALID_ARG_TYPE(
3503
+ env, "The \"options.source\" argument must be a string.");
3504
+ return env.Undefined();
2856
3505
  }
2857
3506
  source_db = source_value.As<Napi::String>().Utf8Value();
2858
3507
  }
@@ -2861,10 +3510,9 @@ Napi::Value DatabaseSync::Backup(const Napi::CallbackInfo &info) {
2861
3510
  Napi::Value target_value = options.Get("target");
2862
3511
  if (!target_value.IsUndefined()) {
2863
3512
  if (!target_value.IsString()) {
2864
- deferred.Reject(
2865
- Napi::TypeError::New(env, "The \"options.target\" must be a string")
2866
- .Value());
2867
- return deferred.Promise();
3513
+ node::THROW_ERR_INVALID_ARG_TYPE(
3514
+ env, "The \"options.target\" argument must be a string.");
3515
+ return env.Undefined();
2868
3516
  }
2869
3517
  target_db = target_value.As<Napi::String>().Utf8Value();
2870
3518
  }
@@ -2873,19 +3521,21 @@ Napi::Value DatabaseSync::Backup(const Napi::CallbackInfo &info) {
2873
3521
  Napi::Value progress_value = options.Get("progress");
2874
3522
  if (!progress_value.IsUndefined()) {
2875
3523
  if (!progress_value.IsFunction()) {
2876
- deferred.Reject(Napi::TypeError::New(
2877
- env, "The \"options.progress\" must be a function")
2878
- .Value());
2879
- return deferred.Promise();
3524
+ node::THROW_ERR_INVALID_ARG_TYPE(
3525
+ env, "The \"options.progress\" argument must be a function.");
3526
+ return env.Undefined();
2880
3527
  }
2881
3528
  progress_func = progress_value.As<Napi::Function>();
2882
3529
  }
2883
3530
  }
2884
3531
 
3532
+ // Create promise for async backup operation
3533
+ Napi::Promise::Deferred deferred = Napi::Promise::Deferred::New(env);
3534
+
2885
3535
  // Create and schedule backup job
2886
- BackupJob *job = new BackupJob(env, this, std::move(destination_path).value(),
2887
- std::move(source_db), std::move(target_db),
2888
- rate, progress_func, deferred);
3536
+ BackupJob *job =
3537
+ new BackupJob(env, this, std::move(*dest_path), std::move(source_db),
3538
+ std::move(target_db), rate, progress_func, deferred);
2889
3539
 
2890
3540
  // Queue the async work - AsyncWorker will delete itself when complete
2891
3541
  job->Queue();
@@ -2990,7 +3640,7 @@ int DatabaseSync::AuthorizerCallback(void *user_data, int action_code,
2990
3640
  if (int_result != SQLITE_OK && int_result != SQLITE_DENY &&
2991
3641
  int_result != SQLITE_IGNORE) {
2992
3642
  db->SetDeferredAuthorizerException(
2993
- "Authorizer callback returned an invalid authorization code");
3643
+ "Authorizer callback returned a invalid authorization code");
2994
3644
  db->SetIgnoreNextSQLiteError(true);
2995
3645
  return SQLITE_DENY;
2996
3646
  }