From 7c3fbd645bf40368ff4f9b8eecb0c7f10eb205e4 Mon Sep 17 00:00:00 2001 From: Joao Henrique Machado Silva Date: Mon, 11 May 2026 11:27:51 +0200 Subject: [PATCH] feat(repl): Phase 11.11a REPL .spawn for interactive BEGIN CONCURRENT demos (SQLR-22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifts the REPL from a single Database to Vec so users can mint sibling handles in-session and step through cross-handle MVCC scenarios. The prompt now shows the active handle ('sqlrite[A]> ' / 'sqlrite[B]> ') so it's obvious which connection will execute the next line. New meta-commands: - .spawn Mint a sibling Connection sharing the same Arc> via Connection::connect(), then switch to it. Each handle gets a stable letter name (A, B, ..., Z, then AA, AB). - .use NAME Switch the active handle (case-insensitive); errors with the list of valid names on miss. - .conns List every handle, mark active with *, tag handles holding an open BEGIN CONCURRENT with (BEGIN CONCURRENT). Plumbing: - New ReplState in src/repl/mod.rs owns Vec, parallel Vec of names, and the active index. Exposes lock_active() for meta-commands that mutate the underlying Database directly, and active_conn_mut() for SQL dispatch through Connection. - src/main.rs migrates the REPL loop from &mut Database to &mut ReplState. SQL dispatch routes through a new Connection::execute_with_render which returns CommandOutput (status + optional rendered prettytable) instead of a bare String — so BEGIN CONCURRENT / COMMIT / ROLLBACK still hit the per-connection MVCC state, while SELECTs come back with the prettytable for the REPL to print above the status line. - Connection::concurrent_tx_is_open() promoted from private to public so .conns can render per-handle tx state. - .open collapses every sibling back to a single handle named A — siblings pointing at the previous Database would otherwise be stranded with stale MVCC state. Tests: - 8 new cases in src/meta_command/mod.rs cover .spawn / .use / .conns parse + dispatch behaviour, case-insensitive .use, the error message on unknown names, multi-handle shared-database visibility, .open-collapse, and the A → Z → AA naming wrap. Docs: - roadmap.md: Phase 11.11a promoted to shipped; the heavier benchmark workload split out as 11.11b. - usage.md: new "Multi-handle mode" section with a worked BEGIN-CONCURRENT-vs-BEGIN-CONCURRENT demo. - smoke-test.md: prompt example refreshed (sqlrite[A]>), command count bumped from 5 → 9. - design-decisions.md: new §12h covering the migration rationale (why Vec over HashMap, why .open collapses siblings, why execute_with_render instead of pre-parsing). Workspace: 615/615 Rust tests pass (was 607, +8 new). fmt + clippy + doc all clean. Smoke-tested the BEGIN CONCURRENT demo end-to-end across A/B handles — A's commit hits Busy as expected. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/design-decisions.md | 55 +++++++ docs/roadmap.md | 16 +- docs/smoke-test.md | 8 +- docs/usage.md | 32 +++- src/connection.rs | 59 +++++++- src/main.rs | 92 ++++++----- src/meta_command/mod.rs | 320 +++++++++++++++++++++++++++++++++------ src/repl/mod.rs | 137 +++++++++++++++++ 8 files changed, 625 insertions(+), 94 deletions(-) diff --git a/docs/design-decisions.md b/docs/design-decisions.md index 8d2e45f..444d0a7 100644 --- a/docs/design-decisions.md +++ b/docs/design-decisions.md @@ -309,6 +309,61 @@ the existing per-commit GC bounds in-memory chain growth. --- +### 12h. REPL holds `Vec` rather than a single `Database` (Phase 11.11a) + +**Decision.** The `sqlrite` REPL binary keeps its state in a small +`ReplState` struct holding a `Vec`, a parallel +`Vec` of stable display names (`A`, `B`, …), and a +`usize` index pointing at the active handle. SQL dispatch goes +through [`Connection::execute_with_render`](../src/connection.rs) +(new in this slice); meta-commands either operate on the +underlying `Database` via `ReplState::lock_active()` or mutate +the connection list itself (`.spawn`, `.use`, `.conns`). + +**Why migrate from `&mut Database`.** `.spawn` only makes sense +across multiple `Connection`s sharing the same +`Arc>` — that's the entire point of +`Connection::connect()`. The previous REPL owned a single +`Database` by value, which made siblings unrepresentable. The +migration is mostly mechanical: the SQL dispatch routes through +the active connection, and every existing meta-command keeps +operating on the database directly by grabbing the mutex guard. + +**Why `Vec` and not a `HashMap`.** +Handle creation order is the only ordering that matters for +demos. A `Vec` keeps `.conns` output deterministic without +sorting; the parallel `names` vector keeps the name → index +lookup O(handles), which is fine for the realistic upper bound +(a handful of siblings in a demo session). Skipping the map also +avoids a `String` key per access on the hot SQL path. + +**Why `.open` collapses every sibling back to a single handle.** +Replacing the underlying `Database` via the mutex guard works in +place — every sibling sees the new content. But sibling handles +typically hold per-connection MVCC transaction state +(`Connection::concurrent_tx`) keyed by the *previous* clock / +`MvStore`; after a `.open` swap, that state is meaningless and +attempting to commit would walk the wrong active-tx registry. +Dropping siblings on `.open` is cleaner than retroactively +invalidating their tx state, and matches the user's mental model +("`.open` is a fresh start"). + +**Why `execute_with_render` instead of pre-parsing in the REPL.** +The REPL needs both the rendered SELECT table and the BEGIN +CONCURRENT routing. The old `process_command_with_render` gives +the former but bypasses the per-connection MVCC dispatch +(`BEGIN CONCURRENT` / `COMMIT` / `ROLLBACK` interception, the +snapshot-read swap). The new method mirrors `Connection::execute` +but threads the `CommandOutput` struct through every branch — +the BEGIN/COMMIT/ROLLBACK arms produce +`CommandOutput { status, rendered: None }`; the +process-command path produces the full output. One method, one +dispatch tree, every REPL line goes through it. + +**Plan-doc reference.** [`concurrent-writes-plan.md`](concurrent-writes-plan.md) §10.8 (REPL `.spawn` meta-command and demos). + +--- + ## Query execution ### 13. `NULL`-as-false in `WHERE` clauses diff --git a/docs/roadmap.md b/docs/roadmap.md index 51377d4..e4fbd35 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -685,9 +685,21 @@ MVCC commits now leave a typed log-record frame in the WAL on top of the existin Each secondary-index entry becomes its own `RowVersion`. Turso explicitly punted on this; SQLRite's v0 will reject `CREATE INDEX` while `journal_mode = mvcc`. -### Phase 11.11 — REPL `.spawn` + bench workload *(planned)* +### ✅ Phase 11.11a — REPL `.spawn` for interactive `BEGIN CONCURRENT` demos -REPL `.spawn` meta-command for interactive `BEGIN CONCURRENT` demos. New "N concurrent writers" benchmark workload pitting SQLRite-MVCC against SQLite + DuckDB on disjoint-row write throughput. Plus Go SDK multi-handle work (cross-pool sibling shape). +Lift the REPL from a single `Database` to a `Vec` so users can mint sibling handles in-session and step through cross-handle MVCC scenarios. The prompt now shows the active handle (`sqlrite[A]> ` / `sqlrite[B]> `) so it's always obvious which connection is about to execute the next line. + +- **`.spawn`** mints a sibling off the active handle (via `Connection::connect`) and switches to it. Each handle gets a stable letter name (`A`, `B`, `C`, …, `Z`, then `AA`, `AB`). +- **`.use `** switches the active handle (case-insensitive); errors with the list of valid names if the target is unknown. +- **`.conns`** lists every handle, marks the active one with `*`, and tags any handle that holds an open `BEGIN CONCURRENT` so demos can show the conflict-detection state at a glance. +- **`.open`** collapses every sibling back to a single handle named `A` so the new database doesn't strand siblings pointing at the old one. +- New [`Connection::execute_with_render`](../src/connection.rs) returns a `CommandOutput` instead of a bare status string, so the REPL's SQL dispatch routes through `Connection` (catching `BEGIN CONCURRENT` / `COMMIT` / `ROLLBACK` and the in-flight tx swap) while still printing the prettytable for `SELECT`. The old non-render `execute` stays for callers that don't need it. + +The downstream "N concurrent writers" benchmark workload (originally bundled into 11.11) is its own follow-up: it touches the `benchmarks/` harness, links SQLite + DuckDB drivers, and is much heavier than this slice. + +### Phase 11.11b — `N concurrent writers` benchmark workload *(planned)* + +New benchmark in [`benchmarks/`](../benchmarks/) that pits SQLRite-MVCC against SQLite + DuckDB on a disjoint-row "N writers, mostly disjoint rows" scenario. Slots into the existing SQLR-16 harness as a Group D differentiator workload. Also includes Go SDK multi-handle work (cross-pool sibling shape) — see the 11.8 note for why that's a separate slice. ### Phase 11.12 — Docs *(planned, plan-doc "Phase 10.9")* diff --git a/docs/smoke-test.md b/docs/smoke-test.md index e2f8618..3b5f8ee 100644 --- a/docs/smoke-test.md +++ b/docs/smoke-test.md @@ -39,9 +39,11 @@ Enter .exit to quit. Enter .help for usage hints. Connected to a transient in-memory database. Use '.open FILENAME' to reopen on a persistent database. -sqlrite> +sqlrite[A]> ``` +(The `[A]` suffix is the active connection handle — Phase 11.11a added `.spawn` / `.use` / `.conns` for multi-handle demos.) + (The version line in the banner tracks the current build — `cargo run` always shows the live value, so don't be surprised if it's later than what's printed here.) Also verify the help text is detailed: @@ -58,7 +60,7 @@ Should print the project description, the meta-command table, and a summary of s sqlrite> .help ``` -Expect the 5-command list (`.help`, `.open`, `.save`, `.tables`, `.exit`) and a note that `.read` / `.ast` aren't implemented. +Expect the 9-command list (`.help`, `.open`, `.save`, `.tables`, `.ask`, `.spawn`, `.use`, `.conns`, `.exit`) and a note that `.read` / `.ast` aren't implemented. ### 1.3 Create a table @@ -585,7 +587,7 @@ When you want a fast before/after comparison for a change, run this condensed ch - [ ] `cargo run --bin sqlrite-mcp -- --help` prints the MCP server CLI without crashing — quick check that the stdio_redirect dance still works - [ ] `cargo run -- --help` prints the full description + meta-command table + SQL surface (not just `-h` / `-V`) - [ ] `cargo run -- somefile.sqlrite` on a non-existent path creates the file and enters the REPL with auto-save on -- [ ] REPL launches, `.help` shows 5 commands +- [ ] REPL launches, `.help` shows 9 commands - [ ] `.tables` in a populated DB prints one name per line - [ ] CREATE TABLE + INSERT + SELECT `*` work in memory - [ ] `SELECT ... WHERE col = literal` on a UNIQUE column returns the right row (index probe path) diff --git a/docs/usage.md b/docs/usage.md index 3ef17bf..b62e17a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -23,6 +23,10 @@ Meta commands start with a dot and don't need a trailing semicolon. | `.open FILENAME` | Open (or create) a `.sqlrite` file. From this point on, every committing SQL statement auto-saves. | | `.save FILENAME` | Force-flush the current DB to `FILENAME`. Rarely needed — auto-save makes this redundant when it's the active file. Useful for "save as" to a different path. | | `.tables` | List tables in the current database, sorted alphabetically | +| `.ask QUESTION` | Natural-language → SQL via the configured LLM. Requires `SQLRITE_LLM_API_KEY`. | +| `.spawn` | Mint a sibling connection sharing the same backing database. Switches to it. (Phase 11.11a) | +| `.use NAME` | Switch the active handle (case-insensitive). (Phase 11.11a) | +| `.conns` | List every active handle; marks the current one with `*` and flags handles in an open `BEGIN CONCURRENT`. (Phase 11.11a) | | `.read` / `.ast` | Not yet implemented | ### `.open` semantics @@ -31,7 +35,33 @@ Meta commands start with a dot and don't need a trailing semicolon. - If `FILENAME` doesn't exist: create an empty database at that path (auto-save enabled immediately). - If `FILENAME` exists but is not a valid SQLRite database: reject with a `bad magic bytes` error — the REPL stays in its previous state. -Only one database is active at a time. A subsequent `.open` replaces the in-memory state. +Only one database is active at a time. A subsequent `.open` replaces the in-memory state and **collapses every sibling handle minted via `.spawn` back to a single one** (named `A`) — siblings pointing at the previous database would be stranded otherwise. + +### Multi-handle mode (Phase 11.11a) + +The REPL holds a vector of `Connection`s; the prompt always shows which one is active: `sqlrite[A]> `, `sqlrite[B]> `, etc. + +- `.spawn` mints a new sibling off the active handle and switches to it. Each new handle gets the next letter in sequence (`A`, `B`, `C`, …, `Z`, `AA`, `AB`). +- `.use NAME` switches the active handle. The next SQL line runs on that connection. +- `.conns` shows the current roster, with `*` next to the active handle and `(BEGIN CONCURRENT)` next to any handle holding an open concurrent transaction. + +A worked demo (assumes `PRAGMA journal_mode = mvcc;`): + +```text +sqlrite[A]> CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER); +sqlrite[A]> INSERT INTO t (id, v) VALUES (1, 0); +sqlrite[A]> .spawn +Spawned sibling handle 'B' and switched to it. 2 handles open. +sqlrite[B]> .use A +sqlrite[A]> BEGIN CONCURRENT; +sqlrite[A]> UPDATE t SET v = 100 WHERE id = 1; +sqlrite[A]> .use B +sqlrite[B]> BEGIN CONCURRENT; +sqlrite[B]> UPDATE t SET v = 200 WHERE id = 1; +sqlrite[B]> COMMIT; +sqlrite[B]> .use A +sqlrite[A]> COMMIT; -- Busy: write-write conflict on t/1 +``` ## Supported SQL diff --git a/src/connection.rs b/src/connection.rs index be359ae..b97147b 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -297,13 +297,42 @@ impl Connection { } } + /// Phase 11.11a — same as [`Connection::execute`], but returns + /// the full [`CommandOutput`] (status + optional pre-rendered + /// prettytable for `SELECT`). The REPL needs this to print the + /// table the engine produced *and* the status line in one + /// pass, while still routing `BEGIN CONCURRENT` / `COMMIT` / + /// `ROLLBACK` through the per-connection MVCC state. + /// + /// `BEGIN` / `COMMIT` / `ROLLBACK` carry no rendered output — + /// they return `CommandOutput { status, rendered: None }`. + pub fn execute_with_render(&mut self, sql: &str) -> Result { + let intent = concurrent_tx_intent(sql); + let has_tx = self.concurrent_tx_is_open(); + let status = match intent { + ConcurrentTxIntent::Begin => self.begin_concurrent()?, + ConcurrentTxIntent::Commit if has_tx => self.commit_concurrent()?, + ConcurrentTxIntent::Rollback if has_tx => self.rollback_concurrent()?, + ConcurrentTxIntent::None + | ConcurrentTxIntent::Commit + | ConcurrentTxIntent::Rollback => return self.execute_dispatch_with_render(sql), + }; + Ok(crate::sql::CommandOutput { + status, + rendered: None, + }) + } + /// Phase 11.5 — cheap probe used by [`Connection::execute`] /// (and [`Statement::query`]) to decide whether to route /// through the concurrent-tx dispatch. Acquires the /// `concurrent_tx` mutex briefly; never blocks for a /// meaningful amount of time because the only other lockers /// are this connection's own writers. - fn concurrent_tx_is_open(&self) -> bool { + /// + /// Public so the REPL can render per-handle tx state in + /// `.conns` output (Phase 11.11a). + pub fn concurrent_tx_is_open(&self) -> bool { self.lock_concurrent_tx().is_some() } @@ -407,6 +436,20 @@ impl Connection { } } + /// Phase 11.11a — render-aware twin of + /// [`Connection::execute_dispatch`]. Same branching, but the + /// non-concurrent path calls `process_command_with_render` and + /// the concurrent path goes through + /// [`Connection::execute_in_concurrent_tx_with_render`]. + fn execute_dispatch_with_render(&mut self, sql: &str) -> Result { + if self.concurrent_tx_is_open() { + self.execute_in_concurrent_tx_with_render(sql) + } else { + let mut db = self.lock(); + crate::sql::process_command_with_render(sql, &mut db) + } + } + /// Phase 11.4 — opens a `BEGIN CONCURRENT` transaction on this /// connection. Allocates a new `TxHandle` (which advances the /// MVCC clock by one), deep-clones the live tables into the @@ -630,6 +673,18 @@ impl Connection { /// new tables to the live database without a separate merge /// pass). fn execute_in_concurrent_tx(&mut self, sql: &str) -> Result { + self.execute_in_concurrent_tx_with_render(sql) + .map(|o| o.status) + } + + /// Render-aware twin of [`Connection::execute_in_concurrent_tx`]. + /// Same swap-based dispatch; the only difference is the inner + /// call goes through `process_command_with_render` so the + /// caller gets the rendered SELECT table (Phase 11.11a). + fn execute_in_concurrent_tx_with_render( + &mut self, + sql: &str, + ) -> Result { let intent = legacy_tx_intent(sql); if matches!(intent, LegacyTxIntent::Begin) { return Err(SQLRiteError::General( @@ -672,7 +727,7 @@ impl Connection { tables: HashMap::new(), }); - let result = crate::sql::process_command(sql, &mut db); + let result = crate::sql::process_command_with_render(sql, &mut db); // Unwind in reverse: take the dummy txn off (don't restore // anything from it), swap the tables back. diff --git a/src/main.rs b/src/main.rs index 6c792e6..9a44246 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,8 +9,8 @@ mod repl; use std::path::PathBuf; use meta_command::handle_meta_command; -use repl::{CommandType, REPLHelper, get_command_type, get_config}; -use sqlrite::{Database, process_command_with_render}; +use repl::{CommandType, REPLHelper, ReplState, get_command_type, get_config}; +use sqlrite::Connection; use rustyline::Editor; use rustyline::error::ReadlineError; @@ -39,6 +39,9 @@ Once in the REPL, meta commands start with a dot: .save Write the current DB to FILENAME (rarely needed — once .open is in play, every write auto-saves) .tables List tables in the current database + .spawn Mint a sibling connection sharing this database + .use Switch the active handle (A, B, ...) — see .conns + .conns List every handle, marking the active one .exit Quit Supported SQL: CREATE TABLE / CREATE [UNIQUE] INDEX / INSERT / SELECT / @@ -104,17 +107,25 @@ fn main() -> rustyline::Result<()> { std::process::exit(1); } - let (mut db, opened_path): (Database, Option<&std::path::PathBuf>) = match &initial_db_path { - Some(path) => match open_or_create(path, read_only) { - Ok(db) => (db, Some(path)), - Err(err) => { - eprintln!("Could not open '{}': {err}", path.display()); - eprintln!("Falling back to a transient in-memory database."); - (Database::new("tempdb".to_string()), None) - } - }, - None => (Database::new("tempdb".to_string()), None), - }; + let (initial_conn, opened_path): (Connection, Option<&std::path::PathBuf>) = + match &initial_db_path { + Some(path) => match open_or_create(path, read_only) { + Ok(conn) => (conn, Some(path)), + Err(err) => { + eprintln!("Could not open '{}': {err}", path.display()); + eprintln!("Falling back to a transient in-memory database."); + ( + Connection::open_in_memory().expect("in-memory open never fails"), + None, + ) + } + }, + None => ( + Connection::open_in_memory().expect("in-memory open never fails"), + None, + ), + }; + let mut state = ReplState::new(initial_conn); // Friendly intro message for the user let connection_line = match opened_path { @@ -129,30 +140,35 @@ fn main() -> rustyline::Result<()> { connection_line, ); - let prompt = "sqlrite> "; - loop { + // Prompt shows the active handle so multi-handle demos + // (`.spawn` / `.use`) make it obvious which connection is + // about to execute the next statement. + let prompt = format!("sqlrite[{}]> ", state.active_name()); repl.helper_mut().expect("No helper found").colored_prompt = format!("\x1b[1;32m{prompt}\x1b[0m"); // Source for ANSI Color information: http://www.perpetualpc.net/6429_colors.html#color_list // http://bixense.com/clicolors/ - let readline = repl.readline(prompt); + let readline = repl.readline(&prompt); match readline { Ok(command) => { let _ = repl.add_history_entry(command.as_str()); // Parsing user's input and returning and enum of repl::CommandType match get_command_type(command.trim()) { CommandType::SQLCommand(_cmd) => { - // `process_command_with_render` returns a CommandOutput - // carrying both the status line and (for SELECT) the - // pre-rendered prettytable. Print rendered first if - // present so the user sees the rows above the - // confirmation. Prior to the engine-stdout-pollution - // cleanup the engine printed the table itself, which - // corrupted any non-REPL stdout channel — now the REPL - // owns the printing. - match process_command_with_render(&command, &mut db) { + // Route through `Connection::execute_with_render` + // so `BEGIN CONCURRENT` / `COMMIT` / `ROLLBACK` + // hit the per-connection MVCC state, and reads + // inside an open concurrent transaction see the + // BEGIN-time snapshot. SELECTs come back with + // the pre-rendered prettytable; we print that + // first so the user sees the rows above the + // confirmation. Prior to the engine-stdout- + // pollution cleanup the engine printed the table + // itself, which corrupted any non-REPL stdout + // channel — the REPL owns the printing now. + match state.active_conn_mut().execute_with_render(&command) { Ok(output) => { if let Some(rendered) = output.rendered.as_deref() { print!("{rendered}"); @@ -165,7 +181,7 @@ fn main() -> rustyline::Result<()> { CommandType::MetaCommand(cmd) => { // handle_meta_command parses and executes the MetaCommand // and returns a Result - match handle_meta_command(cmd, &mut repl, &mut db) { + match handle_meta_command(cmd, &mut repl, &mut state) { Ok(response) => println!("{response}"), Err(err) => eprintln!("An error occured: {err}"), } @@ -190,18 +206,14 @@ fn main() -> rustyline::Result<()> { } /// Equivalent to typing `.open FILE` at the REPL: load if present, -/// materialize an empty DB on disk if missing. Attaches the long-lived -/// Pager either way so subsequent writes auto-save. +/// materialize an empty DB on disk if missing. Returns a fresh +/// `Connection` whose backing `Arc>` carries the +/// long-lived pager so subsequent writes auto-save. /// /// When `read_only` is set we take a shared advisory lock and never /// materialize a missing file — read-only mode must fail cleanly if /// the target doesn't exist rather than silently creating one. -fn open_or_create(path: &std::path::Path, read_only: bool) -> sqlrite::Result { - let db_name = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("db") - .to_string(); +fn open_or_create(path: &std::path::Path, read_only: bool) -> sqlrite::Result { if read_only { if !path.exists() { return Err(sqlrite::SQLRiteError::General(format!( @@ -209,13 +221,11 @@ fn open_or_create(path: &std::path::Path, read_only: bool) -> sqlrite::Result>` and switch to it. + /// Enables interactive `BEGIN CONCURRENT` demos in the REPL. + Spawn, + /// `.use NAME` — Phase 11.11a — switch the active handle to + /// the one whose display name matches `NAME` (case-insensitive). + Use(String), + /// `.conns` — Phase 11.11a — list every active handle, with a + /// marker showing the current one and a `(tx)` flag per handle + /// in an open `BEGIN CONCURRENT`. + Conns, /// Parsed line that didn't match any known meta-command. Unknown, } @@ -40,6 +51,9 @@ impl fmt::Display for MetaCommand { MetaCommand::Save(_) => f.write_str(".save"), MetaCommand::Tables => f.write_str(".tables"), MetaCommand::Ask(_) => f.write_str(".ask"), + MetaCommand::Spawn => f.write_str(".spawn"), + MetaCommand::Use(_) => f.write_str(".use"), + MetaCommand::Conns => f.write_str(".conns"), MetaCommand::Unknown => f.write_str("Unknown command"), } } @@ -87,17 +101,24 @@ impl MetaCommand { None => MetaCommand::Unknown, }, ".tables" => MetaCommand::Tables, + ".spawn" => MetaCommand::Spawn, + ".conns" => MetaCommand::Conns, + ".use" => match args.get(1) { + Some(name) => MetaCommand::Use(name.to_string()), + None => MetaCommand::Unknown, + }, _ => MetaCommand::Unknown, } } } -/// Executes a parsed meta-command. May mutate `db` — `.open` replaces it -/// with the loaded file's database; `.save` just reads it. +/// Executes a parsed meta-command. May mutate the active handle's +/// underlying `Database` (`.open` swaps it; `.save` reads it) or the +/// REPL state itself (`.spawn`, `.use`, `.conns`). pub fn handle_meta_command( command: MetaCommand, repl: &mut Editor, - db: &mut Database, + state: &mut ReplState, ) -> Result { match command { MetaCommand::Exit => { @@ -105,27 +126,80 @@ pub fn handle_meta_command( std::process::exit(0) } MetaCommand::Help => Ok(format!( - "{}{}{}{}{}{}{}{}", + "{}{}{}{}{}{}{}{}{}{}{}{}", "Special commands:\n", ".help - Display this message\n", ".open - Open a SQLRite database file (creates it if missing)\n", ".save - Write the current in-memory database to FILENAME\n", ".tables - List tables in the current database\n", ".ask - Generate SQL from a natural-language question (LLM)\n", + ".spawn - Mint a sibling connection sharing this database\n", + ".use - Switch the active handle (A, B, …) — see .conns\n", + ".conns - List every handle, marking the active one\n", ".exit - Quit this application\n", + "\nMulti-handle (.spawn / .use / .conns) is Phase 11.11a — drives\n\ + interactive BEGIN CONCURRENT demos. Every sibling handle shares\n\ + the same backing Database via Arc>.\n", "\nOther meta commands (.read, .ast) are not implemented yet.\n\ For .ask, set SQLRITE_LLM_API_KEY in your environment first." )), - MetaCommand::Open(path) => handle_open(&path, db), - MetaCommand::Save(path) => handle_save(&path, db), - MetaCommand::Tables => handle_tables(db), - MetaCommand::Ask(question) => handle_ask(&question, repl, db), + MetaCommand::Open(path) => { + // `.open` replaces the underlying Database, which strands + // any sibling pointing at the old one. Collapse to a + // single handle so the new file has a clean owner. + state.collapse_to_active(); + let mut db = state.lock_active(); + handle_open(&path, &mut db) + } + MetaCommand::Save(path) => { + let mut db = state.lock_active(); + handle_save(&path, &mut db) + } + MetaCommand::Tables => { + let db = state.lock_active(); + handle_tables(&db) + } + MetaCommand::Ask(question) => { + let mut db = state.lock_active(); + handle_ask(&question, repl, &mut db) + } + MetaCommand::Spawn => handle_spawn(state), + MetaCommand::Use(name) => handle_use(&name, state), + MetaCommand::Conns => Ok(handle_conns(state)), MetaCommand::Unknown => Err(SQLRiteError::UnknownCommand( "Unknown command or invalid arguments. Enter '.help'".to_string(), )), } } +fn handle_spawn(state: &mut ReplState) -> Result { + let name = state.spawn_sibling(); + Ok(format!( + "Spawned sibling handle '{name}' and switched to it. \ + {n} handles open. Use '.use NAME' to switch back.", + n = state.handle_count() + )) +} + +fn handle_use(target: &str, state: &mut ReplState) -> Result { + match state.use_handle(target) { + Ok(name) => Ok(format!("Active handle: '{name}'.")), + Err(msg) => Err(SQLRiteError::General(msg)), + } +} + +fn handle_conns(state: &ReplState) -> String { + let active = state.active_name().to_string(); + let mut lines = Vec::with_capacity(state.handles_summary().len() + 1); + lines.push(format!("{} handle(s):", state.handles_summary().len())); + for (name, in_tx) in state.handles_summary() { + let marker = if name == active { "*" } else { " " }; + let tx_note = if in_tx { " (BEGIN CONCURRENT)" } else { "" }; + lines.push(format!(" {marker} {name}{tx_note}")); + } + lines.join("\n") +} + fn handle_open(path: &Path, db: &mut Database) -> Result { let db_name = path .file_stem() @@ -267,6 +341,7 @@ fn handle_ask( mod tests { use super::*; use crate::repl::{REPLHelper, get_config}; + use sqlrite::Connection; use sqlrite::process_command; fn new_editor() -> Editor { @@ -278,6 +353,10 @@ mod tests { repl } + fn new_state() -> ReplState { + ReplState::new(Connection::open_in_memory().expect("in-memory open")) + } + fn tmp_path(name: &str) -> PathBuf { let mut p = std::env::temp_dir(); let pid = std::process::id(); @@ -301,8 +380,8 @@ mod tests { #[test] fn help_works() { let mut repl = new_editor(); - let mut db = Database::new("x".to_string()); - let result = handle_meta_command(MetaCommand::Help, &mut repl, &mut db); + let mut state = new_state(); + let result = handle_meta_command(MetaCommand::Help, &mut repl, &mut state); assert!(result.is_ok()); } @@ -375,15 +454,18 @@ mod tests { #[test] fn tables_meta_command() { let mut repl = new_editor(); - let mut db = Database::new("x".to_string()); + let mut state = new_state(); // Empty case. - let msg = handle_meta_command(MetaCommand::Tables, &mut repl, &mut db).unwrap(); + let msg = handle_meta_command(MetaCommand::Tables, &mut repl, &mut state).unwrap(); assert_eq!(msg, "(no tables)"); // Populated case — two tables, output should be sorted. - process_command("CREATE TABLE zebras (id INTEGER PRIMARY KEY);", &mut db).unwrap(); - process_command("CREATE TABLE apples (id INTEGER PRIMARY KEY);", &mut db).unwrap(); - let msg = handle_meta_command(MetaCommand::Tables, &mut repl, &mut db).unwrap(); + { + let mut db = state.lock_active(); + process_command("CREATE TABLE zebras (id INTEGER PRIMARY KEY);", &mut db).unwrap(); + process_command("CREATE TABLE apples (id INTEGER PRIMARY KEY);", &mut db).unwrap(); + } + let msg = handle_meta_command(MetaCommand::Tables, &mut repl, &mut state).unwrap(); assert_eq!(msg, "apples\nzebras"); } @@ -393,23 +475,27 @@ mod tests { let path = tmp_path("meta_roundtrip"); let mut repl = new_editor(); - let mut db = Database::new("x".to_string()); - - process_command( - "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);", - &mut db, - ) - .unwrap(); - process_command("INSERT INTO users (name) VALUES ('alice');", &mut db).unwrap(); + let mut state = new_state(); + + { + let mut db = state.lock_active(); + process_command( + "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);", + &mut db, + ) + .unwrap(); + process_command("INSERT INTO users (name) VALUES ('alice');", &mut db).unwrap(); + } - handle_meta_command(MetaCommand::Save(path.clone()), &mut repl, &mut db).expect("save"); + handle_meta_command(MetaCommand::Save(path.clone()), &mut repl, &mut state).expect("save"); - // Replace db with a fresh one, then .open the file. - db = Database::new("fresh".to_string()); - let msg = - handle_meta_command(MetaCommand::Open(path.clone()), &mut repl, &mut db).expect("open"); + // Replace state with a fresh one, then .open the file. + state = new_state(); + let msg = handle_meta_command(MetaCommand::Open(path.clone()), &mut repl, &mut state) + .expect("open"); assert!(msg.contains("1 table loaded")); + let db = state.lock_active(); let users = db.get_table("users".to_string()).unwrap(); let rowids = users.rowids(); assert_eq!(rowids.len(), 1); @@ -417,6 +503,7 @@ mod tests { users.get_value("name", rowids[0]), Some(Value::Text("alice".to_string())) ); + drop(db); cleanup(&path); } @@ -425,16 +512,18 @@ mod tests { fn open_missing_file_creates_fresh_db_and_materializes_file() { let path = tmp_path("missing"); let mut repl = new_editor(); - let mut db = Database::new("x".to_string()); + let mut state = new_state(); - let msg = - handle_meta_command(MetaCommand::Open(path.clone()), &mut repl, &mut db).expect("open"); + let msg = handle_meta_command(MetaCommand::Open(path.clone()), &mut repl, &mut state) + .expect("open"); assert!(msg.contains("new database")); + let db = state.lock_active(); assert_eq!(db.tables.len(), 0); // Auto-save expects a file to exist to auto-flush into, so open-of-missing // touches the file with a valid empty DB. assert!(path.exists()); assert_eq!(db.source_path.as_deref(), Some(path.as_path())); + drop(db); cleanup(&path); } @@ -445,23 +534,26 @@ mod tests { let path = tmp_path("autosave"); let mut repl = new_editor(); - let mut db = Database::new("x".to_string()); + let mut state = new_state(); - handle_meta_command(MetaCommand::Open(path.clone()), &mut repl, &mut db).expect("open"); + handle_meta_command(MetaCommand::Open(path.clone()), &mut repl, &mut state).expect("open"); // The first write should auto-flush to disk. - process_command( - "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);", - &mut db, - ) - .unwrap(); - process_command("INSERT INTO users (name) VALUES ('alice');", &mut db).unwrap(); - - // Drop the first Database so its exclusive lock releases before we - // reopen the same file for verification. - drop(db); + { + let mut db = state.lock_active(); + process_command( + "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);", + &mut db, + ) + .unwrap(); + process_command("INSERT INTO users (name) VALUES ('alice');", &mut db).unwrap(); + } + + // Drop the state (and thus the connection holding the + // pager's exclusive lock) before we reopen the same file + // for verification. + drop(state); - // Reopen the file from scratch in a fresh Database — no manual .save was called. let fresh = sqlrite::sql::pager::open_database(&path, "x".to_string()) .expect("open after auto-save"); let users = fresh.get_table("users".to_string()).expect("users table"); @@ -474,4 +566,142 @@ mod tests { cleanup(&path); } + + // ----- Phase 11.11a multi-handle tests ----------------------- + + #[test] + fn parse_spawn_no_arg() { + assert_eq!(MetaCommand::new(".spawn".to_string()), MetaCommand::Spawn); + // Trailing whitespace is fine. + assert_eq!( + MetaCommand::new(".spawn ".to_string()), + MetaCommand::Spawn + ); + } + + #[test] + fn parse_use_requires_argument() { + assert_eq!(MetaCommand::new(".use".to_string()), MetaCommand::Unknown); + assert_eq!( + MetaCommand::new(".use B".to_string()), + MetaCommand::Use("B".to_string()) + ); + assert_eq!( + MetaCommand::new(".use b".to_string()), + MetaCommand::Use("b".to_string()) + ); + } + + #[test] + fn parse_conns_no_arg() { + assert_eq!(MetaCommand::new(".conns".to_string()), MetaCommand::Conns); + } + + #[test] + fn spawn_creates_sibling_and_switches() { + let mut repl = new_editor(); + let mut state = new_state(); + assert_eq!(state.active_name(), "A"); + + let msg = handle_meta_command(MetaCommand::Spawn, &mut repl, &mut state).expect("spawn ok"); + assert!(msg.contains("'B'")); + assert!(msg.contains("2 handles")); + assert_eq!(state.active_name(), "B"); + + // .use A switches back. + let msg = handle_meta_command(MetaCommand::Use("A".to_string()), &mut repl, &mut state) + .expect("use ok"); + assert!(msg.contains("'A'")); + assert_eq!(state.active_name(), "A"); + } + + #[test] + fn use_is_case_insensitive() { + let mut repl = new_editor(); + let mut state = new_state(); + handle_meta_command(MetaCommand::Spawn, &mut repl, &mut state).unwrap(); + // Now active is "B"; switch back via lowercase. + let msg = handle_meta_command(MetaCommand::Use("a".to_string()), &mut repl, &mut state) + .expect("lowercase use should match A"); + assert!(msg.contains("'A'")); + } + + #[test] + fn use_unknown_handle_errors_with_valid_list() { + let mut repl = new_editor(); + let mut state = new_state(); + let err = handle_meta_command(MetaCommand::Use("Z".to_string()), &mut repl, &mut state) + .unwrap_err(); + let s = format!("{err}"); + assert!(s.contains("no handle named 'Z'")); + assert!(s.contains("current handles: A")); + } + + #[test] + fn conns_reports_active_and_count() { + let mut repl = new_editor(); + let mut state = new_state(); + handle_meta_command(MetaCommand::Spawn, &mut repl, &mut state).unwrap(); + let msg = handle_meta_command(MetaCommand::Conns, &mut repl, &mut state).expect("conns ok"); + assert!(msg.contains("2 handle(s):")); + // Active is B (spawn switched to it); marked with `*`. + assert!(msg.lines().any(|l| l.contains("* B"))); + assert!(msg.lines().any(|l| l.starts_with(" A"))); + } + + #[test] + fn siblings_share_underlying_database() { + let mut repl = new_editor(); + let mut state = new_state(); + // Create a table on handle A. + { + let mut db = state.lock_active(); + process_command( + "CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER);", + &mut db, + ) + .unwrap(); + process_command("INSERT INTO t (id, v) VALUES (1, 100);", &mut db).unwrap(); + } + handle_meta_command(MetaCommand::Spawn, &mut repl, &mut state).unwrap(); + // From handle B, the same row is visible. + let db = state.lock_active(); + let t = db.get_table("t".to_string()).expect("t visible on B"); + assert_eq!(t.rowids().len(), 1); + } + + #[test] + fn open_collapses_to_single_handle() { + let path = tmp_path("open_collapses"); + let mut repl = new_editor(); + let mut state = new_state(); + handle_meta_command(MetaCommand::Spawn, &mut repl, &mut state).unwrap(); + handle_meta_command(MetaCommand::Spawn, &mut repl, &mut state).unwrap(); + // 3 handles, active is "C". + assert_eq!(state.handle_count(), 3); + assert_eq!(state.active_name(), "C"); + + // .open should collapse to 1 handle, renamed to A. + handle_meta_command(MetaCommand::Open(path.clone()), &mut repl, &mut state).expect("open"); + assert_eq!(state.handle_count(), 1); + assert_eq!(state.active_name(), "A"); + + drop(state); + cleanup(&path); + } + + #[test] + fn handle_name_sequence_past_z_wraps_to_aa() { + // The Phase 11.11a roadmap caps interactive demos at a few + // siblings, but the naming scheme should at least not panic + // past 26. Test 27 -> AA. + let mut state = new_state(); + // Spawn 26 siblings -> Z is the 26th. The 27th becomes AA. + for _ in 0..26 { + state.spawn_sibling(); + } + // 27 total handles now (A + 26 siblings). + assert_eq!(state.handle_count(), 27); + assert_eq!(state.active_name(), "AA"); + } } diff --git a/src/repl/mod.rs b/src/repl/mod.rs index d1b8e06..2781dd2 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -1,7 +1,10 @@ use crate::meta_command::*; +use sqlrite::Connection; use sqlrite::sql::SQLCommand; +use sqlrite::sql::db::database::Database; use std::borrow::Cow::{self, Borrowed, Owned}; +use std::sync::MutexGuard; use rustyline::error::ReadlineError; use rustyline::highlight::{CmdKind, Highlighter, MatchingBracketHighlighter}; @@ -120,6 +123,140 @@ pub fn get_config() -> Config { .build() } +/// Multi-handle REPL state (Phase 11.11a). +/// +/// Holds every `Connection` the user has minted (`.spawn`) plus the +/// index of the currently-active one. `.spawn` is the headline +/// feature: it appends a sibling handle that shares the same +/// `Arc>` so the user can drive interactive +/// `BEGIN CONCURRENT` demos — open a tx on `A`, write the same row +/// on `B`, watch `A`'s commit lose to `B`'s, and so on. +/// +/// **Naming.** Handles are named `A`, `B`, `C`, …, `Z`, `AA`, `AB`, +/// … in order of creation. Names never get reused inside a session +/// even if a handle is later dropped (no `.drop` exists yet — but +/// future work could keep the names monotonic regardless). +pub struct ReplState { + conns: Vec, + /// Per-handle display name, parallel to `conns`. Stable across + /// `.use` switches. + names: Vec, + /// Index into `conns` (and `names`) of the active handle. + /// Mutated by `.use NAME`; every SQL statement and most meta- + /// commands route through here. + active: usize, +} + +impl ReplState { + /// Builds a fresh REPL state with one connection named `A`. + pub fn new(conn: Connection) -> Self { + Self { + conns: vec![conn], + names: vec!["A".to_string()], + active: 0, + } + } + + /// The currently-active handle's name (`A`, `B`, …) — used in + /// the prompt and in `.conns` output. + pub fn active_name(&self) -> &str { + &self.names[self.active] + } + + /// All `(name, in_concurrent_tx)` tuples, in creation order. + /// Used by `.conns`. `in_concurrent_tx` reflects whether the + /// handle currently has an open `BEGIN CONCURRENT` — useful for + /// demos so the user can see which siblings are mid-tx. + pub fn handles_summary(&self) -> Vec<(String, bool)> { + self.conns + .iter() + .zip(self.names.iter()) + .map(|(c, n)| (n.clone(), c.concurrent_tx_is_open())) + .collect() + } + + /// Locks the active handle's database and returns the guard. + /// Used by meta-commands that need to mutate the underlying + /// `Database` directly (`.open`, `.save`, `.tables`, `.ask`). + pub fn lock_active(&self) -> MutexGuard<'_, Database> { + self.conns[self.active].database() + } + + /// Mutable handle to the active `Connection`. The REPL's SQL + /// dispatch routes through this so `Connection::execute_with_render` + /// catches `BEGIN CONCURRENT` / `COMMIT` / `ROLLBACK` and the + /// per-connection MVCC state stays in sync. + pub fn active_conn_mut(&mut self) -> &mut Connection { + &mut self.conns[self.active] + } + + /// Mints a new sibling handle off the active one and switches + /// to it. Returns the new handle's name. Backs `.spawn`. + pub fn spawn_sibling(&mut self) -> String { + let sibling = self.conns[self.active].connect(); + let name = next_handle_name(self.conns.len()); + self.conns.push(sibling); + self.names.push(name.clone()); + self.active = self.conns.len() - 1; + name + } + + /// Switches the active handle to the one whose display name + /// matches `target` (case-insensitive). Returns `Ok(name)` if + /// found; `Err(msg)` with a list of valid names otherwise. + pub fn use_handle(&mut self, target: &str) -> Result { + let target_upper = target.to_ascii_uppercase(); + if let Some(idx) = self.names.iter().position(|n| *n == target_upper) { + self.active = idx; + Ok(self.names[idx].clone()) + } else { + let valid = self.names.join(", "); + Err(format!( + "no handle named '{target}'; current handles: {valid}" + )) + } + } + + /// Number of live sibling handles. Used by `.open` to decide + /// whether replacing the underlying Database is safe. + pub fn handle_count(&self) -> usize { + self.conns.len() + } + + /// Drops every sibling, keeping only handle `A`. Used by + /// `.open` so the new database doesn't strand siblings pointing + /// at the old one. + pub fn collapse_to_active(&mut self) { + if self.conns.len() == 1 { + return; + } + // Keep the *active* handle (so `.open` from any handle + // works), rename it to `A`, drop the rest. + let kept = self.conns.swap_remove(self.active); + self.conns.clear(); + self.names.clear(); + self.conns.push(kept); + self.names.push("A".to_string()); + self.active = 0; + } +} + +/// Returns the display name for the i-th spawned handle: +/// `0 -> A`, `1 -> B`, …, `25 -> Z`, `26 -> AA`, `27 -> AB`, … +fn next_handle_name(index: usize) -> String { + let mut n = index; + let mut out = String::new(); + loop { + let r = n % 26; + out.insert(0, (b'A' + r as u8) as char); + if n < 26 { + break; + } + n = n / 26 - 1; + } + out +} + #[cfg(test)] mod tests { use super::*;