feat: DSL→SQL teaching echo — channel + create-table slice (ADR-0037 + ADR-0038)

Walking skeleton validating the whole echo architecture end to end; the
Command→SQL renderer currently covers `create table`, with the rest of
Bucket A / B / category-3 to follow (ADR-0038 §8).

- Channel (ADR-0037): the three-way EffectiveMode (reusing the existing
  enum, not a new SubmissionMode — recorded in the ADR) rides on
  Action::ExecuteDsl to the runtime. `replay` bypasses the interactive
  spawn, so it never echoes (silent, for free).
- Echo (ADR-0038): built at the runtime's ExecuteDsl dispatch — the worker
  gets decomposed calls, not the Command, so ADR §4's "worker builds it"
  was corrected to the dispatch layer. Gated by echo_for (advanced
  effective mode + DSL-form). Carried on DslSucceeded; rendered by
  note_ok_summary as `Executing SQL: …` immediately beneath `[ok]`. New
  src/echo.rs renderer; echo.executing_sql i18n key.
- command_to_sql: `create table` → `CREATE TABLE T (id serial PRIMARY KEY)`
  (single inline / compound table-level PK), playground type vocabulary,
  round-trip-verified against the advanced walker (the §1 contract).

Tests: echo.rs (render, round-trip contract, mode gate, Sql*-not-echoed);
app.rs (submit carries the 3-way mode; echo renders beneath [ok]).
Suite 1970/0/1; clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-27 22:09:54 +00:00
parent 9a23e28f30
commit 04c8e4295f
12 changed files with 350 additions and 29 deletions
@@ -109,20 +109,24 @@ effective mode it ran under. The value is **output-only**: no executor
branches its *effect* on it (that would be a behavioural mode dependency, branches its *effect* on it (that would be a behavioural mode dependency,
which ADR-0033 Amendment 3 forbids — identity and effect are intrinsic). which ADR-0033 Amendment 3 forbids — identity and effect are intrinsic).
### 3. The worker produces mode-dependent output; the App renders it ### 3. The runtime's execution dispatcher produces the echo; the App renders it
For the first consumer (ADR-0038): when the command is a **DSL-form** For the first consumer (ADR-0038): when the command is a **DSL-form**
command (`Command::CreateTable`/`Insert`/… — *not* the `Sql*` variants) command (`Command::CreateTable`/`Insert`/… — *not* the `Sql*` variants)
and `submission_mode` is `Advanced` or `AdvancedOneShot`, the worker and `submission_mode` is `Advanced` or `AdvancedOneShot`, the teaching
builds the teaching echo (equivalent SQL + any category-3 expansion echo (equivalent SQL + any category-3 expansion data — ADR-0038) is built
data — ADR-0038) and returns it on the result event. In `Simple` mode, from the `Command` **plus the worker's execution result**, and the App
or for a command typed as SQL, no echo is produced. The App renders the renders it as de-emphasised `OutputLine`(s) beneath `[ok]`. In `Simple`
returned echo as de-emphasised `OutputLine`(s) beneath `[ok]`. mode, or for a command typed as SQL, no echo is produced.
Co-locating echo construction with execution is deliberate: the echo's **Where it is built (build correction — see Implementation notes).** Not
harder forms (resolved auto-names, generated `shortid`s, conversion in the db.rs worker: the worker receives *decomposed* calls, not the
counts) are facts the worker already computes. Gating on the threaded `Command`, so it cannot render `Command → SQL`. The echo is built at the
mode means the work happens **only when an echo will be shown**. **runtime's `ExecuteDsl` handler**, the one place where the `Command`,
the threaded `EffectiveMode`, and the worker's result (resolved
auto-names, generated `shortid`s, conversion counts) all converge. This
is still **execution-time aware** — it consumes the execution *results*
it just lives at the dispatch layer, not inside the storage worker.
**Non-interactive re-execution does not echo.** `replay` (ADR-0034) **Non-interactive re-execution does not echo.** `replay` (ADR-0034)
re-runs recorded commands through the dispatch pipeline in advanced mode re-runs recorded commands through the dispatch pipeline in advanced mode
@@ -172,6 +176,26 @@ the gating contract in §3.
`Action` → worker round-trip; a Simple-mode DSL command yields no echo `Action` → worker round-trip; a Simple-mode DSL command yields no echo
request while an Advanced / one-shot one does (the gating contract). request while an Advanced / one-shot one does (the gating contract).
## Implementation notes (2026-05-27, during build)
Two refinements found when building, recorded so the ADR matches reality:
- **Reuse the existing `EffectiveMode`, do not add `SubmissionMode`.** The
codebase already has `EffectiveMode { Simple, AdvancedPersistent,
AdvancedOneShot }` (`app.rs`), computed by `effective_mode()` and used
today for the `:` one-shot UI feedback. It is exactly the three-way,
per-submission, *separate-from-`Mode`* enum §1 argued for — so §1's
"new enum" is already satisfied; the build reuses `EffectiveMode`
(`AdvancedPersistent` is the ADR's `Advanced`). No new type.
- **The channel ships with its consumer (merged with ADR-0038).** A
threaded-but-unread `EffectiveMode` on the worker request is dead code,
which this project's `-D warnings` (nursery) rejects. The side-channel
has no consumer other than the echo, so the `Action`→worker threading
is built **together with ADR-0038** rather than as a standalone commit
— the submit-side resolution (which `Action` carries which
`EffectiveMode`) is Tier-1 testable, and the worker-side threading
becomes live + end-to-end testable the moment the echo reads it.
## See also ## See also
- ADR-0033 Amendment 3 — deferred this side-channel; defines the - ADR-0033 Amendment 3 — deferred this side-channel; defines the
+11 -7
View File
@@ -97,13 +97,17 @@ echoes would bury the replay summary — ADR-0037 §3).
### 4. Where it is built and rendered ### 4. Where it is built and rendered
The **worker builds** the echo (ADR-0037 §3) — it alone holds the facts The echo is built at the **runtime's `ExecuteDsl` handler** (build
several echoes need: auto-resolved index / relationship names, generated correction, ADR-0037 §3 + Implementation notes): the db.rs worker
`shortid` values, and lossy-conversion counts. It returns the echo receives *decomposed* calls, not the `Command`, so it cannot render
payload on the result event. The **App renders** it as one or more `Command → SQL`. The runtime is the one place where the `Command`, the
**de-emphasised** `OutputLine`s beneath the `[ok]` summary, using the threaded `EffectiveMode`, and the worker's **result** (resolved auto-
ADR-0028 styled-runs mechanism (a dimmed `Executing SQL:` prefix; the SQL names, generated `shortid`s, conversion counts) all converge — so it
itself in a code-ish run). One statement per line (§6 category 2). builds the echo there, still **execution-time aware** (it consumes the
results). The **App renders** it as one or more **de-emphasised**
`OutputLine`s beneath the `[ok]` summary, using the ADR-0028 styled-runs
mechanism (a dimmed `Executing SQL:` prefix; the SQL in a code-ish run).
One statement per line (§6 category 2).
### 5. `Value → SQL-literal` rendering ### 5. `Value → SQL-literal` rendering
+7
View File
@@ -7,6 +7,7 @@
//! itself, which keeps update directly testable without a Tokio //! itself, which keeps update directly testable without a Tokio
//! runtime, a real terminal, or a database. //! runtime, a real terminal, or a database.
use crate::app::EffectiveMode;
use crate::dsl::Command; use crate::dsl::Command;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -23,6 +24,12 @@ pub enum Action {
ExecuteDsl { ExecuteDsl {
command: Command, command: Command,
source: String, source: String,
/// The effective mode the line was submitted under (ADR-0037):
/// `Simple` / `AdvancedPersistent` / `AdvancedOneShot`. Output-only
/// — execution semantics do not depend on it; the runtime uses it
/// to gate the DSL → SQL teaching echo (ADR-0038), which fires for
/// DSL-form commands submitted in an advanced effective mode.
submission_mode: EffectiveMode,
}, },
/// Record a *failed* submission to `history.log` as an `err` /// Record a *failed* submission to `history.log` as an `err`
/// record (ADR-0034 §1/§2). Emitted by the pure-sync `App` /// record (ADR-0034 §1/§2). Emitted by the pure-sync `App`
+102 -11
View File
@@ -216,6 +216,12 @@ pub struct App {
/// flag; the `undo` / `redo` commands then report undo is off /// flag; the `undo` / `redo` commands then report undo is off
/// rather than emitting a prepare action. /// rather than emitting a prepare action.
pub undo_enabled: bool, pub undo_enabled: bool,
/// The DSL → SQL teaching echo (ADR-0038) for the command currently
/// being rendered: set from the success event just before its handler
/// runs, consumed by `note_ok_summary` (which pushes it beneath
/// `[ok]`), within the same synchronous `update()` call. `None` when
/// the command has no echo.
pending_echo: Option<String>,
} }
/// Dialogs that take over keyboard input when active. /// Dialogs that take over keyboard input when active.
@@ -346,6 +352,7 @@ impl App {
// Undo is on by default; the runtime flips this off for // Undo is on by default; the runtime flips this off for
// a `--no-undo` session (ADR-0006 Amendment 1). // a `--no-undo` session (ADR-0006 Amendment 1).
undo_enabled: true, undo_enabled: true,
pending_echo: None,
} }
} }
@@ -438,7 +445,11 @@ impl App {
AppEvent::DslSucceeded { AppEvent::DslSucceeded {
command, command,
description, description,
echo,
} => { } => {
// Stash the teaching echo (ADR-0038) for `note_ok_summary`
// to render beneath `[ok]` — consumed synchronously below.
self.pending_echo = echo;
self.handle_dsl_success(&command, description); self.handle_dsl_success(&command, description);
Vec::new() Vec::new()
} }
@@ -1113,12 +1124,16 @@ impl App {
// `:` one-shot escape: in simple mode, a leading `:` means // `:` one-shot escape: in simple mode, a leading `:` means
// treat *this single submission* as advanced. The persistent // treat *this single submission* as advanced. The persistent
// mode is unchanged. // mode is unchanged. The three-way `EffectiveMode` (ADR-0037) is
let (effective_mode, effective_input) = // carried through dispatch so the runtime can gate the DSL → SQL
// teaching echo (ADR-0038) on an advanced effective mode.
let (submission_mode, effective_input) =
if self.mode == Mode::Simple && trimmed.starts_with(':') { if self.mode == Mode::Simple && trimmed.starts_with(':') {
(Mode::Advanced, trimmed[1..].trim().to_string()) (EffectiveMode::AdvancedOneShot, trimmed[1..].trim().to_string())
} else if self.mode == Mode::Advanced {
(EffectiveMode::AdvancedPersistent, trimmed.to_string())
} else { } else {
(self.mode, trimmed.to_string()) (EffectiveMode::Simple, trimmed.to_string())
}; };
if effective_input.is_empty() { if effective_input.is_empty() {
@@ -1143,7 +1158,7 @@ impl App {
// form in advanced mode runs and a SQL form in simple // form in advanced mode runs and a SQL form in simple
// mode yields the precise "this is SQL" hint through the // mode yields the precise "this is SQL" hint through the
// walker's mode gate — no separate placeholder branch. // walker's mode gate — no separate placeholder branch.
self.dispatch_dsl(&effective_input, effective_mode) self.dispatch_dsl(&effective_input, submission_mode)
} }
/// Dispatch a parsed app-lifecycle command. Works in both /// Dispatch a parsed app-lifecycle command. Works in both
@@ -1237,7 +1252,11 @@ impl App {
} }
} }
fn dispatch_dsl(&mut self, input: &str, submission_mode: Mode) -> Vec<Action> { fn dispatch_dsl(&mut self, input: &str, submission_mode: EffectiveMode) -> Vec<Action> {
// The two-way mode the walker + the `[mode]` render tag read; the
// three-way `submission_mode` (ADR-0037) rides on `ExecuteDsl` for
// the runtime's echo gate (ADR-0038).
let mode = submission_mode.as_mode();
// ADR-0024 §Phase D: parse with the live schema so typed // ADR-0024 §Phase D: parse with the live schema so typed
// value slots (insert-into-T-values-…) dispatch on the // value slots (insert-into-T-values-…) dispatch on the
// column's actual user-facing type instead of accepting // column's actual user-facing type instead of accepting
@@ -1250,7 +1269,7 @@ impl App {
match crate::dsl::parser::parse_command_with_schema_in_mode( match crate::dsl::parser::parse_command_with_schema_in_mode(
input, input,
&self.schema_cache, &self.schema_cache,
submission_mode, mode,
) { ) {
Ok(Command::Replay { path }) => { Ok(Command::Replay { path }) => {
// `replay` is parsed as a DSL command for the // `replay` is parsed as a DSL command for the
@@ -1270,7 +1289,7 @@ impl App {
self.push_output(OutputLine { self.push_output(OutputLine {
text: crate::t!("dsl.running", input = input), text: crate::t!("dsl.running", input = input),
kind: OutputKind::Echo, kind: OutputKind::Echo,
mode_at_submission: submission_mode, mode_at_submission: mode,
styled_runs: None, styled_runs: None,
}); });
vec![Action::Replay { path }] vec![Action::Replay { path }]
@@ -1279,12 +1298,13 @@ impl App {
self.push_output(OutputLine { self.push_output(OutputLine {
text: crate::t!("dsl.running", input = input), text: crate::t!("dsl.running", input = input),
kind: OutputKind::Echo, kind: OutputKind::Echo,
mode_at_submission: submission_mode, mode_at_submission: mode,
styled_runs: None, styled_runs: None,
}); });
vec![Action::ExecuteDsl { vec![Action::ExecuteDsl {
command: cmd, command: cmd,
source: input.to_string(), source: input.to_string(),
submission_mode,
}] }]
} }
Err(ParseError::Empty) => Vec::new(), Err(ParseError::Empty) => Vec::new(),
@@ -1294,7 +1314,7 @@ impl App {
self.push_output(OutputLine { self.push_output(OutputLine {
text: crate::t!("dsl.running", input = input), text: crate::t!("dsl.running", input = input),
kind: OutputKind::Echo, kind: OutputKind::Echo,
mode_at_submission: submission_mode, mode_at_submission: mode,
styled_runs: None, styled_runs: None,
}); });
// Caret pointer at the failure position, when we // Caret pointer at the failure position, when we
@@ -1334,7 +1354,7 @@ impl App {
// covers SQL constructs that surface only on submit // covers SQL constructs that surface only on submit
// (e.g. `delete … returning`, where the live hint // (e.g. `delete … returning`, where the live hint
// shows WHERE-completion rather than an error). // shows WHERE-completion rather than an error).
if submission_mode == Mode::Simple if mode == Mode::Simple
&& let Some(note) = && let Some(note) =
crate::input_render::advanced_alternative_note(input, &self.schema_cache) crate::input_render::advanced_alternative_note(input, &self.schema_cache)
{ {
@@ -1367,6 +1387,12 @@ impl App {
verb = command.verb(), verb = command.verb(),
subject = command.display_subject() subject = command.display_subject()
)); ));
// ADR-0038: the DSL → SQL teaching echo, beneath `[ok]`. Set on
// the success event when a DSL-form command ran in an advanced
// effective mode (ADR-0037); `None` otherwise. De-emphasised.
if let Some(sql) = self.pending_echo.take() {
self.note_system(crate::t!("echo.executing_sql", sql = sql));
}
} }
fn handle_dsl_success(&mut self, command: &Command, description: Option<TableDescription>) { fn handle_dsl_success(&mut self, command: &Command, description: Option<TableDescription>) {
@@ -2789,6 +2815,70 @@ mod tests {
); );
} }
#[test]
fn submit_carries_the_three_way_effective_submission_mode() {
// ADR-0037: ExecuteDsl carries the effective mode so the runtime
// can gate the teaching echo (ADR-0038).
let case = |mode: Mode, input: &str| -> EffectiveMode {
let mut app = App::new();
app.mode = mode;
type_str(&mut app, input);
match submit(&mut app).as_slice() {
[Action::ExecuteDsl { submission_mode, .. }] => *submission_mode,
other => panic!("expected one ExecuteDsl; got {other:?}"),
}
};
assert_eq!(
case(Mode::Advanced, "create table T with pk"),
EffectiveMode::AdvancedPersistent
);
assert_eq!(
case(Mode::Simple, ":create table T with pk"),
EffectiveMode::AdvancedOneShot
);
assert_eq!(
case(Mode::Simple, "create table T with pk"),
EffectiveMode::Simple
);
}
#[test]
fn dsl_success_renders_the_teaching_echo_beneath_ok() {
// ADR-0038: the echo carried on the success event renders as a
// line immediately beneath the `[ok]` summary.
let cmd = Command::CreateTable {
name: "Other".to_string(),
columns: vec![crate::dsl::ColumnSpec::new("id", Type::Serial)],
primary_key: vec!["id".to_string()],
};
let mut app = App::new();
app.update(AppEvent::DslSucceeded {
command: cmd.clone(),
description: None,
echo: Some("CREATE TABLE Other (id serial PRIMARY KEY)".to_string()),
});
let texts: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
let ok_idx = texts.iter().position(|t| t.starts_with("[ok]")).expect("an [ok] line");
let echo_idx = texts
.iter()
.position(|t| t.contains("Executing SQL:"))
.expect("an echo line");
assert_eq!(echo_idx, ok_idx + 1, "echo sits immediately beneath [ok]: {texts:?}");
assert!(texts[echo_idx].contains("CREATE TABLE Other (id serial PRIMARY KEY)"));
// No echo line when the event carries none (simple mode etc.).
let mut app = App::new();
app.update(AppEvent::DslSucceeded {
command: cmd,
description: None,
echo: None,
});
assert!(
!app.output.iter().any(|l| l.text.contains("Executing SQL:")),
"no echo line when echo is None"
);
}
#[test] #[test]
fn mode_command_switches_persistently() { fn mode_command_switches_persistently() {
let mut app = App::new(); let mut app = App::new();
@@ -2960,6 +3050,7 @@ mod tests {
app.update(AppEvent::DslSucceeded { app.update(AppEvent::DslSucceeded {
command: cmd, command: cmd,
description: Some(desc.clone()), description: Some(desc.clone()),
echo: None,
}); });
assert_eq!(app.current_table, Some(desc)); assert_eq!(app.current_table, Some(desc));
// Some line in the output buffer is the structure // Some line in the output buffer is the structure
+160
View File
@@ -0,0 +1,160 @@
//! The DSL → SQL teaching echo renderer (ADR-0038).
//!
//! Maps a **DSL-form** `Command` to the equivalent advanced-mode SQL, so
//! a learner who typed the simple-mode form reads off how to spell it in
//! SQL (ADR-0030 §10). The output obeys the **copy-paste contract**
//! (ADR-0038 §1): it is runnable advanced-mode SQL in the playground's
//! own type vocabulary (`Type::keyword()`), so it round-trips through the
//! advanced walker. The standard-first dialect (ADR-0035 Amendment 2)
//! governs statement shape; the playground keywords fill the type slots.
//!
//! `None` means "no echo" — a command in Bucket C (ADR-0038 §7) or a form
//! not yet covered by the renderer. The caller (the runtime's `ExecuteDsl`
//! dispatch) only invokes this for DSL-form commands submitted in an
//! advanced effective mode (ADR-0037).
use crate::app::EffectiveMode;
use crate::dsl::Command;
use crate::dsl::command::ColumnSpec;
/// The teaching echo for a command submitted under `mode`, or `None`.
///
/// Fires only in an advanced effective mode (`AdvancedPersistent` /
/// `AdvancedOneShot`) — simple mode stays uncluttered (ADR-0030 §10) — and
/// only for a DSL-form command that has an echo (`command_to_sql`; a
/// `Sql*` / app command returns `None`). This is the runtime's gate;
/// replay never reaches it (it bypasses the spawn). (ADR-0037 + ADR-0038)
#[must_use]
pub fn echo_for(command: &Command, mode: EffectiveMode) -> Option<String> {
if mode.is_advanced() {
command_to_sql(command)
} else {
None
}
}
/// Render the equivalent advanced-mode SQL for a DSL-form command, or
/// `None` when it has no echo.
#[must_use]
pub fn command_to_sql(command: &Command) -> Option<String> {
match command {
Command::CreateTable {
name,
columns,
primary_key,
} => Some(render_create_table(name, columns, primary_key)),
// Remaining Bucket A/B forms land in follow-up slices (ADR-0038 §8).
_ => None,
}
}
/// `CREATE TABLE <name> (<col defs>[, PRIMARY KEY (…)])` in the
/// playground's type vocabulary. A single first-column primary key is
/// rendered inline (`id serial PRIMARY KEY`), matching the rebuild
/// generator's rule (ADR-0035 §4a) and the round-trip surface; a compound
/// key becomes a table-level `PRIMARY KEY (a, b)`.
fn render_create_table(name: &str, columns: &[ColumnSpec], primary_key: &[String]) -> String {
let inline_pk =
primary_key.len() == 1 && columns.first().is_some_and(|c| c.name == primary_key[0]);
let col_defs: Vec<String> = columns
.iter()
.map(|c| {
let mut s = format!("{} {}", c.name, c.ty.keyword());
if inline_pk && c.name == primary_key[0] {
s.push_str(" PRIMARY KEY");
}
if c.not_null {
s.push_str(" NOT NULL");
}
if c.unique {
s.push_str(" UNIQUE");
}
s
})
.collect();
if primary_key.len() > 1 {
format!(
"CREATE TABLE {name} ({}, PRIMARY KEY ({}))",
col_defs.join(", "),
primary_key.join(", "),
)
} else {
format!("CREATE TABLE {name} ({})", col_defs.join(", "))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dsl::types::Type;
fn create_table(name: &str, cols: Vec<ColumnSpec>, pk: &[&str]) -> Command {
Command::CreateTable {
name: name.to_string(),
columns: cols,
primary_key: pk.iter().map(|s| (*s).to_string()).collect(),
}
}
#[test]
fn create_table_single_serial_pk_renders_inline() {
let cmd = create_table("Other", vec![ColumnSpec::new("id", Type::Serial)], &["id"]);
assert_eq!(
command_to_sql(&cmd).as_deref(),
Some("CREATE TABLE Other (id serial PRIMARY KEY)")
);
}
#[test]
fn create_table_compound_pk_renders_table_level() {
let cmd = create_table(
"T",
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
&["a", "b"],
);
assert_eq!(
command_to_sql(&cmd).as_deref(),
Some("CREATE TABLE T (a int, b int, PRIMARY KEY (a, b))")
);
}
#[test]
fn create_table_echo_round_trips_in_advanced_mode() {
// ADR-0038 §1 copy-paste contract: the echo is runnable advanced SQL.
let cmd = create_table("Other", vec![ColumnSpec::new("id", Type::Serial)], &["id"]);
let sql = command_to_sql(&cmd).expect("echo");
let reparsed = crate::dsl::parser::parse_command_in_mode(&sql, crate::mode::Mode::Advanced);
assert!(
matches!(reparsed, Ok(Command::SqlCreateTable { .. })),
"echo must round-trip as runnable advanced SQL; got {reparsed:?}"
);
}
#[test]
fn echo_for_gates_on_advanced_mode() {
let cmd = create_table("Other", vec![ColumnSpec::new("id", Type::Serial)], &["id"]);
assert!(echo_for(&cmd, EffectiveMode::AdvancedPersistent).is_some());
assert!(echo_for(&cmd, EffectiveMode::AdvancedOneShot).is_some());
assert!(
echo_for(&cmd, EffectiveMode::Simple).is_none(),
"simple mode stays uncluttered (ADR-0030 §10)"
);
}
#[test]
fn sql_entered_command_is_not_echoed() {
// A command the user typed as SQL (SqlCreateTable) is not echoed
// back (ADR-0030 §10) — command_to_sql covers only DSL-form variants.
let cmd = Command::SqlCreateTable {
name: "T".to_string(),
columns: vec![ColumnSpec::new("id", Type::Serial)],
primary_key: vec!["id".to_string()],
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
foreign_keys: Vec::new(),
if_not_exists: false,
};
assert!(command_to_sql(&cmd).is_none());
assert!(echo_for(&cmd, EffectiveMode::AdvancedPersistent).is_none());
}
}
+6
View File
@@ -27,6 +27,12 @@ pub enum AppEvent {
DslSucceeded { DslSucceeded {
command: Command, command: Command,
description: Option<TableDescription>, description: Option<TableDescription>,
/// The DSL → SQL teaching echo (ADR-0038): equivalent advanced-mode
/// SQL, built by the runtime when a DSL-form command ran in an
/// advanced effective mode (ADR-0037). `None` when no echo applies
/// (simple mode, SQL-entered, or a form with no echo). The App
/// renders it beneath `[ok]`.
echo: Option<String>,
}, },
/// A SQL `CREATE TABLE IF NOT EXISTS` matched an existing table — /// A SQL `CREATE TABLE IF NOT EXISTS` matched an existing table —
/// a no-op (ADR-0035 §4). Renders the existing structure plus an /// a no-op (ADR-0035 §4). Renders the existing structure plus an
+2
View File
@@ -497,6 +497,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("ok.rows_inserted", &["count"]), ("ok.rows_inserted", &["count"]),
("ok.rows_updated", &["count"]), ("ok.rows_updated", &["count"]),
("ok.summary", &["verb", "subject"]), ("ok.summary", &["verb", "subject"]),
// ---- DSL → SQL teaching echo (ADR-0038) ----
("echo.executing_sql", &["sql"]),
// ---- Client-side success notes (ADR-0017 §6, ADR-0018 §9) ---- // ---- Client-side success notes (ADR-0017 §6, ADR-0018 §9) ----
("client_side.auto_fill_add_serial", &["count"]), ("client_side.auto_fill_add_serial", &["count"]),
("client_side.auto_fill_add_shortid", &["count"]), ("client_side.auto_fill_add_shortid", &["count"]),
+6
View File
@@ -874,6 +874,12 @@ db:
Cannot add this CHECK to `{table}.{column}`: {total} row(s) do not satisfy `{rule}`. Cannot add this CHECK to `{table}.{column}`: {total} row(s) do not satisfy `{rule}`.
# ---- DSL command success summaries (ADR-0019 §9 sweep) -------------- # ---- DSL command success summaries (ADR-0019 §9 sweep) --------------
# DSL → SQL teaching echo (ADR-0038): the equivalent advanced-mode SQL,
# rendered beneath `[ok]` for a DSL-form command run in an advanced
# effective mode (ADR-0037).
echo:
executing_sql: "Executing SQL: {sql}"
ok: ok:
# Generic `[ok] <verb> <subject>` header used for every # Generic `[ok] <verb> <subject>` header used for every
# successful DSL command. Verbs come from `Command::verb()` # successful DSL command. Verbs come from `Command::verb()`
+1
View File
@@ -12,6 +12,7 @@ pub mod cli;
pub mod completion; pub mod completion;
pub mod db; pub mod db;
pub mod dsl; pub mod dsl;
pub mod echo;
pub mod event; pub mod event;
pub mod friendly; pub mod friendly;
pub mod input_render; pub mod input_render;
+15 -1
View File
@@ -384,12 +384,17 @@ async fn run_loop(
debug!("quit action received"); debug!("quit action received");
should_quit = true; should_quit = true;
} }
Action::ExecuteDsl { command, source } => { Action::ExecuteDsl {
command,
source,
submission_mode,
} => {
spawn_dsl_dispatch( spawn_dsl_dispatch(
session.database().clone(), session.database().clone(),
event_tx.clone(), event_tx.clone(),
command, command,
source, source,
submission_mode,
); );
} }
Action::JournalFailure { source } => { Action::JournalFailure { source } => {
@@ -1251,16 +1256,25 @@ fn spawn_dsl_dispatch(
event_tx: mpsc::Sender<AppEvent>, event_tx: mpsc::Sender<AppEvent>,
command: Command, command: Command,
source: String, source: String,
submission_mode: crate::app::EffectiveMode,
) { ) {
tokio::spawn(async move { tokio::spawn(async move {
// Retain the source for `DslFailed` so the App can journal a // Retain the source for `DslFailed` so the App can journal a
// rejected command as `err` (ADR-0034 §1/§2). // rejected command as `err` (ADR-0034 §1/§2).
let source_for_journal = source.clone(); let source_for_journal = source.clone();
// ADR-0038: the DSL → SQL teaching echo fires for a DSL-form
// command submitted in an advanced effective mode (ADR-0037).
// `replay` bypasses this spawn (it calls `execute_command_typed`
// directly), so replayed lines never echo. Built before execution
// from the Command; resolved-name / category-3 forms (Bucket B/§6,
// later slices) will additionally read the execution result.
let echo = crate::echo::echo_for(&command, submission_mode);
let outcome = execute_command_typed(&database, command.clone(), source).await; let outcome = execute_command_typed(&database, command.clone(), source).await;
let event = match outcome { let event = match outcome {
Ok(CommandOutcome::Schema(description)) => AppEvent::DslSucceeded { Ok(CommandOutcome::Schema(description)) => AppEvent::DslSucceeded {
command: command.clone(), command: command.clone(),
description, description,
echo,
}, },
Ok(CommandOutcome::SchemaSkipped(description)) => AppEvent::DslCreateSkipped { Ok(CommandOutcome::SchemaSkipped(description)) => AppEvent::DslCreateSkipped {
command: command.clone(), command: command.clone(),
+1
View File
@@ -60,6 +60,7 @@ fn advanced_mode_select_dispatches_as_command_select() {
[Action::ExecuteDsl { [Action::ExecuteDsl {
command: Command::Select { sql }, command: Command::Select { sql },
source, source,
..
}] => { }] => {
assert!( assert!(
sql.contains("select 1"), sql.contains("select 1"),
+5
View File
@@ -294,6 +294,7 @@ fn create_table_flow_updates_tables_list_and_structure_view() {
app.update(AppEvent::DslSucceeded { app.update(AppEvent::DslSucceeded {
command: expected_cmd, command: expected_cmd,
description: Some(desc.clone()), description: Some(desc.clone()),
echo: None,
}); });
app.update(AppEvent::TablesRefreshed(vec!["Customers".to_string()])); app.update(AppEvent::TablesRefreshed(vec!["Customers".to_string()]));
@@ -358,6 +359,7 @@ fn add_column_flow_updates_structure_view() {
check: None, check: None,
}, },
description: Some(updated.clone()), description: Some(updated.clone()),
echo: None,
}); });
assert_eq!(app.current_table, Some(updated)); assert_eq!(app.current_table, Some(updated));
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
@@ -387,6 +389,7 @@ fn drop_table_flow_clears_items_list() {
name: "Customers".to_string(), name: "Customers".to_string(),
}, },
description: None, description: None,
echo: None,
}); });
app.update(AppEvent::TablesRefreshed(Vec::new())); app.update(AppEvent::TablesRefreshed(Vec::new()));
@@ -460,6 +463,7 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() {
create_fk: false, create_fk: false,
}, },
description: Some(customers), description: Some(customers),
echo: None,
}); });
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
@@ -513,6 +517,7 @@ fn add_relationship_flow_shows_inbound_section_on_parent() {
check: None, check: None,
}, },
description: Some(customers), description: Some(customers),
echo: None,
}); });
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(rendered.contains("Referenced by:"), "{rendered}"); assert!(rendered.contains("Referenced by:"), "{rendered}");