Skip to content

Commit dba3cbd

Browse files
committed
feat(docs): add documentation for fixing callback error message extraction in applyChangeset
1 parent cef4138 commit dba3cbd

File tree

1 file changed

+126
-0
lines changed

1 file changed

+126
-0
lines changed
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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

Comments
 (0)