1use std::path::Path;
6
7use bhtune_core::DcsTemplate;
8use bhtune_db::SqlitePool;
9use bhtune_db::models::TemplateOrigin;
10
11pub async fn open(
36 path: &Path,
37 user_templates: Option<Vec<DcsTemplate>>,
38 retention_days: Option<u32>,
39) -> anyhow::Result<SqlitePool> {
40 open_with_orphan_recovery(path, user_templates, retention_days, true).await
41}
42
43pub async fn open_demo(
48 path: &Path,
49 user_templates: Option<Vec<DcsTemplate>>,
50 retention_days: Option<u32>,
51) -> anyhow::Result<SqlitePool> {
52 open_with_orphan_recovery(path, user_templates, retention_days, false).await
53}
54
55async fn open_with_orphan_recovery(
56 path: &Path,
57 user_templates: Option<Vec<DcsTemplate>>,
58 retention_days: Option<u32>,
59 recover_live_orphans: bool,
60) -> anyhow::Result<SqlitePool> {
61 tracing::info!(db_path = %path.display(), "opening database");
62 ensure_parent_dir(path)?;
63 let pool = bhtune_db::connect(path).await?;
64 if recover_live_orphans {
65 let report = crate::tune::recover_startup_orphans(&pool).await?;
66 if report.marked_orphaned > 0
67 || report.interrupted_recoveries > 0
68 || report.retired_unrecoverable > 0
69 || report.skipped_for_live_owner
70 {
71 tracing::warn!(
72 examined = report.examined,
73 marked_orphaned = report.marked_orphaned,
74 recoverable = report.recoverable,
75 interrupted_recoveries = report.interrupted_recoveries,
76 retired_unrecoverable = report.retired_unrecoverable,
77 skipped_for_live_owner = report.skipped_for_live_owner,
78 exports = ?report.exports,
79 "completed live-orphan startup sweep"
80 );
81 }
82 }
83 let now = chrono::Utc::now();
84 let seeded = bhtune_db::seed_builtin_templates(&pool, now).await?;
85 tracing::debug!(templates = seeded.len(), "seeded built-in DCS templates");
86 if let Some(templates) = user_templates {
87 let seeded =
88 bhtune_db::seed_templates(&pool, templates, TemplateOrigin::Catalog, now).await?;
89 let count = seeded.len();
90 tracing::debug!(templates = count, "seeded user catalog DCS templates");
91 }
92 if let Some(days) = retention_days {
93 crate::retention::sweep_retention(&pool, days, now).await?;
94 }
95 Ok(pool)
96}
97
98pub async fn open_read_only(path: &Path) -> anyhow::Result<Option<SqlitePool>> {
101 if !tokio::fs::try_exists(path).await? {
102 return Ok(None);
103 }
104 Ok(Some(bhtune_db::connect_read_only(path).await?))
105}
106
107fn ensure_parent_dir(path: &Path) -> anyhow::Result<()> {
112 path.parent()
113 .map(|parent| {
114 std::fs::create_dir_all(parent)
115 .map_err(|e| anyhow::anyhow!("failed to create database directory {parent:?}: {e}"))
116 })
117 .transpose()
118 .map(|_| ())
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use bhtune_db::models::DcsTemplateRow;
125
126 fn sample_catalog_template() -> DcsTemplate {
127 let mut template = bhtune_core::built_in_templates().remove(0);
128 template.name = "Test Catalog Template".to_string();
129 template
130 }
131
132 #[tokio::test]
133 async fn open_seeds_builtin_templates_on_a_fresh_database() {
134 let dir = tempfile::tempdir().unwrap();
135 let path = dir.path().join("bhtune.db");
136 let pool = open(&path, None, None).await.unwrap();
137 let templates = bhtune_db::models::DcsTemplateRow::list(&pool)
138 .await
139 .unwrap();
140 assert_eq!(templates.len(), 4);
141 }
142
143 #[tokio::test]
144 async fn open_read_only_does_not_create_a_missing_database() {
145 let dir = tempfile::tempdir().unwrap();
146 let path = dir.path().join("missing.db");
147
148 assert!(open_read_only(&path).await.unwrap().is_none());
149 assert!(!path.exists());
150 }
151
152 #[tokio::test]
153 async fn open_read_only_opens_an_existing_database_without_seeding() {
154 let dir = tempfile::tempdir().unwrap();
155 let path = dir.path().join("existing.db");
156 bhtune_db::connect(&path).await.unwrap().close().await;
157
158 let pool = open_read_only(&path).await.unwrap().unwrap();
159 assert!(DcsTemplateRow::list(&pool).await.unwrap().is_empty());
160 pool.close().await;
161 }
162
163 #[tokio::test]
164 async fn open_is_idempotent_across_repeated_calls() {
165 let dir = tempfile::tempdir().unwrap();
166 let path = dir.path().join("bhtune.db");
167 open(&path, None, None).await.unwrap();
168 let pool = open(&path, None, None).await.unwrap();
169 let templates = bhtune_db::models::DcsTemplateRow::list(&pool)
170 .await
171 .unwrap();
172 assert_eq!(templates.len(), 4);
173 }
174
175 #[tokio::test]
176 async fn open_keeps_running_when_a_live_owner_blocks_the_startup_sweep() {
177 let dir = tempfile::tempdir().unwrap();
178 let path = dir.path().join("bhtune.db");
179 let pool = bhtune_db::connect(&path).await.unwrap();
180 let owner = crate::live_ownership::LiveOperationGuard::acquire(
181 &pool,
182 None,
183 bhtune_db::models::LiveOperationKind::OpcWrite,
184 "127.0.0.1:7602",
185 "Mock.Kepware.Sim",
186 "Simulation.Examples.MV",
187 Some(r#"{"kind":"opc_write","state":"pending"}"#.to_string()),
188 )
189 .await
190 .unwrap();
191
192 let reopened = open(&path, None, None).await.unwrap();
193 owner.release().await.unwrap();
194 reopened.close().await;
195 pool.close().await;
196 }
197
198 #[tokio::test]
199 async fn open_creates_missing_nested_parent_directories() {
200 let dir = tempfile::tempdir().unwrap();
201 let path = dir.path().join("nested").join("deeper").join("bhtune.db");
202 assert!(!path.parent().unwrap().exists());
203 let pool = open(&path, None, None).await.unwrap();
204 assert!(path.exists());
205 let templates = bhtune_db::models::DcsTemplateRow::list(&pool)
206 .await
207 .unwrap();
208 assert_eq!(templates.len(), 4);
209 }
210
211 #[tokio::test]
212 async fn open_seeds_user_catalog_templates_with_catalog_origin_when_provided() {
213 let dir = tempfile::tempdir().unwrap();
214 let path = dir.path().join("bhtune.db");
215 let pool = open(&path, Some(vec![sample_catalog_template()]), None)
216 .await
217 .unwrap();
218 let templates = DcsTemplateRow::list(&pool).await.unwrap();
219 assert_eq!(templates.len(), 5);
220 let seeded = templates
221 .iter()
222 .find(|t| t.template.name == "Test Catalog Template")
223 .expect("user catalog template should have been seeded");
224 assert_eq!(seeded.origin, TemplateOrigin::Catalog);
225 }
226
227 #[tokio::test]
228 async fn open_reseeding_the_same_user_catalog_is_idempotent() {
229 let dir = tempfile::tempdir().unwrap();
230 let path = dir.path().join("bhtune.db");
231 open(&path, Some(vec![sample_catalog_template()]), None)
232 .await
233 .unwrap();
234 let pool = open(&path, Some(vec![sample_catalog_template()]), None)
235 .await
236 .unwrap();
237 let templates = DcsTemplateRow::list(&pool).await.unwrap();
238 assert_eq!(templates.len(), 5);
239 }
240
241 #[tokio::test]
242 async fn open_sweeps_retention_on_startup_when_a_policy_is_configured() {
243 use bhtune_core::{ControllerType, LoopConfig, LoopTags, ProcessType};
244 use bhtune_db::models::{TuneDriver, TuneRunRow};
245
246 let dir = tempfile::tempdir().unwrap();
247 let path = dir.path().join("bhtune.db");
248 let pool = open(&path, None, None).await.unwrap();
251 let template = bhtune_core::built_in_templates().remove(0);
252 let tags = LoopTags::derive_from_pv_tag("Unit1.LIC101.PV", &template);
253 let config = LoopConfig {
254 process_type: ProcessType::Flow,
255 controller_type: ControllerType::Pi,
256 relay_amp_percent: 5.0,
257 num_cycles_skip: 1,
258 num_cycles_count: 2,
259 noise_protection_secs: 3,
260 mrft_delay_secs: 0,
261 };
262 let old_started_at = chrono::Utc::now() - chrono::Duration::days(400);
263 let old_run = TuneRunRow::start(
264 &pool,
265 None,
266 "LIC-X",
267 TuneDriver::Simulator,
268 config,
269 TemplateOrigin::Builtin,
270 &template,
271 &tags,
272 old_started_at,
273 )
274 .await
275 .unwrap();
276 pool.close().await;
277
278 let pool = open(&path, None, Some(30)).await.unwrap();
280 assert!(TuneRunRow::get(&pool, old_run.id).await.unwrap().is_none());
281 }
282
283 #[test]
284 fn ensure_parent_dir_is_a_no_op_for_a_bare_filename() {
285 ensure_parent_dir(Path::new("bhtune.db")).unwrap();
289 }
290}