| 1 | import { assert, describe, it } from "@effect/vitest" |
| 2 | import { Effect, Schema } from "effect" |
| 3 | import * as SqlError from "effect/sql/SqlError" |
| 4 | |
| 5 | type ReasonCase = { |
| 6 | readonly tag: SqlError.SqlErrorReason["_tag"] |
| 7 | readonly isRetryable: boolean |
| 8 | readonly make: (args: { |
| 9 | readonly cause: unknown |
| 10 | readonly message?: string | undefined |
| 11 | readonly operation?: string | undefined |
| 12 | }) => SqlError.SqlErrorReason |
| 13 | } |
| 14 | |
| 15 | const uniqueViolationConstraint = "users_email_key" |
| 16 | |
| 17 | const assertUniqueViolation = (reason: SqlError.SqlErrorReason, constraint: string) => { |
| 18 | assert.strictEqual(reason._tag, "UniqueViolation") |
| 19 | if (reason._tag === "UniqueViolation") { |
| 20 | assert.strictEqual(reason.constraint, constraint) |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | const reasonCases = [ |
| 25 | { tag: "ConnectionError", isRetryable: true, make: (args) => new SqlError.ConnectionError(args) }, |
| 26 | { tag: "AuthenticationError", isRetryable: false, make: (args) => new SqlError.AuthenticationError(args) }, |
| 27 | { tag: "AuthorizationError", isRetryable: false, make: (args) => new SqlError.AuthorizationError(args) }, |
| 28 | { tag: "SqlSyntaxError", isRetryable: false, make: (args) => new SqlError.SqlSyntaxError(args) }, |
| 29 | { |
| 30 | tag: "UniqueViolation", |
| 31 | isRetryable: false, |
| 32 | make: (args) => new SqlError.UniqueViolation({ ...args, constraint: uniqueViolationConstraint }) |
| 33 | }, |
| 34 | { tag: "ConstraintError", isRetryable: false, make: (args) => new SqlError.ConstraintError(args) }, |
| 35 | { tag: "DeadlockError", isRetryable: true, make: (args) => new SqlError.DeadlockError(args) }, |
| 36 | { tag: "SerializationError", isRetryable: true, make: (args) => new SqlError.SerializationError(args) }, |
| 37 | { tag: "LockTimeoutError", isRetryable: true, make: (args) => new SqlError.LockTimeoutError(args) }, |
| 38 | { tag: "StatementTimeoutError", isRetryable: true, make: (args) => new SqlError.StatementTimeoutError(args) }, |
| 39 | { tag: "UnknownError", isRetryable: false, make: (args) => new SqlError.UnknownError(args) } |
| 40 | ] as const satisfies ReadonlyArray<ReasonCase> |
| 41 | |
| 42 | describe("SqlError", () => { |
| 43 | it("reason classes expose expected tags and retryability", () => { |
| 44 | for (const reasonCase of reasonCases) { |
| 45 | const reason = reasonCase.make({ |
| 46 | cause: { tag: reasonCase.tag }, |
| 47 | message: `${reasonCase.tag} message`, |
| 48 | operation: "execute" |
| 49 | }) |
| 50 | |
| 51 | assert.strictEqual(reason._tag, reasonCase.tag) |
| 52 | assert.strictEqual(reason.isRetryable, reasonCase.isRetryable) |
| 53 | assert.strictEqual(reason.message, `${reasonCase.tag} message`) |
| 54 | assert.strictEqual(reason.operation, "execute") |
| 55 | assert.deepStrictEqual(reason.cause, { tag: reasonCase.tag }) |
| 56 | } |
| 57 | }) |
| 58 | |
| 59 | it("delegates message, cause and retryability for every reason type", () => { |
| 60 | for (const reasonCase of reasonCases) { |
| 61 | const withoutMessage = reasonCase.make({ |
| 62 | cause: { tag: `${reasonCase.tag}-fallback` } |
| 63 | }) |
| 64 | const withMessage = reasonCase.make({ |
| 65 | cause: { tag: `${reasonCase.tag}-custom` }, |
| 66 | message: `${reasonCase.tag} custom`, |
| 67 | operation: "execute" |
| 68 | }) |
| 69 | const withEmptyMessage = reasonCase.make({ |
| 70 | cause: { tag: `${reasonCase.tag}-empty` }, |
| 71 | message: "" |
| 72 | }) |
| 73 | const fallbackError = new SqlError.SqlError({ reason: withoutMessage }) |
| 74 | const explicitMessageError = new SqlError.SqlError({ reason: withMessage }) |
| 75 | const emptyMessageError = new SqlError.SqlError({ reason: withEmptyMessage }) |
| 76 | |
| 77 | assert.strictEqual(fallbackError.message, reasonCase.tag) |
| 78 | assert.strictEqual(fallbackError.cause, withoutMessage) |
| 79 | assert.strictEqual(fallbackError.isRetryable, reasonCase.isRetryable) |
| 80 | |
| 81 | assert.strictEqual(explicitMessageError.message, `${reasonCase.tag} custom`) |
| 82 | assert.strictEqual(explicitMessageError.cause, withMessage) |
| 83 | assert.strictEqual(explicitMessageError.isRetryable, reasonCase.isRetryable) |
| 84 | |
| 85 | assert.strictEqual(emptyMessageError.message, reasonCase.tag) |
| 86 | assert.strictEqual(emptyMessageError.cause, withEmptyMessage) |
| 87 | assert.strictEqual(emptyMessageError.isRetryable, reasonCase.isRetryable) |
| 88 | } |
| 89 | }) |
| 90 | |
| 91 | it("isSqlError only matches the SqlError wrapper", () => { |
| 92 | const reason = new SqlError.UnknownError({ |
| 93 | cause: new Error("boom") |
| 94 | }) |
| 95 | const error = new SqlError.SqlError({ reason }) |
| 96 | const mismatch = new SqlError.ResultLengthMismatch({ expected: 1, actual: 0 }) |
| 97 | |
| 98 | assert.strictEqual(SqlError.isSqlError(error), true) |
| 99 | assert.strictEqual(SqlError.isSqlError(reason), false) |
| 100 | assert.strictEqual(SqlError.isSqlError(mismatch), false) |
| 101 | }) |
| 102 | |
| 103 | it("isSqlErrorReason only matches reason values", () => { |
| 104 | const reason = new SqlError.UnknownError({ |
| 105 | cause: new Error("boom") |
| 106 | }) |
| 107 | const error = new SqlError.SqlError({ reason }) |
| 108 | |
| 109 | assert.strictEqual(SqlError.isSqlErrorReason(reason), true) |
| 110 | assert.strictEqual(SqlError.isSqlErrorReason(error), false) |
| 111 | }) |
| 112 | |
| 113 | it("constructs and recognizes UniqueViolation reasons", () => { |
| 114 | const reason = new SqlError.UniqueViolation({ |
| 115 | cause: { code: "23505" }, |
| 116 | message: "duplicate key value violates unique constraint", |
| 117 | operation: "insert", |
| 118 | constraint: uniqueViolationConstraint |
| 119 | }) |
| 120 | |
| 121 | assert.strictEqual(SqlError.isSqlErrorReason(reason), true) |
| 122 | assert.strictEqual(reason._tag, "UniqueViolation") |
| 123 | assert.strictEqual(reason.isRetryable, false) |
| 124 | assert.strictEqual(reason.constraint, uniqueViolationConstraint) |
| 125 | }) |
| 126 | |
| 127 | it("classifySqliteError maps sqlite unique constraint codes to UniqueViolation", () => { |
| 128 | const byString = SqlError.classifySqliteError({ |
| 129 | code: "SQLITE_CONSTRAINT_UNIQUE", |
| 130 | constraint: " " + uniqueViolationConstraint + " " |
| 131 | }) |
| 132 | const byNumericCode = SqlError.classifySqliteError({ code: 2067, constraint: uniqueViolationConstraint }) |
| 133 | const byErrno = SqlError.classifySqliteError({ errno: 2067, constraint: uniqueViolationConstraint }) |
| 134 | const byExtendedErrno = SqlError.classifySqliteError({ |
| 135 | code: 19, |
| 136 | errno: 2067, |
| 137 | constraint: uniqueViolationConstraint |
| 138 | }) |
| 139 | const unknown = SqlError.classifySqliteError({ code: "NOT_SQLITE" }) |
| 140 | |
| 141 | assertUniqueViolation(byString, uniqueViolationConstraint) |
| 142 | assertUniqueViolation(byNumericCode, uniqueViolationConstraint) |
| 143 | assertUniqueViolation(byErrno, uniqueViolationConstraint) |
| 144 | assertUniqueViolation(byExtendedErrno, uniqueViolationConstraint) |
| 145 | assert.strictEqual(unknown._tag, "UnknownError") |
| 146 | }) |
| 147 | |
| 148 | it("classifySqliteError extracts sqlite unique descriptors from messages", () => { |
| 149 | const reason = SqlError.classifySqliteError({ |
| 150 | code: "SQLITE_CONSTRAINT_UNIQUE", |
| 151 | message: "UNIQUE constraint failed: users.email" |
| 152 | }) |
| 153 | |
| 154 | assertUniqueViolation(reason, "users.email") |
| 155 | }) |
| 156 | |
| 157 | it("classifySqliteError prefers explicit sqlite unique constraint identifiers", () => { |
| 158 | const reason = SqlError.classifySqliteError({ |
| 159 | code: "SQLITE_CONSTRAINT_UNIQUE", |
| 160 | constraint: " users_email_key ", |
| 161 | message: "UNIQUE constraint failed: users.email" |
| 162 | }) |
| 163 | |
| 164 | assertUniqueViolation(reason, "users_email_key") |
| 165 | }) |
| 166 | |
| 167 | it("classifySqliteError falls back to unknown for blank or missing sqlite unique identifiers", () => { |
| 168 | const blank = SqlError.classifySqliteError({ |
| 169 | code: "SQLITE_CONSTRAINT_UNIQUE", |
| 170 | constraint: " ", |
| 171 | message: "UNIQUE constraint failed: users.email" |
| 172 | }) |
| 173 | const missing = SqlError.classifySqliteError({ code: "SQLITE_CONSTRAINT_UNIQUE" }) |
| 174 | const nonString = SqlError.classifySqliteError({ code: "SQLITE_CONSTRAINT_UNIQUE", constraint: 123 }) |
| 175 | const malformedMessage = SqlError.classifySqliteError({ |
| 176 | code: "SQLITE_CONSTRAINT_UNIQUE", |
| 177 | message: { text: "UNIQUE constraint failed: users.email" } |
| 178 | }) |
| 179 | |
| 180 | assertUniqueViolation(blank, "unknown") |
| 181 | assertUniqueViolation(missing, "unknown") |
| 182 | assertUniqueViolation(nonString, "unknown") |
| 183 | assertUniqueViolation(malformedMessage, "unknown") |
| 184 | }) |
| 185 | |
| 186 | it("classifySqliteError keeps generic sqlite constraints as ConstraintError", () => { |
| 187 | const byString = SqlError.classifySqliteError({ code: "SQLITE_CONSTRAINT" }) |
| 188 | const byNumericCode = SqlError.classifySqliteError({ code: 19 }) |
| 189 | const byErrno = SqlError.classifySqliteError({ errno: 19 }) |
| 190 | const primaryKeyString = SqlError.classifySqliteError({ code: "SQLITE_CONSTRAINT_PRIMARYKEY" }) |
| 191 | const primaryKeyNumber = SqlError.classifySqliteError({ code: 1555 }) |
| 192 | |
| 193 | assert.strictEqual(byString._tag, "ConstraintError") |
| 194 | assert.strictEqual(byNumericCode._tag, "ConstraintError") |
| 195 | assert.strictEqual(byErrno._tag, "ConstraintError") |
| 196 | assert.strictEqual(primaryKeyString._tag, "ConstraintError") |
| 197 | assert.strictEqual(primaryKeyNumber._tag, "ConstraintError") |
| 198 | }) |
| 199 | |
| 200 | for (const reasonCase of reasonCases) { |
| 201 | it.effect(`schema roundtrip for SqlError wrapping ${reasonCase.tag}`, () => |
| 202 | Effect.gen(function*() { |
| 203 | const cause = { tag: reasonCase.tag } |
| 204 | const error = new SqlError.SqlError({ |
| 205 | reason: reasonCase.make({ |
| 206 | cause, |
| 207 | message: `${reasonCase.tag} message`, |
| 208 | operation: "execute" |
| 209 | }) |
| 210 | }) |
| 211 | |
| 212 | const encoded = yield* Schema.encodeEffect(SqlError.SqlError)(error) |
| 213 | const decoded = yield* Schema.decodeEffect(SqlError.SqlError)(encoded) |
| 214 | |
| 215 | assert.strictEqual(decoded._tag, "SqlError") |
| 216 | assert.strictEqual(decoded.reason._tag, reasonCase.tag) |
| 217 | assert.strictEqual(decoded.reason.message, `${reasonCase.tag} message`) |
| 218 | assert.strictEqual(decoded.reason.operation, "execute") |
| 219 | assert.deepStrictEqual(decoded.reason.cause, cause) |
| 220 | assert.strictEqual(decoded.message, `${reasonCase.tag} message`) |
| 221 | assert.strictEqual(decoded.isRetryable, reasonCase.isRetryable) |
| 222 | assert.strictEqual(decoded.cause, decoded.reason) |
| 223 | if (decoded.reason._tag === "UniqueViolation") { |
| 224 | assert.strictEqual(decoded.reason.constraint, uniqueViolationConstraint) |
| 225 | } |
| 226 | })) |
| 227 | } |
| 228 | }) |