1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import * as NodeOS from "node:os";
6import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";
7import * as Console from "effect/Console";
8import * as DateTime from "effect/DateTime";
9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
11import * as Option from "effect/Option";
12import * as Path from "effect/Path";
13import * as Schema from "effect/Schema";
14import * as SqlClient from "effect/sql/SqlClient";
15import { Argument, Command, Flag } from "effect/cli";
16
17import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
18
19export const SqliteStateOperation = Schema.Literals(["query", "exec"]);
20export type SqliteStateOperation = typeof SqliteStateOperation.Type;
21
22export class SqliteStateMultipleSqlSourcesError extends Schema.TaggedError<SqliteStateMultipleSqlSourcesError>()(
23 "SqliteStateMultipleSqlSourcesError",
24 {},
25) {
26 override get message(): string {
27 return "Provide only one of --sql or --file.";
28 }
29}
30
31export class SqliteStateMissingSqlSourceError extends Schema.TaggedError<SqliteStateMissingSqlSourceError>()(
32 "SqliteStateMissingSqlSourceError",
33 {},
34) {
35 override get message(): string {
36 return "Provide one of --sql or --file.";
37 }
38}
39
40export class SqliteStateEmptySqlError extends Schema.TaggedError<SqliteStateEmptySqlError>()(
41 "SqliteStateEmptySqlError",
42 {},
43) {
44 override get message(): string {
45 return "SQL input is empty.";
46 }
47}
48
49export class SqliteStateDatabaseMissingError extends Schema.TaggedError<SqliteStateDatabaseMissingError>()(
50 "SqliteStateDatabaseMissingError",
51 {
52 databasePath: Schema.String,
53 },
54) {
55 override get message(): string {
56 return `Database does not exist at '${this.databasePath}'. Start T3 once to run migrations.`;
57 }
58}
59
60export class SqliteStateSharedHomeMutationError extends Schema.TaggedError<SqliteStateSharedHomeMutationError>()(
61 "SqliteStateSharedHomeMutationError",
62 {},
63) {
64 override get message(): string {
65 return "Refusing to mutate the shared ~/.t3 database. Use an isolated --base-dir.";
66 }
67}
68
69export class SqliteStateSqlFileError extends Schema.TaggedError<SqliteStateSqlFileError>()(
70 "SqliteStateSqlFileError",
71 {
72 filePath: Schema.String,
73 cause: Schema.Defect(),
74 },
75) {
76 override get message(): string {
77 return `Failed to read SQL from '${this.filePath}'.`;
78 }
79}
80
81export class SqliteStateDatabaseError extends Schema.TaggedError<SqliteStateDatabaseError>()(
82 "SqliteStateDatabaseError",
83 {
84 operation: SqliteStateOperation,
85 databasePath: Schema.String,
86 cause: Schema.Defect(),
87 },
88) {
89 override get message(): string {
90 return `Failed to ${this.operation} SQLite database at '${this.databasePath}'.`;
91 }
92}
93
94const SqliteStateValue = Schema.Union([
95 Schema.Null,
96 Schema.String,
97 Schema.Number,
98 Schema.Array(Schema.Number),
99]);
100const SqliteStateRow = Schema.Record(Schema.String, SqliteStateValue);
101const SqliteStateQueryResult = Schema.Struct({
102 operation: Schema.Literal("query"),
103 database: Schema.String,
104 rows: Schema.Array(SqliteStateRow),
105});
106const SqliteStateExecResult = Schema.Struct({
107 operation: Schema.Literal("exec"),
108 database: Schema.String,
109 backup: Schema.String,
110});
111const SqliteStateResult = Schema.Union([SqliteStateQueryResult, SqliteStateExecResult]);
112const encodeSqliteStateResult = Schema.encodeEffect(fromJsonStringPretty(SqliteStateResult));
113
114export type SqliteStateResult = typeof SqliteStateResult.Type;
115
116type RawSqliteValue = null | string | number | bigint | Uint8Array;
117type RawSqliteRow = Readonly<Record<string, RawSqliteValue>>;
118
119export interface RunSqliteStateInput {
120 readonly operation: SqliteStateOperation;
121 readonly baseDir: string;
122 readonly sql?: string | undefined;
123 readonly file?: string | undefined;
124}
125
126export interface RunSqliteStateOptions {
127 readonly sharedHome?: string | undefined;
128}
129
130const resolveSqlSource = Effect.fn("resolveSqliteStateSqlSource")(function* (
131 sql: string | undefined,
132 file: string | undefined,
133) {
134 if (sql !== undefined && file !== undefined) {
135 return yield* new SqliteStateMultipleSqlSourcesError();
136 }
137 if (sql === undefined && file === undefined) {
138 return yield* new SqliteStateMissingSqlSourceError();
139 }
140
141 const fs = yield* FileSystem.FileSystem;
142 const path = yield* Path.Path;
143 let source: string;
144 if (sql !== undefined) {
145 source = sql;
146 } else {
147 const filePath = path.resolve(file as string);
148 source = yield* fs
149 .readFileString(filePath)
150 .pipe(Effect.mapError((cause) => new SqliteStateSqlFileError({ filePath, cause })));
151 }
152
153 const trimmed = source.trim();
154 if (trimmed.length === 0) {
155 return yield* new SqliteStateEmptySqlError();
156 }
157 return trimmed;
158});
159
160function normalizeSqliteValue(value: RawSqliteValue): typeof SqliteStateValue.Type {
161 if (typeof value === "bigint") {
162 const numericValue = Number(value);
163 return Number.isSafeInteger(numericValue) ? numericValue : value.toString();
164 }
165 if (value instanceof Uint8Array) {
166 return Array.from(value);
167 }
168 return value;
169}
170
171function normalizeSqliteRow(row: RawSqliteRow): typeof SqliteStateRow.Type {
172 return Object.fromEntries(
173 Object.entries(row).map(([key, value]) => [key, normalizeSqliteValue(value)]),
174 );
175}
176
177export const runSqliteState = Effect.fn("runSqliteState")(function* (
178 input: RunSqliteStateInput,
179 options: RunSqliteStateOptions = {},
180) {
181 const fs = yield* FileSystem.FileSystem;
182 const path = yield* Path.Path;
183 const baseDir = path.resolve(input.baseDir);
184 const sharedHome = path.resolve(options.sharedHome ?? path.join(NodeOS.homedir(), ".t3"));
185 const databasePath = path.join(baseDir, "userdata", "statev2.sqlite");
186 const source = yield* resolveSqlSource(input.sql, input.file);
187
188 if (!(yield* fs.exists(databasePath))) {
189 return yield* new SqliteStateDatabaseMissingError({ databasePath });
190 }
191 if (input.operation === "exec") {
192 const [canonicalBaseDir, canonicalSharedHome] = yield* Effect.all([
193 fs.realPath(baseDir),
194 fs.realPath(sharedHome).pipe(Effect.orElseSucceed(() => sharedHome)),
195 ]);
196 if (canonicalBaseDir === canonicalSharedHome) {
197 return yield* new SqliteStateSharedHomeMutationError();
198 }
199 }
200
201 const program = Effect.gen(function* () {
202 const sql = yield* SqlClient.SqlClient;
203 yield* sql.unsafe("PRAGMA busy_timeout = 5000").unprepared;
204 // This connection bypasses persistence/Sqlite.ts and can checkpoint
205 // statev2.sqlite, so it needs the same F_FULLFSYNC checkpoint barrier.
206 yield* sql.unsafe("PRAGMA checkpoint_fullfsync = ON").unprepared;
207
208 if (input.operation === "query") {
209 const rows = yield* sql.unsafe<RawSqliteRow>(source).unprepared.pipe(
210 Effect.provideService(SqlClient.SafeIntegers, true),
211 Effect.map((rows) => rows.map(normalizeSqliteRow)),
212 );
213 return {
214 operation: "query",
215 database: databasePath,
216 rows,
217 } as const;
218 }
219
220 const timestamp = DateTime.formatIso(yield* DateTime.now).replaceAll(":", "-");
221 const backupPath = `${databasePath}.backup-${timestamp}`;
222 yield* sql`VACUUM INTO ${backupPath}`;
223 yield* fs.chmod(backupPath, 0o600);
224 yield* sql.withTransaction(sql.unsafe(source).unprepared);
225
226 return {
227 operation: "exec",
228 database: databasePath,
229 backup: backupPath,
230 } as const;
231 });
232
233 return yield* program.pipe(
234 Effect.provide(
235 NodeSqliteClient.layer({
236 filename: databasePath,
237 readonly: input.operation === "query",
238 }),
239 ),
240 Effect.mapError(
241 (cause) =>
242 new SqliteStateDatabaseError({
243 operation: input.operation,
244 databasePath,
245 cause,
246 }),
247 ),
248 );
249});
250
251const t3SqliteStateCommand = Command.make(
252 "t3-sqlite-state",
253 {
254 operation: Argument.Literals("operation", SqliteStateOperation.literals).pipe(
255 Argument.withDescription("Run a read-only query or a backed-up fixture mutation."),
256 ),
257 baseDir: Flag.String("base-dir").pipe(
258 Flag.withDescription("Explicit T3 base directory containing userdata/statev2.sqlite."),
259 ),
260 sql: Flag.String("sql").pipe(
261 Flag.optional,
262 Flag.withDescription("SQL source supplied directly on the command line."),
263 ),
264 file: Flag.String("file").pipe(
265 Flag.optional,
266 Flag.withDescription("Path to a SQL source file."),
267 ),
268 },
269 ({ operation, baseDir, sql, file }) =>
270 runSqliteState({
271 operation,
272 baseDir,
273 sql: Option.getOrUndefined(sql),
274 file: Option.getOrUndefined(file),
275 }).pipe(Effect.flatMap(encodeSqliteStateResult), Effect.flatMap(Console.log)),
276).pipe(
277 Command.withDescription(
278 "Inspect or seed an isolated T3 SQLite database with automatic backups for writes.",
279 ),
280);
281
282if (import.meta.main) {
283 Command.run(t3SqliteStateCommand, { version: "0.0.0" }).pipe(
284 Effect.provide(NodeServices.layer),
285 NodeRuntime.runMain,
286 );
287}