Skip to content

Commit 13aa882

Browse files
feat(server): t3 doctor can delete the database from before 0.1.0 (#20948)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7e0418a commit 13aa882

5 files changed

Lines changed: 223 additions & 85 deletions

File tree

‎apps/server/src/cli/doctor.test.ts‎

Lines changed: 46 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,46 +7,51 @@ import * as BootService from "../cloud/bootService.ts";
77
import { runDoctor } from "./doctor.ts";
88
import { DatabaseMaintenance } from "../persistence/DatabaseMaintenance.ts";
99

10-
it.effect("--fix reclaims space with the service paused, and --dry-run changes nothing", () =>
11-
Effect.gen(function* () {
12-
const calls: string[] = [];
13-
const doctor = (dryRun: boolean) =>
14-
runDoctor("/test/statev2.sqlite", { fix: true, dryRun, interactive: false }).pipe(
15-
Effect.provide(
16-
Layer.mergeAll(
17-
NodeServices.layer,
18-
Layer.mock(BootService.BootService)({
19-
withStopped: (operation) =>
20-
Effect.sync(() => calls.push("pause")).pipe(
21-
Effect.andThen(operation),
22-
Effect.ensuring(Effect.sync(() => calls.push("resume"))),
23-
),
24-
}),
25-
Layer.succeed(DatabaseMaintenance, {
26-
// An unconverted database: reclaiming space is recommended.
27-
inspect: () =>
28-
Effect.succeed({
29-
databaseBytes: 2000,
30-
reclaimableBytes: 1000,
31-
incrementalVacuumEnabled: false,
32-
}),
33-
compact: () =>
34-
Effect.sync(() => {
35-
calls.push("compact");
36-
return {
37-
beforeBytes: 2000,
38-
afterBytes: 1000,
39-
deletedEventCount: 1,
40-
deletedReceiptCount: 0,
41-
};
42-
}),
43-
}),
10+
it.effect(
11+
"--fix reclaims space with the service paused and keeps the V1 database, and --dry-run changes nothing",
12+
() =>
13+
Effect.gen(function* () {
14+
const calls: string[] = [];
15+
const doctor = (dryRun: boolean) =>
16+
runDoctor("/test/statev2.sqlite", { fix: true, dryRun, interactive: false }).pipe(
17+
Effect.provide(
18+
Layer.mergeAll(
19+
NodeServices.layer,
20+
Layer.mock(BootService.BootService)({
21+
withStopped: (operation) =>
22+
Effect.sync(() => calls.push("pause")).pipe(
23+
Effect.andThen(operation),
24+
Effect.ensuring(Effect.sync(() => calls.push("resume"))),
25+
),
26+
}),
27+
Layer.succeed(DatabaseMaintenance, {
28+
// An unconverted database: reclaiming space is recommended.
29+
// A database from before 0.1.0 sits beside it; --fix must keep it.
30+
inspect: () =>
31+
Effect.succeed({
32+
databaseBytes: 2000,
33+
reclaimableBytes: 1000,
34+
incrementalVacuumEnabled: false,
35+
legacyDatabaseBytes: 4000,
36+
}),
37+
compact: () =>
38+
Effect.sync(() => {
39+
calls.push("compact");
40+
return {
41+
beforeBytes: 2000,
42+
afterBytes: 1000,
43+
deletedEventCount: 1,
44+
deletedReceiptCount: 0,
45+
};
46+
}),
47+
removeLegacyDatabase: () => Effect.sync(() => calls.push("remove legacy")),
48+
}),
49+
),
4450
),
45-
),
46-
);
47-
yield* doctor(true);
48-
assert.deepEqual(calls, []);
49-
yield* doctor(false);
50-
assert.deepEqual(calls, ["pause", "compact", "resume"]);
51-
}),
51+
);
52+
yield* doctor(true);
53+
assert.deepEqual(calls, []);
54+
yield* doctor(false);
55+
assert.deepEqual(calls, ["pause", "compact", "resume"]);
56+
}),
5257
);

‎apps/server/src/cli/doctor.ts‎

Lines changed: 57 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,26 +25,47 @@ export const runDoctor = Effect.fn("runDoctor")(function* (
2525
yield* Console.log(
2626
`Database: ${formatBytes(status.databaseBytes)}, ${formatBytes(status.reclaimableBytes)} reclaimable.`,
2727
);
28-
if (status.incrementalVacuumEnabled && status.reclaimableBytes === 0) {
29-
yield* Console.log("Nothing to repair.");
30-
return;
28+
if (status.legacyDatabaseBytes !== null) {
29+
yield* Console.log(
30+
`Database from before 0.1.0: ${formatBytes(status.legacyDatabaseBytes)}, kept for rolling back.`,
31+
);
3132
}
32-
const recommended =
33-
!status.incrementalVacuumEnabled ||
34-
status.reclaimableBytes >= DATABASE_COMPACTION_RECOMMENDED_BYTES;
3533
const repairs = [
36-
{
37-
id: "database" as const,
38-
title: "Reclaim database space",
39-
description: status.incrementalVacuumEnabled
40-
? "Returns unused space to disk."
41-
: "Returns unused space to disk and turns on automatic shrinking.",
42-
recommended,
43-
},
34+
...(status.incrementalVacuumEnabled && status.reclaimableBytes === 0
35+
? []
36+
: [
37+
{
38+
id: "database" as const,
39+
title: "Reclaim database space",
40+
description: `${
41+
status.incrementalVacuumEnabled
42+
? "Returns unused space to disk."
43+
: "Returns unused space to disk and turns on automatic shrinking."
44+
}\n Pauses the background service, if installed, while it runs and may need temporary disk space.`,
45+
recommended:
46+
!status.incrementalVacuumEnabled ||
47+
status.reclaimableBytes >= DATABASE_COMPACTION_RECOMMENDED_BYTES,
48+
},
49+
]),
50+
...(status.legacyDatabaseBytes === null
51+
? []
52+
: [
53+
{
54+
id: "legacy" as const,
55+
title: "Delete the database from before 0.1.0",
56+
description: `Frees ${formatBytes(status.legacyDatabaseBytes)}. Your threads and settings are already in the current database.\n Warning: after this, a T3 Code version before 0.1.0 starts with no threads or settings, so you can no longer roll back.`,
57+
// Never chosen for you: deleting it gives up rolling back.
58+
recommended: false,
59+
},
60+
]),
4461
];
62+
if (repairs.length === 0) {
63+
yield* Console.log("Nothing to repair.");
64+
return;
65+
}
4566
for (const repair of repairs) {
4667
yield* Console.log(
47-
`\n${repair.title}${repair.recommended ? " (recommended)" : ""}\n ${repair.description}\n Pauses the background service, if installed, while it runs and may need temporary disk space.`,
68+
`\n${repair.title}${repair.recommended ? " (recommended)" : ""}\n ${repair.description}`,
4869
);
4970
}
5071
if (options.dryRun) {
@@ -78,21 +99,29 @@ export const runDoctor = Effect.fn("runDoctor")(function* (
7899
);
79100
return;
80101
}
81-
const service = yield* BootService.BootService;
82-
yield* Console.log("\nReclaiming database space. This can take several minutes...");
83-
// Report inside the pause: a repair that succeeded is worth knowing about
84-
// even when starting the service again fails afterwards.
85-
yield* service.withStopped(
86-
maintenance
87-
.compact(databasePath)
88-
.pipe(
89-
Effect.tap((result) =>
90-
Console.log(
91-
`Done: ${formatBytes(result.beforeBytes)} -> ${formatBytes(result.afterBytes)}, ${result.deletedEventCount} superseded events removed.`,
102+
if (selected.includes("database")) {
103+
const service = yield* BootService.BootService;
104+
yield* Console.log("\nReclaiming database space. This can take several minutes...");
105+
// Report inside the pause: a repair that succeeded is worth knowing about
106+
// even when starting the service again fails afterwards.
107+
yield* service.withStopped(
108+
maintenance
109+
.compact(databasePath)
110+
.pipe(
111+
Effect.tap((result) =>
112+
Console.log(
113+
`Done: ${formatBytes(result.beforeBytes)} -> ${formatBytes(result.afterBytes)}, ${result.deletedEventCount} superseded events removed.`,
114+
),
92115
),
93116
),
94-
),
95-
);
117+
);
118+
}
119+
if (selected.includes("legacy")) {
120+
// Only versions before 0.1.0 open this file, so the service keeps running;
121+
// the removal itself refuses while one of those still has it open.
122+
yield* maintenance.removeLegacyDatabase(databasePath);
123+
yield* Console.log("\nDeleted the database from before 0.1.0.");
124+
}
96125
});
97126

98127
export const doctorCommand = Command.make("doctor", {

‎apps/server/src/persistence/DatabaseMaintenance.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,45 @@ it.layer(DatabaseMaintenance.layer.pipe(Layer.provideMerge(NodeServices.layer)))
3131
}).pipe(Effect.scoped),
3232
);
3333

34+
it.effect("reports and deletes only the database from before 0.1.0", () =>
35+
Effect.gen(function* () {
36+
const fs = yield* FileSystem.FileSystem;
37+
const path = yield* Path.Path;
38+
const maintenance = yield* DatabaseMaintenance.DatabaseMaintenance;
39+
const userdata = yield* fs.makeTempDirectoryScoped({ prefix: "t3-legacy-" });
40+
const dbPath = path.join(userdata, "statev2.sqlite");
41+
const legacy = path.join(userdata, "state.sqlite");
42+
yield* Effect.gen(function* () {
43+
const sql = yield* SqlClient.SqlClient;
44+
yield* sql`CREATE TABLE retained_data (value TEXT)`;
45+
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: dbPath })));
46+
assert.isNull((yield* maintenance.inspect(dbPath)).legacyDatabaseBytes);
47+
48+
const openLegacy = NodeSqliteClient.layer({ filename: legacy });
49+
yield* Effect.gen(function* () {
50+
const sql = yield* SqlClient.SqlClient;
51+
yield* sql`PRAGMA journal_mode = WAL`;
52+
yield* sql`CREATE TABLE v1_data (value TEXT)`;
53+
}).pipe(Effect.provide(openLegacy));
54+
assert.isAbove((yield* maintenance.inspect(dbPath)).legacyDatabaseBytes ?? 0, 0);
55+
56+
// A version before 0.1.0 still running against it keeps it.
57+
const refused = yield* Effect.gen(function* () {
58+
const sql = yield* SqlClient.SqlClient;
59+
yield* sql`SELECT value FROM v1_data`;
60+
return yield* Effect.flip(maintenance.removeLegacyDatabase(dbPath));
61+
}).pipe(Effect.provide(openLegacy));
62+
assert.equal(refused._tag, "DatabaseInUseError");
63+
assert.isTrue(yield* fs.exists(legacy));
64+
65+
yield* maintenance.removeLegacyDatabase(dbPath);
66+
assert.isFalse(yield* fs.exists(legacy));
67+
assert.isFalse(yield* fs.exists(`${legacy}-wal`));
68+
assert.isTrue(yield* fs.exists(dbPath));
69+
assert.isNull((yield* maintenance.inspect(dbPath)).legacyDatabaseBytes);
70+
}).pipe(Effect.scoped),
71+
);
72+
3473
it.effect("compacts an existing database offline only once its server has stopped", () =>
3574
Effect.gen(function* () {
3675
const fs = yield* FileSystem.FileSystem;

0 commit comments

Comments
 (0)