Files
k-mood/crates/adapters/sqlite/src/db.rs
Gabriel Kaszewski bf148902ab spa hardening, offline logging, rate limit fixes
server:
- backup exporter, auth extractors, error shapes, CONTEXT (prior work)
- spa assets served outside the rate limit via route_layer
- requests_per_second went to per_second(), which takes an interval not a
  rate: 50 meant one request per 50s once burst was spent. now converted
  properly. 15/s, burst 60

spa fixes:
- account delete cleared snake_case token keys that were never written
- refresh interceptor could retry forever
- date ranges used local day boundaries stamped +00:00
- "all" period trend plotted one page; calendar days fabricated mood 3
- chart grid invisible: hsl(var(--border)) against rgba tokens
- blob url leak, orphaned media on failed save, devtools in prod bundle
- pt-safe/safe-area-pb classes never existed

spa features:
- offline outbox: entries queue to IndexedDB, replay with backoff, only
  server refusals count against an entry
- drafts persist, quick-log sheet, diary infinite scroll + filters
- route error boundary, stale-chunk recovery, no service worker in dev

a11y + perf:
- mood picker is a radiogroup, activity picker keyboard-operable,
  text alternatives for colour/emoji, locale week start
- dark glass over the bright photo: worst case 1.4:1 -> 4.9-9.6:1
- initial payload 1095->769kB raw, 306->230kB gzip; 38 unused components
  and 5 deps dropped; fonts 218->133kB

53 tests added (43 spa, 10 server)
2026-08-28 15:00:30 +02:00

156 lines
4.2 KiB
Rust

use std::time::Duration;
use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
const BUSY_TIMEOUT: Duration = Duration::from_secs(10);
const MIGRATIONS: &[(&str, &str)] = &[
("001_initial", include_str!("migrations/001_initial.sql")),
(
"002_push_subscriptions",
include_str!("migrations/002_push_subscriptions.sql"),
),
(
"003_entry_content",
include_str!("migrations/003_entry_content.sql"),
),
(
"004_drop_entry_content_column",
include_str!("migrations/004_drop_entry_content_column.sql"),
),
(
"005_location_and_song",
include_str!("migrations/005_location_and_song.sql"),
),
(
"006_provider_connections",
include_str!("migrations/006_provider_connections.sql"),
),
(
"007_daily_metrics",
include_str!("migrations/007_daily_metrics.sql"),
),
(
"008_api_tokens",
include_str!("migrations/008_api_tokens.sql"),
),
(
"009_metric_rejections",
include_str!("migrations/009_metric_rejections.sql"),
),
(
"010_cycle_and_preferences",
include_str!("migrations/010_cycle_and_preferences.sql"),
),
("011_jobs", include_str!("migrations/011_jobs.sql")),
(
"012_entry_weather",
include_str!("migrations/012_entry_weather.sql"),
),
(
"013_reminder_last_sent",
include_str!("migrations/013_reminder_last_sent.sql"),
),
(
"014_media_ownership",
include_str!("migrations/014_media_ownership.sql"),
),
(
"015_logged_at_in_utc",
include_str!("migrations/015_logged_at_in_utc.sql"),
),
(
"016_api_token_scopes",
include_str!("migrations/016_api_token_scopes.sql"),
),
];
const TAKE_THE_WRITE_LOCK_UP_FRONT: &str = "BEGIN IMMEDIATE";
const SCHEMA_MIGRATIONS_TABLE: &str = "CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY NOT NULL,
applied_at TEXT NOT NULL
)";
pub fn migrations_before(name: &str) -> impl Iterator<Item = (&'static str, &'static str)> {
MIGRATIONS
.iter()
.take_while(move |(applied, _)| *applied != name)
.copied()
}
pub async fn create_pool(database_url: &str) -> Result<SqlitePool, sqlx::Error> {
let options: SqliteConnectOptions = database_url
.parse::<SqliteConnectOptions>()?
.create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.busy_timeout(BUSY_TIMEOUT)
.foreign_keys(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(options)
.await?;
Ok(pool)
}
pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> {
let mut connection = pool.acquire().await?;
sqlx::raw_sql(SCHEMA_MIGRATIONS_TABLE)
.execute(&mut *connection)
.await?;
sqlx::raw_sql(TAKE_THE_WRITE_LOCK_UP_FRONT)
.execute(&mut *connection)
.await?;
match apply_pending(&mut connection).await {
Ok(()) => {
sqlx::raw_sql("COMMIT").execute(&mut *connection).await?;
Ok(())
}
Err(error) => {
let _ = sqlx::raw_sql("ROLLBACK").execute(&mut *connection).await;
Err(error)
}
}
}
async fn apply_pending(connection: &mut sqlx::SqliteConnection) -> Result<(), sqlx::Error> {
for (name, sql) in MIGRATIONS {
if is_applied(connection, name).await? {
continue;
}
sqlx::raw_sql(*sql).execute(&mut *connection).await?;
sqlx::query("INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)")
.bind(*name)
.bind(chrono::Utc::now().to_rfc3339())
.execute(&mut *connection)
.await?;
tracing::info!(migration = *name, "applied migration");
}
Ok(())
}
async fn is_applied(
connection: &mut sqlx::SqliteConnection,
name: &str,
) -> Result<bool, sqlx::Error> {
let existing: Option<(String,)> =
sqlx::query_as("SELECT name FROM schema_migrations WHERE name = ?")
.bind(name)
.fetch_optional(&mut *connection)
.await?;
Ok(existing.is_some())
}