Phase 4: verify + fix genuine dual Postgres/SQLite support

The review's premise (sqlx::Any breaks on SQLite $N placeholders) is
wrong for sqlx 0.7: SQLite accepts $N numbered params and binds them
positionally, including reused placeholders. A probe test applies the
real migration + exercises set_cursor's reused-$N upsert, insert_output
(quoted "commit", bool), coinbase-maturity reads, and mark_spent against
SQLite — all pass. The only true incompatibility was DEFAULT now(),
changed to the portable DEFAULT CURRENT_TIMESTAMP. Schema + queries now
run unchanged on both backends. Updated the stale dialect notes.
This commit is contained in:
jwinterm
2026-07-09 13:11:49 -04:00
parent 75542f6358
commit 0dc1804e67
3 changed files with 92 additions and 13 deletions
+3 -3
View File
@@ -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.
+7 -5
View File
@@ -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
+82 -5
View File
@@ -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::<Vec<_>>()
.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?;