From 427254fc0559c7d0e28d535a890bbc96d795743b Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Fri, 20 Mar 2026 13:25:45 +0000 Subject: [PATCH 1/2] fix(server): detect corrupted SQLite database and auto-recover on startup --- apps/server/src/persistence/Layers/Sqlite.ts | 43 ++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 33f99482d9..8e85a0c44f 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -1,9 +1,31 @@ import { Effect, Layer, FileSystem, Path } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { openSync, readSync, closeSync } from "node:fs"; import { runMigrations } from "../Migrations.ts"; import { ServerConfig } from "../../config.ts"; +// First 16 bytes of every valid SQLite database file +const SQLITE_MAGIC = "SQLite format 3\0"; + +/** Check whether the file at `dbPath` starts with the SQLite magic header. */ +const isSqliteFileValid = (dbPath: string): Effect.Effect => + Effect.sync(() => { + try { + const fd = openSync(dbPath, "r"); + try { + const buf = Buffer.alloc(16); + const bytesRead = readSync(fd, buf, 0, 16, 0); + if (bytesRead < 16) return false; + return buf.toString("ascii", 0, 16) === SQLITE_MAGIC; + } finally { + closeSync(fd); + } + } catch { + return false; + } + }); + type RuntimeSqliteLayerConfig = { readonly filename: string; }; @@ -41,6 +63,27 @@ export const makeSqlitePersistenceLive = (dbPath: string) => const path = yield* Path.Path; yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true }); + // Detect and recover from a corrupted database file. + // A valid SQLite file must start with "SQLite format 3\0". + // If the header is invalid, back up the corrupted file and let + // a fresh database be created so the app can start. + const fileExists = yield* fs.exists(dbPath); + if (fileExists) { + const valid = yield* isSqliteFileValid(dbPath); + if (!valid) { + const backupPath = `${dbPath}.corrupted.${Date.now()}`; + yield* Effect.logWarning( + `Corrupted database detected at ${dbPath}. ` + + `Backing up to ${backupPath} and creating a fresh database. ` + + `Session history will be reset.`, + ); + yield* fs.rename(dbPath, backupPath); + // Remove stale WAL/SHM journal files from the corrupted database + yield* fs.remove(`${dbPath}-wal`).pipe(Effect.ignore); + yield* fs.remove(`${dbPath}-shm`).pipe(Effect.ignore); + } + } + return Layer.provideMerge(setup, makeRuntimeSqliteLayer({ filename: dbPath })); }).pipe(Layer.unwrap); From 33283db07ed2f32966792b7c0461b7c846da91cd Mon Sep 17 00:00:00 2001 From: eggfriedrice Date: Fri, 20 Mar 2026 13:25:51 +0000 Subject: [PATCH 2/2] fix(server): wrap openDatabase in Effect.try to prevent unhandled throws --- apps/server/src/persistence/NodeSqliteClient.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts index 1d6e22d9b0..12e38e409d 100644 --- a/apps/server/src/persistence/NodeSqliteClient.ts +++ b/apps/server/src/persistence/NodeSqliteClient.ts @@ -86,7 +86,10 @@ const makeWithDatabase = ( const makeConnection = Effect.gen(function* () { const scope = yield* Effect.scope; - const db = openDatabase(); + const db = yield* Effect.try({ + try: () => openDatabase(), + catch: (cause) => new SqlError({ cause, message: "Failed to open database" }), + }).pipe(Effect.orDie); yield* Scope.addFinalizer( scope, Effect.sync(() => db.close()),