diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 81c0900..cf8850f 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -1,13 +1,12 @@ import { test as base, expect, Page } from "@playwright/test"; import { spawn, ChildProcess } from "child_process"; -import * as fs from "fs"; import * as os from "os"; import * as path from "path"; /** - * Starts a fresh Sustenance server against a unique, throwaway database on a - * unique port for each test, and tears it down afterwards. This gives every - * test a clean DB with no shared state between tests. + * Starts a fresh Sustenance server against an in-memory SQLite database on a + * unique port for each test, and tears it down afterwards. Every test gets a + * clean DB with no shared state between tests, and nothing is written to disk. */ export const test = base.extend<{ server: { baseURL: string }; page: Page }>({ server: [ @@ -15,10 +14,6 @@ export const test = base.extend<{ server: { baseURL: string }; page: Page }>({ const server = await startServer(); await use({ baseURL: server.baseURL }); await killTree(server.child); - // Clean up the DB files (including -wal / -shm). - for (const suffix of ["", "-wal", "-shm"]) { - fs.rmSync(server.dbPath + suffix, { force: true }); - } }, { scope: "test", auto: true }, ], @@ -35,12 +30,6 @@ export const test = base.extend<{ server: { baseURL: string }; page: Page }>({ /** Starts a server, retrying on a fresh port if the first attempt fails to bind. */ async function startServer() { for (let attempt = 0; attempt < 5; attempt++) { - const dbPath = path.join( - os.tmpdir(), - `sustenance-e2e-${process.pid}-${Date.now()}-${Math.random() - .toString(36) - .slice(2)}.db`, - ); const port = 20000 + Math.floor(Math.random() * 30000); const baseURL = `http://localhost:${port}`; @@ -50,7 +39,7 @@ async function startServer() { { env: { ...process.env, - DATABASE_PATH: dbPath, + DATABASE_IN_MEMORY: "1", REGISTRATION_MODE: "open", BIND_ADDRESS: `127.0.0.1:${port}`, PUBLIC_BASE_URL: baseURL, @@ -72,13 +61,10 @@ async function startServer() { try { await waitForServer(baseURL, child); - return { baseURL, child, dbPath }; + return { baseURL, child }; } catch (error) { // The server may have failed to bind (port collision). Clean up and retry. await killTree(child); - for (const suffix of ["", "-wal", "-shm"]) { - fs.rmSync(dbPath + suffix, { force: true }); - } if (attempt === 4) { throw new Error( `server failed to start after retries; last stderr:\n${stderr}\n${error}`, diff --git a/src/main.rs b/src/main.rs index 8964111..53ba6d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,6 +42,9 @@ async fn main() -> Result<(), Box> { .init(); let database_path = env::var("DATABASE_PATH").unwrap_or_else(|_| "sustenance.db".into()); + let database_in_memory = env::var("DATABASE_IN_MEMORY") + .map(|value| value == "1" || value.eq_ignore_ascii_case("true")) + .unwrap_or(false); let bind_address = env::var("BIND_ADDRESS").unwrap_or_else(|_| "127.0.0.1:3000".into()); // For loopback hosts, advertise `localhost` so WebAuthn works locally (browsers // reject IP addresses as RP IDs). Access the app via http://localhost:PORT. @@ -69,7 +72,11 @@ async fn main() -> Result<(), Box> { }; // Build the adapters (ports) and wire them into application services. - let db = SqliteDatabase::open(&database_path).await?; + let db = if database_in_memory { + SqliteDatabase::open_in_memory().await? + } else { + SqliteDatabase::open(&database_path).await? + }; let users: Arc = Arc::new(SqliteUserRepository); let sessions: Arc = Arc::new(SqliteSessionRepository); let lists: Arc = Arc::new(SqliteListRepository); diff --git a/src/sqlite.rs b/src/sqlite.rs index b66d2b3..867d641 100644 --- a/src/sqlite.rs +++ b/src/sqlite.rs @@ -39,26 +39,32 @@ impl SqliteDatabase { Ok(Self { pool }) } - #[cfg(test)] + /// Opens an in-memory database. Every connection in the pool shares the same + /// in-memory database via a named shared-cache database, so the schema and + /// data are visible across the whole pool. Nothing is persisted to disk. pub async fn open_in_memory() -> DomainResult { - // A pool of `:memory:` connections would each get a separate database, - // so use a unique temporary file that shares the schema across the pool. + // A plain `:memory:` database is private to each connection, so a pool + // would get a separate database per connection. Use a uniquely-named + // shared-cache in-memory database instead so the whole pool shares one. + // + // The name must be unique per call: shared-cache in-memory databases are + // keyed by name, so two calls with the same name within a process would + // resolve to the same database and share state. This matters for the + // unit tests, which run many `open_in_memory()` calls in parallel within + // a single process and need isolation from each other. The counter makes + // each call unique. (Shared-cache and in-memory databases are per-process, + // so there is no cross-process collision to guard against.) use std::sync::atomic::{AtomicU64, Ordering}; static COUNTER: AtomicU64 = AtomicU64::new(0); let unique = COUNTER.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir().join(format!( - "sustenance-test-{}-{}-{}.db", - std::process::id(), - now(), - unique - )); - let path = path.to_str().unwrap(); + let filename = format!("file:sustenance-in-memory-{}", unique); let options = SqliteConnectOptions::new() - .filename(path) - .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) + .filename(filename) + .in_memory(true) + .shared_cache(true) + .journal_mode(sqlx::sqlite::SqliteJournalMode::Memory) .foreign_keys(true) - .busy_timeout(std::time::Duration::from_secs(5)) - .create_if_missing(true); + .busy_timeout(std::time::Duration::from_secs(5)); let pool = SqlitePool::connect_with(options).await.map_err(db_error)?; MIGRATOR.run(&pool).await.map_err(migrate_error)?; seed_default_categories(&pool).await?;