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
43 changes: 43 additions & 0 deletions apps/server/src/persistence/Layers/Sqlite.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> =>
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;
};
Expand Down Expand Up @@ -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);

Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/persistence/NodeSqliteClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down