Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion apps/server/src/persistence/Errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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";
Expand Down
38 changes: 35 additions & 3 deletions apps/server/src/persistence/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SqlError reason not traversed

High Severity

sqliteCondition only walks the .cause chain, but the Node SQLite client wraps driver failures as SqlError with reason: classifySqliteError(...) and does not pass the raw driver as cause. When PersistenceSqlError is built from that SqlError, describeSqlCause may find no errcode/errstr, leave detail unset, and #4818-style upsert failures can still show an operation-only message.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fbb2ec7. Configure here.

}

/**
* 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);
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

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.
Expand Down
Loading