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 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; | ||
| 2 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schema from "effect/Schema"; | 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 | const decodeRuntimePayload = Schema.decodeUnknownEffect( | 10 | const decodeRuntimePayload = Schema.decodeUnknownEffect( |
| 8 | Schema.Struct({ | 11 | Schema.Struct({ |
| @@ -25,6 +28,74 @@ it("keeps SQL operation context without a tautological detail", () => { · 16 unmodified lines | |||
| 25 | assert.equal(error.message, "SQL error in AuthSessionRepository.list:query"); | 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 | it.effect("maps schema errors without copying rejected payloads into diagnostics", () => | 99 | it.effect("maps schema errors without copying rejected payloads into diagnostics", () => |
| 29 | Effect.gen(function* () { | 100 | Effect.gen(function* () { |
| 30 | const rejectedPayload = "runtime-payload-secret-sentinel"; | 101 | const rejectedPayload = "runtime-payload-secret-sentinel"; |
| @@ -1,3 +1,4 @@ | |||
| 1 | import * as Predicate from "effect/Predicate"; | ||
| 1 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 2 | import * as SchemaIssue from "effect/SchemaIssue"; | 3 | import * as SchemaIssue from "effect/SchemaIssue"; |
| 3 | 4 | ||
| @@ -72,14 +73,55 @@ export class PersistenceDecodeError extends Schema.TaggedErrorClass<PersistenceD · 68 unmodified lines | |||
| 72 | const isPersistenceSqlError = Schema.is(PersistenceSqlError); | 73 | const isPersistenceSqlError = Schema.is(PersistenceSqlError); |
| 73 | const isPersistenceDecodeError = Schema.is(PersistenceDecodeError); | 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 | // Kept for orchestration/projection call sites, which are being revamped separately. | 115 | // Kept for orchestration/projection call sites, which are being revamped separately. |
| 76 | export function toPersistenceSqlError(operation: string) { | 116 | export function toPersistenceSqlError(operation: string) { |
| 77 | return (cause: unknown): PersistenceSqlError => | 117 | return (cause: unknown): PersistenceSqlError => { |
| 78 | new PersistenceSqlError({ | 118 | const detail = describeSqlCause(cause); |
| 119 | return new PersistenceSqlError({ | ||
| 79 | operation, | 120 | operation, |
| 80 | detail: `Failed to execute ${operation}`, | 121 | ...(detail === undefined ? {} : { detail }), |
| 81 | cause, | 122 | cause, |
| 82 | }); | 123 | }); |
| 124 | }; | ||
| 83 | } | 125 | } |
| 84 | 126 | ||
| 85 | // Kept for orchestration/projection call sites, which are being revamped separately. | 127 | // Kept for orchestration/projection call sites, which are being revamped separately. |