Skip to main content

bhtune_db/models/
demo_sessions.rs

1use chrono::{DateTime, Utc};
2use sqlx::{Row, SqlitePool, sqlite::SqliteRow};
3
4use crate::error::{DbError, DbResult};
5
6// demo_sessions {{{1
7
8/// Failure reason persisted when startup recovery terminates an owned demo run that was still
9/// marked as running.
10pub const DEMO_RESTART_INTERRUPTED_REASON: &str = "demo run was interrupted by a server restart";
11
12/// A short-lived anonymous owner for simulator-only public demo runs. The raw bearer token is
13/// never stored; callers pass its lowercase hexadecimal SHA-256 hash to the repository.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct DemoSessionRow {
16    pub id: i64,
17    pub token_hash: String,
18    pub created_at: DateTime<Utc>,
19    pub last_seen_at: DateTime<Utc>,
20    pub expires_at: DateTime<Utc>,
21    pub revoked_at: Option<DateTime<Utc>>,
22}
23
24impl DemoSessionRow {
25    /// Persists a session on first use, or returns the already-persisted valid row when another
26    /// request won the same token-hash race.
27    pub async fn create(
28        pool: &SqlitePool,
29        token_hash: &str,
30        now: DateTime<Utc>,
31        expires_at: DateTime<Utc>,
32    ) -> DbResult<Self> {
33        Self::get_or_create(pool, token_hash, now, expires_at).await
34    }
35
36    /// Lazily persists a token hash on first use. Concurrent callers presenting the same token
37    /// converge on one row: the unique-token loser reloads the winner's valid row rather than
38    /// surfacing a uniqueness error.
39    ///
40    /// An expired or revoked conflicting row remains authoritative and is returned as
41    /// `RowNotFound`; it is never revived and its fixed expiry is never extended.
42    pub async fn get_or_create(
43        pool: &SqlitePool,
44        token_hash: &str,
45        now: DateTime<Utc>,
46        expires_at: DateTime<Utc>,
47    ) -> DbResult<Self> {
48        let inserted = sqlx::query(
49            "INSERT INTO demo_sessions (token_hash, created_at, last_seen_at, expires_at) \
50             VALUES (?, ?, ?, ?) \
51             ON CONFLICT(token_hash) DO NOTHING \
52             RETURNING *",
53        )
54        .bind(token_hash)
55        .bind(now)
56        .bind(now)
57        .bind(expires_at)
58        .fetch_optional(pool)
59        .await
60        .map_err(DbError::Query)?;
61
62        match inserted {
63            Some(row) => row_to_demo_session(row),
64            None => Self::get_by_token_hash(pool, token_hash, now)
65                .await?
66                .ok_or_else(|| DbError::Query(sqlx::Error::RowNotFound)),
67        }
68    }
69
70    /// Looks up an authorization session and atomically records its latest activity. Expired
71    /// and revoked rows are deliberately indistinguishable from a missing row, and the fixed
72    /// expiry is never extended.
73    pub async fn get_by_token_hash(
74        pool: &SqlitePool,
75        token_hash: &str,
76        now: DateTime<Utc>,
77    ) -> DbResult<Option<Self>> {
78        let row = sqlx::query(
79            "UPDATE demo_sessions \
80             SET last_seen_at = MAX(last_seen_at, ?) \
81             WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ? \
82             RETURNING *",
83        )
84        .bind(now)
85        .bind(token_hash)
86        .bind(now)
87        .fetch_optional(pool)
88        .await
89        .map_err(DbError::Query)?;
90        row.map(row_to_demo_session).transpose()
91    }
92
93    /// Records activity only while the session remains valid. `expires_at` is never changed:
94    /// demo sessions have an absolute lifetime, not a sliding one.
95    pub async fn touch_by_token_hash(
96        pool: &SqlitePool,
97        token_hash: &str,
98        now: DateTime<Utc>,
99    ) -> DbResult<Option<Self>> {
100        Self::get_by_token_hash(pool, token_hash, now).await
101    }
102
103    pub async fn revoke(pool: &SqlitePool, id: i64, now: DateTime<Utc>) -> DbResult<bool> {
104        let result = sqlx::query(
105            "UPDATE demo_sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL",
106        )
107        .bind(now)
108        .bind(id)
109        .execute(pool)
110        .await
111        .map_err(DbError::Query)?;
112        Ok(result.rows_affected() != 0)
113    }
114
115    /// Removes expired/revoked sessions and cascades their terminal owned history.
116    ///
117    /// A session owning a running run is protected even after expiry or revocation. Startup
118    /// recovery must first mark that run failed via [`Self::recover_running_demo_runs`]; only a
119    /// later cleanup can remove the session and its now-terminal history.
120    pub async fn cleanup_expired(pool: &SqlitePool, now: DateTime<Utc>) -> DbResult<u64> {
121        let result = sqlx::query(
122            "DELETE FROM demo_sessions \
123             WHERE (expires_at <= ? OR revoked_at IS NOT NULL) \
124               AND NOT EXISTS ( \
125                   SELECT 1 FROM tune_runs \
126                   WHERE tune_runs.demo_session_id = demo_sessions.id \
127                     AND tune_runs.outcome = 'running' \
128               )",
129        )
130        .bind(now)
131        .execute(pool)
132        .await
133        .map_err(DbError::Query)?;
134        Ok(result.rows_affected())
135    }
136
137    /// Marks demo runs left in `running` state by a process restart as failed. Demo work is
138    /// intentionally not resumed after a crash or restart.
139    pub async fn recover_running_demo_runs(pool: &SqlitePool, now: DateTime<Utc>) -> DbResult<u64> {
140        let result = sqlx::query(
141            "UPDATE tune_runs SET outcome = 'failed', completed_at = ?, \
142             failure_reason = ? \
143             WHERE demo_session_id IS NOT NULL AND outcome = 'running'",
144        )
145        .bind(now)
146        .bind(DEMO_RESTART_INTERRUPTED_REASON)
147        .execute(pool)
148        .await
149        .map_err(DbError::Query)?;
150        Ok(result.rows_affected())
151    }
152}
153
154fn row_to_demo_session(row: SqliteRow) -> DbResult<DemoSessionRow> {
155    Ok(DemoSessionRow {
156        id: row.try_get("id").map_err(DbError::Query)?,
157        token_hash: row.try_get("token_hash").map_err(DbError::Query)?,
158        created_at: row.try_get("created_at").map_err(DbError::Query)?,
159        last_seen_at: row.try_get("last_seen_at").map_err(DbError::Query)?,
160        expires_at: row.try_get("expires_at").map_err(DbError::Query)?,
161        revoked_at: row.try_get("revoked_at").map_err(DbError::Query)?,
162    })
163}