changes
All checks were successful
CI / ci (push) Successful in 19m38s

This commit is contained in:
2026-08-26 20:55:30 +02:00
parent a557c183e9
commit 23d052278a
523 changed files with 24448 additions and 2005 deletions

View File

@@ -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"] }

View File

@@ -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())
}

View File

@@ -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
);

View File

@@ -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;

View File

@@ -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
);

View File

@@ -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);

View File

@@ -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)
);

View 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);

View File

@@ -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);

View File

@@ -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
);

View 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);

View File

@@ -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
);

View 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(())
}
}

View File

@@ -0,0 +1,6 @@
mod command;
mod query;
mod rows;
pub use command::SqliteApiTokenCommandRepository;
pub use query::SqliteApiTokenQueryRepository;

View 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())
}
}

View 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
}

View File

@@ -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(())
}

View File

@@ -0,0 +1,3 @@
mod repository;
pub use repository::{SqliteCycleStartRepository, SqliteUserPreferencesRepository};

View 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)))
}
}

View File

@@ -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(())
}
}

View File

@@ -0,0 +1,6 @@
mod command;
mod query;
mod rows;
pub use command::SqliteDailyMetricCommandRepository;
pub use query::SqliteDailyMetricQueryRepository;

View File

@@ -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",
)
}

View 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())
}

View 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(())
}
}

View 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(())
}
}

View 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;

View 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(())
}
}

View 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(())
}
}

View 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)))
}

View File

@@ -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)?;

View File

@@ -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()
}

View 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(),
})
}

View 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;

View 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())
}
}

View 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()?,
}))
}

View 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()?,
})
}

View File

@@ -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};

View File

@@ -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(())
}
}

View File

@@ -0,0 +1,6 @@
mod command;
mod query;
mod rows;
pub use command::SqliteProviderConnectionCommandRepository;
pub use query::SqliteProviderConnectionQueryRepository;

View File

@@ -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()
}
}

View File

@@ -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())?,
},
))
}

View File

@@ -0,0 +1,4 @@
mod repository;
mod rows;
pub use repository::SqliteRejectionRepository;

View 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
}

View 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()?,
}))
}

View File

@@ -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()
}
}

View File

@@ -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
}
}
}

View 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);
}

View 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"));
}

View 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);
}

View 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"]);
}

View 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());
}

View 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);
}

View 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);
}

View 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"));
}

View 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);
}