diff --git a/apps/server/src/persistence/Errors.test.ts b/apps/server/src/persistence/Errors.test.ts index 680a362e20a..4223273aca7 100644 --- a/apps/server/src/persistence/Errors.test.ts +++ b/apps/server/src/persistence/Errors.test.ts @@ -2,7 +2,7 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { PersistenceDecodeError, PersistenceSqlError } from "./Errors.ts"; +import { PersistenceDecodeError, PersistenceSqlError, toPersistenceSqlError } from "./Errors.ts"; const decodeRuntimePayload = Schema.decodeUnknownEffect( Schema.Struct({ @@ -25,6 +25,56 @@ it("keeps SQL operation context without a tautological detail", () => { assert.equal(error.message, "SQL error in AuthSessionRepository.list:query"); }); +it("names the SQLite condition by its normalized result code", () => { + const cause = Object.assign(new Error("UNIQUE constraint failed: orders.customer_email"), { + errcode: 1555, + errstr: "constraint failed", + }); + const error = toPersistenceSqlError("OrchestrationCommandReceiptRepository.upsert:query")(cause); + + assert.equal(error.detail, "SQLITE(1555) constraint failed"); + assert.equal(error.cause, cause); +}); + +it("reads the condition through a wrapping driver error", () => { + const driver = Object.assign(new Error("locked"), { errcode: 5, errstr: "database is locked" }); + const error = toPersistenceSqlError("AuthSessionRepository.list:query")( + new Error("Failed to prepare statement", { cause: driver }), + ); + + assert.equal(error.detail, "SQLITE(5) database is locked"); +}); + +it("keeps the driver's own prose out of the message", () => { + const cause = Object.assign(new Error("UNIQUE constraint failed: orders.customer_email"), { + errcode: 1555, + errstr: "constraint failed", + }); + const error = toPersistenceSqlError("AuthSessionRepository.create:query")(cause); + + assert.ok(!error.message.includes("customer_email")); +}); + +it("omits a detail for a cause it cannot categorize", () => { + const error = toPersistenceSqlError("AuthSessionRepository.list:query")(new Error("unhelpful")); + + assert.equal(error.detail, undefined); + assert.equal(error.message, "SQL error in AuthSessionRepository.list:query"); +}); + +it.effect("summarizes a schema cause by issue tag instead of by rejected value", () => + Effect.gen(function* () { + const rejectedPayload = "sql-mapper-secret-sentinel"; + const cause = yield* Effect.flip( + decodeRuntimePayload({ runtimePayload: { attempt: rejectedPayload } }), + ); + const error = toPersistenceSqlError("ProviderSessionRuntimeRepository.list:query")(cause); + + assert.ok(error.detail !== undefined); + assert.ok(!error.message.includes(rejectedPayload)); + }), +); + it.effect("maps schema errors without copying rejected payloads into diagnostics", () => Effect.gen(function* () { const rejectedPayload = "runtime-payload-secret-sentinel"; diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index 03edaec77d6..bce1dd1f36b 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -73,13 +73,45 @@ const isPersistenceSqlError = Schema.is(PersistenceSqlError); const isPersistenceDecodeError = Schema.is(PersistenceDecodeError); // Kept for orchestration/projection call sites, which are being revamped separately. +/** + * SQLite names its condition through a fixed result-code table, so the code and + * its canonical string are bounded and carry no query data. The driver message + * is not bounded: a constraint failure spells out the table and column it hit. + * Only the normalized pair is carried here; `cause` keeps everything else. + */ +function sqliteCondition(cause: unknown): string | undefined { + let value: unknown = cause; + for (let depth = 0; depth < 4 && typeof value === "object" && value !== null; depth += 1) { + const { errcode, errstr } = value as { errcode?: unknown; errstr?: unknown }; + if (typeof errcode === "number" && typeof errstr === "string") { + return `SQLITE(${errcode}) ${errstr}`; + } + value = (value as { cause?: unknown }).cause; + } + return undefined; +} + +/** + * A rejected payload must never reach diagnostics, so a schema failure + * contributes only its issue tags, and a driver failure only its normalized + * condition. Anything the mapper cannot categorize leaves the detail unset. + */ +function describeSqlCause(cause: unknown): string | undefined { + if (Schema.isSchemaError(cause)) return summarizeSchemaIssue(cause.issue); + return sqliteCondition(cause); +} + export function toPersistenceSqlError(operation: string) { - return (cause: unknown): PersistenceSqlError => - new PersistenceSqlError({ + return (cause: unknown): PersistenceSqlError => { + // Restating the operation as the detail only doubled it in the message and + // buried the cause; `message` already reads well without a detail. + const detail = describeSqlCause(cause); + return new PersistenceSqlError({ operation, - detail: `Failed to execute ${operation}`, + ...(detail === undefined ? {} : { detail }), cause, }); + }; } // Kept for orchestration/projection call sites, which are being revamped separately.