Skip to content

Commit 8a7d945

Browse files
feat: suggest t3 doctor when the database has space to reclaim
The server now streams its database size, free pages, and auto_vacuum mode over `server.getDatabaseStorage`, republished after each maintenance pass. Settings > Storage (web) and Settings > Maintenance (mobile) show a dismissible notice with the exact `t3 doctor --base-dir` command when a legacy-mode database holds at least 1 GiB of freed pages; the threshold lives next to the contract as shouldRecommendDatabaseCompaction. Environments without the `databaseStorage` capability show nothing. Co-Authored-By: Claude Fable 5.1 <[email protected]>
1 parent 65e9de5 commit 8a7d945

18 files changed

Lines changed: 321 additions & 13 deletions

File tree

‎apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
SettingsEnvironmentFilterHeader,
2727
} from "./components/SettingsEnvironmentFilterHeader";
2828
import { BranchNamingSettings } from "./components/BranchNamingSettings";
29+
import { DatabaseStorageNotice } from "./components/DatabaseStorageNotice";
2930
import { SettingsChoiceRow } from "./components/SettingsChoiceRow";
3031
import { SettingsSection } from "./components/SettingsSection";
3132
import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
@@ -428,6 +429,15 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
428429

429430
{props.page === "maintenance" ? (
430431
<>
432+
{!projectSelected &&
433+
selectedTargets
434+
.filter(
435+
(target) =>
436+
target.serverConfig.environment.capabilities.databaseStorage === true,
437+
)
438+
.map((target) => (
439+
<DatabaseStorageNotice key={target.environmentId} target={target} />
440+
))}
431441
{!projectSelected ? (
432442
<SettingsSection title="Manage environments">
433443
{selectedTargets.map((target) => (
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { useAtomSet, useAtomValue } from "@effect/atom-react";
2+
import { shouldRecommendDatabaseCompaction } from "@t3tools/contracts";
3+
import { AsyncResult } from "effect/reactivity";
4+
import { View } from "react-native";
5+
6+
import { AppText as Text } from "../../../components/AppText";
7+
import { copyTextWithHaptic } from "../../../lib/copyTextWithHaptic";
8+
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences";
9+
import { serverEnvironment } from "../../../state/server";
10+
import type { SettingsTarget } from "../settings-environment-filter";
11+
import { SettingsActionRow } from "./SettingsActionRow";
12+
import { SettingsSection } from "./SettingsSection";
13+
14+
/** Suggests `t3 doctor` for a database that still holds at least 1 GiB of freed pages. */
15+
export function DatabaseStorageNotice({ target }: { readonly target: SettingsTarget }) {
16+
const result = useAtomValue(
17+
serverEnvironment.databaseStorage({ environmentId: target.environmentId, input: {} }),
18+
);
19+
const preferences = useAtomValue(mobilePreferencesAtom);
20+
const updatePreferences = useAtomSet(updateMobilePreferencesAtom);
21+
const dismissed =
22+
AsyncResult.isSuccess(preferences) &&
23+
preferences.value.dismissedDatabaseStorageNotices?.includes(target.environmentId) === true;
24+
const status = result._tag === "Success" ? result.value : null;
25+
if (dismissed || status === null || !shouldRecommendDatabaseCompaction(status)) return null;
26+
const gib = (status.reclaimableBytes / 1024 ** 3).toFixed(1);
27+
return (
28+
<SettingsSection title={`Reclaim ${gib} GiB on ${target.label}`}>
29+
<View className="gap-3 p-4">
30+
<Text className="text-sm text-muted-foreground">
31+
Old history is cleaned up automatically, but this database still holds the freed space.
32+
Run this command on {target.label} to return it to disk; it pauses and restarts the
33+
background service. Quit the desktop app first if it hosts this environment.
34+
</Text>
35+
<Text selectable className="font-mono text-sm text-foreground">
36+
{status.compactCommand}
37+
</Text>
38+
</View>
39+
<SettingsActionRow
40+
icon="doc.on.doc"
41+
label="Copy command"
42+
onPress={() => copyTextWithHaptic(status.compactCommand, { target: "command" })}
43+
/>
44+
<SettingsActionRow
45+
icon="xmark"
46+
label="Dismiss"
47+
onPress={() =>
48+
updatePreferences({
49+
transform: (current) => ({
50+
dismissedDatabaseStorageNotices: [
51+
...new Set([
52+
...(current.dismissedDatabaseStorageNotices ?? []),
53+
target.environmentId,
54+
]),
55+
],
56+
}),
57+
})
58+
}
59+
/>
60+
</SettingsSection>
61+
);
62+
}

‎apps/mobile/src/persistence/mobile-preferences.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ export interface Preferences {
6767
readonly codeWordBreak?: boolean;
6868
readonly connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
6969
readonly collapsedProjectGroups?: readonly string[];
70+
/** Environments whose "reclaim database space" notice was dismissed. */
71+
readonly dismissedDatabaseStorageNotices?: readonly string[];
7072
/** What the Return key does in the composer on a hardware keyboard. iOS only. */
7173
readonly composerEnterBehavior?: ComposerEnterBehavior;
7274
/**
@@ -150,6 +152,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
150152
codeWordBreak?: boolean;
151153
connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
152154
collapsedProjectGroups?: readonly string[];
155+
dismissedDatabaseStorageNotices?: readonly string[];
153156
composerEnterBehavior?: ComposerEnterBehavior;
154157
followUpBehavior?: FollowUpBehavior;
155158
microphones?: ReadonlyArray<MicrophoneEntry>;
@@ -226,6 +229,11 @@ function sanitizePreferences(parsed: Preferences): Preferences {
226229
(key): key is string => typeof key === "string",
227230
);
228231
}
232+
if (Array.isArray(parsed.dismissedDatabaseStorageNotices)) {
233+
preferences.dismissedDatabaseStorageNotices = parsed.dismissedDatabaseStorageNotices.filter(
234+
(value): value is string => typeof value === "string",
235+
);
236+
}
229237
if (parsed.composerEnterBehavior === "send" || parsed.composerEnterBehavior === "newline") {
230238
preferences.composerEnterBehavior = parsed.composerEnterBehavior;
231239
}

‎apps/server/src/auth/RpcAuthorization.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export const RPC_REQUIRED_SCOPES = {
8585
[WS_METHODS.serverUpsertKeybinding]: AuthSettingsWriteScope,
8686
[WS_METHODS.serverRemoveKeybinding]: AuthSettingsWriteScope,
8787
[WS_METHODS.serverGetStorageCleanupReport]: AuthOrchestrationReadScope,
88+
[WS_METHODS.serverGetDatabaseStorage]: AuthOrchestrationReadScope,
8889
[WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope,
8990
[WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope,
9091
[WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope,

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as Path from "effect/Path";
77

88
import {
99
formatCliCommand,
10+
resolveHostCliCommand,
1011
resolveRootCliCommand,
1112
resolveServerInstallation,
1213
} from "./invocation.ts";
@@ -179,6 +180,42 @@ it.layer(NodeServices.layer)("root CLI commands", (it) => {
179180
);
180181
});
181182

183+
it.layer(NodeServices.layer)("host CLI commands", (it) => {
184+
it.effect("renders a PowerShell command line on Windows", () =>
185+
Effect.gen(function* () {
186+
const fs = yield* FileSystem.FileSystem;
187+
const path = yield* Path.Path;
188+
const home = yield* fs.makeTempDirectoryScoped();
189+
const shim = path.join(home, "t3.cmd");
190+
yield* fs.writeFileString(shim, "@echo off\r\n");
191+
const hostCommand = (platform: NodeJS.Platform, env: Record<string, string>) =>
192+
resolveHostCliCommand("doctor", ["--base-dir", "C:\\Users\\Jane Doe\\.t3"]).pipe(
193+
Effect.provideService(HostProcess.ExecutablePath, "C:\\Program Files\\T3 Code\\t3.exe"),
194+
Effect.provideService(HostProcess.Arguments, [
195+
"C:\\Program Files\\T3 Code\\t3.exe",
196+
"C:\\Program Files\\T3 Code\\resources\\app.asar\\apps\\server\\dist\\bin.mjs",
197+
]),
198+
Effect.provideService(HostProcess.IsExecutable, false),
199+
Effect.provideService(HostProcess.Platform, platform),
200+
Effect.provideService(HostProcess.Environment, { PATH: "", ...env }),
201+
);
202+
// The desktop shim is called through `&`, and each argument is quoted
203+
// the PowerShell way: no POSIX single quotes anywhere.
204+
expect((yield* hostCommand("win32", { T3CODE_CLI_PATH: shim })).command).toBe(
205+
`& "${shim}" doctor "--base-dir" "C:\\Users\\Jane Doe\\.t3"`,
206+
);
207+
// Without a launcher, plain `t3` with PowerShell-quoted arguments.
208+
expect((yield* hostCommand("win32", {})).command).toBe(
209+
't3 doctor "--base-dir" "C:\\Users\\Jane Doe\\.t3"',
210+
);
211+
// POSIX hosts keep single quotes for the same input.
212+
expect((yield* hostCommand("linux", { T3CODE_CLI_PATH: shim })).command).toBe(
213+
`${shim} doctor --base-dir 'C:\\Users\\Jane Doe\\.t3'`,
214+
);
215+
}).pipe(Effect.scoped),
216+
);
217+
});
218+
182219
it.layer(NodeServices.layer)("manual server installation ownership", (it) => {
183220
it.effect("recognizes runner caches for both script and executable packages", () =>
184221
Effect.gen(function* () {

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

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,18 @@ export const resolveCliCommand = (subcommand: string) =>
147147
const shellWord = (value: string) =>
148148
/^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`;
149149

150+
/**
151+
* Quotes `value` for PowerShell, the default Windows terminal. Always quoted,
152+
* so a path reads the same whether or not it holds a space. Inside double
153+
* quotes `"` doubles, and `$` and the backtick are escaped so nothing expands.
154+
*/
155+
const powershellWord = (value: string) =>
156+
`"${value.replaceAll("`", "``").replaceAll("$", "`$").replaceAll('"', '""')}"`;
157+
158+
/** Quotes a word for the host's shell: POSIX, or PowerShell on Windows. */
159+
const hostShellWord = (platform: NodeJS.Platform, value: string) =>
160+
platform === "win32" ? powershellWord(value) : shellWord(value);
161+
150162
/**
151163
* The launcher a person can type to run this install when `t3` is not on
152164
* PATH: the desktop app's `t3` shim, which the app and the shim itself name in
@@ -165,19 +177,26 @@ const resolveInstallLauncher = Effect.gen(function* () {
165177
});
166178

167179
/**
168-
* `t3 <subcommand>` for a person to run on this host: `t3` when it is on PATH,
169-
* the package runner this process came from, or else the absolute path of the
170-
* launcher for this install, such as the one the desktop app installs.
180+
* `t3 <subcommand> <args>` for a person to run on this host: `t3` when it is
181+
* on PATH, the package runner this process came from, or else the absolute
182+
* path of the launcher for this install, such as the one the desktop app
183+
* installs. `args` are quoted for the host's shell: POSIX, or PowerShell on
184+
* Windows.
171185
*/
172-
const resolveHostCliCommand = (subcommand: string) =>
186+
export const resolveHostCliCommand = (subcommand: string, args: ReadonlyArray<string> = []) =>
173187
Effect.gen(function* () {
188+
const platform = yield* HostProcess.Platform;
189+
const withArgs = (prefix: string) =>
190+
[prefix, ...args.map((arg) => hostShellWord(platform, arg))].join(" ");
174191
const command = yield* resolveCliCommand(subcommand);
175-
if (command !== `t3 ${subcommand}`) return { command, launcher: false };
176-
if (yield* isCommandAvailable("t3")) return { command, launcher: false };
192+
if (command !== `t3 ${subcommand}`) return { command: withArgs(command), launcher: false };
193+
if (yield* isCommandAvailable("t3")) return { command: withArgs(command), launcher: false };
177194
const launcher = yield* resolveInstallLauncher;
178-
return Option.isSome(launcher)
179-
? { command: `${shellWord(launcher.value)} ${subcommand}`, launcher: true }
180-
: { command, launcher: false };
195+
if (Option.isNone(launcher)) return { command: withArgs(command), launcher: false };
196+
// PowerShell runs a quoted path only through the call operator.
197+
const quoted = hostShellWord(platform, launcher.value);
198+
const call = platform === "win32" ? `& ${quoted}` : quoted;
199+
return { command: withArgs(`${call} ${subcommand}`), launcher: true };
181200
});
182201

183202
/**

‎apps/server/src/environment/ServerEnvironment.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ export const make = Effect.gen(function* () {
229229
threadAutoSettlement: true,
230230
storageCleanup: true,
231231
storageCleanupRun: true,
232+
databaseStorage: true,
232233
otlpEndpointCheck: true,
233234
projectWorktreeCleanup: true,
234235
worktreesDirectory: true,

‎apps/server/src/observability/RpcInstrumentation.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const RPC_AGGREGATES = {
5757
[WS_METHODS.serverRemoveKeybinding]: "server",
5858
[WS_METHODS.serverRunStorageCleanup]: "server",
5959
[WS_METHODS.serverGetStorageCleanupReport]: "server",
60+
[WS_METHODS.serverGetDatabaseStorage]: "server",
6061
[WS_METHODS.serverGetSettings]: "server",
6162
[WS_METHODS.serverUpdateSettings]: "server",
6263
[WS_METHODS.serverSearchAcpRegistry]: "server",

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import * as NodeServices from "@effect/platform-node/NodeServices";
12
import { assert, it } from "@effect/vitest";
23
import * as Effect from "effect/Effect";
34
import * as Layer from "effect/Layer";
45
import * as SqlClient from "effect/sql/SqlClient";
56

7+
import * as ServerConfig from "../config.ts";
68
import * as EventStore from "../orchestration-v2/EventStore.ts";
79
import * as ProjectionMaintenance from "../orchestration-v2/ProjectionMaintenance.ts";
810
import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
@@ -37,6 +39,8 @@ it.effect("a new database returns freed pages online without losing data", () =>
3739
Layer.provide(ProjectionMaintenance.layer),
3840
Layer.provide(Layer.merge(EventStore.layer, ProjectionStore.layer)),
3941
Layer.provideMerge(Sqlite.layerMemory),
42+
Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "database-storage-" })),
43+
Layer.provide(NodeServices.layer),
4044
),
4145
),
4246
),

‎apps/server/src/persistence/DatabaseStorage.ts‎

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1+
import type { DatabaseStorageStatus } from "@t3tools/contracts";
12
import * as Context from "effect/Context";
23
import * as Effect from "effect/Effect";
4+
import * as Equal from "effect/Equal";
35
import * as Layer from "effect/Layer";
6+
import * as Path from "effect/Path";
47
import * as Schedule from "effect/Schedule";
8+
import * as Stream from "effect/Stream";
9+
import * as SubscriptionRef from "effect/SubscriptionRef";
510
import * as SqlClient from "effect/sql/SqlClient";
611

12+
import { resolveHostCliCommand } from "../cli/invocation.ts";
13+
import * as ServerConfig from "../config.ts";
714
import * as ProjectionMaintenance from "../orchestration-v2/ProjectionMaintenance.ts";
815
import { forkParked } from "../serverActivation.ts";
916

@@ -15,6 +22,8 @@ export class DatabaseStorage extends Context.Service<
1522
{
1623
/** Return free pages to the filesystem on databases with incremental auto_vacuum. */
1724
readonly reclaim: Effect.Effect<void>;
25+
/** Current file accounting, re-published after each maintenance pass. Null until first read. */
26+
readonly status: Stream.Stream<DatabaseStorageStatus | null>;
1827
}
1928
>()("t3/persistence/DatabaseStorage") {}
2029

@@ -29,6 +38,42 @@ export const layer = Layer.effect(
2938
Effect.gen(function* () {
3039
const sql = yield* SqlClient.SqlClient;
3140
const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2;
41+
const config = yield* ServerConfig.ServerConfig;
42+
const path = yield* Path.Path;
43+
const statusRef = yield* SubscriptionRef.make<DatabaseStorageStatus | null>(null);
44+
45+
// The notice shows this verbatim; quote for the host's shell. Desktop
46+
// installs have no `t3` on PATH, so the command names their launcher.
47+
const { command: compactCommand } = yield* resolveHostCliCommand("doctor", [
48+
"--base-dir",
49+
config.baseDir,
50+
]);
51+
// `t3 doctor --base-dir` repairs <baseDir>/userdata. A dev server without
52+
// an explicit home keeps its state in <baseDir>/dev instead, so there the
53+
// command would target the live install; publish no status at all.
54+
const doctorTargetsThisDatabase =
55+
path.resolve(config.dbPath) ===
56+
path.resolve(config.baseDir, "userdata", path.basename(config.dbPath));
57+
const publishStatus = Effect.gen(function* () {
58+
if (!doctorTargetsThisDatabase) return;
59+
const pages = yield* sql<{ readonly page_count: number }>`PRAGMA page_count`;
60+
const free = yield* sql<{ readonly freelist_count: number }>`PRAGMA freelist_count`;
61+
const size = yield* sql<{ readonly page_size: number }>`PRAGMA page_size`;
62+
const mode = yield* sql<{ readonly auto_vacuum: number }>`PRAGMA auto_vacuum`;
63+
const pageSize = size[0]?.page_size ?? 0;
64+
const next: DatabaseStorageStatus = {
65+
databaseBytes: (pages[0]?.page_count ?? 0) * pageSize,
66+
reclaimableBytes: (free[0]?.freelist_count ?? 0) * pageSize,
67+
incrementalVacuumEnabled: mode[0]?.auto_vacuum === 2,
68+
compactCommand,
69+
};
70+
// Every set reaches each open Storage page; most passes change nothing.
71+
if (!Equal.equals(yield* SubscriptionRef.get(statusRef), next)) {
72+
yield* SubscriptionRef.set(statusRef, next);
73+
}
74+
}).pipe(
75+
Effect.catch((cause) => Effect.logWarning("Database storage inspection failed", { cause })),
76+
);
3277

3378
const reclaim = Effect.gen(function* () {
3479
const mode = yield* sql<{ readonly auto_vacuum: number }>`PRAGMA auto_vacuum`;
@@ -59,15 +104,20 @@ export const layer = Layer.effect(
59104

60105
yield* forkParked(
61106
Effect.sleep("1 minute").pipe(
62-
Effect.andThen(compact.pipe(Effect.repeat(Schedule.spaced("6 hours")))),
107+
Effect.andThen(
108+
compact.pipe(Effect.andThen(publishStatus), Effect.repeat(Schedule.spaced("6 hours"))),
109+
),
63110
),
64111
);
65112
yield* forkParked(
66-
Effect.sleep("1 minute").pipe(
67-
Effect.andThen(reclaim.pipe(Effect.repeat(Schedule.spaced("1 minute")))),
113+
publishStatus.pipe(
114+
Effect.andThen(Effect.sleep("1 minute")),
115+
Effect.andThen(
116+
reclaim.pipe(Effect.andThen(publishStatus), Effect.repeat(Schedule.spaced("1 minute"))),
117+
),
68118
),
69119
);
70120

71-
return DatabaseStorage.of({ reclaim });
121+
return DatabaseStorage.of({ reclaim, status: SubscriptionRef.changes(statusRef) });
72122
}),
73123
);

0 commit comments

Comments
 (0)