feat: ADR-0006 §8 step 3 — wire the snapshot ring into the db worker

- snapshot_then() brackets all 19 mutating dispatch arms: stage a
  pre-op snapshot, finalise on success / discard on rollback; gated
  on a user command source (internal ops like open-time rebuild are
  not snapshotted) and on undo being enabled
- BatchState + BeginBatch/EndBatch requests: a batch takes one
  boundary snapshot, suppresses per-command snapshots, and finalises
  iff a mutation committed (one undo step per replay/batch)
- Undo/Redo/PeekUndo/PeekRedo requests handled in worker_loop with
  &mut conn for the restore; cleanup() sweeps crash leftovers on open
- Database::{undo,redo,peek_undo,peek_redo,begin_batch,end_batch} +
  open_with_persistence_and_undo(); snapshot failures are non-fatal
  (logged), restore failures surface
- 6 Tier-3 integration tests through the real worker

1680 passed / 0 failed / 1 ignored; clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-24 20:31:05 +00:00
parent 64eee3ed6d
commit a97069c02e
2 changed files with 566 additions and 25 deletions
+236
View File
@@ -0,0 +1,236 @@
//! Tier-3 integration tests for the undo/snapshot ring wired into
//! the db worker (ADR-0006 Amendment 1, §8 step 3).
//!
//! These drive the real `Database` worker: a mutation takes a
//! pre-op snapshot, `undo` restores it through the live connection,
//! `redo` re-applies, a batch collapses to a single undo step, and
//! `--no-undo` (undo disabled) takes no snapshots at all.
use std::path::Path;
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, RowFilter, Type, Value};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project;
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio rt")
}
/// Open a fresh temp project with undo enabled (or not).
fn open_project(
data: &tempfile::TempDir,
undo_enabled: bool,
) -> (project::Project, Database, std::path::PathBuf) {
let project = project::open_or_create(None, Some(data.path())).expect("open project");
let path = project.path().to_path_buf();
let persistence = Persistence::new(path.clone());
let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, undo_enabled)
.expect("open db");
(project, db, path)
}
async fn make_customers(db: &Database) {
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec::new("id".to_string(), Type::Serial),
ColumnSpec::new("Name".to_string(), Type::Text),
],
vec!["id".to_string()],
Some("create table Customers with pk id(serial)".to_string()),
)
.await
.unwrap();
}
async fn insert_named(db: &Database, name: &str) {
db.insert(
"Customers".to_string(),
None,
vec![Value::Text(name.to_string())],
Some(format!("insert into Customers ('{name}')")),
)
.await
.unwrap();
}
async fn row_count(db: &Database) -> usize {
db.query_data("Customers".to_string(), None, None, None)
.await
.unwrap()
.rows
.len()
}
fn snapshots_dir(path: &Path) -> std::path::PathBuf {
path.join(".snapshots")
}
#[test]
fn mutation_snapshots_and_undo_restores_through_the_worker() {
let data = tempdir();
let (_p, db, path) = open_project(&data, true);
rt().block_on(async {
make_customers(&db).await;
insert_named(&db, "Alice").await;
insert_named(&db, "Bob").await;
assert_eq!(row_count(&db).await, 2);
// Destructive op: delete Bob (id = 2).
db.delete(
"Customers".to_string(),
RowFilter::eq("id", Value::Number("2".to_string())),
Some("delete from Customers where id = 2".to_string()),
)
.await
.unwrap();
assert_eq!(row_count(&db).await, 1);
// The pending undo names the delete.
let peek = db.peek_undo().await.unwrap().expect("an undo entry");
assert_eq!(peek.command, "delete from Customers where id = 2");
// Undo restores Bob.
let undone = db.undo().await.unwrap().expect("undo applied");
assert_eq!(undone.command, "delete from Customers where id = 2");
assert_eq!(row_count(&db).await, 2, "Bob restored by undo");
});
assert!(snapshots_dir(&path).exists(), "snapshots dir created");
}
#[test]
fn redo_reapplies_the_undone_command() {
let data = tempdir();
let (_p, db, _path) = open_project(&data, true);
rt().block_on(async {
make_customers(&db).await;
insert_named(&db, "Alice").await;
insert_named(&db, "Bob").await;
db.delete(
"Customers".to_string(),
RowFilter::eq("id", Value::Number("2".to_string())),
Some("delete from Customers where id = 2".to_string()),
)
.await
.unwrap();
assert_eq!(row_count(&db).await, 1);
db.undo().await.unwrap();
assert_eq!(row_count(&db).await, 2);
let redone = db.redo().await.unwrap().expect("redo applied");
assert_eq!(redone.command, "delete from Customers where id = 2");
assert_eq!(row_count(&db).await, 1, "delete re-applied by redo");
});
}
#[test]
fn new_work_after_undo_clears_redo() {
let data = tempdir();
let (_p, db, _path) = open_project(&data, true);
rt().block_on(async {
make_customers(&db).await;
insert_named(&db, "Alice").await;
db.delete(
"Customers".to_string(),
RowFilter::AllRows,
Some("delete from Customers --all-rows".to_string()),
)
.await
.unwrap();
db.undo().await.unwrap();
assert!(db.peek_redo().await.unwrap().is_some(), "redo available");
// New destructive work.
insert_named(&db, "Carol").await;
assert!(
db.peek_redo().await.unwrap().is_none(),
"new work discards the redo stack"
);
});
}
#[test]
fn undo_disabled_takes_no_snapshots() {
let data = tempdir();
let (_p, db, path) = open_project(&data, false);
rt().block_on(async {
make_customers(&db).await;
insert_named(&db, "Alice").await;
db.delete(
"Customers".to_string(),
RowFilter::AllRows,
Some("delete from Customers --all-rows".to_string()),
)
.await
.unwrap();
// Nothing to undo, and no snapshot machinery on disk.
assert!(db.undo().await.unwrap().is_none(), "undo is a no-op when disabled");
assert!(db.peek_undo().await.unwrap().is_none());
});
assert!(
!snapshots_dir(&path).exists(),
"no .snapshots dir when undo is disabled"
);
}
#[test]
fn batch_records_a_single_undo_step() {
let data = tempdir();
let (_p, db, _path) = open_project(&data, true);
rt().block_on(async {
make_customers(&db).await; // one undo entry (the create)
// A batch of three inserts → one boundary snapshot.
db.begin_batch(Some("replay history.log".to_string()))
.await
.unwrap();
insert_named(&db, "Alice").await;
insert_named(&db, "Bob").await;
insert_named(&db, "Carol").await;
db.end_batch().await.unwrap();
assert_eq!(row_count(&db).await, 3);
// The single batch undo names the batch command.
let peek = db.peek_undo().await.unwrap().expect("batch undo entry");
assert_eq!(peek.command, "replay history.log");
// One undo rolls the whole batch back to the pre-batch state
// (table exists, no rows).
db.undo().await.unwrap();
assert_eq!(row_count(&db).await, 0, "whole batch undone in one step");
// The next undo is the create_table (table gone).
let next = db.peek_undo().await.unwrap().expect("create entry");
assert_eq!(next.command, "create table Customers with pk id(serial)");
});
}
#[test]
fn empty_undo_and_redo_are_no_ops() {
let data = tempdir();
let (_p, db, _path) = open_project(&data, true);
rt().block_on(async {
assert!(db.undo().await.unwrap().is_none());
assert!(db.redo().await.unwrap().is_none());
assert!(db.peek_undo().await.unwrap().is_none());
assert!(db.peek_redo().await.unwrap().is_none());
});
}