229 lines
9.7 KiB
TypeScript
229 lines
9.7 KiB
TypeScript
import { assert, describe, it } from "@effect/vitest"
|
|
import { Effect, Schema } from "effect"
|
|
import * as SqlError from "effect/unstable/sql/SqlError"
|
|
|
|
type ReasonCase = {
|
|
readonly tag: SqlError.SqlErrorReason["_tag"]
|
|
readonly isRetryable: boolean
|
|
readonly make: (args: {
|
|
readonly cause: unknown
|
|
readonly message?: string | undefined
|
|
readonly operation?: string | undefined
|
|
}) => SqlError.SqlErrorReason
|
|
}
|
|
|
|
const uniqueViolationConstraint = "users_email_key"
|
|
|
|
const assertUniqueViolation = (reason: SqlError.SqlErrorReason, constraint: string) => {
|
|
assert.strictEqual(reason._tag, "UniqueViolation")
|
|
if (reason._tag === "UniqueViolation") {
|
|
assert.strictEqual(reason.constraint, constraint)
|
|
}
|
|
}
|
|
|
|
const reasonCases = [
|
|
{ tag: "ConnectionError", isRetryable: true, make: (args) => new SqlError.ConnectionError(args) },
|
|
{ tag: "AuthenticationError", isRetryable: false, make: (args) => new SqlError.AuthenticationError(args) },
|
|
{ tag: "AuthorizationError", isRetryable: false, make: (args) => new SqlError.AuthorizationError(args) },
|
|
{ tag: "SqlSyntaxError", isRetryable: false, make: (args) => new SqlError.SqlSyntaxError(args) },
|
|
{
|
|
tag: "UniqueViolation",
|
|
isRetryable: false,
|
|
make: (args) => new SqlError.UniqueViolation({ ...args, constraint: uniqueViolationConstraint })
|
|
},
|
|
{ tag: "ConstraintError", isRetryable: false, make: (args) => new SqlError.ConstraintError(args) },
|
|
{ tag: "DeadlockError", isRetryable: true, make: (args) => new SqlError.DeadlockError(args) },
|
|
{ tag: "SerializationError", isRetryable: true, make: (args) => new SqlError.SerializationError(args) },
|
|
{ tag: "LockTimeoutError", isRetryable: true, make: (args) => new SqlError.LockTimeoutError(args) },
|
|
{ tag: "StatementTimeoutError", isRetryable: true, make: (args) => new SqlError.StatementTimeoutError(args) },
|
|
{ tag: "UnknownError", isRetryable: false, make: (args) => new SqlError.UnknownError(args) }
|
|
] as const satisfies ReadonlyArray<ReasonCase>
|
|
|
|
describe("SqlError", () => {
|
|
it("reason classes expose expected tags and retryability", () => {
|
|
for (const reasonCase of reasonCases) {
|
|
const reason = reasonCase.make({
|
|
cause: { tag: reasonCase.tag },
|
|
message: `${reasonCase.tag} message`,
|
|
operation: "execute"
|
|
})
|
|
|
|
assert.strictEqual(reason._tag, reasonCase.tag)
|
|
assert.strictEqual(reason.isRetryable, reasonCase.isRetryable)
|
|
assert.strictEqual(reason.message, `${reasonCase.tag} message`)
|
|
assert.strictEqual(reason.operation, "execute")
|
|
assert.deepStrictEqual(reason.cause, { tag: reasonCase.tag })
|
|
}
|
|
})
|
|
|
|
it("delegates message, cause and retryability for every reason type", () => {
|
|
for (const reasonCase of reasonCases) {
|
|
const withoutMessage = reasonCase.make({
|
|
cause: { tag: `${reasonCase.tag}-fallback` }
|
|
})
|
|
const withMessage = reasonCase.make({
|
|
cause: { tag: `${reasonCase.tag}-custom` },
|
|
message: `${reasonCase.tag} custom`,
|
|
operation: "execute"
|
|
})
|
|
const withEmptyMessage = reasonCase.make({
|
|
cause: { tag: `${reasonCase.tag}-empty` },
|
|
message: ""
|
|
})
|
|
const fallbackError = new SqlError.SqlError({ reason: withoutMessage })
|
|
const explicitMessageError = new SqlError.SqlError({ reason: withMessage })
|
|
const emptyMessageError = new SqlError.SqlError({ reason: withEmptyMessage })
|
|
|
|
assert.strictEqual(fallbackError.message, reasonCase.tag)
|
|
assert.strictEqual(fallbackError.cause, withoutMessage)
|
|
assert.strictEqual(fallbackError.isRetryable, reasonCase.isRetryable)
|
|
|
|
assert.strictEqual(explicitMessageError.message, `${reasonCase.tag} custom`)
|
|
assert.strictEqual(explicitMessageError.cause, withMessage)
|
|
assert.strictEqual(explicitMessageError.isRetryable, reasonCase.isRetryable)
|
|
|
|
assert.strictEqual(emptyMessageError.message, reasonCase.tag)
|
|
assert.strictEqual(emptyMessageError.cause, withEmptyMessage)
|
|
assert.strictEqual(emptyMessageError.isRetryable, reasonCase.isRetryable)
|
|
}
|
|
})
|
|
|
|
it("isSqlError only matches the SqlError wrapper", () => {
|
|
const reason = new SqlError.UnknownError({
|
|
cause: new Error("boom")
|
|
})
|
|
const error = new SqlError.SqlError({ reason })
|
|
const mismatch = new SqlError.ResultLengthMismatch({ expected: 1, actual: 0 })
|
|
|
|
assert.strictEqual(SqlError.isSqlError(error), true)
|
|
assert.strictEqual(SqlError.isSqlError(reason), false)
|
|
assert.strictEqual(SqlError.isSqlError(mismatch), false)
|
|
})
|
|
|
|
it("isSqlErrorReason only matches reason values", () => {
|
|
const reason = new SqlError.UnknownError({
|
|
cause: new Error("boom")
|
|
})
|
|
const error = new SqlError.SqlError({ reason })
|
|
|
|
assert.strictEqual(SqlError.isSqlErrorReason(reason), true)
|
|
assert.strictEqual(SqlError.isSqlErrorReason(error), false)
|
|
})
|
|
|
|
it("constructs and recognizes UniqueViolation reasons", () => {
|
|
const reason = new SqlError.UniqueViolation({
|
|
cause: { code: "23505" },
|
|
message: "duplicate key value violates unique constraint",
|
|
operation: "insert",
|
|
constraint: uniqueViolationConstraint
|
|
})
|
|
|
|
assert.strictEqual(SqlError.isSqlErrorReason(reason), true)
|
|
assert.strictEqual(reason._tag, "UniqueViolation")
|
|
assert.strictEqual(reason.isRetryable, false)
|
|
assert.strictEqual(reason.constraint, uniqueViolationConstraint)
|
|
})
|
|
|
|
it("classifySqliteError maps sqlite unique constraint codes to UniqueViolation", () => {
|
|
const byString = SqlError.classifySqliteError({
|
|
code: "SQLITE_CONSTRAINT_UNIQUE",
|
|
constraint: " " + uniqueViolationConstraint + " "
|
|
})
|
|
const byNumericCode = SqlError.classifySqliteError({ code: 2067, constraint: uniqueViolationConstraint })
|
|
const byErrno = SqlError.classifySqliteError({ errno: 2067, constraint: uniqueViolationConstraint })
|
|
const byExtendedErrno = SqlError.classifySqliteError({
|
|
code: 19,
|
|
errno: 2067,
|
|
constraint: uniqueViolationConstraint
|
|
})
|
|
const unknown = SqlError.classifySqliteError({ code: "NOT_SQLITE" })
|
|
|
|
assertUniqueViolation(byString, uniqueViolationConstraint)
|
|
assertUniqueViolation(byNumericCode, uniqueViolationConstraint)
|
|
assertUniqueViolation(byErrno, uniqueViolationConstraint)
|
|
assertUniqueViolation(byExtendedErrno, uniqueViolationConstraint)
|
|
assert.strictEqual(unknown._tag, "UnknownError")
|
|
})
|
|
|
|
it("classifySqliteError extracts sqlite unique descriptors from messages", () => {
|
|
const reason = SqlError.classifySqliteError({
|
|
code: "SQLITE_CONSTRAINT_UNIQUE",
|
|
message: "UNIQUE constraint failed: users.email"
|
|
})
|
|
|
|
assertUniqueViolation(reason, "users.email")
|
|
})
|
|
|
|
it("classifySqliteError prefers explicit sqlite unique constraint identifiers", () => {
|
|
const reason = SqlError.classifySqliteError({
|
|
code: "SQLITE_CONSTRAINT_UNIQUE",
|
|
constraint: " users_email_key ",
|
|
message: "UNIQUE constraint failed: users.email"
|
|
})
|
|
|
|
assertUniqueViolation(reason, "users_email_key")
|
|
})
|
|
|
|
it("classifySqliteError falls back to unknown for blank or missing sqlite unique identifiers", () => {
|
|
const blank = SqlError.classifySqliteError({
|
|
code: "SQLITE_CONSTRAINT_UNIQUE",
|
|
constraint: " ",
|
|
message: "UNIQUE constraint failed: users.email"
|
|
})
|
|
const missing = SqlError.classifySqliteError({ code: "SQLITE_CONSTRAINT_UNIQUE" })
|
|
const nonString = SqlError.classifySqliteError({ code: "SQLITE_CONSTRAINT_UNIQUE", constraint: 123 })
|
|
const malformedMessage = SqlError.classifySqliteError({
|
|
code: "SQLITE_CONSTRAINT_UNIQUE",
|
|
message: { text: "UNIQUE constraint failed: users.email" }
|
|
})
|
|
|
|
assertUniqueViolation(blank, "unknown")
|
|
assertUniqueViolation(missing, "unknown")
|
|
assertUniqueViolation(nonString, "unknown")
|
|
assertUniqueViolation(malformedMessage, "unknown")
|
|
})
|
|
|
|
it("classifySqliteError keeps generic sqlite constraints as ConstraintError", () => {
|
|
const byString = SqlError.classifySqliteError({ code: "SQLITE_CONSTRAINT" })
|
|
const byNumericCode = SqlError.classifySqliteError({ code: 19 })
|
|
const byErrno = SqlError.classifySqliteError({ errno: 19 })
|
|
const primaryKeyString = SqlError.classifySqliteError({ code: "SQLITE_CONSTRAINT_PRIMARYKEY" })
|
|
const primaryKeyNumber = SqlError.classifySqliteError({ code: 1555 })
|
|
|
|
assert.strictEqual(byString._tag, "ConstraintError")
|
|
assert.strictEqual(byNumericCode._tag, "ConstraintError")
|
|
assert.strictEqual(byErrno._tag, "ConstraintError")
|
|
assert.strictEqual(primaryKeyString._tag, "ConstraintError")
|
|
assert.strictEqual(primaryKeyNumber._tag, "ConstraintError")
|
|
})
|
|
|
|
for (const reasonCase of reasonCases) {
|
|
it.effect(`schema roundtrip for SqlError wrapping ${reasonCase.tag}`, () =>
|
|
Effect.gen(function*() {
|
|
const cause = { tag: reasonCase.tag }
|
|
const error = new SqlError.SqlError({
|
|
reason: reasonCase.make({
|
|
cause,
|
|
message: `${reasonCase.tag} message`,
|
|
operation: "execute"
|
|
})
|
|
})
|
|
|
|
const encoded = yield* Schema.encodeEffect(SqlError.SqlError)(error)
|
|
const decoded = yield* Schema.decodeEffect(SqlError.SqlError)(encoded)
|
|
|
|
assert.strictEqual(decoded._tag, "SqlError")
|
|
assert.strictEqual(decoded.reason._tag, reasonCase.tag)
|
|
assert.strictEqual(decoded.reason.message, `${reasonCase.tag} message`)
|
|
assert.strictEqual(decoded.reason.operation, "execute")
|
|
assert.deepStrictEqual(decoded.reason.cause, cause)
|
|
assert.strictEqual(decoded.message, `${reasonCase.tag} message`)
|
|
assert.strictEqual(decoded.isRetryable, reasonCase.isRetryable)
|
|
assert.strictEqual(decoded.cause, decoded.reason)
|
|
if (decoded.reason._tag === "UniqueViolation") {
|
|
assert.strictEqual(decoded.reason.constraint, uniqueViolationConstraint)
|
|
}
|
|
}))
|
|
}
|
|
})
|