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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user