Iteration 2: per-command write-through to project.yaml, CSVs, history.log

Every successful user command now persists through to YAML, the
affected CSVs, and history.log inside the same SQLite transaction,
with the commit-db-last ordering from ADR-0015 §6: validate ->
mutate -> stage text + fsync -> atomic rename -> append history ->
commit. A failure in any text-write step rolls back the SQLite tx,
so disk state is unchanged on failure. Persistence failures are
routed through a new AppEvent::PersistenceFatal which sets a
fatal_message on the App, emits Action::Quit, and is printed to
stderr after terminal teardown so the banner remains above the
shell prompt (ADR-0015 §8).

New persistence module owns the file formats: hand-rolled YAML
schema writer, per-type CSV encoder (RFC 4180, NULL distinct from
empty string, base64 blobs), append-only history.log with ISO-8601
timestamps and successful-only entries. Atomic per-file writes via
tmp + fsync + rename.

The db worker holds an Option<Persistence>; tests still use
Database::open(":memory:") with no persistence. Action::ExecuteDsl
gains a source field carrying the user-typed text, threaded
through to history.log.

Tests: 289 passing (256 lib + 7 new integration + 9 lifecycle + 17
walking-skeleton), 0 failing, 0 skipped. Clippy clean with nursery
lints.
This commit is contained in:
claude@clouddev1
2026-05-07 21:09:15 +00:00
parent 601d3b6c51
commit 5c076f6d8f
15 changed files with 2275 additions and 213 deletions
+371
View File
@@ -0,0 +1,371 @@
//! Iteration-2 integration tests: per-command write-through
//! to `project.yaml`, `data/<table>.csv`, and `history.log`
//! (ADR-0015 §3-§6).
//!
//! These tests exercise the full path from
//! `Database::open_with_persistence` through a successful
//! command into the on-disk text targets. They use
//! `Database::open_with_persistence(...)` so the worker
//! thread runs the persistence callbacks the runtime would.
use std::fs;
use std::path::Path;
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, RowFilter, Type, Value};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{
self, DATA_DIR, HISTORY_LOG, PROJECT_YAML,
};
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 project under a fresh data root and return the
/// `Database` (with persistence wired) plus the path so the
/// test can inspect on-disk state. The project is held alive
/// implicitly via the leaked `TempDir` returned alongside.
fn open_project(
data: &tempfile::TempDir,
) -> (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(project.db_path(), persistence)
.expect("open db with persistence");
(project, db, path)
}
fn read_history(project_path: &Path) -> Vec<String> {
let body = fs::read_to_string(project_path.join(HISTORY_LOG)).unwrap_or_default();
body.lines().map(str::to_string).collect()
}
fn read_yaml(project_path: &Path) -> String {
fs::read_to_string(project_path.join(PROJECT_YAML)).expect("project.yaml")
}
fn read_csv(project_path: &Path, table: &str) -> Option<String> {
fs::read_to_string(project_path.join(DATA_DIR).join(format!("{table}.csv"))).ok()
}
#[test]
fn create_table_writes_yaml_and_history() {
let data = tempdir();
let (_p, db, path) = open_project(&data);
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec { name: "id".to_string(), ty: Type::Serial },
ColumnSpec { name: "Name".to_string(), ty: Type::Text },
],
vec!["id".to_string()],
Some("create table Customers with pk id:serial".to_string()),
)
.await
.unwrap();
});
let yaml = read_yaml(&path);
assert!(yaml.contains("- name: Customers"), "yaml missing table:\n{yaml}");
assert!(yaml.contains("primary_key: [id]"), "yaml: {yaml}");
assert!(yaml.contains("type: serial"), "yaml: {yaml}");
assert!(yaml.contains("type: text"), "yaml: {yaml}");
let history = read_history(&path);
assert_eq!(history.len(), 1, "expected one history line; got {history:?}");
assert!(history[0].ends_with("|ok|create table Customers with pk id:serial"));
}
#[test]
fn insert_writes_csv_and_history() {
let data = tempdir();
let (_p, db, path) = open_project(&data);
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec { name: "id".to_string(), ty: Type::Serial },
ColumnSpec { name: "Name".to_string(), ty: Type::Text },
],
vec!["id".to_string()],
Some("create table Customers with pk id:serial".to_string()),
)
.await
.unwrap();
db.insert(
"Customers".to_string(),
None,
vec![Value::Text("Alice".to_string())],
Some("insert into Customers ('Alice')".to_string()),
)
.await
.unwrap();
});
let csv = read_csv(&path, "Customers").expect("Customers.csv missing");
let lines: Vec<&str> = csv.trim_end().lines().collect();
assert_eq!(lines[0], "id,Name");
assert_eq!(lines[1], "1,Alice");
let history = read_history(&path);
assert!(
history.iter().any(|l| l.ends_with("|ok|insert into Customers ('Alice')")),
"history missing insert: {history:?}",
);
}
#[test]
fn drop_table_removes_its_csv() {
let data = tempdir();
let (_p, db, path) = open_project(&data);
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![ColumnSpec { name: "id".to_string(), ty: Type::Serial }],
vec!["id".to_string()],
Some("create table Customers with pk id:serial".to_string()),
)
.await
.unwrap();
db.insert(
"Customers".to_string(),
Some(vec!["id".to_string()]),
vec![Value::Number("42".to_string())],
Some("insert into Customers (id) values (42)".to_string()),
)
.await
.unwrap();
// The CSV exists before drop.
assert!(read_csv(&path, "Customers").is_some());
db.drop_table(
"Customers".to_string(),
Some("drop table Customers".to_string()),
)
.await
.unwrap();
});
assert!(read_csv(&path, "Customers").is_none(), "CSV should be deleted");
let yaml = read_yaml(&path);
assert!(!yaml.contains("- name: Customers"), "table should be gone from yaml:\n{yaml}");
}
#[test]
fn delete_with_cascade_rewrites_both_csvs() {
let data = tempdir();
let (_p, db, path) = open_project(&data);
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![ColumnSpec { name: "id".to_string(), ty: Type::Serial }],
vec!["id".to_string()],
Some("create table Customers with pk id:serial".to_string()),
)
.await
.unwrap();
db.create_table(
"Orders".to_string(),
vec![
ColumnSpec { name: "id".to_string(), ty: Type::Serial },
ColumnSpec { name: "CustId".to_string(), ty: Type::Int },
],
vec!["id".to_string()],
Some("create table Orders with pk id:serial, CustId:int".to_string()),
)
.await
.unwrap();
db.add_relationship(
None,
"Customers".to_string(),
"id".to_string(),
"Orders".to_string(),
"CustId".to_string(),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
Some(
"add 1:n relationship from Customers.id to Orders.CustId on delete cascade"
.to_string(),
),
)
.await
.unwrap();
// Customers has only a serial PK; long-form INSERT with
// an explicit id keeps the test independent of short-form
// semantics for "all-auto-generated" tables.
db.insert(
"Customers".to_string(),
Some(vec!["id".to_string()]),
vec![Value::Number("1".to_string())],
Some("insert into Customers (id) values (1)".to_string()),
)
.await
.unwrap();
db.insert(
"Orders".to_string(),
Some(vec!["CustId".to_string()]),
vec![Value::Number("1".to_string())],
Some("insert into Orders (CustId) values (1)".to_string()),
)
.await
.unwrap();
// Cascade delete from Customers should also clean Orders.
let result = db
.delete(
"Customers".to_string(),
RowFilter::Where {
column: "id".to_string(),
value: Value::Number("1".to_string()),
},
Some("delete from Customers where id=1".to_string()),
)
.await
.unwrap();
assert_eq!(result.rows_affected, 1);
});
let customers_csv = read_csv(&path, "Customers").expect("Customers.csv");
let orders_csv = read_csv(&path, "Orders").expect("Orders.csv");
// Both CSVs should be header-only after cascade.
assert_eq!(customers_csv.lines().count(), 1, "got: {customers_csv}");
assert_eq!(orders_csv.lines().count(), 1, "got: {orders_csv}");
}
#[test]
fn show_table_appends_history_only() {
let data = tempdir();
let (_p, db, path) = open_project(&data);
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![ColumnSpec { name: "id".to_string(), ty: Type::Serial }],
vec!["id".to_string()],
Some("create table Customers with pk id:serial".to_string()),
)
.await
.unwrap();
let yaml_before = read_yaml(&path);
db.describe_table(
"Customers".to_string(),
Some("show table Customers".to_string()),
)
.await
.unwrap();
let yaml_after = read_yaml(&path);
// YAML body did not change for a read-only command.
assert_eq!(yaml_before, yaml_after);
});
let history = read_history(&path);
assert!(
history.iter().any(|l| l.ends_with("|ok|show table Customers")),
"history missing show entry: {history:?}",
);
}
#[test]
fn failed_command_does_not_append_history_or_change_yaml() {
let data = tempdir();
let (_p, db, path) = open_project(&data);
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![ColumnSpec { name: "id".to_string(), ty: Type::Serial }],
vec!["id".to_string()],
Some("create table Customers with pk id:serial".to_string()),
)
.await
.unwrap();
let yaml_before = read_yaml(&path);
// Same name again — should fail.
let err = db
.create_table(
"Customers".to_string(),
vec![ColumnSpec { name: "id".to_string(), ty: Type::Serial }],
vec!["id".to_string()],
Some("create table Customers with pk id:serial".to_string()),
)
.await
.expect_err("must fail");
let _ = err;
let yaml_after = read_yaml(&path);
assert_eq!(yaml_before, yaml_after, "failed cmd must not change yaml");
});
let history = read_history(&path);
// Only the first (successful) create_table should have logged.
let create_count = history
.iter()
.filter(|l| l.contains("|ok|create table Customers"))
.count();
assert_eq!(create_count, 1, "expected exactly one logged create; got: {history:?}");
}
#[test]
fn project_yaml_carries_relationship_after_add() {
let data = tempdir();
let (_p, db, path) = open_project(&data);
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![ColumnSpec { name: "id".to_string(), ty: Type::Serial }],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.create_table(
"Orders".to_string(),
vec![
ColumnSpec { name: "id".to_string(), ty: Type::Serial },
ColumnSpec { name: "CustId".to_string(), ty: Type::Int },
],
vec!["id".to_string()],
None,
)
.await
.unwrap();
db.add_relationship(
None,
"Customers".to_string(),
"id".to_string(),
"Orders".to_string(),
"CustId".to_string(),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
Some(
"add 1:n relationship from Customers.id to Orders.CustId on delete cascade"
.to_string(),
),
)
.await
.unwrap();
});
let yaml = read_yaml(&path);
assert!(yaml.contains("- name: Customers_id_to_Orders_CustId"), "yaml: {yaml}");
assert!(yaml.contains("on_delete: cascade"), "yaml: {yaml}");
assert!(yaml.contains("on_update: no_action"), "yaml: {yaml}");
}