|
| 1 | +# TPP: Fix Callback Error Message Extraction in applyChangeset |
| 2 | + |
| 3 | +## Goal Definition |
| 4 | + |
| 5 | +- **What Success Looks Like**: When JS callbacks in `applyChangeset` throw errors, the thrown value is preserved in the re-thrown error message |
| 6 | +- **Core Problem**: `throw "some error"` (primitive) becomes `"onConflict callback threw an exception"` instead of `"some error"` |
| 7 | +- **Key Constraints**: Must handle both `throw new Error("msg")` and `throw "primitive"` - match node:sqlite behavior |
| 8 | +- **Success Validation**: `npm run test:node -- --test-name-pattern="conflict resolution handler throws|filter handler throws"` |
| 9 | + |
| 10 | +## ✅ COMPLETED |
| 11 | + |
| 12 | +### Solution Summary |
| 13 | + |
| 14 | +The issue was a misunderstanding of how `Napi::Error::Value()` works with `GetAndClearPendingException()`. |
| 15 | + |
| 16 | +**Root Cause**: When JavaScript throws a primitive like `throw "string"`, `env.GetAndClearPendingException()` returns a `Napi::Error` where `err.Value()` IS the original primitive (a string), NOT an Error wrapper object. |
| 17 | + |
| 18 | +**The Fix**: When `Message()` is empty (primitives don't have `.message`), call `err.Value().ToString()` directly instead of trying to access `ERROR_WRAP_VALUE` property. |
| 19 | + |
| 20 | +The `ERROR_WRAP_VALUE` property only exists when you CATCH a `Napi::Error` as a C++ exception in a catch block - it's the node-addon-api mechanism for preserving primitives across the C++/JS boundary when exceptions are thrown. But when using `GetAndClearPendingException()`, the original value is already unwrapped. |
| 21 | + |
| 22 | +### Key Insight from node-addon-api |
| 23 | + |
| 24 | +From `node-addon-api/test/error.cc` line 306-310: |
| 25 | + |
| 26 | +```cpp |
| 27 | +Value CatchError(const CallbackInfo& info) { |
| 28 | + // ... |
| 29 | + if (env.IsExceptionPending()) { |
| 30 | + Error e = env.GetAndClearPendingException(); |
| 31 | + return e.Value(); // Returns ORIGINAL exception value |
| 32 | + } |
| 33 | +} |
| 34 | +``` |
| 35 | +
|
| 36 | +### Changes Made |
| 37 | +
|
| 38 | +**File**: `src/sqlite_impl.cpp` (lines 1589-1632) |
| 39 | +
|
| 40 | +```cpp |
| 41 | +static std::string GetErrorMessage(const Napi::Error &err, |
| 42 | + const char *fallback) { |
| 43 | + // Try 1: Message() works for Error objects with .message property |
| 44 | + try { |
| 45 | + std::string msg = err.Message(); |
| 46 | + if (!msg.empty()) { |
| 47 | + return msg; |
| 48 | + } |
| 49 | + } catch (...) {} |
| 50 | +
|
| 51 | + // Try 2: For primitives, err.Value() IS the original thrown value |
| 52 | + try { |
| 53 | + Napi::Value val = err.Value(); |
| 54 | + if (!val.IsEmpty() && !val.IsUndefined() && !val.IsNull()) { |
| 55 | + return val.ToString().Utf8Value(); |
| 56 | + } |
| 57 | + } catch (...) {} |
| 58 | +
|
| 59 | + // Try 3: ERROR_WRAP_VALUE property (for C++ catch blocks) |
| 60 | + try { |
| 61 | + Napi::Value val = err.Value(); |
| 62 | + if (val.IsObject()) { |
| 63 | + Napi::Object errObj = val.As<Napi::Object>(); |
| 64 | + static const char *ERROR_WRAP_VALUE = |
| 65 | + "4bda9e7e-4913-4dbc-95de-891cbf66598e-errorVal"; |
| 66 | + Napi::Value wrapped = errObj.Get(ERROR_WRAP_VALUE); |
| 67 | + if (!wrapped.IsUndefined()) { |
| 68 | + return wrapped.ToString().Utf8Value(); |
| 69 | + } |
| 70 | + } |
| 71 | + } catch (...) {} |
| 72 | +
|
| 73 | + return fallback; |
| 74 | +} |
| 75 | +``` |
| 76 | + |
| 77 | +## Validation Results |
| 78 | + |
| 79 | +All tests pass: |
| 80 | + |
| 81 | +```bash |
| 82 | +$ npm t -- --testNamePattern="string errors thrown" |
| 83 | +# PASS - 2 tests passing |
| 84 | + |
| 85 | +$ npm run test:node -- --test-name-pattern="conflict resolution handler throws|filter handler throws" |
| 86 | +# ✔ conflict resolution handler throws |
| 87 | +# ✔ filter handler throws |
| 88 | + |
| 89 | +$ npm t |
| 90 | +# 793 passed, 22 skipped |
| 91 | + |
| 92 | +$ npm run lint |
| 93 | +# 0 errors, 3 warnings |
| 94 | +``` |
| 95 | + |
| 96 | +### Completion checklist |
| 97 | + |
| 98 | +- [x] `throw new Error("msg")` preserves "msg" |
| 99 | +- [x] `throw "primitive"` preserves "primitive" |
| 100 | +- [x] `throw 42` preserves "42" |
| 101 | +- [x] No regressions: `npm t` passes |
| 102 | +- [x] Lint passes: `npm run lint` |
| 103 | + |
| 104 | +## Tribal Knowledge |
| 105 | + |
| 106 | +### ERROR_WRAP_VALUE vs GetAndClearPendingException |
| 107 | + |
| 108 | +- `ERROR_WRAP_VALUE` property is only relevant when catching `Napi::Error` as a C++ exception |
| 109 | +- When using `GetAndClearPendingException()`, `err.Value()` returns the original exception value directly |
| 110 | +- The documentation about primitive wrapping in `node-addon-api/doc/error_handling.md` describes a different code path |
| 111 | + |
| 112 | +### Why the Previous Implementation Failed |
| 113 | + |
| 114 | +The old code assumed `err.Value()` was always an object and tried to access properties on it: |
| 115 | + |
| 116 | +```cpp |
| 117 | +Napi::Object errObj = err.Value(); // FAILS if Value() is a primitive! |
| 118 | +``` |
| 119 | + |
| 120 | +When JavaScript throws `throw "string"`, `err.Value()` returns a Napi::Value that IS the string, not an object. Casting to Object silently fails. |
| 121 | + |
| 122 | +## References |
| 123 | + |
| 124 | +- `node-addon-api/test/error.cc:300-310` - CatchError example showing `e.Value()` returns original exception |
| 125 | +- `node-addon-api/doc/error_handling.md` - Documentation on exception handling |
| 126 | +- `src/upstream/node_sqlite.cc:1854-1862` - Node.js uses `TryCatch::ReThrow()` which preserves original exception |
0 commit comments