Files
holy-flavor-rater/backend-rs/src/db.rs
T

172 lines
6.5 KiB
Rust

use sea_orm::{
ConnectOptions, ConnectionTrait, Database, DatabaseBackend, DatabaseConnection, Statement,
};
use std::time::Duration;
pub async fn connect(database_url: &str) -> anyhow::Result<DatabaseConnection> {
let mut opts = ConnectOptions::new(database_url.to_owned());
opts.max_connections(10)
.min_connections(1)
.connect_timeout(Duration::from_secs(8))
.sqlx_logging(false);
// sqlx already applies `busy_timeout=5s` and `foreign_keys=ON` per connection
// by default (matching Django's 5s SQLite timeout), so SQLITE_BUSY waits up to
// 5s before erroring rather than failing instantly.
let conn = Database::connect(opts).await?;
// Enable WAL so the Rust and Django processes that share this SQLite file
// during the strangler don't serialize readers behind the writer. In the
// default rollback-journal mode a writer takes an exclusive lock that blocks
// all readers, which surfaces as SQLITE_BUSY → 500s under concurrent load.
// WAL lets readers proceed during a write. It is a persistent, database-level
// setting stored in the file header, so this single statement covers every
// pooled connection and the Django side too. Best-effort: switching journal
// mode needs a brief exclusive lock that can't wait on busy_timeout, so log
// and continue (the 5s busy_timeout still applies) if a writer holds it now.
match conn
.execute(Statement::from_string(
DatabaseBackend::Sqlite,
"PRAGMA journal_mode=WAL;",
))
.await
{
Ok(_) => tracing::info!("sqlite journal_mode=WAL enabled"),
Err(e) => {
tracing::warn!("could not enable WAL (continuing in default journal mode): {e}")
}
}
ensure_schema(&conn).await?;
Ok(conn)
}
/// Idempotent, additive schema patches the Rust backend owns now that Django is
/// retired. SQLite has no `ADD COLUMN IF NOT EXISTS`, so we probe `PRAGMA
/// table_info` first and only ALTER when the column is missing.
async fn ensure_schema(conn: &DatabaseConnection) -> anyhow::Result<()> {
add_column_if_missing(
conn,
"api_flavor",
"aliases",
"ALTER TABLE api_flavor ADD COLUMN aliases TEXT",
)
.await?;
// Account-deletion (GDPR "right to erasure") flow gets its own dedicated,
// DB-persisted code + expiry. Previously it reused `email_confirmation_code`
// and an in-process TTL, so a concurrent email-confirm/password-reset flow
// could silently clobber the deletion code (the column ended up empty) and a
// backend restart dropped the expiry — both could block a user from deleting
// their account. Dedicated, persisted columns make the flow restart-safe,
// multi-instance-safe, and immune to cross-flow collisions.
add_column_if_missing(
conn,
"api_user",
"deletion_code",
"ALTER TABLE api_user ADD COLUMN deletion_code TEXT",
)
.await?;
add_column_if_missing(
conn,
"api_user",
"deletion_code_expires",
"ALTER TABLE api_user ADD COLUMN deletion_code_expires DATETIME",
)
.await?;
// Rust-owned table (no Django model): emoji reactions on ratings. UNIQUE makes
// a duplicate react a no-op; the index keeps the per-feed count query cheap.
conn.execute(Statement::from_string(
DatabaseBackend::Sqlite,
"CREATE TABLE IF NOT EXISTS rating_reaction (\
id INTEGER PRIMARY KEY AUTOINCREMENT, \
user_id INTEGER NOT NULL, \
rating_id INTEGER NOT NULL, \
kind VARCHAR(32) NOT NULL, \
created_at DATETIME NOT NULL, \
UNIQUE(user_id, rating_id, kind))"
.to_owned(),
))
.await?;
conn.execute(Statement::from_string(
DatabaseBackend::Sqlite,
"CREATE INDEX IF NOT EXISTS idx_rating_reaction_rating \
ON rating_reaction(rating_id)"
.to_owned(),
))
.await?;
// Shopify variant ids belonging to this flavor's product, as a JSON list of
// strings. Populated by `sync_flavors`; `sync_reviews` needs it because the
// reviews.io feed identifies a product by *variant* id (its `sku` field),
// while `external_id` holds the *product* id for everything but Syrup.
add_column_if_missing(
conn,
"api_flavor",
"variant_ids",
"ALTER TABLE api_flavor ADD COLUMN variant_ids TEXT",
)
.await?;
// Rust-owned table (no Django model): shop reviews pulled from reviews.io by
// the `sync_reviews` job, used as an anonymous prior under the community
// recommender. Deliberately stores no personal data — the reviewer is a
// salted hash of the upstream reviewer id, and the review body, author name
// and city from the upstream payload are all discarded on ingest.
// UNIQUE(source, external_id) makes re-ingesting a page a no-op.
conn.execute(Statement::from_string(
DatabaseBackend::Sqlite,
"CREATE TABLE IF NOT EXISTS external_review (\
id INTEGER PRIMARY KEY AUTOINCREMENT, \
source VARCHAR(32) NOT NULL, \
external_id VARCHAR(64) NOT NULL, \
reviewer_key VARCHAR(64) NOT NULL, \
flavor_id INTEGER NOT NULL, \
rating INTEGER NOT NULL, \
reviewed_at DATETIME NOT NULL, \
created_at DATETIME NOT NULL, \
UNIQUE(source, external_id))"
.to_owned(),
))
.await?;
conn.execute(Statement::from_string(
DatabaseBackend::Sqlite,
"CREATE INDEX IF NOT EXISTS idx_external_review_flavor \
ON external_review(flavor_id)"
.to_owned(),
))
.await?;
conn.execute(Statement::from_string(
DatabaseBackend::Sqlite,
"CREATE INDEX IF NOT EXISTS idx_external_review_reviewer \
ON external_review(reviewer_key)"
.to_owned(),
))
.await?;
Ok(())
}
async fn add_column_if_missing(
conn: &DatabaseConnection,
table: &str,
column: &str,
alter_sql: &str,
) -> anyhow::Result<()> {
let rows = conn
.query_all(Statement::from_string(
DatabaseBackend::Sqlite,
format!("PRAGMA table_info({table})"),
))
.await?;
let exists = rows
.iter()
.any(|r| r.try_get::<String>("", "name").as_deref() == Ok(column));
if exists {
return Ok(());
}
conn.execute(Statement::from_string(
DatabaseBackend::Sqlite,
alter_sql.to_owned(),
))
.await?;
tracing::info!("schema: added {table}.{column}");
Ok(())
}