1/**
2 * Defines structured failures for SQL clients and driver integrations.
3 *
4 * `SqlError` wraps the different reasons a SQL operation can fail, such as
5 * connection, authentication, authorization, syntax, constraint, or transaction
6 * problems. Each reason keeps the original cause, optional message and
7 * operation metadata, and whether retrying may succeed. This module also
8 * includes schemas, guards, a SQLite error classifier, and the
9 * `ResultLengthMismatch` error used by ordered batched SQL resolvers.
10 *
11 * @stability unstable
12 * @since 4.0.0
13 */
14import * as Predicate from "../Predicate.ts"
15import * as Schema from "../Schema.ts"
16
17const TypeId = "~effect/sql/SqlError" as const
18const ReasonTypeId = "~effect/sql/SqlError/Reason" as const
19
20const ReasonFields = {
21 cause: Schema.Defect(),
22 message: Schema.optional(Schema.String),
23 operation: Schema.optional(Schema.String)
24}
25
26/**
27 * SQL error reason for connection or open failures; marked retryable.
28 *
29 * @stability unstable
30 * @category errors
31 * @since 4.0.0
32 */
33export class ConnectionError extends Schema.TaggedError<ConnectionError>("effect/sql/SqlError/ConnectionError")(
34 "ConnectionError",
35 ReasonFields
36) {
37 /**
38 * Marks this value as a structured SQL error reason for runtime guards.
39 *
40 * @stability unstable
41 * @since 4.0.0
42 */
43 readonly [ReasonTypeId] = ReasonTypeId
44
45 /**
46 * Indicates whether retrying the failed SQL operation may succeed.
47 *
48 * @stability unstable
49 * @since 4.0.0
50 */
51 get isRetryable(): boolean {
52 return true
53 }
54}
55
56/**
57 * SQL error reason for authentication failures such as invalid credentials; not
58 * marked retryable.
59 *
60 * @stability unstable
61 * @category errors
62 * @since 4.0.0
63 */
64export class AuthenticationError extends Schema.TaggedError<AuthenticationError>(
65 "effect/sql/SqlError/AuthenticationError"
66)("AuthenticationError", ReasonFields) {
67 /**
68 * Marks this value as a structured SQL error reason for runtime guards.
69 *
70 * @stability unstable
71 * @since 4.0.0
72 */
73 readonly [ReasonTypeId] = ReasonTypeId
74
75 /**
76 * Indicates whether retrying the failed SQL operation may succeed.
77 *
78 * @stability unstable
79 * @since 4.0.0
80 */
81 get isRetryable(): boolean {
82 return false
83 }
84}
85
86/**
87 * SQL error reason for authorization or permission failures; not marked
88 * retryable.
89 *
90 * @stability unstable
91 * @category errors
92 * @since 4.0.0
93 */
94export class AuthorizationError extends Schema.TaggedError<AuthorizationError>(
95 "effect/sql/SqlError/AuthorizationError"
96)("AuthorizationError", ReasonFields) {
97 /**
98 * Marks this value as a structured SQL error reason for runtime guards.
99 *
100 * @stability unstable
101 * @since 4.0.0
102 */
103 readonly [ReasonTypeId] = ReasonTypeId
104
105 /**
106 * Indicates whether retrying the failed SQL operation may succeed.
107 *
108 * @stability unstable
109 * @since 4.0.0
110 */
111 get isRetryable(): boolean {
112 return false
113 }
114}
115
116/**
117 * SQL error reason for invalid SQL syntax; not marked retryable.
118 *
119 * @stability unstable
120 * @category errors
121 * @since 4.0.0
122 */
123export class SqlSyntaxError extends Schema.TaggedError<SqlSyntaxError>("effect/sql/SqlError/SqlSyntaxError")(
124 "SqlSyntaxError",
125 ReasonFields
126) {
127 /**
128 * Marks this value as a structured SQL error reason for runtime guards.
129 *
130 * @stability unstable
131 * @since 4.0.0
132 */
133 readonly [ReasonTypeId] = ReasonTypeId
134
135 /**
136 * Indicates whether retrying the failed SQL operation may succeed.
137 *
138 * @stability unstable
139 * @since 4.0.0
140 */
141 get isRetryable(): boolean {
142 return false
143 }
144}
145
146const UniqueViolationFields = {
147 ...ReasonFields,
148 constraint: Schema.String
149}
150
151/**
152 * SQL error reason for a unique constraint violation, including the violated
153 * constraint identifier; not marked retryable.
154 *
155 * @stability unstable
156 * @category errors
157 * @since 4.0.0
158 */
159export class UniqueViolation extends Schema.TaggedError<UniqueViolation>("effect/sql/SqlError/UniqueViolation")(
160 "UniqueViolation",
161 UniqueViolationFields
162) {
163 /**
164 * Marks this value as a structured SQL error reason for runtime guards.
165 *
166 * @stability unstable
167 * @since 4.0.0
168 */
169 readonly [ReasonTypeId] = ReasonTypeId
170
171 /**
172 * Indicates whether retrying the failed SQL operation may succeed.
173 *
174 * @stability unstable
175 * @since 4.0.0
176 */
177 get isRetryable(): boolean {
178 return false
179 }
180}
181
182/**
183 * SQL error reason for a non-unique constraint violation; not marked retryable.
184 *
185 * @stability unstable
186 * @category errors
187 * @since 4.0.0
188 */
189export class ConstraintError extends Schema.TaggedError<ConstraintError>("effect/sql/SqlError/ConstraintError")(
190 "ConstraintError",
191 ReasonFields
192) {
193 /**
194 * Marks this value as a structured SQL error reason for runtime guards.
195 *
196 * @stability unstable
197 * @since 4.0.0
198 */
199 readonly [ReasonTypeId] = ReasonTypeId
200
201 /**
202 * Indicates whether retrying the failed SQL operation may succeed.
203 *
204 * @stability unstable
205 * @since 4.0.0
206 */
207 get isRetryable(): boolean {
208 return false
209 }
210}
211
212/**
213 * SQL error reason for a database deadlock; marked retryable.
214 *
215 * @stability unstable
216 * @category errors
217 * @since 4.0.0
218 */
219export class DeadlockError extends Schema.TaggedError<DeadlockError>("effect/sql/SqlError/DeadlockError")(
220 "DeadlockError",
221 ReasonFields
222) {
223 /**
224 * Marks this value as a structured SQL error reason for runtime guards.
225 *
226 * @stability unstable
227 * @since 4.0.0
228 */
229 readonly [ReasonTypeId] = ReasonTypeId
230
231 /**
232 * Indicates whether retrying the failed SQL operation may succeed.
233 *
234 * @stability unstable
235 * @since 4.0.0
236 */
237 get isRetryable(): boolean {
238 return true
239 }
240}
241
242/**
243 * SQL error reason for a transaction serialization or isolation conflict;
244 * marked retryable.
245 *
246 * @stability unstable
247 * @category errors
248 * @since 4.0.0
249 */
250export class SerializationError extends Schema.TaggedError<SerializationError>(
251 "effect/sql/SqlError/SerializationError"
252)("SerializationError", ReasonFields) {
253 /**
254 * Marks this value as a structured SQL error reason for runtime guards.
255 *
256 * @stability unstable
257 * @since 4.0.0
258 */
259 readonly [ReasonTypeId] = ReasonTypeId
260
261 /**
262 * Indicates whether retrying the failed SQL operation may succeed.
263 *
264 * @stability unstable
265 * @since 4.0.0
266 */
267 get isRetryable(): boolean {
268 return true
269 }
270}
271
272/**
273 * SQL error reason for timing out while waiting on a database lock; marked
274 * retryable.
275 *
276 * @stability unstable
277 * @category errors
278 * @since 4.0.0
279 */
280export class LockTimeoutError extends Schema.TaggedError<LockTimeoutError>("effect/sql/SqlError/LockTimeoutError")(
281 "LockTimeoutError",
282 ReasonFields
283) {
284 /**
285 * Marks this value as a structured SQL error reason for runtime guards.
286 *
287 * @stability unstable
288 * @since 4.0.0
289 */
290 readonly [ReasonTypeId] = ReasonTypeId
291
292 /**
293 * Indicates whether retrying the failed SQL operation may succeed.
294 *
295 * @stability unstable
296 * @since 4.0.0
297 */
298 get isRetryable(): boolean {
299 return true
300 }
301}
302
303/**
304 * SQL error reason for a statement or query timeout; marked retryable.
305 *
306 * @stability unstable
307 * @category errors
308 * @since 4.0.0
309 */
310export class StatementTimeoutError extends Schema.TaggedError<StatementTimeoutError>(
311 "effect/sql/SqlError/StatementTimeoutError"
312)("StatementTimeoutError", ReasonFields) {
313 /**
314 * Marks this value as a structured SQL error reason for runtime guards.
315 *
316 * @stability unstable
317 * @since 4.0.0
318 */
319 readonly [ReasonTypeId] = ReasonTypeId
320
321 /**
322 * Indicates whether retrying the failed SQL operation may succeed.
323 *
324 * @stability unstable
325 * @since 4.0.0
326 */
327 get isRetryable(): boolean {
328 return true
329 }
330}
331
332/**
333 * SQL error reason for an unclassified database failure; not marked retryable.
334 *
335 * @stability unstable
336 * @category errors
337 * @since 4.0.0
338 */
339export class UnknownError extends Schema.TaggedError<UnknownError>("effect/sql/SqlError/UnknownError")(
340 "UnknownError",
341 ReasonFields
342) {
343 /**
344 * Marks this value as a structured SQL error reason for runtime guards.
345 *
346 * @stability unstable
347 * @since 4.0.0
348 */
349 readonly [ReasonTypeId] = ReasonTypeId
350
351 /**
352 * Indicates whether retrying the failed SQL operation may succeed.
353 *
354 * @stability unstable
355 * @since 4.0.0
356 */
357 get isRetryable(): boolean {
358 return false
359 }
360}
361
362/**
363 * Union of structured SQL error reasons, each carrying the original cause plus
364 * optional message and operation metadata.
365 *
366 * @stability unstable
367 * @category errors
368 * @since 4.0.0
369 */
370export type SqlErrorReason =
371 | ConnectionError
372 | AuthenticationError
373 | AuthorizationError
374 | SqlSyntaxError
375 | UniqueViolation
376 | ConstraintError
377 | DeadlockError
378 | SerializationError
379 | LockTimeoutError
380 | StatementTimeoutError
381 | UnknownError
382
383/**
384 * Schema for encoding and decoding SQL error reasons.
385 *
386 * @stability unstable
387 * @category schemas
388 * @since 4.0.0
389 */
390export const SqlErrorReason: Schema.Union<[
391 typeof ConnectionError,
392 typeof AuthenticationError,
393 typeof AuthorizationError,
394 typeof SqlSyntaxError,
395 typeof UniqueViolation,
396 typeof ConstraintError,
397 typeof DeadlockError,
398 typeof SerializationError,
399 typeof LockTimeoutError,
400 typeof StatementTimeoutError,
401 typeof UnknownError
402]> = Schema.Union([
403 ConnectionError,
404 AuthenticationError,
405 AuthorizationError,
406 SqlSyntaxError,
407 UniqueViolation,
408 ConstraintError,
409 DeadlockError,
410 SerializationError,
411 LockTimeoutError,
412 StatementTimeoutError,
413 UnknownError
414])
415
416/**
417 * Error wrapper for SQL failures whose `message`, `cause`, and `isRetryable`
418 * values are derived from its `SqlErrorReason`.
419 *
420 * @stability unstable
421 * @category errors
422 * @since 4.0.0
423 */
424export class SqlError extends Schema.TaggedError<SqlError>("effect/sql/SqlError")("SqlError", {
425 reason: SqlErrorReason
426}) {
427 /**
428 * Marks this value as the top-level SQL error wrapper for runtime guards.
429 *
430 * @stability unstable
431 * @since 4.0.0
432 */
433 readonly [TypeId] = TypeId
434
435 /**
436 * Exposes the structured SQL reason as the JavaScript error cause.
437 *
438 * @stability unstable
439 * @since 4.0.0
440 */
441 override readonly cause = this.reason
442
443 /**
444 * Uses the reason message when present, otherwise falls back to the reason tag.
445 *
446 * @stability unstable
447 * @since 4.0.0
448 */
449 override get message(): string {
450 return this.reason.message || this.reason._tag
451 }
452
453 /**
454 * Delegates retryability to the underlying SQL error reason.
455 *
456 * @stability unstable
457 * @since 4.0.0
458 */
459 get isRetryable(): boolean {
460 return this.reason.isRetryable
461 }
462}
463
464/**
465 * Returns `true` when a value is a `SqlError`.
466 *
467 * @stability unstable
468 * @category guards
469 * @since 4.0.0
470 */
471export const isSqlError = (u: unknown): u is SqlError => Predicate.hasProperty(u, TypeId)
472
473/**
474 * Returns `true` when a value is a `SqlErrorReason`.
475 *
476 * @stability unstable
477 * @category guards
478 * @since 4.0.0
479 */
480export const isSqlErrorReason = (u: unknown): u is SqlErrorReason => Predicate.hasProperty(u, ReasonTypeId)
481
482type SqliteClassifyOptions = {
483 readonly message?: string | undefined
484 readonly operation?: string | undefined
485}
486
487const sqliteCodeFromCause = (cause: unknown): string | number | undefined => {
488 if (!Predicate.hasProperty(cause, "code")) {
489 return undefined
490 }
491 const code = cause.code
492 return typeof code === "string" || typeof code === "number" ? code : undefined
493}
494
495const sqliteNumericCodeFromCause = (cause: unknown): number | undefined => {
496 const code = sqliteCodeFromCause(cause)
497 if (typeof code === "number") {
498 return code
499 }
500 if (!Predicate.hasProperty(cause, "errno")) {
501 return undefined
502 }
503 const errno = cause.errno
504 return typeof errno === "number" ? errno : undefined
505}
506
507const matchesSqliteNumericCode = (cause: unknown, expected: number): boolean => {
508 const code = sqliteCodeFromCause(cause)
509 if (code === expected) {
510 return true
511 }
512 if (!Predicate.hasProperty(cause, "errno")) {
513 return false
514 }
515 return cause.errno === expected
516}
517
518const matchesSqliteCode = (code: string, expected: string): boolean =>
519 code === expected || code.startsWith(expected + "_")
520
521const UNKNOWN_CONSTRAINT = "unknown"
522const SQLITE_CONSTRAINT_UNIQUE = "SQLITE_CONSTRAINT_UNIQUE"
523const SQLITE_CONSTRAINT_UNIQUE_CODE = 2067
524
525const normalizeConstraintIdentifier = (identifier: unknown): string => {
526 if (typeof identifier !== "string") {
527 return UNKNOWN_CONSTRAINT
528 }
529 const trimmed = identifier.trim()
530 return trimmed.length === 0 ? UNKNOWN_CONSTRAINT : trimmed
531}
532
533const sqliteUniqueConstraintFromCause = (cause: unknown): string => {
534 if (Predicate.hasProperty(cause, "constraint")) {
535 return normalizeConstraintIdentifier(cause.constraint)
536 }
537 if (!Predicate.hasProperty(cause, "message")) {
538 return UNKNOWN_CONSTRAINT
539 }
540 const message = cause.message
541 if (typeof message !== "string") {
542 return UNKNOWN_CONSTRAINT
543 }
544 const prefix = "UNIQUE constraint failed:"
545 const index = message.indexOf(prefix)
546 return index === -1 ? UNKNOWN_CONSTRAINT : normalizeConstraintIdentifier(message.slice(index + prefix.length))
547}
548
549/**
550 * Classifies a native SQLite error cause into a `SqlErrorReason` using its
551 * `code` or `errno`, with optional message and operation metadata.
552 *
553 * @stability unstable
554 * @category converting
555 * @since 4.0.0
556 */
557export const classifySqliteError = (
558 cause: unknown,
559 { message, operation }: SqliteClassifyOptions = {}
560): SqlErrorReason => {
561 const props = {
562 cause,
563 message,
564 operation
565 }
566 const code = sqliteCodeFromCause(cause)
567 const numericCode = sqliteNumericCodeFromCause(cause)
568
569 if (code === SQLITE_CONSTRAINT_UNIQUE || matchesSqliteNumericCode(cause, SQLITE_CONSTRAINT_UNIQUE_CODE)) {
570 return new UniqueViolation({ ...props, constraint: sqliteUniqueConstraintFromCause(cause) })
571 }
572
573 if (typeof code === "string") {
574 if (matchesSqliteCode(code, "SQLITE_AUTH")) {
575 return new AuthenticationError(props)
576 }
577 if (matchesSqliteCode(code, "SQLITE_PERM")) {
578 return new AuthorizationError(props)
579 }
580 if (matchesSqliteCode(code, "SQLITE_CONSTRAINT")) {
581 return new ConstraintError(props)
582 }
583 if (matchesSqliteCode(code, "SQLITE_BUSY") || matchesSqliteCode(code, "SQLITE_LOCKED")) {
584 return new LockTimeoutError(props)
585 }
586 if (matchesSqliteCode(code, "SQLITE_CANTOPEN")) {
587 return new ConnectionError(props)
588 }
589 }
590
591 if (typeof numericCode === "number") {
592 const code = numericCode & 0xff
593 switch (code) {
594 case 23:
595 return new AuthenticationError(props)
596 case 3:
597 return new AuthorizationError(props)
598 case 19:
599 return new ConstraintError(props)
600 case 5:
601 case 6:
602 return new LockTimeoutError(props)
603 case 14:
604 return new ConnectionError(props)
605 default:
606 return new UnknownError(props)
607 }
608 }
609
610 return new UnknownError(props)
611}
612
613/**
614 * Error raised when an ordered batched SQL resolver receives a different number
615 * of result rows than requests.
616 *
617 * @stability unstable
618 * @category errors
619 * @since 4.0.0
620 */
621export class ResultLengthMismatch
622 extends Schema.TaggedError<ResultLengthMismatch>("effect/sql/ResultLengthMismatch")("ResultLengthMismatch", {
623 expected: Schema.Natural,
624 actual: Schema.Natural
625 })
626{
627 /**
628 * Explains the mismatch between expected and actual batched SQL result counts.
629 *
630 * @stability unstable
631 * @since 4.0.0
632 */
633 override get message() {
634 return `Expected ${this.expected} results but got ${this.actual}`
635 }
636}