1import { assert, describe, it } from "@effect/vitest"
2import { Effect, Schema } from "effect"
3import * as SqlError from "effect/sql/SqlError"
4
5type 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
15const uniqueViolationConstraint = "users_email_key"
16
17const 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
24const 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
42describe("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})