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
+55 -43
View File
@@ -46,7 +46,9 @@ pub async fn run(args: Args) -> Result<()> {
.context("open or create project")?;
let db_path = project.db_path();
let display_name = project.display_name().to_string();
let database = Database::open(db_path.as_path()).context("open database")?;
let persistence = crate::persistence::Persistence::new(project.path().to_path_buf());
let database = Database::open_with_persistence(db_path.as_path(), persistence)
.context("open database")?;
let mut terminal = setup_terminal().context("setup terminal")?;
let result = run_loop(&mut terminal, args.theme, database, display_name).await;
@@ -57,7 +59,14 @@ pub async fn run(args: Args) -> Result<()> {
// `project` (and the lock it holds) is dropped here, releasing
// the lock file *after* the terminal has been restored.
drop(project);
result
// ADR-0015 §8: a fatal persistence failure makes its
// banner visible above the shell prompt by writing to
// stderr after the alternate screen has been left.
if let Ok(Some(banner)) = &result {
eprintln!("{banner}");
}
result.map(|_| ())
}
async fn run_loop(
@@ -65,7 +74,7 @@ async fn run_loop(
theme: Theme,
database: Database,
project_display_name: String,
) -> Result<()> {
) -> Result<Option<String>> {
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
let reader_handle = spawn_event_reader(event_tx.clone());
@@ -92,8 +101,8 @@ async fn run_loop(
debug!("quit action received");
should_quit = true;
}
Action::ExecuteDsl(command) => {
spawn_dsl_dispatch(database.clone(), event_tx.clone(), command);
Action::ExecuteDsl { command, source } => {
spawn_dsl_dispatch(database.clone(), event_tx.clone(), command, source);
}
}
}
@@ -108,7 +117,7 @@ async fn run_loop(
let _ = tokio::time::timeout(SHUTDOWN_GRACE, reader_handle).await;
info!("event loop exited");
Ok(())
Ok(app.fatal_message.clone())
}
async fn seed_initial_tables(database: &Database, event_tx: &mpsc::Sender<AppEvent>) {
@@ -128,9 +137,10 @@ fn spawn_dsl_dispatch(
database: Database,
event_tx: mpsc::Sender<AppEvent>,
command: Command,
source: String,
) {
tokio::spawn(async move {
let outcome = execute_command(&database, command.clone()).await;
let outcome = execute_command_typed(&database, command.clone(), source).await;
let event = match outcome {
Ok(CommandOutcome::Schema(description)) => AppEvent::DslSucceeded {
command: command.clone(),
@@ -152,9 +162,18 @@ fn spawn_dsl_dispatch(
command: command.clone(),
result,
},
Err(DbError::PersistenceFatal {
operation,
path,
message,
}) => AppEvent::PersistenceFatal {
operation: operation.to_string(),
path,
message,
},
Err(error) => AppEvent::DslFailed {
command: command.clone(),
error,
error: error.friendly_message(),
},
};
if event_tx.send(event).await.is_err() {
@@ -181,30 +200,33 @@ enum CommandOutcome {
Delete(DeleteResult),
}
async fn execute_command(
/// Execute a parsed user command and return either a typed
/// `CommandOutcome` or the raw `DbError`. Keeping the typed
/// error here lets us distinguish persistence-fatal failures
/// from ordinary user errors at dispatch time (ADR-0015 §8).
async fn execute_command_typed(
database: &Database,
command: Command,
) -> Result<CommandOutcome, String> {
source: String,
) -> Result<CommandOutcome, DbError> {
let src = Some(source);
match command {
Command::CreateTable {
name,
columns,
primary_key,
} => database
.create_table(name, columns, primary_key)
.create_table(name, columns, primary_key, src)
.await
.map(|d| CommandOutcome::Schema(Some(d)))
.map_err(friendly),
.map(|d| CommandOutcome::Schema(Some(d))),
Command::DropTable { name } => database
.drop_table(name)
.drop_table(name, src)
.await
.map(|()| CommandOutcome::Schema(None))
.map_err(friendly),
.map(|()| CommandOutcome::Schema(None)),
Command::AddColumn { table, column, ty } => database
.add_column(table, column, ty)
.add_column(table, column, ty, src)
.await
.map(|d| CommandOutcome::Schema(Some(d)))
.map_err(friendly),
.map(|d| CommandOutcome::Schema(Some(d))),
Command::AddRelationship {
name,
parent_table,
@@ -224,55 +246,45 @@ async fn execute_command(
on_delete,
on_update,
create_fk,
src,
)
.await
.map(|d| CommandOutcome::Schema(Some(d)))
.map_err(friendly),
.map(|d| CommandOutcome::Schema(Some(d))),
Command::DropRelationship { selector } => database
.drop_relationship(selector)
.drop_relationship(selector, src)
.await
.map(CommandOutcome::Schema)
.map_err(friendly),
.map(CommandOutcome::Schema),
Command::ShowTable { name } => database
.describe_table(name)
.describe_table(name, src)
.await
.map(|d| CommandOutcome::Schema(Some(d)))
.map_err(friendly),
.map(|d| CommandOutcome::Schema(Some(d))),
Command::Insert {
table,
columns,
values,
} => database
.insert(table, columns, values)
.insert(table, columns, values, src)
.await
.map(CommandOutcome::Insert)
.map_err(friendly),
.map(CommandOutcome::Insert),
Command::Update {
table,
assignments,
filter,
} => database
.update(table, assignments, filter)
.update(table, assignments, filter, src)
.await
.map(CommandOutcome::Update)
.map_err(friendly),
.map(CommandOutcome::Update),
Command::Delete { table, filter } => database
.delete(table, filter)
.delete(table, filter, src)
.await
.map(CommandOutcome::Delete)
.map_err(friendly),
.map(CommandOutcome::Delete),
Command::ShowData { name } => database
.query_data(name)
.query_data(name, src)
.await
.map(CommandOutcome::Query)
.map_err(friendly),
.map(CommandOutcome::Query),
}
}
fn friendly(err: DbError) -> String {
err.friendly_message()
}
fn spawn_event_reader(tx: mpsc::Sender<AppEvent>) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut stream = EventStream::new();