History

fix(server): include SQLite conditions in persistence errors

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.

Theo Browne committed 2 months ago

Commit dddc0bd1 parent 07c4ab52 files · +117 −4Browse files

Unified · Split

2 files
  1. apps/server/src/persistence/Errors.test.ts +72 −1
  2. apps/server/src/persistence/Errors.ts +45 −3
apps/server/src/persistence/Errors.test.ts+72−1
@@ -1,8 +1,11 @@
11 import { assert, it } from "@effect/vitest";
2+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
23 import * as Effect from "effect/Effect";
34 import * as Schema from "effect/Schema";
5+import * as SqlClient from "effect/unstable/sql/SqlClient";
6+import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError";
47
5−import { PersistenceDecodeError, PersistenceSqlError } from "./Errors.ts";
8+import { PersistenceDecodeError, PersistenceSqlError, toPersistenceSqlError } from "./Errors.ts";
69
710 const decodeRuntimePayload = Schema.decodeUnknownEffect(
811 Schema.Struct({
@@ -25,6 +28,74 @@ it("keeps SQL operation context without a tautological detail", () => { · 16 unmodified lines
2528 assert.equal(error.message, "SQL error in AuthSessionRepository.list:query");
2629 });
2730
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+
2899 it.effect("maps schema errors without copying rejected payloads into diagnostics", () =>
29100 Effect.gen(function* () {
30101 const rejectedPayload = "runtime-payload-secret-sentinel";
apps/server/src/persistence/Errors.ts+45−3
@@ -1,3 +1,4 @@
1+import * as Predicate from "effect/Predicate";
12 import * as Schema from "effect/Schema";
23 import * as SchemaIssue from "effect/SchemaIssue";
34
@@ -72,14 +73,55 @@ export class PersistenceDecodeError extends Schema.TaggedErrorClass<PersistenceD · 68 unmodified lines
7273 const isPersistenceSqlError = Schema.is(PersistenceSqlError);
7374 const isPersistenceDecodeError = Schema.is(PersistenceDecodeError);
7475
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+
75115 // Kept for orchestration/projection call sites, which are being revamped separately.
76116 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({
79120 operation,
80− detail: `Failed to execute ${operation}`,
121+ ...(detail === undefined ? {} : { detail }),
81122 cause,
82123 });
124+ };
83125 }
84126
85127 // Kept for orchestration/projection call sites, which are being revamped separately.