Prefs: owner defaults (model, backend, persona_id, reel span/length)

Closes #127.

Grows user_ai_prefs with five nullable owner-default columns and makes
them the server-side fallback layer: request param -> stored pref ->
hard default, at insight generation (agentic + standard), all three
insight-chat turn variants, and reel creation.

- PUT /prefs accepts the fields with the existing three-state partial
  update semantics (TriState generalized to TriState<T> for the numeric
  reel_max_segments); validation (canonical BackendKind::parse,
  MemoriesSpan::from_str) runs before any write.
- GET /prefs returns the new fields.
- capture_prefs reads the existing row and carries the AI owner-defaults
  through unchanged (the upsert overwrites all columns), mirroring only
  the effective reel span/segments.
- MemoriesSpan gains Display + FromStr so stored spans round-trip
  losslessly.

1463/0 tests (+27), clippy clean.
This commit is contained in:
Cameron Cordes committed 2026-09-27 19:48:45 -04:00
1 parent aa3c58f051
commit a699c940b8
10 files changed
+863 -85

No files matched your search

+412 -70
View File
@@ -8,15 +8,19 @@
//! `GET /timezones` — the IANA zone list from chrono-tz, for picker UIs
//! (unauthenticated; static data).
//!
//! Scope: existing columns only (voice, library, display_tz) — no migration.
//! The legacy `tz_offset_minutes` column is preserved verbatim on every
//! write. `POST /reels`'s `capture_prefs` stays as the incidental writer;
//! both writers hit the same single row, so they're idempotent. Owner
//! defaults (model/backend/persona/reels) are a separate follow-on ticket
//! that adds the columns.
//! Surface: all persisted prefs — the passive mirror (voice, library,
//! display_tz) and the owner defaults (default_model, default_backend,
//! default_persona_id, reel_span, reel_max_segments). Consumers resolve
//! `request param → stored pref → hard default`; this endpoint is the only
//! place the owner-default fields are written. `POST /reels`'s
//! `capture_prefs` stays as the incidental writer for the mirror fields
//! (plus the effective reel span/segments); both writers hit the same
//! single row, so they're idempotent. The legacy `tz_offset_minutes`
//! column is preserved verbatim on every write.
use actix_web::{HttpResponse, web};
use chrono_tz::Tz;
use serde::de::IntoDeserializer;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::str::FromStr;
@@ -24,6 +28,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use crate::data::Claims;
use crate::database::models::{UpsertUserAiPrefs, UserAiPrefs};
use crate::memories::MemoriesSpan;
use crate::state::AppState;
// ---------------------------------------------------------------------------
@@ -35,6 +40,18 @@ pub struct PrefsResponse {
pub voice: Option<String>,
pub library: Option<String>,
pub display_tz: Option<String>,
/// Last/explicit insight + chat model choice. NULL = per-request /
/// env default behavior.
pub default_model: Option<String>,
/// `"local"` | `"hybrid"`. NULL = per-request / hard default.
pub default_backend: Option<String>,
/// Active persona for persona chat. NULL = hard default ("default").
pub default_persona_id: Option<String>,
/// Reel span: `"day"` | `"week"` | `"month"`. NULL = per-request /
/// hard default (day).
pub reel_span: Option<String>,
/// Reel segment cap. NULL = per-request / hard default.
pub reel_max_segments: Option<i32>,
pub updated_at: Option<i64>,
}
@@ -43,41 +60,59 @@ pub struct PrefsResponse {
/// explicit null and a missing key apart on `Option<Option<T>>` (both come
/// out as outer `None`), so this carries a hand-rolled visitor.
#[derive(Clone, Debug, Default)]
pub enum TriState {
pub enum TriState<T> {
#[default]
Absent,
Null,
Val(String),
Val(T),
}
impl<'de> Deserialize<'de> for TriState {
fn deserialize<D>(deserializer: D) -> Result<TriState, D::Error>
impl<'de, T: Deserialize<'de>> Deserialize<'de> for TriState<T> {
fn deserialize<D>(deserializer: D) -> Result<TriState<T>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = TriState;
struct Visitor<T>(std::marker::PhantomData<T>);
impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Visitor<T> {
type Value = TriState<T>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a string or null")
f.write_str("a value or null")
}
// serde_json routes an explicit JSON null to visit_unit/visit_none;
// a missing key never calls deserialize — `#[serde(default)]`
// supplies Absent instead.
fn visit_unit<E: serde::de::Error>(self) -> Result<TriState, E> {
// supplies Absent instead. Concrete values are re-deserialized into
// T through single-value deserializers (serde_json's
// deserialize_any dispatches directly to visit_str/visit_i64/…
// — visit_some only fires on the deserialize_option path).
fn visit_unit<E: serde::de::Error>(self) -> Result<TriState<T>, E> {
Ok(TriState::Null)
}
fn visit_none<E: serde::de::Error>(self) -> Result<TriState, E> {
fn visit_none<E: serde::de::Error>(self) -> Result<TriState<T>, E> {
Ok(TriState::Null)
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<TriState, E> {
Ok(TriState::Val(v.to_string()))
fn visit_some<D2>(self, d: D2) -> Result<TriState<T>, D2::Error>
where
D2: serde::Deserializer<'de>,
{
Ok(TriState::Val(T::deserialize(d)?))
}
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<TriState, E> {
Ok(TriState::Val(v))
// `into_deserializer()` gives a single-value deserializer whose
// error type is the visitor's E, so T::deserialize lands on
// Result<T, E> with no conversion.
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<TriState<T>, E> {
T::deserialize(v.into_deserializer()).map(TriState::Val)
}
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<TriState<T>, E> {
T::deserialize(v.as_str().into_deserializer()).map(TriState::Val)
}
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<TriState<T>, E> {
T::deserialize(v.into_deserializer()).map(TriState::Val)
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<TriState<T>, E> {
T::deserialize(v.into_deserializer()).map(TriState::Val)
}
}
deserializer.deserialize_any(Visitor)
deserializer.deserialize_any(Visitor(std::marker::PhantomData))
}
}
@@ -86,11 +121,21 @@ impl<'de> Deserialize<'de> for TriState {
#[derive(Deserialize, Debug, Default)]
pub struct UpdatePrefsRequest {
#[serde(default)]
pub voice: TriState,
pub voice: TriState<String>,
#[serde(default)]
pub library: TriState,
pub library: TriState<String>,
#[serde(default)]
pub display_tz: TriState,
pub display_tz: TriState<String>,
#[serde(default)]
pub default_model: TriState<String>,
#[serde(default)]
pub default_backend: TriState<String>,
#[serde(default)]
pub default_persona_id: TriState<String>,
#[serde(default)]
pub reel_span: TriState<String>,
#[serde(default)]
pub reel_max_segments: TriState<i32>,
}
#[derive(Serialize, Debug)]
@@ -109,6 +154,18 @@ fn is_valid_display_tz(name: &str) -> bool {
Tz::from_str(name.trim()).is_ok()
}
/// A stored `default_backend` must be consumable downstream, i.e. accepted
/// by the canonical `BackendKind::parse` ("local"/"hybrid", case-insensitive).
/// Delegating to that parser keeps the write-time validator and the read-time
/// consumer from drifting apart; anything else stored here would 500 on every
/// insight/chat request instead of failing once at write time. The blank
/// string the parser tolerates (empty request field = app default) is
/// rejected here — a stored default is an explicit choice, and NULL already
/// means "no default".
fn is_valid_backend(name: &str) -> bool {
!name.trim().is_empty() && crate::ai::backend::BackendKind::parse(name).is_ok()
}
/// The full set of zone names `PUT /prefs` will accept — `chrono_tz::TZ_VARIANTS`
/// is exactly the domain of `Tz::from_str`, so the picker list and the
/// validation check can never drift apart.
@@ -119,14 +176,13 @@ pub fn all_timezones() -> Vec<String> {
.collect()
}
/// Merge a partial update onto the existing row. Returns
/// `(voice, library, display_tz, changed)`. An omitted field keeps the
/// stored value (or `None` when there is no row); a present field wins.
fn tri_value(
field: &TriState,
/// Merge a partial update onto the existing row. An omitted field keeps
/// the stored value (or `None` when there is no row); a present field wins.
fn tri_value<T: Clone>(
field: &TriState<T>,
existing: Option<&UserAiPrefs>,
keep: impl FnOnce(&UserAiPrefs) -> Option<String>,
) -> Option<String> {
keep: impl FnOnce(&UserAiPrefs) -> Option<T>,
) -> Option<T> {
match field {
TriState::Absent => existing.and_then(keep),
TriState::Null => None,
@@ -134,17 +190,43 @@ fn tri_value(
}
}
fn merge_patch(
existing: Option<&UserAiPrefs>,
patch: &UpdatePrefsRequest,
) -> (Option<String>, Option<String>, Option<String>, bool) {
let voice = tri_value(&patch.voice, existing, |e| e.voice.clone());
let library = tri_value(&patch.library, existing, |e| e.library.clone());
let display_tz = tri_value(&patch.display_tz, existing, |e| e.display_tz.clone());
/// The eight partial-update fields after a merge (everything except the
/// frozen `tz_offset_minutes`, which the handler copies through verbatim).
struct MergedPrefs {
voice: Option<String>,
library: Option<String>,
display_tz: Option<String>,
default_model: Option<String>,
default_backend: Option<String>,
default_persona_id: Option<String>,
reel_span: Option<String>,
reel_max_segments: Option<i32>,
}
fn merge_patch(existing: Option<&UserAiPrefs>, patch: &UpdatePrefsRequest) -> (MergedPrefs, bool) {
let merged = MergedPrefs {
voice: tri_value(&patch.voice, existing, |e| e.voice.clone()),
library: tri_value(&patch.library, existing, |e| e.library.clone()),
display_tz: tri_value(&patch.display_tz, existing, |e| e.display_tz.clone()),
default_model: tri_value(&patch.default_model, existing, |e| e.default_model.clone()),
default_backend: tri_value(&patch.default_backend, existing, |e| {
e.default_backend.clone()
}),
default_persona_id: tri_value(&patch.default_persona_id, existing, |e| {
e.default_persona_id.clone()
}),
reel_span: tri_value(&patch.reel_span, existing, |e| e.reel_span.clone()),
reel_max_segments: tri_value(&patch.reel_max_segments, existing, |e| e.reel_max_segments),
};
let changed = !matches!(patch.voice, TriState::Absent)
|| !matches!(patch.library, TriState::Absent)
|| !matches!(patch.display_tz, TriState::Absent);
(voice, library, display_tz, changed)
|| !matches!(patch.display_tz, TriState::Absent)
|| !matches!(patch.default_model, TriState::Absent)
|| !matches!(patch.default_backend, TriState::Absent)
|| !matches!(patch.default_persona_id, TriState::Absent)
|| !matches!(patch.reel_span, TriState::Absent)
|| !matches!(patch.reel_max_segments, TriState::Absent);
(merged, changed)
}
fn row_to_response(row: Option<&UserAiPrefs>) -> PrefsResponse {
@@ -153,12 +235,22 @@ fn row_to_response(row: Option<&UserAiPrefs>) -> PrefsResponse {
voice: row.voice.clone(),
library: row.library.clone(),
display_tz: row.display_tz.clone(),
default_model: row.default_model.clone(),
default_backend: row.default_backend.clone(),
default_persona_id: row.default_persona_id.clone(),
reel_span: row.reel_span.clone(),
reel_max_segments: row.reel_max_segments,
updated_at: Some(row.updated_at),
},
None => PrefsResponse {
voice: None,
library: None,
display_tz: None,
default_model: None,
default_backend: None,
default_persona_id: None,
reel_span: None,
reel_max_segments: None,
updated_at: None,
},
}
@@ -192,7 +284,8 @@ pub async fn put_prefs(
) -> HttpResponse {
let patch = body.0;
// Validate before any write: an invalid zone name → 400, nothing written.
// Validate before any write: an invalid value → 400, nothing written
// (not even the other fields in the same body).
if let TriState::Val(name) = &patch.display_tz
&& !is_valid_display_tz(name)
{
@@ -200,6 +293,20 @@ pub async fn put_prefs(
"error": format!("invalid display_tz: {name}")
}));
}
if let TriState::Val(name) = &patch.default_backend
&& !is_valid_backend(name)
{
return HttpResponse::BadRequest().json(json!({
"error": format!("invalid default_backend: {name} (expected local|hybrid)")
}));
}
if let TriState::Val(name) = &patch.reel_span
&& MemoriesSpan::from_str(name).is_err()
{
return HttpResponse::BadRequest().json(json!({
"error": format!("invalid reel_span: {name} (expected day|week|month)")
}));
}
let mut dao = match app_state.user_ai_prefs_dao.lock() {
Ok(dao) => dao,
@@ -219,7 +326,7 @@ pub async fn put_prefs(
}
};
let (voice, library, display_tz, changed) = merge_patch(existing.as_ref(), &patch);
let (merged, changed) = merge_patch(existing.as_ref(), &patch);
if !changed {
// Empty body: no-op, return the current row without bumping updated_at.
return HttpResponse::Ok().json(row_to_response(existing.as_ref()));
@@ -231,18 +338,28 @@ pub async fn put_prefs(
.as_secs() as i64;
let resp = PrefsResponse {
voice: voice.clone(),
library: library.clone(),
display_tz: display_tz.clone(),
voice: merged.voice.clone(),
library: merged.library.clone(),
display_tz: merged.display_tz.clone(),
default_model: merged.default_model.clone(),
default_backend: merged.default_backend.clone(),
default_persona_id: merged.default_persona_id.clone(),
reel_span: merged.reel_span.clone(),
reel_max_segments: merged.reel_max_segments,
updated_at: Some(now),
};
let upsert = UpsertUserAiPrefs {
voice,
voice: merged.voice,
// Frozen legacy column: preserved verbatim, never written by this
// surface.
tz_offset_minutes: existing.as_ref().and_then(|e| e.tz_offset_minutes),
library,
display_tz,
library: merged.library,
display_tz: merged.display_tz,
default_model: merged.default_model,
default_backend: merged.default_backend,
default_persona_id: merged.default_persona_id,
reel_span: merged.reel_span,
reel_max_segments: merged.reel_max_segments,
updated_at: now,
};
match dao.upsert_prefs(&ctx, &upsert) {
@@ -311,6 +428,27 @@ mod tests {
library: library.map(str::to_string),
updated_at: 1_700_000_000,
display_tz: display_tz.map(str::to_string),
default_model: None,
default_backend: None,
default_persona_id: None,
reel_span: None,
reel_max_segments: None,
}
}
/// A row with owner defaults populated, for the owner-default tests.
fn seeded_row_with_defaults() -> UpsertUserAiPrefs {
UpsertUserAiPrefs {
voice: Some("grandma".to_string()),
tz_offset_minutes: Some(-240),
library: Some("1".to_string()),
updated_at: 1_700_000_000,
display_tz: Some("America/New_York".to_string()),
default_model: Some("llama3.2:3b".to_string()),
default_backend: Some("hybrid".to_string()),
default_persona_id: Some("cabin".to_string()),
reel_span: Some("week".to_string()),
reel_max_segments: Some(24),
}
}
@@ -363,6 +501,11 @@ mod tests {
"voice": null,
"library": null,
"display_tz": null,
"default_model": null,
"default_backend": null,
"default_persona_id": null,
"reel_span": null,
"reel_max_segments": null,
"updated_at": null
})
);
@@ -682,43 +825,242 @@ mod tests {
// --- merge_patch (pure) --------------------------------------------------
#[test]
fn merge_patch_omitted_keeps_existing() {
let row = UserAiPrefs {
fn row_with_defaults() -> UserAiPrefs {
UserAiPrefs {
id: 1,
voice: Some("a".into()),
tz_offset_minutes: None,
library: Some("b".into()),
updated_at: 0,
display_tz: Some("c".into()),
};
let (voice, library, display_tz, changed) =
merge_patch(Some(&row), &UpdatePrefsRequest::default());
assert_eq!(voice.as_deref(), Some("a"));
assert_eq!(library.as_deref(), Some("b"));
assert_eq!(display_tz.as_deref(), Some("c"));
default_model: Some("m1".into()),
default_backend: Some("local".into()),
default_persona_id: Some("p1".into()),
reel_span: Some("week".into()),
reel_max_segments: Some(24),
}
}
#[test]
fn merge_patch_omitted_keeps_existing() {
let row = row_with_defaults();
let (merged, changed) = merge_patch(Some(&row), &UpdatePrefsRequest::default());
assert_eq!(merged.voice.as_deref(), Some("a"));
assert_eq!(merged.library.as_deref(), Some("b"));
assert_eq!(merged.display_tz.as_deref(), Some("c"));
assert_eq!(merged.default_model.as_deref(), Some("m1"));
assert_eq!(merged.default_backend.as_deref(), Some("local"));
assert_eq!(merged.default_persona_id.as_deref(), Some("p1"));
assert_eq!(merged.reel_span.as_deref(), Some("week"));
assert_eq!(merged.reel_max_segments, Some(24));
assert!(!changed);
}
#[test]
fn merge_patch_present_field_wins_including_null() {
let row = UserAiPrefs {
id: 1,
voice: Some("a".into()),
tz_offset_minutes: None,
library: None,
updated_at: 0,
display_tz: None,
};
let row = row_with_defaults();
let patch = UpdatePrefsRequest {
voice: TriState::Null, // clear
library: TriState::Val(String::from("x")), // set
display_tz: TriState::Absent,
default_backend: TriState::Null, // clear an owner default
reel_span: TriState::Val(String::from("month")),
reel_max_segments: TriState::Val(12),
..UpdatePrefsRequest::default()
};
let (voice, library, display_tz, changed) = merge_patch(Some(&row), &patch);
assert!(voice.is_none());
assert_eq!(library.as_deref(), Some("x"));
assert!(display_tz.is_none());
let (merged, changed) = merge_patch(Some(&row), &patch);
assert!(merged.voice.is_none());
assert_eq!(merged.library.as_deref(), Some("x"));
// Absent field keeps the stored value.
assert_eq!(merged.display_tz.as_deref(), Some("c"));
assert!(merged.default_backend.is_none());
assert_eq!(merged.reel_span.as_deref(), Some("month"));
assert_eq!(merged.reel_max_segments, Some(12));
// Untouched owner defaults survive.
assert_eq!(merged.default_model.as_deref(), Some("m1"));
assert_eq!(merged.default_persona_id.as_deref(), Some("p1"));
assert!(changed);
}
// --- PUT /prefs: owner defaults ------------------------------------------
#[actix_rt::test]
async fn put_prefs_owner_defaults_set_and_persist() {
let (state, mut seed_dao) = prefs_state();
seed(
&mut seed_dao,
seeded_row(Some("grandma"), Some("1"), Some("America/New_York")),
);
let app = init_service(
App::new()
.service(
web::resource("/prefs")
.route(web::get().to(get_prefs))
.route(web::put().to(put_prefs)),
)
.service(web::resource("/timezones").route(web::get().to(list_timezones)))
.app_data(state.clone()),
)
.await;
let (header, value) = auth();
let req = TestRequest::put()
.uri("/prefs")
.insert_header((header, value))
.set_json(serde_json::json!({
"default_model": "llama3.2:3b",
"default_backend": "hybrid",
"default_persona_id": "cabin",
"reel_span": "week",
"reel_max_segments": 24
}))
.to_request();
let resp = call_service(&app, req).await;
assert_eq!(resp.status(), 200);
let mut dao = state.user_ai_prefs_dao.lock().unwrap();
let row = dao
.get_prefs(&opentelemetry::Context::new())
.unwrap()
.unwrap();
assert_eq!(row.default_model.as_deref(), Some("llama3.2:3b"));
assert_eq!(row.default_backend.as_deref(), Some("hybrid"));
assert_eq!(row.default_persona_id.as_deref(), Some("cabin"));
assert_eq!(row.reel_span.as_deref(), Some("week"));
assert_eq!(row.reel_max_segments, Some(24));
// Mirror fields untouched by the owner-default patch.
assert_eq!(row.voice.as_deref(), Some("grandma"));
assert_eq!(row.library.as_deref(), Some("1"));
}
#[actix_rt::test]
async fn put_prefs_owner_defaults_null_clears() {
let (state, mut seed_dao) = prefs_state();
seed(&mut seed_dao, seeded_row_with_defaults());
let app = init_service(
App::new()
.service(
web::resource("/prefs")
.route(web::get().to(get_prefs))
.route(web::put().to(put_prefs)),
)
.service(web::resource("/timezones").route(web::get().to(list_timezones)))
.app_data(state.clone()),
)
.await;
let (header, value) = auth();
let req = TestRequest::put()
.uri("/prefs")
.insert_header((header, value))
.set_json(serde_json::json!({
"default_model": null,
"default_backend": null,
"default_persona_id": null,
"reel_span": null,
"reel_max_segments": null
}))
.to_request();
let resp = call_service(&app, req).await;
assert_eq!(resp.status(), 200);
let mut dao = state.user_ai_prefs_dao.lock().unwrap();
let row = dao
.get_prefs(&opentelemetry::Context::new())
.unwrap()
.unwrap();
assert!(row.default_model.is_none());
assert!(row.default_backend.is_none());
assert!(row.default_persona_id.is_none());
assert!(row.reel_span.is_none());
assert!(row.reel_max_segments.is_none());
// Sibling fields survive a clear patch.
assert_eq!(row.voice.as_deref(), Some("grandma"));
}
#[actix_rt::test]
async fn put_prefs_invalid_backend_returns_400_and_writes_nothing() {
let (state, mut seed_dao) = prefs_state();
seed(
&mut seed_dao,
seeded_row(Some("grandma"), Some("1"), Some("America/New_York")),
);
let app = init_service(
App::new()
.service(
web::resource("/prefs")
.route(web::get().to(get_prefs))
.route(web::put().to(put_prefs)),
)
.service(web::resource("/timezones").route(web::get().to(list_timezones)))
.app_data(state.clone()),
)
.await;
let (header, value) = auth();
let req = TestRequest::put()
.uri("/prefs")
.insert_header((header, value))
.set_json(serde_json::json!({"default_backend": "quantum", "voice": "dad"}))
.to_request();
let resp = call_service(&app, req).await;
assert_eq!(resp.status(), 400);
let mut dao = state.user_ai_prefs_dao.lock().unwrap();
let row = dao
.get_prefs(&opentelemetry::Context::new())
.unwrap()
.unwrap();
// Nothing written — not even the valid field in the same body.
assert_eq!(row.voice.as_deref(), Some("grandma"));
assert!(row.default_backend.is_none());
assert_eq!(row.updated_at, 1_700_000_000);
}
#[test]
fn backend_validator_delegates_to_canonical_parser() {
// Case-insensitive like BackendKind::parse (a stored pref must always
// be consumable downstream), but the blank string the parser tolerates
// for request fields is rejected — NULL already means "no default".
assert!(is_valid_backend("local"));
assert!(is_valid_backend("hybrid"));
assert!(is_valid_backend("Hybrid"));
assert!(is_valid_backend(" LOCAL "));
assert!(!is_valid_backend("quantum"));
assert!(!is_valid_backend(""));
assert!(!is_valid_backend(" "));
}
#[actix_rt::test]
async fn put_prefs_invalid_reel_span_returns_400_and_writes_nothing() {
let (state, mut seed_dao) = prefs_state();
seed(
&mut seed_dao,
seeded_row(Some("grandma"), Some("1"), Some("America/New_York")),
);
let app = init_service(
App::new()
.service(
web::resource("/prefs")
.route(web::get().to(get_prefs))
.route(web::put().to(put_prefs)),
)
.service(web::resource("/timezones").route(web::get().to(list_timezones)))
.app_data(state.clone()),
)
.await;
let (header, value) = auth();
let req = TestRequest::put()
.uri("/prefs")
.insert_header((header, value))
.set_json(serde_json::json!({"reel_span": "decade"}))
.to_request();
let resp = call_service(&app, req).await;
assert_eq!(resp.status(), 400);
let mut dao = state.user_ai_prefs_dao.lock().unwrap();
let row = dao
.get_prefs(&opentelemetry::Context::new())
.unwrap()
.unwrap();
assert!(row.reel_span.is_none());
assert_eq!(row.updated_at, 1_700_000_000);
}
}