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:
+55
-43
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user