feat: persist & restore per-project input mode (#14)

The input mode always started in simple; a learner who quit in advanced
had to re-toggle every launch. Store the mode per-project in project.yaml
(project.mode:, optional, default simple) and restore it on every open.

Mode is live UI state, not schema: the worker stamps the current mode
into project.yaml on every write, so a later command rewrites the live
value rather than clobbering it — no db round-trip needed. The mode is
persisted on unload (quit + project switch) so the mode you leave a
project in is always what reopens; the `mode` command also persists
immediately. A switch saves the outgoing mode, then restores the
incoming project's stored mode.

New --mode simple|advanced CLI flag (precedence --mode > stored >
simple; combines with --resume). A teacher can ship a project that
opens in advanced mode and export it to students (the mode travels in
the zip).

ADR-0015 Amendment 1; ADR-0003 note; help banner; requirements L1b.
This commit is contained in:
claude@clouddev1
2026-06-02 06:47:34 +00:00
parent ae57c6fc82
commit 4cd574b909
16 changed files with 769 additions and 14 deletions
+134 -3
View File
@@ -39,6 +39,7 @@ use crate::dsl::ColumnSpec;
use crate::dsl::shortid;
use crate::dsl::types::Type;
use crate::dsl::value::{Bound, Value, ValueError};
use crate::mode::Mode;
use crate::output_render::{Alignment, render_diagnostic_table};
use crate::type_change;
use crate::persistence::{
@@ -829,6 +830,14 @@ enum Request {
EndBatch {
reply: oneshot::Sender<()>,
},
/// Record the current input mode and persist it to
/// `project.yaml` (ADR-0015 mode-restore amendment, issue
/// #14). Sent at boot / project-switch to seed the mode and
/// whenever the user changes mode mid-session.
SetMode {
mode: Mode,
reply: oneshot::Sender<Result<(), DbError>>,
},
}
impl Database {
@@ -1741,6 +1750,16 @@ impl Database {
recv.await.map_err(|_| DbError::WorkerGone)
}
/// Record the current input mode and persist it to
/// `project.yaml` (ADR-0015 mode-restore amendment, issue
/// #14). Idempotent and cheap; a no-op for databases opened
/// without persistence.
pub async fn set_mode(&self, mode: Mode) -> Result<(), DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::SetMode { mode, reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
async fn send(&self, req: Request) -> Result<(), DbError> {
self.inbox.send(req).await.map_err(|_| DbError::WorkerGone)
}
@@ -1876,6 +1895,18 @@ fn worker_loop(
end_batch(snap, &mut batch);
let _ = reply.send(());
}
// ADR-0015 mode-restore amendment (issue #14): record the
// current input mode so `project.yaml` reflects it. We
// persist immediately (not just update the in-memory
// value) so a mode change followed by quit — with no
// intervening command — is still saved.
Request::SetMode { mode, reply } => {
let result = persistence.as_ref().map_or(Ok(()), |p| {
p.set_mode(mode);
persist_current_mode(&conn, p)
});
let _ = reply.send(result);
}
other => handle_request(&conn, persistence.as_ref(), snap, &mut batch, other),
}
}
@@ -2640,8 +2671,9 @@ fn handle_request(
| Request::PeekUndo { .. }
| Request::PeekRedo { .. }
| Request::BeginBatch { .. }
| Request::EndBatch { .. } => {
unreachable!("undo/redo/peek/batch are handled in worker_loop")
| Request::EndBatch { .. }
| Request::SetMode { .. } => {
unreachable!("undo/redo/peek/batch/set-mode are handled in worker_loop")
}
}
}
@@ -2835,7 +2867,12 @@ fn finalize_persistence(
return Ok(());
};
if changes.schema_dirty {
let schema = read_schema_snapshot(conn)?;
let mut schema = read_schema_snapshot(conn)?;
// Stamp the live input mode (ADR-0015 mode-restore
// amendment, issue #14). Mode is not stored in the db, so
// `read_schema_snapshot` leaves a placeholder; the
// persister is authoritative and writes the current value.
schema.mode = p.current_mode();
p.write_schema(&schema).map_err(DbError::from_persistence)?;
}
for table in &changes.rewritten_tables {
@@ -2902,12 +2939,30 @@ fn read_schema_snapshot(conn: &Connection) -> Result<SchemaSnapshot, DbError> {
let created_at = read_project_created_at(conn)?;
Ok(SchemaSnapshot {
created_at,
// Mode is live UI state, not stored in the database
// (ADR-0015 mode-restore amendment, issue #14). This is a
// placeholder the persister overwrites with the current
// mode; the field exists for the read/restore path, where
// `parse_schema` fills it from `project.yaml`.
mode: Mode::default(),
tables,
relationships,
indexes,
})
}
/// Write `project.yaml` now with the current schema and the
/// persister's current input mode (ADR-0015 mode-restore
/// amendment, issue #14). Used by the `SetMode` request so a mode
/// change is saved immediately, without waiting for the next
/// schema-mutating command. A no-op when persistence is absent
/// (in-memory test databases) or the schema is otherwise clean.
fn persist_current_mode(conn: &Connection, p: &Persistence) -> Result<(), DbError> {
let mut schema = read_schema_snapshot(conn)?;
schema.mode = p.current_mode();
p.write_schema(&schema).map_err(DbError::from_persistence)
}
fn read_all_relationships(conn: &Connection) -> Result<Vec<RelationshipSchema>, DbError> {
let mut stmt = conn
.prepare(&format!(
@@ -13622,6 +13677,82 @@ mod tests {
}
}
#[tokio::test]
async fn set_mode_persists_and_survives_a_later_ddl_command() {
// ADR-0015 mode-restore amendment (issue #14): the worker
// stamps the *current* input mode into `project.yaml` on
// every write, so a mode change is saved immediately AND a
// later schema-mutating command re-writes the same live
// mode rather than clobbering it back to the default. This
// is the core guarantee the whole feature rests on.
use crate::persistence::Persistence;
let dir = tempfile::tempdir().unwrap();
let persistence = Persistence::new(dir.path().to_path_buf());
let db_path = dir.path().join("playground.db");
let db = Database::open_with_persistence(&db_path, persistence).unwrap();
// A table exists first so there is a schema to rewrite.
db.create_table(
"T".to_string(),
vec![col("id", Type::Serial), col("Name", Type::Text)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
// Switch to advanced and confirm it lands in the file.
db.set_mode(Mode::Advanced).await.unwrap();
let yaml = std::fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap();
assert!(
yaml.contains("mode: advanced"),
"set_mode should record the mode in project.yaml: {yaml}"
);
// A later DDL command rewrites the whole project.yaml —
// the mode must NOT regress to the default.
db.add_column(
"T".to_string(),
ColumnSpec::new("extra".to_string(), Type::Text),
None,
)
.await
.unwrap();
let yaml_after = std::fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap();
assert!(
yaml_after.contains("mode: advanced"),
"a later DDL command must preserve the live mode, not clobber it: {yaml_after}"
);
assert_eq!(
Persistence::read_stored_mode(dir.path()),
Some(Mode::Advanced),
"the stored mode reads back as advanced"
);
}
#[tokio::test]
async fn set_mode_persists_even_with_no_prior_command() {
// ADR-0015 mode-restore amendment (issue #14), persist on
// unload: leaving a project must record the mode even if the
// session ran NO command (e.g. a bare `--mode advanced` plus
// read-only browsing). The unload calls `set_mode`, which
// writes project.yaml from the empty schema + the live mode.
use crate::persistence::Persistence;
let dir = tempfile::tempdir().unwrap();
let persistence = Persistence::new(dir.path().to_path_buf()).with_mode(Mode::Advanced);
let db_path = dir.path().join("playground.db");
let db = Database::open_with_persistence(&db_path, persistence).unwrap();
// No command runs — straight to the unload-style persist.
db.set_mode(Mode::Advanced).await.unwrap();
assert_eq!(
Persistence::read_stored_mode(dir.path()),
Some(Mode::Advanced),
"an unload persists the mode with no prior command",
);
}
#[tokio::test]
async fn unique_flag_round_trips_through_rebuild() {
// End-to-end: create a table with a non-PK serial,