diff --git a/AGENTS.md b/AGENTS.md index efca474..bb45242 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ │ ├── entry-server.tsx # SSR rendering (renderToPipeableStream) │ ├── database/ │ │ ├── schema.sql # SQLite schema — source of truth for DB structure +│ │ ├── migrations/ # Forward-only migration scripts (production) │ │ └── seeder.sql # Test/seed data │ ├── express/ │ │ ├── routes.ts # Registers all Express modules via importAndUse() @@ -60,19 +61,17 @@ ```bash npm install cp .env.sample .env -npm run database:sync # Load schema + seed data into SQLite +npm run database:reset # Drop all, replay schema + migrations + seeder npm run dev # Start dev server (Express + Vite together on port 5173) ``` ### Database ```bash -npm run database:schema:load # Apply schema.sql to the SQLite file -npm run database:seeder:load # Load seeder.sql test data -npm run database:sync # Both above — resets DB to a clean state -npm run database:sync -- -n # Non-interactive (CI/CD — skips confirmation prompt) -npm run database:schema:load -- -n # Same, schema only -npm run database:seeder:load -- -n # Same, seeder only +npm run database:reset # Drop all, replay schema + migrations + seeder +npm run database:reset -- -n # Non-interactive (CI/CD — skips confirmation prompt) +npm run database:migrate # Apply un-applied migrations (production) +npm run database:migrate -- -n # Non-interactive (CI/CD) ``` > SQLite requires NO Docker, NO connection string, NO async setup. The DB file is created on the fly. diff --git a/README.fr-FR.md b/README.fr-FR.md index a4223c9..b2d98bc 100644 --- a/README.fr-FR.md +++ b/README.fr-FR.md @@ -50,7 +50,7 @@ cd mon-projet # 2. Installer les dépendances et initialiser la base de données npm install cp .env.sample .env -npm run database:sync +npm run database:reset # 3. Lancer l'application npm run dev diff --git a/README.md b/README.md index 3f875e7..efdf0fd 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ cd my-prototype # 2. Install dependencies and initialize the database npm install cp .env.sample .env -npm run database:sync +npm run database:reset # 3. Start co-creating npm run dev diff --git a/package.json b/package.json index 426310e..71a6ba2 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,8 @@ "build": "npm run build:client && npm run build:server", "build:client": "vite build --outDir dist/client", "build:server": "vite build --outDir dist/server --ssr src/entry-server", - "database:schema:load": "tsx --env-file=.env scripts/database-sync schema", - "database:seeder:load": "tsx --env-file=.env scripts/database-sync seeder", - "database:sync": "tsx --env-file=.env scripts/database-sync both", + "database:migrate": "tsx --env-file=.env scripts/database-migrate", + "database:reset": "tsx --env-file=.env scripts/database-reset", "dev": "tsx watch --include .env --env-file=.env server", "install:check": "vitest run tests/install", "make:clone": "tsx --env-file=.env scripts/make-clone", diff --git a/scripts/database-helpers.ts b/scripts/database-helpers.ts new file mode 100644 index 0000000..4750c5f --- /dev/null +++ b/scripts/database-helpers.ts @@ -0,0 +1,109 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import type { DatabaseSync } from "node:sqlite"; + +/** + * Scans `src/database/` for all SQL files in the correct execution order: + * 1. `schema.sql` (always first) + * 2. migration files in `migrations/` (alphabetical order) + * 3. `seeder.sql` (always last) + * + * Missing files or directories are silently skipped. + */ +export function scanDatabaseFiles(rootDir: string): string[] { + const databaseDir = path.join(rootDir, "src/database"); + + const files: string[] = []; + + // 1. schema.sql first + const schemaFile = path.join(databaseDir, "schema.sql"); + if (fs.existsSync(schemaFile)) { + files.push(schemaFile); + } + + // 2. migration files in alphabetical order + const migrationsDir = path.join(databaseDir, "migrations"); + if (fs.existsSync(migrationsDir)) { + const migrationFiles = fs + .readdirSync(migrationsDir) + .filter((f) => f.endsWith(".sql")) + .sort() + .map((f) => path.join(migrationsDir, f)); + + files.push(...migrationFiles); + } + + // 3. seeder.sql last + const seederFile = path.join(databaseDir, "seeder.sql"); + if (fs.existsSync(seederFile)) { + files.push(seederFile); + } + + return files; +} + +/** + * Same as scanDatabaseFiles but excludes seeder.sql. + * Used by `database:migrate` (production) and test infrastructure. + */ +export function scanMigrationFiles(rootDir: string): string[] { + return scanDatabaseFiles(rootDir).filter( + (f) => path.basename(f) !== "seeder.sql", + ); +} + +/** + * Computes the SHA-256 checksum of a file's content. + */ +export function computeChecksum(content: string): string { + return crypto.createHash("sha256").update(content).digest("hex"); +} + +/** + * Drops all user-created tables from the database. + * Temporarily disables foreign keys to avoid cascade errors during drop. + */ +export function dropAllTables(database: DatabaseSync): void { + const existingTables = database + .prepare( + "select name from sqlite_schema where type ='table' and name not like 'sqlite_%'", + ) + .all(); + + // Prevent errors because of cascade deletion + database.exec("PRAGMA foreign_keys = OFF"); + + for (const table of existingTables) { + database.exec(`drop table "${table.name}"`); + } + + // Re-enable cascade deletion + database.exec("PRAGMA foreign_keys = ON"); +} + +/** + * Ensures the _migrations tracking table exists. + */ +export function ensureMigrationsTable(database: DatabaseSync): void { + database.exec(` + create table if not exists _migrations ( + filename text primary key, + checksum text not null, + applied_at datetime default current_timestamp + ); + `); +} + +/** + * Records an executed SQL file in the _migrations table. + */ +export function recordMigration( + database: DatabaseSync, + filename: string, + checksum: string, +): void { + database + .prepare("insert into _migrations (filename, checksum) values (?, ?)") + .run(filename, checksum); +} diff --git a/scripts/database-migrate.ts b/scripts/database-migrate.ts new file mode 100644 index 0000000..6b3dc5e --- /dev/null +++ b/scripts/database-migrate.ts @@ -0,0 +1,168 @@ +import fs from "node:fs"; +import path from "node:path"; +import readline from "node:readline/promises"; +import database from "../src/database"; +import { + computeChecksum, + ensureMigrationsTable, + recordMigration, + scanMigrationFiles, +} from "./database-helpers"; + +export async function main( + argv: string[] = process.argv, + rootDirOverride?: string, +) { + const rootDir = rootDirOverride ?? path.join(import.meta.dirname, ".."); + + const databaseFile = database.location() ?? ":memory:"; + + const args = argv.slice(2); + + const noInteraction = + args.includes("--no-interaction") || args.includes("-n"); + + const expectedArgs = noInteraction ? ["--no-interaction"] : []; + + if (args.length !== expectedArgs.length) { + throw new Error("Usage: database-migrate [--no-interaction|-n]"); + } + + // Back up the database file (temporary: deleted after migration) + const backupFile = `${databaseFile}.bak`; + const isFileDatabase = databaseFile !== ":memory:"; + + if (isFileDatabase) { + fs.copyFileSync(databaseFile, backupFile); + console.info(`\nBackup created: '${path.normalize(backupFile)}'`); + } + + // Ensure the _migrations tracking table exists + ensureMigrationsTable(database); + + // Scan all SQL files (schema + migrations, excluding seeder) + const files = scanMigrationFiles(rootDir); + + // Check which files have already been applied + const applied = new Map(); + const rows = database + .prepare("select filename, checksum from _migrations") + .all() as { filename: string; checksum: string }[]; + + for (const row of rows) { + applied.set(row.filename, row.checksum); + } + + // Determine which files need to be applied + const pending: { file: string; sql: string }[] = []; + + for (const file of files) { + const sql = fs.readFileSync(file, "utf8"); + const filename = path.basename(file); + const currentChecksum = computeChecksum(sql); + + if (applied.has(filename)) { + const storedChecksum = applied.get(filename); + + if (storedChecksum !== currentChecksum) { + console.warn( + `\n⚠️ '${filename}' was modified since it was applied. These changes will NOT take effect. Revert your change or write a new migration script.`, + ); + } + } else { + pending.push({ file, sql }); + } + } + + if (pending.length === 0) { + console.info("\nNothing to migrate. Database is up to date. ✅"); + + // Clean up backup + if (isFileDatabase && fs.existsSync(backupFile)) { + fs.unlinkSync(backupFile); + } + + return; + } + + // Confirm before applying + console.info(`\n${pending.length} file(s) to apply:`); + for (const { file } of pending) { + console.info(` - ${path.basename(file)}`); + } + + if (!noInteraction) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + const answer = await rl.question("\nApply these migrations? (y/N) "); + + if (answer.toLowerCase() !== "y") { + console.info("\nMigration cancelled."); + + // Clean up backup + if (isFileDatabase && fs.existsSync(backupFile)) { + fs.unlinkSync(backupFile); + } + + return; + } + } finally { + rl.close(); + } + } + + // Apply pending files inside a transaction + try { + database.exec("BEGIN"); + + for (const { file, sql } of pending) { + database.exec(sql); + + const filename = path.basename(file); + const checksum = computeChecksum(sql); + recordMigration(database, filename, checksum); + + console.info( + `\n'${filename}' applied to '${path.normalize(databaseFile)}' ✅`, + ); + } + + database.exec("COMMIT"); + + console.info("\nMigration complete! ✅"); + } catch (error) { + // Rollback the transaction + try { + database.exec("ROLLBACK"); + } catch { + // ROLLBACK may fail if the transaction was already rolled back + } + + // Restore from backup + if (isFileDatabase && fs.existsSync(backupFile)) { + fs.copyFileSync(backupFile, databaseFile); + console.info( + `\nDatabase restored from backup: '${path.normalize(backupFile)}'`, + ); + } + + throw error; + } finally { + // Always clean up the backup file + if (isFileDatabase && fs.existsSync(backupFile)) { + fs.unlinkSync(backupFile); + } + } +} + +/* v8 ignore next 6 */ +if (process.env.NODE_ENV !== "test") { + main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }); +} diff --git a/scripts/database-reset.ts b/scripts/database-reset.ts new file mode 100644 index 0000000..e5aede4 --- /dev/null +++ b/scripts/database-reset.ts @@ -0,0 +1,87 @@ +import fs from "node:fs"; +import path from "node:path"; +import readline from "node:readline/promises"; +import database from "../src/database"; +import { + computeChecksum, + dropAllTables, + ensureMigrationsTable, + recordMigration, + scanDatabaseFiles, +} from "./database-helpers"; + +export async function main( + argv: string[] = process.argv, + rootDirOverride?: string, +) { + const rootDir = rootDirOverride ?? path.join(import.meta.dirname, ".."); + + const databaseFile = database.location() ?? ":memory:"; + + const args = argv.slice(2); + + const noInteraction = + args.includes("--no-interaction") || args.includes("-n"); + + const expectedArgs = noInteraction ? ["--no-interaction"] : []; + + if (args.length !== expectedArgs.length) { + throw new Error("Usage: database-reset [--no-interaction|-n]"); + } + + console.info( + `This script will drop existing data in '${path.normalize(databaseFile)}'.`, + ); + + if (!noInteraction) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + const answer = await rl.question( + "Are you sure you want to continue? This action cannot be undone. (y/N) ", + ); + + if (answer.toLowerCase() !== "y") { + console.info("\nReset operation cancelled."); + return; + } + } finally { + rl.close(); + } + } + + // Drop all existing tables (including _migrations) + dropAllTables(database); + + // Create a fresh _migrations tracking table + ensureMigrationsTable(database); + + // Scan and execute all SQL files in order + const files = scanDatabaseFiles(rootDir); + + for (const file of files) { + const sql = fs.readFileSync(file, "utf8"); + + database.exec(sql); + + // Record the execution in _migrations + const filename = path.basename(file); + const checksum = computeChecksum(sql); + recordMigration(database, filename, checksum); + + console.info( + `\n'${path.normalize(file)}' loaded in '${path.normalize(databaseFile)}' ✅`, + ); + } +} + +/* v8 ignore next 6 */ +if (process.env.NODE_ENV !== "test") { + main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }); +} diff --git a/scripts/database-sync.ts b/scripts/database-sync.ts deleted file mode 100644 index 55b157b..0000000 --- a/scripts/database-sync.ts +++ /dev/null @@ -1,122 +0,0 @@ -import path from "node:path"; -import readline from "node:readline/promises"; -import fs from "fs-extra"; -import database from "../src/database"; - -export async function main( - argv: string[] = process.argv, - rootDirOverride?: string, -) { - const rootDir = rootDirOverride ?? path.join(import.meta.dirname, ".."); - - // Locate the schema, seeder and database files - const schemaFile = path.join(rootDir, "src/database/schema.sql"); - const seederFile = path.join(rootDir, "src/database/seeder.sql"); - const databaseFile = database.location() ?? ":memory:"; - - const args = argv.slice(2); - - const target = args.includes("schema") - ? "schema" - : args.includes("seeder") - ? "seeder" - : args.includes("both") - ? "both" - : null; - - const noInteraction = - args.includes("--no-interaction") || args.includes("-n"); - - const expectedArgs = [target, ...(noInteraction ? ["--no-interaction"] : [])]; - - if (target == null || args.length !== expectedArgs.length) { - throw new Error( - "Usage: database-sync [--no-interaction|-n] schema|seeder|both", - ); - } - - console.info( - `This script will drop existing data in '${path.normalize(databaseFile)}'.`, - ); - - if (!noInteraction) { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - try { - const answer = await rl.question( - "Are you sure you want to continue? This action cannot be undone. (y/N) ", - ); - - if (answer.toLowerCase() !== "y") { - console.info("\nSync operation cancelled."); - return; - } - } finally { - rl.close(); - } - } - - if (target === "schema" || target === "both") { - // Drop existing tables - const existingTables = database - .prepare( - "select name from sqlite_schema where type ='table' and name not like 'sqlite_%'", - ) - .all() as { name: string }[]; - - // Prevent errors because of cascade deletion - database.exec("PRAGMA foreign_keys = OFF"); - - for (const table of existingTables) { - database.exec(`drop table "${table.name}"`); - } - - // Re-enable cascade deletion - database.exec("PRAGMA foreign_keys = ON"); - - // Read the SQL statements from the schema file - const sql = await fs.readFile(schemaFile, "utf8"); - - // Execute the SQL statements to update the database schema - database.exec(sql); - - console.info( - `\nSchema '${path.normalize(schemaFile)}' loaded in '${path.normalize(databaseFile)}' 🆙`, - ); - } - - if (target === "seeder" || target === "both") { - // truncate existing tables - const existingTables = database - .prepare(` - select name - from sqlite_schema - where type ='table' and name not like 'sqlite_%'`) - .all(); - - for (const table of existingTables) { - database.exec(`delete from "${table.name}"`); - } - - // Read the SQL statements from the seeder file - const sql = await fs.readFile(seederFile, "utf8"); - - // Execute the SQL statements to seed the database - database.exec(sql); - - console.info( - `\nSeeder '${path.normalize(seederFile)}' loaded in '${path.normalize(databaseFile)}' 🌱`, - ); - } -} - -/* v8 ignore next 6 */ -if (process.env.NODE_ENV !== "test") { - main().catch((err) => { - console.error(err instanceof Error ? err.message : err); - process.exit(1); - }); -} diff --git a/scripts/make-clone.ts b/scripts/make-clone.ts index 1e84f0f..e86df25 100644 --- a/scripts/make-clone.ts +++ b/scripts/make-clone.ts @@ -189,9 +189,9 @@ export async function main(argv: string[] = process.argv) { [ ] Register the new table in src/database/schema.sql [ ] (Optional) Add dummy data in src/database/seeder.sql -[ ] Sync database: +[ ] Reset database: - npm run database:sync + npm run database:reset [ ] Define the "${singular}" type in src/types/index.d.ts`); diff --git a/tests/express/test-utils.ts b/tests/express/test-utils.ts index 1c3abdb..e6ce7d2 100644 --- a/tests/express/test-utils.ts +++ b/tests/express/test-utils.ts @@ -25,6 +25,8 @@ vi.mock("../../src/database", () => ({ default: new DatabaseSync(":memory:"), })); +import { scanMigrationFiles } from "../../scripts/database-helpers"; + const mockDatabase = () => { /* drop existing tables */ const existingTables = database @@ -44,14 +46,14 @@ const mockDatabase = () => { /* re-enable cascade deletion */ database.exec("PRAGMA foreign_keys = ON"); - /* load schema */ - const schema = path.join( - import.meta.dirname, - "../../src/database/schema.sql", - ); + /* load schema + migration files */ + const rootDir = path.join(import.meta.dirname, "../.."); + const files = scanMigrationFiles(rootDir); - const schemaSql = fs.readFileSync(schema, "utf8"); - database.exec(schemaSql); + for (const file of files) { + const sql = fs.readFileSync(file, "utf8"); + database.exec(sql); + } /* insert all users */ const insertUser = database.prepare( diff --git a/tests/scripts/database-migrate.test.ts b/tests/scripts/database-migrate.test.ts new file mode 100644 index 0000000..ad70330 --- /dev/null +++ b/tests/scripts/database-migrate.test.ts @@ -0,0 +1,390 @@ +import fs from "node:fs"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { main as migrateMain } from "../../scripts/database-migrate"; +import { main as resetMain } from "../../scripts/database-reset"; +import database from "../../src/database"; +import { createDbTestFixture, type DbTestFixture } from "./test-utils"; + +let mockDb = new DatabaseSync(":memory:"); + +vi.mock("../../src/database", () => ({ + get default() { + return mockDb; + }, +})); + +describe("database-migrate.ts", () => { + let consoleSpy: ReturnType; + let warnSpy: ReturnType; + let dbTestFixture: DbTestFixture; + + beforeEach(async () => { + consoleSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + // Create a unique temporary directory sandbox for each test case + const fixture = await createDbTestFixture("db-migrate-test-"); + dbTestFixture = fixture; + + // Reset database state by instantiating a fresh in-memory database and running clean reset + mockDb = new DatabaseSync(":memory:"); + await resetMain(["node", "script", "-n"], dbTestFixture.rootDir); + consoleSpy.mockClear(); + warnSpy.mockClear(); + }); + + afterEach(async () => { + consoleSpy.mockRestore(); + warnSpy.mockRestore(); + await dbTestFixture.cleanup(); + }); + + it("fails when given unexpected arguments", async () => { + await expect( + migrateMain(["node", "script", "--unknown-flag"]), + ).rejects.toThrow(/usage/i); + }); + + it("fails when given extra arguments", async () => { + await expect( + migrateMain(["node", "script", "--no-interaction", "--extra"]), + ).rejects.toThrow(/usage/i); + }); + + it("cancels when user answers no interactively", async () => { + // Create a dummy migration + const testFile = path.join(dbTestFixture.migrationsDir, "0000_dummy.sql"); + + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); + await fs.promises.writeFile(testFile, "SELECT 'hello, world!';\n"); + + const readline = await import("node:readline/promises"); + readline.default.createInterface = vi.fn().mockReturnValue({ + question: () => "n", + close: vi.fn(), + }); + + await migrateMain(["node", "script"], dbTestFixture.rootDir); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringMatching(/cancelled/)); + }); + + it("proceeds when user answers yes interactively", async () => { + // Create a dummy migration + const testFile = path.join(dbTestFixture.migrationsDir, "0000_dummy.sql"); + + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); + await fs.promises.writeFile( + testFile, + "CREATE TABLE interactive_test (id INTEGER PRIMARY KEY);\n", + ); + + const readline = await import("node:readline/promises"); + readline.default.createInterface = vi.fn().mockReturnValue({ + question: () => "y", + close: vi.fn(), + }); + + await migrateMain(["node", "script"], dbTestFixture.rootDir); + + // Verify it migrated successfully + const tables = database + .prepare( + "select name from sqlite_schema where type = 'table' and name = 'interactive_test'", + ) + .all(); + expect(tables.length).toBe(1); + }); + + it("reports nothing to migrate when database is up to date", async () => { + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/nothing to migrate/i), + ); + }); + + it("applies a new migration file", async () => { + // Create a migration that adds a test table + const testFile = path.join( + dbTestFixture.migrationsDir, + "0001_add_test_table.sql", + ); + + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); + await fs.promises.writeFile( + testFile, + "CREATE TABLE test_migrate (id INTEGER PRIMARY KEY);\n", + ); + + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); + + // Verify the table was created + const tables = database + .prepare( + "select name from sqlite_schema where type = 'table' and name = 'test_migrate'", + ) + .all(); + + expect(tables.length).toBe(1); + + // Verify it was tracked in _migrations + const migration = database + .prepare("select * from _migrations where filename = ?") + .get("0001_add_test_table.sql") as + | { filename: string; checksum: string } + | undefined; + + expect(migration).toBeDefined(); + expect(migration?.checksum.length).toBe(64); + }); + + it("skips already-applied files", async () => { + // Create and apply a migration + const testFile = path.join( + dbTestFixture.migrationsDir, + "0002_skip_test.sql", + ); + + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); + await fs.promises.writeFile( + testFile, + "CREATE TABLE skip_test (id INTEGER PRIMARY KEY);\n", + ); + + // Apply it once + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); + consoleSpy.mockClear(); + + // Apply again: should report nothing to migrate + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/nothing to migrate/i), + ); + }); + + it("warns when an applied file has been modified", async () => { + // Create and apply a migration + const testFile = path.join( + dbTestFixture.migrationsDir, + "0003_checksum_test.sql", + ); + + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); + await fs.promises.writeFile( + testFile, + "CREATE TABLE checksum_test (id INTEGER PRIMARY KEY);\n", + ); + + // Apply it + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); + warnSpy.mockClear(); + + // Modify the file + await fs.promises.writeFile( + testFile, + "CREATE TABLE checksum_test (id INTEGER PRIMARY KEY, name TEXT);\n", + ); + + // Migrate again: should warn about modification + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringMatching(/was modified since it was applied/), + ); + }); + + it("does not apply seeder.sql", async () => { + // After reset, seeder data exists. Let's clear it and migrate. + database.exec("DELETE FROM item"); + database.exec("DELETE FROM user"); + consoleSpy.mockClear(); + + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); + + // Seeder should NOT have been re-applied + const users = database.prepare("select * from user").all(); + expect(users.length).toBe(0); + }); + + it("respects file ordering: schema.sql first, then migrations alphabetically", async () => { + const fileA = path.join(dbTestFixture.migrationsDir, "aaa_first.sql"); + const fileB = path.join(dbTestFixture.migrationsDir, "zzz_second.sql"); + + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); + await fs.promises.writeFile(fileA, "-- first\n"); + await fs.promises.writeFile(fileB, "-- second\n"); + + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); + + const migrations = database + .prepare( + "SELECT filename FROM _migrations WHERE filename IN ('aaa_first.sql', 'zzz_second.sql') ORDER BY applied_at", + ) + .all(); + + const filenames = migrations.map((m) => m.filename); + expect(filenames).toEqual(["aaa_first.sql", "zzz_second.sql"]); + }); + + it("rolls back on execution failure", async () => { + const goodFile = path.join(dbTestFixture.migrationsDir, "0004_good.sql"); + const badFile = path.join(dbTestFixture.migrationsDir, "0005_bad.sql"); + + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); + await fs.promises.writeFile( + goodFile, + "CREATE TABLE good_table (id INTEGER PRIMARY KEY);\n", + ); + await fs.promises.writeFile(badFile, "THIS IS NOT VALID SQL;\n"); + + await expect( + migrateMain(["node", "script", "-n"], dbTestFixture.rootDir), + ).rejects.toThrow(); + + // The good table should NOT exist (transaction was rolled back) + const tables = database + .prepare( + "select name from sqlite_schema where type = 'table' and name = 'good_table'", + ) + .all(); + + expect(tables.length).toBe(0); + + // Neither file should be tracked in _migrations + const tracked = database + .prepare( + "select * from _migrations where filename IN ('0004_good.sql', '0005_bad.sql')", + ) + .all(); + + expect(tracked.length).toBe(0); + }); + + it("skips schema.sql gracefully if it is missing", async () => { + // Start with a fresh in-memory database (bypassing beforeEach's resetMain state) + mockDb = new DatabaseSync(":memory:"); + + // Delete schema.sql from this sandbox + await fs.promises.unlink( + path.join(dbTestFixture.databaseDir, "schema.sql"), + ); + + // Run migration + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); + + // Verify schema.sql is not tracked in _migrations + const migrations = database + .prepare("select filename from _migrations") + .all(); + const filenames = migrations.map((m) => m.filename); + expect(filenames).not.toContain("schema.sql"); + }); + + describe("File-based Database", () => { + let tempDbPath: string; + + beforeEach(async () => { + tempDbPath = path.join(dbTestFixture.rootDir, "test-database.sqlite"); + mockDb = new DatabaseSync(tempDbPath); + // Clean reset + await resetMain(["node", "script", "-n"], dbTestFixture.rootDir); + consoleSpy.mockClear(); + warnSpy.mockClear(); + }); + + afterEach(async () => { + mockDb.close(); + mockDb = new DatabaseSync(":memory:"); + await fs.promises.rm(tempDbPath, { force: true }); + await fs.promises.rm(`${tempDbPath}.bak`, { force: true }); + }); + + it("creates backup, migrates successfully and cleans up backup", async () => { + // Create a dummy migration in our isolated temp migrations directory + const testFile = path.join( + dbTestFixture.migrationsDir, + "0099_file_db_test.sql", + ); + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); + await fs.promises.writeFile( + testFile, + "CREATE TABLE file_db_test (id INTEGER PRIMARY KEY);\n", + ); + + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); + + // Verify it applied successfully + const tables = mockDb + .prepare( + "select name from sqlite_schema where type = 'table' and name = 'file_db_test'", + ) + .all(); + expect(tables.length).toBe(1); + + // Verify the backup file was cleaned up (does not exist anymore) + expect(fs.existsSync(`${tempDbPath}.bak`)).toBe(false); + }); + + it("reports nothing to migrate and cleans up backup on up-to-date file database", async () => { + // Run once to ensure everything is up to date (and since there are no new migrations, pending is 0) + await migrateMain(["node", "script", "-n"], dbTestFixture.rootDir); + + // Verify backup was deleted + expect(fs.existsSync(`${tempDbPath}.bak`)).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/nothing to migrate/i), + ); + }); + + it("removes backup when interactive migration is cancelled", async () => { + // Create a dummy migration in our isolated temp migrations directory + const testFile = path.join( + dbTestFixture.migrationsDir, + "0099_cancel_test.sql", + ); + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); + await fs.promises.writeFile( + testFile, + "CREATE TABLE cancel_test (id INTEGER PRIMARY KEY);\n", + ); + + const readline = await import("node:readline/promises"); + readline.default.createInterface = vi.fn().mockReturnValue({ + question: () => "n", + close: vi.fn(), + }); + + await migrateMain(["node", "script"], dbTestFixture.rootDir); + + // Verify backup was deleted + expect(fs.existsSync(`${tempDbPath}.bak`)).toBe(false); + }); + + it("restores database from backup and removes backup on migration failure", async () => { + // Create a bad migration + const testFile = path.join( + dbTestFixture.migrationsDir, + "0099_fail_test.sql", + ); + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); + await fs.promises.writeFile(testFile, "INVALID SQL STATEMENT;\n"); + + await expect( + migrateMain(["node", "script", "-n"], dbTestFixture.rootDir), + ).rejects.toThrow(); + + // Verify backup was deleted + expect(fs.existsSync(`${tempDbPath}.bak`)).toBe(false); + // Verify database is still queryable (restored and unlocked) + const tables = mockDb + .prepare( + "select name from sqlite_schema where type = 'table' and name not like 'sqlite_%'", + ) + .all(); + expect(tables.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/tests/scripts/database-reset.test.ts b/tests/scripts/database-reset.test.ts new file mode 100644 index 0000000..410575e --- /dev/null +++ b/tests/scripts/database-reset.test.ts @@ -0,0 +1,172 @@ +import fs from "node:fs"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import { main } from "../../scripts/database-reset"; +import database from "../../src/database"; +import { createDbTestFixture, type DbTestFixture } from "./test-utils"; + +let mockDb = new DatabaseSync(":memory:"); + +vi.mock("../../src/database", () => ({ + get default() { + return mockDb; + }, +})); + +const checkSchema = () => { + const tables = database + .prepare( + "select name from sqlite_schema where type = 'table' and name not like 'sqlite_%'", + ) + .all(); + + const tableNames = tables.map((t) => t.name); + + expect(tableNames).toContain("user"); + expect(tableNames).toContain("item"); + expect(tableNames).toContain("magic_link_token"); +}; + +describe("database-reset.ts", () => { + let consoleSpy: ReturnType; + let dbTestFixture: DbTestFixture; + + beforeEach(async () => { + consoleSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + + // Create a unique temporary directory sandbox for each test case + const fixture = await createDbTestFixture("db-reset-test-"); + dbTestFixture = fixture; + + // Reset database state by instantiating a fresh in-memory database + mockDb = new DatabaseSync(":memory:"); + }); + + afterEach(async () => { + consoleSpy.mockRestore(); + await dbTestFixture.cleanup(); + }); + + it("fails when given unexpected arguments", async () => { + await expect(main(["node", "script", "--unknown-flag"])).rejects.toThrow( + /usage/i, + ); + }); + + it("fails when given extra arguments", async () => { + await expect( + main(["node", "script", "--no-interaction", "--extra"]), + ).rejects.toThrow(/usage/i); + }); + + it("cancels when user answers no interactively", async () => { + const readline = await import("node:readline/promises"); + readline.default.createInterface = vi.fn().mockReturnValue({ + question: () => "n", + close: vi.fn(), + }); + + await main(["node", "script"], dbTestFixture.rootDir); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringMatching(/cancelled/)); + }); + + it("loads schema, migrations and seeder in non-interactive mode", async () => { + // Run once to create tables + await main(["node", "script", "-n"], dbTestFixture.rootDir); + + checkSchema(); + + // Run a second time: tables now exist and should be dropped successfully + await main(["node", "script", "-n"], dbTestFixture.rootDir); + + checkSchema(); + + const users = database.prepare("select * from user").all(); + + expect(users.length).toBeGreaterThan(0); + + const items = database.prepare("select * from item").all(); + + expect(items.length).toBeGreaterThan(0); + }); + + it("loads schema, migrations and seeder in interactive mode", async () => { + const readline = await import("node:readline/promises"); + readline.default.createInterface = vi.fn().mockReturnValue({ + question: () => "y", + close: vi.fn(), + }); + + await main(["node", "script"], dbTestFixture.rootDir); + + checkSchema(); + + const users = database.prepare("select * from user").all(); + + expect(users.length).toBeGreaterThan(0); + + const items = database.prepare("select * from item").all(); + + expect(items.length).toBeGreaterThan(0); + }); + + it("populates _migrations table with all executed files", async () => { + await main(["node", "script", "-n"], dbTestFixture.rootDir); + + const migrations = database + .prepare("select filename, checksum from _migrations") + .all() as { filename: string; checksum: string }[]; + + // Should have at least schema.sql and seeder.sql + const filenames = migrations.map((m) => m.filename); + expect(filenames).toContain("schema.sql"); + expect(filenames).toContain("seeder.sql"); + + // All entries should have a checksum + for (const m of migrations) { + expect(m.checksum).toBeTruthy(); + expect(m.checksum.length).toBe(64); // SHA-256 hex + } + }); + + it("includes migration files when present", async () => { + // Create a temporary migration file in our sandbox + const testMigration = path.join( + dbTestFixture.migrationsDir, + "0000_test_reset_migration.sql", + ); + + await fs.promises.mkdir(dbTestFixture.migrationsDir, { recursive: true }); + await fs.promises.writeFile(testMigration, "-- test migration for reset\n"); + + await main(["node", "script", "-n"], dbTestFixture.rootDir); + + const migrations = database + .prepare("select filename from _migrations") + .all(); + + const filenames = migrations.map((m) => m.filename); + expect(filenames).toContain("0000_test_reset_migration.sql"); + }); + + it("skips seeder.sql gracefully if it is missing", async () => { + // Delete seeder.sql from this sandbox + await fs.promises.unlink( + path.join(dbTestFixture.databaseDir, "seeder.sql"), + ); + + await main(["node", "script", "-n"], dbTestFixture.rootDir); + + checkSchema(); + + // Verify seeder.sql was not tracked in _migrations + const migrations = database + .prepare("select filename from _migrations") + .all(); + const filenames = migrations.map((m) => m.filename); + expect(filenames).toContain("schema.sql"); + expect(filenames).not.toContain("seeder.sql"); + }); +}); diff --git a/tests/scripts/database-sync.test.ts b/tests/scripts/database-sync.test.ts deleted file mode 100644 index c3d220d..0000000 --- a/tests/scripts/database-sync.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { DatabaseSync } from "node:sqlite"; - -import { main } from "../../scripts/database-sync"; -import database from "../../src/database"; - -vi.mock("../../src/database", () => ({ - default: new DatabaseSync(":memory:"), -})); - -const checkSchema = () => { - const tables = database - .prepare( - "select name from sqlite_schema where type = 'table' and name not like 'sqlite_%'", - ) - .all() as { name: string }[]; - - const tableNames = tables.map((t) => t.name); - - expect(tableNames).toContain("user"); - expect(tableNames).toContain("item"); - expect(tableNames).toContain("magic_link_token"); -}; - -describe("database-sync.ts", () => { - let consoleSpy: ReturnType; - - beforeEach(() => { - consoleSpy = vi.spyOn(console, "info").mockImplementation(() => {}); - }); - - afterEach(() => { - consoleSpy.mockRestore(); - }); - - it("fails when no target is provided", async () => { - await expect(main(["node", "script"])).rejects.toThrow(/usage/i); - }); - - it("fails when given unexpected arguments", async () => { - await expect(main(["node", "script", "--unknown-flag"])).rejects.toThrow( - /usage/i, - ); - }); - - it("cancels when user answers no interactively", async () => { - const readline = await import("node:readline/promises"); - readline.default.createInterface = vi.fn().mockReturnValue({ - question: () => "n", - close: vi.fn(), - }); - - await main(["node", "script", "both"]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringMatching(/cancelled/)); - }); - - it("loads schema in interactive mode", async () => { - const readline = await import("node:readline/promises"); - readline.default.createInterface = vi.fn().mockReturnValue({ - question: () => "y", - close: vi.fn(), - }); - - await main(["node", "script", "schema"]); - - checkSchema(); - - const users = database.prepare("select * from user").all(); - - expect(users.length).toBe(0); - - const items = database.prepare("select * from item").all(); - - expect(items.length).toBe(0); - }); - - it("loads schema in non-interactive mode", async () => { - await main(["node", "script", "schema", "-n"]); - - checkSchema(); - - const users = database.prepare("select * from user").all(); - - expect(users.length).toBe(0); - - const items = database.prepare("select * from item").all(); - - expect(items.length).toBe(0); - }); - - it("loads seeder in interactive mode", async () => { - await main(["node", "script", "schema", "-n"]); - - checkSchema(); - - const readline = await import("node:readline/promises"); - readline.default.createInterface = vi.fn().mockReturnValue({ - question: () => "y", - close: vi.fn(), - }); - - await main(["node", "script", "seeder"]); - - const users = database.prepare("select * from user").all(); - - expect(users.length).toBeGreaterThan(0); - - const items = database.prepare("select * from item").all(); - - expect(items.length).toBeGreaterThan(0); - }); - - it("loads seeder in non-interactive mode", async () => { - await main(["node", "script", "schema", "-n"]); - - checkSchema(); - - await main(["node", "script", "seeder", "-n"]); - - const users = database.prepare("select * from user").all(); - - expect(users.length).toBeGreaterThan(0); - - const items = database.prepare("select * from item").all(); - - expect(items.length).toBeGreaterThan(0); - }); - - it("loads both in interactive mode", async () => { - const readline = await import("node:readline/promises"); - readline.default.createInterface = vi.fn().mockReturnValue({ - question: () => "y", - close: vi.fn(), - }); - - await main(["node", "script", "both"]); - - checkSchema(); - - const users = database.prepare("select * from user").all(); - - expect(users.length).toBeGreaterThan(0); - - const items = database.prepare("select * from item").all(); - - expect(items.length).toBeGreaterThan(0); - }); - - it("loads both in non-interactive mode", async () => { - await main(["node", "script", "both", "-n"]); - - checkSchema(); - - const users = database.prepare("select * from user").all(); - - expect(users.length).toBeGreaterThan(0); - - const items = database.prepare("select * from item").all(); - - expect(items.length).toBeGreaterThan(0); - }); -}); diff --git a/tests/scripts/make-clone.test.ts b/tests/scripts/make-clone.test.ts index 7f96d34..cc1809f 100644 --- a/tests/scripts/make-clone.test.ts +++ b/tests/scripts/make-clone.test.ts @@ -149,4 +149,36 @@ describe("make-clone.ts", () => { expect(unrelatedContent).toBe("export class Unrelated { apple = 10; }\n"); }); + + it("clones to src/express/modules and logs checklist instructions", async () => { + const src = path.join(tmpDir, "src", "cherry.ts"); + const dest = path.join(tmpDir, "src", "express", "modules", "berry.ts"); + + await fs.ensureDir(path.dirname(src)); + await fs.writeFile(src, "export const cherry = 'sweet';\n"); + + await main(["node", "script", src, dest, "cherry", "berry"]); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching( + /Register the Express module in src\/express\/routes.ts/, + ), + ); + }); + + it("clones to src/react/components and logs checklist instructions", async () => { + const src = path.join(tmpDir, "src", "cherry.ts"); + const dest = path.join(tmpDir, "src", "react", "components", "berry.ts"); + + await fs.ensureDir(path.dirname(src)); + await fs.writeFile(src, "export const cherry = 'sweet';\n"); + + await main(["node", "script", src, dest, "cherry", "berry"]); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching( + /Register the React routes in src\/react\/routes.tsx/, + ), + ); + }); }); diff --git a/tests/scripts/make-purge.test.ts b/tests/scripts/make-purge.test.ts index 1cb82a0..cfa511a 100644 --- a/tests/scripts/make-purge.test.ts +++ b/tests/scripts/make-purge.test.ts @@ -4,6 +4,10 @@ import fs from "fs-extra"; import { main } from "../../scripts/make-purge"; +class TestError extends Error { + code?: string; +} + const projectRoot = path.join(import.meta.dirname, "../.."); /** @@ -68,6 +72,9 @@ describe.skipIf(isAlreadyPurged)("make-purge.ts", () => { expect( await fs.pathExists(path.join(tmpDir, "src/express/modules/auth")), ).toBe(false); + + // Run second time to ensure idempotency and cover unmodified files path + await main(["node", "script", "-n"], tmpDir); }); it("runs purge for items only with --keep-auth", async () => { @@ -405,4 +412,65 @@ describe.skipIf(isAlreadyPurged)("make-purge.ts", () => { expect(result).toContain("itemRoutes"); }); }); + + describe("error handling in remove", () => { + let errorSpy: ReturnType; + + beforeEach(() => { + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + errorSpy.mockRestore(); + }); + + it("logs console error when fs.remove fails with non-ENOENT error", async () => { + const mockError = new TestError("Permission denied"); + mockError.code = "EACCES"; + const removeSpy = vi.spyOn(fs, "remove").mockRejectedValueOnce(mockError); + + await main(["node", "script", "-n"], tmpDir); + + expect(errorSpy).toHaveBeenCalled(); + removeSpy.mockRestore(); + }); + + it("silently ignores ENOENT error in remove", async () => { + const mockError = new TestError("File not found"); + mockError.code = "ENOENT"; + const removeSpy = vi.spyOn(fs, "remove").mockRejectedValueOnce(mockError); + + await main(["node", "script", "-n"], tmpDir); + + expect(errorSpy).not.toHaveBeenCalled(); + removeSpy.mockRestore(); + }); + }); + + describe("error handling in updateFile", () => { + let errorSpy: ReturnType; + + beforeEach(() => { + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + errorSpy.mockRestore(); + }); + + it("logs console error when fs.readFile fails with non-ENOENT error", async () => { + await scaffoldProject(tmpDir); + + const mockError = new TestError("Permission denied"); + mockError.code = "EACCES"; + const readFileSpy = vi + .spyOn(fs, "readFile") + .mockRejectedValueOnce(mockError); + + await main(["node", "script", "-n"], tmpDir); + + expect(errorSpy).toHaveBeenCalled(); + readFileSpy.mockRestore(); + }); + }); }); diff --git a/tests/scripts/test-utils.ts b/tests/scripts/test-utils.ts new file mode 100644 index 0000000..e15926d --- /dev/null +++ b/tests/scripts/test-utils.ts @@ -0,0 +1,45 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export interface DbTestFixture { + rootDir: string; + databaseDir: string; + migrationsDir: string; + cleanup: () => Promise; +} + +/** + * Creates a unique isolated database sandbox workspace under os.tmpdir() + * and populates it with the project's schema.sql and seeder.sql. + */ +export async function createDbTestFixture( + prefix: string, +): Promise { + const rootDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), prefix)); + const databaseDir = path.join(rootDir, "src/database"); + const migrationsDir = path.join(databaseDir, "migrations"); + + await fs.promises.mkdir(databaseDir, { recursive: true }); + + const projectRoot = path.join(import.meta.dirname, "../.."); + await fs.promises.copyFile( + path.join(projectRoot, "src/database/schema.sql"), + path.join(databaseDir, "schema.sql"), + ); + await fs.promises.copyFile( + path.join(projectRoot, "src/database/seeder.sql"), + path.join(databaseDir, "seeder.sql"), + ); + + const cleanup = async () => { + await fs.promises.rm(rootDir, { recursive: true, force: true }); + }; + + return { + rootDir, + databaseDir, + migrationsDir, + cleanup, + }; +}