Iteration 6: --resume + persistent input history + migration scaffold
Closes out track 2's ADR-0015 backlog. * `--resume` CLI flag (L1a, ADR-0015 §7) opens the most- recently-used project, tracked in <data-root>/last_project. Mutually exclusive with a positional <project-path>; errors cleanly to stderr (above the shell prompt) on missing file or stale recorded path. last_project is rewritten on every successful project open (startup, load, new, save as, import). * Persistent input history (I2-persist, ADR-0015 §12). On project open, the in-memory navigable history is hydrated from the tail of history.log (capped at the in-memory cap). ProjectSwitched gains a `history_entries` payload field; App::seed_history is the entry point. Pipes inside source text round-trip via splitn(3); unknown escape sequences are passed through literally. * Migration framework scaffold (F3, ADR-0015 §9). New persistence::migrations module with MigratorRegistry + migrate_to_latest + ensure_project_yaml_migrated. Empty in v1 (production registry has no migrators); the loader runs through it on every project open and is exercised by tests with a fake v1→v2 migrator. Writes project.yaml.v<N>.bak before any migrator runs; verifies each step bumps the version field. Refreshes docs/requirements.md (A1 / I2 / F3 / E1 / L1a / test baseline) and adds docs/handoff/20260508-handoff-3.md covering both Iter 5 and Iter 6. Total tests: 408 passing, 0 failing, 0 skipped (up from 345 at handoff-2). Clippy clean.
This commit is contained in:
+117
-3
@@ -10,6 +10,7 @@
|
||||
//! additional producers.
|
||||
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
@@ -34,7 +35,7 @@ use crate::dsl::Command;
|
||||
use crate::event::AppEvent;
|
||||
use crate::project::{
|
||||
Project, ProjectKind, copy_project, list_projects, open_or_create, projects_dir,
|
||||
resolve_data_root, safely_delete_temp_project,
|
||||
read_last_project, resolve_data_root, safely_delete_temp_project, write_last_project,
|
||||
};
|
||||
use crate::theme::Theme;
|
||||
use crate::ui;
|
||||
@@ -51,8 +52,71 @@ pub async fn run(args: Args) -> Result<()> {
|
||||
// Project alone, so we keep it ourselves.
|
||||
let data_root = resolve_data_root(args.data_dir.as_deref())
|
||||
.context("resolve data root")?;
|
||||
let project = open_or_create(args.project_path.as_deref(), Some(data_root.as_path()))
|
||||
|
||||
// Resolve the initial project path: --resume reads it from
|
||||
// <data-root>/last_project; otherwise an explicit positional
|
||||
// arg, falling back to a fresh auto-named temp.
|
||||
//
|
||||
// ADR-0015 §7: --resume errors out cleanly when the path is
|
||||
// missing or the recorded project no longer exists. We
|
||||
// surface those failures to stderr before booting the
|
||||
// terminal so the message lands directly in the user's
|
||||
// shell.
|
||||
let initial_path: Option<PathBuf> = if args.resume {
|
||||
match read_last_project(&data_root)
|
||||
.context("read last_project")?
|
||||
{
|
||||
Some(p) if p.exists() => Some(p),
|
||||
Some(p) => {
|
||||
eprintln!(
|
||||
"rdbms-playground: --resume: recorded project `{}` no longer exists",
|
||||
p.display(),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
None => {
|
||||
eprintln!(
|
||||
"rdbms-playground: --resume: no previous project recorded under `{}`",
|
||||
data_root.display(),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
args.project_path.clone()
|
||||
};
|
||||
let project = open_or_create(initial_path.as_deref(), Some(data_root.as_path()))
|
||||
.context("open or create project")?;
|
||||
|
||||
// Run any pending project.yaml migrations before the
|
||||
// database opens (so the rebuild path only ever sees the
|
||||
// latest schema). The registry is empty in v1; future
|
||||
// versions register their migrators here. A migration
|
||||
// that runs is recorded in tracing and leaves a
|
||||
// `project.yaml.v<N>.bak` breadcrumb on disk; that's
|
||||
// sufficient v1 UX and lets us defer dedicated event
|
||||
// plumbing until a real migrator demands it.
|
||||
let migrate_registry = crate::persistence::migrations::MigratorRegistry::production();
|
||||
let migration_outcome = crate::persistence::migrations::ensure_project_yaml_migrated(
|
||||
project.path(),
|
||||
&migrate_registry,
|
||||
)
|
||||
.context("migrate project.yaml")?;
|
||||
if let Some(from) = migration_outcome.migrated_from {
|
||||
info!(
|
||||
from_version = from,
|
||||
to_version = migrate_registry.latest_version(),
|
||||
"migrated project.yaml",
|
||||
);
|
||||
}
|
||||
|
||||
// Record the just-opened project as the new resume target.
|
||||
// Write failures here are non-fatal: --resume on the next
|
||||
// launch will report the missing/stale state, which is the
|
||||
// safer default than refusing to launch.
|
||||
if let Err(e) = write_last_project(&data_root, project.path()) {
|
||||
warn!(error = %e, "could not update last_project");
|
||||
}
|
||||
let db_path = project.db_path();
|
||||
let display_name = project.display_name().to_string();
|
||||
let project_path = project.path().to_path_buf();
|
||||
@@ -170,6 +234,11 @@ async fn run_loop(
|
||||
let mut app = App::new();
|
||||
app.project_name = Some(project_display_name);
|
||||
app.project_is_temp = project_is_temp;
|
||||
// Seed the in-memory navigable history from the
|
||||
// initial project's history.log (I2-persist, ADR-0015
|
||||
// §12). Subsequent project switches re-seed via the
|
||||
// `ProjectSwitched` event payload.
|
||||
app.seed_history(read_history_seed(session.project().path()));
|
||||
|
||||
// Send any startup events (e.g., the system-message form
|
||||
// of "rebuilt from text on missing .db") so they're
|
||||
@@ -369,10 +438,12 @@ async fn handle_project_switch(
|
||||
) {
|
||||
match perform_switch(session, req, source).await {
|
||||
Ok((display_name, is_temp)) => {
|
||||
let history_entries = read_history_seed(session.project().path());
|
||||
let _ = event_tx
|
||||
.send(AppEvent::ProjectSwitched {
|
||||
display_name,
|
||||
is_temp,
|
||||
history_entries,
|
||||
})
|
||||
.await;
|
||||
if let Ok(tables) = session.database().list_tables().await {
|
||||
@@ -387,6 +458,28 @@ async fn handle_project_switch(
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the most-recent `HISTORY_HYDRATION_CAP` source lines
|
||||
/// out of the project's `history.log` for input-history
|
||||
/// seeding. Failures are logged and swallowed — an empty
|
||||
/// hydration is the right fallback when the file is unreadable.
|
||||
fn read_history_seed(project_path: &std::path::Path) -> Vec<String> {
|
||||
let p = crate::persistence::Persistence::new(project_path.to_path_buf());
|
||||
match p.read_recent_history(HISTORY_HYDRATION_CAP) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "history hydration failed; starting empty");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum number of `history.log` entries to seed the
|
||||
/// in-memory navigable history with on project open. Matches
|
||||
/// the in-memory cap (`app::HISTORY_CAPACITY`) per ADR-0015
|
||||
/// §12: "latest N entries, where N is the same in-memory
|
||||
/// cap as today."
|
||||
const HISTORY_HYDRATION_CAP: usize = 1000;
|
||||
|
||||
async fn perform_switch(
|
||||
session: &mut Session,
|
||||
req: SwitchRequest,
|
||||
@@ -510,6 +603,19 @@ async fn perform_switch(
|
||||
};
|
||||
let new_path = new_project.path().to_path_buf();
|
||||
|
||||
// Run any pending project.yaml migrations before the
|
||||
// database opens. Same registry as `run()`. A failed
|
||||
// migration aborts the switch (the old project has
|
||||
// already been dropped — user lands in a "no project"
|
||||
// state momentarily, but the next user action will
|
||||
// surface the error and they can retry).
|
||||
let migrate_registry = crate::persistence::migrations::MigratorRegistry::production();
|
||||
crate::persistence::migrations::ensure_project_yaml_migrated(
|
||||
new_project.path(),
|
||||
&migrate_registry,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Open the new database (rebuild from text if .db is
|
||||
// missing — applies to NewTemp's just-created project,
|
||||
// and to Load when the user opened a project whose .db
|
||||
@@ -535,7 +641,15 @@ async fn perform_switch(
|
||||
// history.log. The worker's persistence is wired but not
|
||||
// directly addressable from here, so we use a fresh
|
||||
// Persistence handle for this single line.
|
||||
let _ = Persistence::new(new_path).append_history(&source);
|
||||
let _ = Persistence::new(new_path.clone()).append_history(&source);
|
||||
|
||||
// Update the resume pointer so the next `--resume`
|
||||
// launch reopens the project we just switched to. Write
|
||||
// failures are non-fatal — see the same rationale at
|
||||
// `run()` startup.
|
||||
if let Err(e) = write_last_project(&session.data_root, &new_path) {
|
||||
tracing::warn!(error = %e, "could not update last_project after switch");
|
||||
}
|
||||
|
||||
Ok((display_name, is_temp))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user