DSL parser, async DB worker, types, history, metadata, polish

Track 1 implementation plus polish round.

Parser (chumsky):
- Grammar-based DSL producing a typed Command AST.
- create table X with pk [name:type[,name:type...]] supports
  arbitrary names, any user type, compound PKs natively. Bare
  form errors with a friendly hint pointing at `with pk`.
- add column to table X: Name (type); drop table X.
- Required clauses use keyword grammar; -- reserved for opt-in
  flags (ADR-0009). Custom Rich reasons preferred when surfacing
  chumsky errors so unknown-type messages list valid alternatives.

Database (ADR-0010, ADR-0012):
- rusqlite + STRICT tables + foreign_keys=ON.
- Dedicated worker thread; mpsc Request inbox, oneshot replies.
- Typed DbError with friendly_message() hook for H1.
- Internal __rdbms_playground_columns metadata table preserves
  user-facing types across schema reads, atomically maintained
  alongside DDL via Connection transactions. list_tables hides
  it via the new __rdbms_ internal-table convention.

Types (ADR-0005, ADR-0011):
- All ten user-facing types: text, int, real, decimal, bool,
  date, datetime, blob, serial, shortid.
- Type::fk_target_type() for FK-side column-type rule
  (Serial->Int, ShortId->Text, others identity) -- foundation
  for the FK iteration.

App / Runtime / UI:
- update() stays pure-sync; runtime dispatches DSL via spawned
  tasks, results post back as AppEvent::Dsl*.
- Items panel renders live tables list; output panel shows the
  user-facing structure of the current table after each DDL.
- In-memory command history (Up/Down, draft preservation,
  consecutive-duplicate dedup) -- I2 partial.
- Mouse capture removed; terminal native text selection
  restored (toggle approach revisited when scroll/click
  features land).

Docs:
- ADRs 0009 (DSL syntax conventions), 0010 (DB worker),
  0011 (FK type compat), 0012 (internal metadata table).
- requirements.md progress notes; new V4 entry for the
  scrollable session-log + inline rich rendering + Markdown
  export direction.

Tests: 103 passing (91 lib + 12 integration), 0 skipped.
Clippy clean with nursery enabled.
This commit is contained in:
claude@clouddev1
2026-05-07 13:32:19 +00:00
parent 25a0f1260f
commit c1e52920eb
21 changed files with 3186 additions and 120 deletions
+109 -19
View File
@@ -3,18 +3,17 @@
//! A blocking task reads crossterm events and forwards them onto
//! an `mpsc` channel as `AppEvent`s. The main loop awaits events,
//! feeds them to `App::update`, enacts any returned `Action`s,
//! and redraws the terminal. Future async work (query execution,
//! snapshotting, auto-save) joins the same channel as additional
//! producers, which is why we set the architecture up this way
//! from day one.
//! and redraws the terminal. DSL execution is dispatched onto
//! the database worker (see `db::Database`), and its result is
//! posted back as a new `AppEvent`. Future async work (snapshot
//! capture, auto-save) joins the same event channel as
//! additional producers.
use std::io;
use std::time::Duration;
use anyhow::{Context, Result};
use crossterm::event::{
DisableMouseCapture, EnableMouseCapture, Event as CtEvent, EventStream,
};
use crossterm::event::{Event as CtEvent, EventStream};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
@@ -27,6 +26,8 @@ use tracing::{debug, error, info, warn};
use crate::action::Action;
use crate::app::App;
use crate::db::{Database, DbError, TableDescription};
use crate::dsl::Command;
use crate::event::AppEvent;
use crate::theme::Theme;
use crate::ui;
@@ -37,8 +38,12 @@ const SHUTDOWN_GRACE: Duration = Duration::from_millis(100);
/// Run the application until a `Quit` action is enacted or the
/// terminal closes.
pub async fn run(theme: Theme) -> Result<()> {
// For this iteration, every session uses a fresh in-memory
// database. Track 2 (project storage) wires up file-backed
// databases with proper lifecycle management.
let database = Database::open(":memory:").context("open database")?;
let mut terminal = setup_terminal().context("setup terminal")?;
let result = run_loop(&mut terminal, theme).await;
let result = run_loop(&mut terminal, theme, database).await;
if let Err(e) = teardown_terminal(&mut terminal) {
// Teardown failures should not mask the primary error.
warn!(error = %e, "terminal teardown failed");
@@ -49,13 +54,19 @@ pub async fn run(theme: Theme) -> Result<()> {
async fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
theme: Theme,
database: Database,
) -> Result<()> {
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
let reader_handle = spawn_event_reader(event_tx);
let reader_handle = spawn_event_reader(event_tx.clone());
let mut app = App::new();
// Initial draw before any events arrive.
// Seed the table list with whatever the database currently
// shows. For a fresh in-memory DB this is empty, but doing
// it explicitly means file-backed databases (track 2) will
// show their tables on launch without changes here.
seed_initial_tables(&database, &event_tx).await;
terminal
.draw(|f| ui::render(&app, &theme, f))
.context("initial draw")?;
@@ -70,6 +81,9 @@ async fn run_loop(
debug!("quit action received");
should_quit = true;
}
Action::ExecuteDsl(command) => {
spawn_dsl_dispatch(database.clone(), event_tx.clone(), command);
}
}
}
terminal
@@ -80,13 +94,89 @@ async fn run_loop(
}
}
// Give the reader a moment to notice the dropped sender.
let _ = tokio::time::timeout(SHUTDOWN_GRACE, reader_handle).await;
info!("event loop exited");
Ok(())
}
async fn seed_initial_tables(database: &Database, event_tx: &mpsc::Sender<AppEvent>) {
match database.list_tables().await {
Ok(tables) => {
let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
}
Err(e) => {
error!(error = %e, "failed to seed initial table list");
}
}
}
/// Spawn a task that runs a DSL command against the database
/// and forwards the result back as an `AppEvent`.
fn spawn_dsl_dispatch(
database: Database,
event_tx: mpsc::Sender<AppEvent>,
command: Command,
) {
tokio::spawn(async move {
let outcome = execute_command(&database, command.clone()).await;
let event = match outcome {
Ok(description) => AppEvent::DslSucceeded {
command: command.clone(),
description,
},
Err(error) => AppEvent::DslFailed {
command: command.clone(),
error,
},
};
if event_tx.send(event).await.is_err() {
return;
}
// Refresh the table list after every DDL operation so
// the items panel reflects reality. A failed list_tables
// here is logged but not surfaced to the user — they
// already saw the primary outcome.
match database.list_tables().await {
Ok(tables) => {
let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
}
Err(e) => warn!(error = %e, "post-DDL list_tables failed"),
}
});
}
async fn execute_command(
database: &Database,
command: Command,
) -> Result<Option<TableDescription>, String> {
match command {
Command::CreateTable {
name,
columns,
primary_key,
} => database
.create_table(name, columns, primary_key)
.await
.map(Some)
.map_err(friendly),
Command::DropTable { name } => database
.drop_table(name)
.await
.map(|()| None)
.map_err(friendly),
Command::AddColumn { table, column, ty } => database
.add_column(table, column, ty)
.await
.map(Some)
.map_err(friendly),
}
}
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();
@@ -118,8 +208,12 @@ fn spawn_event_reader(tx: mpsc::Sender<AppEvent>) -> tokio::task::JoinHandle<()>
fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
enable_raw_mode().context("enable raw mode")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
.context("enter alternate screen")?;
// Mouse capture is intentionally NOT enabled: it would prevent the
// host terminal's native text selection (the cost of capturing every
// mouse event), which we don't currently use for anything in-app.
// If we ever want click-to-select panes or scroll wheel handling,
// we'll need a different strategy than blanket capture.
execute!(stdout, EnterAlternateScreen).context("enter alternate screen")?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend).context("construct terminal")?;
Ok(terminal)
@@ -129,12 +223,8 @@ fn teardown_terminal(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<()> {
disable_raw_mode().context("disable raw mode")?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)
.context("leave alternate screen")?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)
.context("leave alternate screen")?;
terminal.show_cursor().context("show cursor")?;
Ok(())
}