authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-03 18:35:27-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 18:35:27-07:00
logdddc0bdcb2230147e207efb17df2e49dbe1bdd8c
treede25d23ff0e9e1b79f1b031db19e853fb11128f0
parent07c4ab5077fa9c32bf34ab07dd2caa3907878dd9
signature Signed by PGP key B5690EEEBB952194

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. Co-authored-by: Sy-D <8460326+Sy-D@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

2 files changed, 117 insertions(+), 4 deletions(-)

apps/server/src/persistence/Errors.test.ts+72-1
...@@ -1,8 +1,11 @@...@@ -1,8 +1,11 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
2import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
5import * as SqlClient from "effect/unstable/sql/SqlClient";
6import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError";
47
5import { PersistenceDecodeError, PersistenceSqlError } from "./Errors.ts";8import { PersistenceDecodeError, PersistenceSqlError, toPersistenceSqlError } from "./Errors.ts";
69
7const decodeRuntimePayload = Schema.decodeUnknownEffect(10const decodeRuntimePayload = Schema.decodeUnknownEffect(
8 Schema.Struct({11 Schema.Struct({
...@@ -25,6 +28,74 @@ it("keeps SQL operation context without a tautological detail", () => {...@@ -25,6 +28,74 @@ it("keeps SQL operation context without a tautological detail", () => {
25 assert.equal(error.message, "SQL error in AuthSessionRepository.list:query");28 assert.equal(error.message, "SQL error in AuthSessionRepository.list:query");
26});29});
2730
31it.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
49it("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
58it.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
76it.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
86it.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
28it.effect("maps schema errors without copying rejected payloads into diagnostics", () =>99it.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";
apps/server/src/persistence/Errors.ts+45-3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as Predicate from "effect/Predicate";
1import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
2import * as SchemaIssue from "effect/SchemaIssue";3import * as SchemaIssue from "effect/SchemaIssue";
34
...@@ -72,14 +73,55 @@ export class PersistenceDecodeError extends Schema.TaggedErrorClass<PersistenceD...@@ -72,14 +73,55 @@ export class PersistenceDecodeError extends Schema.TaggedErrorClass<PersistenceD
72const isPersistenceSqlError = Schema.is(PersistenceSqlError);73const isPersistenceSqlError = Schema.is(PersistenceSqlError);
73const isPersistenceDecodeError = Schema.is(PersistenceDecodeError);74const 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 */
80function 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 */
110function 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.
76export function toPersistenceSqlError(operation: string) {116export 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}
84126
85// Kept for orchestration/projection call sites, which are being revamped separately.127// Kept for orchestration/projection call sites, which are being revamped separately.