1import { DATABASE_COMPACTION_RECOMMENDED_BYTES } from "@t3tools/contracts";
2import * as Clock from "effect/Clock";
3import * as Console from "effect/Console";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import { Command, Flag, GlobalFlag, Prompt } from "effect/cli";
8
9import * as BootService from "../cloud/bootService.ts";
10import * as DatabaseMaintenance from "../persistence/DatabaseMaintenance.ts";
11import * as ServerOwnership from "../serverOwnership.ts";
12import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
13import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
14import * as CliService from "./service.ts";
15
16const formatBytes = (bytes: number) =>
17 bytes >= 1024 ** 3
18 ? `${(bytes / 1024 ** 3).toFixed(2)} GiB`
19 : bytes >= 1024 ** 2
20 ? `${(bytes / 1024 ** 2).toFixed(1)} MiB`
21 : `${Math.ceil(bytes / 1024)} KiB`;
22
23const formatElapsed = (nanos: bigint) => {
24 const seconds = Number(nanos / 1_000_000_000n);
25 return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
26};
27
28const COMPACT_STEP_LABELS: Record<DatabaseMaintenance.DatabaseCompactStep, string> = {
29 "prune-events": "Removing superseded events",
30 vacuum: "Rewriting the database file",
31 "rebuild-search": "Rebuilding the search index",
32 checkpoint: "Flushing the write-ahead log",
33};
34
35// Holding the server lock refuses a server launched mid-repair, such as one started over SSH.
36// Lock only: acquireServerOwnership would also refuse the older service doctor just stopped.
37const lockServerHome = Effect.fn("runDoctor.lockServerHome")(function* (paths: {
38 readonly dbPath: string;
39 readonly serverRuntimeStatePath: string;
40}) {
41 yield* ServerOwnership.holdServerOwnershipLock(paths.serverRuntimeStatePath).pipe(
42 Effect.catchTags({
43 ServerAlreadyRunningError: Effect.fn("runDoctor.nameServerHolder")(function* (cause) {
44 const owner = yield* readPersistedServerRuntimeState(paths.serverRuntimeStatePath).pipe(
45 Effect.map(Option.filter((state) => isProcessAlive(state.pid))),
46 Effect.orElseSucceed(() => Option.none()),
47 );
48 return yield* new DatabaseMaintenance.DatabaseInUseError({
49 path: paths.dbPath,
50 cause,
51 ...(Option.isSome(owner) && {
52 pid: owner.value.pid,
53 serviceManaged: owner.value.serviceManaged === true,
54 }),
55 });
56 }),
57 }),
58 );
59});
60
61export const runDoctor = Effect.fn("runDoctor")(function* (
62 paths: { readonly dbPath: string; readonly serverRuntimeStatePath: string },
63 options: { readonly fix: boolean; readonly dryRun: boolean; readonly interactive?: boolean },
64) {
65 const databasePath = paths.dbPath;
66 const maintenance = yield* DatabaseMaintenance.DatabaseMaintenance;
67 const status = yield* maintenance.inspect(databasePath);
68 yield* Console.log(
69 `Database: ${formatBytes(status.databaseBytes)}, ${formatBytes(status.reclaimableBytes)} reclaimable.`,
70 );
71 if (status.legacyDatabaseBytes !== null) {
72 yield* Console.log(
73 `Database from before 0.1.0: ${formatBytes(status.legacyDatabaseBytes)}, kept for rolling back.`,
74 );
75 }
76 const repairs = [
77 ...(status.incrementalVacuumEnabled && status.reclaimableBytes === 0
78 ? []
79 : [
80 {
81 id: "database" as const,
82 title: "Reclaim database space",
83 description: `${
84 status.incrementalVacuumEnabled
85 ? "Returns unused space to disk."
86 : "Returns unused space to disk and turns on automatic shrinking."
87 }\n Rewrites the whole ${formatBytes(status.databaseBytes)} database, which can take more than an hour on a large database or slow disk.\n Pauses the background service, if installed, while it runs and may need temporary disk space.`,
88 recommended:
89 !status.incrementalVacuumEnabled ||
90 status.reclaimableBytes >= DATABASE_COMPACTION_RECOMMENDED_BYTES,
91 },
92 ]),
93 ...(status.legacyDatabaseBytes === null
94 ? []
95 : [
96 {
97 id: "legacy" as const,
98 title: "Delete the database from before 0.1.0",
99 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.`,
100 recommended: false,
101 },
102 ]),
103 ];
104 if (repairs.length === 0) {
105 yield* Console.log("Nothing to repair.");
106 return;
107 }
108 for (const repair of repairs) {
109 yield* Console.log(
110 `\n${repair.title}${repair.recommended ? " (recommended)" : ""}\n ${repair.description}`,
111 );
112 }
113 if (options.dryRun) {
114 yield* Console.log("\nDry run: nothing changed.");
115 return;
116 }
117 const interactive = options.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
118 if (!options.fix && !interactive) {
119 yield* Console.log(
120 "\nRun in a terminal to choose repairs, or pass --fix for the recommended ones.",
121 );
122 return;
123 }
124 const selected = options.fix
125 ? repairs.filter((repair) => repair.recommended).map((repair) => repair.id)
126 : yield* Prompt.run(
127 Prompt.MultiSelect({
128 message: "Choose repairs to run",
129 choices: repairs.map((repair) => ({
130 title: repair.title + (repair.recommended ? " (recommended)" : ""),
131 value: repair.id,
132 selected: repair.recommended,
133 })),
134 }),
135 ).pipe(Effect.catchTags({ QuitError: () => Effect.succeed([]) }));
136 if (selected.length === 0) {
137 yield* Console.log(
138 options.fix
139 ? "No recommended repairs; nothing changed."
140 : "No repairs selected; nothing changed.",
141 );
142 return;
143 }
144 if (selected.includes("database")) {
145 const service = yield* BootService.BootService;
146 yield* Console.log("\nReclaiming database space. Each step is shown as it starts.");
147 const startedAt = yield* Clock.monotonicTimeNanos;
148 const reportStep = (step: DatabaseMaintenance.DatabaseCompactStep) =>
149 Clock.monotonicTimeNanos.pipe(
150 Effect.flatMap((now) =>
151 Console.log(` [${formatElapsed(now - startedAt)}] ${COMPACT_STEP_LABELS[step]}`),
152 ),
153 );
154 // Report the repair before restarting, which can fail independently.
155 yield* service.withStopped(
156 lockServerHome(paths).pipe(
157 Effect.andThen(maintenance.compact(databasePath, reportStep)),
158 Effect.tap((result) =>
159 Clock.monotonicTimeNanos.pipe(
160 Effect.flatMap((now) =>
161 Console.log(
162 `Done in ${formatElapsed(now - startedAt)}: ${formatBytes(result.beforeBytes)} -> ${formatBytes(result.afterBytes)}, ${result.deletedEventCount} superseded events removed.`,
163 ),
164 ),
165 ),
166 ),
167 Effect.scoped,
168 ),
169 );
170 }
171 if (selected.includes("legacy")) {
172 // Only pre-0.1.0 servers use this file; removal refuses while they hold it open.
173 yield* maintenance.removeLegacyDatabase(databasePath);
174 yield* Console.log("\nDeleted the database from before 0.1.0.");
175 }
176});
177
178export const doctorCommand = Command.make("doctor", {
179 ...projectLocationFlags,
180 fix: Flag.Boolean("fix").pipe(
181 Flag.withDescription("Apply the recommended repairs without prompting."),
182 Flag.withDefault(false),
183 ),
184 dryRun: Flag.Boolean("dry-run").pipe(
185 Flag.withDescription("Show the repairs without changing anything, even with --fix."),
186 Flag.withDefault(false),
187 ),
188}).pipe(
189 Command.withDescription(
190 "Inspect this T3 Code data directory and repair it. Pauses and restarts the background service as needed.",
191 ),
192 Command.withHandler((flags) =>
193 Effect.gen(function* () {
194 const logLevel = yield* GlobalFlag.LogLevel;
195 const config = yield* resolveCliAuthConfig(flags, logLevel);
196 yield* runDoctor(config, { fix: flags.fix, dryRun: flags.dryRun }).pipe(
197 Effect.provide(Layer.merge(DatabaseMaintenance.layer, CliService.layer(config))),
198 );
199 }),
200 ),
201);