import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../src/persistence/Migrations.ts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { runMigrateDevDb } from "./migrate-dev-db.ts"; const withDatabase = ( databasePath: string, effect: Effect.Effect, ) => effect.pipe(Effect.provide(NodeSqliteClient.layer({ filename: databasePath }))); /** A migrated source db with one thread per lifecycle state. Only * `stopped-thread` qualifies for the clone. */ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(function* ( baseDir: string, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const stateDir = path.join(baseDir, "userdata"); const databasePath = path.join(stateDir, "state.sqlite"); yield* fs.makeDirectory(stateDir, { recursive: true }); yield* withDatabase( databasePath, Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* runMigrations(); // The real shared db carries this column from a branch build without a // matching migration; reproduce that drift so the filter is exercised. yield* sql`ALTER TABLE projection_threads ADD COLUMN monitor_json TEXT`; yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at) VALUES ('project-kept', 'Kept', '/tmp/kept', '[]', '2026-08-01', '2026-08-01', NULL), ('project-deleted', 'Deleted', '/tmp/deleted', '[]', '2026-08-01', '2026-08-02', '2026-08-02')`; const threads = [ ["stopped-thread", "project-kept", "stopped", null, null], ["running-thread", "project-kept", "running", null, null], ["settled-thread", "project-kept", "stopped", "2026-08-01", null], ["monitored-thread", "project-kept", "stopped", null, '{"kind":"pr"}'], ["deleted-project-thread", "project-deleted", "stopped", null, null], ] as const; for (const [threadId, projectId, status, settledAt, monitorJson] of threads) { yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, created_at, updated_at, settled_at, monitor_json) VALUES (${threadId}, ${projectId}, ${threadId}, '2026-08-01', '2026-08-01', ${settledAt}, ${monitorJson})`; yield* sql`INSERT INTO projection_thread_sessions (thread_id, status, updated_at) VALUES (${threadId}, ${status}, '2026-08-01')`; yield* sql`INSERT INTO orchestration_events (event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, actor_kind, payload_json, metadata_json) VALUES (${`event-${threadId}`}, 'thread', ${threadId}, 0, 'thread.created', '2026-08-01', 'user', '{}', '{}')`; } yield* sql`INSERT INTO auth_sessions (session_id, subject, scopes, method, issued_at, expires_at) VALUES ('session-1', 'user', '[]', 'pairing', '2026-08-01', '2027-08-01')`; }), ); return databasePath; }); it.layer(NodeServices.layer)("migrate-dev-db", (it) => { it.effect("keeps only stopped threads from live projects and clears auth state", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-src-" }); const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-dest-" }); const source = yield* createFixtureSource(sourceDir); const result = yield* runMigrateDevDb( { baseDir: destDir, source, projects: 5, threadsPerProject: 10 }, { sharedHome: sourceDir }, ); assert.equal(result.databasePath, path.join(destDir, "userdata", "state.sqlite")); const kept = yield* withDatabase( result.databasePath, Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const threads = yield* sql<{ thread_id: string }>` SELECT thread_id FROM projection_threads ORDER BY thread_id`; const events = yield* sql<{ stream_id: string }>` SELECT stream_id FROM orchestration_events`; const [auth] = yield* sql<{ count: number }>` SELECT COUNT(*) AS count FROM auth_sessions`; return { threads, events, authCount: auth?.count ?? 0 }; }), ); assert.deepStrictEqual( kept.threads.map((row) => row.thread_id), ["stopped-thread"], ); assert.deepStrictEqual( kept.events.map((row) => row.stream_id), ["stopped-thread"], ); assert.equal(kept.authCount, 0); }), ); it.effect("fails loudly on a migration slot collision", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slot-" }); const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slot-dest-" }); const source = yield* createFixtureSource(sourceDir); // Simulate another branch having claimed slot 1 first: the id is // recorded, so this checkout's migration 1 silently never runs. yield* withDatabase( source, Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`UPDATE effect_sql_migrations SET name = 'SomebodyElsesMigration' WHERE migration_id = 1`; }), ); const error = yield* runMigrateDevDb( { baseDir: destDir, source, projects: 5, threadsPerProject: 10 }, { sharedHome: sourceDir }, ).pipe(Effect.flip); assert.equal(error._tag, "MigrateDevDbSlotCollisionError"); if (error._tag === "MigrateDevDbSlotCollisionError") { assert.equal(error.slot, 1); assert.equal(error.appliedName, "SomebodyElsesMigration"); } }), ); it.effect("refuses while a dev server holds the destination", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-busy-" }); const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-busy-dest-" }); const source = yield* createFixtureSource(sourceDir); // This test process stands in for a live dev server. const stateDir = path.join(destDir, "userdata"); yield* fs.makeDirectory(stateDir, { recursive: true }); yield* fs.writeFileString( path.join(stateDir, "server-runtime.json"), `{"version":1,"pid":${process.pid}}`, ); const error = yield* runMigrateDevDb( { baseDir: destDir, source, projects: 5, threadsPerProject: 10 }, { sharedHome: sourceDir }, ).pipe(Effect.flip); assert.equal(error._tag, "MigrateDevDbServerRunningError"); if (error._tag === "MigrateDevDbServerRunningError") { assert.equal(error.pid, process.pid); } }), ); it.effect("refuses a source that resolves to a destination path", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const sharedDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-overlap-" }); const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-overlap-dest-" }); // A leftover snapshot from a prior failed run, passed as --source: it // must not be deleted before it is read. const leftoverSnapshot = path.join(destDir, "userdata", "state.sqlite.migrate-dev-db-tmp"); yield* fs.makeDirectory(path.dirname(leftoverSnapshot), { recursive: true }); yield* fs.writeFileString(leftoverSnapshot, "not a real db"); const error = yield* runMigrateDevDb( { baseDir: destDir, source: leftoverSnapshot, projects: 5, threadsPerProject: 10 }, { sharedHome: sharedDir }, ).pipe(Effect.flip); assert.equal(error._tag, "MigrateDevDbSourceIsDestinationError"); assert.equal(yield* fs.exists(leftoverSnapshot), true); }), ); it.effect("refuses to rebuild the shared home", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-shared-" }); const source = yield* createFixtureSource(sourceDir); const error = yield* runMigrateDevDb( { baseDir: sourceDir, source, projects: 5, threadsPerProject: 10 }, { sharedHome: sourceDir }, ).pipe(Effect.flip); assert.equal(error._tag, "MigrateDevDbSharedHomeError"); }), ); });