Skip to content
Source
Sign inSign up

initializeV2Database.ts

89 lines3.2 KB

1import * as NodeSqlite from "node:sqlite";
2​
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as Schema from "effect/Schema";
7​
8export class V2DatabaseImportError extends Schema.TaggedError<V2DatabaseImportError>()(
9 "V2DatabaseImportError",
10 { sourcePath: Schema.String, destinationPath: Schema.String, cause: Schema.Defect() },
11) {
12 override get message() {
13 return `Could not copy the V1 database at ${this.sourcePath} to ${this.destinationPath}. The V1 database has not been migrated.`;
14 }
16​
17const isV2DatabaseImportError = Schema.is(V2DatabaseImportError);
18​
19const isProcessAlive = (pid: number): boolean => {
20 if (!Number.isInteger(pid) || pid <= 0) return false;
21 try {
22 process.kill(pid, 0);
23 return true;
24 } catch (error) {
25 return error instanceof Error && "code" in error && error.code === "EPERM";
26 }
27};
28​
29// Legacy names have no PID; newer copies stay while their process is alive.
30const removeAbandonedCopies = Effect.fn("removeAbandonedCopies")(function* (directory: string) {
31 const fs = yield* FileSystem.FileSystem;
32 const path = yield* Path.Path;
33 const entries = yield* fs.readDirectory(directory).pipe(
34 Effect.catchIf(
35 (error) => error.reason._tag === "NotFound",
36 () => Effect.succeed([]),
37 ),
38 );
39 for (const entry of entries) {
40 const copy = /^\.v2-import-(?:(\d+)-)?[A-Za-z0-9]{6}$/.exec(entry);
41 if (copy === null || (copy[1] !== undefined && isProcessAlive(Number(copy[1])))) continue;
42 yield* fs.remove(path.join(directory, entry), { recursive: true });
43 }
44});
45​
46/** Its copied legacy tables remain the source for lazy transcript import. */
47export const initializeV2Database = Effect.fn("initializeV2Database")(function* (
48 destinationPath: string,
49) {
50 const fs = yield* FileSystem.FileSystem;
51 const path = yield* Path.Path;
52 const directory = path.dirname(destinationPath);
53 const sourcePath = path.join(directory, "state.sqlite");
54 yield* removeAbandonedCopies(directory).pipe(Effect.ignore({ log: true }));
55 yield* Effect.gen(function* () {
56 if (yield* fs.exists(destinationPath)) return;
57 if (!(yield* fs.exists(sourcePath))) return;
58 const temporaryDirectory = yield* fs.makeTempDirectoryScoped({
59 directory,
60 prefix: `.v2-import-${process.pid}-`,
61 });
62 const snapshotPath = path.join(temporaryDirectory, "snapshot.sqlite");
63 yield* Effect.tryPromise({
64 try: async () => {
65 const database = new NodeSqlite.DatabaseSync(sourcePath, { readOnly: true });
66 try {
67 await NodeSqlite.backup(database, snapshotPath);
68 } finally {
69 database.close();
70 }
71 },
72 catch: (cause) => new V2DatabaseImportError({ sourcePath, destinationPath, cause }),
73 });
74 // Publish only a complete snapshot, without replacing an existing V2 database.
75 yield* fs.link(snapshotPath, destinationPath).pipe(
76 Effect.catchIf(
77 (error) => error.reason._tag === "AlreadyExists",
78 (_error) => Effect.void,
79 ),
80 );
81 }).pipe(
82 Effect.scoped,
83 Effect.mapError((cause) =>
84 isV2DatabaseImportError(cause)
85 ? cause
86 : new V2DatabaseImportError({ sourcePath, destinationPath, cause }),
87 ),
88 );
89});