import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { afterEach, describe, expect, it } from "vitest"; import { idFromNumber } from "../src/ids.ts"; import { applySqliteMigrations, CURRENT_SQLITE_SCHEMA_VERSION, SQLITE_MIGRATIONS, type SqliteMigration, } from "../src/storage/sqlite/index.ts"; import { openNodeSqliteDatabase, openNodeSqliteStorage } from "../src/storage/sqlite/node.ts"; import { type EntryId, ROOT_CONVERSATION_ID } from "../src/types.ts"; const directories = new Set(); async function databasePath(): Promise { const directory = await mkdtemp(join(tmpdir(), "pi-durable-migrations-")); directories.add(directory); return join(directory, "storage.sqlite"); } afterEach(async () => { for (const directory of directories) await rm(directory, { recursive: true, force: true }); directories.clear(); }); describe("durable SQLite migrations", () => { it("creates the current schema and can be applied repeatedly", async () => { const database = await openNodeSqliteDatabase(await databasePath()); try { await applySqliteMigrations(database); await applySqliteMigrations(database); expect(await database.get("SELECT version FROM durable_schema WHERE singleton = 1")).toEqual({ version: CURRENT_SQLITE_SCHEMA_VERSION, }); expect(await database.get("SELECT next_id, next_seq FROM durable_metadata WHERE singleton = 1")).toEqual({ next_id: "2", next_seq: 1, }); } finally { await database.close(); } }); it("rejects a database newer than the portable core", async () => { const path = await databasePath(); const database = await openNodeSqliteDatabase(path); await applySqliteMigrations(database); await database.run( "UPDATE durable_schema SET version = ? WHERE singleton = 1", CURRENT_SQLITE_SCHEMA_VERSION + 1, ); await database.close(); await expect(openNodeSqliteStorage(path)).rejects.toThrow("is newer than supported version"); }); it("rolls initial bootstrap and every pending migration back together", async () => { const database = await openNodeSqliteDatabase(await databasePath()); try { const failed: readonly SqliteMigration[] = [ { version: 1, statements: [ "CREATE TABLE migration_first (value TEXT) STRICT", "INSERT INTO migration_first (value) VALUES ('retained')", ], }, { version: 2, statements: ["CREATE TABLE migration_second (value TEXT) STRICT", "THIS IS NOT SQL"], }, ]; await expect(applySqliteMigrations(database, failed)).rejects.toThrow(); expect( await database.get( "SELECT count(*) AS count FROM sqlite_schema WHERE name IN ('durable_schema', 'migration_first', 'migration_second')", ), ).toEqual({ count: 0 }); await applySqliteMigrations(database, [ failed[0], { version: 2, statements: ["CREATE TABLE migration_second (value TEXT) STRICT"] }, ]); expect(await database.get("SELECT version FROM durable_schema WHERE singleton = 1")).toEqual({ version: 2, }); expect(await database.get("SELECT value FROM migration_first")).toEqual({ value: "retained", }); } finally { await database.close(); } }); it("rolls a failed migration back and preserves stored data for a successful retry", async () => { const path = await databasePath(); const storage = await openNodeSqliteStorage(path); await storage.commit( [ { type: "conversation", value: { id: ROOT_CONVERSATION_ID } }, { type: "entry", value: { id: idFromNumber(2), conversationId: ROOT_CONVERSATION_ID, kind: "retained", data: { retained: true }, }, }, ], BACKGROUND_CONTEXT, ); await storage.close(BACKGROUND_CONTEXT); const database = await openNodeSqliteDatabase(path); const nextVersion = CURRENT_SQLITE_SCHEMA_VERSION + 1; const failedMigrations: readonly SqliteMigration[] = [ ...SQLITE_MIGRATIONS, { version: nextVersion, statements: ["CREATE TABLE migration_probe (value TEXT) STRICT", "THIS IS NOT SQL"], }, ]; await expect(applySqliteMigrations(database, failedMigrations)).rejects.toThrow(); expect(await database.get("SELECT version FROM durable_schema WHERE singleton = 1")).toEqual({ version: CURRENT_SQLITE_SCHEMA_VERSION, }); expect( await database.get( "SELECT count(*) AS count FROM sqlite_schema WHERE type = 'table' AND name = 'migration_probe'", ), ).toEqual({ count: 0 }); const successfulMigrations: readonly SqliteMigration[] = [ ...SQLITE_MIGRATIONS, { version: nextVersion, statements: ["CREATE TABLE migration_probe (value TEXT) STRICT"] }, ]; await applySqliteMigrations(database, successfulMigrations); expect(await database.get("SELECT version FROM durable_schema WHERE singleton = 1")).toEqual({ version: nextVersion, }); expect(await database.get("SELECT record, commit_seq FROM entries WHERE id = 2")).toEqual({ record: JSON.stringify({ id: 2, conversationId: ROOT_CONVERSATION_ID, kind: "retained", data: { retained: true }, }), commit_seq: 1, }); expect(await database.get("SELECT next_id, next_seq FROM durable_metadata WHERE singleton = 1")).toEqual({ next_id: "3", next_seq: 2, }); await database.close(); }); });