initial commit
This commit is contained in:
commit
65de1782c9
43 files changed
+9164
No files matched your search
+185
@@ -0,0 +1,185 @@
|
||||
use rusqlite::Connection;
|
||||
use std::{error::Error, path::Path, time::Duration};
|
||||
|
||||
/// Open the existing Go server's SQLite database without changing its schema.
|
||||
pub fn open(path: &str) -> Result<Connection, Box<dyn Error + Send + Sync>> {
|
||||
if let Some(parent) = Path::new(path)
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
{
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
builder.recursive(true);
|
||||
// Match Go's MkdirAll(..., 0750): the database directory must not be
|
||||
// accessible to other local users under the usual 022 umask.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
builder.mode(0o750);
|
||||
}
|
||||
builder.create(parent)?;
|
||||
}
|
||||
let db = Connection::open(path)?;
|
||||
db.pragma_update(None, "foreign_keys", true)?;
|
||||
db.pragma_update(None, "journal_mode", "WAL")?;
|
||||
db.busy_timeout(Duration::from_secs(5))?;
|
||||
migrate(&db)?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
pub fn migrate(db: &Connection) -> rusqlite::Result<()> {
|
||||
db.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
revoked_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS refresh_tokens_user_idx ON refresh_tokens(user_id);
|
||||
CREATE TABLE IF NOT EXISTS records (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
entity TEXT NOT NULL,
|
||||
record_id TEXT NOT NULL,
|
||||
payload TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
version INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, entity, record_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS changes (
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
entity TEXT NOT NULL,
|
||||
record_id TEXT NOT NULL,
|
||||
payload TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted INTEGER NOT NULL,
|
||||
version INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS changes_user_sequence_idx ON changes(user_id, sequence);",
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn schema_is_repeatable_and_enforces_existing_constraints() {
|
||||
let db = open(":memory:").unwrap();
|
||||
migrate(&db).unwrap();
|
||||
db.execute(
|
||||
"INSERT INTO users VALUES ('u','Test@example.com','hash','now')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
db.execute(
|
||||
"INSERT INTO users VALUES ('v','test@example.com','hash','now')",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
db.execute(
|
||||
"INSERT INTO records VALUES ('missing','category','c',NULL,'now',1,1)",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
db.execute("INSERT INTO refresh_tokens(token_hash,user_id,expires_at,created_at) VALUES ('hash','u','then','now')", []).unwrap();
|
||||
db.execute("DELETE FROM users WHERE id='u'", []).unwrap();
|
||||
assert_eq!(
|
||||
db.query_row("SELECT count(*) FROM refresh_tokens", [], |r| r
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_populated_go_schema_without_changing_rows_or_sequence() {
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"balance-go-db-{}",
|
||||
crate::auth::identifier().unwrap()
|
||||
));
|
||||
std::fs::create_dir(&directory).unwrap();
|
||||
let path = directory.join("existing.sqlite");
|
||||
{
|
||||
let db = Connection::open(&path).unwrap();
|
||||
// Use the original schema, so a divergence between implementations
|
||||
// is caught even when Rust's migration is internally consistent.
|
||||
let go_source = include_str!("../internal/database/database.go");
|
||||
let schema = go_source
|
||||
.split("const schema = `")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split('`')
|
||||
.next()
|
||||
.unwrap();
|
||||
db.execute_batch(schema).unwrap();
|
||||
db.execute_batch(
|
||||
"INSERT INTO users VALUES ('go-user','go@example.com','existing hash','2026-01-01T00:00:00.123456789Z');
|
||||
INSERT INTO refresh_tokens(id,token_hash,user_id,expires_at,created_at) VALUES (7,'existing-token-hash','go-user','2099-01-01T00:00:00Z','2026-01-01T00:00:00Z');
|
||||
INSERT INTO records VALUES ('go-user','transaction','record', '{\"amount\":12.5}', '2026-01-01T00:00:00.123456789Z',0,41);
|
||||
INSERT INTO changes VALUES (41,'go-user','transaction','record','{\"amount\":12.5}','2026-01-01T00:00:00.123456789Z',0,41);"
|
||||
).unwrap();
|
||||
}
|
||||
{
|
||||
let db = open(path.to_str().unwrap()).unwrap();
|
||||
let user: (String, String) = db
|
||||
.query_row(
|
||||
"SELECT password_hash,created_at FROM users WHERE id='go-user'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
user,
|
||||
(
|
||||
"existing hash".into(),
|
||||
"2026-01-01T00:00:00.123456789Z".into()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
db.query_row(
|
||||
"SELECT id FROM refresh_tokens WHERE revoked_at IS NULL",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
7
|
||||
);
|
||||
assert_eq!(
|
||||
db.query_row("SELECT payload FROM records", [], |r| r.get::<_, String>(0))
|
||||
.unwrap(),
|
||||
"{\"amount\":12.5}"
|
||||
);
|
||||
db.execute("INSERT INTO changes(user_id,entity,record_id,payload,updated_at,deleted,version) VALUES ('go-user','transaction','next',NULL,'now',1,42)", []).unwrap();
|
||||
assert_eq!(db.last_insert_rowid(), 42);
|
||||
assert_eq!(
|
||||
db.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
db.query_row("PRAGMA journal_mode", [], |r| r.get::<_, String>(0))
|
||||
.unwrap(),
|
||||
"wal"
|
||||
);
|
||||
assert_eq!(
|
||||
db.query_row("PRAGMA busy_timeout", [], |r| r.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
5000
|
||||
);
|
||||
}
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user