@@ -11,3 +11,7 @@ sqlx.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
domain = { workspace = true, features = ["test-helpers"] }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
|
||||
@@ -1,16 +1,68 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
|
||||
const MIGRATIONS: &[&str] = &[
|
||||
include_str!("migrations/001_initial.sql"),
|
||||
include_str!("migrations/002_push_subscriptions.sql"),
|
||||
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"),
|
||||
),
|
||||
];
|
||||
|
||||
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 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()
|
||||
@@ -22,9 +74,59 @@ pub async fn create_pool(database_url: &str) -> Result<SqlitePool, sqlx::Error>
|
||||
}
|
||||
|
||||
pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> {
|
||||
for migration in MIGRATIONS {
|
||||
sqlx::raw_sql(*migration).execute(pool).await?;
|
||||
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)
|
||||
}
|
||||
}
|
||||
tracing::info!("database migrations completed");
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE IF NOT EXISTS entry_content (
|
||||
entry_id TEXT PRIMARY KEY NOT NULL REFERENCES mood_entries(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
INSERT OR IGNORE INTO entry_content (entry_id, content)
|
||||
SELECT id, content FROM mood_entries WHERE content IS NOT NULL;
|
||||
|
||||
ALTER TABLE mood_entries DROP COLUMN content;
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS entry_location (
|
||||
entry_id TEXT PRIMARY KEY NOT NULL REFERENCES mood_entries(id) ON DELETE CASCADE,
|
||||
latitude REAL NOT NULL,
|
||||
longitude REAL NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_song (
|
||||
entry_id TEXT PRIMARY KEY NOT NULL REFERENCES mood_entries(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
artist TEXT NOT NULL,
|
||||
album TEXT,
|
||||
recording_id TEXT
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS provider_connections (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
credential BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (user_id, provider)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_provider_connections_user_id ON provider_connections(user_id);
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS daily_metrics (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
date TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
value INTEGER NOT NULL,
|
||||
provider TEXT,
|
||||
PRIMARY KEY (user_id, date, kind)
|
||||
);
|
||||
12
crates/adapters/sqlite/src/migrations/008_api_tokens.sql
Normal file
12
crates/adapters/sqlite/src/migrations/008_api_tokens.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
digest TEXT NOT NULL UNIQUE,
|
||||
scope TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
UNIQUE (user_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS metric_rejections (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
origin TEXT NOT NULL,
|
||||
provider TEXT,
|
||||
date TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
value INTEGER,
|
||||
reason TEXT NOT NULL,
|
||||
recorded_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_metric_rejections_user ON metric_rejections(user_id, recorded_at);
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS cycle_starts (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
date TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
user_id TEXT PRIMARY KEY NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tracks_cycle INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
13
crates/adapters/sqlite/src/migrations/011_jobs.sql
Normal file
13
crates/adapters/sqlite/src/migrations/011_jobs.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
enqueued_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (kind, subject)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_claimable ON jobs(kind, status, enqueued_at);
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE IF NOT EXISTS entry_weather (
|
||||
entry_id TEXT PRIMARY KEY NOT NULL REFERENCES mood_entries(id) ON DELETE CASCADE,
|
||||
condition TEXT NOT NULL,
|
||||
temperature REAL NOT NULL,
|
||||
observed_by TEXT NOT NULL
|
||||
);
|
||||
80
crates/adapters/sqlite/src/repositories/api_token/command.rs
Normal file
80
crates/adapters/sqlite/src/repositories/api_token/command.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::api_token::{ApiToken, ApiTokenId};
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteApiTokenCommandRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteApiTokenCommandRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ApiTokenCommandPort for SqliteApiTokenCommandRepository {
|
||||
async fn save(&self, token: &ApiToken) -> Result<(), DomainError> {
|
||||
let taken: Option<(String,)> =
|
||||
sqlx::query_as("SELECT id FROM api_tokens WHERE user_id = ? AND name = ?")
|
||||
.bind(token.user_id().value().to_string())
|
||||
.bind(token.name().value())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
if taken.is_some() {
|
||||
return Err(DomainError::Conflict(format!(
|
||||
"a token named {} already exists",
|
||||
token.name().value()
|
||||
)));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO api_tokens (id, user_id, name, digest, scope, created_at, last_used_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(token.id().value().to_string())
|
||||
.bind(token.user_id().value().to_string())
|
||||
.bind(token.name().value())
|
||||
.bind(token.digest().value())
|
||||
.bind(token.scope().name())
|
||||
.bind(token.created_at().to_rfc3339())
|
||||
.bind(token.last_used_at().map(|used| used.to_rfc3339()))
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke(&self, user_id: &UserId, id: &ApiTokenId) -> Result<(), DomainError> {
|
||||
let removed = sqlx::query("DELETE FROM api_tokens WHERE id = ? AND user_id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.bind(user_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
if removed.rows_affected() == 0 {
|
||||
return Err(DomainError::NotFound("api token not found".into()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mark_used(&self, id: &ApiTokenId) -> Result<(), DomainError> {
|
||||
sqlx::query("UPDATE api_tokens SET last_used_at = ? WHERE id = ?")
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.bind(id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
6
crates/adapters/sqlite/src/repositories/api_token/mod.rs
Normal file
6
crates/adapters/sqlite/src/repositories/api_token/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod command;
|
||||
mod query;
|
||||
mod rows;
|
||||
|
||||
pub use command::SqliteApiTokenCommandRepository;
|
||||
pub use query::SqliteApiTokenQueryRepository;
|
||||
47
crates/adapters/sqlite/src/repositories/api_token/query.rs
Normal file
47
crates/adapters/sqlite/src/repositories/api_token/query.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::api_token::{ApiToken, TokenDigest};
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::rows::{ApiTokenRow, readable};
|
||||
|
||||
pub struct SqliteApiTokenQueryRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteApiTokenQueryRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ApiTokenQueryPort for SqliteApiTokenQueryRepository {
|
||||
async fn find_by_digest(&self, digest: &TokenDigest) -> Result<Option<ApiToken>, DomainError> {
|
||||
let row: Option<ApiTokenRow> = sqlx::query_as(
|
||||
"SELECT id, user_id, name, digest, scope, created_at, last_used_at
|
||||
FROM api_tokens WHERE digest = ?",
|
||||
)
|
||||
.bind(digest.value())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(row.as_ref().and_then(readable))
|
||||
}
|
||||
|
||||
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<ApiToken>, DomainError> {
|
||||
let rows: Vec<ApiTokenRow> = sqlx::query_as(
|
||||
"SELECT id, user_id, name, digest, scope, created_at, last_used_at
|
||||
FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(rows.iter().filter_map(readable).collect())
|
||||
}
|
||||
}
|
||||
45
crates/adapters/sqlite/src/repositories/api_token/rows.rs
Normal file
45
crates/adapters/sqlite/src/repositories/api_token/rows.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use domain::api_token::{ApiToken, ApiTokenData, ApiTokenId, TokenDigest, TokenScope};
|
||||
use domain::provider::ProviderName;
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct ApiTokenRow {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub name: String,
|
||||
pub digest: String,
|
||||
pub scope: String,
|
||||
pub created_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
}
|
||||
|
||||
pub fn row_to_token(row: &ApiTokenRow) -> Option<ApiToken> {
|
||||
let last_used_at = match &row.last_used_at {
|
||||
None => None,
|
||||
Some(stamp) => Some(stamp.parse().ok()?),
|
||||
};
|
||||
|
||||
Some(ApiToken::from_persistence(ApiTokenData {
|
||||
id: ApiTokenId::from_uuid(row.id.parse().ok()?),
|
||||
user_id: UserId::from_uuid(row.user_id.parse().ok()?),
|
||||
name: ProviderName::from_persistence(row.name.clone()),
|
||||
digest: TokenDigest::from_persistence(row.digest.clone()),
|
||||
scope: TokenScope::from_name(&row.scope)?,
|
||||
created_at: row.created_at.parse().ok()?,
|
||||
last_used_at,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn readable(row: &ApiTokenRow) -> Option<ApiToken> {
|
||||
let token = row_to_token(row);
|
||||
|
||||
if token.is_none() {
|
||||
tracing::warn!(
|
||||
token_id = %row.id,
|
||||
scope = %row.scope,
|
||||
"skipped a stored api token this build cannot read"
|
||||
);
|
||||
}
|
||||
|
||||
token
|
||||
}
|
||||
@@ -41,6 +41,12 @@ impl domain::ports::CascadeDeletePort for SqliteCascadeDeleteRepository {
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
sqlx::query("DELETE FROM daily_metrics WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
3
crates/adapters/sqlite/src/repositories/cycle/mod.rs
Normal file
3
crates/adapters/sqlite/src/repositories/cycle/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod repository;
|
||||
|
||||
pub use repository::{SqliteCycleStartRepository, SqliteUserPreferencesRepository};
|
||||
103
crates/adapters/sqlite/src/repositories/cycle/repository.rs
Normal file
103
crates/adapters/sqlite/src/repositories/cycle/repository.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::entry::Date;
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::{UserId, UserPreferences};
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteCycleStartRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteCycleStartRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::CycleStartCommandPort for SqliteCycleStartRepository {
|
||||
async fn record(&self, user_id: &UserId, date: &Date) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO cycle_starts (user_id, date) VALUES (?, ?)
|
||||
ON CONFLICT(user_id, date) DO NOTHING",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.bind(date.to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn forget(&self, user_id: &UserId, date: &Date) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM cycle_starts WHERE user_id = ? AND date = ?")
|
||||
.bind(user_id.value().to_string())
|
||||
.bind(date.to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::CycleStartQueryPort for SqliteCycleStartRepository {
|
||||
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<Date>, DomainError> {
|
||||
let rows: Vec<(String,)> =
|
||||
sqlx::query_as("SELECT date FROM cycle_starts WHERE user_id = ? ORDER BY date")
|
||||
.bind(user_id.value().to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.filter_map(|row| row.0.parse().ok().map(Date::from_persistence))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SqliteUserPreferencesRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteUserPreferencesRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::UserPreferencesCommandPort for SqliteUserPreferencesRepository {
|
||||
async fn save(&self, preferences: &UserPreferences) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO user_preferences (user_id, tracks_cycle) VALUES (?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET tracks_cycle = excluded.tracks_cycle",
|
||||
)
|
||||
.bind(preferences.user_id().value().to_string())
|
||||
.bind(preferences.tracks_cycle())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::UserPreferencesQueryPort for SqliteUserPreferencesRepository {
|
||||
async fn find_by_user(&self, user_id: &UserId) -> Result<Option<UserPreferences>, DomainError> {
|
||||
let row: Option<(bool,)> =
|
||||
sqlx::query_as("SELECT tracks_cycle FROM user_preferences WHERE user_id = ?")
|
||||
.bind(user_id.value().to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(row.map(|found| UserPreferences::from_persistence(user_id.clone(), found.0)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::entry::Date;
|
||||
use domain::errors::DomainError;
|
||||
use domain::metric::{DailyMetric, MetricKind};
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::rows::{DailyMetricRow, provider_column, source_of};
|
||||
|
||||
pub struct SqliteDailyMetricCommandRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteDailyMetricCommandRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::DailyMetricCommandPort for SqliteDailyMetricCommandRepository {
|
||||
async fn save(&self, metrics: &[DailyMetric]) -> Result<usize, DomainError> {
|
||||
let mut tx = self.pool.begin().await.map_err(db_err)?;
|
||||
let mut written = 0;
|
||||
|
||||
for metric in metrics {
|
||||
let user_id = metric.user_id().value().to_string();
|
||||
let date = metric.date().to_string();
|
||||
let kind = metric.kind().name();
|
||||
|
||||
let stored: Option<DailyMetricRow> = sqlx::query_as(
|
||||
"SELECT user_id, date, kind, value, provider FROM daily_metrics
|
||||
WHERE user_id = ? AND date = ? AND kind = ?",
|
||||
)
|
||||
.bind(&user_id)
|
||||
.bind(&date)
|
||||
.bind(kind)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
if let Some(row) = &stored
|
||||
&& !source_of(row).is_superseded_by(metric.source())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO daily_metrics (user_id, date, kind, value, provider)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, date, kind) DO UPDATE SET
|
||||
value = excluded.value, provider = excluded.provider",
|
||||
)
|
||||
.bind(&user_id)
|
||||
.bind(&date)
|
||||
.bind(kind)
|
||||
.bind(metric.value().count())
|
||||
.bind(provider_column(metric.source()))
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
written += 1;
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
date: &Date,
|
||||
kinds: &[MetricKind],
|
||||
) -> Result<(), DomainError> {
|
||||
if kinds.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let placeholders = vec!["?"; kinds.len()].join(",");
|
||||
let sql = format!(
|
||||
"DELETE FROM daily_metrics WHERE user_id = ? AND date = ? AND kind IN ({placeholders})"
|
||||
);
|
||||
|
||||
let mut query = sqlx::query(sqlx::AssertSqlSafe(sql))
|
||||
.bind(user_id.value().to_string())
|
||||
.bind(date.to_string());
|
||||
|
||||
for kind in kinds {
|
||||
query = query.bind(kind.name());
|
||||
}
|
||||
|
||||
query.execute(&self.pool).await.map_err(db_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod command;
|
||||
mod query;
|
||||
mod rows;
|
||||
|
||||
pub use command::SqliteDailyMetricCommandRepository;
|
||||
pub use query::SqliteDailyMetricQueryRepository;
|
||||
@@ -0,0 +1,98 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::entry::DateSpan;
|
||||
use domain::errors::DomainError;
|
||||
use domain::metric::DailyMetric;
|
||||
use domain::ports::RejectionCommandPort;
|
||||
use domain::rejection::{RejectedMetric, RejectionDetail, RejectionOrigin};
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::rows::{DailyMetricRow, row_to_metric};
|
||||
|
||||
pub struct SqliteDailyMetricQueryRepository {
|
||||
pool: SqlitePool,
|
||||
rejections: Arc<dyn RejectionCommandPort>,
|
||||
}
|
||||
|
||||
impl SqliteDailyMetricQueryRepository {
|
||||
pub fn new(pool: SqlitePool, rejections: Arc<dyn RejectionCommandPort>) -> Self {
|
||||
Self { pool, rejections }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::DailyMetricQueryPort for SqliteDailyMetricQueryRepository {
|
||||
async fn find_by_span(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
span: &DateSpan,
|
||||
) -> Result<Vec<DailyMetric>, DomainError> {
|
||||
let rows: Vec<DailyMetricRow> = sqlx::query_as(
|
||||
"SELECT user_id, date, kind, value, provider FROM daily_metrics
|
||||
WHERE user_id = ? AND date >= ? AND date <= ?
|
||||
ORDER BY date, kind",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.bind(span.start().to_string())
|
||||
.bind(span.end().to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
let mut readable = Vec::with_capacity(rows.len());
|
||||
let mut unreadable = Vec::new();
|
||||
|
||||
for row in &rows {
|
||||
match row_to_metric(row) {
|
||||
Some(metric) => readable.push(metric),
|
||||
None => unreadable.push(unreadable_row(user_id, row)),
|
||||
}
|
||||
}
|
||||
|
||||
self.trace(&unreadable).await;
|
||||
|
||||
Ok(readable)
|
||||
}
|
||||
}
|
||||
|
||||
impl SqliteDailyMetricQueryRepository {
|
||||
async fn trace(&self, unreadable: &[RejectedMetric]) {
|
||||
if unreadable.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
count = unreadable.len(),
|
||||
"skipped stored metrics this build cannot read"
|
||||
);
|
||||
|
||||
if let Err(error) = self.rejections.record(unreadable).await {
|
||||
tracing::warn!(%error, "could not write to the rejection trace");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unreadable_row(user_id: &UserId, row: &DailyMetricRow) -> RejectedMetric {
|
||||
let date = row
|
||||
.date
|
||||
.parse()
|
||||
.ok()
|
||||
.map(domain::entry::Date::from_persistence);
|
||||
|
||||
RejectedMetric::new(
|
||||
user_id.clone(),
|
||||
RejectionOrigin::StoredRow,
|
||||
RejectionDetail::new(
|
||||
row.provider
|
||||
.clone()
|
||||
.map(domain::provider::ProviderName::from_persistence),
|
||||
date,
|
||||
row.kind.clone(),
|
||||
Some(row.value),
|
||||
),
|
||||
"this reading is stored but cannot be read back by this build",
|
||||
)
|
||||
}
|
||||
33
crates/adapters/sqlite/src/repositories/daily_metric/rows.rs
Normal file
33
crates/adapters/sqlite/src/repositories/daily_metric/rows.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use domain::entry::Date;
|
||||
use domain::metric::{DailyMetric, MetricKind, MetricValue, Source};
|
||||
use domain::provider::ProviderName;
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct DailyMetricRow {
|
||||
pub user_id: String,
|
||||
pub date: String,
|
||||
pub kind: String,
|
||||
pub value: i64,
|
||||
pub provider: Option<String>,
|
||||
}
|
||||
|
||||
pub fn row_to_metric(row: &DailyMetricRow) -> Option<DailyMetric> {
|
||||
let user_id = row.user_id.parse().ok().map(UserId::from_uuid)?;
|
||||
let date = row.date.parse().ok().map(Date::from_persistence)?;
|
||||
let kind = MetricKind::from_name(&row.kind)?;
|
||||
let value = MetricValue::of_kind(kind, row.value).ok()?;
|
||||
|
||||
Some(DailyMetric::new(user_id, date, value, source_of(row)))
|
||||
}
|
||||
|
||||
pub fn source_of(row: &DailyMetricRow) -> Source {
|
||||
match &row.provider {
|
||||
None => Source::Manual,
|
||||
Some(name) => Source::Provider(ProviderName::from_persistence(name.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn provider_column(source: &Source) -> Option<&str> {
|
||||
source.provider().map(|name| name.value())
|
||||
}
|
||||
94
crates/adapters/sqlite/src/repositories/dimension/content.rs
Normal file
94
crates/adapters/sqlite/src/repositories/dimension/content.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::dimension::{DimensionKind, DimensionValue};
|
||||
use domain::entry::{Content, MoodEntryId};
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteContentDimensionRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteContentDimensionRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ContentRow {
|
||||
entry_id: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::EntryDimensionPort for SqliteContentDimensionRepository {
|
||||
async fn load(
|
||||
&self,
|
||||
entry_ids: &[MoodEntryId],
|
||||
) -> Result<HashMap<MoodEntryId, DimensionValue>, DomainError> {
|
||||
if entry_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let placeholders = vec!["?"; entry_ids.len()].join(",");
|
||||
let sql = format!(
|
||||
"SELECT entry_id, content FROM entry_content WHERE entry_id IN ({placeholders})"
|
||||
);
|
||||
|
||||
let mut query = sqlx::query_as::<_, ContentRow>(sqlx::AssertSqlSafe(sql));
|
||||
for id in entry_ids {
|
||||
query = query.bind(id.value().to_string());
|
||||
}
|
||||
|
||||
let rows = query.fetch_all(&self.pool).await.map_err(db_err)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|row| {
|
||||
let id = row.entry_id.parse().ok()?;
|
||||
Some((
|
||||
MoodEntryId::from_uuid(id),
|
||||
DimensionValue::Content(Content::from_persistence(row.content)),
|
||||
))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
entry_id: &MoodEntryId,
|
||||
values: &[DimensionValue],
|
||||
) -> Result<(), DomainError> {
|
||||
let id = entry_id.value().to_string();
|
||||
|
||||
match values
|
||||
.iter()
|
||||
.find(|value| value.kind() == DimensionKind::Content)
|
||||
{
|
||||
Some(DimensionValue::Content(content)) => {
|
||||
sqlx::query(
|
||||
"INSERT INTO entry_content (entry_id, content) VALUES (?, ?)
|
||||
ON CONFLICT(entry_id) DO UPDATE SET content = excluded.content",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(content.value())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
_ => {
|
||||
sqlx::query("DELETE FROM entry_content WHERE entry_id = ?")
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
101
crates/adapters/sqlite/src/repositories/dimension/location.rs
Normal file
101
crates/adapters/sqlite/src/repositories/dimension/location.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::dimension::{DimensionKind, DimensionValue};
|
||||
use domain::entry::MoodEntryId;
|
||||
use domain::errors::DomainError;
|
||||
use domain::location::Coordinates;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteLocationDimensionRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteLocationDimensionRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct LocationRow {
|
||||
entry_id: String,
|
||||
latitude: f64,
|
||||
longitude: f64,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::EntryDimensionPort for SqliteLocationDimensionRepository {
|
||||
async fn load(
|
||||
&self,
|
||||
entry_ids: &[MoodEntryId],
|
||||
) -> Result<HashMap<MoodEntryId, DimensionValue>, DomainError> {
|
||||
if entry_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let placeholders = vec!["?"; entry_ids.len()].join(",");
|
||||
let sql = format!(
|
||||
"SELECT entry_id, latitude, longitude FROM entry_location WHERE entry_id IN ({placeholders})"
|
||||
);
|
||||
|
||||
let mut query = sqlx::query_as::<_, LocationRow>(sqlx::AssertSqlSafe(sql));
|
||||
for id in entry_ids {
|
||||
query = query.bind(id.value().to_string());
|
||||
}
|
||||
|
||||
let rows = query.fetch_all(&self.pool).await.map_err(db_err)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|row| {
|
||||
let entry_id = row.entry_id.parse().ok()?;
|
||||
Some((
|
||||
MoodEntryId::from_uuid(entry_id),
|
||||
DimensionValue::Location(Coordinates::from_persistence(
|
||||
row.latitude,
|
||||
row.longitude,
|
||||
)),
|
||||
))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
entry_id: &MoodEntryId,
|
||||
values: &[DimensionValue],
|
||||
) -> Result<(), DomainError> {
|
||||
let id = entry_id.value().to_string();
|
||||
|
||||
match values
|
||||
.iter()
|
||||
.find(|value| value.kind() == DimensionKind::Location)
|
||||
{
|
||||
Some(DimensionValue::Location(coordinates)) => {
|
||||
sqlx::query(
|
||||
"INSERT INTO entry_location (entry_id, latitude, longitude) VALUES (?, ?, ?)
|
||||
ON CONFLICT(entry_id) DO UPDATE SET
|
||||
latitude = excluded.latitude, longitude = excluded.longitude",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(coordinates.latitude().value())
|
||||
.bind(coordinates.longitude().value())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
_ => {
|
||||
sqlx::query("DELETE FROM entry_location WHERE entry_id = ?")
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
11
crates/adapters/sqlite/src/repositories/dimension/mod.rs
Normal file
11
crates/adapters/sqlite/src/repositories/dimension/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
mod content;
|
||||
mod location;
|
||||
mod relation;
|
||||
mod song;
|
||||
mod weather;
|
||||
|
||||
pub use content::SqliteContentDimensionRepository;
|
||||
pub use location::SqliteLocationDimensionRepository;
|
||||
pub use relation::SqliteRelationDimensionRepository;
|
||||
pub use song::SqliteSongDimensionRepository;
|
||||
pub use weather::SqliteWeatherDimensionRepository;
|
||||
166
crates/adapters/sqlite/src/repositories/dimension/relation.rs
Normal file
166
crates/adapters/sqlite/src/repositories/dimension/relation.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::attachment::{PhotoId, VoiceMemoId};
|
||||
use domain::dimension::{DimensionKind, DimensionValue};
|
||||
use domain::entry::MoodEntryId;
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteRelationDimensionRepository {
|
||||
pool: SqlitePool,
|
||||
table: &'static str,
|
||||
column: &'static str,
|
||||
kind: DimensionKind,
|
||||
}
|
||||
|
||||
impl SqliteRelationDimensionRepository {
|
||||
pub fn activities(pool: SqlitePool) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
table: "entry_activities",
|
||||
column: "activity_id",
|
||||
kind: DimensionKind::Activities,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn photos(pool: SqlitePool) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
table: "entry_photos",
|
||||
column: "photo_id",
|
||||
kind: DimensionKind::Photos,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn voice_memos(pool: SqlitePool) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
table: "entry_voice_memos",
|
||||
column: "voice_memo_id",
|
||||
kind: DimensionKind::VoiceMemos,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_value(&self, ids: Vec<Uuid>) -> DimensionValue {
|
||||
match self.kind {
|
||||
DimensionKind::Activities => {
|
||||
DimensionValue::Activities(ids.into_iter().map(ActivityId::from_uuid).collect())
|
||||
}
|
||||
DimensionKind::Photos => {
|
||||
DimensionValue::Photos(ids.into_iter().map(PhotoId::from_uuid).collect())
|
||||
}
|
||||
DimensionKind::VoiceMemos => {
|
||||
DimensionValue::VoiceMemos(ids.into_iter().map(VoiceMemoId::from_uuid).collect())
|
||||
}
|
||||
DimensionKind::Content
|
||||
| DimensionKind::Location
|
||||
| DimensionKind::Song
|
||||
| DimensionKind::Weather => {
|
||||
unreachable!("relation repository serves only id-list dimensions")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn related_ids(value: &DimensionValue) -> Vec<String> {
|
||||
match value {
|
||||
DimensionValue::Activities(ids) => ids.iter().map(|id| id.value().to_string()).collect(),
|
||||
DimensionValue::Photos(ids) => ids.iter().map(|id| id.value().to_string()).collect(),
|
||||
DimensionValue::VoiceMemos(ids) => ids.iter().map(|id| id.value().to_string()).collect(),
|
||||
DimensionValue::Content(_)
|
||||
| DimensionValue::Location(_)
|
||||
| DimensionValue::Song(_)
|
||||
| DimensionValue::Weather(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RelationRow {
|
||||
entry_id: String,
|
||||
related_id: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::EntryDimensionPort for SqliteRelationDimensionRepository {
|
||||
async fn load(
|
||||
&self,
|
||||
entry_ids: &[MoodEntryId],
|
||||
) -> Result<HashMap<MoodEntryId, DimensionValue>, DomainError> {
|
||||
if entry_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let placeholders = vec!["?"; entry_ids.len()].join(",");
|
||||
let sql = format!(
|
||||
"SELECT entry_id, {} AS related_id FROM {} WHERE entry_id IN ({placeholders})",
|
||||
self.column, self.table
|
||||
);
|
||||
|
||||
let mut query = sqlx::query_as::<_, RelationRow>(sqlx::AssertSqlSafe(sql));
|
||||
for id in entry_ids {
|
||||
query = query.bind(id.value().to_string());
|
||||
}
|
||||
|
||||
let rows = query.fetch_all(&self.pool).await.map_err(db_err)?;
|
||||
|
||||
let mut grouped: HashMap<MoodEntryId, Vec<Uuid>> = HashMap::new();
|
||||
for row in rows {
|
||||
let (Ok(entry_id), Ok(related_id)) =
|
||||
(row.entry_id.parse::<Uuid>(), row.related_id.parse::<Uuid>())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
grouped
|
||||
.entry(MoodEntryId::from_uuid(entry_id))
|
||||
.or_default()
|
||||
.push(related_id);
|
||||
}
|
||||
|
||||
Ok(grouped
|
||||
.into_iter()
|
||||
.map(|(entry_id, ids)| (entry_id, self.to_value(ids)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
entry_id: &MoodEntryId,
|
||||
values: &[DimensionValue],
|
||||
) -> Result<(), DomainError> {
|
||||
let id = entry_id.value().to_string();
|
||||
|
||||
let mut tx = self.pool.begin().await.map_err(db_err)?;
|
||||
|
||||
sqlx::query(sqlx::AssertSqlSafe(format!(
|
||||
"DELETE FROM {} WHERE entry_id = ?",
|
||||
self.table
|
||||
)))
|
||||
.bind(&id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
if let Some(value) = values.iter().find(|value| value.kind() == self.kind) {
|
||||
let insert = format!(
|
||||
"INSERT OR IGNORE INTO {} (entry_id, {}) VALUES (?, ?)",
|
||||
self.table, self.column
|
||||
);
|
||||
for related in related_ids(value) {
|
||||
sqlx::query(sqlx::AssertSqlSafe(insert.clone()))
|
||||
.bind(&id)
|
||||
.bind(related)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
109
crates/adapters/sqlite/src/repositories/dimension/song.rs
Normal file
109
crates/adapters/sqlite/src/repositories/dimension/song.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::dimension::{DimensionKind, DimensionValue};
|
||||
use domain::entry::MoodEntryId;
|
||||
use domain::errors::DomainError;
|
||||
use domain::song::{AlbumName, ArtistName, RecordingId, Song, SongTitle};
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteSongDimensionRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteSongDimensionRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SongRow {
|
||||
entry_id: String,
|
||||
title: String,
|
||||
artist: String,
|
||||
album: Option<String>,
|
||||
recording_id: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::EntryDimensionPort for SqliteSongDimensionRepository {
|
||||
async fn load(
|
||||
&self,
|
||||
entry_ids: &[MoodEntryId],
|
||||
) -> Result<HashMap<MoodEntryId, DimensionValue>, DomainError> {
|
||||
if entry_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let placeholders = vec!["?"; entry_ids.len()].join(",");
|
||||
let sql = format!(
|
||||
"SELECT entry_id, title, artist, album, recording_id FROM entry_song WHERE entry_id IN ({placeholders})"
|
||||
);
|
||||
|
||||
let mut query = sqlx::query_as::<_, SongRow>(sqlx::AssertSqlSafe(sql));
|
||||
for id in entry_ids {
|
||||
query = query.bind(id.value().to_string());
|
||||
}
|
||||
|
||||
let rows = query.fetch_all(&self.pool).await.map_err(db_err)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|row| {
|
||||
let entry_id = row.entry_id.parse().ok()?;
|
||||
let song = Song::from_persistence(
|
||||
SongTitle::from_persistence(row.title),
|
||||
ArtistName::from_persistence(row.artist),
|
||||
row.album.map(AlbumName::from_persistence),
|
||||
row.recording_id
|
||||
.and_then(|id| id.parse().ok())
|
||||
.map(RecordingId::from_uuid),
|
||||
);
|
||||
Some((MoodEntryId::from_uuid(entry_id), DimensionValue::Song(song)))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
entry_id: &MoodEntryId,
|
||||
values: &[DimensionValue],
|
||||
) -> Result<(), DomainError> {
|
||||
let id = entry_id.value().to_string();
|
||||
|
||||
match values
|
||||
.iter()
|
||||
.find(|value| value.kind() == DimensionKind::Song)
|
||||
{
|
||||
Some(DimensionValue::Song(song)) => {
|
||||
sqlx::query(
|
||||
"INSERT INTO entry_song (entry_id, title, artist, album, recording_id)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(entry_id) DO UPDATE SET
|
||||
title = excluded.title, artist = excluded.artist,
|
||||
album = excluded.album, recording_id = excluded.recording_id",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(song.title().value())
|
||||
.bind(song.artist().value())
|
||||
.bind(song.album().map(|album| album.value().to_string()))
|
||||
.bind(song.recording_id().map(|id| id.value().to_string()))
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
_ => {
|
||||
sqlx::query("DELETE FROM entry_song WHERE entry_id = ?")
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
109
crates/adapters/sqlite/src/repositories/dimension/weather.rs
Normal file
109
crates/adapters/sqlite/src/repositories/dimension/weather.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::dimension::{DimensionKind, DimensionValue};
|
||||
use domain::entry::MoodEntryId;
|
||||
use domain::errors::DomainError;
|
||||
use domain::provider::ProviderName;
|
||||
use domain::weather::{Celsius, Condition, Weather};
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteWeatherDimensionRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteWeatherDimensionRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct WeatherRow {
|
||||
entry_id: String,
|
||||
condition: String,
|
||||
temperature: f64,
|
||||
observed_by: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::EntryDimensionPort for SqliteWeatherDimensionRepository {
|
||||
async fn load(
|
||||
&self,
|
||||
entry_ids: &[MoodEntryId],
|
||||
) -> Result<HashMap<MoodEntryId, DimensionValue>, DomainError> {
|
||||
if entry_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let placeholders = vec!["?"; entry_ids.len()].join(",");
|
||||
let sql = format!(
|
||||
"SELECT entry_id, condition, temperature, observed_by
|
||||
FROM entry_weather WHERE entry_id IN ({placeholders})"
|
||||
);
|
||||
|
||||
let mut query = sqlx::query_as::<_, WeatherRow>(sqlx::AssertSqlSafe(sql));
|
||||
for id in entry_ids {
|
||||
query = query.bind(id.value().to_string());
|
||||
}
|
||||
|
||||
let rows = query.fetch_all(&self.pool).await.map_err(db_err)?;
|
||||
|
||||
Ok(rows.iter().filter_map(readable).collect())
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
entry_id: &MoodEntryId,
|
||||
values: &[DimensionValue],
|
||||
) -> Result<(), DomainError> {
|
||||
let observed = values
|
||||
.iter()
|
||||
.find(|value| value.kind() == DimensionKind::Weather);
|
||||
|
||||
let Some(DimensionValue::Weather(weather)) = observed else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO entry_weather (entry_id, condition, temperature, observed_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(entry_id) DO UPDATE SET
|
||||
condition = excluded.condition,
|
||||
temperature = excluded.temperature,
|
||||
observed_by = excluded.observed_by",
|
||||
)
|
||||
.bind(entry_id.value().to_string())
|
||||
.bind(weather.condition().name())
|
||||
.bind(weather.temperature().value())
|
||||
.bind(weather.observed_by().value())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn readable(row: &WeatherRow) -> Option<(MoodEntryId, DimensionValue)> {
|
||||
let entry_id = MoodEntryId::from_uuid(row.entry_id.parse().ok()?);
|
||||
let condition = Condition::from_name(&row.condition);
|
||||
|
||||
if condition.is_none() {
|
||||
tracing::warn!(
|
||||
entry_id = %row.entry_id,
|
||||
condition = %row.condition,
|
||||
"skipped stored weather this build cannot read"
|
||||
);
|
||||
}
|
||||
|
||||
let weather = Weather::new(
|
||||
condition?,
|
||||
Celsius::from_persistence(row.temperature),
|
||||
ProviderName::from_persistence(row.observed_by.clone()),
|
||||
);
|
||||
|
||||
Some((entry_id, DimensionValue::Weather(weather)))
|
||||
}
|
||||
@@ -15,78 +15,29 @@ impl SqliteEntryCommandRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
async fn save_relations(&self, entry: &MoodEntry) -> Result<(), DomainError> {
|
||||
let entry_id = entry.id().value().to_string();
|
||||
|
||||
sqlx::query("DELETE FROM entry_activities WHERE entry_id = ?")
|
||||
.bind(&entry_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
for activity_id in entry.activities() {
|
||||
sqlx::query("INSERT INTO entry_activities (entry_id, activity_id) VALUES (?, ?)")
|
||||
.bind(&entry_id)
|
||||
.bind(activity_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM entry_photos WHERE entry_id = ?")
|
||||
.bind(&entry_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
for photo_id in entry.photos() {
|
||||
sqlx::query("INSERT INTO entry_photos (entry_id, photo_id) VALUES (?, ?)")
|
||||
.bind(&entry_id)
|
||||
.bind(photo_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM entry_voice_memos WHERE entry_id = ?")
|
||||
.bind(&entry_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
for voice_memo_id in entry.voice_memos() {
|
||||
sqlx::query("INSERT INTO entry_voice_memos (entry_id, voice_memo_id) VALUES (?, ?)")
|
||||
.bind(&entry_id)
|
||||
.bind(voice_memo_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::MoodEntryCommandPort for SqliteEntryCommandRepository {
|
||||
async fn save(&self, entry: &MoodEntry) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO mood_entries (id, user_id, mood, logged_at, content, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
"INSERT INTO mood_entries (id, user_id, mood, logged_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
mood = excluded.mood, logged_at = excluded.logged_at,
|
||||
content = excluded.content, updated_at = excluded.updated_at"
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(entry.id().value().to_string())
|
||||
.bind(entry.user_id().value().to_string())
|
||||
.bind(entry.mood().value() as i32)
|
||||
.bind(entry.logged_at().to_rfc3339())
|
||||
.bind(entry.content().map(|c| c.value().to_string()))
|
||||
.bind(entry.created_at().to_rfc3339())
|
||||
.bind(entry.updated_at().to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
self.save_relations(entry).await
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_batch(&self, entries: &[MoodEntry]) -> Result<(), DomainError> {
|
||||
@@ -96,51 +47,21 @@ impl domain::ports::MoodEntryCommandPort for SqliteEntryCommandRepository {
|
||||
let entry_id = entry.id().value().to_string();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO mood_entries (id, user_id, mood, logged_at, content, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
"INSERT INTO mood_entries (id, user_id, mood, logged_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
mood = excluded.mood, logged_at = excluded.logged_at,
|
||||
content = excluded.content, updated_at = excluded.updated_at"
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(&entry_id)
|
||||
.bind(entry.user_id().value().to_string())
|
||||
.bind(entry.mood().value() as i32)
|
||||
.bind(entry.logged_at().to_rfc3339())
|
||||
.bind(entry.content().map(|c| c.value().to_string()))
|
||||
.bind(entry.created_at().to_rfc3339())
|
||||
.bind(entry.updated_at().to_rfc3339())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
for activity_id in entry.activities() {
|
||||
sqlx::query("INSERT INTO entry_activities (entry_id, activity_id) VALUES (?, ?)")
|
||||
.bind(&entry_id)
|
||||
.bind(activity_id.value().to_string())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
|
||||
for photo_id in entry.photos() {
|
||||
sqlx::query("INSERT INTO entry_photos (entry_id, photo_id) VALUES (?, ?)")
|
||||
.bind(&entry_id)
|
||||
.bind(photo_id.value().to_string())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
|
||||
for voice_memo_id in entry.voice_memos() {
|
||||
sqlx::query(
|
||||
"INSERT INTO entry_voice_memos (entry_id, voice_memo_id) VALUES (?, ?)",
|
||||
)
|
||||
.bind(&entry_id)
|
||||
.bind(voice_memo_id.value().to_string())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
|
||||
@@ -1,153 +1,39 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::attachment::{PhotoId, VoiceMemoId};
|
||||
use domain::entry::{Content, Mood, MoodEntry, MoodEntryData, MoodEntryId};
|
||||
use domain::entry::{Mood, MoodEntry, MoodEntryData, MoodEntryId};
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct EntryRow {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub mood: i32,
|
||||
pub logged_at: String,
|
||||
pub content: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RelationRow {
|
||||
entry_id: String,
|
||||
related_id: String,
|
||||
}
|
||||
pub fn row_to_entry(row: EntryRow) -> Result<MoodEntry, DomainError> {
|
||||
let parse_failed = || DomainError::InvalidInput("stored entry row is malformed".into());
|
||||
|
||||
pub fn row_to_entry(
|
||||
row: EntryRow,
|
||||
activity_ids: Vec<String>,
|
||||
photo_ids: Vec<String>,
|
||||
voice_memo_ids: Vec<String>,
|
||||
) -> Result<MoodEntry, DomainError> {
|
||||
Ok(MoodEntry::from_persistence(MoodEntryData {
|
||||
id: MoodEntryId::from_uuid(row.id.parse().unwrap()),
|
||||
user_id: UserId::from_uuid(row.user_id.parse().unwrap()),
|
||||
id: MoodEntryId::from_uuid(row.id.parse().map_err(|_| parse_failed())?),
|
||||
user_id: UserId::from_uuid(row.user_id.parse().map_err(|_| parse_failed())?),
|
||||
mood: Mood::try_from(row.mood as u8)?,
|
||||
logged_at: row.logged_at.parse().unwrap(),
|
||||
activities: activity_ids
|
||||
.into_iter()
|
||||
.map(|id| ActivityId::from_uuid(id.parse().unwrap()))
|
||||
.collect(),
|
||||
content: row.content.map(Content::from_persistence),
|
||||
photos: photo_ids
|
||||
.into_iter()
|
||||
.map(|id| PhotoId::from_uuid(id.parse().unwrap()))
|
||||
.collect(),
|
||||
voice_memos: voice_memo_ids
|
||||
.into_iter()
|
||||
.map(|id| VoiceMemoId::from_uuid(id.parse().unwrap()))
|
||||
.collect(),
|
||||
created_at: row.created_at.parse().unwrap(),
|
||||
updated_at: row.updated_at.parse().unwrap(),
|
||||
logged_at: row.logged_at.parse().map_err(|_| parse_failed())?,
|
||||
created_at: row.created_at.parse().map_err(|_| parse_failed())?,
|
||||
updated_at: row.updated_at.parse().map_err(|_| parse_failed())?,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn hydrate_single(pool: &SqlitePool, row: EntryRow) -> Result<MoodEntry, DomainError> {
|
||||
let entry_id = row.id.clone();
|
||||
|
||||
let activities: Vec<RelationRow> = sqlx::query_as(
|
||||
"SELECT entry_id, activity_id AS related_id FROM entry_activities WHERE entry_id = ?",
|
||||
)
|
||||
.bind(&entry_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
let photos: Vec<RelationRow> = sqlx::query_as(
|
||||
"SELECT entry_id, photo_id AS related_id FROM entry_photos WHERE entry_id = ?",
|
||||
)
|
||||
.bind(&entry_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
let voice_memos: Vec<RelationRow> = sqlx::query_as(
|
||||
"SELECT entry_id, voice_memo_id AS related_id FROM entry_voice_memos WHERE entry_id = ?",
|
||||
)
|
||||
.bind(&entry_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
row_to_entry(
|
||||
row,
|
||||
activities.into_iter().map(|r| r.related_id).collect(),
|
||||
photos.into_iter().map(|r| r.related_id).collect(),
|
||||
voice_memos.into_iter().map(|r| r.related_id).collect(),
|
||||
)
|
||||
pub async fn hydrate_single(_pool: &SqlitePool, row: EntryRow) -> Result<MoodEntry, DomainError> {
|
||||
row_to_entry(row)
|
||||
}
|
||||
|
||||
pub async fn hydrate_batch(
|
||||
pool: &SqlitePool,
|
||||
_pool: &SqlitePool,
|
||||
rows: Vec<EntryRow>,
|
||||
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
if rows.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let entry_ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
|
||||
let placeholders = vec!["?"; entry_ids.len()].join(",");
|
||||
|
||||
let activities = batch_load(
|
||||
pool,
|
||||
&format!("SELECT entry_id, activity_id AS related_id FROM entry_activities WHERE entry_id IN ({placeholders})"),
|
||||
&entry_ids,
|
||||
).await?;
|
||||
|
||||
let photos = batch_load(
|
||||
pool,
|
||||
&format!("SELECT entry_id, photo_id AS related_id FROM entry_photos WHERE entry_id IN ({placeholders})"),
|
||||
&entry_ids,
|
||||
).await?;
|
||||
|
||||
let voice_memos = batch_load(
|
||||
pool,
|
||||
&format!("SELECT entry_id, voice_memo_id AS related_id FROM entry_voice_memos WHERE entry_id IN ({placeholders})"),
|
||||
&entry_ids,
|
||||
).await?;
|
||||
|
||||
let mut entries = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let id = row.id.clone();
|
||||
entries.push(row_to_entry(
|
||||
row,
|
||||
activities.get(&id).cloned().unwrap_or_default(),
|
||||
photos.get(&id).cloned().unwrap_or_default(),
|
||||
voice_memos.get(&id).cloned().unwrap_or_default(),
|
||||
)?);
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
async fn batch_load(
|
||||
pool: &SqlitePool,
|
||||
sql: &str,
|
||||
entry_ids: &[String],
|
||||
) -> Result<HashMap<String, Vec<String>>, DomainError> {
|
||||
let mut query = sqlx::query_as::<_, RelationRow>(sqlx::AssertSqlSafe(sql));
|
||||
for id in entry_ids {
|
||||
query = query.bind(id);
|
||||
}
|
||||
|
||||
let rows = query.fetch_all(pool).await.map_err(db_err)?;
|
||||
|
||||
let mut map: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for row in rows {
|
||||
map.entry(row.entry_id).or_default().push(row.related_id);
|
||||
}
|
||||
Ok(map)
|
||||
rows.into_iter().map(row_to_entry).collect()
|
||||
}
|
||||
|
||||
78
crates/adapters/sqlite/src/repositories/job/backfill.rs
Normal file
78
crates/adapters/sqlite/src/repositories/job/backfill.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::entry::MoodEntryId;
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::UnidentifiedSong;
|
||||
use domain::song::RecordingId;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteRecordingBackfillRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteRecordingBackfillRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct UnidentifiedSongRow {
|
||||
entry_id: String,
|
||||
user_id: String,
|
||||
title: String,
|
||||
artist: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::RecordingBackfillQueryPort for SqliteRecordingBackfillRepository {
|
||||
async fn find_songs_without_a_recording(
|
||||
&self,
|
||||
most: usize,
|
||||
) -> Result<Vec<UnidentifiedSong>, DomainError> {
|
||||
let rows: Vec<UnidentifiedSongRow> = sqlx::query_as(
|
||||
"SELECT s.entry_id, e.user_id, s.title, s.artist
|
||||
FROM entry_song s
|
||||
JOIN mood_entries e ON e.id = s.entry_id
|
||||
WHERE s.recording_id IS NULL
|
||||
ORDER BY e.logged_at DESC
|
||||
LIMIT ?",
|
||||
)
|
||||
.bind(most_as_limit(most))
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(rows.iter().filter_map(readable).collect())
|
||||
}
|
||||
|
||||
async fn record_identity(
|
||||
&self,
|
||||
entry_id: &MoodEntryId,
|
||||
recording_id: &RecordingId,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query("UPDATE entry_song SET recording_id = ? WHERE entry_id = ?")
|
||||
.bind(recording_id.value().to_string())
|
||||
.bind(entry_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn most_as_limit(most: usize) -> i64 {
|
||||
i64::try_from(most).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
fn readable(row: &UnidentifiedSongRow) -> Option<UnidentifiedSong> {
|
||||
Some(UnidentifiedSong {
|
||||
entry_id: MoodEntryId::from_uuid(row.entry_id.parse().ok()?),
|
||||
user_id: UserId::from_uuid(row.user_id.parse().ok()?),
|
||||
title: row.title.clone(),
|
||||
artist: row.artist.clone(),
|
||||
})
|
||||
}
|
||||
8
crates/adapters/sqlite/src/repositories/job/mod.rs
Normal file
8
crates/adapters/sqlite/src/repositories/job/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
mod backfill;
|
||||
mod repository;
|
||||
mod rows;
|
||||
mod weather;
|
||||
|
||||
pub use backfill::SqliteRecordingBackfillRepository;
|
||||
pub use repository::SqliteJobQueueRepository;
|
||||
pub use weather::SqliteWeatherBacklogRepository;
|
||||
160
crates/adapters/sqlite/src/repositories/job/repository.rs
Normal file
160
crates/adapters/sqlite/src/repositories/job/repository.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::job::{Job, JobId, JobKind, JobStatus, JobSubject};
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::rows::{JobRow, readable};
|
||||
|
||||
const COLUMNS: &str = "id, kind, subject, status, attempts, last_error, enqueued_at, updated_at";
|
||||
|
||||
pub struct SqliteJobQueueRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteJobQueueRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::JobQueueCommandPort for SqliteJobQueueRepository {
|
||||
async fn enqueue(&self, kind: JobKind, subject: &JobSubject) -> Result<bool, DomainError> {
|
||||
let job = Job::pending(kind, subject.clone());
|
||||
|
||||
let written = sqlx::query(
|
||||
"INSERT INTO jobs (id, kind, subject, status, attempts, last_error, enqueued_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 0, NULL, ?, ?)
|
||||
ON CONFLICT(kind, subject) DO NOTHING",
|
||||
)
|
||||
.bind(job.id().value().to_string())
|
||||
.bind(kind.name())
|
||||
.bind(subject.key())
|
||||
.bind(JobStatus::Pending.name())
|
||||
.bind(job.enqueued_at().to_rfc3339())
|
||||
.bind(job.updated_at().to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(written.rows_affected() > 0)
|
||||
}
|
||||
|
||||
async fn claim(&self, kind: JobKind, most: usize) -> Result<Vec<Job>, DomainError> {
|
||||
let sql = format!(
|
||||
"UPDATE jobs SET status = ?, updated_at = ?
|
||||
WHERE id IN (
|
||||
SELECT id FROM jobs WHERE kind = ? AND status = ?
|
||||
ORDER BY enqueued_at LIMIT ?
|
||||
)
|
||||
RETURNING {COLUMNS}"
|
||||
);
|
||||
|
||||
let rows: Vec<JobRow> = sqlx::query_as(sqlx::AssertSqlSafe(sql))
|
||||
.bind(JobStatus::Running.name())
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.bind(kind.name())
|
||||
.bind(JobStatus::Pending.name())
|
||||
.bind(most as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
let mut claimed = Vec::with_capacity(rows.len());
|
||||
|
||||
for row in &rows {
|
||||
match readable(row) {
|
||||
Some(job) => claimed.push(job),
|
||||
None => self.abandon_unreadable(row).await?,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(claimed)
|
||||
}
|
||||
|
||||
async fn finish(&self, id: &JobId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM jobs WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn release(&self, id: &JobId, reason: &str) -> Result<(), DomainError> {
|
||||
self.settle(id, JobStatus::Pending, reason).await
|
||||
}
|
||||
|
||||
async fn exhaust(&self, id: &JobId, reason: &str) -> Result<(), DomainError> {
|
||||
self.settle(id, JobStatus::Exhausted, reason).await
|
||||
}
|
||||
|
||||
async fn reclaim_stalled(&self, stalled_after_seconds: i64) -> Result<u64, DomainError> {
|
||||
let stalled_before =
|
||||
chrono::Utc::now() - chrono::Duration::seconds(stalled_after_seconds.max(0));
|
||||
|
||||
let reclaimed = sqlx::query(
|
||||
"UPDATE jobs SET status = ?, updated_at = ?
|
||||
WHERE status = ? AND updated_at <= ?",
|
||||
)
|
||||
.bind(JobStatus::Pending.name())
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.bind(JobStatus::Running.name())
|
||||
.bind(stalled_before.to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(reclaimed.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
impl SqliteJobQueueRepository {
|
||||
async fn abandon_unreadable(&self, row: &JobRow) -> Result<(), DomainError> {
|
||||
sqlx::query("UPDATE jobs SET status = ?, last_error = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(JobStatus::Exhausted.name())
|
||||
.bind("this job is stored in a shape this build cannot read")
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.bind(&row.id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn settle(&self, id: &JobId, status: JobStatus, reason: &str) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"UPDATE jobs SET status = ?, attempts = attempts + 1, last_error = ?, updated_at = ?
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(status.name())
|
||||
.bind(reason)
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.bind(id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::JobQueueQueryPort for SqliteJobQueueRepository {
|
||||
async fn find_exhausted(&self, most: usize) -> Result<Vec<Job>, DomainError> {
|
||||
let sql =
|
||||
format!("SELECT {COLUMNS} FROM jobs WHERE status = ? ORDER BY updated_at DESC LIMIT ?");
|
||||
|
||||
let rows: Vec<JobRow> = sqlx::query_as(sqlx::AssertSqlSafe(sql))
|
||||
.bind(JobStatus::Exhausted.name())
|
||||
.bind(most as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(rows.iter().filter_map(readable).collect())
|
||||
}
|
||||
}
|
||||
41
crates/adapters/sqlite/src/repositories/job/rows.rs
Normal file
41
crates/adapters/sqlite/src/repositories/job/rows.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use domain::job::{Job, JobData, JobId, JobKind, JobStatus, JobSubject};
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct JobRow {
|
||||
pub id: String,
|
||||
pub kind: String,
|
||||
pub subject: String,
|
||||
pub status: String,
|
||||
pub attempts: i64,
|
||||
pub last_error: Option<String>,
|
||||
pub enqueued_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
pub fn readable(row: &JobRow) -> Option<Job> {
|
||||
let job = row_to_job(row);
|
||||
|
||||
if job.is_none() {
|
||||
tracing::warn!(
|
||||
job_id = %row.id,
|
||||
kind = %row.kind,
|
||||
status = %row.status,
|
||||
"skipped a stored job this build cannot read"
|
||||
);
|
||||
}
|
||||
|
||||
job
|
||||
}
|
||||
|
||||
fn row_to_job(row: &JobRow) -> Option<Job> {
|
||||
Some(Job::from_persistence(JobData {
|
||||
id: JobId::from_uuid(row.id.parse().ok()?),
|
||||
kind: JobKind::from_name(&row.kind)?,
|
||||
subject: JobSubject::from_key(&row.subject)?,
|
||||
status: JobStatus::from_name(&row.status)?,
|
||||
attempts: u32::try_from(row.attempts).ok()?,
|
||||
last_error: row.last_error.clone(),
|
||||
enqueued_at: row.enqueued_at.parse().ok()?,
|
||||
updated_at: row.updated_at.parse().ok()?,
|
||||
}))
|
||||
}
|
||||
58
crates/adapters/sqlite/src/repositories/job/weather.rs
Normal file
58
crates/adapters/sqlite/src/repositories/job/weather.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::entry::MoodEntryId;
|
||||
use domain::errors::DomainError;
|
||||
use domain::location::Coordinates;
|
||||
use domain::ports::UnwatchedPlace;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteWeatherBacklogRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteWeatherBacklogRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct UnwatchedPlaceRow {
|
||||
entry_id: String,
|
||||
latitude: f64,
|
||||
longitude: f64,
|
||||
logged_at: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::WeatherBacklogQueryPort for SqliteWeatherBacklogRepository {
|
||||
async fn find_places_without_weather(
|
||||
&self,
|
||||
most: usize,
|
||||
) -> Result<Vec<UnwatchedPlace>, DomainError> {
|
||||
let rows: Vec<UnwatchedPlaceRow> = sqlx::query_as(
|
||||
"SELECT l.entry_id, l.latitude, l.longitude, e.logged_at
|
||||
FROM entry_location l
|
||||
JOIN mood_entries e ON e.id = l.entry_id
|
||||
LEFT JOIN entry_weather w ON w.entry_id = l.entry_id
|
||||
WHERE w.entry_id IS NULL
|
||||
ORDER BY e.logged_at DESC
|
||||
LIMIT ?",
|
||||
)
|
||||
.bind(i64::try_from(most).unwrap_or(i64::MAX))
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(rows.iter().filter_map(readable).collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn readable(row: &UnwatchedPlaceRow) -> Option<UnwatchedPlace> {
|
||||
Some(UnwatchedPlace {
|
||||
entry_id: MoodEntryId::from_uuid(row.entry_id.parse().ok()?),
|
||||
coordinates: Coordinates::from_persistence(row.latitude, row.longitude),
|
||||
logged_at: row.logged_at.parse().ok()?,
|
||||
})
|
||||
}
|
||||
@@ -1,21 +1,43 @@
|
||||
pub mod shared;
|
||||
|
||||
mod activity;
|
||||
mod api_token;
|
||||
mod cascade;
|
||||
mod cycle;
|
||||
mod daily_metric;
|
||||
mod dimension;
|
||||
mod entry;
|
||||
mod job;
|
||||
mod provider_connection;
|
||||
mod push_subscription;
|
||||
mod refresh_session;
|
||||
mod rejection;
|
||||
mod reminder;
|
||||
mod user;
|
||||
|
||||
pub use activity::{SqliteActivityCommandRepository, SqliteActivityQueryRepository};
|
||||
pub use api_token::{SqliteApiTokenCommandRepository, SqliteApiTokenQueryRepository};
|
||||
pub use cascade::SqliteCascadeDeleteRepository;
|
||||
pub use cycle::{SqliteCycleStartRepository, SqliteUserPreferencesRepository};
|
||||
pub use daily_metric::{SqliteDailyMetricCommandRepository, SqliteDailyMetricQueryRepository};
|
||||
pub use dimension::{
|
||||
SqliteContentDimensionRepository, SqliteLocationDimensionRepository,
|
||||
SqliteRelationDimensionRepository, SqliteSongDimensionRepository,
|
||||
SqliteWeatherDimensionRepository,
|
||||
};
|
||||
pub use entry::{SqliteEntryCommandRepository, SqliteEntryQueryRepository};
|
||||
pub use job::{
|
||||
SqliteJobQueueRepository, SqliteRecordingBackfillRepository, SqliteWeatherBacklogRepository,
|
||||
};
|
||||
pub use provider_connection::{
|
||||
SqliteProviderConnectionCommandRepository, SqliteProviderConnectionQueryRepository,
|
||||
};
|
||||
pub use push_subscription::{
|
||||
SqlitePushSubscriptionCommandRepository, SqlitePushSubscriptionQueryRepository,
|
||||
};
|
||||
pub use refresh_session::{
|
||||
SqliteRefreshSessionCommandRepository, SqliteRefreshSessionQueryRepository,
|
||||
};
|
||||
pub use rejection::SqliteRejectionRepository;
|
||||
pub use reminder::{SqliteReminderCommandRepository, SqliteReminderQueryRepository};
|
||||
pub use user::{SqliteUserCommandRepository, SqliteUserQueryRepository};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::provider::{ProviderConnection, ProviderName};
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteProviderConnectionCommandRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteProviderConnectionCommandRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ProviderConnectionCommandPort for SqliteProviderConnectionCommandRepository {
|
||||
async fn save(&self, connection: &ProviderConnection) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO provider_connections (id, user_id, provider, credential, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, provider) DO UPDATE SET
|
||||
credential = excluded.credential, updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(connection.id().value().to_string())
|
||||
.bind(connection.user_id().value().to_string())
|
||||
.bind(connection.provider().value())
|
||||
.bind(connection.credential().value())
|
||||
.bind(connection.created_at().to_rfc3339())
|
||||
.bind(connection.updated_at().to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, user_id: &UserId, provider: &ProviderName) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM provider_connections WHERE user_id = ? AND provider = ?")
|
||||
.bind(user_id.value().to_string())
|
||||
.bind(provider.value())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod command;
|
||||
mod query;
|
||||
mod rows;
|
||||
|
||||
pub use command::SqliteProviderConnectionCommandRepository;
|
||||
pub use query::SqliteProviderConnectionQueryRepository;
|
||||
@@ -0,0 +1,50 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::provider::{ProviderConnection, ProviderName};
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::rows::{ProviderConnectionRow, row_to_connection};
|
||||
|
||||
pub struct SqliteProviderConnectionQueryRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteProviderConnectionQueryRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ProviderConnectionQueryPort for SqliteProviderConnectionQueryRepository {
|
||||
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<ProviderConnection>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, ProviderConnectionRow>(
|
||||
"SELECT * FROM provider_connections WHERE user_id = ? ORDER BY provider",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
rows.into_iter().map(row_to_connection).collect()
|
||||
}
|
||||
|
||||
async fn find_by_user_and_provider(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
provider: &ProviderName,
|
||||
) -> Result<Option<ProviderConnection>, DomainError> {
|
||||
let row = sqlx::query_as::<_, ProviderConnectionRow>(
|
||||
"SELECT * FROM provider_connections WHERE user_id = ? AND provider = ?",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.bind(provider.value())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
row.map(row_to_connection).transpose()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::provider::{
|
||||
EncryptedCredential, ProviderConnection, ProviderConnectionData, ProviderConnectionId,
|
||||
ProviderName,
|
||||
};
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct ProviderConnectionRow {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub provider: String,
|
||||
pub credential: Vec<u8>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
pub fn row_to_connection(row: ProviderConnectionRow) -> Result<ProviderConnection, DomainError> {
|
||||
let malformed = || DomainError::InvalidInput("stored provider connection is malformed".into());
|
||||
|
||||
Ok(ProviderConnection::from_persistence(
|
||||
ProviderConnectionData {
|
||||
id: ProviderConnectionId::from_uuid(row.id.parse().map_err(|_| malformed())?),
|
||||
user_id: UserId::from_uuid(row.user_id.parse().map_err(|_| malformed())?),
|
||||
provider: ProviderName::from_persistence(row.provider),
|
||||
credential: EncryptedCredential::from_persistence(row.credential),
|
||||
created_at: row.created_at.parse().map_err(|_| malformed())?,
|
||||
updated_at: row.updated_at.parse().map_err(|_| malformed())?,
|
||||
},
|
||||
))
|
||||
}
|
||||
4
crates/adapters/sqlite/src/repositories/rejection/mod.rs
Normal file
4
crates/adapters/sqlite/src/repositories/rejection/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
mod repository;
|
||||
mod rows;
|
||||
|
||||
pub use repository::SqliteRejectionRepository;
|
||||
100
crates/adapters/sqlite/src/repositories/rejection/repository.rs
Normal file
100
crates/adapters/sqlite/src/repositories/rejection/repository.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::rejection::RejectedMetric;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::rows::{RejectionRow, readable};
|
||||
|
||||
pub struct SqliteRejectionRepository {
|
||||
pool: SqlitePool,
|
||||
kept_per_user: usize,
|
||||
}
|
||||
|
||||
impl SqliteRejectionRepository {
|
||||
pub fn new(pool: SqlitePool, kept_per_user: usize) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
kept_per_user,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::RejectionCommandPort for SqliteRejectionRepository {
|
||||
async fn record(&self, rejections: &[RejectedMetric]) -> Result<(), DomainError> {
|
||||
let mut tx = self.pool.begin().await.map_err(db_err)?;
|
||||
|
||||
for rejected in rejections {
|
||||
sqlx::query(
|
||||
"INSERT INTO metric_rejections
|
||||
(id, user_id, origin, provider, date, kind, value, reason, recorded_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(rejected.id().value().to_string())
|
||||
.bind(rejected.user_id().value().to_string())
|
||||
.bind(rejected.origin().name())
|
||||
.bind(rejected.detail().provider().map(|name| name.value()))
|
||||
.bind(rejected.detail().date().map(|date| date.to_string()))
|
||||
.bind(rejected.detail().kind())
|
||||
.bind(rejected.detail().value())
|
||||
.bind(rejected.reason())
|
||||
.bind(rejected.recorded_at().to_rfc3339())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
|
||||
for owner in owners(rejections) {
|
||||
sqlx::query(
|
||||
"DELETE FROM metric_rejections WHERE user_id = ? AND id NOT IN (
|
||||
SELECT id FROM metric_rejections WHERE user_id = ?
|
||||
ORDER BY recorded_at DESC, id DESC LIMIT ?
|
||||
)",
|
||||
)
|
||||
.bind(&owner)
|
||||
.bind(&owner)
|
||||
.bind(self.kept_per_user as i64)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::RejectionQueryPort for SqliteRejectionRepository {
|
||||
async fn find_recent_by_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<RejectedMetric>, DomainError> {
|
||||
let rows: Vec<RejectionRow> = sqlx::query_as(
|
||||
"SELECT id, user_id, origin, provider, date, kind, value, reason, recorded_at
|
||||
FROM metric_rejections WHERE user_id = ?
|
||||
ORDER BY recorded_at DESC, id DESC LIMIT ?",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.bind(self.kept_per_user as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(rows.iter().filter_map(readable).collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn owners(rejections: &[RejectedMetric]) -> Vec<String> {
|
||||
let mut owners: Vec<String> = rejections
|
||||
.iter()
|
||||
.map(|rejected| rejected.user_id().value().to_string())
|
||||
.collect();
|
||||
owners.sort();
|
||||
owners.dedup();
|
||||
|
||||
owners
|
||||
}
|
||||
50
crates/adapters/sqlite/src/repositories/rejection/rows.rs
Normal file
50
crates/adapters/sqlite/src/repositories/rejection/rows.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use domain::entry::Date;
|
||||
use domain::provider::ProviderName;
|
||||
use domain::rejection::{
|
||||
RejectedMetric, RejectedMetricData, RejectionDetail, RejectionId, RejectionOrigin,
|
||||
};
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct RejectionRow {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub origin: String,
|
||||
pub provider: Option<String>,
|
||||
pub date: Option<String>,
|
||||
pub kind: String,
|
||||
pub value: Option<i64>,
|
||||
pub reason: String,
|
||||
pub recorded_at: String,
|
||||
}
|
||||
|
||||
pub fn readable(row: &RejectionRow) -> Option<RejectedMetric> {
|
||||
let origin = RejectionOrigin::from_name(&row.origin);
|
||||
|
||||
if origin.is_none() {
|
||||
tracing::warn!(
|
||||
rejection_id = %row.id,
|
||||
origin = %row.origin,
|
||||
"skipped a stored rejection this build cannot read"
|
||||
);
|
||||
}
|
||||
|
||||
let date = match &row.date {
|
||||
None => None,
|
||||
Some(day) => Some(Date::from_persistence(day.parse().ok()?)),
|
||||
};
|
||||
|
||||
Some(RejectedMetric::from_persistence(RejectedMetricData {
|
||||
id: RejectionId::from_uuid(row.id.parse().ok()?),
|
||||
user_id: UserId::from_uuid(row.user_id.parse().ok()?),
|
||||
origin: origin?,
|
||||
detail: RejectionDetail::new(
|
||||
row.provider.clone().map(ProviderName::from_persistence),
|
||||
date,
|
||||
row.kind.clone(),
|
||||
row.value,
|
||||
),
|
||||
reason: row.reason.clone(),
|
||||
recorded_at: row.recorded_at.parse().ok()?,
|
||||
}))
|
||||
}
|
||||
@@ -24,7 +24,7 @@ impl domain::ports::UserQueryPort for SqliteUserQueryRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(row.map(UserRow::into_domain))
|
||||
row.map(UserRow::into_domain).transpose()
|
||||
}
|
||||
|
||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||
@@ -33,7 +33,7 @@ impl domain::ports::UserQueryPort for SqliteUserQueryRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(row.map(UserRow::into_domain))
|
||||
row.map(UserRow::into_domain).transpose()
|
||||
}
|
||||
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||
@@ -42,6 +42,6 @@ impl domain::ports::UserQueryPort for SqliteUserQueryRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(row.map(UserRow::into_domain))
|
||||
row.map(UserRow::into_domain).transpose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::{
|
||||
DisplayName, Email, PasswordHash, Timezone, User, UserData, UserId, UserRole, Username,
|
||||
};
|
||||
@@ -16,22 +17,37 @@ pub struct UserRow {
|
||||
}
|
||||
|
||||
impl UserRow {
|
||||
pub fn into_domain(self) -> User {
|
||||
pub fn into_domain(self) -> Result<User, DomainError> {
|
||||
let malformed = || DomainError::InvalidInput("stored user row is malformed".into());
|
||||
|
||||
let role = match self.role.as_str() {
|
||||
"Admin" => UserRole::Admin,
|
||||
_ => UserRole::User,
|
||||
};
|
||||
|
||||
User::from_persistence(UserData {
|
||||
id: UserId::from_uuid(self.id.parse().unwrap()),
|
||||
Ok(User::from_persistence(UserData {
|
||||
id: UserId::from_uuid(self.id.parse().map_err(|_| malformed())?),
|
||||
username: Username::from_persistence(self.username),
|
||||
email: Email::from_persistence(self.email),
|
||||
password_hash: PasswordHash::new(self.password_hash),
|
||||
display_name: self.display_name.map(DisplayName::from_persistence),
|
||||
timezone: self.timezone.map(Timezone::from_persistence),
|
||||
timezone: self.timezone.as_deref().and_then(resolve_timezone),
|
||||
role,
|
||||
created_at: self.created_at.parse().unwrap(),
|
||||
updated_at: self.updated_at.parse().unwrap(),
|
||||
})
|
||||
created_at: self.created_at.parse().map_err(|_| malformed())?,
|
||||
updated_at: self.updated_at.parse().map_err(|_| malformed())?,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_timezone(stored: &str) -> Option<Timezone> {
|
||||
match Timezone::from_persistence(stored) {
|
||||
Ok(timezone) => Some(timezone),
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
timezone = stored,
|
||||
"stored timezone is not in the IANA database, treating it as unset"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
198
crates/adapters/sqlite/tests/api_token_test.rs
Normal file
198
crates/adapters/sqlite/tests/api_token_test.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
use domain::api_token::{ApiToken, TokenDigest};
|
||||
use domain::ports::{ApiTokenCommandPort, ApiTokenQueryPort, CascadeDeletePort, UserCommandPort};
|
||||
use domain::provider::ProviderName;
|
||||
use domain::testing::test_user;
|
||||
use domain::user::{User, UserId};
|
||||
|
||||
use sqlite::repositories::{
|
||||
SqliteApiTokenCommandRepository, SqliteApiTokenQueryRepository, SqliteCascadeDeleteRepository,
|
||||
SqliteUserCommandRepository,
|
||||
};
|
||||
|
||||
async fn a_pool_with_a_user() -> (sqlx::SqlitePool, User) {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlite::run_migrations(&pool).await.unwrap();
|
||||
|
||||
let user = test_user("alice");
|
||||
SqliteUserCommandRepository::new(pool.clone())
|
||||
.save(&user)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
(pool, user)
|
||||
}
|
||||
|
||||
fn a_token(owner: &UserId, name: &str, digest: &str) -> ApiToken {
|
||||
ApiToken::new(
|
||||
owner.clone(),
|
||||
ProviderName::new(name).unwrap(),
|
||||
TokenDigest::from_persistence(digest.into()),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_token_is_found_by_the_digest_of_its_secret() {
|
||||
let (pool, user) = a_pool_with_a_user().await;
|
||||
let token = a_token(user.id(), "iphone-shortcuts", "abc123");
|
||||
|
||||
SqliteApiTokenCommandRepository::new(pool.clone())
|
||||
.save(&token)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let found = SqliteApiTokenQueryRepository::new(pool.clone())
|
||||
.find_by_digest(&TokenDigest::from_persistence("abc123".into()))
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("the token should be found");
|
||||
|
||||
assert_eq!(found.id(), token.id());
|
||||
assert_eq!(found.name().value(), "iphone-shortcuts");
|
||||
assert!(found.last_used_at().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_digest_nobody_stored_finds_nothing() {
|
||||
let (pool, _) = a_pool_with_a_user().await;
|
||||
|
||||
let found = SqliteApiTokenQueryRepository::new(pool.clone())
|
||||
.find_by_digest(&TokenDigest::from_persistence("nothing".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(found.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_tokens_of_one_account_cannot_share_a_name() {
|
||||
let (pool, user) = a_pool_with_a_user().await;
|
||||
let tokens = SqliteApiTokenCommandRepository::new(pool.clone());
|
||||
|
||||
tokens
|
||||
.save(&a_token(user.id(), "iphone-shortcuts", "first"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let again = tokens
|
||||
.save(&a_token(user.id(), "iphone-shortcuts", "second"))
|
||||
.await;
|
||||
|
||||
let refusal = again
|
||||
.expect_err("a duplicate name must be refused")
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
refusal.contains("already exists"),
|
||||
"the refusal should say what is wrong, got: {refusal}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn using_a_token_is_recorded_against_it() {
|
||||
let (pool, user) = a_pool_with_a_user().await;
|
||||
let token = a_token(user.id(), "tasker", "abc123");
|
||||
let tokens = SqliteApiTokenCommandRepository::new(pool.clone());
|
||||
tokens.save(&token).await.unwrap();
|
||||
|
||||
tokens.mark_used(token.id()).await.unwrap();
|
||||
|
||||
let found = SqliteApiTokenQueryRepository::new(pool.clone())
|
||||
.find_by_digest(&TokenDigest::from_persistence("abc123".into()))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert!(found.last_used_at().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revoking_a_token_removes_it_for_good() {
|
||||
let (pool, user) = a_pool_with_a_user().await;
|
||||
let token = a_token(user.id(), "tasker", "abc123");
|
||||
let tokens = SqliteApiTokenCommandRepository::new(pool.clone());
|
||||
tokens.save(&token).await.unwrap();
|
||||
|
||||
tokens.revoke(user.id(), token.id()).await.unwrap();
|
||||
|
||||
let found = SqliteApiTokenQueryRepository::new(pool.clone())
|
||||
.find_by_digest(&TokenDigest::from_persistence("abc123".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(found.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_token_belonging_to_someone_else_cannot_be_revoked() {
|
||||
let (pool, user) = a_pool_with_a_user().await;
|
||||
let token = a_token(user.id(), "tasker", "abc123");
|
||||
let tokens = SqliteApiTokenCommandRepository::new(pool.clone());
|
||||
tokens.save(&token).await.unwrap();
|
||||
|
||||
let attempt = tokens.revoke(&UserId::generate(), token.id()).await;
|
||||
|
||||
assert!(attempt.is_err());
|
||||
assert!(
|
||||
SqliteApiTokenQueryRepository::new(pool.clone())
|
||||
.find_by_digest(&TokenDigest::from_persistence("abc123".into()))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_row_with_a_scope_this_build_does_not_know_authenticates_nothing() {
|
||||
let (pool, user) = a_pool_with_a_user().await;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO api_tokens (id, user_id, name, digest, scope, created_at, last_used_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NULL)",
|
||||
)
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(user.id().value().to_string())
|
||||
.bind("legacy")
|
||||
.bind("abc123")
|
||||
.bind("readEverything")
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let found = SqliteApiTokenQueryRepository::new(pool.clone())
|
||||
.find_by_digest(&TokenDigest::from_persistence("abc123".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
found.is_none(),
|
||||
"an unreadable scope must not grant anything"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_an_account_takes_its_tokens_with_it() {
|
||||
let (pool, user) = a_pool_with_a_user().await;
|
||||
SqliteApiTokenCommandRepository::new(pool.clone())
|
||||
.save(&a_token(user.id(), "tasker", "abc123"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
SqliteCascadeDeleteRepository::new(pool.clone())
|
||||
.delete_user_account(user.id())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let remaining: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM api_tokens")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(remaining.0, 0);
|
||||
}
|
||||
285
crates/adapters/sqlite/tests/cascade_test.rs
Normal file
285
crates/adapters/sqlite/tests/cascade_test.rs
Normal file
@@ -0,0 +1,285 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::dimension::DimensionValue;
|
||||
use domain::entry::{Content, Mood, MoodEntry, MoodEntryId};
|
||||
use domain::location::Coordinates;
|
||||
use domain::ports::{EntryDimensionPort, MoodEntryCommandPort, UserCommandPort};
|
||||
use domain::song::Song;
|
||||
use domain::testing::test_user;
|
||||
use domain::user::{User, UserId};
|
||||
|
||||
use sqlite::repositories::{
|
||||
SqliteContentDimensionRepository, SqliteEntryCommandRepository,
|
||||
SqliteLocationDimensionRepository, SqliteSongDimensionRepository, SqliteUserCommandRepository,
|
||||
};
|
||||
|
||||
const EVERY_TABLE_THAT_HANGS_OFF_AN_ENTRY: [&str; 4] = [
|
||||
"entry_content",
|
||||
"entry_location",
|
||||
"entry_song",
|
||||
"entry_activities",
|
||||
];
|
||||
|
||||
async fn a_file() -> String {
|
||||
let name = format!("k-mood-cascade-{}.sqlite", uuid::Uuid::new_v4());
|
||||
|
||||
std::env::temp_dir()
|
||||
.join(name)
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn an_entry_with_every_dimension(pool: &SqlitePool) -> (User, MoodEntryId) {
|
||||
let user = test_user("alice");
|
||||
SqliteUserCommandRepository::new(pool.clone())
|
||||
.save(&user)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let entry = MoodEntry::new(
|
||||
user.id().clone(),
|
||||
Mood::Good,
|
||||
chrono::DateTime::parse_from_rfc3339("2026-08-20T12:00:00+02:00").unwrap(),
|
||||
);
|
||||
SqliteEntryCommandRepository::new(pool.clone())
|
||||
.save(&entry)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
SqliteContentDimensionRepository::new(pool.clone())
|
||||
.save(
|
||||
entry.id(),
|
||||
&[DimensionValue::Content(Content::new("a note").unwrap())],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
SqliteLocationDimensionRepository::new(pool.clone())
|
||||
.save(
|
||||
entry.id(),
|
||||
&[DimensionValue::Location(
|
||||
Coordinates::new(52.2297, 21.0122).unwrap(),
|
||||
)],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
SqliteSongDimensionRepository::new(pool.clone())
|
||||
.save(
|
||||
entry.id(),
|
||||
&[DimensionValue::Song(
|
||||
Song::new("Teardrop", "Massive Attack", None, None).unwrap(),
|
||||
)],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
(user, entry.id().clone())
|
||||
}
|
||||
|
||||
async fn rows_in(pool: &SqlitePool, table: &str) -> i64 {
|
||||
let counted: (i64,) =
|
||||
sqlx::query_as(sqlx::AssertSqlSafe(format!("SELECT COUNT(*) FROM {table}")))
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
counted.0
|
||||
}
|
||||
|
||||
async fn dimension_rows(pool: &SqlitePool) -> i64 {
|
||||
let mut total = 0;
|
||||
for table in EVERY_TABLE_THAT_HANGS_OFF_AN_ENTRY {
|
||||
total += rows_in(pool, table).await;
|
||||
}
|
||||
|
||||
total
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn foreign_keys_are_switched_on_for_every_connection_the_pool_hands_out() {
|
||||
let path = a_file().await;
|
||||
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for _ in 0..5 {
|
||||
let on: (i64,) = sqlx::query_as("PRAGMA foreign_keys")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
on.0, 1,
|
||||
"sqlite ignores ON DELETE CASCADE silently when foreign keys are off"
|
||||
);
|
||||
}
|
||||
|
||||
remove(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_an_entry_really_does_remove_its_dimension_rows() {
|
||||
let path = a_file().await;
|
||||
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
|
||||
.await
|
||||
.unwrap();
|
||||
sqlite::run_migrations(&pool).await.unwrap();
|
||||
|
||||
let (_, entry_id) = an_entry_with_every_dimension(&pool).await;
|
||||
assert!(
|
||||
dimension_rows(&pool).await >= 3,
|
||||
"the dimensions were not stored"
|
||||
);
|
||||
|
||||
SqliteEntryCommandRepository::new(pool.clone())
|
||||
.delete(&entry_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
dimension_rows(&pool).await,
|
||||
0,
|
||||
"ON DELETE CASCADE did not fire"
|
||||
);
|
||||
|
||||
remove(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_dimension_row_cannot_be_written_for_an_entry_that_does_not_exist() {
|
||||
let path = a_file().await;
|
||||
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
|
||||
.await
|
||||
.unwrap();
|
||||
sqlite::run_migrations(&pool).await.unwrap();
|
||||
|
||||
let refused = SqliteContentDimensionRepository::new(pool.clone())
|
||||
.save(
|
||||
&MoodEntryId::generate(),
|
||||
&[DimensionValue::Content(Content::new("orphan").unwrap())],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
refused.is_err(),
|
||||
"a foreign key that is not enforced is not a foreign key"
|
||||
);
|
||||
|
||||
remove(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_an_account_removes_everything_that_hangs_off_it() {
|
||||
use domain::ports::CascadeDeletePort;
|
||||
use sqlite::repositories::SqliteCascadeDeleteRepository;
|
||||
|
||||
let path = a_file().await;
|
||||
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
|
||||
.await
|
||||
.unwrap();
|
||||
sqlite::run_migrations(&pool).await.unwrap();
|
||||
|
||||
let (user, _) = an_entry_with_every_dimension(&pool).await;
|
||||
|
||||
SqliteCascadeDeleteRepository::new(pool.clone())
|
||||
.delete_user_account(user.id())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rows_in(&pool, "users").await, 0);
|
||||
assert_eq!(rows_in(&pool, "mood_entries").await, 0);
|
||||
assert_eq!(
|
||||
dimension_rows(&pool).await,
|
||||
0,
|
||||
"the cascade must reach through the entry to its dimensions"
|
||||
);
|
||||
|
||||
remove(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_oldest_tables_do_not_cascade_from_users_which_is_why_they_are_deleted_by_hand() {
|
||||
let path = a_file().await;
|
||||
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
|
||||
.await
|
||||
.unwrap();
|
||||
sqlite::run_migrations(&pool).await.unwrap();
|
||||
|
||||
let (user, _) = an_entry_with_every_dimension(&pool).await;
|
||||
|
||||
let refused = sqlx::query("DELETE FROM users WHERE id = ?")
|
||||
.bind(user.id().value().to_string())
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
refused.is_err(),
|
||||
"mood_entries references users without ON DELETE CASCADE, so the repository must \
|
||||
delete the older tables itself. If this now succeeds, the schema gained a cascade \
|
||||
and those manual deletes are redundant."
|
||||
);
|
||||
|
||||
remove(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_table_added_since_does_cascade_from_users() {
|
||||
let path = a_file().await;
|
||||
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
|
||||
.await
|
||||
.unwrap();
|
||||
sqlite::run_migrations(&pool).await.unwrap();
|
||||
|
||||
let cascading = [
|
||||
"provider_connections",
|
||||
"daily_metrics",
|
||||
"api_tokens",
|
||||
"metric_rejections",
|
||||
"cycle_starts",
|
||||
"user_preferences",
|
||||
];
|
||||
|
||||
for table in cascading {
|
||||
let sql: (String,) =
|
||||
sqlx::query_as("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.bind(table)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
sql.0.contains("REFERENCES users(id) ON DELETE CASCADE"),
|
||||
"{table} should be removed by the database when its account goes"
|
||||
);
|
||||
}
|
||||
|
||||
remove(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_entry_cannot_belong_to_an_account_that_does_not_exist() {
|
||||
let path = a_file().await;
|
||||
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
|
||||
.await
|
||||
.unwrap();
|
||||
sqlite::run_migrations(&pool).await.unwrap();
|
||||
|
||||
let orphan = MoodEntry::new(
|
||||
UserId::generate(),
|
||||
Mood::Good,
|
||||
chrono::DateTime::parse_from_rfc3339("2026-08-20T12:00:00+02:00").unwrap(),
|
||||
);
|
||||
|
||||
let refused = SqliteEntryCommandRepository::new(pool.clone())
|
||||
.save(&orphan)
|
||||
.await;
|
||||
|
||||
assert!(refused.is_err(), "an entry with no owner should be refused");
|
||||
|
||||
remove(&path);
|
||||
}
|
||||
|
||||
fn remove(path: &str) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
let _ = std::fs::remove_file(format!("{path}-wal"));
|
||||
let _ = std::fs::remove_file(format!("{path}-shm"));
|
||||
}
|
||||
179
crates/adapters/sqlite/tests/cycle_test.rs
Normal file
179
crates/adapters/sqlite/tests/cycle_test.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
use domain::entry::Date;
|
||||
use domain::ports::{
|
||||
CascadeDeletePort, CycleStartCommandPort, CycleStartQueryPort, UserCommandPort,
|
||||
UserPreferencesCommandPort, UserPreferencesQueryPort,
|
||||
};
|
||||
use domain::testing::test_user;
|
||||
use domain::user::{User, UserId, UserPreferences};
|
||||
|
||||
use sqlite::repositories::{
|
||||
SqliteCascadeDeleteRepository, SqliteCycleStartRepository, SqliteUserCommandRepository,
|
||||
SqliteUserPreferencesRepository,
|
||||
};
|
||||
|
||||
async fn a_pool_with_a_user() -> (sqlx::SqlitePool, User) {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlite::run_migrations(&pool).await.unwrap();
|
||||
|
||||
let user = test_user("alice");
|
||||
SqliteUserCommandRepository::new(pool.clone())
|
||||
.save(&user)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
(pool, user)
|
||||
}
|
||||
|
||||
fn on(day: &str) -> Date {
|
||||
Date::from_persistence(day.parse().unwrap())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recording_the_same_start_twice_leaves_one_row() {
|
||||
let (pool, user) = a_pool_with_a_user().await;
|
||||
let starts = SqliteCycleStartRepository::new(pool.clone());
|
||||
|
||||
starts.record(user.id(), &on("2026-01-01")).await.unwrap();
|
||||
starts.record(user.id(), &on("2026-01-01")).await.unwrap();
|
||||
|
||||
let rows: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM cycle_starts")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rows.0, 1);
|
||||
assert_eq!(starts.find_by_user(user.id()).await.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn starts_come_back_oldest_first() {
|
||||
let (pool, user) = a_pool_with_a_user().await;
|
||||
let starts = SqliteCycleStartRepository::new(pool.clone());
|
||||
|
||||
for day in ["2026-02-26", "2026-01-01", "2026-01-29"] {
|
||||
starts.record(user.id(), &on(day)).await.unwrap();
|
||||
}
|
||||
|
||||
let found: Vec<String> = starts
|
||||
.find_by_user(user.id())
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|date| date.to_string())
|
||||
.collect();
|
||||
|
||||
assert_eq!(found, ["2026-01-01", "2026-01-29", "2026-02-26"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forgetting_a_start_removes_only_that_one() {
|
||||
let (pool, user) = a_pool_with_a_user().await;
|
||||
let starts = SqliteCycleStartRepository::new(pool.clone());
|
||||
starts.record(user.id(), &on("2026-01-01")).await.unwrap();
|
||||
starts.record(user.id(), &on("2026-01-29")).await.unwrap();
|
||||
|
||||
starts.forget(user.id(), &on("2026-01-01")).await.unwrap();
|
||||
|
||||
let found: Vec<String> = starts
|
||||
.find_by_user(user.id())
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|date| date.to_string())
|
||||
.collect();
|
||||
|
||||
assert_eq!(found, ["2026-01-29"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_accounts_starts_are_not_anothers() {
|
||||
let (pool, mine) = a_pool_with_a_user().await;
|
||||
let starts = SqliteCycleStartRepository::new(pool.clone());
|
||||
|
||||
starts.record(mine.id(), &on("2026-01-01")).await.unwrap();
|
||||
|
||||
assert!(
|
||||
starts
|
||||
.find_by_user(&UserId::generate())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_account_with_no_stored_preference_has_none_to_read() {
|
||||
let (pool, user) = a_pool_with_a_user().await;
|
||||
|
||||
let found = SqliteUserPreferencesRepository::new(pool.clone())
|
||||
.find_by_user(user.id())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
found.is_none(),
|
||||
"the default belongs to the domain, not the row"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_preference_survives_being_written_twice() {
|
||||
let (pool, user) = a_pool_with_a_user().await;
|
||||
let preferences = SqliteUserPreferencesRepository::new(pool.clone());
|
||||
|
||||
let mut held = UserPreferences::off_by_default(user.id().clone());
|
||||
held.track_cycle(true);
|
||||
preferences.save(&held).await.unwrap();
|
||||
|
||||
held.track_cycle(false);
|
||||
preferences.save(&held).await.unwrap();
|
||||
|
||||
let found = preferences.find_by_user(user.id()).await.unwrap().unwrap();
|
||||
|
||||
assert!(!found.tracks_cycle());
|
||||
|
||||
let rows: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM user_preferences")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.0, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_an_account_takes_its_cycle_and_preferences_with_it() {
|
||||
let (pool, user) = a_pool_with_a_user().await;
|
||||
SqliteCycleStartRepository::new(pool.clone())
|
||||
.record(user.id(), &on("2026-01-01"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut held = UserPreferences::off_by_default(user.id().clone());
|
||||
held.track_cycle(true);
|
||||
SqliteUserPreferencesRepository::new(pool.clone())
|
||||
.save(&held)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
SqliteCascadeDeleteRepository::new(pool.clone())
|
||||
.delete_user_account(user.id())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let starts: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM cycle_starts")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let preferences: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM user_preferences")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(starts.0, 0);
|
||||
assert_eq!(preferences.0, 0);
|
||||
}
|
||||
445
crates/adapters/sqlite/tests/daily_metric_test.rs
Normal file
445
crates/adapters/sqlite/tests/daily_metric_test.rs
Normal file
@@ -0,0 +1,445 @@
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
use domain::entry::{Date, DateSpan};
|
||||
use domain::metric::{DailyMetric, Hrv, MetricKind, MetricValue, Source, Steps};
|
||||
use domain::ports::{
|
||||
CascadeDeletePort, DailyMetricCommandPort, DailyMetricQueryPort, UserCommandPort,
|
||||
};
|
||||
use domain::provider::ProviderName;
|
||||
use domain::testing::test_user;
|
||||
use domain::user::UserId;
|
||||
|
||||
use sqlite::repositories::{
|
||||
SqliteCascadeDeleteRepository, SqliteDailyMetricCommandRepository,
|
||||
SqliteDailyMetricQueryRepository, SqliteRejectionRepository, SqliteUserCommandRepository,
|
||||
};
|
||||
|
||||
const REJECTIONS_KEPT: usize = 200;
|
||||
|
||||
fn a_trace(pool: sqlx::SqlitePool) -> std::sync::Arc<dyn domain::ports::RejectionCommandPort> {
|
||||
std::sync::Arc::new(SqliteRejectionRepository::new(pool, REJECTIONS_KEPT))
|
||||
}
|
||||
|
||||
async fn a_pool_with_a_user() -> (sqlx::SqlitePool, UserId) {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlite::run_migrations(&pool).await.unwrap();
|
||||
|
||||
let user = test_user("alice");
|
||||
SqliteUserCommandRepository::new(pool.clone())
|
||||
.save(&user)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
(pool, user.id().clone())
|
||||
}
|
||||
|
||||
fn on(day: &str) -> Date {
|
||||
Date::from_persistence(day.parse().unwrap())
|
||||
}
|
||||
|
||||
fn steps(count: u32) -> MetricValue {
|
||||
MetricValue::Steps(Steps::new(count).unwrap())
|
||||
}
|
||||
|
||||
fn from_provider() -> Source {
|
||||
Source::Provider(ProviderName::new("healthkit").unwrap())
|
||||
}
|
||||
|
||||
async fn stored_for(pool: &sqlx::SqlitePool, user_id: &UserId, day: &str) -> Vec<DailyMetric> {
|
||||
let span = DateSpan::new(on(day), on(day)).unwrap();
|
||||
SqliteDailyMetricQueryRepository::new(pool.clone(), a_trace(pool.clone()))
|
||||
.find_by_span(user_id, &span)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restating_a_date_replaces_the_row_rather_than_adding_one() {
|
||||
let (pool, user_id) = a_pool_with_a_user().await;
|
||||
let metrics = SqliteDailyMetricCommandRepository::new(pool.clone());
|
||||
|
||||
for count in [8_000, 8_412] {
|
||||
metrics
|
||||
.save(&[DailyMetric::new(
|
||||
user_id.clone(),
|
||||
on("2026-08-20"),
|
||||
steps(count),
|
||||
Source::Manual,
|
||||
)])
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let stored = stored_for(&pool, &user_id, "2026-08-20").await;
|
||||
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert_eq!(stored[0].value(), &steps(8_412));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_import_leaves_a_count_the_user_stated_alone() {
|
||||
let (pool, user_id) = a_pool_with_a_user().await;
|
||||
let metrics = SqliteDailyMetricCommandRepository::new(pool.clone());
|
||||
|
||||
metrics
|
||||
.save(&[DailyMetric::new(
|
||||
user_id.clone(),
|
||||
on("2026-08-20"),
|
||||
steps(8_412),
|
||||
Source::Manual,
|
||||
)])
|
||||
.await
|
||||
.unwrap();
|
||||
metrics
|
||||
.save(&[DailyMetric::new(
|
||||
user_id.clone(),
|
||||
on("2026-08-20"),
|
||||
steps(1_000),
|
||||
from_provider(),
|
||||
)])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stored = stored_for(&pool, &user_id, "2026-08-20").await;
|
||||
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert_eq!(stored[0].value(), &steps(8_412));
|
||||
assert_eq!(stored[0].source(), &Source::Manual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_count_the_user_states_replaces_what_a_provider_reported() {
|
||||
let (pool, user_id) = a_pool_with_a_user().await;
|
||||
let metrics = SqliteDailyMetricCommandRepository::new(pool.clone());
|
||||
|
||||
metrics
|
||||
.save(&[DailyMetric::new(
|
||||
user_id.clone(),
|
||||
on("2026-08-20"),
|
||||
steps(1_000),
|
||||
from_provider(),
|
||||
)])
|
||||
.await
|
||||
.unwrap();
|
||||
metrics
|
||||
.save(&[DailyMetric::new(
|
||||
user_id.clone(),
|
||||
on("2026-08-20"),
|
||||
steps(8_412),
|
||||
Source::Manual,
|
||||
)])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stored = stored_for(&pool, &user_id, "2026-08-20").await;
|
||||
|
||||
assert_eq!(stored[0].value(), &steps(8_412));
|
||||
assert_eq!(stored[0].source(), &Source::Manual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_provider_is_remembered_as_the_source() {
|
||||
let (pool, user_id) = a_pool_with_a_user().await;
|
||||
|
||||
SqliteDailyMetricCommandRepository::new(pool.clone())
|
||||
.save(&[DailyMetric::new(
|
||||
user_id.clone(),
|
||||
on("2026-08-20"),
|
||||
steps(1_000),
|
||||
from_provider(),
|
||||
)])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stored = stored_for(&pool, &user_id, "2026-08-20").await;
|
||||
|
||||
assert_eq!(stored[0].source(), &from_provider());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_row_of_a_kind_this_build_does_not_know_is_skipped_and_its_neighbours_survive() {
|
||||
let (pool, user_id) = a_pool_with_a_user().await;
|
||||
|
||||
SqliteDailyMetricCommandRepository::new(pool.clone())
|
||||
.save(&[DailyMetric::new(
|
||||
user_id.clone(),
|
||||
on("2026-08-20"),
|
||||
steps(8_412),
|
||||
Source::Manual,
|
||||
)])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query("INSERT INTO daily_metrics (user_id, date, kind, value, provider) VALUES (?, ?, ?, ?, NULL)")
|
||||
.bind(user_id.value().to_string())
|
||||
.bind("2026-08-20")
|
||||
.bind("telepathy")
|
||||
.bind(42)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stored = stored_for(&pool, &user_id, "2026-08-20").await;
|
||||
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert_eq!(stored[0].value(), &steps(8_412));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_value_outside_its_range_is_skipped_and_its_neighbours_survive() {
|
||||
let (pool, user_id) = a_pool_with_a_user().await;
|
||||
|
||||
SqliteDailyMetricCommandRepository::new(pool.clone())
|
||||
.save(&[DailyMetric::new(
|
||||
user_id.clone(),
|
||||
on("2026-08-19"),
|
||||
steps(8_412),
|
||||
Source::Manual,
|
||||
)])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query("INSERT INTO daily_metrics (user_id, date, kind, value, provider) VALUES (?, ?, ?, ?, NULL)")
|
||||
.bind(user_id.value().to_string())
|
||||
.bind("2026-08-20")
|
||||
.bind("steps")
|
||||
.bind(900_000)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let span = DateSpan::new(on("2026-08-19"), on("2026-08-20")).unwrap();
|
||||
let stored = SqliteDailyMetricQueryRepository::new(pool.clone(), a_trace(pool.clone()))
|
||||
.find_by_span(&user_id, &span)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert_eq!(stored[0].date(), &on("2026-08-19"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn days_outside_the_span_are_not_returned() {
|
||||
let (pool, user_id) = a_pool_with_a_user().await;
|
||||
let metrics = SqliteDailyMetricCommandRepository::new(pool.clone());
|
||||
|
||||
for day in ["2026-07-31", "2026-08-01", "2026-08-31", "2026-09-01"] {
|
||||
metrics
|
||||
.save(&[DailyMetric::new(
|
||||
user_id.clone(),
|
||||
on(day),
|
||||
steps(8_412),
|
||||
Source::Manual,
|
||||
)])
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let span = DateSpan::new(on("2026-08-01"), on("2026-08-31")).unwrap();
|
||||
let stored = SqliteDailyMetricQueryRepository::new(pool.clone(), a_trace(pool.clone()))
|
||||
.find_by_span(&user_id, &span)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let days: Vec<String> = stored
|
||||
.iter()
|
||||
.map(|metric| metric.date().to_string())
|
||||
.collect();
|
||||
|
||||
assert_eq!(days, ["2026-08-01", "2026-08-31"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clearing_a_users_data_removes_their_days() {
|
||||
let (pool, user_id) = a_pool_with_a_user().await;
|
||||
|
||||
SqliteDailyMetricCommandRepository::new(pool.clone())
|
||||
.save(&[DailyMetric::new(
|
||||
user_id.clone(),
|
||||
on("2026-08-20"),
|
||||
steps(8_412),
|
||||
Source::Manual,
|
||||
)])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
SqliteCascadeDeleteRepository::new(pool.clone())
|
||||
.delete_all_user_data(&user_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(stored_for(&pool, &user_id, "2026-08-20").await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_an_account_removes_its_days() {
|
||||
let (pool, user_id) = a_pool_with_a_user().await;
|
||||
|
||||
SqliteDailyMetricCommandRepository::new(pool.clone())
|
||||
.save(&[DailyMetric::new(
|
||||
user_id.clone(),
|
||||
on("2026-08-20"),
|
||||
steps(8_412),
|
||||
Source::Manual,
|
||||
)])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
SqliteCascadeDeleteRepository::new(pool.clone())
|
||||
.delete_user_account(&user_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let remaining: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM daily_metrics")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(remaining.0, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clearing_a_kind_deletes_only_that_row() {
|
||||
let (pool, user_id) = a_pool_with_a_user().await;
|
||||
let metrics = SqliteDailyMetricCommandRepository::new(pool.clone());
|
||||
|
||||
metrics
|
||||
.save(&[
|
||||
DailyMetric::new(
|
||||
user_id.clone(),
|
||||
on("2026-08-20"),
|
||||
steps(8_412),
|
||||
Source::Manual,
|
||||
),
|
||||
DailyMetric::new(
|
||||
user_id.clone(),
|
||||
on("2026-08-20"),
|
||||
MetricValue::Hrv(Hrv::new(61).unwrap()),
|
||||
Source::Manual,
|
||||
),
|
||||
DailyMetric::new(
|
||||
user_id.clone(),
|
||||
on("2026-08-21"),
|
||||
steps(9_000),
|
||||
Source::Manual,
|
||||
),
|
||||
])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
metrics
|
||||
.delete(&user_id, &on("2026-08-20"), &[MetricKind::Steps])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let remaining = stored_for(&pool, &user_id, "2026-08-20").await;
|
||||
|
||||
assert_eq!(remaining.len(), 1);
|
||||
assert_eq!(remaining[0].kind(), MetricKind::Hrv);
|
||||
assert_eq!(stored_for(&pool, &user_id, "2026-08-21").await.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clearing_a_kind_that_was_never_stored_is_not_an_error() {
|
||||
let (pool, user_id) = a_pool_with_a_user().await;
|
||||
|
||||
SqliteDailyMetricCommandRepository::new(pool.clone())
|
||||
.delete(&user_id, &on("2026-08-20"), &[MetricKind::Steps])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(stored_for(&pool, &user_id, "2026-08-20").await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clearing_a_kind_leaves_another_accounts_day_alone() {
|
||||
let (pool, mine) = a_pool_with_a_user().await;
|
||||
let theirs = test_user("bob");
|
||||
SqliteUserCommandRepository::new(pool.clone())
|
||||
.save(&theirs)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let metrics = SqliteDailyMetricCommandRepository::new(pool.clone());
|
||||
for owner in [&mine, theirs.id()] {
|
||||
metrics
|
||||
.save(&[DailyMetric::new(
|
||||
owner.clone(),
|
||||
on("2026-08-20"),
|
||||
steps(8_412),
|
||||
Source::Manual,
|
||||
)])
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
metrics
|
||||
.delete(&mine, &on("2026-08-20"), &[MetricKind::Steps])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(stored_for(&pool, &mine, "2026-08-20").await.is_empty());
|
||||
assert_eq!(
|
||||
stored_for(&pool, theirs.id(), "2026-08-20").await.len(),
|
||||
1,
|
||||
"the other account's day was cleared too"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_stored_row_this_build_cannot_read_lands_in_the_rejection_trace() {
|
||||
use domain::ports::RejectionQueryPort;
|
||||
use domain::rejection::RejectionOrigin;
|
||||
|
||||
let (pool, user_id) = a_pool_with_a_user().await;
|
||||
|
||||
SqliteDailyMetricCommandRepository::new(pool.clone())
|
||||
.save(&[DailyMetric::new(
|
||||
user_id.clone(),
|
||||
on("2026-08-19"),
|
||||
steps(8_412),
|
||||
Source::Manual,
|
||||
)])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for (kind, value) in [("telepathy", 42), ("steps", 900_000)] {
|
||||
sqlx::query(
|
||||
"INSERT INTO daily_metrics (user_id, date, kind, value, provider) VALUES (?, ?, ?, ?, NULL)",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.bind("2026-08-20")
|
||||
.bind(kind)
|
||||
.bind(value)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let span = DateSpan::new(on("2026-08-19"), on("2026-08-20")).unwrap();
|
||||
let readable = SqliteDailyMetricQueryRepository::new(pool.clone(), a_trace(pool.clone()))
|
||||
.find_by_span(&user_id, &span)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let trace = SqliteRejectionRepository::new(pool.clone(), REJECTIONS_KEPT)
|
||||
.find_recent_by_user(&user_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(readable.len(), 1, "the good row still comes back");
|
||||
assert_eq!(trace.len(), 2, "both unreadable rows are recorded");
|
||||
assert!(
|
||||
trace
|
||||
.iter()
|
||||
.all(|entry| entry.origin() == RejectionOrigin::StoredRow)
|
||||
);
|
||||
|
||||
let mut kinds: Vec<&str> = trace.iter().map(|entry| entry.detail().kind()).collect();
|
||||
kinds.sort_unstable();
|
||||
assert_eq!(kinds, ["steps", "telepathy"]);
|
||||
}
|
||||
213
crates/adapters/sqlite/tests/job_test.rs
Normal file
213
crates/adapters/sqlite/tests/job_test.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
use domain::entry::MoodEntryId;
|
||||
use domain::job::{JobKind, JobStatus, JobSubject};
|
||||
use domain::ports::{JobQueueCommandPort, JobQueueQueryPort};
|
||||
|
||||
use sqlite::repositories::SqliteJobQueueRepository;
|
||||
|
||||
const KIND: JobKind = JobKind::BackfillRecordingIdentity;
|
||||
|
||||
async fn a_queue() -> (sqlx::SqlitePool, SqliteJobQueueRepository) {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlite::run_migrations(&pool).await.unwrap();
|
||||
|
||||
(pool.clone(), SqliteJobQueueRepository::new(pool))
|
||||
}
|
||||
|
||||
fn about(entry_id: &MoodEntryId) -> JobSubject {
|
||||
JobSubject::Entry(entry_id.clone())
|
||||
}
|
||||
|
||||
async fn status_of(pool: &sqlx::SqlitePool) -> Vec<(String, i64, Option<String>)> {
|
||||
sqlx::query_as("SELECT status, attempts, last_error FROM jobs ORDER BY enqueued_at")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_same_work_cannot_be_queued_twice() {
|
||||
let (pool, queue) = a_queue().await;
|
||||
let subject = about(&MoodEntryId::generate());
|
||||
|
||||
assert!(queue.enqueue(KIND, &subject).await.unwrap());
|
||||
assert!(!queue.enqueue(KIND, &subject).await.unwrap());
|
||||
|
||||
let rows: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM jobs")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.0, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn claiming_marks_a_job_as_running_so_another_worker_leaves_it_alone() {
|
||||
let (pool, queue) = a_queue().await;
|
||||
queue
|
||||
.enqueue(KIND, &about(&MoodEntryId::generate()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let claimed = queue.claim(KIND, 10).await.unwrap();
|
||||
let claimed_again = queue.claim(KIND, 10).await.unwrap();
|
||||
|
||||
assert_eq!(claimed.len(), 1);
|
||||
assert!(
|
||||
claimed_again.is_empty(),
|
||||
"a running job is not claimed twice"
|
||||
);
|
||||
assert_eq!(status_of(&pool).await[0].0, JobStatus::Running.name());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn claiming_is_bounded_and_takes_the_oldest_first() {
|
||||
let (_, queue) = a_queue().await;
|
||||
for _ in 0..5 {
|
||||
queue
|
||||
.enqueue(KIND, &about(&MoodEntryId::generate()))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let claimed = queue.claim(KIND, 2).await.unwrap();
|
||||
|
||||
assert_eq!(claimed.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finishing_a_job_removes_it() {
|
||||
let (pool, queue) = a_queue().await;
|
||||
queue
|
||||
.enqueue(KIND, &about(&MoodEntryId::generate()))
|
||||
.await
|
||||
.unwrap();
|
||||
let claimed = queue.claim(KIND, 1).await.unwrap();
|
||||
|
||||
queue.finish(claimed[0].id()).await.unwrap();
|
||||
|
||||
assert!(status_of(&pool).await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn releasing_a_job_counts_the_attempt_and_keeps_the_reason() {
|
||||
let (pool, queue) = a_queue().await;
|
||||
queue
|
||||
.enqueue(KIND, &about(&MoodEntryId::generate()))
|
||||
.await
|
||||
.unwrap();
|
||||
let claimed = queue.claim(KIND, 1).await.unwrap();
|
||||
|
||||
queue
|
||||
.release(claimed[0].id(), "musicbrainz timed out")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stored = status_of(&pool).await;
|
||||
assert_eq!(stored[0].0, JobStatus::Pending.name());
|
||||
assert_eq!(stored[0].1, 1);
|
||||
assert_eq!(stored[0].2.as_deref(), Some("musicbrainz timed out"));
|
||||
assert_eq!(
|
||||
queue.claim(KIND, 1).await.unwrap().len(),
|
||||
1,
|
||||
"and it is claimable again"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_exhausted_job_is_never_claimed_but_can_still_be_seen() {
|
||||
let (_, queue) = a_queue().await;
|
||||
queue
|
||||
.enqueue(KIND, &about(&MoodEntryId::generate()))
|
||||
.await
|
||||
.unwrap();
|
||||
let claimed = queue.claim(KIND, 1).await.unwrap();
|
||||
|
||||
queue.exhaust(claimed[0].id(), "gave up").await.unwrap();
|
||||
|
||||
assert!(queue.claim(KIND, 10).await.unwrap().is_empty());
|
||||
let visible = queue.find_exhausted(10).await.unwrap();
|
||||
assert_eq!(visible.len(), 1);
|
||||
assert_eq!(visible[0].last_error(), Some("gave up"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_job_left_running_by_a_dead_worker_becomes_claimable_again() {
|
||||
let (_, queue) = a_queue().await;
|
||||
queue
|
||||
.enqueue(KIND, &about(&MoodEntryId::generate()))
|
||||
.await
|
||||
.unwrap();
|
||||
queue.claim(KIND, 1).await.unwrap();
|
||||
|
||||
let reclaimed = queue.reclaim_stalled(0).await.unwrap();
|
||||
|
||||
assert_eq!(reclaimed, 1);
|
||||
assert_eq!(queue.claim(KIND, 1).await.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_job_still_being_worked_on_is_not_reclaimed() {
|
||||
let (_, queue) = a_queue().await;
|
||||
queue
|
||||
.enqueue(KIND, &about(&MoodEntryId::generate()))
|
||||
.await
|
||||
.unwrap();
|
||||
queue.claim(KIND, 1).await.unwrap();
|
||||
|
||||
let reclaimed = queue.reclaim_stalled(300).await.unwrap();
|
||||
|
||||
assert_eq!(reclaimed, 0, "five minutes have not passed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_row_of_a_kind_this_build_does_not_know_is_never_claimed() {
|
||||
let (pool, queue) = a_queue().await;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO jobs (id, kind, subject, status, attempts, last_error, enqueued_at, updated_at)
|
||||
VALUES (?, 'summonRain', ?, 'pending', 0, NULL, ?, ?)",
|
||||
)
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(queue.claim(KIND, 10).await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_job_this_build_cannot_read_is_given_up_on_rather_than_claimed_forever() {
|
||||
let (pool, queue) = a_queue().await;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO jobs (id, kind, subject, status, attempts, last_error, enqueued_at, updated_at)
|
||||
VALUES (?, 'backfillRecordingIdentity', ?, 'pending', 0, NULL, 'the day before yesterday', ?)",
|
||||
)
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(queue.claim(KIND, 10).await.unwrap().is_empty());
|
||||
|
||||
let stored = status_of(&pool).await;
|
||||
assert_eq!(
|
||||
stored[0].0,
|
||||
JobStatus::Exhausted.name(),
|
||||
"an unreadable job must stop churning through claim and reclaim"
|
||||
);
|
||||
assert!(stored[0].2.is_some(), "and must say why it was given up on");
|
||||
|
||||
assert_eq!(queue.reclaim_stalled(0).await.unwrap(), 0);
|
||||
assert!(queue.claim(KIND, 10).await.unwrap().is_empty());
|
||||
}
|
||||
142
crates/adapters/sqlite/tests/migration_check.rs
Normal file
142
crates/adapters/sqlite/tests/migration_check.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
async fn fresh_pool() -> sqlx::SqlitePool {
|
||||
SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn column_names(pool: &sqlx::SqlitePool, table: &str) -> Vec<String> {
|
||||
let rows: Vec<(String,)> = sqlx::query_as("SELECT name FROM pragma_table_info(?) ORDER BY cid")
|
||||
.bind(table)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
rows.into_iter().map(|row| row.0).collect()
|
||||
}
|
||||
|
||||
async fn applied_count(pool: &sqlx::SqlitePool) -> i64 {
|
||||
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM schema_migrations")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
row.0
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_migration_applies_exactly_once_however_often_the_server_restarts() {
|
||||
let pool = fresh_pool().await;
|
||||
|
||||
sqlite::run_migrations(&pool).await.unwrap();
|
||||
let after_first_boot = applied_count(&pool).await;
|
||||
|
||||
sqlite::run_migrations(&pool).await.unwrap();
|
||||
sqlite::run_migrations(&pool).await.unwrap();
|
||||
let after_further_boots = applied_count(&pool).await;
|
||||
|
||||
assert!(after_first_boot > 0, "no migrations were applied at all");
|
||||
assert_eq!(after_first_boot, after_further_boots);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mood_entries_no_longer_carries_content() {
|
||||
let pool = fresh_pool().await;
|
||||
sqlite::run_migrations(&pool).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
column_names(&pool, "mood_entries").await,
|
||||
[
|
||||
"id",
|
||||
"user_id",
|
||||
"mood",
|
||||
"logged_at",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
column_names(&pool, "entry_content").await,
|
||||
["entry_id", "content"]
|
||||
);
|
||||
}
|
||||
|
||||
fn a_shared_file() -> String {
|
||||
let name = format!("k-mood-migrating-{}.sqlite", uuid::Uuid::new_v4());
|
||||
|
||||
std::env::temp_dir()
|
||||
.join(name)
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn forget(path: &str) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
let _ = std::fs::remove_file(format!("{path}-wal"));
|
||||
let _ = std::fs::remove_file(format!("{path}-shm"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_processes_starting_together_both_migrate_successfully() {
|
||||
let path = a_shared_file();
|
||||
let url = format!("sqlite://{path}");
|
||||
|
||||
let server = sqlite::create_pool(&url).await.unwrap();
|
||||
let worker = sqlite::create_pool(&url).await.unwrap();
|
||||
|
||||
let (migrating_server, migrating_worker) = tokio::join!(
|
||||
tokio::spawn({
|
||||
let pool = server.clone();
|
||||
async move { sqlite::run_migrations(&pool).await }
|
||||
}),
|
||||
tokio::spawn({
|
||||
let pool = worker.clone();
|
||||
async move { sqlite::run_migrations(&pool).await }
|
||||
}),
|
||||
);
|
||||
|
||||
let by_the_server = migrating_server.unwrap();
|
||||
let by_the_worker = migrating_worker.unwrap();
|
||||
|
||||
assert!(
|
||||
by_the_server.is_ok(),
|
||||
"the server could not start alongside the worker: {by_the_server:?}"
|
||||
);
|
||||
assert!(
|
||||
by_the_worker.is_ok(),
|
||||
"the worker could not start alongside the server: {by_the_worker:?}"
|
||||
);
|
||||
|
||||
let applied = applied_count(&server).await;
|
||||
let names: Vec<(String,)> = sqlx::query_as("SELECT name FROM schema_migrations")
|
||||
.fetch_all(&server)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
applied as usize,
|
||||
names.len(),
|
||||
"no migration is recorded twice"
|
||||
);
|
||||
assert!(applied > 0);
|
||||
|
||||
forget(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_process_joining_a_migrated_database_applies_nothing() {
|
||||
let path = a_shared_file();
|
||||
let url = format!("sqlite://{path}");
|
||||
|
||||
let first = sqlite::create_pool(&url).await.unwrap();
|
||||
sqlite::run_migrations(&first).await.unwrap();
|
||||
let already_applied = applied_count(&first).await;
|
||||
|
||||
let second = sqlite::create_pool(&url).await.unwrap();
|
||||
sqlite::run_migrations(&second).await.unwrap();
|
||||
|
||||
assert_eq!(applied_count(&second).await, already_applied);
|
||||
|
||||
forget(&path);
|
||||
}
|
||||
217
crates/adapters/sqlite/tests/rejection_test.rs
Normal file
217
crates/adapters/sqlite/tests/rejection_test.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
use domain::entry::Date;
|
||||
use domain::ports::{CascadeDeletePort, RejectionCommandPort, RejectionQueryPort, UserCommandPort};
|
||||
use domain::provider::ProviderName;
|
||||
use domain::rejection::{RejectedMetric, RejectionDetail, RejectionOrigin};
|
||||
use domain::testing::test_user;
|
||||
use domain::user::{User, UserId};
|
||||
|
||||
use sqlite::repositories::{
|
||||
SqliteCascadeDeleteRepository, SqliteRejectionRepository, SqliteUserCommandRepository,
|
||||
};
|
||||
|
||||
const KEPT: usize = 5;
|
||||
|
||||
async fn a_pool_with_a_user() -> (sqlx::SqlitePool, User) {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlite::run_migrations(&pool).await.unwrap();
|
||||
|
||||
let user = test_user("alice");
|
||||
SqliteUserCommandRepository::new(pool.clone())
|
||||
.save(&user)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
(pool, user)
|
||||
}
|
||||
|
||||
fn rejected(owner: &UserId, kind: &str, reason: &str) -> RejectedMetric {
|
||||
RejectedMetric::new(
|
||||
owner.clone(),
|
||||
RejectionOrigin::Import,
|
||||
RejectionDetail::new(
|
||||
Some(ProviderName::new("healthkit").unwrap()),
|
||||
Some(Date::from_persistence("2026-08-20".parse().unwrap())),
|
||||
kind,
|
||||
Some(9_999),
|
||||
),
|
||||
reason,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_recorded_rejection_comes_back_with_everything_needed_to_diagnose_it() {
|
||||
let (pool, user) = a_pool_with_a_user().await;
|
||||
let trace = SqliteRejectionRepository::new(pool.clone(), KEPT);
|
||||
|
||||
trace
|
||||
.record(&[rejected(
|
||||
user.id(),
|
||||
"hrv",
|
||||
"heart rate variability must be between 1 and 300",
|
||||
)])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let found = trace.find_recent_by_user(user.id()).await.unwrap();
|
||||
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].origin(), RejectionOrigin::Import);
|
||||
assert_eq!(found[0].detail().kind(), "hrv");
|
||||
assert_eq!(found[0].detail().value(), Some(9_999));
|
||||
assert_eq!(found[0].detail().date().unwrap().to_string(), "2026-08-20");
|
||||
assert_eq!(found[0].detail().provider().unwrap().value(), "healthkit");
|
||||
assert!(found[0].reason().contains("between 1 and 300"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_the_most_recent_rejections_are_kept() {
|
||||
let (pool, user) = a_pool_with_a_user().await;
|
||||
let trace = SqliteRejectionRepository::new(pool.clone(), KEPT);
|
||||
|
||||
for number in 0..12 {
|
||||
trace
|
||||
.record(&[rejected(user.id(), "hrv", &format!("failure {number}"))])
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let found = trace.find_recent_by_user(user.id()).await.unwrap();
|
||||
|
||||
assert_eq!(found.len(), KEPT);
|
||||
|
||||
let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM metric_rejections")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(total.0, KEPT as i64, "older rows should have been pruned");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_accounts_rejections_are_never_shown_to_another() {
|
||||
let (pool, mine) = a_pool_with_a_user().await;
|
||||
let theirs = test_user("bob");
|
||||
SqliteUserCommandRepository::new(pool.clone())
|
||||
.save(&theirs)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let trace = SqliteRejectionRepository::new(pool.clone(), KEPT);
|
||||
trace
|
||||
.record(&[rejected(mine.id(), "hrv", "mine")])
|
||||
.await
|
||||
.unwrap();
|
||||
trace
|
||||
.record(&[rejected(theirs.id(), "steps", "theirs")])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let found = trace.find_recent_by_user(mine.id()).await.unwrap();
|
||||
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].reason(), "mine");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pruning_one_account_leaves_anothers_alone() {
|
||||
let (pool, mine) = a_pool_with_a_user().await;
|
||||
let theirs = test_user("bob");
|
||||
SqliteUserCommandRepository::new(pool.clone())
|
||||
.save(&theirs)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let trace = SqliteRejectionRepository::new(pool.clone(), KEPT);
|
||||
trace
|
||||
.record(&[rejected(theirs.id(), "steps", "theirs")])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for number in 0..12 {
|
||||
trace
|
||||
.record(&[rejected(mine.id(), "hrv", &format!("failure {number}"))])
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
trace.find_recent_by_user(theirs.id()).await.unwrap().len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_rejection_with_no_date_is_still_recorded() {
|
||||
let (pool, user) = a_pool_with_a_user().await;
|
||||
let trace = SqliteRejectionRepository::new(pool.clone(), KEPT);
|
||||
|
||||
trace
|
||||
.record(&[RejectedMetric::new(
|
||||
user.id().clone(),
|
||||
RejectionOrigin::Import,
|
||||
RejectionDetail::new(None, None, "steps", None),
|
||||
"yesterday is not a date",
|
||||
)])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let found = trace.find_recent_by_user(user.id()).await.unwrap();
|
||||
|
||||
assert_eq!(found.len(), 1);
|
||||
assert!(found[0].detail().date().is_none());
|
||||
assert!(found[0].detail().provider().is_none());
|
||||
assert!(found[0].detail().value().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_row_with_an_origin_this_build_does_not_know_is_skipped() {
|
||||
let (pool, user) = a_pool_with_a_user().await;
|
||||
let trace = SqliteRejectionRepository::new(pool.clone(), KEPT);
|
||||
trace
|
||||
.record(&[rejected(user.id(), "hrv", "readable")])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO metric_rejections
|
||||
(id, user_id, origin, provider, date, kind, value, reason, recorded_at)
|
||||
VALUES (?, ?, 'telepathy', NULL, NULL, 'steps', NULL, 'from the future', ?)",
|
||||
)
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(user.id().value().to_string())
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let found = trace.find_recent_by_user(user.id()).await.unwrap();
|
||||
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].reason(), "readable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_an_account_takes_its_rejections_with_it() {
|
||||
let (pool, user) = a_pool_with_a_user().await;
|
||||
SqliteRejectionRepository::new(pool.clone(), KEPT)
|
||||
.record(&[rejected(user.id(), "hrv", "whatever")])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
SqliteCascadeDeleteRepository::new(pool.clone())
|
||||
.delete_user_account(user.id())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let remaining: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM metric_rejections")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(remaining.0, 0);
|
||||
}
|
||||
118
crates/adapters/sqlite/tests/two_writers_test.rs
Normal file
118
crates/adapters/sqlite/tests/two_writers_test.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use domain::entry::MoodEntryId;
|
||||
use domain::job::{JobKind, JobSubject};
|
||||
use domain::ports::JobQueueCommandPort;
|
||||
|
||||
use sqlite::repositories::SqliteJobQueueRepository;
|
||||
|
||||
const KIND: JobKind = JobKind::BackfillRecordingIdentity;
|
||||
|
||||
fn a_file() -> String {
|
||||
let name = format!("k-mood-two-writers-{}.sqlite", uuid::Uuid::new_v4());
|
||||
|
||||
std::env::temp_dir()
|
||||
.join(name)
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_processes_writing_one_file_both_succeed() {
|
||||
let path = a_file();
|
||||
let url = format!("sqlite://{path}");
|
||||
|
||||
let server = sqlite::create_pool(&url).await.unwrap();
|
||||
sqlite::run_migrations(&server).await.unwrap();
|
||||
|
||||
let worker = sqlite::create_pool(&url).await.unwrap();
|
||||
|
||||
let enqueuing = tokio::spawn({
|
||||
let queue = SqliteJobQueueRepository::new(server.clone());
|
||||
async move {
|
||||
for _ in 0..80 {
|
||||
queue
|
||||
.enqueue(KIND, &JobSubject::Entry(MoodEntryId::generate()))
|
||||
.await
|
||||
.expect("the server's write should not be refused");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let working = tokio::spawn({
|
||||
let queue = SqliteJobQueueRepository::new(worker.clone());
|
||||
async move {
|
||||
let mut claimed = 0;
|
||||
for _ in 0..80 {
|
||||
let batch = queue
|
||||
.claim(KIND, 5)
|
||||
.await
|
||||
.expect("the worker's write should not be refused");
|
||||
|
||||
for job in &batch {
|
||||
queue
|
||||
.finish(job.id())
|
||||
.await
|
||||
.expect("finishing should not be refused");
|
||||
}
|
||||
|
||||
claimed += batch.len();
|
||||
}
|
||||
|
||||
claimed
|
||||
}
|
||||
});
|
||||
|
||||
enqueuing.await.unwrap();
|
||||
let claimed = working.await.unwrap();
|
||||
|
||||
assert!(claimed > 0, "the worker never saw any of the server's work");
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{path}-wal"));
|
||||
let _ = std::fs::remove_file(format!("{path}-shm"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_writer_waits_for_a_held_lock_rather_than_being_refused() {
|
||||
let path = a_file();
|
||||
let url = format!("sqlite://{path}");
|
||||
|
||||
let holder = sqlite::create_pool(&url).await.unwrap();
|
||||
sqlite::run_migrations(&holder).await.unwrap();
|
||||
let other = sqlite::create_pool(&url).await.unwrap();
|
||||
|
||||
let mut held = holder.begin().await.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO jobs (id, kind, subject, status, attempts, last_error, enqueued_at, updated_at)
|
||||
VALUES (?, 'backfillRecordingIdentity', ?, 'pending', 0, NULL, ?, ?)",
|
||||
)
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.execute(&mut *held)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let waiting = tokio::spawn({
|
||||
let queue = SqliteJobQueueRepository::new(other);
|
||||
async move {
|
||||
queue
|
||||
.enqueue(KIND, &JobSubject::Entry(MoodEntryId::generate()))
|
||||
.await
|
||||
}
|
||||
});
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||
held.commit().await.unwrap();
|
||||
|
||||
let written = waiting.await.unwrap();
|
||||
|
||||
assert!(
|
||||
written.is_ok(),
|
||||
"the second writer was refused instead of waiting: {written:?}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{path}-wal"));
|
||||
let _ = std::fs::remove_file(format!("{path}-shm"));
|
||||
}
|
||||
294
crates/adapters/sqlite/tests/weather_dimension_test.rs
Normal file
294
crates/adapters/sqlite/tests/weather_dimension_test.rs
Normal file
@@ -0,0 +1,294 @@
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
use domain::dimension::{DimensionKind, DimensionValue};
|
||||
use domain::entry::{Mood, MoodEntry, MoodEntryId};
|
||||
use domain::location::Coordinates;
|
||||
use domain::ports::{EntryDimensionPort, MoodEntryCommandPort, UserCommandPort};
|
||||
use domain::provider::ProviderName;
|
||||
use domain::testing::test_user;
|
||||
use domain::weather::{Celsius, Condition, Weather};
|
||||
|
||||
use sqlite::repositories::{
|
||||
SqliteEntryCommandRepository, SqliteLocationDimensionRepository, SqliteUserCommandRepository,
|
||||
SqliteWeatherDimensionRepository,
|
||||
};
|
||||
|
||||
async fn an_entry() -> (sqlx::SqlitePool, MoodEntryId) {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlite::run_migrations(&pool).await.unwrap();
|
||||
|
||||
let user = test_user("alice");
|
||||
SqliteUserCommandRepository::new(pool.clone())
|
||||
.save(&user)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let entry = MoodEntry::new(
|
||||
user.id().clone(),
|
||||
Mood::Good,
|
||||
chrono::DateTime::parse_from_rfc3339("2026-08-20T14:00:00+02:00").unwrap(),
|
||||
);
|
||||
SqliteEntryCommandRepository::new(pool.clone())
|
||||
.save(&entry)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
(pool, entry.id().clone())
|
||||
}
|
||||
|
||||
fn observed(condition: Condition, degrees: f64) -> DimensionValue {
|
||||
DimensionValue::Weather(Weather::new(
|
||||
condition,
|
||||
Celsius::new(degrees).unwrap(),
|
||||
ProviderName::new("open-meteo").unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn weather_of(pool: &sqlx::SqlitePool, entry_id: &MoodEntryId) -> Option<DimensionValue> {
|
||||
SqliteWeatherDimensionRepository::new(pool.clone())
|
||||
.load(std::slice::from_ref(entry_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.remove(entry_id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn observed_weather_is_stored_and_read_back_whole() {
|
||||
let (pool, entry_id) = an_entry().await;
|
||||
let weather = SqliteWeatherDimensionRepository::new(pool.clone());
|
||||
|
||||
weather
|
||||
.save(&entry_id, &[observed(Condition::Rain, 11.5)])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let Some(DimensionValue::Weather(stored)) = weather_of(&pool, &entry_id).await else {
|
||||
panic!("the weather was not stored");
|
||||
};
|
||||
|
||||
assert_eq!(stored.condition(), Condition::Rain);
|
||||
assert!((stored.temperature().value() - 11.5).abs() < f64::EPSILON);
|
||||
assert_eq!(stored.observed_by().value(), "open-meteo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editing_an_entry_cannot_erase_what_a_provider_observed() {
|
||||
let (pool, entry_id) = an_entry().await;
|
||||
let weather = SqliteWeatherDimensionRepository::new(pool.clone());
|
||||
weather
|
||||
.save(&entry_id, &[observed(Condition::Rain, 11.5)])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let an_edit_that_says_nothing_about_weather = vec![DimensionValue::Location(
|
||||
Coordinates::new(52.2297, 21.0122).unwrap(),
|
||||
)];
|
||||
weather
|
||||
.save(&entry_id, &an_edit_that_says_nothing_about_weather)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
weather_of(&pool, &entry_id).await.is_some(),
|
||||
"weather is observed, not stated: an edit that omits it is not a request to delete it"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_later_observation_replaces_an_earlier_one() {
|
||||
let (pool, entry_id) = an_entry().await;
|
||||
let weather = SqliteWeatherDimensionRepository::new(pool.clone());
|
||||
|
||||
weather
|
||||
.save(&entry_id, &[observed(Condition::Clear, 20.0)])
|
||||
.await
|
||||
.unwrap();
|
||||
weather
|
||||
.save(&entry_id, &[observed(Condition::Snow, -2.0)])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let Some(DimensionValue::Weather(stored)) = weather_of(&pool, &entry_id).await else {
|
||||
panic!("the weather was lost");
|
||||
};
|
||||
|
||||
assert_eq!(stored.condition(), Condition::Snow);
|
||||
|
||||
let rows: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM entry_weather")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.0, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_condition_this_build_does_not_know_is_skipped() {
|
||||
let (pool, entry_id) = an_entry().await;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO entry_weather (entry_id, condition, temperature, observed_by)
|
||||
VALUES (?, 'raining frogs', 11.5, 'open-meteo')",
|
||||
)
|
||||
.bind(entry_id.value().to_string())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(weather_of(&pool, &entry_id).await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_an_entry_takes_its_weather_with_it() {
|
||||
let (pool, entry_id) = an_entry().await;
|
||||
SqliteWeatherDimensionRepository::new(pool.clone())
|
||||
.save(&entry_id, &[observed(Condition::Rain, 11.5)])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
SqliteEntryCommandRepository::new(pool.clone())
|
||||
.delete(&entry_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rows: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM entry_weather")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.0, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn weather_reports_its_own_kind() {
|
||||
assert_eq!(observed(Condition::Fog, 3.0).kind(), DimensionKind::Weather);
|
||||
let _ = SqliteLocationDimensionRepository::new;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_place_with_no_weather_is_backlogged_and_then_is_not() {
|
||||
use domain::ports::WeatherBacklogQueryPort;
|
||||
use sqlite::repositories::SqliteWeatherBacklogRepository;
|
||||
|
||||
let (pool, entry_id) = an_entry().await;
|
||||
|
||||
SqliteLocationDimensionRepository::new(pool.clone())
|
||||
.save(
|
||||
&entry_id,
|
||||
&[DimensionValue::Location(
|
||||
Coordinates::new(52.2297, 21.0122).unwrap(),
|
||||
)],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let backlog = SqliteWeatherBacklogRepository::new(pool.clone());
|
||||
|
||||
let waiting = backlog.find_places_without_weather(50).await.unwrap();
|
||||
assert_eq!(waiting.len(), 1);
|
||||
assert_eq!(waiting[0].entry_id, entry_id);
|
||||
assert!((waiting[0].coordinates.latitude().value() - 52.2297).abs() < 1e-9);
|
||||
|
||||
SqliteWeatherDimensionRepository::new(pool.clone())
|
||||
.save(&entry_id, &[observed(Condition::Rain, 11.5)])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
backlog
|
||||
.find_places_without_weather(50)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"an entry that now has weather is no longer waiting for any"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_entries_that_know_where_they_were_are_backlogged() {
|
||||
use domain::ports::WeatherBacklogQueryPort;
|
||||
use sqlite::repositories::SqliteWeatherBacklogRepository;
|
||||
|
||||
let (pool, somewhere_known) = an_entry().await;
|
||||
SqliteLocationDimensionRepository::new(pool.clone())
|
||||
.save(
|
||||
&somewhere_known,
|
||||
&[DimensionValue::Location(
|
||||
Coordinates::new(52.2297, 21.0122).unwrap(),
|
||||
)],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let owner: (String,) = sqlx::query_as("SELECT id FROM users LIMIT 1")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let nowhere = MoodEntry::new(
|
||||
domain::user::UserId::from_uuid(owner.0.parse().unwrap()),
|
||||
Mood::Meh,
|
||||
chrono::DateTime::parse_from_rfc3339("2026-08-21T09:00:00+02:00").unwrap(),
|
||||
);
|
||||
SqliteEntryCommandRepository::new(pool.clone())
|
||||
.save(&nowhere)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let waiting = SqliteWeatherBacklogRepository::new(pool)
|
||||
.find_places_without_weather(50)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ids: Vec<&MoodEntryId> = waiting.iter().map(|place| &place.entry_id).collect();
|
||||
|
||||
assert_eq!(
|
||||
ids,
|
||||
[&somewhere_known],
|
||||
"weather needs somewhere to have happened"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_backlog_is_bounded_by_what_is_asked_for() {
|
||||
use domain::ports::WeatherBacklogQueryPort;
|
||||
use sqlite::repositories::SqliteWeatherBacklogRepository;
|
||||
|
||||
let (pool, first) = an_entry().await;
|
||||
let locations = SqliteLocationDimensionRepository::new(pool.clone());
|
||||
let somewhere = DimensionValue::Location(Coordinates::new(52.2297, 21.0122).unwrap());
|
||||
locations
|
||||
.save(&first, std::slice::from_ref(&somewhere))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let owner: (String,) = sqlx::query_as("SELECT id FROM users LIMIT 1")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for hour in 0..5 {
|
||||
let entry = MoodEntry::new(
|
||||
domain::user::UserId::from_uuid(owner.0.parse().unwrap()),
|
||||
Mood::Good,
|
||||
chrono::DateTime::parse_from_rfc3339(&format!("2026-08-2{hour}T09:00:00+02:00"))
|
||||
.unwrap(),
|
||||
);
|
||||
SqliteEntryCommandRepository::new(pool.clone())
|
||||
.save(&entry)
|
||||
.await
|
||||
.unwrap();
|
||||
locations
|
||||
.save(entry.id(), std::slice::from_ref(&somewhere))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let waiting = SqliteWeatherBacklogRepository::new(pool)
|
||||
.find_places_without_weather(3)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(waiting.len(), 3);
|
||||
}
|
||||
Reference in New Issue
Block a user