Skip to content

Commit df55b60

Browse files
fix(sentry): null-coalesce PostgREST details/hint to prevent TypeError (#945)
PostgREST 500 responses return `details: null` and `hint: null`. The `isPostgrestError` duck-type check uses the `in` operator which returns true when the key exists regardless of value type. Calling `null.startsWith()` or `null.includes()` in `isTransientNetworkError` and `isSupabaseAuthLockError` crashes the retry logic, turning a retryable Supabase 500 into an unhandled rejection. - Update `isPostgrestError` type guard to reflect nullable fields - Add `?? ""` null coalescing in isTransientNetworkError, isSupabaseAuthLockError, and captureSupabaseError - Add regression tests for null details/hint - Document null-safety convention in .agents/conventions.md Closes #944 Co-authored-by: Ona <no-reply@ona.com>
1 parent 4b845db commit df55b60

3 files changed

Lines changed: 57 additions & 5 deletions

File tree

.agents/conventions.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,19 @@ proper `Error` before sending to Sentry so they get proper stack traces and
254254
grouping. The `isPostgrestError` duck-type check in `src/lib/sentry.ts` also
255255
handles both shapes.
256256

257+
**Null-safety for PostgREST fields:** PostgREST error responses may return
258+
`details: null` and `hint: null` (e.g. PGRST500 server errors). The `in` operator
259+
in `isPostgrestError` returns `true` when the key exists regardless of value type.
260+
Always null-coalesce when reading these fields:
261+
262+
```typescript
263+
// ✅ Correct — handles null from PostgREST 500 responses
264+
const details = isPostgrestError(error) ? (error.details ?? "") : "";
265+
266+
// ❌ Wrong — crashes with TypeError: null.startsWith() / null.includes()
267+
const details = isPostgrestError(error) ? error.details : "";
268+
```
269+
257270
### Always use `captureApiError` for internal API fetch errors
258271

259272
Non-Supabase fetch calls (e.g. `/api/pages/…/versions`) must use `captureApiError`

src/lib/sentry.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export function isE2ETestRequest(event: ErrorEvent): boolean {
5656
*/
5757
function isPostgrestError(
5858
error: unknown,
59-
): error is { message: string; code: string; details: string; hint: string } {
59+
): error is { message: string; code: string; details: string | null; hint: string | null } {
6060
if (error == null || typeof error !== "object") return false;
6161
return (
6262
"message" in error &&
@@ -317,7 +317,8 @@ const NODE_FETCH_CAUSE_PATTERNS = [
317317
*/
318318
export function isSupabaseAuthLockError(error: Error): boolean {
319319
const msg = error.message;
320-
const details = isPostgrestError(error) ? error.details : "";
320+
// PostgREST 500 responses may have `details: null` — coalesce to ""
321+
const details = isPostgrestError(error) ? (error.details ?? "") : "";
321322

322323
return (
323324
msg.includes("Lock broken by another request") ||
@@ -429,7 +430,8 @@ export function isSupabaseAuthLockContention(event: ErrorEvent): boolean {
429430
*/
430431
export function isTransientNetworkError(error: Error): boolean {
431432
const msg = error.message;
432-
const details = isPostgrestError(error) ? error.details : "";
433+
// PostgREST 500 responses may have `details: null` — coalesce to ""
434+
const details = isPostgrestError(error) ? (error.details ?? "") : "";
433435

434436
// Browser-style fetch errors.
435437
// The Supabase client may append the hostname in parentheses, e.g.
@@ -548,8 +550,8 @@ export function captureSupabaseError(
548550

549551
if (isPostgrestError(error)) {
550552
extra.code = error.code;
551-
extra.details = error.details;
552-
extra.hint = error.hint;
553+
extra.details = error.details ?? "";
554+
extra.hint = error.hint ?? "";
553555
}
554556

555557
// Wrap plain objects in a proper Error for Sentry grouping

src/lib/sentry.unit.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,21 @@ describe("isTransientNetworkError", () => {
243243
});
244244
expect(isTransientNetworkError(error as unknown as Error)).toBe(true);
245245
});
246+
247+
// --- Null details regression test (MEMO-2E / #944) ---
248+
// PostgREST 500 responses return `details: null`. The `in` operator in
249+
// isPostgrestError passes (key exists), but `null.startsWith()` crashes.
250+
251+
it("does not crash when PostgREST error has details: null (#944 MEMO-2E)", () => {
252+
const error = { message: "server error", code: "XX000", details: null, hint: null };
253+
expect(() => isTransientNetworkError(error as unknown as Error)).not.toThrow();
254+
expect(isTransientNetworkError(error as unknown as Error)).toBe(false);
255+
});
256+
257+
it("still detects transient fetch error when details is null but message matches (#944 MEMO-2E)", () => {
258+
const error = { message: "TypeError: fetch failed", code: "XX000", details: null, hint: null };
259+
expect(isTransientNetworkError(error as unknown as Error)).toBe(true);
260+
});
246261
});
247262

248263
describe("isSchemaNotFoundError", () => {
@@ -605,6 +620,14 @@ describe("isSupabaseAuthLockError", () => {
605620
});
606621
expect(isSupabaseAuthLockError(error)).toBe(false);
607622
});
623+
624+
// --- Null details regression test (MEMO-2E / #944) ---
625+
626+
it("does not crash when PostgREST error has details: null (#944 MEMO-2E)", () => {
627+
const error = { message: "server error", code: "XX000", details: null, hint: null };
628+
expect(() => isSupabaseAuthLockError(error as unknown as Error)).not.toThrow();
629+
expect(isSupabaseAuthLockError(error as unknown as Error)).toBe(false);
630+
});
608631
});
609632

610633
describe("captureSupabaseError", () => {
@@ -894,6 +917,20 @@ describe("captureSupabaseError", () => {
894917
expect(opts.level).toBeUndefined();
895918
expect(opts.extra.code).toBe("PGRST500");
896919
});
920+
921+
// --- Null details/hint regression test (MEMO-2E / #944) ---
922+
923+
it("does not crash when PostgREST error has details: null and hint: null (#944 MEMO-2E)", async () => {
924+
const error = { message: "server error", code: "XX000", details: null, hint: null };
925+
expect(() => captureSupabaseError(error as unknown as Error, "page-tree:fetch-pages")).not.toThrow();
926+
await flush();
927+
928+
expect(captureExceptionMock).toHaveBeenCalledOnce();
929+
const [, opts] = captureExceptionMock.mock.calls[0];
930+
expect(opts.extra.details).toBe("");
931+
expect(opts.extra.hint).toBe("");
932+
expect(opts.extra.code).toBe("XX000");
933+
});
897934
});
898935

899936
function makeSentryEvent(

0 commit comments

Comments
 (0)