1import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import * as Predicate from "effect/Predicate";
6import * as Schema from "effect/Schema";
7import * as SqlClient from "effect/sql/SqlClient";
8import * as SqlError from "effect/sql/SqlError";
9import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
10
11import { hasPendingMigrations, runMigrations } from "./Migrations.ts";
12import { initializeV2Database } from "./initializeV2Database.ts";
13import * as ServerConfig from "../config.ts";
14import * as ServerOwnership from "../serverOwnership.ts";
15
16const WAL_SIZE_LIMIT_BYTES = 128 * 1024 * 1024;
17
18export class ServerOwnsDatabaseError extends Schema.TaggedError<ServerOwnsDatabaseError>()(
19 "ServerOwnsDatabaseError",
20 { stateDir: Schema.String },
21) {
22 override get message(): string {
23 return `Another T3 Code process, usually a running server, owns ${this.stateDir}, and this CLI would change its database schema. Use the \`t3\` that matches the running server, or stop that server, then retry. The database was not changed.`;
24 }
25}
26
27// SQLITE_CORRUPT and SQLITE_NOTADB; node:sqlite reports extended codes whose low byte is the primary code.
28const DAMAGED_SQLITE_CODES = new Set([11, 26]);
29
30const isDamagedDatabase = (error: SqlError.SqlError): boolean => {
31 const seen = new Set<unknown>();
32 let cause: unknown = error.cause;
33 while (Predicate.isObject(cause) && !seen.has(cause)) {
34 seen.add(cause);
35 if (typeof cause.errcode === "number" && DAMAGED_SQLITE_CODES.has(cause.errcode & 0xff)) {
36 return true;
37 }
38 cause = cause.cause;
39 }
40 return false;
41};
42
43// Keeps the SqlError type so callers' error channels do not change; the original stays as the cause.
44const damagedDatabaseError = (dbPath: string, cause: SqlError.SqlError) =>
45 new SqlError.SqlError({
46 reason: new SqlError.UnknownError({
47 cause,
48 operation: "open",
49 message: `The T3 Code database at ${dbPath} is damaged or is not a SQLite database, so T3 Code cannot start. To recover, quit T3 Code, then move that file and any -wal and -shm files next to it out of the folder, or replace them with a backup. T3 Code creates a new database on the next start.`,
50 }),
51 });
52
53// CLI commands open the server's database from another process; migrating behind its owner changes the schema it runs on.
54const runMigrationsAsOwner = Effect.fn("SqlitePersistence.runMigrationsAsOwner")(function* () {
55 // With nothing pending, runMigrations only validates the recorded history and writes nothing.
56 if (!(yield* hasPendingMigrations())) return yield* runMigrations();
57 const { serverRuntimeStatePath } = yield* ServerConfig.ServerConfig;
58 yield* ServerOwnership.holdServerOwnershipLock(serverRuntimeStatePath).pipe(
59 Effect.catchTags({
60 ServerAlreadyRunningError: ({ stateDir }) =>
61 Effect.fail(new ServerOwnsDatabaseError({ stateDir })),
62 }),
63 );
64 yield* runMigrations();
65}, Effect.scoped);
66
67const makeLayerSetup = <E, R>(migrate: Effect.Effect<unknown, E, R>) =>
68 Layer.effectDiscard(
69 Effect.gen(function* () {
70 const sql = yield* SqlClient.SqlClient;
71 // CLI and server write from separate processes; wait rather than fail with SQLITE_BUSY.
72 yield* sql`PRAGMA busy_timeout = 5000;`;
73 yield* sql`PRAGMA foreign_keys = ON;`;
74 // Existing databases need doctor's offline VACUUM to add pointer maps for this mode.
75 const tables = yield* sql<{
76 readonly count: number;
77 }>`SELECT COUNT(*) AS count FROM sqlite_schema WHERE type = 'table'`;
78 if (tables[0]?.count === 0) yield* sql`PRAGMA auto_vacuum = INCREMENTAL;`;
79 yield* sql`PRAGMA journal_mode = WAL;`;
80 // FULL fsyncs every event commit; NORMAL syncs only at WAL checkpoints.
81 // App crashes lose nothing; OS crashes or power loss can roll back post-checkpoint commits without corruption.
82 yield* sql`PRAGMA synchronous = NORMAL;`;
83 // macOS fsync misses drive caches; F_FULLFSYNC flushes them at checkpoints and is ignored elsewhere.
84 yield* sql`PRAGMA checkpoint_fullfsync = ON;`;
85 // Keep statement journals and sort spills off the disk.
86 // Large statements and open savepoints can increase peak memory use.
87 yield* sql`PRAGMA temp_store = MEMORY;`;
88 // PASSIVE checkpoints retain peak WAL size until the last connection closes.
89 yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`);
90 yield* migrate;
91 // Collecting stats scans every page, and stale small-table stats cause scans after growth; use indexes by default.
92 // Connections cache stats; reload after deleting them.
93 const statTables = yield* sql`SELECT 1 FROM sqlite_schema WHERE name = 'sqlite_stat1'`;
94 if (statTables.length > 0) {
95 yield* sql`DELETE FROM sqlite_stat1;`;
96 yield* sql`ANALYZE sqlite_schema;`;
97 }
98 }).pipe(Effect.withSpan("Sqlite.setup")),
99 );
100
101const layerSetup = makeLayerSetup(runMigrations());
102
103const makeLayerFromPath = <E, R>(makeSetup: (dbPath: string) => Layer.Layer<never, E, R>) =>
104 Effect.fn("makeSqlitePersistenceLive")(function* (dbPath: string) {
105 const fs = yield* FileSystem.FileSystem;
106 const path = yield* Path.Path;
107 yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true });
108
109 return Layer.provideMerge(
110 makeSetup(dbPath),
111 NodeSqliteClient.layer({
112 filename: dbPath,
113 readerWorker: true,
114 // A slow disk then delays writes instead of freezing every client.
115 writerWorker: true,
116 spanAttributes: {
117 "db.name": path.basename(dbPath),
118 "service.name": "t3code-server",
119 },
120 }),
121 ).pipe(
122 Layer.catchTag("SqlError", (error) =>
123 Layer.effect(
124 SqlClient.SqlClient,
125 Effect.fail(
126 SqlError.isSqlError(error) && isDamagedDatabase(error)
127 ? damagedDatabaseError(dbPath, error)
128 : error,
129 ),
130 ),
131 ),
132 );
133 }, Layer.unwrap);
134
135export const layerFromPath = makeLayerFromPath(() => layerSetup);
136
137export const layerMemory = Layer.provideMerge(
138 layerSetup,
139 NodeSqliteClient.layer({ filename: ":memory:" }),
140);
141
142const makeLayerConfig = <E, R>(
143 fromPath: (dbPath: string) => Layer.Layer<SqlClient.SqlClient, E, R>,
144) =>
145 Layer.unwrap(
146 Effect.gen(function* () {
147 const { dbPath } = yield* ServerConfig.ServerConfig;
148 yield* initializeV2Database(dbPath);
149 return fromPath(dbPath);
150 }),
151 );
152
153export const layerConfig = makeLayerConfig(layerFromPath);
154
155/** For CLI commands: pending migrations run only while no server owns the T3 home. */
156export const layerCliConfig = makeLayerConfig(
157 makeLayerFromPath(() => makeLayerSetup(runMigrationsAsOwner())),
158);