Skip to main content

bhtune_db/
models.rs

1//! Row types mirroring the tables in `migrations/0001_initial_schema.sql`.
2//!
3//! These are deliberately *typed*, not raw-column, shapes: wherever a table's columns are a
4//! clean 1:1 match for an existing `bhtune-core` type (`DcsTemplate`, `LoopTags`,
5//! `LoopConfig`, `Tick` + `MrftState`), the row struct holds that type directly rather than
6//! re-declaring its fields — one less place for the two to drift apart. Where a table
7//! combines fields from two `bhtune-core` types that both carry their own overlapping field
8//! (`TuningResult`/`PidParameters` both carry `response_level`; `InitialReadings`/`PvRange`
9//! don't nest cleanly with the extra `controller_direction` column), the row struct is flat
10//! instead, matching the table exactly and avoiding a redundant, only-sometimes-consistent
11//! duplicate field.
12//!
13//! [`DemoSessionRow`], [`DcsTemplateRow`], and
14//! [`TuneRunRow`]/[`TuneSampleRow`]/[`TuneResultRow`]/
15//! [`TuneMvActuationRow`]/[`TuneWriteRow`] have full repository methods (insert, lifecycle
16//! transitions, filtering, pagination) — covering `db-seed-templates` and
17//! `history-query-api`. [`LoopRow`] deliberately has none yet: full CRUD for saved loops
18//! (list/update/delete) is a separate "loop management" concern from history (which is about
19//! *runs*, not the loops they reference), left to whichever future todo actually needs it.
20//! Until then, tests construct `loops` rows with raw SQL (see `tests/schema.rs`'s `seed_loop`
21//! helper) purely as foreign-key setup.
22//!
23//! [`TuneRunRow::list`]/[`TuneRunRow::count`] build their `WHERE` clause dynamically with
24//! `sqlx::QueryBuilder`, since [`TuneRunFilter`]'s fields are all optional and the set of
25//! active conditions varies per call — a fixed `query!` string can't express that, and
26//! `bhtune-db` uses runtime `query`/`query_as` throughout anyway (see `Cargo.toml`), so this
27//! doesn't introduce a new query style, just the first dynamic one.
28
29use bhtune_core::{
30    ControllerDirection, ControllerType, DcsTemplate, LoopConfig, LoopTags, MrftState, ProcessType,
31    ResponseLevel, Tick,
32    tuning_math::{
33        CheckedTuningResult, PidParameters, TuningResult, TuningResultInvalidReason,
34        TuningResultStatus,
35    },
36};
37use chrono::{DateTime, Utc};
38use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool, sqlite::SqliteRow};
39
40use crate::{
41    convert::{enum_to_text, text_to_enum},
42    error::{DbError, DbResult},
43};
44
45// demo_sessions {{{1
46
47/// Failure reason persisted when startup recovery terminates an owned demo run that was still
48/// marked as running.
49pub const DEMO_RESTART_INTERRUPTED_REASON: &str = "demo run was interrupted by a server restart";
50
51/// A short-lived anonymous owner for simulator-only public demo runs. The raw bearer token is
52/// never stored; callers pass its lowercase hexadecimal SHA-256 hash to the repository.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct DemoSessionRow {
55    pub id: i64,
56    pub token_hash: String,
57    pub created_at: DateTime<Utc>,
58    pub last_seen_at: DateTime<Utc>,
59    pub expires_at: DateTime<Utc>,
60    pub revoked_at: Option<DateTime<Utc>>,
61}
62
63impl DemoSessionRow {
64    /// Persists a session on first use, or returns the already-persisted valid row when another
65    /// request won the same token-hash race.
66    pub async fn create(
67        pool: &SqlitePool,
68        token_hash: &str,
69        now: DateTime<Utc>,
70        expires_at: DateTime<Utc>,
71    ) -> DbResult<Self> {
72        Self::get_or_create(pool, token_hash, now, expires_at).await
73    }
74
75    /// Lazily persists a token hash on first use. Concurrent callers presenting the same token
76    /// converge on one row: the unique-token loser reloads the winner's valid row rather than
77    /// surfacing a uniqueness error.
78    ///
79    /// An expired or revoked conflicting row remains authoritative and is returned as
80    /// `RowNotFound`; it is never revived and its fixed expiry is never extended.
81    pub async fn get_or_create(
82        pool: &SqlitePool,
83        token_hash: &str,
84        now: DateTime<Utc>,
85        expires_at: DateTime<Utc>,
86    ) -> DbResult<Self> {
87        let inserted = sqlx::query(
88            "INSERT INTO demo_sessions (token_hash, created_at, last_seen_at, expires_at) \
89             VALUES (?, ?, ?, ?) \
90             ON CONFLICT(token_hash) DO NOTHING \
91             RETURNING *",
92        )
93        .bind(token_hash)
94        .bind(now)
95        .bind(now)
96        .bind(expires_at)
97        .fetch_optional(pool)
98        .await
99        .map_err(DbError::Query)?;
100
101        match inserted {
102            Some(row) => row_to_demo_session(row),
103            None => Self::get_by_token_hash(pool, token_hash, now)
104                .await?
105                .ok_or_else(|| DbError::Query(sqlx::Error::RowNotFound)),
106        }
107    }
108
109    /// Looks up an authorization session and atomically records its latest activity. Expired
110    /// and revoked rows are deliberately indistinguishable from a missing row, and the fixed
111    /// expiry is never extended.
112    pub async fn get_by_token_hash(
113        pool: &SqlitePool,
114        token_hash: &str,
115        now: DateTime<Utc>,
116    ) -> DbResult<Option<Self>> {
117        let row = sqlx::query(
118            "UPDATE demo_sessions \
119             SET last_seen_at = MAX(last_seen_at, ?) \
120             WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ? \
121             RETURNING *",
122        )
123        .bind(now)
124        .bind(token_hash)
125        .bind(now)
126        .fetch_optional(pool)
127        .await
128        .map_err(DbError::Query)?;
129        row.map(row_to_demo_session).transpose()
130    }
131
132    /// Records activity only while the session remains valid. `expires_at` is never changed:
133    /// demo sessions have an absolute lifetime, not a sliding one.
134    pub async fn touch_by_token_hash(
135        pool: &SqlitePool,
136        token_hash: &str,
137        now: DateTime<Utc>,
138    ) -> DbResult<Option<Self>> {
139        Self::get_by_token_hash(pool, token_hash, now).await
140    }
141
142    pub async fn revoke(pool: &SqlitePool, id: i64, now: DateTime<Utc>) -> DbResult<bool> {
143        let result = sqlx::query(
144            "UPDATE demo_sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL",
145        )
146        .bind(now)
147        .bind(id)
148        .execute(pool)
149        .await
150        .map_err(DbError::Query)?;
151        Ok(result.rows_affected() != 0)
152    }
153
154    /// Removes expired/revoked sessions and cascades their terminal owned history.
155    ///
156    /// A session owning a running run is protected even after expiry or revocation. Startup
157    /// recovery must first mark that run failed via [`Self::recover_running_demo_runs`]; only a
158    /// later cleanup can remove the session and its now-terminal history.
159    pub async fn cleanup_expired(pool: &SqlitePool, now: DateTime<Utc>) -> DbResult<u64> {
160        let result = sqlx::query(
161            "DELETE FROM demo_sessions \
162             WHERE (expires_at <= ? OR revoked_at IS NOT NULL) \
163               AND NOT EXISTS ( \
164                   SELECT 1 FROM tune_runs \
165                   WHERE tune_runs.demo_session_id = demo_sessions.id \
166                     AND tune_runs.outcome = 'running' \
167               )",
168        )
169        .bind(now)
170        .execute(pool)
171        .await
172        .map_err(DbError::Query)?;
173        Ok(result.rows_affected())
174    }
175
176    /// Marks demo runs left in `running` state by a process restart as failed. Demo work is
177    /// intentionally not resumed after a crash or restart.
178    pub async fn recover_running_demo_runs(pool: &SqlitePool, now: DateTime<Utc>) -> DbResult<u64> {
179        let result = sqlx::query(
180            "UPDATE tune_runs SET outcome = 'failed', completed_at = ?, \
181             failure_reason = ? \
182             WHERE demo_session_id IS NOT NULL AND outcome = 'running'",
183        )
184        .bind(now)
185        .bind(DEMO_RESTART_INTERRUPTED_REASON)
186        .execute(pool)
187        .await
188        .map_err(DbError::Query)?;
189        Ok(result.rows_affected())
190    }
191}
192
193fn row_to_demo_session(row: SqliteRow) -> DbResult<DemoSessionRow> {
194    Ok(DemoSessionRow {
195        id: row.try_get("id").map_err(DbError::Query)?,
196        token_hash: row.try_get("token_hash").map_err(DbError::Query)?,
197        created_at: row.try_get("created_at").map_err(DbError::Query)?,
198        last_seen_at: row.try_get("last_seen_at").map_err(DbError::Query)?,
199        expires_at: row.try_get("expires_at").map_err(DbError::Query)?,
200        revoked_at: row.try_get("revoked_at").map_err(DbError::Query)?,
201    })
202}
203
204// dcs_templates {{{1
205
206/// Where a `dcs_templates` row came from, and -- since [`TuneRunRow`] snapshots a copy of one
207/// at [`TuneRunRow::start`] time -- where a run's snapshotted template came from too. Kept as
208/// one definition reused by both tables (`dcs_templates.origin`, `tune_runs.template_origin`)
209/// rather than two, so they can never drift on what the possible origins even are, and so a
210/// run's history never needs to look the original row back up to know its provenance --
211/// which matters precisely because that row might no longer exist, or might have been
212/// re-imported under a different origin since.
213///
214/// `builtin` and `catalog` rows are re-upserted from their respective data files on every
215/// startup ([`crate::seed::seed_templates`]); `user` rows (hand-imported via `bhtune template
216/// import`, or created through a future GUI editor) are never auto-touched -- auto-reseeding
217/// a hand-edited row would silently discard someone's own customization, while *not*
218/// reseeding a shipped preset would mean a suffix/unit fix in a later release never reaches
219/// existing installs.
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
221#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
222#[serde(rename_all = "snake_case")]
223pub enum TemplateOrigin {
224    /// One of the templates `bhtune-core` ships embedded in its own binary (see
225    /// `template-catalog`).
226    Builtin,
227    /// Loaded from a user-supplied catalog file (`$XDG_CONFIG_HOME/bhtune/templates.toml`
228    /// and platform equivalents, or an explicit `--templates`/`BHTUNE_TEMPLATES` override --
229    /// see `template-user-catalog`), auto-seeded on every startup the same way `Builtin`
230    /// rows are.
231    Catalog,
232    /// Hand-imported (`bhtune template import`) or otherwise created by whoever is running
233    /// bhtune.
234    User,
235}
236
237/// One row of `dcs_templates`: a [`DcsTemplate`] plus the database bookkeeping fields that
238/// don't belong on the pure domain type itself.
239#[derive(Debug, Clone, PartialEq)]
240pub struct DcsTemplateRow {
241    pub id: i64,
242    pub origin: TemplateOrigin,
243    pub template: DcsTemplate,
244    pub created_at: DateTime<Utc>,
245    pub updated_at: DateTime<Utc>,
246}
247
248impl DcsTemplateRow {
249    /// Inserts `template`, returning the persisted row (with its assigned `id`). `now` is
250    /// used for both `created_at` and `updated_at`; the caller supplies it rather than this
251    /// function reading the clock, keeping "who reads the clock" consistent with the rest of
252    /// bhtune's architecture (see `bhtune_core`'s crate docs).
253    pub async fn insert(
254        pool: &SqlitePool,
255        template: &DcsTemplate,
256        origin: TemplateOrigin,
257        now: DateTime<Utc>,
258    ) -> DbResult<DcsTemplateRow> {
259        let versions_json = serde_json::to_string(&template.versions)
260            .expect("Vec<String> serialization is infallible");
261        let row = sqlx::query(
262            r#"
263            INSERT INTO dcs_templates (
264                name, origin, revert_mode, proportional_type, integral_type,
265                integral_unit, derivative_type, derivative_unit,
266                process_variable_suffix, manipulated_variable_suffix, setpoint_variable_suffix,
267                controller_direction_suffix, controller_mode_suffix, mode_attribute_suffix,
268                upper_pv_range_suffix, lower_pv_range_suffix, upper_mv_range_suffix,
269                lower_mv_range_suffix, proportional_constant_suffix, integral_constant_suffix,
270                derivative_constant_suffix, mode_manual_value, mode_auto_value,
271                mode_attribute_program_value, controller_action_direct_value,
272                versions_json, description, source,
273                created_at, updated_at
274            )
275            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
276            RETURNING *
277            "#,
278        )
279        .bind(&template.name)
280        .bind(enum_to_text(&origin))
281        .bind(template.revert_mode)
282        .bind(enum_to_text(&template.proportional_type))
283        .bind(enum_to_text(&template.integral_type))
284        .bind(enum_to_text(&template.integral_unit))
285        .bind(enum_to_text(&template.derivative_type))
286        .bind(enum_to_text(&template.derivative_unit))
287        .bind(&template.process_variable_suffix)
288        .bind(&template.manipulated_variable_suffix)
289        .bind(&template.setpoint_variable_suffix)
290        .bind(&template.controller_direction_suffix)
291        .bind(&template.controller_mode_suffix)
292        .bind(&template.mode_attribute_suffix)
293        .bind(&template.upper_pv_range_suffix)
294        .bind(&template.lower_pv_range_suffix)
295        .bind(&template.upper_mv_range_suffix)
296        .bind(&template.lower_mv_range_suffix)
297        .bind(&template.proportional_constant_suffix)
298        .bind(&template.integral_constant_suffix)
299        .bind(&template.derivative_constant_suffix)
300        .bind(&template.mode_manual_value)
301        .bind(&template.mode_auto_value)
302        .bind(&template.mode_attribute_program_value)
303        .bind(&template.controller_action_direct_value)
304        .bind(versions_json)
305        .bind(&template.description)
306        .bind(&template.source)
307        .bind(now)
308        .bind(now)
309        .fetch_one(pool)
310        .await
311        .map_err(DbError::Query)?;
312
313        row_to_dcs_template(row)
314    }
315
316    /// Fetches one row by id, or `None` if it doesn't exist.
317    pub async fn get(pool: &SqlitePool, id: i64) -> DbResult<Option<DcsTemplateRow>> {
318        let row = sqlx::query("SELECT * FROM dcs_templates WHERE id = ?")
319            .bind(id)
320            .fetch_optional(pool)
321            .await
322            .map_err(DbError::Query)?;
323        row.map(row_to_dcs_template).transpose()
324    }
325
326    /// Fetches one row by its (unique) `name`, or `None` if it doesn't exist. Used by
327    /// [`crate::seed::seed_templates`] to find any existing row before deciding whether to
328    /// insert or update.
329    pub async fn get_by_name(pool: &SqlitePool, name: &str) -> DbResult<Option<DcsTemplateRow>> {
330        let row = sqlx::query("SELECT * FROM dcs_templates WHERE name = ?")
331            .bind(name)
332            .fetch_optional(pool)
333            .await
334            .map_err(DbError::Query)?;
335        row.map(row_to_dcs_template).transpose()
336    }
337
338    /// Lists every row, ordered by `name`, for the loop-editor's template picker.
339    pub async fn list(pool: &SqlitePool) -> DbResult<Vec<DcsTemplateRow>> {
340        let rows = sqlx::query("SELECT * FROM dcs_templates ORDER BY name")
341            .fetch_all(pool)
342            .await
343            .map_err(DbError::Query)?;
344        rows.into_iter().map(row_to_dcs_template).collect()
345    }
346
347    /// Overwrites every template field of the row at `id` with `template`'s, bumping
348    /// `updated_at` to `now`. Deliberately does not touch `name` (the match key callers
349    /// already looked the row up by) or `origin` (ownership of a row never changes after
350    /// creation) — only [`Self::insert`] sets those.
351    pub async fn update(
352        pool: &SqlitePool,
353        id: i64,
354        template: &DcsTemplate,
355        now: DateTime<Utc>,
356    ) -> DbResult<DcsTemplateRow> {
357        let versions_json = serde_json::to_string(&template.versions)
358            .expect("Vec<String> serialization is infallible");
359        let row = sqlx::query(
360            r#"
361            UPDATE dcs_templates SET
362                revert_mode = ?, proportional_type = ?, integral_type = ?,
363                integral_unit = ?, derivative_type = ?, derivative_unit = ?,
364                process_variable_suffix = ?, manipulated_variable_suffix = ?,
365                setpoint_variable_suffix = ?, controller_direction_suffix = ?,
366                controller_mode_suffix = ?, mode_attribute_suffix = ?,
367                upper_pv_range_suffix = ?, lower_pv_range_suffix = ?,
368                upper_mv_range_suffix = ?, lower_mv_range_suffix = ?,
369                proportional_constant_suffix = ?, integral_constant_suffix = ?,
370                derivative_constant_suffix = ?, mode_manual_value = ?, mode_auto_value = ?,
371                mode_attribute_program_value = ?, controller_action_direct_value = ?,
372                versions_json = ?, description = ?, source = ?,
373                updated_at = ?
374            WHERE id = ?
375            RETURNING *
376            "#,
377        )
378        .bind(template.revert_mode)
379        .bind(enum_to_text(&template.proportional_type))
380        .bind(enum_to_text(&template.integral_type))
381        .bind(enum_to_text(&template.integral_unit))
382        .bind(enum_to_text(&template.derivative_type))
383        .bind(enum_to_text(&template.derivative_unit))
384        .bind(&template.process_variable_suffix)
385        .bind(&template.manipulated_variable_suffix)
386        .bind(&template.setpoint_variable_suffix)
387        .bind(&template.controller_direction_suffix)
388        .bind(&template.controller_mode_suffix)
389        .bind(&template.mode_attribute_suffix)
390        .bind(&template.upper_pv_range_suffix)
391        .bind(&template.lower_pv_range_suffix)
392        .bind(&template.upper_mv_range_suffix)
393        .bind(&template.lower_mv_range_suffix)
394        .bind(&template.proportional_constant_suffix)
395        .bind(&template.integral_constant_suffix)
396        .bind(&template.derivative_constant_suffix)
397        .bind(&template.mode_manual_value)
398        .bind(&template.mode_auto_value)
399        .bind(&template.mode_attribute_program_value)
400        .bind(&template.controller_action_direct_value)
401        .bind(versions_json)
402        .bind(&template.description)
403        .bind(&template.source)
404        .bind(now)
405        .bind(id)
406        .fetch_one(pool)
407        .await
408        .map_err(DbError::Query)?;
409
410        row_to_dcs_template(row)
411    }
412
413    /// Deletes the row at `id`. Returns `Ok(true)` if a row existed and was removed,
414    /// `Ok(false)` if no row with that id existed (not an error -- deciding whether "nothing
415    /// to delete" should itself be an error is the caller's call; `bhtune-cli`'s `template
416    /// delete` already resolves `id` from a name via [`Self::get_by_name`] and produces its
417    /// own "no template named" error before ever calling this).
418    ///
419    /// Fails with [`DbError::TemplateInUse`] if `loops.dcs_template_id`'s `ON DELETE
420    /// RESTRICT` foreign key rejects the delete because a saved loop still references this
421    /// template. Classified as "any database-level error on this specific statement",
422    /// rather than solely `sqlx`'s `DatabaseError::is_foreign_key_violation()` -- confirmed
423    /// empirically that SQLite's C implementation reports an *immediate* `RESTRICT`
424    /// violation like this one under the extended result code `SQLITE_CONSTRAINT_TRIGGER`
425    /// (its FK-action enforcement runs through the same internal machinery as a trigger
426    /// body), not `SQLITE_CONSTRAINT_FOREIGNKEY` -- the latter is what `sqlx-sqlite` maps to
427    /// `is_foreign_key_violation()`, and it's only what a *deferred* FK check reports at
428    /// commit time, which this crate never uses. This is safe to broaden to "any database
429    /// error" specifically because `dcs_templates` has exactly one foreign key pointing at
430    /// it (`loops.dcs_template_id`), no triggers exist anywhere in the schema, and a bare
431    /// `DELETE` can't violate this table's own `CHECK` constraints (they all apply to
432    /// column values, which a delete-by-id never touches) -- so a database-level failure of
433    /// this exact statement structurally has only one possible cause.
434    pub async fn delete(pool: &SqlitePool, id: i64) -> DbResult<bool> {
435        let result = sqlx::query("DELETE FROM dcs_templates WHERE id = ?")
436            .bind(id)
437            .execute(pool)
438            .await
439            .map_err(|e| {
440                if e.as_database_error().is_some() {
441                    DbError::TemplateInUse { id }
442                } else {
443                    DbError::Query(e)
444                }
445            })?;
446        Ok(result.rows_affected() > 0)
447    }
448}
449
450fn row_to_dcs_template(row: SqliteRow) -> DbResult<DcsTemplateRow> {
451    let get_enum = |column: &'static str| -> DbResult<String> {
452        row.try_get::<String, _>(column).map_err(DbError::Query)
453    };
454
455    let template = DcsTemplate {
456        name: row.try_get("name").map_err(DbError::Query)?,
457        revert_mode: row.try_get("revert_mode").map_err(DbError::Query)?,
458        proportional_type: text_to_enum("proportional_type", &get_enum("proportional_type")?)?,
459        integral_type: text_to_enum("integral_type", &get_enum("integral_type")?)?,
460        integral_unit: text_to_enum("integral_unit", &get_enum("integral_unit")?)?,
461        derivative_type: text_to_enum("derivative_type", &get_enum("derivative_type")?)?,
462        derivative_unit: text_to_enum("derivative_unit", &get_enum("derivative_unit")?)?,
463        process_variable_suffix: row
464            .try_get("process_variable_suffix")
465            .map_err(DbError::Query)?,
466        manipulated_variable_suffix: row
467            .try_get("manipulated_variable_suffix")
468            .map_err(DbError::Query)?,
469        setpoint_variable_suffix: row
470            .try_get("setpoint_variable_suffix")
471            .map_err(DbError::Query)?,
472        controller_direction_suffix: row
473            .try_get("controller_direction_suffix")
474            .map_err(DbError::Query)?,
475        controller_mode_suffix: row
476            .try_get("controller_mode_suffix")
477            .map_err(DbError::Query)?,
478        mode_attribute_suffix: row
479            .try_get("mode_attribute_suffix")
480            .map_err(DbError::Query)?,
481        upper_pv_range_suffix: row
482            .try_get("upper_pv_range_suffix")
483            .map_err(DbError::Query)?,
484        lower_pv_range_suffix: row
485            .try_get("lower_pv_range_suffix")
486            .map_err(DbError::Query)?,
487        upper_mv_range_suffix: row
488            .try_get("upper_mv_range_suffix")
489            .map_err(DbError::Query)?,
490        lower_mv_range_suffix: row
491            .try_get("lower_mv_range_suffix")
492            .map_err(DbError::Query)?,
493        proportional_constant_suffix: row
494            .try_get("proportional_constant_suffix")
495            .map_err(DbError::Query)?,
496        integral_constant_suffix: row
497            .try_get("integral_constant_suffix")
498            .map_err(DbError::Query)?,
499        derivative_constant_suffix: row
500            .try_get("derivative_constant_suffix")
501            .map_err(DbError::Query)?,
502        mode_manual_value: row.try_get("mode_manual_value").map_err(DbError::Query)?,
503        mode_auto_value: row.try_get("mode_auto_value").map_err(DbError::Query)?,
504        mode_attribute_program_value: row
505            .try_get("mode_attribute_program_value")
506            .map_err(DbError::Query)?,
507        controller_action_direct_value: row
508            .try_get("controller_action_direct_value")
509            .map_err(DbError::Query)?,
510        versions: {
511            let versions_json: String = row.try_get("versions_json").map_err(DbError::Query)?;
512            serde_json::from_str(&versions_json).map_err(|source| DbError::InvalidJsonShape {
513                column: "versions_json",
514                source,
515            })?
516        },
517        description: row.try_get("description").map_err(DbError::Query)?,
518        source: row.try_get("source").map_err(DbError::Query)?,
519    };
520
521    Ok(DcsTemplateRow {
522        id: row.try_get("id").map_err(DbError::Query)?,
523        origin: text_to_enum("origin", &get_enum("origin")?)?,
524        template,
525        created_at: row.try_get("created_at").map_err(DbError::Query)?,
526        updated_at: row.try_get("updated_at").map_err(DbError::Query)?,
527    })
528}
529// }}}1
530
531// loops {{{1
532
533/// One row of `loops`: a saved, named tag mapping plus default MRFT parameters.
534#[derive(Debug, Clone, PartialEq)]
535pub struct LoopRow {
536    pub id: i64,
537    pub name: String,
538    pub dcs_template_id: i64,
539    pub tags: LoopTags,
540    pub config: LoopConfig,
541    pub created_at: DateTime<Utc>,
542    pub updated_at: DateTime<Utc>,
543}
544// }}}1
545
546// tune_runs {{{1
547
548/// Which [`crate`]-agnostic I/O driver a run used. Lives in `bhtune-db` rather than
549/// `bhtune-core` because it's a persistence/orchestration concept (which adapter drove this
550/// run), not a domain concept the pure MRFT engine itself needs to know about.
551#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
552#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
553#[serde(rename_all = "snake_case")]
554pub enum TuneDriver {
555    Opcda,
556    Simulator,
557    Replay,
558}
559
560/// A run's lifecycle state.
561#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
562#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
563#[serde(rename_all = "snake_case")]
564pub enum TuneOutcome {
565    Running,
566    Completed,
567    Failed,
568    Aborted,
569}
570
571/// The clock basis used for a run's persisted polling-cadence diagnostics.
572#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
573#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
574#[serde(rename_all = "snake_case")]
575pub enum TimingBasis {
576    /// Simulator process evolution and MRFT timestamps both advance by one exact configured
577    /// poll interval per successful PV sample.
578    SimulatedFixedStep,
579    /// Live OPC DA timestamps are UTC projections of monotonic elapsed time, preserving real
580    /// scheduling and driver delays without exposure to wall-clock adjustments.
581    LiveMonotonic,
582}
583
584/// Advisory assessment of whether the recorded samples provide enough observations per
585/// oscillation period for a trustworthy extrema measurement.
586#[derive(
587    Debug, Clone, Copy, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
588)]
589#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
590#[serde(rename_all = "snake_case")]
591pub enum SamplingAdequacy {
592    /// At least the documented minimum number of samples per measured oscillation period.
593    Adequate,
594    /// Fewer than the documented minimum number of samples per measured oscillation period.
595    Marginal,
596    /// No finite, positive oscillation period was available for this assessment.
597    #[default]
598    NotAssessed,
599}
600
601/// Summary statistics for one class of measured polling work.
602#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
603#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
604pub struct TimingSummary {
605    pub count: u64,
606    pub mean_ms: Option<f64>,
607    pub max_ms: Option<f64>,
608}
609
610/// Operation-latency diagnostics captured while a run is polling.
611///
612/// Each category counts only operations that completed successfully. A zero count with
613/// `None` mean/max means that the operation did not occur during the run. Categories can
614/// overlap: while a relay command is pending, one batched OPC read supplies both the PV sample
615/// and MV verification, so its elapsed duration may appear in both summaries and must not be
616/// added twice as independent I/O time.
617#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
618#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
619pub struct PollLatencyMetrics {
620    pub pv_read: TimingSummary,
621    pub mv_write: TimingSummary,
622    pub mv_verification: TimingSummary,
623    pub sample_persist: TimingSummary,
624    pub tick_work: TimingSummary,
625}
626
627/// Polling-cadence diagnostics captured over one run's successful PV samples.
628///
629/// The two optional gap fields are `None` when fewer than two samples were observed. The
630/// measured oscillation fields are populated only for a completed MRFT run. Sampling adequacy
631/// is advisory metadata and does not determine calculated-result validity or write eligibility.
632#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
633#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
634pub struct TimingMetrics {
635    pub basis: TimingBasis,
636    pub requested_interval_ms: u64,
637    pub sample_gap_count: u64,
638    pub mean_sample_gap_ms: Option<f64>,
639    pub max_sample_gap_ms: Option<f64>,
640    /// Number of adjacent sample gaps at least twice the requested interval. Each such gap
641    /// proves that at least one complete polling opportunity was missed.
642    pub missed_poll_opportunity_count: u64,
643    pub measured_oscillation_period_ms: Option<f64>,
644    pub approximate_samples_per_period: Option<f64>,
645    /// Assessment uses the observed samples-per-period value and a six-sample advisory
646    /// threshold. Old timing snapshots deserialize as `not_assessed`.
647    #[serde(default)]
648    pub sampling_adequacy: SamplingAdequacy,
649    /// Detailed operation timings. Old timing snapshots may not contain this field.
650    #[serde(default)]
651    pub poll_latency: Option<PollLatencyMetrics>,
652}
653
654/// Concrete tune timing values after configuration defaults have been resolved.
655///
656/// Stored as one compact snapshot because these values are consumed together and have no
657/// SQL-level filtering requirement. `None` on [`TuneRunRow::effective_tuning`] identifies
658/// runs created before this snapshot existed or callers that have not recorded it yet.
659#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
660#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
661pub struct EffectiveTuning {
662    pub mrft_delay_secs: u32,
663    pub poll_interval_ms: u64,
664    pub timeout_secs: u64,
665    pub op_timeout_secs: u64,
666    pub restore_timeout_secs: u64,
667}
668
669/// The outcome of a best-effort loop-restore attempt made after a run ended --
670/// `safety-restore-guard` (finding 3 of the live-plant safety review). Recorded via
671/// [`TuneRunRow::record_restore_status`]; `NULL` in the database (mapped to `None` on
672/// [`TuneRunRow::restore_status`]) means no restore was ever attempted -- either the run
673/// never mutated the loop at all, or it hasn't ended yet.
674#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
675#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
676#[serde(rename_all = "snake_case")]
677pub enum RestoreStatus {
678    /// `restore()` ran every applicable step to completion with no failures.
679    Confirmed,
680    /// A second Ctrl+C arrived, `[tuning].restore_timeout_secs` elapsed, or one or more
681    /// individual restore steps themselves failed, before the restore could be confirmed
682    /// complete. The loop may still be at a relay-test MV/mode -- see `restore_detail` for
683    /// what an operator (or `bhtune restore-loop`) needs to check by hand.
684    Incomplete,
685}
686
687/// The initial-readings snapshot for a [`TuneRunRow`] — known only once the driver's initial
688/// read actually succeeds (`ReadInitialOPCvalues` in the legacy app); `None` for a run that
689/// failed before or during that step. Combines
690/// [`bhtune_core::mrft::InitialReadings`]/[`bhtune_core::range::PvRange`] with the
691/// resolved [`ControllerDirection`] `core-tuning-math` needs alongside them, as one bespoke
692/// type, since gluing the two existing structs together with one extra field isn't any
693/// simpler than a purpose-built one here.
694#[derive(Debug, Clone, PartialEq)]
695pub struct TuneRunInitialReadings {
696    pub pv_ini: f32,
697    pub mv_ini: f32,
698    pub mv_range_low: f32,
699    pub mv_range_high: f32,
700    pub pv_range_high: f32,
701    pub pv_range_low: f32,
702    pub controller_direction: ControllerDirection,
703    /// The controller mode tag's raw value at read time, before any mutation --
704    /// `None` when the template/loop has no mode tag at all. Persisted (rather than kept
705    /// only in-process) so a crashed run's restore intent survives the process dying
706    /// outright -- `safety-restore-guard` (finding 3 of the live-plant safety review).
707    pub mode_raw: Option<String>,
708    /// The mode-attribute tag's raw value at read time, before any mutation -- `None` when
709    /// the template/loop has no mode-attribute tag at all. See `mode_raw`.
710    pub mode_attribute_raw: Option<String>,
711    /// The setpoint read while the loop was still in its original mode, captured only when
712    /// that mode was Auto (mirrors `SvValueIni` in the legacy app) -- `None` otherwise. Read
713    /// here rather than during the mode transition itself (unlike the legacy app), since a
714    /// plain read has no mutation risk and can safely happen before the loop is touched at
715    /// all. See `mode_raw`.
716    pub setpoint_ini: Option<f32>,
717}
718
719/// One row of `tune_runs`: a single MRFT (or future Step Test) execution against a loop.
720#[derive(Debug, Clone, PartialEq)]
721pub struct TuneRunRow {
722    pub id: i64,
723    pub loop_id: Option<i64>,
724    pub demo_session_id: Option<i64>,
725    pub loop_name: String,
726    pub driver: TuneDriver,
727    /// The OPC DA server ProgID this run actually used -- `None` for a non-opcda run, or for
728    /// any run started before [`TuneRunRow::record_connection`] is called (see that method's
729    /// doc comment). Flat and filterable rather than folded into `request_json`, since
730    /// `bhtune history revert` must know exactly which plant a past run touched.
731    pub opc_server: Option<String>,
732    /// The opcda-bridge gateway host this run actually used -- `None` for a non-opcda run,
733    /// or before [`TuneRunRow::record_connection`] is called. See `opc_server`.
734    pub bridge_host: Option<String>,
735    pub started_at: DateTime<Utc>,
736    pub completed_at: Option<DateTime<Utc>>,
737    pub outcome: TuneOutcome,
738    pub failure_reason: Option<String>,
739    /// Snapshot of the `LoopConfig` this run was started with — always known up front, since
740    /// it's user/schedule input rather than something read from the driver.
741    pub config: LoopConfig,
742    /// Where the snapshotted `template` below came from (see [`TemplateOrigin`]).
743    pub template_origin: TemplateOrigin,
744    /// Snapshot of the exact [`DcsTemplate`] this run was configured against, deserialized
745    /// from `template_snapshot_json`. Held as the full struct rather than just its `name` --
746    /// which is what makes a historical run stay interpretable once the template catalog
747    /// changes underneath it (`safety-run-snapshot`). There's no separate `template_name`
748    /// field here even though the table has a `template_name` column: `.name` on this field
749    /// already carries that value, and the column exists purely so it's filterable/indexable
750    /// without `json_extract` (see this module's own doc comment).
751    pub template: DcsTemplate,
752    /// Snapshot of the resolved [`LoopTags`] this run actually used, deserialized from
753    /// `tags_json`.
754    pub tags: LoopTags,
755    /// The complete run request exactly as submitted (CLI flags or the HTTP
756    /// `POST /api/runs` body), before any config-driven defaulting -- raw JSON rather than a
757    /// typed struct, since its shape is owned by `bhtune-cli`/`bhtune-server`, not
758    /// `bhtune-db`. `"{}"` for any run started before
759    /// [`TuneRunRow::record_connection`] is called. Powers `ui-prefill-last-run` and
760    /// "duplicate this run"; never treat this as the source of truth for connection
761    /// facts -- that's `opc_server`/`bridge_host` above.
762    pub request_json: String,
763    /// Mutable operator notes for this run. `None` means no note is recorded.
764    pub notes: Option<String>,
765    pub initial_readings: Option<TuneRunInitialReadings>,
766    /// Whether this run permitted `Quality::Uncertain` OPC readings under the global
767    /// `allow_uncertain_quality` policy (finding 5 of the live-plant safety review;
768    /// `Quality::Bad` is never accepted regardless). `false` for every run started before
769    /// [`TuneRunRow::record_allow_uncertain_quality`] is called -- see that method's doc
770    /// comment for why it's a separate post-`start()` update rather than a `start()`
771    /// parameter.
772    pub allow_uncertain_quality: bool,
773    /// Polling-cadence diagnostics collected from successful PV samples. `None` for runs
774    /// created before timing diagnostics existed or attempts that ended before polling began.
775    pub timing_metrics: Option<TimingMetrics>,
776    /// Concrete timing values used by this run after configuration defaults were resolved.
777    /// `None` for runs created before effective-tuning snapshots existed or until
778    /// [`TuneRunRow::record_effective_tuning`] is called.
779    pub effective_tuning: Option<EffectiveTuning>,
780    /// Outcome of the best-effort restore attempted after this run ended -- `None` if no
781    /// restore was ever attempted (the run never mutated the loop, or hasn't ended yet). See
782    /// [`RestoreStatus`] and [`TuneRunRow::record_restore_status`].
783    pub restore_status: Option<RestoreStatus>,
784    /// Set only alongside `restore_status = Some(RestoreStatus::Incomplete)`: what a second
785    /// Ctrl+C, `[tuning].restore_timeout_secs`, or an individual failed restore step
786    /// prevented from being confirmed.
787    pub restore_detail: Option<String>,
788    pub created_at: DateTime<Utc>,
789}
790
791/// Filter criteria for [`TuneRunRow::list`]/[`TuneRunRow::count`]. Every field is optional;
792/// the all-`None` default matches every run. Build one with [`TuneRunFilter::default`] and
793/// the `with_*` methods, e.g. `TuneRunFilter::default().with_outcome(TuneOutcome::Failed)`.
794#[derive(Debug, Clone, Default, PartialEq)]
795pub struct TuneRunFilter {
796    pub loop_id: Option<i64>,
797    pub demo_session_id: Option<i64>,
798    pub process_type: Option<ProcessType>,
799    pub controller_type: Option<ControllerType>,
800    pub outcome: Option<TuneOutcome>,
801    pub driver: Option<TuneDriver>,
802    /// Exact match against the stored `opc_server` column. See
803    /// [`TuneRunRow::record_connection`].
804    pub opc_server: Option<String>,
805    /// Exact match against the stored `bridge_host` column. See
806    /// [`TuneRunRow::record_connection`].
807    pub bridge_host: Option<String>,
808    /// Matches runs with `started_at >= started_after` (inclusive).
809    pub started_after: Option<DateTime<Utc>>,
810    /// Matches runs with `started_at <= started_before` (inclusive).
811    pub started_before: Option<DateTime<Utc>>,
812    pub template_name: Option<String>,
813    pub template_origin: Option<TemplateOrigin>,
814}
815
816impl TuneRunFilter {
817    pub fn with_demo_session_id(mut self, demo_session_id: i64) -> TuneRunFilter {
818        self.demo_session_id = Some(demo_session_id);
819        self
820    }
821    pub fn with_loop_id(mut self, loop_id: i64) -> TuneRunFilter {
822        self.loop_id = Some(loop_id);
823        self
824    }
825
826    pub fn with_process_type(mut self, process_type: ProcessType) -> TuneRunFilter {
827        self.process_type = Some(process_type);
828        self
829    }
830
831    pub fn with_controller_type(mut self, controller_type: ControllerType) -> TuneRunFilter {
832        self.controller_type = Some(controller_type);
833        self
834    }
835
836    pub fn with_outcome(mut self, outcome: TuneOutcome) -> TuneRunFilter {
837        self.outcome = Some(outcome);
838        self
839    }
840
841    pub fn with_driver(mut self, driver: TuneDriver) -> TuneRunFilter {
842        self.driver = Some(driver);
843        self
844    }
845
846    pub fn with_opc_server(mut self, opc_server: impl Into<String>) -> TuneRunFilter {
847        self.opc_server = Some(opc_server.into());
848        self
849    }
850
851    pub fn with_bridge_host(mut self, bridge_host: impl Into<String>) -> TuneRunFilter {
852        self.bridge_host = Some(bridge_host.into());
853        self
854    }
855
856    pub fn with_started_after(mut self, started_after: DateTime<Utc>) -> TuneRunFilter {
857        self.started_after = Some(started_after);
858        self
859    }
860
861    pub fn with_started_before(mut self, started_before: DateTime<Utc>) -> TuneRunFilter {
862        self.started_before = Some(started_before);
863        self
864    }
865
866    pub fn with_template_name(mut self, template_name: impl Into<String>) -> TuneRunFilter {
867        self.template_name = Some(template_name.into());
868        self
869    }
870
871    pub fn with_template_origin(mut self, template_origin: TemplateOrigin) -> TuneRunFilter {
872        self.template_origin = Some(template_origin);
873        self
874    }
875}
876
877/// A page of [`TuneRunRow::list`] results: `limit` rows starting at `offset`, ordered newest
878/// first.
879#[derive(Debug, Clone, Copy, PartialEq)]
880pub struct Pagination {
881    pub limit: i64,
882    pub offset: i64,
883}
884
885impl Pagination {
886    pub fn new(limit: i64, offset: i64) -> Pagination {
887        Pagination { limit, offset }
888    }
889
890    /// The first `limit` rows.
891    pub fn first(limit: i64) -> Pagination {
892        Pagination { limit, offset: 0 }
893    }
894}
895
896impl Default for Pagination {
897    /// 50 rows, offset 0 — a reasonable default page size for a CLI/GUI run list.
898    fn default() -> Pagination {
899        Pagination {
900            limit: 50,
901            offset: 0,
902        }
903    }
904}
905
906impl TuneRunRow {
907    /// Starts a new run: inserts a `tune_runs` row with `outcome = 'running'` and no initial
908    /// readings yet (see [`Self::record_initial_readings`]). `now` is used for both
909    /// `started_at` and `created_at`, which are naturally the same instant for a run that's
910    /// only just begun. `loop_id` may be `None` for an ad-hoc run against tags that were
911    /// never saved as a reusable [`LoopRow`].
912    ///
913    /// `template_origin`/`template`/`tags` snapshot exactly what this run was configured
914    /// against (`safety-run-snapshot`), so a historical run stays interpretable even after
915    /// the template catalog changes underneath it. Serializing `template`/`tags` is treated
916    /// as infallible here, the same way [`enum_to_text`] treats enum serialization as
917    /// infallible: both types are plain, `derive`d, string/enum-only structures with no maps,
918    /// and every `f32` field they can carry is validated finite well before a run reaches
919    /// this call (see `safety-validation`) -- a panic here would mean that contract regressed
920    /// upstream, not a normal runtime failure this function's `DbResult` should model.
921    #[allow(clippy::too_many_arguments)]
922    pub async fn start(
923        pool: &SqlitePool,
924        loop_id: Option<i64>,
925        loop_name: &str,
926        driver: TuneDriver,
927        config: LoopConfig,
928        template_origin: TemplateOrigin,
929        template: &DcsTemplate,
930        tags: &LoopTags,
931        now: DateTime<Utc>,
932    ) -> DbResult<TuneRunRow> {
933        Self::start_with_demo_session(
934            pool,
935            None,
936            loop_id,
937            loop_name,
938            driver,
939            config,
940            template_origin,
941            template,
942            tags,
943            now,
944        )
945        .await
946    }
947
948    #[allow(clippy::too_many_arguments)]
949    pub async fn start_with_demo_session(
950        pool: &SqlitePool,
951        demo_session_id: Option<i64>,
952        loop_id: Option<i64>,
953        loop_name: &str,
954        driver: TuneDriver,
955        config: LoopConfig,
956        template_origin: TemplateOrigin,
957        template: &DcsTemplate,
958        tags: &LoopTags,
959        now: DateTime<Utc>,
960    ) -> DbResult<TuneRunRow> {
961        let template_snapshot_json =
962            serde_json::to_string(template).expect("DcsTemplate serialization is infallible");
963        let tags_json = serde_json::to_string(tags).expect("LoopTags serialization is infallible");
964
965        let row = sqlx::query(
966            r#"
967            INSERT INTO tune_runs (
968                loop_id, demo_session_id, loop_name, driver, started_at, outcome,
969                process_type, controller_type, relay_amp_percent, num_cycles_skip,
970                num_cycles_count, noise_protection_secs, mrft_delay_secs,
971                template_name, template_origin, template_snapshot_json, tags_json,
972                created_at
973            )
974            SELECT ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
975            WHERE ? IS NULL OR EXISTS (
976                SELECT 1
977                FROM demo_sessions
978                WHERE id = ? AND revoked_at IS NULL AND expires_at > ?
979            )
980            RETURNING *
981            "#,
982        )
983        .bind(loop_id)
984        .bind(demo_session_id)
985        .bind(loop_name)
986        .bind(enum_to_text(&driver))
987        .bind(now)
988        .bind(enum_to_text(&config.process_type))
989        .bind(enum_to_text(&config.controller_type))
990        .bind(config.relay_amp_percent)
991        .bind(config.num_cycles_skip)
992        .bind(config.num_cycles_count)
993        .bind(config.noise_protection_secs)
994        .bind(config.mrft_delay_secs)
995        .bind(&template.name)
996        .bind(enum_to_text(&template_origin))
997        .bind(template_snapshot_json)
998        .bind(tags_json)
999        .bind(now)
1000        .bind(demo_session_id)
1001        .bind(demo_session_id)
1002        .bind(now)
1003        .fetch_optional(pool)
1004        .await
1005        .map_err(DbError::Query)?
1006        .ok_or_else(|| DbError::Query(sqlx::Error::RowNotFound))?;
1007
1008        row_to_tune_run(row)
1009    }
1010
1011    /// Starts a simulator run owned by a demo session. The database trigger rejects any
1012    /// attempt to attach an owner to a live OPC DA run.
1013    #[allow(clippy::too_many_arguments)]
1014    pub async fn start_owned(
1015        pool: &SqlitePool,
1016        demo_session_id: i64,
1017        loop_name: &str,
1018        config: LoopConfig,
1019        template_origin: TemplateOrigin,
1020        template: &DcsTemplate,
1021        tags: &LoopTags,
1022        now: DateTime<Utc>,
1023    ) -> DbResult<TuneRunRow> {
1024        Self::start_with_demo_session(
1025            pool,
1026            Some(demo_session_id),
1027            None,
1028            loop_name,
1029            TuneDriver::Simulator,
1030            config,
1031            template_origin,
1032            template,
1033            tags,
1034            now,
1035        )
1036        .await
1037    }
1038
1039    pub async fn get_for_demo_session(
1040        pool: &SqlitePool,
1041        run_id: i64,
1042        demo_session_id: i64,
1043    ) -> DbResult<Option<TuneRunRow>> {
1044        let row = sqlx::query("SELECT * FROM tune_runs WHERE id = ? AND demo_session_id = ?")
1045            .bind(run_id)
1046            .bind(demo_session_id)
1047            .fetch_optional(pool)
1048            .await
1049            .map_err(DbError::Query)?;
1050        row.map(row_to_tune_run).transpose()
1051    }
1052
1053    pub async fn list_for_demo_session(
1054        pool: &SqlitePool,
1055        demo_session_id: i64,
1056        pagination: Pagination,
1057    ) -> DbResult<Vec<TuneRunRow>> {
1058        let rows = sqlx::query(
1059            "SELECT * FROM tune_runs WHERE demo_session_id = ? \
1060             ORDER BY started_at DESC, id DESC LIMIT ? OFFSET ?",
1061        )
1062        .bind(demo_session_id)
1063        .bind(pagination.limit)
1064        .bind(pagination.offset)
1065        .fetch_all(pool)
1066        .await
1067        .map_err(DbError::Query)?;
1068        rows.into_iter().map(row_to_tune_run).collect()
1069    }
1070
1071    pub async fn newest_for_demo_session(
1072        pool: &SqlitePool,
1073        demo_session_id: i64,
1074    ) -> DbResult<Option<TuneRunRow>> {
1075        let row = sqlx::query(
1076            "SELECT * FROM tune_runs WHERE demo_session_id = ? \
1077             ORDER BY started_at DESC, id DESC LIMIT 1",
1078        )
1079        .bind(demo_session_id)
1080        .fetch_optional(pool)
1081        .await
1082        .map_err(DbError::Query)?;
1083        row.map(row_to_tune_run).transpose()
1084    }
1085
1086    pub async fn count_for_demo_session(pool: &SqlitePool, demo_session_id: i64) -> DbResult<i64> {
1087        sqlx::query_scalar("SELECT COUNT(*) FROM tune_runs WHERE demo_session_id = ?")
1088            .bind(demo_session_id)
1089            .fetch_one(pool)
1090            .await
1091            .map_err(DbError::Query)
1092    }
1093
1094    /// Counts every current Demo-owned run across all sessions and outcomes. Full-mode rows have
1095    /// `demo_session_id = NULL` and are excluded.
1096    pub async fn count_demo_owned(pool: &SqlitePool) -> DbResult<i64> {
1097        sqlx::query_scalar("SELECT COUNT(*) FROM tune_runs WHERE demo_session_id IS NOT NULL")
1098            .fetch_one(pool)
1099            .await
1100            .map_err(DbError::Query)
1101    }
1102
1103    /// Deletes terminal history beyond the newest `retain` rows for one Demo owner. Running
1104    /// rows never consume a retention slot and are never deleted. Child samples, results,
1105    /// writes, and MV-actuation evidence cascade with each deleted run.
1106    pub async fn prune_terminal_for_demo_session(
1107        pool: &SqlitePool,
1108        demo_session_id: i64,
1109        retain: u32,
1110    ) -> DbResult<u64> {
1111        let result = sqlx::query(
1112            "DELETE FROM tune_runs \
1113             WHERE id IN ( \
1114                 SELECT id FROM tune_runs \
1115                 WHERE demo_session_id = ? AND outcome <> 'running' \
1116                 ORDER BY started_at DESC, id DESC \
1117                 LIMIT -1 OFFSET ? \
1118             )",
1119        )
1120        .bind(demo_session_id)
1121        .bind(i64::from(retain))
1122        .execute(pool)
1123        .await
1124        .map_err(DbError::Query)?;
1125        Ok(result.rows_affected())
1126    }
1127
1128    /// Applies [`Self::prune_terminal_for_demo_session`]'s retention rule independently to
1129    /// every Demo owner in one statement. Intended for periodic global cleanup.
1130    pub async fn prune_terminal_demo_owned(pool: &SqlitePool, retain: u32) -> DbResult<u64> {
1131        let result = sqlx::query(
1132            "WITH ranked AS ( \
1133                 SELECT id, ROW_NUMBER() OVER ( \
1134                     PARTITION BY demo_session_id ORDER BY started_at DESC, id DESC \
1135                 ) AS retention_rank \
1136                 FROM tune_runs \
1137                 WHERE demo_session_id IS NOT NULL AND outcome <> 'running' \
1138             ) \
1139             DELETE FROM tune_runs \
1140             WHERE id IN (SELECT id FROM ranked WHERE retention_rank > ?)",
1141        )
1142        .bind(i64::from(retain))
1143        .execute(pool)
1144        .await
1145        .map_err(DbError::Query)?;
1146        Ok(result.rows_affected())
1147    }
1148
1149    pub async fn count_rows_for_demo_session(
1150        pool: &SqlitePool,
1151        demo_session_id: i64,
1152    ) -> DbResult<i64> {
1153        sqlx::query_scalar(
1154            "SELECT
1155                (SELECT COUNT(*) FROM tune_runs WHERE demo_session_id = ?)
1156              + (SELECT COUNT(*) FROM tune_samples WHERE run_id IN
1157                    (SELECT id FROM tune_runs WHERE demo_session_id = ?))
1158              + (SELECT COUNT(*) FROM tune_results WHERE run_id IN
1159                    (SELECT id FROM tune_runs WHERE demo_session_id = ?))
1160              + (SELECT COUNT(*) FROM tune_writes WHERE run_id IN
1161                    (SELECT id FROM tune_runs WHERE demo_session_id = ?))
1162              + (SELECT COUNT(*) FROM tune_mv_actuations WHERE run_id IN
1163                    (SELECT id FROM tune_runs WHERE demo_session_id = ?))",
1164        )
1165        .bind(demo_session_id)
1166        .bind(demo_session_id)
1167        .bind(demo_session_id)
1168        .bind(demo_session_id)
1169        .bind(demo_session_id)
1170        .fetch_one(pool)
1171        .await
1172        .map_err(DbError::Query)
1173    }
1174
1175    /// Records this run's connection provenance and the exact request it was started with
1176    /// (`db-run-request-snapshot`): the OPC DA server ProgID and opcda-bridge gateway host
1177    /// actually used (`None`/`None` for a non-opcda run), and a JSON snapshot of the complete
1178    /// submitted request (CLI flags or the HTTP `POST /api/runs` body), captured *before* any
1179    /// config-driven defaulting so it reflects what the caller actually asked for.
1180    ///
1181    /// A separate post-`start()` update rather than three more `start()` parameters, matching
1182    /// [`Self::record_allow_uncertain_quality`]'s precedent -- `start()` already has 8
1183    /// positional parameters across dozens of call sites in this workspace's test suites
1184    /// alone, and three more would make every one of them noisier for no benefit, since none
1185    /// of those tests care about connection provenance. Unlike that method, this data *is*
1186    /// normally known the instant a run begins; the one production caller (`bhtune-cli`'s
1187    /// `prepare()`) calls this immediately after `start()` succeeds, before any driver I/O.
1188    /// `opc_server`/`bridge_host` default to `NULL` and `request_json` defaults to `"{}"`
1189    /// (see the migration), so every existing `start()` call site keeps compiling and
1190    /// behaving exactly as before.
1191    pub async fn record_connection(
1192        pool: &SqlitePool,
1193        run_id: i64,
1194        opc_server: Option<&str>,
1195        bridge_host: Option<&str>,
1196        request_json: &str,
1197    ) -> DbResult<TuneRunRow> {
1198        let row = sqlx::query(
1199            r#"
1200            UPDATE tune_runs SET opc_server = ?, bridge_host = ?, request_json = ?
1201            WHERE id = ?
1202            RETURNING *
1203            "#,
1204        )
1205        .bind(opc_server)
1206        .bind(bridge_host)
1207        .bind(request_json)
1208        .bind(run_id)
1209        .fetch_one(pool)
1210        .await
1211        .map_err(DbError::Query)?;
1212
1213        row_to_tune_run(row)
1214    }
1215
1216    /// Records the concrete tune timing policy after all configuration defaults have been
1217    /// resolved. This is a follow-up update rather than another [`Self::start`] parameter so
1218    /// existing repository callers remain source-compatible. Production orchestration should
1219    /// call it immediately after `start()` and before any driver I/O.
1220    pub async fn record_effective_tuning(
1221        pool: &SqlitePool,
1222        run_id: i64,
1223        effective_tuning: EffectiveTuning,
1224    ) -> DbResult<TuneRunRow> {
1225        let effective_tuning_json = serde_json::to_string(&effective_tuning)
1226            .expect("EffectiveTuning serialization is infallible");
1227        let row = sqlx::query(
1228            r#"
1229            UPDATE tune_runs SET effective_tuning_json = ?
1230            WHERE id = ?
1231            RETURNING *
1232            "#,
1233        )
1234        .bind(effective_tuning_json)
1235        .bind(run_id)
1236        .fetch_one(pool)
1237        .await
1238        .map_err(DbError::Query)?;
1239
1240        row_to_tune_run(row)
1241    }
1242
1243    /// Replaces this run's operator notes. Passing `None` clears the note, which is the
1244    /// persistence-layer implementation of the GUI's delete-note action. This deliberately
1245    /// has no lifecycle restriction: notes remain editable while a run is active and after it
1246    /// reaches a terminal outcome.
1247    pub async fn update_notes(
1248        pool: &SqlitePool,
1249        run_id: i64,
1250        notes: Option<&str>,
1251    ) -> DbResult<TuneRunRow> {
1252        let row = sqlx::query(
1253            r#"
1254            UPDATE tune_runs SET notes = ?
1255            WHERE id = ?
1256            RETURNING *
1257            "#,
1258        )
1259        .bind(notes)
1260        .bind(run_id)
1261        .fetch_one(pool)
1262        .await
1263        .map_err(DbError::Query)?;
1264
1265        row_to_tune_run(row)
1266    }
1267
1268    /// Records the driver's initial-readings snapshot (`ReadInitialOPCvalues` in the legacy
1269    /// app) for an already-started run. Called at most once per run, right after that read
1270    /// succeeds -- and, deliberately, *before* `transition_to_manual`'s first mutating write
1271    /// rather than after it (`safety-restore-guard`, finding 3 of the live-plant safety
1272    /// review), so `mode_raw`/`mode_attribute_raw`/`setpoint_ini` are always durably
1273    /// persisted before the loop is touched at all, letting a crashed run be reconstructed
1274    /// and restored later via `bhtune restore-loop`. A run that fails before or during the
1275    /// read instead goes straight to [`Self::fail`] with `initial_readings` left `None`.
1276    pub async fn record_initial_readings(
1277        pool: &SqlitePool,
1278        run_id: i64,
1279        readings: TuneRunInitialReadings,
1280    ) -> DbResult<TuneRunRow> {
1281        let row = sqlx::query(
1282            r#"
1283            UPDATE tune_runs SET
1284                pv_ini = ?, mv_ini = ?, mv_range_low = ?, mv_range_high = ?,
1285                pv_range_high = ?, pv_range_low = ?, controller_direction = ?,
1286                mode_raw = ?, mode_attribute_raw = ?, setpoint_ini = ?
1287            WHERE id = ?
1288            RETURNING *
1289            "#,
1290        )
1291        .bind(readings.pv_ini)
1292        .bind(readings.mv_ini)
1293        .bind(readings.mv_range_low)
1294        .bind(readings.mv_range_high)
1295        .bind(readings.pv_range_high)
1296        .bind(readings.pv_range_low)
1297        .bind(enum_to_text(&readings.controller_direction))
1298        .bind(readings.mode_raw)
1299        .bind(readings.mode_attribute_raw)
1300        .bind(readings.setpoint_ini)
1301        .bind(run_id)
1302        .fetch_one(pool)
1303        .await
1304        .map_err(DbError::Query)?;
1305
1306        row_to_tune_run(row)
1307    }
1308
1309    /// Records whether this run permitted `Quality::Uncertain` OPC readings under the global
1310    /// configuration policy (finding 5 of the live-plant safety review). A separate
1311    /// post-`start()` update rather than a new `start()` parameter deliberately: `start()`
1312    /// already has 8 positional parameters across 28 call sites in this crate's own test
1313    /// suite alone, and this is a rarely-used escape hatch, not information every caller
1314    /// naturally has on hand at the moment a run begins the way `template_origin`/`template`/
1315    /// `tags` are. The column defaults to `0`/`false` (see the migration), so every existing
1316    /// `start()` call site keeps compiling and behaving exactly as before; only the one
1317    /// production caller in `bhtune-cli`'s `run()` needs to call this, right after `start()`
1318    /// succeeds.
1319    pub async fn record_allow_uncertain_quality(
1320        pool: &SqlitePool,
1321        run_id: i64,
1322        allow_uncertain_quality: bool,
1323    ) -> DbResult<TuneRunRow> {
1324        let row = sqlx::query(
1325            r#"
1326            UPDATE tune_runs SET allow_uncertain_quality = ?
1327            WHERE id = ?
1328            RETURNING *
1329            "#,
1330        )
1331        .bind(allow_uncertain_quality)
1332        .bind(run_id)
1333        .fetch_one(pool)
1334        .await
1335        .map_err(DbError::Query)?;
1336
1337        row_to_tune_run(row)
1338    }
1339
1340    /// Records the polling cadence observed while this run was active. Kept as one typed JSON
1341    /// snapshot because these diagnostics are nested, evolve together, and have no SQL-level
1342    /// filtering requirement. Normal completed/aborted tune orchestration uses
1343    /// [`Self::complete_with_timing_metrics`] or [`Self::abort_with_timing_metrics`] so the
1344    /// terminal outcome and diagnostics become visible atomically; this standalone update is
1345    /// retained for non-terminal/failure paths and direct repository consumers.
1346    pub async fn record_timing_metrics(
1347        pool: &SqlitePool,
1348        run_id: i64,
1349        metrics: TimingMetrics,
1350    ) -> DbResult<TuneRunRow> {
1351        let metrics_json =
1352            serde_json::to_string(&metrics).expect("TimingMetrics serialization is infallible");
1353        let row = sqlx::query(
1354            r#"
1355            UPDATE tune_runs SET timing_metrics_json = ?
1356            WHERE id = ?
1357            RETURNING *
1358            "#,
1359        )
1360        .bind(metrics_json)
1361        .bind(run_id)
1362        .fetch_one(pool)
1363        .await
1364        .map_err(DbError::Query)?;
1365
1366        row_to_tune_run(row)
1367    }
1368
1369    /// Records the outcome of a best-effort loop-restore attempt made after this run ended
1370    /// (`safety-restore-guard`, finding 3 of the live-plant safety review). Called once,
1371    /// after `complete`/`fail`/`abort` (whichever applies) and after `attempt_restore` has
1372    /// actually run -- never before, and never for a run that ended without ever mutating
1373    /// the loop (nothing to restore, so nothing to record). `detail` should be `Some(..)`
1374    /// whenever `status` is [`RestoreStatus::Incomplete`], naming what could not be
1375    /// confirmed; pass `None` for [`RestoreStatus::Confirmed`]. A separate post-hoc update
1376    /// rather than a `complete`/`fail`/`abort` parameter, matching
1377    /// [`Self::record_allow_uncertain_quality`]'s precedent: the restore attempt always
1378    /// happens strictly after one of those three, never alongside it.
1379    pub async fn record_restore_status(
1380        pool: &SqlitePool,
1381        run_id: i64,
1382        status: RestoreStatus,
1383        detail: Option<&str>,
1384    ) -> DbResult<TuneRunRow> {
1385        let row = sqlx::query(
1386            r#"
1387            UPDATE tune_runs SET restore_status = ?, restore_detail = ?
1388            WHERE id = ?
1389            RETURNING *
1390            "#,
1391        )
1392        .bind(enum_to_text(&status))
1393        .bind(detail)
1394        .bind(run_id)
1395        .fetch_one(pool)
1396        .await
1397        .map_err(DbError::Query)?;
1398
1399        row_to_tune_run(row)
1400    }
1401
1402    /// Marks a run `completed` — a full MRFT test that ran to its natural end. The calculated
1403    /// results themselves are recorded separately via [`TuneResultRow::insert`].
1404    pub async fn complete(
1405        pool: &SqlitePool,
1406        run_id: i64,
1407        completed_at: DateTime<Utc>,
1408    ) -> DbResult<TuneRunRow> {
1409        let row = sqlx::query(
1410            "UPDATE tune_runs SET outcome = 'completed', completed_at = ? WHERE id = ? RETURNING *",
1411        )
1412        .bind(completed_at)
1413        .bind(run_id)
1414        .fetch_one(pool)
1415        .await
1416        .map_err(DbError::Query)?;
1417
1418        row_to_tune_run(row)
1419    }
1420
1421    /// Atomically marks a run completed and publishes its timing diagnostics. This prevents
1422    /// readers that react to the terminal outcome (notably the SSE stream) from observing a
1423    /// completed run before its timing snapshot is visible.
1424    pub async fn complete_with_timing_metrics(
1425        pool: &SqlitePool,
1426        run_id: i64,
1427        completed_at: DateTime<Utc>,
1428        timing_metrics: Option<TimingMetrics>,
1429    ) -> DbResult<TuneRunRow> {
1430        Self::set_terminal_outcome_with_timing_metrics(
1431            pool,
1432            run_id,
1433            completed_at,
1434            TuneOutcome::Completed,
1435            timing_metrics,
1436            None,
1437        )
1438        .await
1439    }
1440
1441    /// Marks a run `failed`, recording why. Valid whether or not
1442    /// [`Self::record_initial_readings`] was ever called for this run — a run can fail before,
1443    /// during, or after the initial read.
1444    pub async fn fail(
1445        pool: &SqlitePool,
1446        run_id: i64,
1447        completed_at: DateTime<Utc>,
1448        failure_reason: &str,
1449    ) -> DbResult<TuneRunRow> {
1450        let row = sqlx::query(
1451            r#"
1452            UPDATE tune_runs SET outcome = 'failed', completed_at = ?, failure_reason = ?
1453            WHERE id = ?
1454            RETURNING *
1455            "#,
1456        )
1457        .bind(completed_at)
1458        .bind(failure_reason)
1459        .bind(run_id)
1460        .fetch_one(pool)
1461        .await
1462        .map_err(DbError::Query)?;
1463
1464        row_to_tune_run(row)
1465    }
1466
1467    /// Marks a run `aborted` — stopped deliberately (by a human, or `cli-safety`'s
1468    /// wall-clock timeout guardrail) rather than failing on its own.
1469    pub async fn abort(
1470        pool: &SqlitePool,
1471        run_id: i64,
1472        completed_at: DateTime<Utc>,
1473    ) -> DbResult<TuneRunRow> {
1474        let row = sqlx::query(
1475            "UPDATE tune_runs SET outcome = 'aborted', completed_at = ? WHERE id = ? RETURNING *",
1476        )
1477        .bind(completed_at)
1478        .bind(run_id)
1479        .fetch_one(pool)
1480        .await
1481        .map_err(DbError::Query)?;
1482
1483        row_to_tune_run(row)
1484    }
1485
1486    /// Atomically marks a run aborted and publishes any timing diagnostics collected before
1487    /// the abort, so terminal-state readers cannot miss the final timing snapshot.
1488    pub async fn abort_with_timing_metrics(
1489        pool: &SqlitePool,
1490        run_id: i64,
1491        completed_at: DateTime<Utc>,
1492        timing_metrics: Option<TimingMetrics>,
1493    ) -> DbResult<TuneRunRow> {
1494        Self::set_terminal_outcome_with_timing_metrics(
1495            pool,
1496            run_id,
1497            completed_at,
1498            TuneOutcome::Aborted,
1499            timing_metrics,
1500            None,
1501        )
1502        .await
1503    }
1504
1505    /// Atomically marks a run aborted, publishes its timing diagnostics, and persists the
1506    /// operator-facing reason for the abort in the existing `failure_reason` column. This is
1507    /// used when an abort has a durable safety explanation (for example an MV command whose
1508    /// live readback did not reach its target), while preserving the database's existing
1509    /// [`TuneOutcome::Aborted`] value.
1510    pub async fn abort_with_timing_metrics_and_reason(
1511        pool: &SqlitePool,
1512        run_id: i64,
1513        completed_at: DateTime<Utc>,
1514        timing_metrics: Option<TimingMetrics>,
1515        reason: &str,
1516    ) -> DbResult<TuneRunRow> {
1517        Self::set_terminal_outcome_with_timing_metrics(
1518            pool,
1519            run_id,
1520            completed_at,
1521            TuneOutcome::Aborted,
1522            timing_metrics,
1523            Some(reason),
1524        )
1525        .await
1526    }
1527
1528    async fn set_terminal_outcome_with_timing_metrics(
1529        pool: &SqlitePool,
1530        run_id: i64,
1531        completed_at: DateTime<Utc>,
1532        outcome: TuneOutcome,
1533        timing_metrics: Option<TimingMetrics>,
1534        failure_reason: Option<&str>,
1535    ) -> DbResult<TuneRunRow> {
1536        let timing_metrics_json = timing_metrics.map(|metrics| {
1537            serde_json::to_string(&metrics).expect("TimingMetrics serialization is infallible")
1538        });
1539        let row = sqlx::query(
1540            r#"
1541            UPDATE tune_runs
1542            SET outcome = ?, completed_at = ?, timing_metrics_json = ?, failure_reason = ?
1543            WHERE id = ?
1544            RETURNING *
1545            "#,
1546        )
1547        .bind(enum_to_text(&outcome))
1548        .bind(completed_at)
1549        .bind(timing_metrics_json)
1550        .bind(failure_reason)
1551        .bind(run_id)
1552        .fetch_one(pool)
1553        .await
1554        .map_err(DbError::Query)?;
1555
1556        row_to_tune_run(row)
1557    }
1558
1559    /// Fetches one row by id, or `None` if it doesn't exist.
1560    pub async fn get(pool: &SqlitePool, id: i64) -> DbResult<Option<TuneRunRow>> {
1561        let row = sqlx::query("SELECT * FROM tune_runs WHERE id = ?")
1562            .bind(id)
1563            .fetch_optional(pool)
1564            .await
1565            .map_err(DbError::Query)?;
1566        row.map(row_to_tune_run).transpose()
1567    }
1568
1569    /// Lists runs matching `filter`, newest-started first, one `pagination` page at a time.
1570    /// See [`Self::count`] for the total number of rows `filter` matches across all pages.
1571    pub async fn list(
1572        pool: &SqlitePool,
1573        filter: &TuneRunFilter,
1574        pagination: Pagination,
1575    ) -> DbResult<Vec<TuneRunRow>> {
1576        let mut builder: QueryBuilder<Sqlite> = QueryBuilder::new("SELECT * FROM tune_runs");
1577        push_filter(&mut builder, filter);
1578        builder.push(" ORDER BY started_at DESC LIMIT ");
1579        builder.push_bind(pagination.limit);
1580        builder.push(" OFFSET ");
1581        builder.push_bind(pagination.offset);
1582
1583        let rows = builder
1584            .build()
1585            .fetch_all(pool)
1586            .await
1587            .map_err(DbError::Query)?;
1588        rows.into_iter().map(row_to_tune_run).collect()
1589    }
1590
1591    /// Counts every run matching `filter`, ignoring pagination — the total [`Self::list`]
1592    /// would page through.
1593    pub async fn count(pool: &SqlitePool, filter: &TuneRunFilter) -> DbResult<i64> {
1594        let mut builder: QueryBuilder<Sqlite> = QueryBuilder::new("SELECT COUNT(*) FROM tune_runs");
1595        push_filter(&mut builder, filter);
1596        builder
1597            .build_query_scalar::<i64>()
1598            .fetch_one(pool)
1599            .await
1600            .map_err(DbError::Query)
1601    }
1602
1603    /// Deletes every run matching `filter` in one statement (SQLite treats a single
1604    /// statement as its own transaction, so no explicit `BEGIN`/`COMMIT` is needed). Returns
1605    /// the number of runs deleted. `tune_samples`/`tune_results`/`tune_writes`'s `ON DELETE
1606    /// CASCADE` foreign keys (see `db-schema`'s migration) remove each deleted run's samples,
1607    /// results, and write-back audit rows automatically.
1608    ///
1609    /// Shares [`push_filter`] with [`Self::list`]/[`Self::count`], so "what a `--dry-run`
1610    /// preview reports" and "what an actual sweep deletes" can never disagree — used this way
1611    /// by `history-retention`'s automatic sweep and `bhtune history prune`.
1612    ///
1613    /// An empty `filter` (every field `None`) matches and deletes every run in the table —
1614    /// callers that mean to scope a deletion must build a `filter` that says so explicitly;
1615    /// this function has no separate "are you sure" guard of its own, matching `count`/`list`
1616    /// treating an empty filter as "everything" rather than "nothing".
1617    pub async fn delete_matching(pool: &SqlitePool, filter: &TuneRunFilter) -> DbResult<u64> {
1618        let mut builder: QueryBuilder<Sqlite> = QueryBuilder::new("DELETE FROM tune_runs");
1619        push_filter(&mut builder, filter);
1620        let result = builder
1621            .build()
1622            .execute(pool)
1623            .await
1624            .map_err(DbError::Query)?;
1625        Ok(result.rows_affected())
1626    }
1627
1628    /// Deletes exactly one run by id (`history-explorer-ui`'s delete action). Returns
1629    /// whether a row was actually deleted -- `false` if no run has that id, letting the
1630    /// caller map that to a 404 rather than a silent no-op. Unlike
1631    /// [`DcsTemplateRow::delete`], no foreign key ever blocks this: `tune_runs` has no
1632    /// parent-side `RESTRICT` reference pointing at it, only the `ON DELETE CASCADE`
1633    /// children (`tune_samples`/`tune_results`/`tune_writes`, see `db-schema`'s migration),
1634    /// which SQLite removes automatically as part of the same statement.
1635    pub async fn delete(pool: &SqlitePool, id: i64) -> DbResult<bool> {
1636        let result = sqlx::query("DELETE FROM tune_runs WHERE id = ?")
1637            .bind(id)
1638            .execute(pool)
1639            .await
1640            .map_err(DbError::Query)?;
1641        Ok(result.rows_affected() > 0)
1642    }
1643
1644    pub async fn delete_for_demo_session(
1645        pool: &SqlitePool,
1646        id: i64,
1647        demo_session_id: i64,
1648    ) -> DbResult<bool> {
1649        let result = sqlx::query("DELETE FROM tune_runs WHERE id = ? AND demo_session_id = ?")
1650            .bind(id)
1651            .bind(demo_session_id)
1652            .execute(pool)
1653            .await
1654            .map_err(DbError::Query)?;
1655        Ok(result.rows_affected() > 0)
1656    }
1657}
1658
1659/// Appends `WHERE <conditions>` to `builder` for every `Some` field in `filter`, or nothing
1660/// at all if every field is `None`. Shared by [`TuneRunRow::list`]/[`TuneRunRow::count`] so
1661/// the two can never disagree about which rows match a given filter.
1662fn push_filter(builder: &mut QueryBuilder<Sqlite>, filter: &TuneRunFilter) {
1663    // `1=1` makes every real condition an unconditional `AND`, rather than needing to track
1664    // whether it's the first one (and therefore needs `WHERE` instead of `AND`).
1665    builder.push(" WHERE 1=1");
1666
1667    if let Some(loop_id) = filter.loop_id {
1668        builder.push(" AND loop_id = ").push_bind(loop_id);
1669    }
1670    if let Some(demo_session_id) = filter.demo_session_id {
1671        builder
1672            .push(" AND demo_session_id = ")
1673            .push_bind(demo_session_id);
1674    }
1675    if let Some(process_type) = filter.process_type {
1676        builder
1677            .push(" AND process_type = ")
1678            .push_bind(enum_to_text(&process_type));
1679    }
1680    if let Some(controller_type) = filter.controller_type {
1681        builder
1682            .push(" AND controller_type = ")
1683            .push_bind(enum_to_text(&controller_type));
1684    }
1685    if let Some(outcome) = filter.outcome {
1686        builder
1687            .push(" AND outcome = ")
1688            .push_bind(enum_to_text(&outcome));
1689    }
1690    if let Some(driver) = filter.driver {
1691        builder
1692            .push(" AND driver = ")
1693            .push_bind(enum_to_text(&driver));
1694    }
1695    if let Some(opc_server) = &filter.opc_server {
1696        builder
1697            .push(" AND opc_server = ")
1698            .push_bind(opc_server.clone());
1699    }
1700    if let Some(bridge_host) = &filter.bridge_host {
1701        builder
1702            .push(" AND bridge_host = ")
1703            .push_bind(bridge_host.clone());
1704    }
1705    if let Some(started_after) = filter.started_after {
1706        builder.push(" AND started_at >= ").push_bind(started_after);
1707    }
1708    if let Some(started_before) = filter.started_before {
1709        builder
1710            .push(" AND started_at <= ")
1711            .push_bind(started_before);
1712    }
1713    if let Some(template_name) = &filter.template_name {
1714        builder
1715            .push(" AND template_name = ")
1716            .push_bind(template_name.clone());
1717    }
1718    if let Some(template_origin) = filter.template_origin {
1719        builder
1720            .push(" AND template_origin = ")
1721            .push_bind(enum_to_text(&template_origin));
1722    }
1723}
1724
1725fn row_to_tune_run(row: SqliteRow) -> DbResult<TuneRunRow> {
1726    let pv_ini: Option<f32> = row.try_get("pv_ini").map_err(DbError::Query)?;
1727    let initial_readings = match pv_ini {
1728        Some(pv_ini) => {
1729            let controller_direction: String = row
1730                .try_get("controller_direction")
1731                .map_err(DbError::Query)?;
1732            Some(TuneRunInitialReadings {
1733                pv_ini,
1734                mv_ini: row.try_get("mv_ini").map_err(DbError::Query)?,
1735                mv_range_low: row.try_get("mv_range_low").map_err(DbError::Query)?,
1736                mv_range_high: row.try_get("mv_range_high").map_err(DbError::Query)?,
1737                pv_range_high: row.try_get("pv_range_high").map_err(DbError::Query)?,
1738                pv_range_low: row.try_get("pv_range_low").map_err(DbError::Query)?,
1739                controller_direction: text_to_enum("controller_direction", &controller_direction)?,
1740                mode_raw: row.try_get("mode_raw").map_err(DbError::Query)?,
1741                mode_attribute_raw: row.try_get("mode_attribute_raw").map_err(DbError::Query)?,
1742                setpoint_ini: row.try_get("setpoint_ini").map_err(DbError::Query)?,
1743            })
1744        }
1745        None => None,
1746    };
1747
1748    let process_type: String = row.try_get("process_type").map_err(DbError::Query)?;
1749    let controller_type: String = row.try_get("controller_type").map_err(DbError::Query)?;
1750    let config = LoopConfig {
1751        process_type: text_to_enum("process_type", &process_type)?,
1752        controller_type: text_to_enum("controller_type", &controller_type)?,
1753        relay_amp_percent: row.try_get("relay_amp_percent").map_err(DbError::Query)?,
1754        num_cycles_skip: row.try_get("num_cycles_skip").map_err(DbError::Query)?,
1755        num_cycles_count: row.try_get("num_cycles_count").map_err(DbError::Query)?,
1756        noise_protection_secs: row
1757            .try_get("noise_protection_secs")
1758            .map_err(DbError::Query)?,
1759        mrft_delay_secs: row.try_get("mrft_delay_secs").map_err(DbError::Query)?,
1760    };
1761
1762    let driver: String = row.try_get("driver").map_err(DbError::Query)?;
1763    let outcome: String = row.try_get("outcome").map_err(DbError::Query)?;
1764
1765    let restore_status_text: Option<String> =
1766        row.try_get("restore_status").map_err(DbError::Query)?;
1767    let restore_status = restore_status_text
1768        .map(|text| text_to_enum("restore_status", &text))
1769        .transpose()?;
1770
1771    let template_origin: String = row.try_get("template_origin").map_err(DbError::Query)?;
1772    let template_snapshot_json: String = row
1773        .try_get("template_snapshot_json")
1774        .map_err(DbError::Query)?;
1775    let tags_json: String = row.try_get("tags_json").map_err(DbError::Query)?;
1776    let request_json: String = row.try_get("request_json").map_err(DbError::Query)?;
1777    let timing_metrics_json: Option<String> =
1778        row.try_get("timing_metrics_json").map_err(DbError::Query)?;
1779    let effective_tuning_json: Option<String> = row
1780        .try_get("effective_tuning_json")
1781        .map_err(DbError::Query)?;
1782    let template: DcsTemplate =
1783        serde_json::from_str(&template_snapshot_json).map_err(|source| {
1784            DbError::InvalidJsonShape {
1785                column: "template_snapshot_json",
1786                source,
1787            }
1788        })?;
1789    let tags: LoopTags =
1790        serde_json::from_str(&tags_json).map_err(|source| DbError::InvalidJsonShape {
1791            column: "tags_json",
1792            source,
1793        })?;
1794    let timing_metrics = timing_metrics_json
1795        .map(|json| {
1796            serde_json::from_str(&json).map_err(|source| DbError::InvalidJsonShape {
1797                column: "timing_metrics_json",
1798                source,
1799            })
1800        })
1801        .transpose()?;
1802    let effective_tuning = effective_tuning_json
1803        .map(|json| {
1804            serde_json::from_str(&json).map_err(|source| DbError::InvalidJsonShape {
1805                column: "effective_tuning_json",
1806                source,
1807            })
1808        })
1809        .transpose()?;
1810
1811    Ok(TuneRunRow {
1812        id: row.try_get("id").map_err(DbError::Query)?,
1813        loop_id: row.try_get("loop_id").map_err(DbError::Query)?,
1814        demo_session_id: row.try_get("demo_session_id").map_err(DbError::Query)?,
1815        loop_name: row.try_get("loop_name").map_err(DbError::Query)?,
1816        driver: text_to_enum("driver", &driver)?,
1817        opc_server: row.try_get("opc_server").map_err(DbError::Query)?,
1818        bridge_host: row.try_get("bridge_host").map_err(DbError::Query)?,
1819        started_at: row.try_get("started_at").map_err(DbError::Query)?,
1820        completed_at: row.try_get("completed_at").map_err(DbError::Query)?,
1821        outcome: text_to_enum("outcome", &outcome)?,
1822        failure_reason: row.try_get("failure_reason").map_err(DbError::Query)?,
1823        config,
1824        template_origin: text_to_enum("template_origin", &template_origin)?,
1825        template,
1826        tags,
1827        request_json,
1828        notes: row.try_get("notes").map_err(DbError::Query)?,
1829        initial_readings,
1830        allow_uncertain_quality: row
1831            .try_get("allow_uncertain_quality")
1832            .map_err(DbError::Query)?,
1833        timing_metrics,
1834        effective_tuning,
1835        restore_status,
1836        restore_detail: row.try_get("restore_detail").map_err(DbError::Query)?,
1837        created_at: row.try_get("created_at").map_err(DbError::Query)?,
1838    })
1839}
1840// }}}1
1841
1842// tune_samples {{{1
1843
1844/// How much a [`TuneSampleRow`]'s `sample.pv` reading should be trusted, as recorded at the
1845/// moment it was read (finding 5 of the live-plant safety review).
1846///
1847/// A `bhtune-db`-local mirror of [`bhtune_driver::Quality`], not a reuse of it directly:
1848/// `bhtune-db` deliberately doesn't depend on `bhtune-driver` (a leaf I/O-adapter crate with
1849/// a much heavier dependency tree -- `tokio`, `tonic`, `opcda-bridge` -- that has no business
1850/// in the persistence crate just to name one three-variant enum), so `bhtune-cli`, which
1851/// already depends on both, is the one place that converts between them. This mirrors
1852/// [`TemplateOrigin`]'s own precedent: a small, persistence-local enum rather than a second
1853/// dependency edge.
1854#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1855#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1856#[serde(rename_all = "snake_case")]
1857pub enum SampleQuality {
1858    Good,
1859    Uncertain,
1860    Bad,
1861}
1862
1863/// One row of `tune_samples`: a single tick's [`Tick`] input and resulting [`MrftState`].
1864#[derive(Debug, Clone, Copy, PartialEq)]
1865pub struct TuneSampleRow {
1866    pub id: i64,
1867    pub run_id: i64,
1868    /// 0-based sample sequence number within the run. Named `tick_index` rather than `tick`
1869    /// to avoid colliding with the unrelated [`Tick`] type held in `sample`.
1870    pub tick_index: i64,
1871    pub sample: Tick,
1872    pub state: MrftState,
1873    /// The driver-reported quality of `sample.pv` at the moment it was read. See
1874    /// [`SampleQuality`].
1875    pub pv_quality: SampleQuality,
1876}
1877
1878impl TuneSampleRow {
1879    /// Records one tick of a run, taking the exact [`Tick`]/[`MrftState`] pair
1880    /// [`bhtune_core::mrft::MrftEngine::step`] produced, plus the [`SampleQuality`] the
1881    /// driver reported for `sample.pv` at read time. `(run_id, tick_index)` is unique (see
1882    /// the migration), so re-recording the same tick twice is a caller bug, not a silent
1883    /// overwrite.
1884    pub async fn insert(
1885        pool: &SqlitePool,
1886        run_id: i64,
1887        tick_index: i64,
1888        sample: Tick,
1889        state: MrftState,
1890        pv_quality: SampleQuality,
1891    ) -> DbResult<TuneSampleRow> {
1892        let row = sqlx::query(
1893            r#"
1894            INSERT INTO tune_samples (
1895                run_id, tick, time, pv, pv_quality, hysteresis, mv_value_current,
1896                mv_sign_next_step, counter_all_switches, cycles_completed, cycles_remaining
1897            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1898            RETURNING *
1899            "#,
1900        )
1901        .bind(run_id)
1902        .bind(tick_index)
1903        .bind(sample.time)
1904        .bind(sample.pv)
1905        .bind(enum_to_text(&pv_quality))
1906        .bind(state.hysteresis)
1907        .bind(state.mv_value_current)
1908        .bind(state.mv_sign_next_step)
1909        .bind(state.counter_all_switches)
1910        .bind(state.cycles_completed)
1911        .bind(state.cycles_remaining)
1912        .fetch_one(pool)
1913        .await
1914        .map_err(DbError::Query)?;
1915
1916        row_to_tune_sample(row)
1917    }
1918
1919    /// Lists every sample of `run_id`, ordered by tick — the full per-tick trend the history
1920    /// explorer's chart (`history-explorer-ui`) plots.
1921    pub async fn list_for_run(pool: &SqlitePool, run_id: i64) -> DbResult<Vec<TuneSampleRow>> {
1922        let rows = sqlx::query("SELECT * FROM tune_samples WHERE run_id = ? ORDER BY tick")
1923            .bind(run_id)
1924            .fetch_all(pool)
1925            .await
1926            .map_err(DbError::Query)?;
1927        rows.into_iter().map(row_to_tune_sample).collect()
1928    }
1929
1930    /// Lists only the samples of `run_id` recorded *after* `after_tick`, ordered by tick --
1931    /// what `bhtune-server`'s `GET /api/runs/{id}/stream` (`frontend-live-stream`) polls on
1932    /// every iteration so it never re-sends a tick it has already pushed to the browser.
1933    /// Pass `-1` to fetch every sample from the very first tick (`tune_samples.tick` is
1934    /// `>= 0`, so nothing is ever excluded by that sentinel).
1935    pub async fn list_for_run_since(
1936        pool: &SqlitePool,
1937        run_id: i64,
1938        after_tick: i64,
1939    ) -> DbResult<Vec<TuneSampleRow>> {
1940        let rows =
1941            sqlx::query("SELECT * FROM tune_samples WHERE run_id = ? AND tick > ? ORDER BY tick")
1942                .bind(run_id)
1943                .bind(after_tick)
1944                .fetch_all(pool)
1945                .await
1946                .map_err(DbError::Query)?;
1947        rows.into_iter().map(row_to_tune_sample).collect()
1948    }
1949}
1950
1951fn row_to_tune_sample(row: SqliteRow) -> DbResult<TuneSampleRow> {
1952    Ok(TuneSampleRow {
1953        id: row.try_get("id").map_err(DbError::Query)?,
1954        run_id: row.try_get("run_id").map_err(DbError::Query)?,
1955        tick_index: row.try_get("tick").map_err(DbError::Query)?,
1956        sample: Tick {
1957            time: row.try_get("time").map_err(DbError::Query)?,
1958            pv: row.try_get("pv").map_err(DbError::Query)?,
1959        },
1960        state: MrftState {
1961            hysteresis: row.try_get("hysteresis").map_err(DbError::Query)?,
1962            mv_value_current: row.try_get("mv_value_current").map_err(DbError::Query)?,
1963            mv_sign_next_step: row.try_get("mv_sign_next_step").map_err(DbError::Query)?,
1964            counter_all_switches: row
1965                .try_get("counter_all_switches")
1966                .map_err(DbError::Query)?,
1967            cycles_completed: row.try_get("cycles_completed").map_err(DbError::Query)?,
1968            cycles_remaining: row.try_get("cycles_remaining").map_err(DbError::Query)?,
1969        },
1970        pv_quality: {
1971            let pv_quality: String = row.try_get("pv_quality").map_err(DbError::Query)?;
1972            text_to_enum("pv_quality", &pv_quality)?
1973        },
1974    })
1975}
1976// }}}1
1977
1978// tune_results {{{1
1979
1980/// One row of `tune_results`: the calculated PID result for one [`ResponseLevel`] of one run.
1981///
1982/// Flattened rather than nesting [`TuningResult`]/[`PidParameters`] directly, since both of
1983/// those types carry their own `response_level` field — nesting both would mean either two
1984/// redundant copies that could disagree, or an awkward "just trust the outer one" rule. One
1985/// flat set of columns matching the table exactly avoids that.
1986#[derive(Debug, Clone, Copy, PartialEq)]
1987pub struct TuneResultRow {
1988    pub id: i64,
1989    pub run_id: i64,
1990    pub response_level: ResponseLevel,
1991    pub kp: Option<f32>,
1992    pub ti_minutes: Option<f32>,
1993    pub td_minutes: Option<f32>,
1994    pub proportional: Option<f32>,
1995    pub integral: Option<f32>,
1996    pub derivative: Option<f32>,
1997    pub status: TuningResultStatus,
1998    pub invalid_reason: Option<TuningResultInvalidReason>,
1999}
2000
2001impl TuneResultRow {
2002    /// Builds a (not-yet-inserted, `id = 0`) row from a matching [`TuningResult`]/
2003    /// [`PidParameters`] pair, as produced together by
2004    /// [`bhtune_core::tuning_math::calculate_tuning_result`]/
2005    /// [`bhtune_core::tuning_math::calculate_pid_parameters`] for the same [`ResponseLevel`].
2006    ///
2007    /// # Panics
2008    /// Panics if `tuning.response_level != pid.response_level`: pairing results for different
2009    /// response levels together is always a caller bug, never a runtime data problem, the
2010    /// same contract [`bhtune_core::tuning_math::measure_oscillation`] documents for its own
2011    /// caller-contract panics.
2012    pub fn from_calculated(run_id: i64, tuning: TuningResult, pid: PidParameters) -> TuneResultRow {
2013        assert_eq!(
2014            tuning.response_level, pid.response_level,
2015            "TuningResult and PidParameters must be for the same ResponseLevel"
2016        );
2017        TuneResultRow {
2018            id: 0,
2019            run_id,
2020            response_level: tuning.response_level,
2021            kp: Some(tuning.kp),
2022            ti_minutes: Some(tuning.ti_minutes),
2023            td_minutes: Some(tuning.td_minutes),
2024            proportional: Some(pid.proportional),
2025            integral: Some(pid.integral),
2026            derivative: Some(pid.derivative),
2027            status: TuningResultStatus::Valid,
2028            invalid_reason: None,
2029        }
2030    }
2031
2032    /// Builds a row from the checked calculation path. Invalid results retain their response
2033    /// level and reason while leaving every numeric value absent, so they cannot be mistaken for
2034    /// writable PID constants.
2035    pub fn from_checked(run_id: i64, checked: CheckedTuningResult) -> TuneResultRow {
2036        let values = checked.usable_values();
2037        TuneResultRow {
2038            id: 0,
2039            run_id,
2040            response_level: checked.response_level,
2041            kp: values.map(|(tuning, _)| tuning.kp),
2042            ti_minutes: values.map(|(tuning, _)| tuning.ti_minutes),
2043            td_minutes: values.map(|(tuning, _)| tuning.td_minutes),
2044            proportional: values.map(|(_, pid)| pid.proportional),
2045            integral: values.map(|(_, pid)| pid.integral),
2046            derivative: values.map(|(_, pid)| pid.derivative),
2047            status: checked.status,
2048            invalid_reason: checked.invalid_reason,
2049        }
2050    }
2051
2052    /// Inserts `row` (typically built via [`Self::from_calculated`]), returning the persisted
2053    /// copy with its assigned `id`. `(run_id, response_level)` is unique (see the migration)
2054    /// — a successfully completed run writes exactly the 3 [`ResponseLevel`] rows once, at
2055    /// completion.
2056    pub async fn insert(pool: &SqlitePool, row: &TuneResultRow) -> DbResult<TuneResultRow> {
2057        let inserted = sqlx::query(
2058            r#"
2059            INSERT INTO tune_results (
2060                run_id, response_level, kp, ti_minutes, td_minutes,
2061                proportional, integral, derivative, status, invalid_reason
2062            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2063            RETURNING *
2064            "#,
2065        )
2066        .bind(row.run_id)
2067        .bind(enum_to_text(&row.response_level))
2068        .bind(row.kp)
2069        .bind(row.ti_minutes)
2070        .bind(row.td_minutes)
2071        .bind(row.proportional)
2072        .bind(row.integral)
2073        .bind(row.derivative)
2074        .bind(enum_to_text(&row.status))
2075        .bind(row.invalid_reason.map(|reason| enum_to_text(&reason)))
2076        .fetch_one(pool)
2077        .await
2078        .map_err(DbError::Query)?;
2079
2080        row_to_tune_result(inserted)
2081    }
2082
2083    /// Lists every calculated result of `run_id`, ordered by [`ResponseLevel`] (which sorts
2084    /// alphabetically as Aggressive, Moderate, Sluggish — the same order
2085    /// [`bhtune_core::constants::ResponseLevel::ALL`] enumerates them in). 0 rows for a run
2086    /// that never completed, up to 3 for one that did.
2087    pub async fn list_for_run(pool: &SqlitePool, run_id: i64) -> DbResult<Vec<TuneResultRow>> {
2088        let rows =
2089            sqlx::query("SELECT * FROM tune_results WHERE run_id = ? ORDER BY response_level")
2090                .bind(run_id)
2091                .fetch_all(pool)
2092                .await
2093                .map_err(DbError::Query)?;
2094        rows.into_iter().map(row_to_tune_result).collect()
2095    }
2096}
2097
2098fn row_to_tune_result(row: SqliteRow) -> DbResult<TuneResultRow> {
2099    let response_level: String = row.try_get("response_level").map_err(DbError::Query)?;
2100    Ok(TuneResultRow {
2101        id: row.try_get("id").map_err(DbError::Query)?,
2102        run_id: row.try_get("run_id").map_err(DbError::Query)?,
2103        response_level: text_to_enum("response_level", &response_level)?,
2104        kp: row.try_get("kp").map_err(DbError::Query)?,
2105        ti_minutes: row.try_get("ti_minutes").map_err(DbError::Query)?,
2106        td_minutes: row.try_get("td_minutes").map_err(DbError::Query)?,
2107        proportional: row.try_get("proportional").map_err(DbError::Query)?,
2108        integral: row.try_get("integral").map_err(DbError::Query)?,
2109        derivative: row.try_get("derivative").map_err(DbError::Query)?,
2110        status: {
2111            let status: String = row.try_get("status").map_err(DbError::Query)?;
2112            text_to_enum("status", &status)?
2113        },
2114        invalid_reason: {
2115            let reason: Option<String> = row.try_get("invalid_reason").map_err(DbError::Query)?;
2116            reason
2117                .as_deref()
2118                .map(|reason| text_to_enum("invalid_reason", reason))
2119                .transpose()?
2120        },
2121    })
2122}
2123// }}}1
2124
2125// tune_mv_actuations {{{1
2126
2127/// The physical purpose of one accepted manipulated-variable command.
2128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2129#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2130#[serde(rename_all = "snake_case")]
2131pub enum MvActuationKind {
2132    /// An MRFT relay step, including the engine's final snapback to the initial MV.
2133    Relay,
2134    /// The authoritative post-run restore write to the original MV.
2135    Restore,
2136}
2137
2138/// Lifecycle state of one accepted manipulated-variable command.
2139#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2140#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2141#[serde(rename_all = "snake_case")]
2142pub enum MvActuationStatus {
2143    /// The write was accepted, but the live MV has not yet produced terminal evidence.
2144    Pending,
2145    /// A live readback matched `target_mv` within the recorded `tolerance`.
2146    Confirmed,
2147    /// A finite, acceptable-quality live readback missed the target/tolerance requirement.
2148    Failed,
2149    /// The run ended before the command could be conclusively checked (for example, a
2150    /// cancellation, driver error, or operation timeout).
2151    Unverified,
2152    /// A later authoritative command deliberately replaced responsibility for confirming
2153    /// this command, such as the restore write taking over from the engine's final snapback.
2154    Superseded,
2155}
2156
2157impl MvActuationStatus {
2158    fn ensure_terminal(self) -> DbResult<()> {
2159        if self == MvActuationStatus::Pending {
2160            Err(DbError::InvalidMvActuationFinalStatus)
2161        } else {
2162            Ok(())
2163        }
2164    }
2165}
2166
2167/// Input for [`TuneMvActuationRow::insert_pending`].
2168///
2169/// `commanded_at` is the time the driver accepted the write, while
2170/// `confirmation_due_at` is the exact deadline selected by the actuation policy for this
2171/// command. Persisting both that deadline and `tolerance` makes historical evidence
2172/// interpretable even if the policy changes in a later release.
2173#[derive(Debug, Clone, PartialEq)]
2174pub struct NewTuneMvActuation {
2175    pub sequence: i64,
2176    pub kind: MvActuationKind,
2177    pub commanded_at: DateTime<Utc>,
2178    pub target_mv: f32,
2179    /// The immediately preceding commanded value used to derive the relay-step-aware
2180    /// tolerance. `None` for the first command in a run and valid for either command kind.
2181    pub previous_commanded_mv: Option<f32>,
2182    pub tolerance: f32,
2183    pub confirmation_due_at: DateTime<Utc>,
2184}
2185
2186/// One row of `tune_mv_actuations`: durable evidence for an accepted OPC DA MV write.
2187///
2188/// These rows are separate from [`TuneSampleRow`], whose MV remains the engine's commanded
2189/// series for trend/export compatibility. `readback_mv` and `readback_quality` are the most
2190/// recent physical observation made by the verifier; `attempt_count` counts every persisted
2191/// observation, including attempts where no numeric value or trustworthy quality could be
2192/// obtained.
2193#[derive(Debug, Clone, PartialEq)]
2194pub struct TuneMvActuationRow {
2195    pub id: i64,
2196    pub run_id: i64,
2197    /// Monotonic command order within one run. Unique together with `run_id`.
2198    pub sequence: i64,
2199    pub kind: MvActuationKind,
2200    /// UTC projection of the instant at which the driver accepted the MV write.
2201    pub commanded_at: DateTime<Utc>,
2202    pub target_mv: f32,
2203    pub previous_commanded_mv: Option<f32>,
2204    /// Exact absolute target/readback tolerance applied to this command.
2205    pub tolerance: f32,
2206    /// Exact time at which the command must have terminal evidence under the active policy.
2207    pub confirmation_due_at: DateTime<Utc>,
2208    pub last_checked_at: Option<DateTime<Utc>>,
2209    pub readback_mv: Option<f32>,
2210    pub readback_quality: Option<SampleQuality>,
2211    pub attempt_count: i64,
2212    pub status: MvActuationStatus,
2213    /// Operator-facing explanation, primarily for failed/unverified/superseded rows.
2214    pub detail: Option<String>,
2215}
2216
2217impl TuneMvActuationRow {
2218    /// Inserts one accepted command in [`MvActuationStatus::Pending`] state. Observation
2219    /// fields are empty and `attempt_count` is zero until [`Self::record_observation`] or
2220    /// [`Self::record_final_observation`] is called.
2221    pub async fn insert_pending(
2222        pool: &SqlitePool,
2223        run_id: i64,
2224        new: NewTuneMvActuation,
2225    ) -> DbResult<TuneMvActuationRow> {
2226        let row = sqlx::query(
2227            r#"
2228            INSERT INTO tune_mv_actuations (
2229                run_id, sequence, kind, commanded_at, target_mv, previous_commanded_mv,
2230                tolerance, confirmation_due_at
2231            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2232            RETURNING *
2233            "#,
2234        )
2235        .bind(run_id)
2236        .bind(new.sequence)
2237        .bind(enum_to_text(&new.kind))
2238        .bind(new.commanded_at)
2239        .bind(new.target_mv)
2240        .bind(new.previous_commanded_mv)
2241        .bind(new.tolerance)
2242        .bind(new.confirmation_due_at)
2243        .fetch_one(pool)
2244        .await
2245        .map_err(DbError::Query)?;
2246
2247        row_to_tune_mv_actuation(row)
2248    }
2249
2250    /// Records the latest verification attempt while leaving the command pending. Both
2251    /// observation values are optional so a caller can still persist that an attempted
2252    /// check produced no usable numeric value and/or no quality classification.
2253    pub async fn record_observation(
2254        pool: &SqlitePool,
2255        id: i64,
2256        checked_at: DateTime<Utc>,
2257        readback_mv: Option<f32>,
2258        readback_quality: Option<SampleQuality>,
2259    ) -> DbResult<TuneMvActuationRow> {
2260        let row = sqlx::query(
2261            r#"
2262            UPDATE tune_mv_actuations
2263            SET last_checked_at = ?, readback_mv = ?, readback_quality = ?,
2264                attempt_count = attempt_count + 1
2265            WHERE id = ? AND status = 'pending'
2266            RETURNING *
2267            "#,
2268        )
2269        .bind(checked_at)
2270        .bind(readback_mv)
2271        .bind(readback_quality.map(|quality| enum_to_text(&quality)))
2272        .bind(id)
2273        .fetch_one(pool)
2274        .await
2275        .map_err(DbError::Query)?;
2276
2277        row_to_tune_mv_actuation(row)
2278    }
2279
2280    /// Atomically records the final observation and terminal status. This is preferable to
2281    /// separate [`Self::record_observation`] and [`Self::finalize`] calls when one read
2282    /// conclusively confirms or rejects the target, because readers can never observe that
2283    /// final evidence while the row still misleadingly says `pending`.
2284    pub async fn record_final_observation(
2285        pool: &SqlitePool,
2286        id: i64,
2287        checked_at: DateTime<Utc>,
2288        readback_mv: Option<f32>,
2289        readback_quality: Option<SampleQuality>,
2290        status: MvActuationStatus,
2291        detail: Option<&str>,
2292    ) -> DbResult<TuneMvActuationRow> {
2293        status.ensure_terminal()?;
2294        let row = sqlx::query(
2295            r#"
2296            UPDATE tune_mv_actuations
2297            SET last_checked_at = ?, readback_mv = ?, readback_quality = ?,
2298                attempt_count = attempt_count + 1, status = ?, detail = ?
2299            WHERE id = ? AND status = 'pending'
2300            RETURNING *
2301            "#,
2302        )
2303        .bind(checked_at)
2304        .bind(readback_mv)
2305        .bind(readback_quality.map(|quality| enum_to_text(&quality)))
2306        .bind(enum_to_text(&status))
2307        .bind(detail)
2308        .bind(id)
2309        .fetch_one(pool)
2310        .await
2311        .map_err(DbError::Query)?;
2312
2313        row_to_tune_mv_actuation(row)
2314    }
2315
2316    /// Finalizes a pending command without inventing an observation. Used for terminal paths
2317    /// such as interruption (`Unverified`) or deliberate handoff (`Superseded`).
2318    pub async fn finalize(
2319        pool: &SqlitePool,
2320        id: i64,
2321        status: MvActuationStatus,
2322        detail: Option<&str>,
2323    ) -> DbResult<TuneMvActuationRow> {
2324        status.ensure_terminal()?;
2325        let row = sqlx::query(
2326            r#"
2327            UPDATE tune_mv_actuations
2328            SET status = ?, detail = ?
2329            WHERE id = ? AND status = 'pending'
2330            RETURNING *
2331            "#,
2332        )
2333        .bind(enum_to_text(&status))
2334        .bind(detail)
2335        .bind(id)
2336        .fetch_one(pool)
2337        .await
2338        .map_err(DbError::Query)?;
2339
2340        row_to_tune_mv_actuation(row)
2341    }
2342
2343    /// Finalizes every still-pending command belonging to `run_id`, returning the number of
2344    /// rows changed. Already-terminal rows and rows for other runs are never modified. This
2345    /// is the terminal-path backstop that prevents a completed/failed/aborted run from
2346    /// retaining misleading `pending` audit records.
2347    pub async fn finalize_pending_for_run(
2348        pool: &SqlitePool,
2349        run_id: i64,
2350        status: MvActuationStatus,
2351        detail: Option<&str>,
2352    ) -> DbResult<u64> {
2353        status.ensure_terminal()?;
2354        let result = sqlx::query(
2355            r#"
2356            UPDATE tune_mv_actuations
2357            SET status = ?, detail = ?
2358            WHERE run_id = ? AND status = 'pending'
2359            "#,
2360        )
2361        .bind(enum_to_text(&status))
2362        .bind(detail)
2363        .bind(run_id)
2364        .execute(pool)
2365        .await
2366        .map_err(DbError::Query)?;
2367        Ok(result.rows_affected())
2368    }
2369
2370    /// Lists every accepted MV command for `run_id` in command-sequence order.
2371    pub async fn list_for_run(pool: &SqlitePool, run_id: i64) -> DbResult<Vec<TuneMvActuationRow>> {
2372        let rows =
2373            sqlx::query("SELECT * FROM tune_mv_actuations WHERE run_id = ? ORDER BY sequence, id")
2374                .bind(run_id)
2375                .fetch_all(pool)
2376                .await
2377                .map_err(DbError::Query)?;
2378        rows.into_iter().map(row_to_tune_mv_actuation).collect()
2379    }
2380}
2381
2382fn row_to_tune_mv_actuation(row: SqliteRow) -> DbResult<TuneMvActuationRow> {
2383    let kind: String = row.try_get("kind").map_err(DbError::Query)?;
2384    let readback_quality: Option<String> =
2385        row.try_get("readback_quality").map_err(DbError::Query)?;
2386    let status: String = row.try_get("status").map_err(DbError::Query)?;
2387    Ok(TuneMvActuationRow {
2388        id: row.try_get("id").map_err(DbError::Query)?,
2389        run_id: row.try_get("run_id").map_err(DbError::Query)?,
2390        sequence: row.try_get("sequence").map_err(DbError::Query)?,
2391        kind: text_to_enum("kind", &kind)?,
2392        commanded_at: row.try_get("commanded_at").map_err(DbError::Query)?,
2393        target_mv: row.try_get("target_mv").map_err(DbError::Query)?,
2394        previous_commanded_mv: row
2395            .try_get("previous_commanded_mv")
2396            .map_err(DbError::Query)?,
2397        tolerance: row.try_get("tolerance").map_err(DbError::Query)?,
2398        confirmation_due_at: row.try_get("confirmation_due_at").map_err(DbError::Query)?,
2399        last_checked_at: row.try_get("last_checked_at").map_err(DbError::Query)?,
2400        readback_mv: row.try_get("readback_mv").map_err(DbError::Query)?,
2401        readback_quality: readback_quality
2402            .map(|quality| text_to_enum("readback_quality", &quality))
2403            .transpose()?,
2404        attempt_count: row.try_get("attempt_count").map_err(DbError::Query)?,
2405        status: text_to_enum("status", &status)?,
2406        detail: row.try_get("detail").map_err(DbError::Query)?,
2407    })
2408}
2409// }}}1
2410
2411// tune_writes {{{1
2412
2413/// One row of `tune_writes`: an audit record of PID constants actually written back to the
2414/// DCS for one [`ResponseLevel`] of one run, distinct from what was merely *calculated*
2415/// ([`TuneResultRow`]). Flattened for the same reason as `TuneResultRow`.
2416///
2417/// `*_written`/`*_readback` are independently nullable (not all-or-nothing like `previous`)
2418/// because `safety-writeback-rollback` writes and verifies P, then I, then D in sequence,
2419/// stopping at the first failure -- so a partial attempt leaves the constants after the
2420/// failure point at `None` rather than 0, distinguishing "never attempted" from "attempted
2421/// and confirmed zero".
2422#[derive(Debug, Clone, PartialEq)]
2423pub struct TuneWriteRow {
2424    pub id: i64,
2425    pub run_id: i64,
2426    pub response_level: ResponseLevel,
2427    pub written_at: DateTime<Utc>,
2428    /// Whether this row is a normal write-back or `bhtune history revert` undoing one. See
2429    /// [`WriteKind`].
2430    pub kind: WriteKind,
2431    /// Whether this operation allowed `Quality::Uncertain` readings. This is
2432    /// the explicit per-operation policy supplied at insertion time; it is
2433    /// independent of [`TuneRunRow::allow_uncertain_quality`].
2434    pub allow_uncertain_quality: bool,
2435    /// The P/I/D values read from the driver *before* any write was attempted. `None` only
2436    /// when the pre-read itself failed -- a hard stop before any write, so nothing else on
2437    /// this row was ever attempted either (`success = false`, every other field below `None`).
2438    pub previous: Option<WriteReadback>,
2439    pub proportional_written: Option<f32>,
2440    pub integral_written: Option<f32>,
2441    pub derivative_written: Option<f32>,
2442    /// Read back immediately after writing to confirm the DCS accepted the value within
2443    /// tolerance. `None` whenever the corresponding `*_written` field is `None`, or when the
2444    /// write was sent but the readback attempt itself failed.
2445    pub proportional_readback: Option<f32>,
2446    pub integral_readback: Option<f32>,
2447    pub derivative_readback: Option<f32>,
2448    pub success: bool,
2449    pub error_message: Option<String>,
2450    /// Set only when `success = false` and at least one constant had already been written
2451    /// before the failure, so a best-effort rollback to `previous` was attempted. `None`
2452    /// means rollback did not apply -- either every constant wrote successfully (`success =
2453    /// true`) or the pre-read failed before any write was attempted. Always `None` for a
2454    /// `kind = Revert` row.
2455    pub rollback_state: Option<RollbackState>,
2456    pub rollback_error: Option<String>,
2457}
2458
2459/// A triple of proportional/integral/derivative values, read from the driver before any
2460/// write is attempted ([`TuneWriteRow::previous`]). Not a `bhtune-core` type like
2461/// `bhtune_core::tuning_math::OpcWriteValues`: this is a raw observation, not a
2462/// calculated/intended value.
2463#[derive(Debug, Clone, Copy, PartialEq)]
2464pub struct WriteReadback {
2465    pub proportional: f32,
2466    pub integral: f32,
2467    pub derivative: f32,
2468}
2469
2470/// Whether a best-effort rollback of a partially-completed PID write was attempted and, if
2471/// so, whether it succeeded. See [`TuneWriteRow::rollback_state`] for when this is `None`.
2472#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2473#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2474#[serde(rename_all = "snake_case")]
2475pub enum RollbackState {
2476    /// Every constant that had been written was successfully written back to its `previous`
2477    /// value.
2478    Succeeded,
2479    /// At least one constant could not be written back to its `previous` value -- the loop
2480    /// may still hold a mismatched, partially-updated set of PID constants. See
2481    /// [`TuneWriteRow::rollback_error`] and `bhtune history revert` for recovering by hand.
2482    Failed,
2483}
2484
2485/// Distinguishes a normal write-back from `bhtune history revert` undoing an earlier one.
2486/// Both share [`TuneWriteRow`]'s exact shape -- pre-read, write-and-verify each constant,
2487/// audit the outcome -- so they live in the same table rather than a second near-duplicate
2488/// one; `kind` is the one column that tells them apart.
2489#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2490#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2491#[serde(rename_all = "snake_case")]
2492pub enum WriteKind {
2493    /// A write-back of freshly calculated PID parameters (`maybe_write_back`).
2494    Write,
2495    /// `bhtune history revert` writing an earlier `Write` row's `previous` values back,
2496    /// undoing it. Never itself has a `rollback_state` -- a revert does not chain into a
2497    /// further rollback.
2498    Revert,
2499}
2500
2501/// Everything needed to record one write-back attempt, successful or not. Built up by the
2502/// caller as it works through the sequential pre-read / write-and-verify / rollback steps,
2503/// then persisted in a single [`TuneWriteRow::insert`] call -- replacing the old two-outcome
2504/// `insert_success`/`insert_failure` split, which could not represent a partial write or a
2505/// rollback attempt at all. See `safety-writeback-rollback` in AGENTS.md for the four
2506/// distinguishable outcomes this shape exists to capture.
2507#[derive(Debug, Clone, PartialEq)]
2508pub struct NewTuneWrite {
2509    pub response_level: ResponseLevel,
2510    pub written_at: DateTime<Utc>,
2511    pub kind: WriteKind,
2512    pub allow_uncertain_quality: bool,
2513    pub previous: Option<WriteReadback>,
2514    pub proportional_written: Option<f32>,
2515    pub integral_written: Option<f32>,
2516    pub derivative_written: Option<f32>,
2517    pub proportional_readback: Option<f32>,
2518    pub integral_readback: Option<f32>,
2519    pub derivative_readback: Option<f32>,
2520    pub success: bool,
2521    pub error_message: Option<String>,
2522    pub rollback_state: Option<RollbackState>,
2523    pub rollback_error: Option<String>,
2524}
2525
2526impl NewTuneWrite {
2527    /// Starts a record with every previous/written/readback/rollback field unset and
2528    /// `kind = WriteKind::Write`. New production write/revert operations must overwrite
2529    /// `allow_uncertain_quality` with the policy captured when that operation began; the
2530    /// permissive default keeps direct repository/test construction backward-compatible.
2531    pub fn new(response_level: ResponseLevel, written_at: DateTime<Utc>) -> Self {
2532        NewTuneWrite {
2533            response_level,
2534            written_at,
2535            kind: WriteKind::Write,
2536            allow_uncertain_quality: true,
2537            previous: None,
2538            proportional_written: None,
2539            integral_written: None,
2540            derivative_written: None,
2541            proportional_readback: None,
2542            integral_readback: None,
2543            derivative_readback: None,
2544            success: false,
2545            error_message: None,
2546            rollback_state: None,
2547            rollback_error: None,
2548        }
2549    }
2550}
2551
2552impl TuneWriteRow {
2553    /// Records one write-back attempt exactly as `new` describes it -- see [`NewTuneWrite`].
2554    pub async fn insert(
2555        pool: &SqlitePool,
2556        run_id: i64,
2557        new: NewTuneWrite,
2558    ) -> DbResult<TuneWriteRow> {
2559        let row = sqlx::query(
2560            r#"
2561            INSERT INTO tune_writes (
2562                run_id, response_level, written_at, kind,
2563                allow_uncertain_quality,
2564                proportional_previous, integral_previous, derivative_previous,
2565                proportional_written, integral_written, derivative_written,
2566                proportional_readback, integral_readback, derivative_readback,
2567                success, error_message, rollback_state, rollback_error
2568            ) VALUES (
2569                ?, ?, ?, ?,
2570                ?,
2571                ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
2572            )
2573            RETURNING *
2574            "#,
2575        )
2576        .bind(run_id)
2577        .bind(enum_to_text(&new.response_level))
2578        .bind(new.written_at)
2579        .bind(enum_to_text(&new.kind))
2580        .bind(new.allow_uncertain_quality)
2581        .bind(new.previous.map(|p| p.proportional))
2582        .bind(new.previous.map(|p| p.integral))
2583        .bind(new.previous.map(|p| p.derivative))
2584        .bind(new.proportional_written)
2585        .bind(new.integral_written)
2586        .bind(new.derivative_written)
2587        .bind(new.proportional_readback)
2588        .bind(new.integral_readback)
2589        .bind(new.derivative_readback)
2590        .bind(new.success)
2591        .bind(new.error_message)
2592        .bind(new.rollback_state.map(|s| enum_to_text(&s)))
2593        .bind(new.rollback_error)
2594        .fetch_one(pool)
2595        .await
2596        .map_err(DbError::Query)?;
2597
2598        row_to_tune_write(row)
2599    }
2600
2601    /// Lists every write-back attempt for `run_id`, oldest first — the full "who changed this
2602    /// loop and when" audit trail `history-writeback-audit` exists to provide.
2603    pub async fn list_for_run(pool: &SqlitePool, run_id: i64) -> DbResult<Vec<TuneWriteRow>> {
2604        let rows = sqlx::query("SELECT * FROM tune_writes WHERE run_id = ? ORDER BY written_at")
2605            .bind(run_id)
2606            .fetch_all(pool)
2607            .await
2608            .map_err(DbError::Query)?;
2609        rows.into_iter().map(row_to_tune_write).collect()
2610    }
2611}
2612
2613fn row_to_tune_write(row: SqliteRow) -> DbResult<TuneWriteRow> {
2614    let response_level: String = row.try_get("response_level").map_err(DbError::Query)?;
2615    let kind: String = row.try_get("kind").map_err(DbError::Query)?;
2616    let proportional_previous: Option<f32> = row
2617        .try_get("proportional_previous")
2618        .map_err(DbError::Query)?;
2619    let integral_previous: Option<f32> =
2620        row.try_get("integral_previous").map_err(DbError::Query)?;
2621    let derivative_previous: Option<f32> =
2622        row.try_get("derivative_previous").map_err(DbError::Query)?;
2623    let previous = match (
2624        proportional_previous,
2625        integral_previous,
2626        derivative_previous,
2627    ) {
2628        (Some(proportional), Some(integral), Some(derivative)) => Some(WriteReadback {
2629            proportional,
2630            integral,
2631            derivative,
2632        }),
2633        _ => None,
2634    };
2635    let rollback_state: Option<String> = row.try_get("rollback_state").map_err(DbError::Query)?;
2636    Ok(TuneWriteRow {
2637        id: row.try_get("id").map_err(DbError::Query)?,
2638        run_id: row.try_get("run_id").map_err(DbError::Query)?,
2639        response_level: text_to_enum("response_level", &response_level)?,
2640        written_at: row.try_get("written_at").map_err(DbError::Query)?,
2641        kind: text_to_enum("kind", &kind)?,
2642        allow_uncertain_quality: row
2643            .try_get("allow_uncertain_quality")
2644            .map_err(DbError::Query)?,
2645        previous,
2646        proportional_written: row
2647            .try_get("proportional_written")
2648            .map_err(DbError::Query)?,
2649        integral_written: row.try_get("integral_written").map_err(DbError::Query)?,
2650        derivative_written: row.try_get("derivative_written").map_err(DbError::Query)?,
2651        proportional_readback: row
2652            .try_get("proportional_readback")
2653            .map_err(DbError::Query)?,
2654        integral_readback: row.try_get("integral_readback").map_err(DbError::Query)?,
2655        derivative_readback: row.try_get("derivative_readback").map_err(DbError::Query)?,
2656        success: row.try_get("success").map_err(DbError::Query)?,
2657        error_message: row.try_get("error_message").map_err(DbError::Query)?,
2658        rollback_state: rollback_state
2659            .map(|s| text_to_enum("rollback_state", &s))
2660            .transpose()?,
2661        rollback_error: row.try_get("rollback_error").map_err(DbError::Query)?,
2662    })
2663}
2664// }}}1
2665
2666// settings {{{1
2667
2668/// One row of `settings`: an app-wide key/value pair (e.g. the `history-retention` policy).
2669#[derive(Debug, Clone, PartialEq)]
2670pub struct SettingRow {
2671    pub key: String,
2672    pub value: serde_json::Value,
2673    pub updated_at: DateTime<Utc>,
2674}
2675
2676impl SettingRow {
2677    /// Loads one app-wide setting by key, returning `None` when it has not been stored yet.
2678    ///
2679    /// The database constraint guarantees that `value` is syntactically valid JSON, but the
2680    /// shape is intentionally left to the feature that owns the key. Parsing it here keeps
2681    /// callers from having to duplicate the TEXT-to-JSON conversion.
2682    pub async fn get(pool: &SqlitePool, key: &str) -> DbResult<Option<Self>> {
2683        let row = sqlx::query("SELECT key, value, updated_at FROM settings WHERE key = ?")
2684            .bind(key)
2685            .fetch_optional(pool)
2686            .await
2687            .map_err(DbError::Query)?;
2688
2689        row.map(setting_from_row).transpose()
2690    }
2691
2692    /// Inserts or replaces one app-wide setting and returns the stored row.
2693    ///
2694    /// `updated_at` is supplied by the caller so database tests and clock ownership remain
2695    /// deterministic, matching the other repository methods in this module.
2696    pub async fn upsert(
2697        pool: &SqlitePool,
2698        key: &str,
2699        value: &serde_json::Value,
2700        updated_at: DateTime<Utc>,
2701    ) -> DbResult<Self> {
2702        let value_json = value.to_string();
2703        let row = sqlx::query(
2704            "INSERT INTO settings (key, value, updated_at)
2705             VALUES (?, ?, ?)
2706             ON CONFLICT(key) DO UPDATE SET
2707                 value = excluded.value,
2708                 updated_at = excluded.updated_at
2709             RETURNING key, value, updated_at",
2710        )
2711        .bind(key)
2712        .bind(value_json)
2713        .bind(updated_at)
2714        .fetch_one(pool)
2715        .await
2716        .map_err(DbError::Query)?;
2717
2718        setting_from_row(row)
2719    }
2720}
2721
2722fn setting_from_row(row: SqliteRow) -> DbResult<SettingRow> {
2723    let value_json: String = row.try_get("value").map_err(DbError::Query)?;
2724    let value = serde_json::from_str(&value_json).map_err(|source| DbError::InvalidJsonShape {
2725        column: "settings.value",
2726        source,
2727    })?;
2728
2729    Ok(SettingRow {
2730        key: row.try_get("key").map_err(DbError::Query)?,
2731        value,
2732        updated_at: row.try_get("updated_at").map_err(DbError::Query)?,
2733    })
2734}
2735// }}}1
2736
2737#[cfg(test)]
2738mod tests {
2739    use super::*;
2740    use crate::convert::{enum_to_text, text_to_enum};
2741
2742    async fn sample_run() -> (crate::SqlitePool, i64) {
2743        let pool = crate::connect_in_memory().await.unwrap();
2744        let template = bhtune_core::built_in_templates().remove(0);
2745        let tags = bhtune_core::LoopTags::derive_from_pv_tag("Unit1.FIC101.PV", &template);
2746        let config = bhtune_core::LoopConfig {
2747            process_type: bhtune_core::ProcessType::Flow,
2748            controller_type: bhtune_core::ControllerType::Pi,
2749            relay_amp_percent: 5.0,
2750            num_cycles_skip: 1,
2751            num_cycles_count: 2,
2752            noise_protection_secs: 3,
2753            mrft_delay_secs: 0,
2754        };
2755        let run = TuneRunRow::start(
2756            &pool,
2757            None,
2758            "Unit1.FIC101.PV",
2759            TuneDriver::Simulator,
2760            config,
2761            TemplateOrigin::Builtin,
2762            &template,
2763            &tags,
2764            chrono::Utc::now(),
2765        )
2766        .await
2767        .unwrap();
2768        (pool, run.id)
2769    }
2770
2771    #[test]
2772    fn tune_driver_round_trips_and_matches_check_constraint() {
2773        let cases = [
2774            (TuneDriver::Opcda, "opcda"),
2775            (TuneDriver::Simulator, "simulator"),
2776            (TuneDriver::Replay, "replay"),
2777        ];
2778        for (variant, text) in cases {
2779            assert_eq!(enum_to_text(&variant), text);
2780            assert_eq!(text_to_enum::<TuneDriver>("driver", text).unwrap(), variant);
2781        }
2782    }
2783
2784    #[test]
2785    fn tune_outcome_round_trips_and_matches_check_constraint() {
2786        let cases = [
2787            (TuneOutcome::Running, "running"),
2788            (TuneOutcome::Completed, "completed"),
2789            (TuneOutcome::Failed, "failed"),
2790            (TuneOutcome::Aborted, "aborted"),
2791        ];
2792        for (variant, text) in cases {
2793            assert_eq!(enum_to_text(&variant), text);
2794            assert_eq!(
2795                text_to_enum::<TuneOutcome>("outcome", text).unwrap(),
2796                variant
2797            );
2798        }
2799    }
2800
2801    #[test]
2802    fn from_calculated_builds_matching_row() {
2803        let tuning = TuningResult {
2804            response_level: ResponseLevel::Moderate,
2805            kp: 1.0,
2806            ti_minutes: 2.0,
2807            td_minutes: 0.0,
2808        };
2809        let pid = PidParameters {
2810            response_level: ResponseLevel::Moderate,
2811            proportional: 3.0,
2812            integral: 4.0,
2813            derivative: 0.0,
2814        };
2815        let row = TuneResultRow::from_calculated(42, tuning, pid);
2816        assert_eq!(row.run_id, 42);
2817        assert_eq!(row.response_level, ResponseLevel::Moderate);
2818        assert_eq!(row.kp, Some(1.0));
2819        assert_eq!(row.proportional, Some(3.0));
2820    }
2821
2822    #[test]
2823    #[should_panic(expected = "same ResponseLevel")]
2824    fn from_calculated_panics_on_mismatched_response_level() {
2825        let tuning = TuningResult {
2826            response_level: ResponseLevel::Aggressive,
2827            kp: 1.0,
2828            ti_minutes: 2.0,
2829            td_minutes: 0.0,
2830        };
2831        let pid = PidParameters {
2832            response_level: ResponseLevel::Sluggish,
2833            proportional: 3.0,
2834            integral: 4.0,
2835            derivative: 0.0,
2836        };
2837        TuneResultRow::from_calculated(1, tuning, pid);
2838    }
2839
2840    #[tokio::test]
2841    async fn record_restore_status_round_trips_both_status_shapes() {
2842        let (pool, run_id) = sample_run().await;
2843        let confirmed =
2844            TuneRunRow::record_restore_status(&pool, run_id, RestoreStatus::Confirmed, None)
2845                .await
2846                .unwrap();
2847        assert_eq!(confirmed.restore_status, Some(RestoreStatus::Confirmed));
2848        assert_eq!(confirmed.restore_detail, None);
2849
2850        let incomplete = TuneRunRow::record_restore_status(
2851            &pool,
2852            run_id,
2853            RestoreStatus::Incomplete,
2854            Some("MV restore failed"),
2855        )
2856        .await
2857        .unwrap();
2858        assert_eq!(incomplete.restore_status, Some(RestoreStatus::Incomplete));
2859        assert_eq!(
2860            incomplete.restore_detail.as_deref(),
2861            Some("MV restore failed")
2862        );
2863    }
2864
2865    #[tokio::test]
2866    async fn tune_run_delete_reports_both_existing_and_missing_ids() {
2867        let (pool, run_id) = sample_run().await;
2868        assert!(TuneRunRow::delete(&pool, run_id).await.unwrap());
2869        assert!(!TuneRunRow::delete(&pool, run_id).await.unwrap());
2870    }
2871
2872    #[tokio::test]
2873    async fn malformed_tune_run_json_is_reported_with_the_respective_column() {
2874        for (column, expected) in [
2875            ("template_snapshot_json", "template_snapshot_json"),
2876            ("tags_json", "tags_json"),
2877            ("timing_metrics_json", "timing_metrics_json"),
2878            ("effective_tuning_json", "effective_tuning_json"),
2879        ] {
2880            let (pool, run_id) = sample_run().await;
2881            let query = match column {
2882                "template_snapshot_json" => {
2883                    sqlx::query("UPDATE tune_runs SET template_snapshot_json = ? WHERE id = ?")
2884                }
2885                "tags_json" => sqlx::query("UPDATE tune_runs SET tags_json = ? WHERE id = ?"),
2886                "timing_metrics_json" => {
2887                    sqlx::query("UPDATE tune_runs SET timing_metrics_json = ? WHERE id = ?")
2888                }
2889                "effective_tuning_json" => {
2890                    sqlx::query("UPDATE tune_runs SET effective_tuning_json = ? WHERE id = ?")
2891                }
2892                _ => unreachable!(),
2893            };
2894            query
2895                .bind("\"wrong-shape\"")
2896                .bind(run_id)
2897                .execute(&pool)
2898                .await
2899                .unwrap();
2900            let err = TuneRunRow::get(&pool, run_id).await.unwrap_err();
2901            assert!(matches!(
2902                err,
2903                DbError::InvalidJsonShape { column: actual, .. } if actual == expected
2904            ));
2905        }
2906    }
2907
2908    #[tokio::test]
2909    async fn malformed_setting_json_is_reported_as_invalid_shape() {
2910        let pool = crate::connect_in_memory().await.unwrap();
2911        sqlx::query("CREATE TABLE malformed_settings (key TEXT, value TEXT, updated_at TEXT)")
2912            .execute(&pool)
2913            .await
2914            .unwrap();
2915        sqlx::query("INSERT INTO malformed_settings (key, value, updated_at) VALUES (?, ?, ?)")
2916            .bind("draft")
2917            .bind("{not-json")
2918            .bind(chrono::Utc::now())
2919            .execute(&pool)
2920            .await
2921            .unwrap();
2922
2923        let row = sqlx::query("SELECT key, value, updated_at FROM malformed_settings")
2924            .fetch_one(&pool)
2925            .await
2926            .unwrap();
2927        let err = setting_from_row(row).unwrap_err();
2928        assert!(matches!(
2929            err,
2930            DbError::InvalidJsonShape {
2931                column: "settings.value",
2932                ..
2933            }
2934        ));
2935    }
2936}