mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-04 00:47:02 +01:00
172 lines
6.5 KiB
Rust
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(())
|
|
}
|