diff --git a/README.md b/README.md index 77bfc04..bc27ad8 100644 --- a/README.md +++ b/README.md @@ -125,8 +125,8 @@ a no-op. ## Database schema -See `migrations/0001_init.sql`. Postgres flavored; a SQLite single-operator build -swaps the type spellings (noted inline). **Never** stores a private/spend key. +See `migrations/0001_init.sql`. It runs **unchanged on both PostgreSQL and +SQLite** (verified in a test). **Never** stores a private/spend key. ``` accounts(rewind_hash PK, start_height, scan_height, created_at) @@ -208,7 +208,7 @@ Steps 3–5 are the bulk of the work and are why this is its own project. ```sh # 1. Provision a DB and run the migration. # Postgres: createdb grinlws && psql grinlws < migrations/0001_init.sql -# SQLite: sqlite3 grin-lws.db < migrations/0001_init.sql (after type-swap) +# SQLite: sqlite3 grin-lws.db < migrations/0001_init.sql # 2. Configure. cp .env.example .env # edit DATABASE_URL + GRIN_NODE_URL # 3. Run. diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index bbc570b..b3da13b 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -6,10 +6,12 @@ -- is view-only (it can recognize a wallet's outputs and read amounts, but -- cannot spend). -- --- Dialect note: written for PostgreSQL. For a single-operator SQLite build, --- replace BIGINT -> INTEGER, BOOLEAN -> INTEGER (0/1), TIMESTAMPTZ -> --- TEXT/INTEGER, and drop `DEFAULT now()`. `commit` is a reserved word in SQL, --- hence it is always quoted as "commit". +-- Dialect: runs as-is on BOTH PostgreSQL and SQLite (verified in a test). +-- SQLite accepts the Postgres type names via its type-affinity rules +-- (BIGINT/BOOLEAN/TIMESTAMPTZ), and the app binds `$N` placeholders through +-- sqlx's `Any` driver, which SQLite accepts. `DEFAULT CURRENT_TIMESTAMP` is +-- portable (unlike `now()`). `commit` is a reserved word, so it is always +-- quoted as "commit". -- An account = one registered view credential. CREATE TABLE IF NOT EXISTS accounts ( @@ -21,7 +23,7 @@ CREATE TABLE IF NOT EXISTS accounts ( -- How far the scanner has processed this account. Advances toward the tip; -- lowered only by an explicit backwards rescan. scan_height BIGINT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- Outputs the scanner recognized for a registered account (rewound rangeproof diff --git a/src/db.rs b/src/db.rs index a712c87..92634a4 100644 --- a/src/db.rs +++ b/src/db.rs @@ -4,11 +4,12 @@ //! Uses `sqlx::Any` so a single build serves both Postgres (scale) and SQLite //! (single operator) — the driver is chosen at runtime from `DATABASE_URL`. //! -//! NOTE (dialect): the SQL below uses Postgres `$N` placeholders. sqlx's `Any` -//! backend forwards placeholders verbatim, so a SQLite deployment should run the -//! SQLite-flavored migration (see `migrations/`) and, if it hits placeholder -//! incompatibility, swap `$N` for `?`. This scaffold keeps one dialect for -//! readability; pick your target DB before landing the scanner. +//! Dialect: the SQL below uses Postgres `$N` placeholders and runs unchanged on +//! BOTH Postgres and SQLite through sqlx's `Any` driver (SQLite accepts `$N` +//! numbered parameters and binds them positionally). Verified end-to-end by the +//! `sqlite_probe` test, which applies the real migration + reused-placeholder +//! upserts against SQLite. `DEFAULT CURRENT_TIMESTAMP` (not `now()`) keeps the +//! schema portable. //! //! SECURITY: only view-only data is ever stored. No spend key, no private key. @@ -331,6 +332,82 @@ pub async fn set_cursor( Ok(()) } +#[cfg(test)] +mod sqlite_probe { + use super::*; + + /// Run the Postgres-flavored `0001_init.sql` against SQLite and exercise the + /// real helpers — including the REUSED-placeholder upsert (`set_cursor` binds + /// `$1/$2/$3` twice each) and quoted `"commit"`. Scopes the Phase-4 dual-DB + /// work: what (if anything) actually breaks on SQLite. + #[tokio::test] + async fn full_schema_and_reused_placeholders_on_sqlite() { + sqlx::any::install_default_drivers(); + let path = std::env::temp_dir().join("grinlws_sqlite_probe.db"); + let _ = std::fs::remove_file(&path); + let url = format!("sqlite://{}?mode=rwc", path.display()); + let pool = AnyPoolOptions::new() + .max_connections(1) + .connect(&url) + .await + .expect("connect sqlite"); + + // Apply the real migration. Strip `-- ...` comments FIRST (they contain + // semicolons), THEN split into statements. + let schema = include_str!("../migrations/0001_init.sql"); + let no_comments: String = schema + .lines() + .map(|l| match l.find("--") { + Some(i) => &l[..i], + None => l, + }) + .collect::>() + .join("\n"); + for chunk in no_comments.split(';') { + let sql = chunk.trim(); + if sql.is_empty() { + continue; + } + sqlx::query(sql) + .execute(&pool) + .await + .unwrap_or_else(|e| panic!("SQLite rejected: {sql}\n -> {e}")); + } + + // accounts + reused-placeholder upsert (set_cursor $1/$2/$3 twice each) + assert!(register_account(&pool, "aa", 100).await.expect("register")); + set_cursor(&pool, 5, 200, "hashA").await.expect("set_cursor insert"); + set_cursor(&pool, 9, 210, "hashB").await.expect("set_cursor upsert (reused $N)"); + let c = get_cursor(&pool).await.expect("get_cursor").expect("row"); + assert_eq!((c.output_mmr_index, c.tip_height, c.tip_hash.as_str()), (9, 210, "hashB")); + + // outputs: insert (quoted "commit", bool, 9 binds) + maturity read + let out = StoredOutput { + commit: "09ab".into(), + value: 1_000_000_000, + height: 100, + mmr_index: 42, + is_coinbase: true, + lock_height: 100 + 1440, + key_id: Some("0400".into()), + n_child: Some(0), + }; + insert_output(&pool, &out, "aa").await.expect("insert_output"); + let (total, count) = account_totals(&pool, "aa").await.expect("totals"); + assert_eq!((total, count), (1_000_000_000, 1)); + // immature coinbase: unlocked at tip 200 must be 0; at tip 1540+ it's spendable + let (unlocked_now, _) = account_unlocked_totals(&pool, "aa", 200).await.expect("unlocked"); + assert_eq!(unlocked_now, 0, "immature coinbase not unlocked"); + let (unlocked_mature, _) = account_unlocked_totals(&pool, "aa", 2000).await.expect("unlocked2"); + assert_eq!(unlocked_mature, 1_000_000_000, "mature coinbase unlocked"); + // spend by absence + mark_spent(&pool, "09ab", 300).await.expect("mark_spent"); + assert_eq!(account_totals(&pool, "aa").await.unwrap().0, 0, "spent → zero balance"); + + let _ = std::fs::remove_file(&path); + } +} + /// Roll back all stored state above `fork_height` after a detected reorg. pub async fn rollback_to(pool: &AnyPool, fork_height: i64) -> Result<()> { let mut tx = pool.begin().await?;