Files
ImageApi/src/database/user_ai_prefs_dao.rs
T
Cameron Cordes 2f2107d4fa feat(dates): foundation for the true-instant date contract (#114)
Populate the new-contract date columns at ingest while keeping the
legacy date_taken column byte-identical (ADR-0001):

- camera_tz module: offset cascade (offset tag -> GPS zone -> server
  zone) as a pure, testable function; bundled offline TZF geometry for
  GPS -> IANA resolution; DST-correct offsets for the capture date;
  display-zone helpers (DEFAULT_DISPLAY_TZ, display_zone_for,
  display_zone_offset_minutes) for the read-side cutover.
- EXIF branch: kamadak-exif now collects OffsetTime* tags paired with
  the winning date tag; resolve_date_taken takes an ExifPrior and
  emits (date_utc, camera_offset_minutes, date_taken_tz_source) per
  branch: exif -> cascade, exiftool/fs_time -> utc_direct, filename
  -> unclassified (pending #116).
- Schema: migration adds image_exif.date_utc /
  camera_offset_minutes / date_taken_tz_source and
  user_ai_prefs.display_tz (all nullable; down.sql drops them).
- DAO + ingest: backfill_date_taken persists the triple alongside the
  legacy pair; watcher, upload, and set_image_gps call sites feed the
  cascade.
- Response surface: ExifMetadata and ExifSummary carry the triple so
  /image/metadata and /photos/exif expose it (null for unconverted
  rows); reel requests mirror display_tz into prefs.

cargo build + clippy clean; 625 lib + 702 bin tests pass (the one
failing doctest in utils.rs is pre-existing at the base commit).
2026-09-24 21:58:46 -04:00

215 lines
7.1 KiB
Rust

use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use std::ops::DerefMut;
use std::sync::{Arc, Mutex};
use crate::database::models::{UpsertUserAiPrefs, UserAiPrefs};
use crate::database::schema;
use crate::database::{DbError, DbErrorKind, connect};
use crate::otel::trace_db_call;
/// Generic single-row table that passively mirrors the latest client AI
/// request parameters (voice, timezone, library). Read by the nightly
/// pre-generation scheduler (Section D) to pick up user preferences.
pub trait UserAiPrefsDao: Sync + Send {
/// Read the single row; `None` when it hasn't been populated yet.
fn get_prefs(
&mut self,
context: &opentelemetry::Context,
) -> Result<Option<UserAiPrefs>, DbError>;
/// Upsert the single row (id is always 1).
#[allow(dead_code)]
fn upsert_prefs(
&mut self,
context: &opentelemetry::Context,
prefs: &UpsertUserAiPrefs,
) -> Result<(), DbError>;
}
pub struct SqliteUserAiPrefsDao {
connection: Arc<Mutex<SqliteConnection>>,
}
impl Default for SqliteUserAiPrefsDao {
fn default() -> Self {
Self::new()
}
}
impl SqliteUserAiPrefsDao {
pub fn new() -> Self {
Self {
connection: Arc::new(Mutex::new(connect())),
}
}
#[cfg(test)]
pub fn from_connection(conn: Arc<Mutex<SqliteConnection>>) -> Self {
Self { connection: conn }
}
}
impl UserAiPrefsDao for SqliteUserAiPrefsDao {
fn get_prefs(
&mut self,
context: &opentelemetry::Context,
) -> Result<Option<UserAiPrefs>, DbError> {
trace_db_call(context, "query", "get_prefs", |_span| {
use schema::user_ai_prefs::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock UserAiPrefsDao");
dsl::user_ai_prefs
.first::<UserAiPrefs>(connection.deref_mut())
.optional()
.map_err(|e| anyhow::anyhow!("Failed to get prefs: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn upsert_prefs(
&mut self,
context: &opentelemetry::Context,
prefs: &UpsertUserAiPrefs,
) -> Result<(), DbError> {
trace_db_call(context, "upsert", "upsert_prefs", |_span| {
use schema::user_ai_prefs::dsl;
let mut connection = self
.connection
.lock()
.expect("Unable to lock UserAiPrefsDao");
// Single-row table (id=1): one atomic upsert. The explicit id=1
// makes the conflict target deterministic so the second call
// updates in place rather than tripping the CHECK(id=1) constraint,
// and real insert errors surface instead of being swallowed into a
// separate update branch. The columns are set explicitly (rather
// than via AsChangeset) so a None field overwrites to NULL — the
// row mirrors the latest request exactly, not a merge of past ones.
diesel::insert_into(dsl::user_ai_prefs)
.values((dsl::id.eq(1), prefs))
.on_conflict(dsl::id)
.do_update()
.set((
dsl::voice.eq(&prefs.voice),
dsl::tz_offset_minutes.eq(&prefs.tz_offset_minutes),
dsl::library.eq(&prefs.library),
dsl::display_tz.eq(&prefs.display_tz),
dsl::updated_at.eq(&prefs.updated_at),
))
.execute(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Failed to upsert prefs: {}", e))?;
Ok(())
})
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
}
}
#[cfg(test)]
mod tests {
use super::*;
use diesel::Connection;
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!();
fn setup_dao() -> SqliteUserAiPrefsDao {
let mut conn = SqliteConnection::establish(":memory:")
.expect("Unable to create in-memory db connection");
conn.run_pending_migrations(DB_MIGRATIONS)
.expect("Failure running DB migrations");
SqliteUserAiPrefsDao::from_connection(Arc::new(Mutex::new(conn)))
}
fn ctx() -> opentelemetry::Context {
opentelemetry::Context::new()
}
#[test]
fn get_prefs_returns_none_when_empty() {
let mut dao = setup_dao();
let result = dao.get_prefs(&ctx()).unwrap();
assert!(result.is_none());
}
#[test]
fn upsert_prefs_inserts_row() {
let mut dao = setup_dao();
let now = 1_700_000_000i64;
let prefs = UpsertUserAiPrefs {
voice: Some("grandma".to_string()),
tz_offset_minutes: Some(-480),
library: Some("1".to_string()),
updated_at: now,
display_tz: Some("America/New_York".to_string()),
};
dao.upsert_prefs(&ctx(), &prefs).unwrap();
let row = dao.get_prefs(&ctx()).unwrap().unwrap();
assert_eq!(row.id, 1);
assert_eq!(row.voice, Some("grandma".to_string()));
assert_eq!(row.tz_offset_minutes, Some(-480));
assert_eq!(row.library, Some("1".to_string()));
assert_eq!(row.display_tz, Some("America/New_York".to_string()));
assert_eq!(row.updated_at, now);
}
#[test]
fn upsert_prefs_replaces_existing() {
let mut dao = setup_dao();
let now1 = 1_700_000_000i64;
let now2 = 1_800_000_000i64;
let prefs1 = UpsertUserAiPrefs {
voice: Some("grandma".to_string()),
tz_offset_minutes: Some(-480),
library: Some("1".to_string()),
updated_at: now1,
display_tz: Some("America/New_York".to_string()),
};
dao.upsert_prefs(&ctx(), &prefs1).unwrap();
let prefs2 = UpsertUserAiPrefs {
voice: Some("dad".to_string()),
tz_offset_minutes: Some(-300),
library: None,
updated_at: now2,
display_tz: None,
};
dao.upsert_prefs(&ctx(), &prefs2).unwrap();
let row = dao.get_prefs(&ctx()).unwrap().unwrap();
assert_eq!(row.voice, Some("dad".to_string()));
assert_eq!(row.tz_offset_minutes, Some(-300));
assert!(row.library.is_none());
assert!(row.display_tz.is_none());
assert_eq!(row.updated_at, now2);
}
#[test]
fn upsert_partial_fields() {
let mut dao = setup_dao();
let now = 1_700_000_000i64;
let prefs = UpsertUserAiPrefs {
voice: None,
tz_offset_minutes: Some(-480),
library: None,
updated_at: now,
display_tz: Some("Europe/Berlin".to_string()),
};
dao.upsert_prefs(&ctx(), &prefs).unwrap();
let row = dao.get_prefs(&ctx()).unwrap().unwrap();
assert_eq!(row.tz_offset_minutes, Some(-480));
assert!(row.voice.is_none());
assert!(row.library.is_none());
assert_eq!(row.display_tz, Some("Europe/Berlin".to_string()));
}
}