Include SQLite conditions and schema issue tags without copying query data. Continue @Sy-D's [#4837](https://github.com/pingdotgg/t3code/pull/4837). Add the missing Bun error codes and test the real SQL client. Created with GPT-6 Astra (preview) in Codex.
Unified · Split
| @@ -1,8 +1,11 @@ | ||
| 1 | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; | |
| 2 | 3 | import * as Effect from "effect/Effect"; |
| 3 | 4 | import * as Schema from "effect/Schema"; |
| 5 | +import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 6 | +import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"; | |
| 4 | 7 | |
| 5 | −import { PersistenceDecodeError, PersistenceSqlError } from "./Errors.ts"; | |
| 8 | +import { PersistenceDecodeError, PersistenceSqlError, toPersistenceSqlError } from "./Errors.ts"; | |
| 6 | 9 | |
| 7 | 10 | const decodeRuntimePayload = Schema.decodeUnknownEffect( |
| 8 | 11 | Schema.Struct({ |
| @@ -25,6 +28,74 @@ it("keeps SQL operation context without a tautological detail", () => { · 16 unmodified lines | ||
| 25 | 28 | assert.equal(error.message, "SQL error in AuthSessionRepository.list:query"); |
| 26 | 29 | }); |
| 27 | 30 | |
| 31 | +it.effect("names a real SQLite condition without copying query data", () => | |
| 32 | + Effect.gen(function* () { | |
| 33 | + const sql = yield* SqlClient.SqlClient; | |
| 34 | + const payload = "sql-private-sentinel"; | |
| 35 | + yield* sql`CREATE TABLE error_test (private_column TEXT PRIMARY KEY)`; | |
| 36 | + yield* sql`INSERT INTO error_test VALUES (${payload})`; | |
| 37 | + const cause = yield* Effect.flip(sql`INSERT INTO error_test VALUES (${payload})`); | |
| 38 | + const error = toPersistenceSqlError("OrchestrationCommandReceiptRepository.upsert:query")( | |
| 39 | + cause, | |
| 40 | + ); | |
| 41 | + | |
| 42 | + assert.equal(error.detail, "SQLITE(1555) constraint failed"); | |
| 43 | + assert.equal(error.cause, cause); | |
| 44 | + assert.notInclude(error.message, payload); | |
| 45 | + assert.notInclude(error.message, "private_column"); | |
| 46 | + }).pipe(Effect.provide(NodeSqliteClient.layerMemory())), | |
| 47 | +); | |
| 48 | + | |
| 49 | +it("reads the condition through a wrapping driver error", () => { | |
| 50 | + const driver = Object.assign(new Error("locked"), { errcode: 5, errstr: "database is locked" }); | |
| 51 | + const error = toPersistenceSqlError("AuthSessionRepository.list:query")( | |
| 52 | + new Error("Failed to prepare statement", { cause: driver }), | |
| 53 | + ); | |
| 54 | + | |
| 55 | + assert.equal(error.detail, "SQLITE(5) database is locked"); | |
| 56 | +}); | |
| 57 | + | |
| 58 | +it.each([{ errno: 1555, code: "SQLITE_CONSTRAINT_PRIMARYKEY" }, { errno: 1 }])( | |
| 59 | + "names Bun SQLite condition $errno through the SQL error wrapper", | |
| 60 | + (condition) => { | |
| 61 | + const driver = Object.assign(new Error("bun-sql-private-sentinel"), { | |
| 62 | + name: "SQLiteError", | |
| 63 | + ...condition, | |
| 64 | + }); | |
| 65 | + const cause = new SqlError({ reason: classifySqliteError(driver) }); | |
| 66 | + const error = toPersistenceSqlError("AuthSessionRepository.create:query")(cause); | |
| 67 | + | |
| 68 | + assert.equal( | |
| 69 | + error.message, | |
| 70 | + `SQL error in AuthSessionRepository.create:query: SQLITE(${condition.errno})`, | |
| 71 | + ); | |
| 72 | + assert.equal(error.cause, cause); | |
| 73 | + }, | |
| 74 | +); | |
| 75 | + | |
| 76 | +it.each([ | |
| 77 | + new Error("unhelpful"), | |
| 78 | + Object.assign(new Error("file not found"), { errno: -2, code: "ENOENT" }), | |
| 79 | +])("omits a detail for a cause it cannot categorize (%#)", (cause) => { | |
| 80 | + const error = toPersistenceSqlError("AuthSessionRepository.list:query")(cause); | |
| 81 | + | |
| 82 | + assert.equal(error.detail, undefined); | |
| 83 | + assert.equal(error.message, "SQL error in AuthSessionRepository.list:query"); | |
| 84 | +}); | |
| 85 | + | |
| 86 | +it.effect("summarizes a schema cause by issue tag instead of by rejected value", () => | |
| 87 | + Effect.gen(function* () { | |
| 88 | + const rejectedPayload = "sql-mapper-secret-sentinel"; | |
| 89 | + const cause = yield* Effect.flip( | |
| 90 | + decodeRuntimePayload({ runtimePayload: { attempt: rejectedPayload } }), | |
| 91 | + ); | |
| 92 | + const error = toPersistenceSqlError("ProviderSessionRuntimeRepository.list:query")(cause); | |
| 93 | + | |
| 94 | + assert.ok(error.detail !== undefined); | |
| 95 | + assert.ok(!error.message.includes(rejectedPayload)); | |
| 96 | + }), | |
| 97 | +); | |
| 98 | + | |
| 28 | 99 | it.effect("maps schema errors without copying rejected payloads into diagnostics", () => |
| 29 | 100 | Effect.gen(function* () { |
| 30 | 101 | const rejectedPayload = "runtime-payload-secret-sentinel"; |
| @@ -1,3 +1,4 @@ | ||
| 1 | +import * as Predicate from "effect/Predicate"; | |
| 1 | 2 | import * as Schema from "effect/Schema"; |
| 2 | 3 | import * as SchemaIssue from "effect/SchemaIssue"; |
| 3 | 4 | |
| @@ -72,14 +73,55 @@ export class PersistenceDecodeError extends Schema.TaggedErrorClass<PersistenceD · 68 unmodified lines | ||
| 72 | 73 | const isPersistenceSqlError = Schema.is(PersistenceSqlError); |
| 73 | 74 | const isPersistenceDecodeError = Schema.is(PersistenceDecodeError); |
| 74 | 75 | |
| 76 | +/** | |
| 77 | + * Read a SQLite condition through SQL error wrappers. | |
| 78 | + * Use Node's fixed description or Bun's numeric code, never the driver message. | |
| 79 | + */ | |
| 80 | +function sqliteCondition(cause: unknown): string | undefined { | |
| 81 | + let value = cause; | |
| 82 | + for (let depth = 0; depth < 4 && Predicate.isObject(value); depth += 1) { | |
| 83 | + if ( | |
| 84 | + "errcode" in value && | |
| 85 | + typeof value.errcode === "number" && | |
| 86 | + "errstr" in value && | |
| 87 | + typeof value.errstr === "string" | |
| 88 | + ) { | |
| 89 | + return `SQLITE(${value.errcode}) ${value.errstr}`; | |
| 90 | + } | |
| 91 | + if ( | |
| 92 | + "name" in value && | |
| 93 | + value.name === "SQLiteError" && | |
| 94 | + "errno" in value && | |
| 95 | + typeof value.errno === "number" && | |
| 96 | + Number.isInteger(value.errno) | |
| 97 | + ) { | |
| 98 | + return `SQLITE(${value.errno})`; | |
| 99 | + } | |
| 100 | + value = "cause" in value ? value.cause : undefined; | |
| 101 | + } | |
| 102 | + return undefined; | |
| 103 | +} | |
| 104 | + | |
| 105 | +/** | |
| 106 | + * A rejected payload must never reach diagnostics, so a schema failure | |
| 107 | + * contributes only its issue tags, and a driver failure only its normalized | |
| 108 | + * condition. Anything the mapper cannot categorize leaves the detail unset. | |
| 109 | + */ | |
| 110 | +function describeSqlCause(cause: unknown): string | undefined { | |
| 111 | + if (Schema.isSchemaError(cause)) return summarizeSchemaIssue(cause.issue); | |
| 112 | + return sqliteCondition(cause); | |
| 113 | +} | |
| 114 | + | |
| 75 | 115 | // Kept for orchestration/projection call sites, which are being revamped separately. |
| 76 | 116 | export function toPersistenceSqlError(operation: string) { |
| 77 | − return (cause: unknown): PersistenceSqlError => | |
| 78 | − new PersistenceSqlError({ | |
| 117 | + return (cause: unknown): PersistenceSqlError => { | |
| 118 | + const detail = describeSqlCause(cause); | |
| 119 | + return new PersistenceSqlError({ | |
| 79 | 120 | operation, |
| 80 | − detail: `Failed to execute ${operation}`, | |
| 121 | + ...(detail === undefined ? {} : { detail }), | |
| 81 | 122 | cause, |
| 82 | 123 | }); |
| 124 | + }; | |
| 83 | 125 | } |
| 84 | 126 | |
| 85 | 127 | // Kept for orchestration/projection call sites, which are being revamped separately. |