Skip to content

Commit 8967649

Browse files
committed
fix(realtime): unwrap cause for schema codes, drop sleep after final attempt
- isSchemaMismatch now walks the error.cause chain — drizzle wraps the driver error, so the SQLSTATE often lives on the inner cause, not the outer throw. Without this a wrapped 42703/42P01 was retried 5x and mis-reported as "database unreachable" instead of failing fast. - No longer sleeps after the final failed attempt (~6-10s of dead wait that undermined the fail-fast contract); sleep now only happens between attempts. - Tests: assert sleep is called exactly 4 times on exhaustion, and add a wrapped-cause fail-fast case.
1 parent 532b9ea commit 8967649

2 files changed

Lines changed: 34 additions & 2 deletions

File tree

apps/realtime/src/database/preflight.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ function pgError(code: string): Error & { code: string } {
3939
return Object.assign(new Error(`pg error ${code}`), { code })
4040
}
4141

42+
/** Mirrors how drizzle wraps the driver error: the SQLSTATE lives on `cause`, not the outer error. */
43+
function wrappedPgError(code: string): Error {
44+
return new Error('Failed query', { cause: pgError(code) })
45+
}
46+
4247
describe('assertSchemaCompatibility', () => {
4348
beforeEach(() => {
4449
vi.clearAllMocks()
@@ -69,6 +74,15 @@ describe('assertSchemaCompatibility', () => {
6974
expect(mockLimit).toHaveBeenCalledTimes(1)
7075
})
7176

77+
it('detects a schema mismatch wrapped in error.cause and fails fast', async () => {
78+
mockLimit.mockRejectedValue(wrappedPgError('42703'))
79+
80+
await expect(assertSchemaCompatibility()).rejects.toThrow(/incompatible with the live database/)
81+
82+
expect(mockLimit).toHaveBeenCalledTimes(1)
83+
expect(sleep).not.toHaveBeenCalled()
84+
})
85+
7286
it('retries transient connection errors and resolves once reachable', async () => {
7387
mockLimit
7488
.mockRejectedValueOnce(pgError('ECONNREFUSED'))
@@ -87,5 +101,6 @@ describe('assertSchemaCompatibility', () => {
87101
await expect(assertSchemaCompatibility()).rejects.toThrow(/database unreachable/)
88102

89103
expect(mockLimit).toHaveBeenCalledTimes(5)
104+
expect(sleep).toHaveBeenCalledTimes(4)
90105
})
91106
})

apps/realtime/src/database/preflight.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,22 @@ const MAX_CONNECT_ATTEMPTS = 5
2020
*/
2121
const SCHEMA_MISMATCH_CODES = new Set(['42703', '42P01', '42883'])
2222

23+
/**
24+
* Walks the `cause` chain so a SQLSTATE code is found even when drizzle wraps the
25+
* driver error (the code commonly lives on the inner `cause`, not the outer throw).
26+
*/
2327
function isSchemaMismatch(error: unknown): boolean {
24-
const code = (error as { code?: unknown })?.code
25-
return typeof code === 'string' && SCHEMA_MISMATCH_CODES.has(code)
28+
const seen = new Set<unknown>()
29+
let current: unknown = error
30+
while (current && typeof current === 'object' && !seen.has(current)) {
31+
seen.add(current)
32+
const code = (current as { code?: unknown }).code
33+
if (typeof code === 'string' && SCHEMA_MISMATCH_CODES.has(code)) {
34+
return true
35+
}
36+
current = (current as { cause?: unknown }).cause
37+
}
38+
return false
2639
}
2740

2841
/**
@@ -66,6 +79,10 @@ export async function assertSchemaCompatibility(): Promise<void> {
6679
)
6780
}
6881

82+
if (attempt === MAX_CONNECT_ATTEMPTS) {
83+
break
84+
}
85+
6986
const delay = backoffWithJitter(attempt, null)
7087
logger.warn(
7188
`Schema-compatibility check could not reach the database (attempt ${attempt}/${MAX_CONNECT_ATTEMPTS}), retrying in ${Math.round(delay)}ms`,

0 commit comments

Comments
 (0)