| 297 |
297 |
|
|
| 298 |
298 |
|
let mut tx = pool.begin().await?;
|
| 299 |
299 |
|
|
| 300 |
|
- |
// Execute each statement in the migration SQL
|
| 301 |
|
- |
for statement in sql.split(';') {
|
| 302 |
|
- |
let trimmed = statement.trim();
|
| 303 |
|
- |
if !trimmed.is_empty() {
|
| 304 |
|
- |
sqlx::query(trimmed).execute(&mut *tx).await?;
|
| 305 |
|
- |
}
|
| 306 |
|
- |
}
|
|
300 |
+ |
// Execute the whole migration as a batch via `raw_sql`, which hands the
|
|
301 |
+ |
// string to SQLite's own parser to split on statement boundaries. A naive
|
|
302 |
+ |
// `split(';')` breaks on the first `;` inside a trigger body or string
|
|
303 |
+ |
// literal (fuzz-2026-07-06 migration split trap); today's migrations have
|
|
304 |
+ |
// none, but this makes the first such migration safe rather than a startup
|
|
305 |
+ |
// failure.
|
|
306 |
+ |
sqlx::raw_sql(sql).execute(&mut *tx).await?;
|
| 307 |
307 |
|
|
| 308 |
308 |
|
// Record the version inside the same transaction
|
| 309 |
309 |
|
let now = chrono::Utc::now().to_rfc3339();
|
| 494 |
494 |
|
run_id: TestRunId,
|
| 495 |
495 |
|
details: &[TestDetail],
|
| 496 |
496 |
|
) -> Result<()> {
|
|
497 |
+ |
// One transaction for the whole detail set: a crash mid-loop otherwise left a
|
|
498 |
+ |
// partial set, which skews get_test_regressions (fuzz-2026-07-06 crash
|
|
499 |
+ |
// atomicity). All-or-nothing.
|
|
500 |
+ |
let mut tx = pool.begin().await?;
|
| 497 |
501 |
|
for detail in details {
|
| 498 |
502 |
|
sqlx::query(
|
| 499 |
503 |
|
"INSERT INTO test_details (run_id, test_name, passed) VALUES (?, ?, ?)",
|
| 501 |
505 |
|
.bind(run_id.0)
|
| 502 |
506 |
|
.bind(&detail.test_name)
|
| 503 |
507 |
|
.bind(detail.passed)
|
| 504 |
|
- |
.execute(pool)
|
|
508 |
+ |
.execute(&mut *tx)
|
| 505 |
509 |
|
.await?;
|
| 506 |
510 |
|
}
|
|
511 |
+ |
tx.commit().await?;
|
| 507 |
512 |
|
Ok(())
|
| 508 |
513 |
|
}
|
| 509 |
514 |
|
|
| 940 |
945 |
|
Ok(result.rows_affected())
|
| 941 |
946 |
|
}
|
| 942 |
947 |
|
|
|
948 |
+ |
/// Close any open incidents for `target` and open a new one, atomically. A status
|
|
949 |
+ |
/// change like `error -> unreachable` closes the old incident and opens the new
|
|
950 |
+ |
/// one; done as two pool calls, a crash between them left the old incident closed
|
|
951 |
+ |
/// and the new one never recorded (fuzz-2026-07-06 crash atomicity). One tx.
|
|
952 |
+ |
#[instrument(skip_all)]
|
|
953 |
+ |
pub async fn close_and_open_incident(
|
|
954 |
+ |
pool: &SqlitePool,
|
|
955 |
+ |
target: &str,
|
|
956 |
+ |
from_status: &str,
|
|
957 |
+ |
to_status: &str,
|
|
958 |
+ |
) -> Result<()> {
|
|
959 |
+ |
let now = chrono::Utc::now().to_rfc3339();
|
|
960 |
+ |
let mut tx = pool.begin().await?;
|
|
961 |
+ |
sqlx::query(
|
|
962 |
+ |
"UPDATE incidents SET ended_at = ?, duration_secs = CAST((julianday(?) - julianday(started_at)) * 86400 AS INTEGER)
|
|
963 |
+ |
WHERE target = ? AND ended_at IS NULL",
|
|
964 |
+ |
)
|
|
965 |
+ |
.bind(&now)
|
|
966 |
+ |
.bind(&now)
|
|
967 |
+ |
.bind(target)
|
|
968 |
+ |
.execute(&mut *tx)
|
|
969 |
+ |
.await?;
|
|
970 |
+ |
sqlx::query(
|
|
971 |
+ |
"INSERT INTO incidents (target, started_at, from_status, to_status) VALUES (?, ?, ?, ?)",
|
|
972 |
+ |
)
|
|
973 |
+ |
.bind(target)
|
|
974 |
+ |
.bind(&now)
|
|
975 |
+ |
.bind(from_status)
|
|
976 |
+ |
.bind(to_status)
|
|
977 |
+ |
.execute(&mut *tx)
|
|
978 |
+ |
.await?;
|
|
979 |
+ |
tx.commit().await?;
|
|
980 |
+ |
Ok(())
|
|
981 |
+ |
}
|
|
982 |
+ |
|
| 943 |
983 |
|
#[instrument(skip_all)]
|
| 944 |
984 |
|
pub async fn get_open_incident(
|
| 945 |
985 |
|
pool: &SqlitePool,
|