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
+261
View File
@@ -0,0 +1,261 @@
//! User-facing column types and their mapping to SQLite STRICT.
//!
//! Implements the full ten-type vocabulary committed to in
//! ADR-0005. Storage choices for the text-backed types
//! (`decimal`, `date`, `datetime`) preserve precision and ISO
//! readability; comparisons rely on lexicographic ordering or
//! explicit casts at query time, which is acceptable for a
//! teaching tool and is documented in user-facing help.
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Type {
/// UTF-8 text of any length.
Text,
/// 64-bit signed integer.
Int,
/// IEEE-754 double-precision float.
Real,
/// Arbitrary-precision decimal stored as a string. Sorts and
/// compares lexicographically; numeric ops require casts.
Decimal,
/// Boolean stored as 0/1; rendered `true`/`false`.
Bool,
/// ISO 8601 date stored as `YYYY-MM-DD` (TEXT).
Date,
/// ISO 8601 datetime stored as `YYYY-MM-DDTHH:MM:SS[.fff][Z]` (TEXT).
DateTime,
/// Arbitrary binary data.
Blob,
/// Auto-incrementing integer; intended as a default primary key.
Serial,
/// 1012 character base58 random identifier (no ambiguous chars).
ShortId,
}
impl Type {
/// The user-facing keyword as it appears in DSL input.
#[must_use]
pub const fn keyword(self) -> &'static str {
match self {
Self::Text => "text",
Self::Int => "int",
Self::Real => "real",
Self::Decimal => "decimal",
Self::Bool => "bool",
Self::Date => "date",
Self::DateTime => "datetime",
Self::Blob => "blob",
Self::Serial => "serial",
Self::ShortId => "shortid",
}
}
/// The SQLite STRICT type clause for this column. The
/// `serial` type also implies `PRIMARY KEY` semantics in
/// `sqlite_strict_extra` — see [`Self::sqlite_strict_extra`].
#[must_use]
pub const fn sqlite_strict_type(self) -> &'static str {
match self {
Self::Text
| Self::ShortId
| Self::Decimal
| Self::Date
| Self::DateTime => "TEXT",
Self::Int | Self::Serial | Self::Bool => "INTEGER",
Self::Real => "REAL",
Self::Blob => "BLOB",
}
}
/// Extra clause appended after the type in DDL — e.g.
/// `PRIMARY KEY` for `serial`. Empty when no extra clause
/// applies.
#[must_use]
pub const fn sqlite_strict_extra(self) -> &'static str {
match self {
Self::Serial => " PRIMARY KEY",
_ => "",
}
}
/// All types known in this iteration, in stable order.
/// Ordering groups numeric types together, then boolean,
/// then temporal, then binary, then identity-flavoured
/// auto-generated types.
#[must_use]
pub const fn all() -> &'static [Self] {
&[
Self::Text,
Self::Int,
Self::Real,
Self::Decimal,
Self::Bool,
Self::Date,
Self::DateTime,
Self::Blob,
Self::Serial,
Self::ShortId,
]
}
/// The user-facing type that an FK column should use to
/// reference a primary key of *this* type. For most types
/// the answer is the same type; for `serial` and `shortid`
/// it differs, because those types carry insert-time
/// auto-generation semantics that only apply on the PK
/// side. The FK side stores plain looked-up values, so:
///
/// - `serial` → `int` (FK holds a plain integer value)
/// - `shortid` → `text` (FK holds a plain text value)
///
/// Consumed by the FK declaration grammar in a later
/// iteration; defined here so the type system is complete
/// before that work begins.
#[must_use]
pub const fn fk_target_type(self) -> Self {
match self {
Self::Serial => Self::Int,
Self::ShortId => Self::Text,
other => other,
}
}
}
impl fmt::Display for Type {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.keyword())
}
}
/// Error returned when parsing a type keyword that isn't
/// recognised.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("unknown type '{found}' (expected one of: {expected})")]
pub struct UnknownType {
pub found: String,
pub expected: String,
}
impl FromStr for Type {
type Err = UnknownType;
fn from_str(s: &str) -> Result<Self, Self::Err> {
for &ty in Self::all() {
if ty.keyword().eq_ignore_ascii_case(s) {
return Ok(ty);
}
}
Err(UnknownType {
found: s.to_string(),
expected: Self::all()
.iter()
.map(|t| t.keyword())
.collect::<Vec<_>>()
.join(", "),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn keyword_round_trip_for_every_type() {
for &ty in Type::all() {
let parsed: Type = ty.keyword().parse().expect("round-trip");
assert_eq!(parsed, ty);
}
}
#[test]
fn parsing_is_case_insensitive() {
assert_eq!("TEXT".parse::<Type>().unwrap(), Type::Text);
assert_eq!("Int".parse::<Type>().unwrap(), Type::Int);
assert_eq!("ShortId".parse::<Type>().unwrap(), Type::ShortId);
}
#[test]
fn unknown_type_lists_expected_alternatives() {
let err = "varchar".parse::<Type>().unwrap_err();
assert_eq!(err.found, "varchar");
assert!(err.expected.contains("text"));
assert!(err.expected.contains("shortid"));
}
#[test]
fn serial_maps_to_integer_with_primary_key() {
assert_eq!(Type::Serial.sqlite_strict_type(), "INTEGER");
assert_eq!(Type::Serial.sqlite_strict_extra(), " PRIMARY KEY");
}
#[test]
fn shortid_maps_to_text() {
assert_eq!(Type::ShortId.sqlite_strict_type(), "TEXT");
assert_eq!(Type::ShortId.sqlite_strict_extra(), "");
}
#[test]
fn fk_target_type_strips_auto_gen_semantics() {
// The two non-identity mappings.
assert_eq!(Type::Serial.fk_target_type(), Type::Int);
assert_eq!(Type::ShortId.fk_target_type(), Type::Text);
}
#[test]
fn fk_target_type_is_identity_for_plain_value_types() {
for ty in [
Type::Text,
Type::Int,
Type::Real,
Type::Decimal,
Type::Bool,
Type::Date,
Type::DateTime,
Type::Blob,
] {
assert_eq!(ty.fk_target_type(), ty);
}
}
#[test]
fn all_ten_types_are_present_and_distinct() {
let kws: Vec<&'static str> = Type::all().iter().map(|t| t.keyword()).collect();
assert_eq!(kws.len(), 10);
let mut sorted = kws.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), 10, "keywords must be unique");
}
#[test]
fn temporal_and_decimal_types_map_to_text_storage() {
assert_eq!(Type::Decimal.sqlite_strict_type(), "TEXT");
assert_eq!(Type::Date.sqlite_strict_type(), "TEXT");
assert_eq!(Type::DateTime.sqlite_strict_type(), "TEXT");
}
#[test]
fn blob_type_maps_to_blob_storage() {
assert_eq!(Type::Blob.sqlite_strict_type(), "BLOB");
}
#[test]
fn unknown_type_message_lists_all_ten() {
let err = "varchar".parse::<Type>().unwrap_err();
for kw in [
"text", "int", "real", "decimal", "bool", "date", "datetime", "blob", "serial",
"shortid",
] {
assert!(
err.expected.contains(kw),
"expected list should contain `{kw}`: {}",
err.expected
);
}
}
}