All files / web/src/lib db-errors.ts

97.26% Statements 71/73
93.93% Branches 31/33
100% Functions 2/2
97.26% Lines 71/73

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 741x 1x 1x 1x 1x 1x 10x 10x 10x 10x 10x 10x 10x 15x 15x 15x 15x 7x 7x 7x 10x 10x 10x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 3x 1x 1x 1x 1x 1x 1x 1x 1x 1x 2x 2x 7x 10x 2x 2x 5x 10x     5x 5x 10x 2x 2x 3x 3x 3x  
/**
 * Walk the cause chain of a thrown error to find the deepest cause that has
 * a code and/or message. Drizzle wraps libsql errors which wrap sqlite errors,
 * so the actionable info ("FOREIGN KEY constraint failed", "SQLITE_CONSTRAINT_*")
 * is buried two levels down.
 */
function findRootCause(err: unknown): { code?: string; message: string } | null {
  let cur: unknown = err
  let rootCode: string | undefined
  let rootMessage = ''
  let depth = 0
 
  while (cur && typeof cur === 'object' && depth < 10) {
    const e = cur as { code?: unknown; message?: unknown; cause?: unknown }
    if (typeof e.code === 'string') rootCode = e.code
    if (typeof e.message === 'string') rootMessage = e.message
    if (!e.cause || e.cause === cur) break
    cur = e.cause
    depth++
  }
 
  return rootMessage ? { code: rootCode, message: rootMessage } : null
}
 
/**
 * Turn a thrown error from a database operation into a user-facing actionable
 * message. Recognizes common SQLite/libsql constraint violations and surfaces
 * what the user can do about them. Falls back to the original message when no
 * recognizer matches.
 *
 * Use this anywhere a caught error is about to be shown to a user, particularly
 * in admin/debug routes where a clear next step is more useful than an opaque
 * stack of wrapped errors.
 */
export function explainError(err: unknown): string {
  const topMessage = err instanceof Error ? err.message : String(err)
  const query =
    typeof (err as { query?: unknown })?.query === 'string' ? (err as { query: string }).query : ''
  const root = findRootCause(err)
 
  // Foreign key violation — by far the most common cause is a stale auth
  // session referencing a user.id that no longer exists after a local DB
  // reset. The SQLite error itself doesn't tell us which FK failed, so we
  // inspect the SQL for `user_id`.
  if (root?.code === 'SQLITE_CONSTRAINT_FOREIGNKEY') {
    if (/\buser_id\b/i.test(query) || /\buser_id\b/i.test(topMessage)) {
      return [
        'Your session is stale: it references a user that no longer exists in the local database',
        '(likely after a DB reset).',
        '',
        'Fix: sign out and sign back in. NextAuth will reissue the session against the current user record.',
      ]
        .join(' ')
        .replace(/ +/g, ' ')
    }
    return `Foreign key violation: a referenced row does not exist. (${root.message})`
  }
 
  if (root?.code === 'SQLITE_CONSTRAINT_UNIQUE') {
    return `Unique constraint violation: ${root.message}`
  }
 
  if (typeof root?.code === 'string' && root.code.startsWith('SQLITE_CONSTRAINT')) {
    return `Database constraint failed: ${root.message}`
  }
 
  // No recognizer matched. Append the root cause when it adds information.
  if (root?.message && root.message !== topMessage) {
    return `${topMessage}\nRoot cause: ${root.message}`
  }
 
  return topMessage
}