mirror of
https://github.com/Such-Software/grin-lws.git
synced 2026-10-18 08:01:32 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?;
|
||||
|
||||
Reference in New Issue
Block a user