Skip to content
Merged
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
37 changes: 35 additions & 2 deletions apps/server/src/persistence/DatabaseMaintenance.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);

Expand All @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}),
}),
);
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),
);
Expand Down
25 changes: 18 additions & 7 deletions apps/server/src/persistence/DatabaseMaintenance.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
Expand Down
Loading