diff --git a/apps/server/src/persistence/DatabaseMaintenance.test.ts b/apps/server/src/persistence/DatabaseMaintenance.test.ts index 91a538a945d20..381b143923adf 100644 --- a/apps/server/src/persistence/DatabaseMaintenance.test.ts +++ b/apps/server/src/persistence/DatabaseMaintenance.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; @@ -44,6 +45,7 @@ it.layer(DatabaseMaintenance.layer.pipe(Layer.provideMerge(NodeServices.layer))) yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`CREATE TABLE retained_data (value TEXT)`; + yield* sql`INSERT INTO retained_data VALUES ('keep')`; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: dbPath }))); assert.isNull((yield* maintenance.inspect(dbPath)).legacyDatabaseBytes); @@ -52,21 +54,52 @@ it.layer(DatabaseMaintenance.layer.pipe(Layer.provideMerge(NodeServices.layer))) const sql = yield* SqlClient.SqlClient; yield* sql`PRAGMA journal_mode = WAL`; yield* sql`CREATE TABLE v1_data (value TEXT)`; + yield* sql`INSERT INTO v1_data VALUES ('rollback')`; }).pipe(Effect.provide(openLegacy)); assert.isAbove((yield* maintenance.inspect(dbPath)).legacyDatabaseBytes ?? 0, 0); const refused = yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`SELECT value FROM v1_data`; - return yield* Effect.flip(maintenance.removeLegacyDatabase(dbPath)); + const failure = yield* Effect.flip(maintenance.removeLegacyDatabase(dbPath)); + assert.deepEqual(yield* sql`SELECT value FROM v1_data`, [{ value: "rollback" }]); + return failure; }).pipe(Effect.provide(openLegacy)); assert.equal(refused._tag, "DatabaseInUseError"); assert.isTrue(yield* fs.exists(legacy)); - yield* maintenance.removeLegacyDatabase(dbPath); + yield* Effect.flatMap(DatabaseMaintenance.DatabaseMaintenance, (service) => + service.removeLegacyDatabase(dbPath), + ).pipe( + Effect.provide(Layer.fresh(DatabaseMaintenance.layer)), + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(FileSystem.FileSystem, { + ...fs, + remove: (target, options) => + Effect.gen(function* () { + if (target === legacy) { + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`PRAGMA busy_timeout = 0`; + yield* sql`BEGIN EXCLUSIVE`; + yield* sql`COMMIT`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: legacy }))); + // Create sidecars after the probe closes so only Doctor can remove them. + yield* fs.writeFileString(`${legacy}-wal`, "leftover WAL"); + yield* fs.writeFileString(`${legacy}-shm`, "leftover SHM"); + } + yield* fs.remove(target, options); + }), + }), + ); assert.isFalse(yield* fs.exists(legacy)); assert.isFalse(yield* fs.exists(`${legacy}-wal`)); + assert.isFalse(yield* fs.exists(`${legacy}-shm`)); assert.isTrue(yield* fs.exists(dbPath)); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + assert.deepEqual(yield* sql`SELECT value FROM retained_data`, [{ value: "keep" }]); + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: dbPath, readonly: true }))); assert.isNull((yield* maintenance.inspect(dbPath)).legacyDatabaseBytes); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/persistence/DatabaseMaintenance.ts b/apps/server/src/persistence/DatabaseMaintenance.ts index 36dad36d90e7a..4a97e6dcace98 100644 --- a/apps/server/src/persistence/DatabaseMaintenance.ts +++ b/apps/server/src/persistence/DatabaseMaintenance.ts @@ -1,3 +1,4 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -220,13 +221,23 @@ const make = Effect.gen(function* () { function* (databasePath: string) { const legacy = legacyPath(databasePath); if (!(yield* fs.exists(legacy))) return; - // Older servers may use this file; hold its lock and delete the database before WAL to preserve commits on failure. - yield* Effect.gen(function* () { - yield* lockExclusively(legacy); - for (const suffix of ["", "-wal", "-shm"]) { - yield* fs.remove(legacy + suffix, { force: true }); - } - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: legacy }))); + // Delete the database before WAL to preserve commits if unlink fails. + const removeFiles = Effect.forEach( + ["", "-wal", "-shm"], + (suffix) => fs.remove(legacy + suffix, { force: true }), + { discard: true }, + ); + const connection = NodeSqliteClient.layer({ filename: legacy }); + if ((yield* HostProcess.Platform) === "win32") { + // Windows blocks unlink while any SQLite handle is open, including our own. + yield* lockExclusively(legacy).pipe(Effect.provide(connection)); + yield* removeFiles; + } else { + yield* lockExclusively(legacy).pipe( + Effect.andThen(removeFiles), + Effect.provide(connection), + ); + } }, (effect, databasePath) => effect.pipe(