1use std::collections::{BTreeMap, BTreeSet, HashMap};
8use std::ffi::OsStr;
9use std::fmt;
10use std::fs::{self, File, OpenOptions};
11use std::io::{self, Read, Write};
12use std::path::{Component, Path, PathBuf};
13use std::sync::Arc;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16use csv::{ReaderBuilder, StringRecord, Writer};
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use serde_json::{Map, Value as JsonValue};
20use sha2::{Digest, Sha256};
21
22use crate::database::Database;
23use crate::db::{Column, DbError, StatementResult};
24use crate::engine::{ExecutionBudget, MCP_EXECUTION_WORK_LIMIT};
25use crate::sql::ast::Statement;
26use crate::sql::parser::parse;
27use crate::types::{ColumnType, Value};
28
29const MANIFEST_FILE: &str = "workspace.json";
30const DATABASE_FILE: &str = "data.basalt";
31const WORKSPACE_LOCK_FILE: &str = ".workspace.lock";
32const FORMAT_VERSION: u32 = 1;
33const MAX_IMPORT_BYTES: u64 = 64 * 1024 * 1024;
34pub(crate) const MAX_MCP_IMPORT_BYTES: usize = 16 * 1024 * 1024;
35const MAX_MCP_IMPORT_ROWS: usize = 10_000;
36const MAX_MCP_IMPORT_COLUMNS: usize = 256;
37const MAX_MCP_IMPORT_CELLS: usize = 1_000_000;
38const IMPORT_BATCH_SIZE: usize = 256;
39const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
40const MAX_PREVIEW_STATEMENTS: usize = 64;
41const MAX_PREVIEW_MUTATIONS: usize = 32;
42const MAX_PREVIEW_ROWS: usize = 10_000;
43const MAX_MCP_MUTATION_ROWS: usize = 10_000;
44const MAX_MCP_DIFF_ROWS: usize = 10_000;
45const MAX_MCP_EXPORT_ROWS: usize = 10_000;
46const HISTORY_DIR: &str = "history";
47const PLANS_DIR: &str = "plans";
48const CHANGES_DIR: &str = "changes";
49const SNAPSHOTS_DIR: &str = "snapshots";
50
51pub const HELP: &str = "Basalt workspace — local, portable SQL workspaces\n\n\
52Usage:\n basalt workspace <COMMAND> [OPTIONS]\n\n\
53Commands:\n init PATH Create a workspace\n inspect [--json] PATH Show workspace metadata and schema\n query [OPTIONS] PATH SQL Run a read-only query\n preview [--json] PATH SQL Preview a write and save its plan\n plan [--json] PATH PLAN_ID Load a saved preview plan\n apply [--json] PATH PLAN_ID Apply one exact preview plan\n history [--json] PATH List applied and recoverable changes\n diff [--json] PATH [CHANGE_ID] Compare a change recovery point\n undo [--json] PATH CHANGE_ID Undo the latest change safely\n import [OPTIONS] WORKSPACE SOURCE Import CSV, JSON, JSONL, or SQL\n export [OPTIONS] WORKSPACE TABLE OUTPUT\n Export CSV, JSONL, or SQL\n\n\
54Import options:\n --table NAME Table name (required for stdin)\n --format csv|json|jsonl|sql Override format inference\n --json Emit a machine-readable import report\n\n\
55Export options:\n --format csv|jsonl|sql Override format inference\n --json Emit a machine-readable export report\n\n\
56Query options:\n --output table|csv|json Result format (table by default)\n\n\
57State options:\n --json Emit machine-readable JSON\n\n\
58SOURCE and OUTPUT may be '-' for stdin/stdout. File extensions infer formats.\n\
59Imports are atomic. Workspace data stays local and uses a versioned manifest.\n";
60
61#[derive(Debug)]
62pub enum WorkspaceError {
63 Usage(String),
64 Invalid(String),
65 Io(io::Error),
66 Database(DbError),
67 Json(serde_json::Error),
68 Csv(csv::Error),
69}
70
71impl WorkspaceError {
72 pub fn exit_code(&self) -> i32 {
73 match self {
74 WorkspaceError::Usage(_) => 2,
75 WorkspaceError::Invalid(_)
76 | WorkspaceError::Io(_)
77 | WorkspaceError::Database(_)
78 | WorkspaceError::Json(_)
79 | WorkspaceError::Csv(_) => 1,
80 }
81 }
82}
83
84impl fmt::Display for WorkspaceError {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 match self {
87 WorkspaceError::Usage(message) | WorkspaceError::Invalid(message) => {
88 write!(f, "{message}")
89 }
90 WorkspaceError::Io(error) => write!(f, "{error}"),
91 WorkspaceError::Database(error) => write!(f, "{error}"),
92 WorkspaceError::Json(error) => write!(f, "{error}"),
93 WorkspaceError::Csv(error) => write!(f, "{error}"),
94 }
95 }
96}
97
98impl std::error::Error for WorkspaceError {}
99
100impl From<io::Error> for WorkspaceError {
101 fn from(error: io::Error) -> Self {
102 WorkspaceError::Io(error)
103 }
104}
105
106impl From<DbError> for WorkspaceError {
107 fn from(error: DbError) -> Self {
108 WorkspaceError::Database(error)
109 }
110}
111
112impl From<serde_json::Error> for WorkspaceError {
113 fn from(error: serde_json::Error) -> Self {
114 WorkspaceError::Json(error)
115 }
116}
117
118impl From<csv::Error> for WorkspaceError {
119 fn from(error: csv::Error) -> Self {
120 WorkspaceError::Csv(error)
121 }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
125pub struct WorkspaceManifest {
126 pub format_version: u32,
127 pub database: String,
128}
129
130#[derive(Debug, Clone)]
131pub struct Workspace {
132 root: PathBuf,
133 manifest: WorkspaceManifest,
134 _lock: Arc<File>,
135}
136
137impl Workspace {
138 pub fn init(path: impl AsRef<Path>) -> Result<Workspace, WorkspaceError> {
139 let root = path.as_ref().to_path_buf();
140 if root.as_os_str().is_empty() {
141 return Err(WorkspaceError::Invalid(
142 "workspace path cannot be empty".to_string(),
143 ));
144 }
145 if path_is_symlink(&root)? {
146 return Err(WorkspaceError::Invalid(
147 "workspace path cannot be a symbolic link".to_string(),
148 ));
149 }
150 if root.exists() && !root.is_dir() {
151 return Err(WorkspaceError::Invalid(format!(
152 "workspace path is not a directory: {}",
153 root.display()
154 )));
155 }
156 fs::create_dir_all(&root)?;
157 let lock = acquire_workspace_lock(&root)?;
158 let manifest_path = root.join(MANIFEST_FILE);
159 let database_path = root.join(DATABASE_FILE);
160 if path_is_symlink(&manifest_path)? || manifest_path.exists() {
161 return Err(WorkspaceError::Invalid(format!(
162 "workspace already exists: {}",
163 root.display()
164 )));
165 }
166 if path_is_symlink(&database_path)? || database_path.exists() {
167 return Err(WorkspaceError::Invalid(format!(
168 "reserved database path already exists: {}",
169 database_path.display()
170 )));
171 }
172
173 let manifest = WorkspaceManifest {
174 format_version: FORMAT_VERSION,
175 database: DATABASE_FILE.to_string(),
176 };
177 write_new_file(&manifest_path, &manifest_bytes(&manifest)?)?;
178
179 let database = Database::open_in_workspace(&database_path)?;
180 database.checkpoint()?;
181 drop(database);
182
183 Ok(Workspace {
184 root,
185 manifest,
186 _lock: lock,
187 })
188 }
189
190 pub fn open(path: impl AsRef<Path>) -> Result<Workspace, WorkspaceError> {
191 let root = path.as_ref().to_path_buf();
192 if path_is_symlink(&root)? {
193 return Err(WorkspaceError::Invalid(
194 "workspace path cannot be a symbolic link".to_string(),
195 ));
196 }
197 if !root.is_dir() {
198 return Err(WorkspaceError::Invalid(format!(
199 "workspace directory does not exist: {}",
200 root.display()
201 )));
202 }
203 let manifest_path = root.join(MANIFEST_FILE);
204 if path_is_symlink(&manifest_path)? {
205 return Err(WorkspaceError::Invalid(
206 "workspace manifest cannot be a symbolic link".to_string(),
207 ));
208 }
209 let bytes = fs::read(&manifest_path).map_err(|error| {
210 if error.kind() == io::ErrorKind::NotFound {
211 WorkspaceError::Invalid(format!(
212 "not a Basalt workspace: missing {}",
213 manifest_path.display()
214 ))
215 } else {
216 WorkspaceError::Io(error)
217 }
218 })?;
219 let manifest: WorkspaceManifest = serde_json::from_slice(&bytes).map_err(|error| {
220 WorkspaceError::Invalid(format!(
221 "invalid workspace manifest {}: {error}",
222 manifest_path.display()
223 ))
224 })?;
225 if manifest.format_version != FORMAT_VERSION {
226 return Err(WorkspaceError::Invalid(format!(
227 "unsupported workspace format version {}; expected {}",
228 manifest.format_version, FORMAT_VERSION
229 )));
230 }
231 if manifest.database != DATABASE_FILE {
232 return Err(WorkspaceError::Invalid(format!(
233 "workspace database must be {DATABASE_FILE:?}, got {:?}",
234 manifest.database
235 )));
236 }
237 validate_database_paths(&root)?;
238 let lock = acquire_workspace_lock(&root)?;
239 Ok(Workspace {
240 root,
241 manifest,
242 _lock: lock,
243 })
244 }
245
246 pub fn open_or_init(path: impl AsRef<Path>) -> Result<Workspace, WorkspaceError> {
248 let path = path.as_ref().to_path_buf();
249 match Self::open(&path) {
250 Ok(workspace) => Ok(workspace),
251 Err(_error) if !path.exists() => Self::init(path),
252 Err(error) => Err(error),
253 }
254 }
255
256 pub fn root(&self) -> &Path {
257 &self.root
258 }
259
260 pub fn manifest(&self) -> &WorkspaceManifest {
261 &self.manifest
262 }
263
264 pub fn database_path(&self) -> PathBuf {
265 self.root.join(DATABASE_FILE)
266 }
267
268 pub fn database(&self) -> Result<Database, WorkspaceError> {
269 let path = self.database_path();
270 validate_database_paths(&self.root)?;
271 Ok(Database::open_in_workspace(path)?)
272 }
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276enum DataFormat {
277 Csv,
278 Json,
279 JsonLines,
280 Sql,
281}
282
283impl DataFormat {
284 fn parse(value: &str) -> Result<DataFormat, WorkspaceError> {
285 match value.to_ascii_lowercase().as_str() {
286 "csv" => Ok(DataFormat::Csv),
287 "json" => Ok(DataFormat::Json),
288 "jsonl" | "ndjson" => Ok(DataFormat::JsonLines),
289 "sql" => Ok(DataFormat::Sql),
290 _ => Err(WorkspaceError::Usage(format!(
291 "unknown format {value:?}; expected csv, json, jsonl, or sql"
292 ))),
293 }
294 }
295
296 fn from_path(path: &Path) -> Option<DataFormat> {
297 let extension = path
298 .extension()
299 .and_then(OsStr::to_str)?
300 .to_ascii_lowercase();
301 match extension.as_str() {
302 "csv" => Some(DataFormat::Csv),
303 "json" => Some(DataFormat::Json),
304 "jsonl" | "ndjson" => Some(DataFormat::JsonLines),
305 "sql" => Some(DataFormat::Sql),
306 _ => None,
307 }
308 }
309
310 fn name(self) -> &'static str {
311 match self {
312 DataFormat::Csv => "csv",
313 DataFormat::Json => "json",
314 DataFormat::JsonLines => "jsonl",
315 DataFormat::Sql => "sql",
316 }
317 }
318}
319
320#[derive(Debug)]
321enum Command {
322 Help,
323 Init(PathBuf),
324 Inspect {
325 json: bool,
326 workspace: PathBuf,
327 },
328 Query {
329 workspace: PathBuf,
330 sql: String,
331 output: crate::cli::OutputMode,
332 },
333 Preview {
334 workspace: PathBuf,
335 sql: String,
336 json: bool,
337 },
338 Plan {
339 workspace: PathBuf,
340 plan_id: String,
341 json: bool,
342 },
343 Apply {
344 workspace: PathBuf,
345 plan_id: String,
346 json: bool,
347 },
348 History {
349 workspace: PathBuf,
350 json: bool,
351 },
352 Diff {
353 workspace: PathBuf,
354 change_id: Option<String>,
355 json: bool,
356 },
357 Undo {
358 workspace: PathBuf,
359 change_id: String,
360 json: bool,
361 },
362 Import {
363 workspace: PathBuf,
364 source: PathBuf,
365 table: Option<String>,
366 format: Option<DataFormat>,
367 json: bool,
368 },
369 Export {
370 workspace: PathBuf,
371 table: String,
372 output: PathBuf,
373 format: Option<DataFormat>,
374 json: bool,
375 },
376}
377
378pub fn run<R: Read, W: Write>(
379 args: &[String],
380 input: &mut R,
381 output: &mut W,
382) -> Result<(), WorkspaceError> {
383 if args.is_empty() || args.iter().any(|arg| arg == "--help") {
384 output.write_all(HELP.as_bytes())?;
385 return Ok(());
386 }
387 let command = parse_command(args)?;
388 match command {
389 Command::Help => output.write_all(HELP.as_bytes())?,
390 Command::Init(path) => {
391 let workspace = Workspace::init(path)?;
392 writeln!(output, "created workspace {}", workspace.root().display())?;
393 }
394 Command::Inspect { json, workspace } => {
395 let workspace = Workspace::open(workspace)?;
396 let database = workspace.database()?;
397 let report = inspect(&workspace, &database)?;
398 if json {
399 serde_json::to_writer_pretty(&mut *output, &report)?;
400 output.write_all(b"\n")?;
401 } else {
402 render_inspect(&report, output)?;
403 }
404 }
405 Command::Query {
406 workspace,
407 sql,
408 output: output_mode,
409 } => {
410 let workspace = Workspace::open(workspace)?;
411 let statements = parse(&sql).map_err(|error| {
412 WorkspaceError::Invalid(format!(
413 "query parse error at byte {}: {}",
414 error.offset, error.message
415 ))
416 })?;
417 if statements.is_empty() || statements.iter().any(|statement| !is_read_only(statement))
418 {
419 return Err(WorkspaceError::Invalid(
420 "workspace query only accepts SELECT and EXPLAIN SELECT".to_string(),
421 ));
422 }
423 let options = crate::cli::CliOptions {
424 database: workspace.database_path().display().to_string(),
425 actions: vec![crate::cli::InputAction::Command(sql)],
426 output: output_mode,
427 headers: true,
428 quiet: false,
429 help: false,
430 version: false,
431 };
432 let database = workspace.database()?;
433 let mut empty_input = io::Cursor::new(Vec::<u8>::new());
434 crate::cli::run(&options, database, &mut empty_input, output)
435 .map_err(|error| WorkspaceError::Invalid(error.to_string()))?;
436 }
437 Command::Preview {
438 workspace,
439 sql,
440 json,
441 } => {
442 let workspace = Workspace::open(workspace)?;
443 let plan = preview_plan(&workspace, &sql)?;
444 if json {
445 let report = PlanReport::from(&plan);
446 serde_json::to_writer_pretty(&mut *output, &report)?;
447 output.write_all(b"\n")?;
448 } else {
449 render_plan(&workspace, &plan, output)?;
450 }
451 }
452 Command::Plan {
453 workspace,
454 plan_id,
455 json,
456 } => {
457 let workspace = Workspace::open(workspace)?;
458 let plan = load_plan(&workspace, &plan_id)?;
459 if json {
460 let report = PlanReport::from(&plan);
461 serde_json::to_writer_pretty(&mut *output, &report)?;
462 output.write_all(b"\n")?;
463 } else {
464 render_plan(&workspace, &plan, output)?;
465 }
466 }
467 Command::Apply {
468 workspace,
469 plan_id,
470 json,
471 } => {
472 let workspace = Workspace::open(workspace)?;
473 let report = apply_plan(&workspace, &plan_id, None, None)?;
474 if json {
475 serde_json::to_writer_pretty(&mut *output, &report)?;
476 output.write_all(b"\n")?;
477 } else {
478 render_apply(&report, output)?;
479 }
480 }
481 Command::History { workspace, json } => {
482 let workspace = Workspace::open(workspace)?;
483 let entries = history(&workspace)?;
484 if json {
485 serde_json::to_writer_pretty(&mut *output, &entries)?;
486 output.write_all(b"\n")?;
487 } else {
488 render_history(&entries, output)?;
489 }
490 }
491 Command::Diff {
492 workspace,
493 change_id,
494 json,
495 } => {
496 let workspace = Workspace::open(workspace)?;
497 let report = diff(&workspace, change_id.as_deref())?;
498 if json {
499 serde_json::to_writer_pretty(&mut *output, &report)?;
500 output.write_all(b"\n")?;
501 } else {
502 render_diff(&report, output)?;
503 }
504 }
505 Command::Undo {
506 workspace,
507 change_id,
508 json,
509 } => {
510 let workspace = Workspace::open(workspace)?;
511 let report = undo(&workspace, &change_id)?;
512 if json {
513 serde_json::to_writer_pretty(&mut *output, &report)?;
514 output.write_all(b"\n")?;
515 } else {
516 render_undo(&report, output)?;
517 }
518 }
519 Command::Import {
520 workspace,
521 source,
522 table,
523 format,
524 json,
525 } => {
526 let workspace = Workspace::open(workspace)?;
527 let format = format
528 .or_else(|| DataFormat::from_path(&source))
529 .ok_or_else(|| {
530 WorkspaceError::Usage(
531 "cannot infer import format; provide --format or use a supported extension"
532 .to_string(),
533 )
534 })?;
535 let bytes = read_source(&source, input)?;
536 let table_name = if format == DataFormat::Sql {
537 None
538 } else {
539 table.or_else(|| inferred_table_name(&source))
540 };
541 let imported = import_with_recovery(
542 &workspace,
543 table_name.as_deref(),
544 format,
545 &bytes,
546 ImportLimits::unbounded(),
547 )?;
548 if json {
549 let report = CliImportReport {
550 operation: "import",
551 workspace: workspace.root.display().to_string(),
552 source: source.display().to_string(),
553 format: imported.format.clone(),
554 table: imported.table.clone(),
555 bytes: bytes.len(),
556 change_id: imported.change_id.clone(),
557 summary: imported.summary.clone(),
558 };
559 serde_json::to_writer_pretty(&mut *output, &report)?;
560 output.write_all(b"\n")?;
561 } else {
562 writeln!(
563 output,
564 "imported {}: {} (change {})",
565 imported.format, imported.summary, imported.change_id
566 )?;
567 }
568 }
569 Command::Export {
570 workspace,
571 table,
572 output: destination,
573 format,
574 json,
575 } => {
576 let workspace = Workspace::open(workspace)?;
577 let format = format
578 .or_else(|| DataFormat::from_path(&destination))
579 .ok_or_else(|| {
580 WorkspaceError::Usage(
581 "cannot infer export format; provide --format or use a supported extension"
582 .to_string(),
583 )
584 })?;
585 if format == DataFormat::Json {
586 return Err(WorkspaceError::Usage(
587 "JSON export is JSON Lines; use --format jsonl or a .jsonl file".to_string(),
588 ));
589 }
590 if json && destination.as_os_str() == OsStr::new("-") {
591 return Err(WorkspaceError::Usage(
592 "--json cannot be combined with stdout export; write the export to a file"
593 .to_string(),
594 ));
595 }
596 let database = workspace.database()?;
597 let (columns, rows) = select_table(&database, &table)?;
598 let bytes = match format {
599 DataFormat::Csv => export_csv(&columns, &rows)?,
600 DataFormat::JsonLines => export_json_lines(&columns, &rows)?,
601 DataFormat::Sql => export_sql(&database, &table, &rows)?,
602 DataFormat::Json => unreachable!(),
603 };
604 write_output(&workspace, &destination, &bytes, output)?;
605 if json {
606 let report = CliExportReport {
607 operation: "export",
608 workspace: workspace.root.display().to_string(),
609 table,
610 format: format.name().to_string(),
611 output: destination.display().to_string(),
612 rows: rows.len(),
613 bytes: bytes.len(),
614 };
615 serde_json::to_writer_pretty(&mut *output, &report)?;
616 output.write_all(b"\n")?;
617 } else if destination.as_os_str() == OsStr::new("-") {
618 output.flush()?;
619 } else {
620 writeln!(
621 output,
622 "exported {} rows to {}",
623 rows.len(),
624 destination.display()
625 )?;
626 }
627 }
628 }
629 output.flush()?;
630 Ok(())
631}
632
633fn parse_command(args: &[String]) -> Result<Command, WorkspaceError> {
634 match args.first().map(String::as_str) {
635 Some("init") => parse_init(&args[1..]),
636 Some("inspect") => parse_inspect(&args[1..]),
637 Some("query") => parse_query(&args[1..]),
638 Some("preview") => parse_preview(&args[1..]),
639 Some("plan") => parse_plan(&args[1..]),
640 Some("apply") => parse_apply(&args[1..]),
641 Some("history") => parse_history(&args[1..]),
642 Some("diff") => parse_diff(&args[1..]),
643 Some("undo") => parse_undo(&args[1..]),
644 Some("import") => parse_import(&args[1..]),
645 Some("export") => parse_export(&args[1..]),
646 Some("--help") => Ok(Command::Help),
647 Some(command) => Err(WorkspaceError::Usage(format!(
648 "unknown workspace command {command:?}; run `basalt workspace --help`"
649 ))),
650 None => Err(WorkspaceError::Usage(
651 "missing workspace command".to_string(),
652 )),
653 }
654}
655
656fn parse_init(args: &[String]) -> Result<Command, WorkspaceError> {
657 if args.len() == 1 && args[0] != "--help" {
658 return Ok(Command::Init(PathBuf::from(&args[0])));
659 }
660 Err(WorkspaceError::Usage(
661 "usage: basalt workspace init PATH".to_string(),
662 ))
663}
664
665fn parse_inspect(args: &[String]) -> Result<Command, WorkspaceError> {
666 let mut json = false;
667 let mut positional = Vec::new();
668 let mut options = true;
669 for arg in args {
670 if options && arg == "--" {
671 options = false;
672 } else if options && arg == "--json" {
673 json = true;
674 } else if options && arg == "--help" {
675 return Err(WorkspaceError::Usage(HELP.to_string()));
676 } else if options && arg.starts_with('-') {
677 return Err(WorkspaceError::Usage(format!(
678 "unknown inspect option {arg:?}"
679 )));
680 } else {
681 positional.push(arg);
682 }
683 }
684 if positional.len() != 1 {
685 return Err(WorkspaceError::Usage(
686 "usage: basalt workspace inspect [--json] PATH".to_string(),
687 ));
688 }
689 Ok(Command::Inspect {
690 json,
691 workspace: PathBuf::from(positional[0]),
692 })
693}
694
695fn parse_query(args: &[String]) -> Result<Command, WorkspaceError> {
696 let mut output = crate::cli::OutputMode::Table;
697 let mut positional = Vec::new();
698 let mut index = 0;
699 let mut options = true;
700 while index < args.len() {
701 let arg = &args[index];
702 if options && arg == "--" {
703 options = false;
704 } else if options && arg == "--json" {
705 output = crate::cli::OutputMode::Json;
706 } else if options && arg == "--csv" {
707 output = crate::cli::OutputMode::Csv;
708 } else if options && arg == "--table" {
709 output = crate::cli::OutputMode::Table;
710 } else if options && arg == "--output" {
711 index += 1;
712 output = parse_query_output(&option_value(args, index, "--output")?)?;
713 } else if options && arg == "--help" {
714 return Err(WorkspaceError::Usage(HELP.to_string()));
715 } else if options && arg.starts_with('-') {
716 return Err(WorkspaceError::Usage(format!(
717 "unknown query option {arg:?}"
718 )));
719 } else {
720 positional.push(arg.clone());
721 }
722 index += 1;
723 }
724 if positional.len() != 2 {
725 return Err(WorkspaceError::Usage(
726 "usage: basalt workspace query [OPTIONS] PATH SQL".to_string(),
727 ));
728 }
729 Ok(Command::Query {
730 workspace: PathBuf::from(&positional[0]),
731 sql: positional[1].clone(),
732 output,
733 })
734}
735
736fn parse_query_output(value: &str) -> Result<crate::cli::OutputMode, WorkspaceError> {
737 match value.to_ascii_lowercase().as_str() {
738 "table" => Ok(crate::cli::OutputMode::Table),
739 "csv" => Ok(crate::cli::OutputMode::Csv),
740 "json" | "jsonl" | "ndjson" => Ok(crate::cli::OutputMode::Json),
741 _ => Err(WorkspaceError::Usage(format!(
742 "unknown query output format {value:?}; expected table, csv, or json"
743 ))),
744 }
745}
746
747fn parse_json_flagged_command(
748 args: &[String],
749 usage: &str,
750 positionals: std::ops::RangeInclusive<usize>,
751) -> Result<(bool, Vec<String>), WorkspaceError> {
752 let mut json = false;
753 let mut positional = Vec::new();
754 let mut options = true;
755 for arg in args {
756 if options && arg == "--" {
757 options = false;
758 } else if options && arg == "--json" {
759 json = true;
760 } else if options && arg == "--help" {
761 return Err(WorkspaceError::Usage(HELP.to_string()));
762 } else if options && arg.starts_with('-') {
763 return Err(WorkspaceError::Usage(format!("unknown option {arg:?}")));
764 } else {
765 positional.push(arg.clone());
766 }
767 }
768 if !positionals.contains(&positional.len()) {
769 return Err(WorkspaceError::Usage(usage.to_string()));
770 }
771 Ok((json, positional))
772}
773
774fn parse_preview(args: &[String]) -> Result<Command, WorkspaceError> {
775 let (json, positional) = parse_json_flagged_command(
776 args,
777 "usage: basalt workspace preview [--json] PATH SQL",
778 2..=2,
779 )?;
780 Ok(Command::Preview {
781 workspace: PathBuf::from(&positional[0]),
782 sql: positional[1].clone(),
783 json,
784 })
785}
786
787fn parse_apply(args: &[String]) -> Result<Command, WorkspaceError> {
788 let (json, positional) = parse_json_flagged_command(
789 args,
790 "usage: basalt workspace apply [--json] PATH PLAN_ID",
791 2..=2,
792 )?;
793 Ok(Command::Apply {
794 workspace: PathBuf::from(&positional[0]),
795 plan_id: positional[1].clone(),
796 json,
797 })
798}
799
800fn parse_plan(args: &[String]) -> Result<Command, WorkspaceError> {
801 let (json, positional) = parse_json_flagged_command(
802 args,
803 "usage: basalt workspace plan [--json] PATH PLAN_ID",
804 2..=2,
805 )?;
806 Ok(Command::Plan {
807 workspace: PathBuf::from(&positional[0]),
808 plan_id: positional[1].clone(),
809 json,
810 })
811}
812
813fn parse_history(args: &[String]) -> Result<Command, WorkspaceError> {
814 let (json, positional) =
815 parse_json_flagged_command(args, "usage: basalt workspace history [--json] PATH", 1..=1)?;
816 Ok(Command::History {
817 workspace: PathBuf::from(&positional[0]),
818 json,
819 })
820}
821
822fn parse_diff(args: &[String]) -> Result<Command, WorkspaceError> {
823 let (json, positional) = parse_json_flagged_command(
824 args,
825 "usage: basalt workspace diff [--json] PATH [CHANGE_ID]",
826 1..=2,
827 )?;
828 Ok(Command::Diff {
829 workspace: PathBuf::from(&positional[0]),
830 change_id: positional.get(1).cloned(),
831 json,
832 })
833}
834
835fn parse_undo(args: &[String]) -> Result<Command, WorkspaceError> {
836 let (json, positional) = parse_json_flagged_command(
837 args,
838 "usage: basalt workspace undo [--json] PATH CHANGE_ID",
839 2..=2,
840 )?;
841 Ok(Command::Undo {
842 workspace: PathBuf::from(&positional[0]),
843 change_id: positional[1].clone(),
844 json,
845 })
846}
847
848fn parse_import(args: &[String]) -> Result<Command, WorkspaceError> {
849 let mut table = None;
850 let mut format = None;
851 let mut json = false;
852 let mut positional = Vec::new();
853 let mut index = 0;
854 let mut options = true;
855 while index < args.len() {
856 let arg = &args[index];
857 if options && arg == "--" {
858 options = false;
859 } else if options && arg == "--table" {
860 index += 1;
861 table = Some(option_value(args, index, "--table")?);
862 } else if options && arg == "--format" {
863 index += 1;
864 format = Some(DataFormat::parse(&option_value(args, index, "--format")?)?);
865 } else if options && arg == "--json" {
866 json = true;
867 } else if options && arg == "--help" {
868 return Err(WorkspaceError::Usage(HELP.to_string()));
869 } else if options && arg.starts_with('-') && arg != "-" {
870 return Err(WorkspaceError::Usage(format!(
871 "unknown import option {arg:?}"
872 )));
873 } else {
874 positional.push(arg.clone());
875 }
876 index += 1;
877 }
878 if positional.len() != 2 {
879 return Err(WorkspaceError::Usage(
880 "usage: basalt workspace import [OPTIONS] WORKSPACE SOURCE".to_string(),
881 ));
882 }
883 let source = PathBuf::from(&positional[1]);
884 if source.as_os_str() == OsStr::new("-") && table.is_none() && format != Some(DataFormat::Sql) {
885 return Err(WorkspaceError::Usage(
886 "stdin imports require --table NAME".to_string(),
887 ));
888 }
889 if format == Some(DataFormat::Sql) && table.is_some() {
890 return Err(WorkspaceError::Usage(
891 "--table is not valid for SQL imports".to_string(),
892 ));
893 }
894 Ok(Command::Import {
895 workspace: PathBuf::from(&positional[0]),
896 source,
897 table,
898 format,
899 json,
900 })
901}
902
903fn parse_export(args: &[String]) -> Result<Command, WorkspaceError> {
904 let mut format = None;
905 let mut json = false;
906 let mut positional = Vec::new();
907 let mut index = 0;
908 let mut options = true;
909 while index < args.len() {
910 let arg = &args[index];
911 if options && arg == "--" {
912 options = false;
913 } else if options && arg == "--format" {
914 index += 1;
915 format = Some(DataFormat::parse(&option_value(args, index, "--format")?)?);
916 } else if options && arg == "--json" {
917 json = true;
918 } else if options && arg == "--help" {
919 return Err(WorkspaceError::Usage(HELP.to_string()));
920 } else if options && arg.starts_with('-') && arg != "-" {
921 return Err(WorkspaceError::Usage(format!(
922 "unknown export option {arg:?}"
923 )));
924 } else {
925 positional.push(arg.clone());
926 }
927 index += 1;
928 }
929 if positional.len() != 3 {
930 return Err(WorkspaceError::Usage(
931 "usage: basalt workspace export [OPTIONS] WORKSPACE TABLE OUTPUT".to_string(),
932 ));
933 }
934 let output = PathBuf::from(&positional[2]);
935 if output.as_os_str() == OsStr::new("-") && format.is_none() {
936 return Err(WorkspaceError::Usage(
937 "stdout exports require --format FORMAT".to_string(),
938 ));
939 }
940 Ok(Command::Export {
941 workspace: PathBuf::from(&positional[0]),
942 table: positional[1].clone(),
943 output,
944 format,
945 json,
946 })
947}
948
949fn option_value(args: &[String], index: usize, option: &str) -> Result<String, WorkspaceError> {
950 args.get(index)
951 .cloned()
952 .filter(|value| !value.starts_with('-') || value == "-")
953 .ok_or_else(|| WorkspaceError::Usage(format!("{option} requires a value")))
954}
955
956fn read_source<R: Read>(source: &Path, input: &mut R) -> Result<Vec<u8>, WorkspaceError> {
957 if source.as_os_str() == OsStr::new("-") {
958 return read_limited(input);
959 }
960 let mut file = File::open(source)?;
961 read_limited(&mut file)
962}
963
964fn read_limited<R: Read>(reader: &mut R) -> Result<Vec<u8>, WorkspaceError> {
965 let mut limited = reader.take(MAX_IMPORT_BYTES + 1);
966 let mut bytes = Vec::new();
967 limited.read_to_end(&mut bytes)?;
968 if bytes.len() as u64 > MAX_IMPORT_BYTES {
969 return Err(WorkspaceError::Invalid(format!(
970 "input exceeds the {} MiB limit",
971 MAX_IMPORT_BYTES / (1024 * 1024)
972 )));
973 }
974 Ok(bytes)
975}
976
977fn inferred_table_name(source: &Path) -> Option<String> {
978 if source.as_os_str() == OsStr::new("-") {
979 return None;
980 }
981 source
982 .file_stem()
983 .and_then(OsStr::to_str)
984 .map(str::to_string)
985}
986
987#[derive(Debug, Clone)]
988enum ImportedCell {
989 Null,
990 Empty,
991 Integer(i64),
992 Real(f64),
993 Boolean(bool),
994 Text(String),
995}
996
997#[derive(Debug, Clone)]
998struct ImportedRows {
999 table: String,
1000 columns: Vec<String>,
1001 types: Vec<ColumnType>,
1002 rows: Vec<Vec<ImportedCell>>,
1003}
1004
1005#[derive(Debug, Clone, Copy)]
1006struct ImportLimits {
1007 max_rows: Option<usize>,
1008 max_columns: Option<usize>,
1009 max_cells: Option<usize>,
1010 max_work: Option<usize>,
1011}
1012
1013impl ImportLimits {
1014 fn unbounded() -> Self {
1015 Self {
1016 max_rows: None,
1017 max_columns: None,
1018 max_cells: None,
1019 max_work: None,
1020 }
1021 }
1022
1023 fn mcp() -> Self {
1024 Self {
1025 max_rows: Some(MAX_MCP_IMPORT_ROWS),
1026 max_columns: Some(MAX_MCP_IMPORT_COLUMNS),
1027 max_cells: Some(MAX_MCP_IMPORT_CELLS),
1028 max_work: Some(MCP_EXECUTION_WORK_LIMIT),
1029 }
1030 }
1031}
1032
1033#[derive(Debug, Serialize)]
1034struct CliImportReport {
1035 operation: &'static str,
1036 workspace: String,
1037 source: String,
1038 format: String,
1039 table: Option<String>,
1040 bytes: usize,
1041 change_id: String,
1042 summary: String,
1043}
1044
1045#[derive(Debug, Serialize)]
1046struct CliExportReport {
1047 operation: &'static str,
1048 workspace: String,
1049 table: String,
1050 format: String,
1051 output: String,
1052 rows: usize,
1053 bytes: usize,
1054}
1055
1056fn import_csv(
1057 database: &Database,
1058 table: Option<&str>,
1059 bytes: &[u8],
1060 limits: ImportLimits,
1061) -> Result<Option<String>, WorkspaceError> {
1062 let table = required_table(table)?;
1063 let mut reader = ReaderBuilder::new()
1064 .has_headers(true)
1065 .flexible(false)
1066 .from_reader(bytes);
1067 let headers = reader.headers()?.clone();
1068 let columns = validate_headers(&headers)?;
1069 enforce_import_limits(0, columns.len(), limits)?;
1070 let mut rows = Vec::new();
1071 for record in reader.records() {
1072 if let Some(max_rows) = limits.max_rows
1073 && rows.len() >= max_rows
1074 {
1075 return Err(WorkspaceError::Invalid(format!(
1076 "MCP import is limited to {max_rows} rows; use the CLI for larger imports"
1077 )));
1078 }
1079 let record = record?;
1080 enforce_import_limits(rows.len().saturating_add(1), columns.len(), limits)?;
1081 rows.push(record.iter().map(parse_csv_cell).collect());
1082 }
1083 let imported = ImportedRows {
1084 table: table.to_string(),
1085 types: infer_types(&rows, columns.len()),
1086 columns,
1087 rows,
1088 };
1089 let summary = imported.summary();
1090 import_rows(database, &imported, limits.max_work)?;
1091 Ok(Some(summary))
1092}
1093
1094fn import_json(
1095 database: &Database,
1096 table: Option<&str>,
1097 bytes: &[u8],
1098 limits: ImportLimits,
1099) -> Result<Option<String>, WorkspaceError> {
1100 let value: JsonValue = serde_json::from_slice(bytes)?;
1101 let objects = match value {
1102 JsonValue::Array(values) => values,
1103 JsonValue::Object(object) => vec![JsonValue::Object(object)],
1104 _ => {
1105 return Err(WorkspaceError::Invalid(
1106 "JSON import expects an object or an array of objects".to_string(),
1107 ));
1108 }
1109 };
1110 import_json_objects(database, table, objects, limits)
1111}
1112
1113fn import_json_lines(
1114 database: &Database,
1115 table: Option<&str>,
1116 bytes: &[u8],
1117 limits: ImportLimits,
1118) -> Result<Option<String>, WorkspaceError> {
1119 let text = std::str::from_utf8(bytes).map_err(|error| {
1120 WorkspaceError::Invalid(format!("JSON Lines input is not UTF-8: {error}"))
1121 })?;
1122 let mut objects = Vec::new();
1123 for (line_number, line) in text.lines().enumerate() {
1124 if line.trim().is_empty() {
1125 continue;
1126 }
1127 if let Some(max_rows) = limits.max_rows
1128 && objects.len() >= max_rows
1129 {
1130 return Err(WorkspaceError::Invalid(format!(
1131 "MCP import is limited to {max_rows} rows; use the CLI for larger imports"
1132 )));
1133 }
1134 let value: JsonValue = serde_json::from_str(line).map_err(|error| {
1135 WorkspaceError::Invalid(format!("invalid JSON on line {}: {error}", line_number + 1))
1136 })?;
1137 if !value.is_object() {
1138 return Err(WorkspaceError::Invalid(format!(
1139 "JSON Lines line {} is not an object",
1140 line_number + 1
1141 )));
1142 }
1143 objects.push(value);
1144 }
1145 import_json_objects(database, table, objects, limits)
1146}
1147
1148fn import_json_objects(
1149 database: &Database,
1150 table: Option<&str>,
1151 objects: Vec<JsonValue>,
1152 limits: ImportLimits,
1153) -> Result<Option<String>, WorkspaceError> {
1154 let table = required_table(table)?;
1155 let object_count = objects.len();
1156 if objects.is_empty() {
1157 return Err(WorkspaceError::Invalid(
1158 "JSON import contains no objects; a table schema cannot be inferred".to_string(),
1159 ));
1160 }
1161 enforce_import_limits(object_count, 0, limits)?;
1162 let mut names = BTreeSet::new();
1163 let mut parsed_objects = Vec::with_capacity(objects.len());
1164 for value in objects {
1165 let JsonValue::Object(object) = value else {
1166 return Err(WorkspaceError::Invalid(
1167 "JSON import expects every row to be an object".to_string(),
1168 ));
1169 };
1170 for name in object.keys() {
1171 validate_name(name, "JSON key")?;
1172 names.insert(name.clone());
1173 }
1174 parsed_objects.push(object);
1175 }
1176 if names.is_empty() {
1177 return Err(WorkspaceError::Invalid(
1178 "JSON objects contain no fields; a table schema cannot be inferred".to_string(),
1179 ));
1180 }
1181 let columns: Vec<String> = names.into_iter().collect();
1182 enforce_import_limits(object_count, columns.len(), limits)?;
1183 let rows = parsed_objects
1184 .iter()
1185 .map(|object| {
1186 columns
1187 .iter()
1188 .map(|name| {
1189 object
1190 .get(name)
1191 .map(json_cell)
1192 .unwrap_or(ImportedCell::Null)
1193 })
1194 .collect()
1195 })
1196 .collect::<Vec<Vec<ImportedCell>>>();
1197 let imported = ImportedRows {
1198 table: table.to_string(),
1199 types: infer_types(&rows, columns.len()),
1200 columns,
1201 rows,
1202 };
1203 let summary = imported.summary();
1204 import_rows(database, &imported, limits.max_work)?;
1205 Ok(Some(summary))
1206}
1207
1208fn enforce_import_limits(
1209 rows: usize,
1210 columns: usize,
1211 limits: ImportLimits,
1212) -> Result<(), WorkspaceError> {
1213 if let Some(max_rows) = limits.max_rows
1214 && rows > max_rows
1215 {
1216 return Err(WorkspaceError::Invalid(format!(
1217 "MCP import is limited to {max_rows} rows; use the CLI for larger imports"
1218 )));
1219 }
1220 if let Some(max_columns) = limits.max_columns
1221 && columns > max_columns
1222 {
1223 return Err(WorkspaceError::Invalid(format!(
1224 "MCP import is limited to {max_columns} columns; use the CLI for wider imports"
1225 )));
1226 }
1227 if let Some(max_cells) = limits.max_cells {
1228 let cells = rows.saturating_mul(columns);
1229 if cells > max_cells {
1230 return Err(WorkspaceError::Invalid(format!(
1231 "MCP import is limited to {max_cells} cells; use the CLI for larger imports"
1232 )));
1233 }
1234 }
1235 Ok(())
1236}
1237
1238fn parse_sql_import(bytes: &[u8]) -> Result<(&str, Vec<Statement>), WorkspaceError> {
1239 let sql = std::str::from_utf8(bytes)
1240 .map_err(|error| WorkspaceError::Invalid(format!("SQL input is not UTF-8: {error}")))?;
1241 let statements = parse(sql).map_err(|error| {
1242 WorkspaceError::Invalid(format!(
1243 "SQL import parse error at byte {}: {}",
1244 error.offset, error.message
1245 ))
1246 })?;
1247 if statements.is_empty() {
1248 return Err(WorkspaceError::Invalid(
1249 "SQL import contains no statements".to_string(),
1250 ));
1251 }
1252 if statements.iter().any(statement_contains_control) {
1253 return Err(WorkspaceError::Invalid(
1254 "SQL imports must not contain BEGIN, COMMIT, ROLLBACK, or CHECKPOINT".to_string(),
1255 ));
1256 }
1257 Ok((sql, statements))
1258}
1259
1260fn import_sql(database: &Database, bytes: &[u8]) -> Result<Option<String>, WorkspaceError> {
1261 let (sql, _statements) = parse_sql_import(bytes)?;
1262
1263 let mut connection = database.connect();
1264 connection.execute_sql("BEGIN")?;
1265 let result = connection.execute_sql(sql);
1266 match result {
1267 Ok(results) => {
1268 connection.execute_sql("COMMIT")?;
1269 let mutations = results
1270 .iter()
1271 .filter(|result| {
1272 matches!(
1273 result,
1274 StatementResult::Insert { .. }
1275 | StatementResult::Update { .. }
1276 | StatementResult::Delete { .. }
1277 | StatementResult::CreateTable { .. }
1278 | StatementResult::DropTable { .. }
1279 | StatementResult::CreateIndex { .. }
1280 | StatementResult::DropIndex { .. }
1281 )
1282 })
1283 .count();
1284 Ok(Some(format!("{mutations} statements from SQL")))
1285 }
1286 Err(error) => {
1287 let _ = connection.execute_sql("ROLLBACK");
1288 Err(error.into())
1289 }
1290 }
1291}
1292
1293fn statement_contains_control(statement: &Statement) -> bool {
1294 match statement {
1295 Statement::Begin | Statement::Commit | Statement::Rollback | Statement::Checkpoint => true,
1296 Statement::Explain(inner) => statement_contains_control(inner),
1297 _ => false,
1298 }
1299}
1300
1301fn is_read_only(statement: &Statement) -> bool {
1302 match statement {
1303 Statement::Select { .. } => true,
1304 Statement::Explain(inner) => is_read_only(inner),
1305 _ => false,
1306 }
1307}
1308
1309#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1310enum ChangeKind {
1311 #[serde(rename = "apply")]
1312 Apply,
1313 #[serde(rename = "undo")]
1314 Undo,
1315}
1316
1317#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1318enum ChangeStatus {
1319 #[serde(rename = "prepared")]
1320 Prepared,
1321 #[serde(rename = "committed")]
1322 Committed,
1323 #[serde(rename = "recovered")]
1324 Recovered,
1325 #[serde(rename = "failed")]
1326 Failed,
1327 #[serde(rename = "unresolved")]
1328 Unresolved,
1329}
1330
1331impl ChangeStatus {
1332 fn is_committed(&self) -> bool {
1333 matches!(self, ChangeStatus::Committed | ChangeStatus::Recovered)
1334 }
1335}
1336
1337#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1338struct PreviewItem {
1339 statement: usize,
1340 kind: String,
1341 mutating: bool,
1342 rows_affected: Option<usize>,
1343 rows_returned: Option<usize>,
1344 object: Option<String>,
1345}
1346
1347#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1348struct PlanRecord {
1349 format_version: u32,
1350 plan_id: String,
1351 base_generation: u64,
1352 base_state: String,
1353 sql: String,
1354 statements: Vec<PreviewItem>,
1355}
1356
1357#[derive(Debug, Serialize, JsonSchema)]
1358pub(crate) struct PlanReport {
1359 plan_id: String,
1360 sql: String,
1361 base_generation: u64,
1362 base_state: String,
1363 statement_count: usize,
1364 mutating_statements: usize,
1365 statements: Vec<PreviewItem>,
1366}
1367
1368impl From<&PlanRecord> for PlanReport {
1369 fn from(plan: &PlanRecord) -> Self {
1370 Self {
1371 plan_id: plan.plan_id.clone(),
1372 sql: plan.sql.clone(),
1373 base_generation: plan.base_generation,
1374 base_state: plan.base_state.clone(),
1375 statement_count: plan.statements.len(),
1376 mutating_statements: plan.statements.iter().filter(|item| item.mutating).count(),
1377 statements: plan.statements.clone(),
1378 }
1379 }
1380}
1381
1382#[derive(Debug, Clone, Serialize, Deserialize)]
1383struct ImportMetadata {
1384 request_key: String,
1385 format: String,
1386 #[serde(default)]
1387 table: Option<String>,
1388 bytes: usize,
1389 summary: String,
1390}
1391
1392#[derive(Debug, Clone, Serialize, Deserialize)]
1393struct ChangeRecord {
1394 format_version: u32,
1395 sequence: u64,
1396 change_id: String,
1397 kind: ChangeKind,
1398 plan_id: Option<String>,
1399 target_change_id: Option<String>,
1400 base_generation: u64,
1401 base_state: String,
1402 expected_state: Option<String>,
1403 snapshot_id: String,
1404 sql: Option<String>,
1405 status: ChangeStatus,
1406 committed_generation: Option<u64>,
1407 after_state: Option<String>,
1408 error: Option<String>,
1409 #[serde(default)]
1410 import: Option<ImportMetadata>,
1411}
1412
1413#[derive(Debug, Serialize, JsonSchema)]
1414pub(crate) struct ApplyReport {
1415 change_id: String,
1416 plan_id: String,
1417 base_state: String,
1418 after_state: String,
1419 generation: u64,
1420}
1421
1422#[derive(Debug, Serialize, JsonSchema)]
1423pub(crate) struct HistoryEntry {
1424 sequence: u64,
1425 change_id: String,
1426 kind: ChangeKind,
1427 status: ChangeStatus,
1428 plan_id: Option<String>,
1429 target_change_id: Option<String>,
1430 base_state: String,
1431 after_state: Option<String>,
1432 committed_generation: Option<u64>,
1433 error: Option<String>,
1434 import: Option<HistoryImport>,
1435}
1436
1437#[derive(Debug, Serialize, JsonSchema)]
1438struct HistoryImport {
1439 format: String,
1440 table: Option<String>,
1441 bytes: usize,
1442 summary: String,
1443}
1444
1445#[derive(Debug, Serialize, JsonSchema)]
1446pub(crate) struct DiffReport {
1447 change_id: String,
1448 kind: ChangeKind,
1449 precision: &'static str,
1450 before_state: String,
1451 current_state: String,
1452 state_changed: bool,
1453 tables: Vec<TableDiff>,
1454}
1455
1456#[derive(Debug, Serialize, JsonSchema)]
1457struct TableDiff {
1458 table: String,
1459 before_rows: Option<usize>,
1460 after_rows: Option<usize>,
1461 added_rows: usize,
1462 removed_rows: usize,
1463 schema_changed: bool,
1464 data_changed: bool,
1465}
1466
1467#[derive(Debug, Serialize, JsonSchema)]
1468pub(crate) struct UndoReport {
1469 change_id: String,
1470 undone_change_id: String,
1471 restored_state: String,
1472 generation: u64,
1473}
1474
1475#[derive(Debug, Clone, PartialEq)]
1476struct TableSnapshot {
1477 columns: Vec<String>,
1478 rows: Vec<Vec<Value>>,
1479}
1480
1481fn preview_plan(workspace: &Workspace, sql: &str) -> Result<PlanRecord, WorkspaceError> {
1482 preview_plan_with_output_limit(workspace, sql, None, None, None)
1483}
1484
1485fn preview_plan_with_output_limit(
1486 workspace: &Workspace,
1487 sql: &str,
1488 max_output_bytes: Option<usize>,
1489 max_work: Option<usize>,
1490 max_mutation_rows: Option<usize>,
1491) -> Result<PlanRecord, WorkspaceError> {
1492 if sql.len() > MAX_PREVIEW_BYTES {
1493 return Err(WorkspaceError::Invalid(format!(
1494 "SQL exceeds the {} MiB preview limit",
1495 MAX_PREVIEW_BYTES / (1024 * 1024)
1496 )));
1497 }
1498 let statements = parse(sql).map_err(|error| {
1499 WorkspaceError::Invalid(format!(
1500 "preview parse error at byte {}: {}",
1501 error.offset, error.message
1502 ))
1503 })?;
1504 if statements.is_empty() {
1505 return Err(WorkspaceError::Invalid(
1506 "preview requires at least one SQL statement".to_string(),
1507 ));
1508 }
1509 if statements.len() > MAX_PREVIEW_STATEMENTS {
1510 return Err(WorkspaceError::Invalid(format!(
1511 "preview accepts at most {MAX_PREVIEW_STATEMENTS} statements"
1512 )));
1513 }
1514 let mutating_statements = statements
1515 .iter()
1516 .filter(|statement| is_mutation_statement(statement))
1517 .count();
1518 if mutating_statements > MAX_PREVIEW_MUTATIONS {
1519 return Err(WorkspaceError::Invalid(format!(
1520 "preview accepts at most {MAX_PREVIEW_MUTATIONS} mutating statements"
1521 )));
1522 }
1523 if statements.iter().any(statement_contains_control) {
1524 return Err(WorkspaceError::Invalid(
1525 "preview SQL must not contain BEGIN, COMMIT, ROLLBACK, or CHECKPOINT".to_string(),
1526 ));
1527 }
1528 if !statements.iter().any(is_mutation_statement) {
1529 return Err(WorkspaceError::Invalid(
1530 "preview requires at least one mutating statement".to_string(),
1531 ));
1532 }
1533
1534 let database = workspace.database()?;
1535 let mut budget = max_work
1536 .map(ExecutionBudget::bounded)
1537 .unwrap_or_else(ExecutionBudget::unlimited);
1538 database.checkpoint_with_budget(&mut budget)?;
1539 let base_generation = database.generation();
1540 let base_state = state_fingerprint(&workspace.database_path())?;
1541 let mut connection = database.connect();
1542 connection.execute_with_budget(&Statement::Begin, &mut budget)?;
1543 let result = (|| {
1544 let mut items = Vec::with_capacity(statements.len());
1545 let mut mutation_rows = 0;
1546 for (index, statement) in statements.iter().enumerate() {
1547 let result = connection.execute_with_budget(statement, &mut budget)?;
1548 enforce_mutation_row_limit(&mut mutation_rows, &result, max_mutation_rows)?;
1549 if let StatementResult::Select { rows, .. } = &result
1550 && rows.len() > MAX_PREVIEW_ROWS
1551 {
1552 return Err(WorkspaceError::Invalid(format!(
1553 "preview query result exceeds the {MAX_PREVIEW_ROWS}-row limit"
1554 )));
1555 }
1556 items.push(preview_item(index + 1, &result));
1557 }
1558 Ok::<Vec<PreviewItem>, WorkspaceError>(items)
1559 })();
1560 let rollback = connection.execute_sql("ROLLBACK");
1561 let preview_items = match result {
1562 Ok(items) => {
1563 rollback?;
1564 items
1565 }
1566 Err(error) => {
1567 let _ = rollback;
1568 return Err(error);
1569 }
1570 };
1571 let plan_id = plan_id_for(&base_state, sql);
1572 let plan = PlanRecord {
1573 format_version: FORMAT_VERSION,
1574 plan_id,
1575 base_generation,
1576 base_state,
1577 sql: sql.to_string(),
1578 statements: preview_items,
1579 };
1580 if let Some(max_output_bytes) = max_output_bytes {
1581 let report = PlanReport::from(&plan);
1582 let output_size = serde_json::to_vec(&report)?.len();
1583 if output_size > max_output_bytes {
1584 return Err(WorkspaceError::Invalid(format!(
1585 "workspace preview is {output_size} bytes; response limit is {max_output_bytes} bytes"
1586 )));
1587 }
1588 }
1589 persist_plan(workspace, &plan)?;
1592 Ok(plan)
1593}
1594
1595fn persist_plan(workspace: &Workspace, plan: &PlanRecord) -> Result<(), WorkspaceError> {
1596 ensure_history_dirs(workspace)?;
1597 let path = plan_path(workspace, &plan.plan_id);
1598 if path.exists() {
1599 let existing: PlanRecord = read_json(&path)?;
1600 if existing != *plan {
1601 return Err(WorkspaceError::Invalid(
1602 "plan identifier collision; refusing to replace an existing plan".to_string(),
1603 ));
1604 }
1605 } else {
1606 write_new_json(&path, plan)?;
1607 }
1608 Ok(())
1609}
1610
1611fn preview_item(statement: usize, result: &StatementResult) -> PreviewItem {
1612 let (kind, mutating, rows_affected, rows_returned, object) = match result {
1613 StatementResult::Select { rows, .. } => ("select", false, None, Some(rows.len()), None),
1614 StatementResult::Insert { rows_affected } => {
1615 ("insert", true, Some(*rows_affected), None, None)
1616 }
1617 StatementResult::Update { rows_affected } => {
1618 ("update", true, Some(*rows_affected), None, None)
1619 }
1620 StatementResult::Delete { rows_affected } => {
1621 ("delete", true, Some(*rows_affected), None, None)
1622 }
1623 StatementResult::CreateTable { name } => {
1624 ("create_table", true, None, None, Some(name.clone()))
1625 }
1626 StatementResult::DropTable { name } => ("drop_table", true, None, None, Some(name.clone())),
1627 StatementResult::CreateIndex { name, .. } => {
1628 ("create_index", true, None, None, Some(name.clone()))
1629 }
1630 StatementResult::DropIndex { name } => ("drop_index", true, None, None, Some(name.clone())),
1631 StatementResult::Explain(_) => ("explain", false, None, None, None),
1632 StatementResult::Begin => ("begin", false, None, None, None),
1633 StatementResult::Commit => ("commit", false, None, None, None),
1634 StatementResult::Rollback => ("rollback", false, None, None, None),
1635 StatementResult::Checkpoint => ("checkpoint", false, None, None, None),
1636 StatementResult::Echo(_) => ("echo", false, None, None, None),
1637 };
1638 PreviewItem {
1639 statement,
1640 kind: kind.to_string(),
1641 mutating,
1642 rows_affected,
1643 rows_returned,
1644 object,
1645 }
1646}
1647
1648fn enforce_mutation_row_limit(
1649 total_rows: &mut usize,
1650 result: &StatementResult,
1651 max_rows: Option<usize>,
1652) -> Result<(), WorkspaceError> {
1653 let rows = match result {
1654 StatementResult::Insert { rows_affected }
1655 | StatementResult::Update { rows_affected }
1656 | StatementResult::Delete { rows_affected } => *rows_affected,
1657 _ => return Ok(()),
1658 };
1659 *total_rows = total_rows.saturating_add(rows);
1660 if let Some(max_rows) = max_rows
1661 && *total_rows > max_rows
1662 {
1663 return Err(WorkspaceError::Invalid(format!(
1664 "MCP workspace mutations are limited to {max_rows} affected rows per plan; split the operation into smaller reviewed plans"
1665 )));
1666 }
1667 Ok(())
1668}
1669
1670fn is_mutation_statement(statement: &Statement) -> bool {
1671 matches!(
1672 statement,
1673 Statement::CreateTable { .. }
1674 | Statement::DropTable { .. }
1675 | Statement::CreateIndex { .. }
1676 | Statement::DropIndex { .. }
1677 | Statement::Insert { .. }
1678 | Statement::InsertSelect { .. }
1679 | Statement::Update { .. }
1680 | Statement::Delete { .. }
1681 )
1682}
1683
1684fn ensure_history_dirs(workspace: &Workspace) -> Result<(), WorkspaceError> {
1685 let directories = history_directories(workspace);
1686 validate_history_dirs(workspace)?;
1687 for directory in directories {
1688 fs::create_dir_all(directory)?;
1689 }
1690 validate_history_dirs(workspace)?;
1691 Ok(())
1692}
1693
1694fn history_directories(workspace: &Workspace) -> [PathBuf; 4] {
1695 let history = workspace.root.join(HISTORY_DIR);
1696 [
1697 history.clone(),
1698 history.join(PLANS_DIR),
1699 history.join(CHANGES_DIR),
1700 history.join(SNAPSHOTS_DIR),
1701 ]
1702}
1703
1704fn validate_history_dirs(workspace: &Workspace) -> Result<(), WorkspaceError> {
1705 for directory in history_directories(workspace) {
1706 if path_is_symlink(&directory)? {
1707 return Err(WorkspaceError::Invalid(format!(
1708 "workspace history directory cannot be a symbolic link: {}",
1709 directory.display()
1710 )));
1711 }
1712 if directory.exists() && !directory.is_dir() {
1713 return Err(WorkspaceError::Invalid(format!(
1714 "workspace history path is not a directory: {}",
1715 directory.display()
1716 )));
1717 }
1718 }
1719 Ok(())
1720}
1721
1722fn valid_id(id: &str) -> Result<(), WorkspaceError> {
1723 if id.len() != 64 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1724 return Err(WorkspaceError::Invalid(
1725 "identifier must be a 64-character hexadecimal value".to_string(),
1726 ));
1727 }
1728 Ok(())
1729}
1730
1731fn plan_path(workspace: &Workspace, plan_id: &str) -> PathBuf {
1732 workspace
1733 .root
1734 .join(HISTORY_DIR)
1735 .join(PLANS_DIR)
1736 .join(format!("{plan_id}.json"))
1737}
1738
1739fn change_path(workspace: &Workspace, change_id: &str) -> PathBuf {
1740 workspace
1741 .root
1742 .join(HISTORY_DIR)
1743 .join(CHANGES_DIR)
1744 .join(format!("{change_id}.json"))
1745}
1746
1747fn snapshot_path(workspace: &Workspace, snapshot_id: &str) -> PathBuf {
1748 workspace
1749 .root
1750 .join(HISTORY_DIR)
1751 .join(SNAPSHOTS_DIR)
1752 .join(format!("{snapshot_id}.basalt"))
1753}
1754
1755fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, WorkspaceError> {
1756 if path_is_symlink(path)? {
1757 return Err(WorkspaceError::Invalid(format!(
1758 "workspace metadata file cannot be a symbolic link: {}",
1759 path.display()
1760 )));
1761 }
1762 let bytes = fs::read(path)?;
1763 Ok(serde_json::from_slice(&bytes)?)
1764}
1765
1766fn write_new_json<T: Serialize>(path: &Path, value: &T) -> Result<(), WorkspaceError> {
1767 let mut bytes = serde_json::to_vec_pretty(value)?;
1768 bytes.push(b'\n');
1769 write_new_file(path, &bytes)
1770}
1771
1772fn write_atomic_json<T: Serialize>(path: &Path, value: &T) -> Result<(), WorkspaceError> {
1773 let mut bytes = serde_json::to_vec_pretty(value)?;
1774 bytes.push(b'\n');
1775 atomic_write_file(path, &bytes)
1776}
1777
1778fn state_fingerprint(path: &Path) -> Result<String, WorkspaceError> {
1779 if path_is_symlink(path)? {
1780 return Err(WorkspaceError::Invalid(format!(
1781 "workspace state file cannot be a symbolic link: {}",
1782 path.display()
1783 )));
1784 }
1785 Ok(format!("sha256:{}", sha256_bytes(&fs::read(path)?)))
1786}
1787
1788fn sha256_bytes(bytes: &[u8]) -> String {
1789 let digest = Sha256::digest(bytes);
1790 digest.iter().map(|byte| format!("{byte:02x}")).collect()
1791}
1792
1793fn plan_id_for(base_state: &str, sql: &str) -> String {
1794 let mut hasher = Sha256::new();
1795 hasher.update(b"basalt-plan-v1\0");
1796 hasher.update(base_state.as_bytes());
1797 hasher.update(b"\0");
1798 hasher.update(sql.as_bytes());
1799 hasher
1800 .finalize()
1801 .iter()
1802 .map(|byte| format!("{byte:02x}"))
1803 .collect()
1804}
1805
1806fn import_change_id(base_state: &str, format: DataFormat, table: &str, content: &[u8]) -> String {
1807 let mut hasher = Sha256::new();
1808 hasher.update(b"basalt-import-v1\0");
1809 hasher.update(base_state.as_bytes());
1810 hasher.update(b"\0");
1811 hasher.update(format.name().as_bytes());
1812 hasher.update(b"\0");
1813 hasher.update(table.as_bytes());
1814 hasher.update(b"\0");
1815 hasher.update(content);
1816 hasher
1817 .finalize()
1818 .iter()
1819 .map(|byte| format!("{byte:02x}"))
1820 .collect()
1821}
1822
1823fn import_request_key(format: DataFormat, table: &str, content: &[u8]) -> String {
1824 let mut hasher = Sha256::new();
1825 hasher.update(b"basalt-import-request-v1\0");
1826 hasher.update(format.name().as_bytes());
1827 hasher.update(b"\0");
1828 hasher.update(table.as_bytes());
1829 hasher.update(b"\0");
1830 hasher.update(content);
1831 hasher
1832 .finalize()
1833 .iter()
1834 .map(|byte| format!("{byte:02x}"))
1835 .collect()
1836}
1837
1838fn apply_change_id(plan_id: &str, base_state: &str) -> String {
1839 let mut hasher = Sha256::new();
1840 hasher.update(b"basalt-apply-v1\0");
1841 hasher.update(plan_id.as_bytes());
1842 hasher.update(b"\0");
1843 hasher.update(base_state.as_bytes());
1844 hasher
1845 .finalize()
1846 .iter()
1847 .map(|byte| format!("{byte:02x}"))
1848 .collect()
1849}
1850
1851fn undo_change_id(change_id: &str, base_state: &str) -> String {
1852 let mut hasher = Sha256::new();
1853 hasher.update(b"basalt-undo-v1\0");
1854 hasher.update(change_id.as_bytes());
1855 hasher.update(b"\0");
1856 hasher.update(base_state.as_bytes());
1857 hasher
1858 .finalize()
1859 .iter()
1860 .map(|byte| format!("{byte:02x}"))
1861 .collect()
1862}
1863
1864fn next_sequence(changes: &[ChangeRecord]) -> Result<u64, WorkspaceError> {
1865 changes
1866 .iter()
1867 .map(|change| change.sequence)
1868 .max()
1869 .unwrap_or(0)
1870 .checked_add(1)
1871 .ok_or_else(|| WorkspaceError::Invalid("change sequence exhausted".to_string()))
1872}
1873
1874fn copy_atomic(source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
1875 if path_is_symlink(source)? || path_is_symlink(destination)? {
1876 return Err(WorkspaceError::Invalid(
1877 "workspace recovery files cannot be symbolic links".to_string(),
1878 ));
1879 }
1880 let bytes = fs::read(source)?;
1881 atomic_write_file(destination, &bytes)
1882}
1883
1884fn truncate_wal(database_path: &Path) -> Result<(), WorkspaceError> {
1885 let mut path = database_path.as_os_str().to_os_string();
1886 path.push(".wal");
1887 let file = File::create(PathBuf::from(path))?;
1888 file.sync_all()?;
1889 Ok(())
1890}
1891
1892fn load_plan(workspace: &Workspace, plan_id: &str) -> Result<PlanRecord, WorkspaceError> {
1893 valid_id(plan_id)?;
1894 validate_history_dirs(workspace)?;
1895 let path = plan_path(workspace, plan_id);
1896 if !path.is_file() {
1897 return Err(WorkspaceError::Invalid(format!(
1898 "plan does not exist: {plan_id}"
1899 )));
1900 }
1901 let plan: PlanRecord = read_json(&path)?;
1902 if plan.format_version != FORMAT_VERSION
1903 || plan.plan_id != plan_id
1904 || plan_id_for(&plan.base_state, &plan.sql) != plan.plan_id
1905 {
1906 return Err(WorkspaceError::Invalid(format!(
1907 "plan is invalid or has been modified: {plan_id}"
1908 )));
1909 }
1910 Ok(plan)
1911}
1912
1913fn load_changes(workspace: &Workspace) -> Result<Vec<ChangeRecord>, WorkspaceError> {
1914 validate_history_dirs(workspace)?;
1915 let directory = workspace.root.join(HISTORY_DIR).join(CHANGES_DIR);
1916 if !directory.exists() {
1917 return Ok(Vec::new());
1918 }
1919 let mut changes = Vec::new();
1920 for entry in fs::read_dir(&directory)? {
1921 let path = entry?.path();
1922 if path_is_symlink(&path)? {
1923 return Err(WorkspaceError::Invalid(format!(
1924 "workspace history record cannot be a symbolic link: {}",
1925 path.display()
1926 )));
1927 }
1928 if path.extension() != Some(OsStr::new("json")) {
1929 continue;
1930 }
1931 if !path.is_file() {
1932 return Err(WorkspaceError::Invalid(format!(
1933 "workspace history record is not a file: {}",
1934 path.display()
1935 )));
1936 }
1937 let change: ChangeRecord = read_json(&path)?;
1938 valid_id(&change.change_id)?;
1939 if path.file_stem() != Some(OsStr::new(&change.change_id)) {
1940 return Err(WorkspaceError::Invalid(format!(
1941 "change filename does not match its identifier: {}",
1942 path.display()
1943 )));
1944 }
1945 if change.format_version != FORMAT_VERSION {
1946 return Err(WorkspaceError::Invalid(format!(
1947 "unsupported change format in {}",
1948 path.display()
1949 )));
1950 }
1951 changes.push(change);
1952 }
1953 changes.sort_by_key(|change| change.sequence);
1954 Ok(changes)
1955}
1956
1957fn reconcile_change(
1958 change: &mut ChangeRecord,
1959 current_state: &str,
1960 current_generation: u64,
1961) -> bool {
1962 if change.status != ChangeStatus::Prepared {
1963 return false;
1964 }
1965 match change.kind {
1966 ChangeKind::Apply => {
1967 if current_generation == change.base_generation.saturating_add(1)
1968 && current_state != change.base_state
1969 {
1970 change.status = ChangeStatus::Recovered;
1971 change.committed_generation = Some(current_generation);
1972 change.after_state = Some(current_state.to_string());
1973 change.error =
1974 Some("commit completed before the history record was finalized".to_string());
1975 } else if current_generation == change.base_generation
1976 && current_state == change.base_state
1977 {
1978 change.status = ChangeStatus::Failed;
1979 change.error =
1980 Some("operation was not observed as committed after interruption".to_string());
1981 } else {
1982 change.status = ChangeStatus::Unresolved;
1983 change.error = Some(
1984 "workspace state does not match either side of the prepared operation"
1985 .to_string(),
1986 );
1987 }
1988 }
1989 ChangeKind::Undo => {
1990 if change.expected_state.as_deref() == Some(current_state) {
1991 change.status = ChangeStatus::Recovered;
1992 change.committed_generation = Some(current_generation);
1993 change.after_state = Some(current_state.to_string());
1994 change.error =
1995 Some("restore completed before the history record was finalized".to_string());
1996 } else if current_generation == change.base_generation
1997 && current_state == change.base_state
1998 {
1999 change.status = ChangeStatus::Failed;
2000 change.error =
2001 Some("restore was not observed as completed after interruption".to_string());
2002 } else {
2003 change.status = ChangeStatus::Unresolved;
2004 change.error = Some(
2005 "workspace state does not match either side of the prepared restore"
2006 .to_string(),
2007 );
2008 }
2009 }
2010 }
2011 true
2012}
2013
2014fn reconcile_changes(
2015 workspace: &Workspace,
2016 changes: &mut [ChangeRecord],
2017 current_state: &str,
2018 current_generation: u64,
2019) -> Result<(), WorkspaceError> {
2020 for change in changes {
2021 if reconcile_change(change, current_state, current_generation) {
2022 write_atomic_json(&change_path(workspace, &change.change_id), change)?;
2023 }
2024 }
2025 Ok(())
2026}
2027
2028fn history(workspace: &Workspace) -> Result<Vec<HistoryEntry>, WorkspaceError> {
2029 let database = workspace.database()?;
2030 let current_state = state_fingerprint(&workspace.database_path())?;
2031 let current_generation = database.generation();
2032 let mut changes = load_changes(workspace)?;
2033 reconcile_changes(workspace, &mut changes, ¤t_state, current_generation)?;
2034 Ok(changes
2035 .into_iter()
2036 .map(|change| {
2037 let import = change.import.map(|import| HistoryImport {
2038 format: import.format,
2039 table: import.table,
2040 bytes: import.bytes,
2041 summary: import.summary,
2042 });
2043 HistoryEntry {
2044 sequence: change.sequence,
2045 change_id: change.change_id,
2046 kind: change.kind,
2047 status: change.status,
2048 plan_id: change.plan_id,
2049 target_change_id: change.target_change_id,
2050 base_state: change.base_state,
2051 after_state: change.after_state,
2052 committed_generation: change.committed_generation,
2053 error: change.error,
2054 import,
2055 }
2056 })
2057 .collect())
2058}
2059
2060fn latest_committed(changes: &[ChangeRecord]) -> Option<&ChangeRecord> {
2061 changes
2062 .iter()
2063 .filter(|change| change.status.is_committed())
2064 .max_by_key(|change| change.sequence)
2065}
2066
2067fn apply_plan(
2068 workspace: &Workspace,
2069 requested_plan_id: &str,
2070 max_work: Option<usize>,
2071 max_mutation_rows: Option<usize>,
2072) -> Result<ApplyReport, WorkspaceError> {
2073 let plan = load_plan(workspace, requested_plan_id)?;
2074 if !plan.statements.iter().any(|item| item.mutating) {
2075 return Err(WorkspaceError::Invalid(
2076 "plan does not contain a mutating statement".to_string(),
2077 ));
2078 }
2079 ensure_history_dirs(workspace)?;
2080 let database = workspace.database()?;
2081 let mut budget = max_work
2082 .map(ExecutionBudget::bounded)
2083 .unwrap_or_else(ExecutionBudget::unlimited);
2084 database.checkpoint_with_budget(&mut budget)?;
2085 let current_state = state_fingerprint(&workspace.database_path())?;
2086 let change_id = apply_change_id(&plan.plan_id, &plan.base_state);
2087 let change_file = change_path(workspace, &change_id);
2088 let mut changes = load_changes(workspace)?;
2089 if let Some(existing) = changes
2090 .iter_mut()
2091 .find(|change| change.change_id == change_id)
2092 {
2093 if reconcile_change(existing, ¤t_state, database.generation()) {
2094 write_atomic_json(&change_file, existing)?;
2095 }
2096 if existing.status.is_committed() {
2097 if existing.after_state.as_deref() == Some(current_state.as_str()) {
2098 return Ok(ApplyReport {
2099 change_id,
2100 plan_id: plan.plan_id,
2101 base_state: existing.base_state.clone(),
2102 after_state: existing.after_state.clone().ok_or_else(|| {
2103 WorkspaceError::Invalid(
2104 "committed plan is missing its after-state".to_string(),
2105 )
2106 })?,
2107 generation: existing.committed_generation.ok_or_else(|| {
2108 WorkspaceError::Invalid(
2109 "committed plan is missing its generation".to_string(),
2110 )
2111 })?,
2112 });
2113 }
2114 return Err(WorkspaceError::Invalid(format!(
2115 "plan has already been applied as change {change_id}; workspace state moved, so it will not be replayed"
2116 )));
2117 }
2118 if existing.status == ChangeStatus::Unresolved {
2119 return Err(WorkspaceError::Invalid(format!(
2120 "change {change_id} is unresolved; inspect its recovery point before continuing"
2121 )));
2122 }
2123 }
2124 if current_state != plan.base_state {
2125 return Err(WorkspaceError::Invalid(
2126 "plan is stale; preview the operation again against the current workspace".to_string(),
2127 ));
2128 }
2129 let snapshot = snapshot_path(workspace, &change_id);
2130 if snapshot.exists() {
2131 if state_fingerprint(&snapshot)? != plan.base_state {
2132 return Err(WorkspaceError::Invalid(format!(
2133 "recovery point for change {change_id} does not match the plan"
2134 )));
2135 }
2136 } else {
2137 copy_atomic(&workspace.database_path(), &snapshot)?;
2138 }
2139 let sequence = changes
2140 .iter()
2141 .find(|change| change.change_id == change_id)
2142 .map(|change| change.sequence)
2143 .unwrap_or(next_sequence(&changes)?);
2144 let mut change = ChangeRecord {
2145 format_version: FORMAT_VERSION,
2146 sequence,
2147 change_id: change_id.clone(),
2148 kind: ChangeKind::Apply,
2149 plan_id: Some(plan.plan_id.clone()),
2150 target_change_id: None,
2151 base_generation: plan.base_generation,
2152 base_state: plan.base_state.clone(),
2153 expected_state: None,
2154 snapshot_id: change_id.clone(),
2155 sql: Some(plan.sql.clone()),
2156 status: ChangeStatus::Prepared,
2157 committed_generation: None,
2158 after_state: None,
2159 error: None,
2160 import: None,
2161 };
2162 if change_file.exists() {
2163 write_atomic_json(&change_file, &change)?;
2164 } else {
2165 write_new_json(&change_file, &change)?;
2166 }
2167
2168 let mut connection = database.connect();
2169 if let Err(error) = connection.execute_with_budget(&Statement::Begin, &mut budget) {
2170 change.status = ChangeStatus::Failed;
2171 change.error = Some(error.to_string());
2172 write_atomic_json(&change_file, &change)?;
2173 return Err(error.into());
2174 }
2175 let execution = if max_work.is_some() {
2176 connection.execute_sql_using_budget(&plan.sql, &mut budget)
2177 } else {
2178 connection.execute_sql(&plan.sql)
2179 };
2180 let results = match execution {
2181 Ok(results) => results,
2182 Err(error) => {
2183 let _ = connection.execute_sql("ROLLBACK");
2184 change.status = ChangeStatus::Failed;
2185 change.error = Some(error.to_string());
2186 write_atomic_json(&change_file, &change)?;
2187 return Err(error.into());
2188 }
2189 };
2190 let mut mutation_rows = 0;
2191 for result in &results {
2192 if let Err(error) =
2193 enforce_mutation_row_limit(&mut mutation_rows, result, max_mutation_rows)
2194 {
2195 let _ = connection.execute_sql("ROLLBACK");
2196 change.status = ChangeStatus::Failed;
2197 change.error = Some(error.to_string());
2198 write_atomic_json(&change_file, &change)?;
2199 return Err(error);
2200 }
2201 }
2202 if let Err(error) = connection.execute_with_budget(&Statement::Commit, &mut budget) {
2203 change.status = ChangeStatus::Unresolved;
2204 change.error = Some(error.to_string());
2205 write_atomic_json(&change_file, &change)?;
2206 return Err(error.into());
2207 }
2208 drop(connection);
2209 if let Err(error) = database.checkpoint() {
2210 change.status = ChangeStatus::Unresolved;
2211 change.error = Some(format!(
2212 "operation committed but checkpoint failed: {error}"
2213 ));
2214 write_atomic_json(&change_file, &change)?;
2215 return Err(error.into());
2216 }
2217 let after_state = state_fingerprint(&workspace.database_path())?;
2218 let generation = database.generation();
2219 if std::env::var_os("BASALT_CRASH_TEST_AFTER_APPLY_CHECKPOINT").is_some() {
2220 std::process::abort();
2221 }
2222 change.status = ChangeStatus::Committed;
2223 change.committed_generation = Some(generation);
2224 change.after_state = Some(after_state.clone());
2225 write_atomic_json(&change_file, &change)?;
2226 Ok(ApplyReport {
2227 change_id,
2228 plan_id: plan.plan_id,
2229 base_state: plan.base_state,
2230 after_state,
2231 generation,
2232 })
2233}
2234
2235fn diff(
2236 workspace: &Workspace,
2237 requested_change_id: Option<&str>,
2238) -> Result<DiffReport, WorkspaceError> {
2239 diff_with_row_limit(workspace, requested_change_id, None)
2240}
2241
2242fn diff_with_row_limit(
2243 workspace: &Workspace,
2244 requested_change_id: Option<&str>,
2245 max_total_rows: Option<usize>,
2246) -> Result<DiffReport, WorkspaceError> {
2247 let mut changes = load_changes(workspace)?;
2248 let database = workspace.database()?;
2249 let current_state = state_fingerprint(&workspace.database_path())?;
2250 let current_generation = database.generation();
2251 reconcile_changes(workspace, &mut changes, ¤t_state, current_generation)?;
2252 let change = match requested_change_id {
2253 Some(change_id) => {
2254 valid_id(change_id)?;
2255 changes
2256 .iter()
2257 .find(|change| change.change_id == change_id)
2258 .cloned()
2259 .ok_or_else(|| {
2260 WorkspaceError::Invalid(format!("change does not exist: {change_id}"))
2261 })?
2262 }
2263 None => latest_committed(&changes)
2264 .cloned()
2265 .ok_or_else(|| WorkspaceError::Invalid("no committed changes to diff".to_string()))?,
2266 };
2267 if !change.status.is_committed() {
2268 return Err(WorkspaceError::Invalid(format!(
2269 "change {} is not committed; status is {:?}",
2270 change.change_id, change.status
2271 )));
2272 }
2273 let snapshot = snapshot_path(workspace, &change.snapshot_id);
2274 if !snapshot.is_file() {
2275 return Err(WorkspaceError::Invalid(format!(
2276 "recovery point is missing for change {}",
2277 change.change_id
2278 )));
2279 }
2280 let before_database = Database::open(&snapshot)?;
2281 let before = logical_snapshot(&before_database, max_total_rows)?;
2282 let after = logical_snapshot(&database, max_total_rows)?;
2283 let mut names = BTreeSet::new();
2284 names.extend(before.keys().cloned());
2285 names.extend(after.keys().cloned());
2286 let tables = names
2287 .into_iter()
2288 .filter_map(|name| {
2289 let before_table = before.get(&name);
2290 let after_table = after.get(&name);
2291 let schema_changed = match (before_table, after_table) {
2292 (Some(before), Some(after)) => before.columns != after.columns,
2293 (None, None) => false,
2294 _ => true,
2295 };
2296 let (added_rows, removed_rows) = match (before_table, after_table) {
2297 (Some(before), Some(after)) => row_delta_counts(&before.rows, &after.rows),
2298 (None, Some(after)) => (after.rows.len(), 0),
2299 (Some(before), None) => (0, before.rows.len()),
2300 (None, None) => (0, 0),
2301 };
2302 let data_changed = added_rows > 0 || removed_rows > 0;
2303 (schema_changed || data_changed).then(|| TableDiff {
2304 table: name,
2305 before_rows: before_table.map(|table| table.rows.len()),
2306 after_rows: after_table.map(|table| table.rows.len()),
2307 added_rows,
2308 removed_rows,
2309 schema_changed,
2310 data_changed,
2311 })
2312 })
2313 .collect::<Vec<_>>();
2314 Ok(DiffReport {
2315 change_id: change.change_id,
2316 kind: change.kind,
2317 precision: "table schema and row-multiset comparison",
2318 before_state: change.base_state,
2319 current_state,
2320 state_changed: !tables.is_empty(),
2321 tables,
2322 })
2323}
2324
2325fn row_delta_counts(before: &[Vec<Value>], after: &[Vec<Value>]) -> (usize, usize) {
2326 let mut before_counts = HashMap::<Vec<u8>, usize>::new();
2327 for row in before {
2328 let key = row_key(row);
2329 *before_counts.entry(key).or_default() += 1;
2330 }
2331 let mut after_counts = HashMap::<Vec<u8>, usize>::new();
2332 for row in after {
2333 let key = row_key(row);
2334 *after_counts.entry(key).or_default() += 1;
2335 }
2336
2337 let removed = before_counts.iter().fold(0usize, |total, (key, count)| {
2338 total.saturating_add(count.saturating_sub(after_counts.get(key).copied().unwrap_or(0)))
2339 });
2340 let added = after_counts.iter().fold(0usize, |total, (key, count)| {
2341 total.saturating_add(count.saturating_sub(before_counts.get(key).copied().unwrap_or(0)))
2342 });
2343 (added, removed)
2344}
2345
2346fn row_key(row: &[Value]) -> Vec<u8> {
2347 let mut key = Vec::new();
2348 key.extend_from_slice(b"basalt-row-v1\0");
2349 for value in row {
2350 match value {
2351 Value::Null => key.push(0),
2352 Value::Integer(value) => {
2353 key.push(1);
2354 key.extend_from_slice(&value.to_le_bytes());
2355 }
2356 Value::Real(value) => {
2357 key.push(2);
2358 key.extend_from_slice(&value.to_bits().to_le_bytes());
2359 }
2360 Value::Text(value) => {
2361 key.push(3);
2362 key.extend_from_slice(&(value.len() as u64).to_le_bytes());
2363 key.extend_from_slice(value.as_bytes());
2364 }
2365 Value::Boolean(value) => {
2366 key.push(4);
2367 key.push(*value as u8);
2368 }
2369 }
2370 }
2371 key
2372}
2373
2374fn logical_snapshot(
2375 database: &Database,
2376 max_total_rows: Option<usize>,
2377) -> Result<BTreeMap<String, TableSnapshot>, WorkspaceError> {
2378 let mut tables = BTreeMap::new();
2379 let mut total_rows = 0usize;
2380 for table in database.table_names()? {
2381 let row_count = database.row_count(&table)?;
2382 if let Some(max_total_rows) = max_total_rows {
2383 total_rows = total_rows.saturating_add(row_count);
2384 if total_rows > max_total_rows {
2385 return Err(WorkspaceError::Invalid(format!(
2386 "MCP diff is limited to {max_total_rows} rows across a compared database; use the CLI diff for larger workspaces"
2387 )));
2388 }
2389 }
2390 let (columns, rows) = select_table(database, &table)?;
2391 debug_assert_eq!(rows.len(), row_count);
2392 tables.insert(table, TableSnapshot { columns, rows });
2393 }
2394 Ok(tables)
2395}
2396
2397fn undo(workspace: &Workspace, requested_change_id: &str) -> Result<UndoReport, WorkspaceError> {
2398 valid_id(requested_change_id)?;
2399 ensure_history_dirs(workspace)?;
2400 let mut changes = load_changes(workspace)?;
2401 let database = workspace.database()?;
2402 database.checkpoint()?;
2403 let current_state = state_fingerprint(&workspace.database_path())?;
2404 let current_generation = database.generation();
2405 reconcile_changes(workspace, &mut changes, ¤t_state, current_generation)?;
2406 if let Some(existing) = changes.iter().find(|change| {
2407 change.kind == ChangeKind::Undo
2408 && change.target_change_id.as_deref() == Some(requested_change_id)
2409 && change.status.is_committed()
2410 }) {
2411 if existing.after_state.as_deref() == Some(current_state.as_str()) {
2412 return Ok(UndoReport {
2413 change_id: existing.change_id.clone(),
2414 undone_change_id: requested_change_id.to_string(),
2415 restored_state: existing.after_state.clone().ok_or_else(|| {
2416 WorkspaceError::Invalid(
2417 "committed undo is missing its restored state".to_string(),
2418 )
2419 })?,
2420 generation: existing.committed_generation.ok_or_else(|| {
2421 WorkspaceError::Invalid("committed undo is missing its generation".to_string())
2422 })?,
2423 });
2424 }
2425 return Err(WorkspaceError::Invalid(format!(
2426 "change {requested_change_id} has already been undone as {}; workspace state moved, so it will not be replayed",
2427 existing.change_id
2428 )));
2429 }
2430 let target = changes
2431 .iter()
2432 .find(|change| change.change_id == requested_change_id)
2433 .cloned()
2434 .ok_or_else(|| {
2435 WorkspaceError::Invalid(format!("change does not exist: {requested_change_id}"))
2436 })?;
2437 if !target.status.is_committed() {
2438 return Err(WorkspaceError::Invalid(format!(
2439 "change {} is not committed; status is {:?}",
2440 target.change_id, target.status
2441 )));
2442 }
2443 let latest = latest_committed(&changes).ok_or_else(|| {
2444 WorkspaceError::Invalid("there are no committed changes to undo".to_string())
2445 })?;
2446 if latest.change_id != target.change_id {
2447 return Err(WorkspaceError::Invalid(
2448 "only the latest committed change can be undone; undo later changes first".to_string(),
2449 ));
2450 }
2451 if target.after_state.as_deref() != Some(current_state.as_str()) {
2452 return Err(WorkspaceError::Invalid(
2453 "workspace state moved after this change; refusing to discard later work".to_string(),
2454 ));
2455 }
2456 let target_snapshot = snapshot_path(workspace, &target.snapshot_id);
2457 if !target_snapshot.is_file() {
2458 return Err(WorkspaceError::Invalid(format!(
2459 "recovery point is missing for change {}",
2460 target.change_id
2461 )));
2462 }
2463 if state_fingerprint(&target_snapshot)? != target.base_state {
2464 return Err(WorkspaceError::Invalid(format!(
2465 "recovery point for change {} failed integrity verification",
2466 target.change_id
2467 )));
2468 }
2469 let undo_id = undo_change_id(&target.change_id, ¤t_state);
2470 let undo_file = change_path(workspace, &undo_id);
2471 if let Some(existing) = changes.iter().find(|change| change.change_id == undo_id) {
2472 if existing.status.is_committed() {
2473 return Err(WorkspaceError::Invalid(format!(
2474 "change has already been undone as {undo_id}"
2475 )));
2476 }
2477 if existing.status == ChangeStatus::Unresolved {
2478 return Err(WorkspaceError::Invalid(format!(
2479 "undo change {undo_id} is unresolved; inspect its recovery point first"
2480 )));
2481 }
2482 }
2483 let undo_snapshot = snapshot_path(workspace, &undo_id);
2484 if undo_snapshot.exists() {
2485 if state_fingerprint(&undo_snapshot)? != current_state {
2486 return Err(WorkspaceError::Invalid(format!(
2487 "recovery point for undo {undo_id} does not match the current state"
2488 )));
2489 }
2490 } else {
2491 copy_atomic(&workspace.database_path(), &undo_snapshot)?;
2492 }
2493 let sequence = changes
2494 .iter()
2495 .find(|change| change.change_id == undo_id)
2496 .map(|change| change.sequence)
2497 .unwrap_or(next_sequence(&changes)?);
2498 let mut undo_record = ChangeRecord {
2499 format_version: FORMAT_VERSION,
2500 sequence,
2501 change_id: undo_id.clone(),
2502 kind: ChangeKind::Undo,
2503 plan_id: None,
2504 target_change_id: Some(target.change_id.clone()),
2505 base_generation: current_generation,
2506 base_state: current_state.clone(),
2507 expected_state: Some(target.base_state.clone()),
2508 snapshot_id: undo_id.clone(),
2509 sql: None,
2510 status: ChangeStatus::Prepared,
2511 committed_generation: None,
2512 after_state: None,
2513 error: None,
2514 import: None,
2515 };
2516 if undo_file.exists() {
2517 write_atomic_json(&undo_file, &undo_record)?;
2518 } else {
2519 write_new_json(&undo_file, &undo_record)?;
2520 }
2521 let database_path = workspace.database_path();
2522 drop(database);
2523 if let Err(error) = truncate_wal(&database_path) {
2524 undo_record.status = ChangeStatus::Unresolved;
2525 undo_record.error = Some(error.to_string());
2526 write_atomic_json(&undo_file, &undo_record)?;
2527 return Err(error);
2528 }
2529 if let Err(error) = copy_atomic(&target_snapshot, &database_path) {
2530 undo_record.status = ChangeStatus::Unresolved;
2531 undo_record.error = Some(error.to_string());
2532 write_atomic_json(&undo_file, &undo_record)?;
2533 return Err(error);
2534 }
2535 let restored = match Database::open_in_workspace(&database_path) {
2536 Ok(database) => database,
2537 Err(error) => {
2538 undo_record.status = ChangeStatus::Unresolved;
2539 undo_record.error = Some(error.to_string());
2540 write_atomic_json(&undo_file, &undo_record)?;
2541 return Err(error.into());
2542 }
2543 };
2544 if let Err(error) = restored.checkpoint() {
2545 undo_record.status = ChangeStatus::Unresolved;
2546 undo_record.error = Some(error.to_string());
2547 drop(restored);
2548 write_atomic_json(&undo_file, &undo_record)?;
2549 return Err(error.into());
2550 }
2551 let restored_state = state_fingerprint(&database_path)?;
2552 let restored_generation = restored.generation();
2553 if std::env::var_os("BASALT_CRASH_TEST_AFTER_UNDO_RESTORE").is_some() {
2554 std::process::abort();
2555 }
2556 drop(restored);
2557 undo_record.status = ChangeStatus::Committed;
2558 undo_record.committed_generation = Some(restored_generation);
2559 undo_record.after_state = Some(restored_state.clone());
2560 write_atomic_json(&undo_file, &undo_record)?;
2561 Ok(UndoReport {
2562 change_id: undo_id,
2563 undone_change_id: target.change_id,
2564 restored_state,
2565 generation: restored_generation,
2566 })
2567}
2568
2569pub(crate) fn mcp_preview(
2570 workspace: &Workspace,
2571 sql: &str,
2572 max_output_bytes: usize,
2573) -> Result<PlanReport, WorkspaceError> {
2574 let plan = preview_plan_with_output_limit(
2575 workspace,
2576 sql,
2577 Some(max_output_bytes),
2578 Some(MCP_EXECUTION_WORK_LIMIT),
2579 Some(MAX_MCP_MUTATION_ROWS),
2580 )?;
2581 Ok(PlanReport::from(&plan))
2582}
2583
2584pub(crate) fn mcp_plan(
2585 workspace: &Workspace,
2586 plan_id: &str,
2587 max_output_bytes: usize,
2588) -> Result<PlanReport, WorkspaceError> {
2589 let plan = load_plan(workspace, plan_id)?;
2590 let report = PlanReport::from(&plan);
2591 let output_size = serde_json::to_vec(&report)?.len();
2592 if output_size > max_output_bytes {
2593 return Err(WorkspaceError::Invalid(format!(
2594 "workspace plan is {output_size} bytes; response limit is {max_output_bytes} bytes"
2595 )));
2596 }
2597 Ok(report)
2598}
2599
2600pub(crate) fn mcp_apply(
2601 workspace: &Workspace,
2602 plan_id: &str,
2603) -> Result<ApplyReport, WorkspaceError> {
2604 apply_plan(
2605 workspace,
2606 plan_id,
2607 Some(MCP_EXECUTION_WORK_LIMIT),
2608 Some(MAX_MCP_MUTATION_ROWS),
2609 )
2610}
2611
2612pub(crate) fn mcp_history(workspace: &Workspace) -> Result<Vec<HistoryEntry>, WorkspaceError> {
2613 history(workspace)
2614}
2615
2616pub(crate) fn mcp_diff(
2617 workspace: &Workspace,
2618 change_id: Option<&str>,
2619) -> Result<DiffReport, WorkspaceError> {
2620 diff_with_row_limit(workspace, change_id, Some(MAX_MCP_DIFF_ROWS))
2621}
2622
2623pub(crate) fn mcp_undo(
2624 workspace: &Workspace,
2625 change_id: &str,
2626) -> Result<UndoReport, WorkspaceError> {
2627 undo(workspace, change_id)
2628}
2629
2630fn import_with_recovery(
2631 workspace: &Workspace,
2632 requested_table: Option<&str>,
2633 format: DataFormat,
2634 bytes: &[u8],
2635 limits: ImportLimits,
2636) -> Result<ImportReport, WorkspaceError> {
2637 let table = match format {
2638 DataFormat::Sql => {
2639 if requested_table.is_some() {
2640 return Err(WorkspaceError::Usage(
2641 "--table is not valid for SQL imports".to_string(),
2642 ));
2643 }
2644 None
2645 }
2646 DataFormat::Csv | DataFormat::Json | DataFormat::JsonLines => {
2647 Some(required_table(requested_table)?.to_string())
2648 }
2649 };
2650 let table_name = table.as_deref();
2651 let validated_summary = match format {
2652 DataFormat::Csv | DataFormat::Json | DataFormat::JsonLines => validate_import(
2653 format,
2654 table_name.ok_or_else(|| {
2655 WorkspaceError::Invalid("row import is missing its table name".to_string())
2656 })?,
2657 bytes,
2658 limits,
2659 )?,
2660 DataFormat::Sql => {
2661 let (_, statements) = parse_sql_import(bytes)?;
2662 format!("{} statements from SQL", statements.len())
2663 }
2664 };
2665
2666 let database = workspace.database()?;
2667 let mut preflight_budget = limits
2668 .max_work
2669 .map(ExecutionBudget::bounded)
2670 .unwrap_or_else(ExecutionBudget::unlimited);
2671 database.checkpoint_with_budget(&mut preflight_budget)?;
2672 let base_state = state_fingerprint(&workspace.database_path())?;
2673 let base_generation = database.generation();
2674 ensure_history_dirs(workspace)?;
2675 let mut changes = load_changes(workspace)?;
2676 reconcile_changes(workspace, &mut changes, &base_state, base_generation)?;
2677
2678 let request_table = table_name.unwrap_or("");
2679 let request_key = import_request_key(format, request_table, bytes);
2680 if let Some(existing) = changes.iter().rev().find(|change| {
2681 change
2682 .import
2683 .as_ref()
2684 .is_some_and(|import| import.request_key == request_key)
2685 }) && existing.status.is_committed()
2686 {
2687 if existing.after_state.as_deref() != Some(base_state.as_str()) {
2688 return Err(WorkspaceError::Invalid(format!(
2689 "import has already been committed as {}; workspace state moved, so it will not be replayed",
2690 existing.change_id
2691 )));
2692 }
2693 let import = existing.import.as_ref().ok_or_else(|| {
2694 WorkspaceError::Invalid("committed import is missing its metadata".to_string())
2695 })?;
2696 let after_state = existing.after_state.clone().ok_or_else(|| {
2697 WorkspaceError::Invalid("committed import is missing its after-state".to_string())
2698 })?;
2699 let generation = existing.committed_generation.ok_or_else(|| {
2700 WorkspaceError::Invalid("committed import is missing its generation".to_string())
2701 })?;
2702 return Ok(ImportReport {
2703 change_id: existing.change_id.clone(),
2704 format: import.format.clone(),
2705 table: import.table.clone(),
2706 bytes: import.bytes,
2707 summary: import.summary.clone(),
2708 base_state: existing.base_state.clone(),
2709 after_state,
2710 generation,
2711 });
2712 }
2713
2714 let change_id = import_change_id(&base_state, format, request_table, bytes);
2715 let retry_sequence = if let Some(existing) =
2716 changes.iter().find(|change| change.change_id == change_id)
2717 {
2718 if existing.status.is_committed() {
2719 return Err(WorkspaceError::Invalid(format!(
2720 "import has already been committed as change {change_id}"
2721 )));
2722 }
2723 if existing.status == ChangeStatus::Failed
2724 && existing.base_state == base_state
2725 && existing.after_state.is_none()
2726 && existing
2727 .import
2728 .as_ref()
2729 .is_some_and(|import| import.request_key == request_key)
2730 {
2731 Some(existing.sequence)
2732 } else {
2733 return Err(WorkspaceError::Invalid(format!(
2734 "import already has a history record with status {:?}; inspect workspace history before retrying",
2735 existing.status
2736 )));
2737 }
2738 } else {
2739 None
2740 };
2741
2742 let snapshot = snapshot_path(workspace, &change_id);
2743 if snapshot.exists() {
2744 if state_fingerprint(&snapshot)? != base_state {
2745 return Err(WorkspaceError::Invalid(format!(
2746 "recovery point for import {change_id} does not match the workspace"
2747 )));
2748 }
2749 } else {
2750 copy_atomic(&workspace.database_path(), &snapshot)?;
2751 }
2752
2753 let change_file = change_path(workspace, &change_id);
2754 let mut change = ChangeRecord {
2755 format_version: FORMAT_VERSION,
2756 sequence: match retry_sequence {
2757 Some(sequence) => sequence,
2758 None => next_sequence(&changes)?,
2759 },
2760 change_id: change_id.clone(),
2761 kind: ChangeKind::Apply,
2762 plan_id: None,
2763 target_change_id: None,
2764 base_generation,
2765 base_state: base_state.clone(),
2766 expected_state: None,
2767 snapshot_id: change_id.clone(),
2768 sql: None,
2769 status: ChangeStatus::Prepared,
2770 committed_generation: None,
2771 after_state: None,
2772 error: None,
2773 import: Some(ImportMetadata {
2774 request_key,
2775 format: format.name().to_string(),
2776 table: table.clone(),
2777 bytes: bytes.len(),
2778 summary: validated_summary,
2779 }),
2780 };
2781 if change_file.exists() {
2782 write_atomic_json(&change_file, &change)?;
2783 } else {
2784 write_new_json(&change_file, &change)?;
2785 }
2786
2787 let imported = match format {
2788 DataFormat::Csv => import_csv(&database, table_name, bytes, limits),
2789 DataFormat::Json => import_json(&database, table_name, bytes, limits),
2790 DataFormat::JsonLines => import_json_lines(&database, table_name, bytes, limits),
2791 DataFormat::Sql => import_sql(&database, bytes),
2792 };
2793 let summary = match imported {
2794 Ok(summary) => summary.unwrap_or_else(|| "import completed".to_string()),
2795 Err(error) => {
2796 change.status = ChangeStatus::Failed;
2797 change.error = Some(error.to_string());
2798 write_atomic_json(&change_file, &change)?;
2799 return Err(error);
2800 }
2801 };
2802 if let Some(import) = change.import.as_mut() {
2803 import.summary = summary.clone();
2804 }
2805
2806 if let Err(error) = database.checkpoint() {
2807 change.status = ChangeStatus::Unresolved;
2808 change.error = Some(format!("import committed but checkpoint failed: {error}"));
2809 write_atomic_json(&change_file, &change)?;
2810 return Err(error.into());
2811 }
2812 let after_state = state_fingerprint(&workspace.database_path())?;
2813 let generation = database.generation();
2814 if std::env::var_os("BASALT_CRASH_TEST_AFTER_IMPORT_CHECKPOINT").is_some() {
2815 std::process::abort();
2816 }
2817 change.status = ChangeStatus::Committed;
2818 change.committed_generation = Some(generation);
2819 change.after_state = Some(after_state.clone());
2820 write_atomic_json(&change_file, &change)?;
2821
2822 Ok(ImportReport {
2823 change_id,
2824 format: format.name().to_string(),
2825 table,
2826 bytes: bytes.len(),
2827 summary,
2828 base_state,
2829 after_state,
2830 generation,
2831 })
2832}
2833
2834#[derive(Debug, Serialize, JsonSchema)]
2835pub(crate) struct ImportReport {
2836 change_id: String,
2837 format: String,
2838 table: Option<String>,
2839 bytes: usize,
2840 summary: String,
2841 base_state: String,
2842 after_state: String,
2843 generation: u64,
2844}
2845
2846pub(crate) fn mcp_import(
2847 workspace: &Workspace,
2848 table: Option<&str>,
2849 format: &str,
2850 content: &str,
2851) -> Result<ImportReport, WorkspaceError> {
2852 if content.len() > MAX_MCP_IMPORT_BYTES {
2853 return Err(WorkspaceError::Invalid(format!(
2854 "MCP import content exceeds the {} MiB limit",
2855 MAX_MCP_IMPORT_BYTES / (1024 * 1024)
2856 )));
2857 }
2858 let format = DataFormat::parse(format)?;
2859 if format == DataFormat::Sql {
2860 return Err(WorkspaceError::Usage(
2861 "workspace_import accepts csv, json, or jsonl; use the CLI for SQL dump imports"
2862 .to_string(),
2863 ));
2864 }
2865 let table = required_mcp_table(table)?;
2866 import_with_recovery(
2867 workspace,
2868 Some(table),
2869 format,
2870 content.as_bytes(),
2871 ImportLimits::mcp(),
2872 )
2873}
2874
2875fn required_mcp_table(table: Option<&str>) -> Result<&str, WorkspaceError> {
2876 let table = table.ok_or_else(|| {
2877 WorkspaceError::Usage("workspace_import requires an explicit table name".to_string())
2878 })?;
2879 validate_name(table, "table name")?;
2880 Ok(table)
2881}
2882
2883fn validate_import(
2884 format: DataFormat,
2885 table: &str,
2886 bytes: &[u8],
2887 limits: ImportLimits,
2888) -> Result<String, WorkspaceError> {
2889 let database = Database::in_memory();
2890 match format {
2891 DataFormat::Csv => import_csv(&database, Some(table), bytes, limits),
2892 DataFormat::Json => import_json(&database, Some(table), bytes, limits),
2893 DataFormat::JsonLines => import_json_lines(&database, Some(table), bytes, limits),
2894 DataFormat::Sql => unreachable!(),
2895 }
2896 .map(|summary| summary.unwrap_or_else(|| "import completed".to_string()))
2897}
2898
2899#[derive(Debug, Serialize, JsonSchema)]
2900pub(crate) struct ExportReport {
2901 table: String,
2902 format: String,
2903 content: String,
2904 bytes: usize,
2905}
2906
2907pub(crate) fn mcp_export(
2908 workspace: &Workspace,
2909 table: &str,
2910 format: &str,
2911 max_content_bytes: usize,
2912) -> Result<ExportReport, WorkspaceError> {
2913 let format = DataFormat::parse(format)?;
2914 if format == DataFormat::Json {
2915 return Err(WorkspaceError::Usage(
2916 "JSON export is JSON Lines; use jsonl".to_string(),
2917 ));
2918 }
2919 let database = workspace.database()?;
2920 let row_count = database.row_count(table)?;
2921 if row_count > MAX_MCP_EXPORT_ROWS {
2922 return Err(WorkspaceError::Invalid(format!(
2923 "MCP export is limited to {MAX_MCP_EXPORT_ROWS} rows; use the CLI export for larger tables"
2924 )));
2925 }
2926 let (columns, rows) = select_table(&database, table)?;
2927 debug_assert_eq!(rows.len(), row_count);
2928 let mut output = LimitedBuffer::new(max_content_bytes);
2929 let result = match format {
2930 DataFormat::Csv => write_csv(&columns, &rows, &mut output),
2931 DataFormat::JsonLines => write_json_lines(&columns, &rows, &mut output),
2932 DataFormat::Sql => write_sql(&database, table, &rows, &mut output),
2933 DataFormat::Json => unreachable!(),
2934 };
2935 if output.exceeded {
2936 return Err(WorkspaceError::Invalid(format!(
2937 "MCP export exceeds the {max_content_bytes}-byte content limit"
2938 )));
2939 }
2940 result?;
2941 let content = String::from_utf8(output.into_inner())
2942 .map_err(|error| WorkspaceError::Invalid(format!("export is not valid UTF-8: {error}")))?;
2943 let bytes = content.len();
2944 Ok(ExportReport {
2945 table: table.to_string(),
2946 format: format.name().to_string(),
2947 bytes,
2948 content,
2949 })
2950}
2951
2952pub(crate) fn mcp_inspect(workspace: &Workspace) -> Result<InspectReport, WorkspaceError> {
2953 let database = workspace.database()?;
2954 inspect(workspace, &database)
2955}
2956
2957fn required_table(table: Option<&str>) -> Result<&str, WorkspaceError> {
2958 let table = table.ok_or_else(|| {
2959 WorkspaceError::Usage("row imports require --table NAME or a source filename".to_string())
2960 })?;
2961 validate_name(table, "table name")?;
2962 Ok(table)
2963}
2964
2965fn validate_headers(headers: &StringRecord) -> Result<Vec<String>, WorkspaceError> {
2966 let mut seen = HashMap::new();
2967 let mut columns = Vec::with_capacity(headers.len());
2968 for header in headers {
2969 validate_name(header, "CSV header")?;
2970 let key = header.to_ascii_lowercase();
2971 if seen.insert(key, ()).is_some() {
2972 return Err(WorkspaceError::Invalid(format!(
2973 "duplicate CSV header: {header:?}"
2974 )));
2975 }
2976 columns.push(header.to_string());
2977 }
2978 if columns.is_empty() {
2979 return Err(WorkspaceError::Invalid(
2980 "CSV input must contain a header row".to_string(),
2981 ));
2982 }
2983 Ok(columns)
2984}
2985
2986fn validate_name(name: &str, label: &str) -> Result<(), WorkspaceError> {
2987 if name.trim().is_empty() {
2988 return Err(WorkspaceError::Invalid(format!("{label} cannot be empty")));
2989 }
2990 if name.contains('\0') {
2991 return Err(WorkspaceError::Invalid(format!(
2992 "{label} cannot contain NUL bytes"
2993 )));
2994 }
2995 Ok(())
2996}
2997
2998fn parse_csv_cell(value: &str) -> ImportedCell {
2999 if value.is_empty() {
3000 return ImportedCell::Empty;
3001 }
3002 if value.eq_ignore_ascii_case("true") {
3003 return ImportedCell::Boolean(true);
3004 }
3005 if value.eq_ignore_ascii_case("false") {
3006 return ImportedCell::Boolean(false);
3007 }
3008 if let Ok(integer) = value.parse::<i64>() {
3009 return ImportedCell::Integer(integer);
3010 }
3011 if let Ok(real) = value.parse::<f64>()
3012 && real.is_finite()
3013 {
3014 return ImportedCell::Real(real);
3015 }
3016 ImportedCell::Text(value.to_string())
3017}
3018
3019fn json_cell(value: &JsonValue) -> ImportedCell {
3020 match value {
3021 JsonValue::Null => ImportedCell::Null,
3022 JsonValue::Bool(value) => ImportedCell::Boolean(*value),
3023 JsonValue::Number(value) => value
3024 .as_i64()
3025 .map(ImportedCell::Integer)
3026 .or_else(|| {
3027 value
3028 .as_f64()
3029 .filter(|value| value.is_finite())
3030 .map(ImportedCell::Real)
3031 })
3032 .unwrap_or_else(|| ImportedCell::Text(value.to_string())),
3033 JsonValue::String(value) => ImportedCell::Text(value.clone()),
3034 JsonValue::Array(_) | JsonValue::Object(_) => ImportedCell::Text(value.to_string()),
3035 }
3036}
3037
3038fn infer_types(rows: &[Vec<ImportedCell>], width: usize) -> Vec<ColumnType> {
3039 (0..width)
3040 .map(|column| {
3041 let mut inferred = None;
3042 for row in rows {
3043 let kind = match row.get(column) {
3044 Some(ImportedCell::Null | ImportedCell::Empty) | None => None,
3045 Some(ImportedCell::Integer(_)) => Some(ColumnType::Integer),
3046 Some(ImportedCell::Real(_)) => Some(ColumnType::Real),
3047 Some(ImportedCell::Boolean(_)) => Some(ColumnType::Boolean),
3048 Some(ImportedCell::Text(_)) => Some(ColumnType::Text),
3049 };
3050 inferred = merge_inferred(inferred, kind);
3051 if inferred == Some(ColumnType::Text) {
3052 break;
3053 }
3054 }
3055 inferred.unwrap_or(ColumnType::Text)
3056 })
3057 .collect()
3058}
3059
3060fn merge_inferred(current: Option<ColumnType>, next: Option<ColumnType>) -> Option<ColumnType> {
3061 match (current, next) {
3062 (None, value) | (value, None) => value,
3063 (Some(ColumnType::Integer), Some(ColumnType::Integer)) => Some(ColumnType::Integer),
3064 (Some(ColumnType::Integer), Some(ColumnType::Real))
3065 | (Some(ColumnType::Real), Some(ColumnType::Integer))
3066 | (Some(ColumnType::Real), Some(ColumnType::Real)) => Some(ColumnType::Real),
3067 (Some(ColumnType::Boolean), Some(ColumnType::Boolean)) => Some(ColumnType::Boolean),
3068 _ => Some(ColumnType::Text),
3069 }
3070}
3071
3072impl ImportedRows {
3073 fn summary(&self) -> String {
3074 format!(
3075 "table {} ({} rows, {} columns)",
3076 self.table,
3077 self.rows.len(),
3078 self.columns.len()
3079 )
3080 }
3081}
3082
3083fn import_rows(
3084 database: &Database,
3085 imported: &ImportedRows,
3086 max_work: Option<usize>,
3087) -> Result<(), WorkspaceError> {
3088 validate_name(&imported.table, "table name")?;
3089 if imported.columns.is_empty() {
3090 return Err(WorkspaceError::Invalid(
3091 "cannot import a table with no columns".to_string(),
3092 ));
3093 }
3094 if imported
3095 .rows
3096 .iter()
3097 .any(|row| row.len() != imported.columns.len())
3098 {
3099 return Err(WorkspaceError::Invalid(
3100 "imported row width does not match the header".to_string(),
3101 ));
3102 }
3103 let definitions = imported
3104 .columns
3105 .iter()
3106 .zip(&imported.types)
3107 .map(|(name, ty)| format!("{} {}", quote_identifier(name), column_type_name(ty)))
3108 .collect::<Vec<_>>();
3109 let create = format!(
3110 "CREATE TABLE {} ({})",
3111 quote_identifier(&imported.table),
3112 definitions.join(", ")
3113 );
3114
3115 let mut budget = max_work
3116 .map(ExecutionBudget::bounded)
3117 .unwrap_or_else(ExecutionBudget::unlimited);
3118 let mut connection = database.connect();
3119 connection.execute_with_budget(&Statement::Begin, &mut budget)?;
3120 let result = (|| {
3121 connection.execute_sql_using_budget(&create, &mut budget)?;
3122 for chunk in imported.rows.chunks(IMPORT_BATCH_SIZE) {
3123 if chunk.is_empty() {
3124 continue;
3125 }
3126 let rows = chunk
3127 .iter()
3128 .map(|row| {
3129 row.iter()
3130 .zip(&imported.types)
3131 .map(|(cell, ty)| cell_to_value(cell, ty).map(|value| sql_literal(&value)))
3132 .collect::<Result<Vec<_>, WorkspaceError>>()
3133 .map(|values| format!("({})", values.join(", ")))
3134 })
3135 .collect::<Result<Vec<_>, WorkspaceError>>()?;
3136 let insert = format!(
3137 "INSERT INTO {} VALUES {}",
3138 quote_identifier(&imported.table),
3139 rows.join(", ")
3140 );
3141 connection.execute_sql_using_budget(&insert, &mut budget)?;
3142 }
3143 connection.execute_with_budget(&Statement::Commit, &mut budget)?;
3144 Ok::<(), WorkspaceError>(())
3145 })();
3146 if let Err(error) = result {
3147 let _ = connection.execute_sql("ROLLBACK");
3148 return Err(error);
3149 }
3150 Ok(())
3151}
3152
3153fn cell_to_value(cell: &ImportedCell, ty: &ColumnType) -> Result<Value, WorkspaceError> {
3154 let value = match (cell, ty) {
3155 (ImportedCell::Null, _) => Value::Null,
3156 (ImportedCell::Empty, ColumnType::Text) => Value::Text(String::new()),
3157 (ImportedCell::Empty, _) => Value::Null,
3158 (ImportedCell::Integer(value), ColumnType::Integer) => Value::Integer(*value),
3159 (ImportedCell::Integer(value), ColumnType::Real) => Value::Real(*value as f64),
3160 (ImportedCell::Integer(value), ColumnType::Boolean) => Value::Boolean(*value != 0),
3161 (ImportedCell::Integer(value), ColumnType::Text) => Value::Text(value.to_string()),
3162 (ImportedCell::Real(value), ColumnType::Real) => Value::Real(*value),
3163 (ImportedCell::Real(value), ColumnType::Text) => Value::Text(value.to_string()),
3164 (ImportedCell::Real(value), ColumnType::Integer) => {
3165 return Err(WorkspaceError::Invalid(format!(
3166 "cannot store REAL value {value} in inferred INTEGER column"
3167 )));
3168 }
3169 (ImportedCell::Real(value), ColumnType::Boolean) => {
3170 return Err(WorkspaceError::Invalid(format!(
3171 "cannot store REAL value {value} in inferred BOOLEAN column"
3172 )));
3173 }
3174 (ImportedCell::Boolean(value), ColumnType::Boolean) => Value::Boolean(*value),
3175 (ImportedCell::Boolean(value), ColumnType::Text) => Value::Text(value.to_string()),
3176 (ImportedCell::Boolean(value), ColumnType::Integer) => Value::Integer(*value as i64),
3177 (ImportedCell::Boolean(value), ColumnType::Real) => Value::Real(*value as u8 as f64),
3178 (ImportedCell::Text(value), ColumnType::Text) => Value::Text(value.clone()),
3179 (ImportedCell::Text(value), _) => {
3180 return Err(WorkspaceError::Invalid(format!(
3181 "text value {value:?} conflicts with inferred {:?} column",
3182 ty
3183 )));
3184 }
3185 (_, ColumnType::Any | ColumnType::Null) => Value::Null,
3186 };
3187 Ok(value)
3188}
3189
3190fn select_table(
3191 database: &Database,
3192 table: &str,
3193) -> Result<(Vec<String>, Vec<Vec<Value>>), WorkspaceError> {
3194 validate_name(table, "table name")?;
3195 let sql = format!("SELECT * FROM {}", quote_identifier(table));
3196 let results = database.execute_sql(&sql)?;
3197 let Some(StatementResult::Select { columns, rows }) = results.into_iter().next() else {
3198 return Err(WorkspaceError::Invalid(
3199 "table query did not return rows".to_string(),
3200 ));
3201 };
3202 Ok((columns, rows))
3203}
3204
3205fn export_csv(columns: &[String], rows: &[Vec<Value>]) -> Result<Vec<u8>, WorkspaceError> {
3206 let mut output = Vec::new();
3207 write_csv(columns, rows, &mut output)?;
3208 Ok(output)
3209}
3210
3211fn write_csv<W: Write>(
3212 columns: &[String],
3213 rows: &[Vec<Value>],
3214 output: &mut W,
3215) -> Result<(), WorkspaceError> {
3216 let mut writer = Writer::from_writer(output);
3217 writer.write_record(columns)?;
3218 for row in rows {
3219 ensure_row_width(columns, row)?;
3220 let values = row.iter().map(csv_field).collect::<Vec<_>>();
3221 writer.write_record(values)?;
3222 }
3223 writer.flush()?;
3224 Ok(())
3225}
3226
3227fn export_json_lines(columns: &[String], rows: &[Vec<Value>]) -> Result<Vec<u8>, WorkspaceError> {
3228 let mut output = Vec::new();
3229 write_json_lines(columns, rows, &mut output)?;
3230 Ok(output)
3231}
3232
3233fn write_json_lines<W: Write>(
3234 columns: &[String],
3235 rows: &[Vec<Value>],
3236 output: &mut W,
3237) -> Result<(), WorkspaceError> {
3238 for row in rows {
3239 ensure_row_width(columns, row)?;
3240 let mut object = Map::new();
3241 for (column, value) in columns.iter().zip(row) {
3242 object.insert(column.clone(), value_to_json(value)?);
3243 }
3244 serde_json::to_writer(&mut *output, &JsonValue::Object(object))?;
3245 output.write_all(b"\n")?;
3246 }
3247 Ok(())
3248}
3249
3250fn export_sql(
3251 database: &Database,
3252 table: &str,
3253 rows: &[Vec<Value>],
3254) -> Result<Vec<u8>, WorkspaceError> {
3255 let mut output = Vec::new();
3256 write_sql(database, table, rows, &mut output)?;
3257 Ok(output)
3258}
3259
3260fn write_sql<W: Write>(
3261 database: &Database,
3262 table: &str,
3263 rows: &[Vec<Value>],
3264 output: &mut W,
3265) -> Result<(), WorkspaceError> {
3266 let columns = database.columns(table)?;
3267 write!(output, "CREATE TABLE {} (", quote_identifier(table))?;
3268 for (index, column) in columns.iter().enumerate() {
3269 if index > 0 {
3270 output.write_all(b", ")?;
3271 }
3272 output.write_all(column_definition(column).as_bytes())?;
3273 }
3274 output.write_all(b");\n")?;
3275 for row in rows {
3276 ensure_row_width(
3277 &columns
3278 .iter()
3279 .map(|column| column.name.clone())
3280 .collect::<Vec<_>>(),
3281 row,
3282 )?;
3283 write!(output, "INSERT INTO {} VALUES (", quote_identifier(table))?;
3284 for (index, value) in row.iter().enumerate() {
3285 if index > 0 {
3286 output.write_all(b", ")?;
3287 }
3288 output.write_all(sql_literal(value).as_bytes())?;
3289 }
3290 output.write_all(b");\n")?;
3291 }
3292 Ok(())
3293}
3294
3295struct LimitedBuffer {
3296 bytes: Vec<u8>,
3297 limit: usize,
3298 exceeded: bool,
3299}
3300
3301impl LimitedBuffer {
3302 fn new(limit: usize) -> Self {
3303 Self {
3304 bytes: Vec::new(),
3305 limit,
3306 exceeded: false,
3307 }
3308 }
3309
3310 fn into_inner(self) -> Vec<u8> {
3311 self.bytes
3312 }
3313}
3314
3315impl Write for LimitedBuffer {
3316 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
3317 if bytes.len() > self.limit.saturating_sub(self.bytes.len()) {
3318 self.exceeded = true;
3319 return Err(io::Error::new(
3320 io::ErrorKind::WriteZero,
3321 "export content limit exceeded",
3322 ));
3323 }
3324 self.bytes.extend_from_slice(bytes);
3325 Ok(bytes.len())
3326 }
3327
3328 fn flush(&mut self) -> io::Result<()> {
3329 Ok(())
3330 }
3331}
3332
3333fn column_definition(column: &Column) -> String {
3334 let mut definition = format!(
3335 "{} {}",
3336 quote_identifier(&column.name),
3337 column_type_name(&column.ty)
3338 );
3339 if column.primary_key {
3340 definition.push_str(" PRIMARY KEY");
3341 } else if column.not_null {
3342 definition.push_str(" NOT NULL");
3343 }
3344 if column.unique && !column.primary_key {
3345 definition.push_str(" UNIQUE");
3346 }
3347 definition
3348}
3349
3350fn ensure_row_width(columns: &[String], row: &[Value]) -> Result<(), WorkspaceError> {
3351 if columns.len() != row.len() {
3352 return Err(WorkspaceError::Invalid(format!(
3353 "row width mismatch: expected {}, got {}",
3354 columns.len(),
3355 row.len()
3356 )));
3357 }
3358 Ok(())
3359}
3360
3361fn csv_field(value: &Value) -> String {
3362 match value {
3363 Value::Null => String::new(),
3364 Value::Integer(value) => value.to_string(),
3365 Value::Real(value) => value.to_string(),
3366 Value::Text(value) => value.clone(),
3367 Value::Boolean(value) => value.to_string(),
3368 }
3369}
3370
3371fn value_to_json(value: &Value) -> Result<JsonValue, WorkspaceError> {
3372 match value {
3373 Value::Null => Ok(JsonValue::Null),
3374 Value::Integer(value) => Ok(JsonValue::Number((*value).into())),
3375 Value::Real(value) => serde_json::Number::from_f64(*value)
3376 .map(JsonValue::Number)
3377 .ok_or_else(|| WorkspaceError::Invalid("cannot export non-finite REAL".to_string())),
3378 Value::Text(value) => Ok(JsonValue::String(value.clone())),
3379 Value::Boolean(value) => Ok(JsonValue::Bool(*value)),
3380 }
3381}
3382
3383fn quote_identifier(value: &str) -> String {
3384 format!("\"{}\"", value.replace('"', "\"\""))
3385}
3386
3387fn column_type_name(ty: &ColumnType) -> &'static str {
3388 match ty {
3389 ColumnType::Integer => "INTEGER",
3390 ColumnType::Real => "REAL",
3391 ColumnType::Text => "TEXT",
3392 ColumnType::Boolean => "BOOLEAN",
3393 ColumnType::Any => "TEXT",
3394 ColumnType::Null => "TEXT",
3395 }
3396}
3397
3398fn sql_literal(value: &Value) -> String {
3399 match value {
3400 Value::Null => "NULL".to_string(),
3401 Value::Integer(value) => value.to_string(),
3402 Value::Real(value) => value.to_string(),
3403 Value::Text(value) => format!("'{}'", value.replace('\'', "''")),
3404 Value::Boolean(value) => value.to_string(),
3405 }
3406}
3407
3408#[derive(Debug, Serialize, JsonSchema)]
3409pub(crate) struct InspectReport {
3410 path: String,
3411 format_version: u32,
3412 database: String,
3413 tables: Vec<InspectTable>,
3414}
3415
3416#[derive(Debug, Serialize, JsonSchema)]
3417struct InspectTable {
3418 name: String,
3419 rows: usize,
3420 columns: Vec<InspectColumn>,
3421}
3422
3423#[derive(Debug, Serialize, JsonSchema)]
3424struct InspectColumn {
3425 name: String,
3426 data_type: &'static str,
3427 not_null: bool,
3428 unique: bool,
3429 primary_key: bool,
3430}
3431
3432fn inspect(workspace: &Workspace, database: &Database) -> Result<InspectReport, WorkspaceError> {
3433 let mut tables = Vec::new();
3434 for table_name in database.table_names()? {
3435 let columns = database.columns(&table_name)?;
3436 let rows = table_row_count(database, &table_name)?;
3437 tables.push(InspectTable {
3438 name: table_name,
3439 rows,
3440 columns: columns
3441 .iter()
3442 .map(|column| InspectColumn {
3443 name: column.name.clone(),
3444 data_type: column_type_name(&column.ty),
3445 not_null: column.not_null,
3446 unique: column.unique,
3447 primary_key: column.primary_key,
3448 })
3449 .collect(),
3450 });
3451 }
3452 Ok(InspectReport {
3453 path: workspace.root.display().to_string(),
3454 format_version: workspace.manifest.format_version,
3455 database: workspace.manifest.database.clone(),
3456 tables,
3457 })
3458}
3459
3460fn table_row_count(database: &Database, table: &str) -> Result<usize, WorkspaceError> {
3461 Ok(database.row_count(table)?)
3462}
3463
3464fn render_inspect(report: &InspectReport, output: &mut dyn Write) -> Result<(), WorkspaceError> {
3465 writeln!(output, "Workspace: {}", report.path)?;
3466 writeln!(output, "Workspace format: {}", report.format_version)?;
3467 writeln!(output, "Database: {}", report.database)?;
3468 if report.tables.is_empty() {
3469 writeln!(output, "Tables: none")?;
3470 return Ok(());
3471 }
3472 writeln!(output, "Tables: {}", report.tables.len())?;
3473 for table in &report.tables {
3474 let columns = table
3475 .columns
3476 .iter()
3477 .map(|column| format!("{} {}", column.name, column.data_type))
3478 .collect::<Vec<_>>()
3479 .join(", ");
3480 writeln!(
3481 output,
3482 "- {} ({} rows): {}",
3483 table.name, table.rows, columns
3484 )?;
3485 }
3486 Ok(())
3487}
3488
3489fn render_plan(
3490 workspace: &Workspace,
3491 plan: &PlanRecord,
3492 output: &mut dyn Write,
3493) -> Result<(), WorkspaceError> {
3494 writeln!(output, "Plan: {}", plan.plan_id)?;
3495 writeln!(output, "Workspace: {}", workspace.root.display())?;
3496 writeln!(output, "Base state: {}", plan.base_state)?;
3497 writeln!(output, "Statements: {}", plan.statements.len())?;
3498 for item in &plan.statements {
3499 let detail = item
3500 .rows_affected
3501 .map(|rows| format!("{rows} row(s) affected"))
3502 .or_else(|| {
3503 item.rows_returned
3504 .map(|rows| format!("{rows} row(s) returned"))
3505 })
3506 .or_else(|| item.object.clone())
3507 .unwrap_or_default();
3508 if detail.is_empty() {
3509 writeln!(output, "- {}: {}", item.statement, item.kind)?;
3510 } else {
3511 writeln!(output, "- {}: {} ({detail})", item.statement, item.kind)?;
3512 }
3513 }
3514 writeln!(
3515 output,
3516 "Apply: basalt workspace apply {} {}",
3517 workspace.root.display(),
3518 plan.plan_id
3519 )?;
3520 Ok(())
3521}
3522
3523fn render_apply(report: &ApplyReport, output: &mut dyn Write) -> Result<(), WorkspaceError> {
3524 writeln!(output, "Applied change {}", report.change_id)?;
3525 writeln!(output, "Plan: {}", report.plan_id)?;
3526 writeln!(output, "State: {}", report.after_state)?;
3527 writeln!(output, "Generation: {}", report.generation)?;
3528 Ok(())
3529}
3530
3531fn render_history(entries: &[HistoryEntry], output: &mut dyn Write) -> Result<(), WorkspaceError> {
3532 if entries.is_empty() {
3533 writeln!(output, "No changes.")?;
3534 return Ok(());
3535 }
3536 for entry in entries {
3537 writeln!(
3538 output,
3539 "#{} {} {} {}",
3540 entry.sequence,
3541 entry.change_id,
3542 change_kind_name(&entry.kind),
3543 change_status_name(&entry.status)
3544 )?;
3545 if let Some(error) = &entry.error {
3546 writeln!(output, " {error}")?;
3547 }
3548 }
3549 Ok(())
3550}
3551
3552fn render_diff(report: &DiffReport, output: &mut dyn Write) -> Result<(), WorkspaceError> {
3553 writeln!(output, "Diff for change {}", report.change_id)?;
3554 writeln!(output, "Precision: {}", report.precision)?;
3555 writeln!(output, "Before: {}", report.before_state)?;
3556 writeln!(output, "Current: {}", report.current_state)?;
3557 if report.tables.is_empty() {
3558 writeln!(output, "No logical table changes.")?;
3559 return Ok(());
3560 }
3561 for table in &report.tables {
3562 let before = table
3563 .before_rows
3564 .map(|rows| rows.to_string())
3565 .unwrap_or_else(|| "absent".to_string());
3566 let after = table
3567 .after_rows
3568 .map(|rows| rows.to_string())
3569 .unwrap_or_else(|| "absent".to_string());
3570 writeln!(
3571 output,
3572 "- {}: {} -> {} row(s), schema_changed={}, data_changed={}",
3573 table.table, before, after, table.schema_changed, table.data_changed
3574 )?;
3575 writeln!(
3576 output,
3577 " rows added: {}, rows removed: {}",
3578 table.added_rows, table.removed_rows
3579 )?;
3580 }
3581 Ok(())
3582}
3583
3584fn render_undo(report: &UndoReport, output: &mut dyn Write) -> Result<(), WorkspaceError> {
3585 writeln!(
3586 output,
3587 "Undid change {} as {}",
3588 report.undone_change_id, report.change_id
3589 )?;
3590 writeln!(output, "Restored state: {}", report.restored_state)?;
3591 writeln!(output, "Generation: {}", report.generation)?;
3592 Ok(())
3593}
3594
3595fn change_kind_name(kind: &ChangeKind) -> &'static str {
3596 match kind {
3597 ChangeKind::Apply => "apply",
3598 ChangeKind::Undo => "undo",
3599 }
3600}
3601
3602fn change_status_name(status: &ChangeStatus) -> &'static str {
3603 match status {
3604 ChangeStatus::Prepared => "prepared",
3605 ChangeStatus::Committed => "committed",
3606 ChangeStatus::Recovered => "recovered",
3607 ChangeStatus::Failed => "failed",
3608 ChangeStatus::Unresolved => "unresolved",
3609 }
3610}
3611
3612fn write_output(
3613 workspace: &Workspace,
3614 destination: &Path,
3615 bytes: &[u8],
3616 stdout: &mut dyn Write,
3617) -> Result<(), WorkspaceError> {
3618 if destination.as_os_str() == OsStr::new("-") {
3619 stdout.write_all(bytes)?;
3620 return Ok(());
3621 }
3622 if same_path(destination, &workspace.database_path())
3623 || same_path(destination, &workspace.root.join(MANIFEST_FILE))
3624 {
3625 return Err(WorkspaceError::Invalid(
3626 "refusing to overwrite workspace metadata or database".to_string(),
3627 ));
3628 }
3629 if let Some(parent) = destination
3630 .parent()
3631 .filter(|parent| !parent.as_os_str().is_empty())
3632 {
3633 fs::create_dir_all(parent)?;
3634 }
3635 atomic_write_file(destination, bytes)
3636}
3637
3638fn atomic_write_file(path: &Path, bytes: &[u8]) -> Result<(), WorkspaceError> {
3639 if path_is_symlink(path)? {
3640 return Err(WorkspaceError::Invalid(format!(
3641 "refusing to write through a symbolic link: {}",
3642 path.display()
3643 )));
3644 }
3645 if let Some(parent) = path
3646 .parent()
3647 .filter(|parent| !parent.as_os_str().is_empty())
3648 {
3649 fs::create_dir_all(parent)?;
3650 }
3651 let temporary = temporary_path(path);
3652 let write_result = (|| {
3653 let mut file = OpenOptions::new()
3654 .create_new(true)
3655 .write(true)
3656 .open(&temporary)?;
3657 file.write_all(bytes)?;
3658 file.sync_all()?;
3659 drop(file);
3660 match fs::rename(&temporary, path) {
3661 Ok(()) => Ok(()),
3662 Err(error) if path.exists() => {
3663 fs::remove_file(path)?;
3664 fs::rename(&temporary, path).map_err(|_| error)
3665 }
3666 Err(error) => Err(error),
3667 }
3668 })();
3669 if write_result.is_err() {
3670 let _ = fs::remove_file(&temporary);
3671 }
3672 write_result.map_err(WorkspaceError::Io)
3673}
3674
3675fn write_new_file(path: &Path, bytes: &[u8]) -> Result<(), WorkspaceError> {
3676 if path_is_symlink(path)? {
3677 return Err(WorkspaceError::Invalid(format!(
3678 "refusing to create through a symbolic link: {}",
3679 path.display()
3680 )));
3681 }
3682 let mut file = OpenOptions::new().create_new(true).write(true).open(path)?;
3683 file.write_all(bytes)?;
3684 file.sync_all()?;
3685 Ok(())
3686}
3687
3688fn path_is_symlink(path: &Path) -> Result<bool, WorkspaceError> {
3689 match fs::symlink_metadata(path) {
3690 Ok(metadata) => Ok(metadata.file_type().is_symlink()),
3691 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
3692 Err(error) => Err(WorkspaceError::Io(error)),
3693 }
3694}
3695
3696fn acquire_workspace_lock(root: &Path) -> Result<Arc<File>, WorkspaceError> {
3697 let path = root.join(WORKSPACE_LOCK_FILE);
3698 if path_is_symlink(&path)? {
3699 return Err(WorkspaceError::Invalid(
3700 "workspace lock cannot be a symbolic link".to_string(),
3701 ));
3702 }
3703 let file = OpenOptions::new()
3704 .create(true)
3705 .truncate(false)
3706 .read(true)
3707 .write(true)
3708 .open(&path)?;
3709 match fs4::FileExt::try_lock(&file) {
3710 Ok(()) => Ok(Arc::new(file)),
3711 Err(fs4::TryLockError::WouldBlock) => Err(WorkspaceError::Invalid(format!(
3712 "workspace is already open: {}",
3713 root.display()
3714 ))),
3715 Err(fs4::TryLockError::Error(error)) => Err(WorkspaceError::Io(error)),
3716 }
3717}
3718
3719fn validate_database_paths(root: &Path) -> Result<(), WorkspaceError> {
3720 let database = root.join(DATABASE_FILE);
3721 if path_is_symlink(&database)? {
3722 return Err(WorkspaceError::Invalid(
3723 "workspace database cannot be a symbolic link".to_string(),
3724 ));
3725 }
3726 if database.exists() && !database.is_file() {
3727 return Err(WorkspaceError::Invalid(format!(
3728 "workspace database is not a file: {}",
3729 database.display()
3730 )));
3731 }
3732 for suffix in [".wal", ".lock", ".tmp"] {
3733 let mut value = database.as_os_str().to_os_string();
3734 value.push(suffix);
3735 let path = PathBuf::from(value);
3736 if path_is_symlink(&path)? {
3737 return Err(WorkspaceError::Invalid(format!(
3738 "workspace database sidecar cannot be a symbolic link: {}",
3739 path.display()
3740 )));
3741 }
3742 if path.exists() && !path.is_file() {
3743 return Err(WorkspaceError::Invalid(format!(
3744 "workspace database sidecar is not a file: {}",
3745 path.display()
3746 )));
3747 }
3748 }
3749 let mut wal_value = database.as_os_str().to_os_string();
3750 wal_value.push(".wal");
3751 if !database.exists() && !Path::new(&wal_value).exists() {
3752 return Err(WorkspaceError::Invalid(format!(
3753 "workspace database is missing: {}",
3754 database.display()
3755 )));
3756 }
3757 Ok(())
3758}
3759
3760fn manifest_bytes(manifest: &WorkspaceManifest) -> Result<Vec<u8>, WorkspaceError> {
3761 let mut bytes = serde_json::to_vec_pretty(manifest)?;
3762 bytes.push(b'\n');
3763 Ok(bytes)
3764}
3765
3766fn temporary_path(destination: &Path) -> PathBuf {
3767 let stamp = SystemTime::now()
3768 .duration_since(UNIX_EPOCH)
3769 .map(|duration| duration.as_nanos())
3770 .unwrap_or_default();
3771 let name = destination
3772 .file_name()
3773 .and_then(OsStr::to_str)
3774 .unwrap_or("export");
3775 destination.with_file_name(format!(".{name}.basalt-tmp-{}-{stamp}", std::process::id()))
3776}
3777
3778fn same_path(left: &Path, right: &Path) -> bool {
3779 if left == right {
3780 return true;
3781 }
3782 match (resolved_path(left), resolved_path(right)) {
3783 (Some(left), Some(right)) => left == right,
3784 _ => normalized_path(left) == normalized_path(right),
3785 }
3786}
3787
3788fn resolved_path(path: &Path) -> Option<PathBuf> {
3789 let mut candidate = path.to_path_buf();
3790 let mut suffix = Vec::new();
3791 loop {
3792 match fs::canonicalize(&candidate) {
3793 Ok(mut resolved) => {
3794 for component in suffix.iter().rev() {
3795 resolved.push(component);
3796 }
3797 return Some(resolved);
3798 }
3799 Err(error) if error.kind() == io::ErrorKind::NotFound => {
3800 let name = candidate.file_name()?.to_os_string();
3801 suffix.push(name);
3802 if !candidate.pop() {
3803 return None;
3804 }
3805 }
3806 Err(_) => return None,
3807 }
3808 }
3809}
3810
3811fn normalized_path(path: &Path) -> PathBuf {
3812 let absolute = if path.is_absolute() {
3813 path.to_path_buf()
3814 } else {
3815 std::env::current_dir()
3816 .map(|directory| directory.join(path))
3817 .unwrap_or_else(|_| path.to_path_buf())
3818 };
3819 let mut components = Vec::new();
3820 for component in absolute.components() {
3821 match component {
3822 Component::CurDir => {}
3823 Component::ParentDir => {
3824 if matches!(components.last(), Some(Component::Normal(_))) {
3825 components.pop();
3826 }
3827 }
3828 _ => components.push(component),
3829 }
3830 }
3831 components
3832 .into_iter()
3833 .fold(PathBuf::new(), |mut path, component| {
3834 path.push(component.as_os_str());
3835 path
3836 })
3837}
3838
3839#[cfg(test)]
3840mod tests {
3841 use super::*;
3842
3843 #[test]
3844 fn infers_numeric_and_text_columns() {
3845 let rows = vec![
3846 vec![ImportedCell::Integer(1), ImportedCell::Text("a".into())],
3847 vec![ImportedCell::Real(2.5), ImportedCell::Empty],
3848 ];
3849 assert_eq!(
3850 infer_types(&rows, 2),
3851 vec![ColumnType::Real, ColumnType::Text]
3852 );
3853 }
3854
3855 #[test]
3856 fn quotes_identifiers_and_literals() {
3857 assert_eq!(quote_identifier("a\"b"), "\"a\"\"b\"");
3858 assert_eq!(sql_literal(&Value::Text("a'b".into())), "'a''b'");
3859 }
3860
3861 #[test]
3862 fn manifest_is_stable() {
3863 let manifest = WorkspaceManifest {
3864 format_version: FORMAT_VERSION,
3865 database: DATABASE_FILE.to_string(),
3866 };
3867 assert_eq!(
3868 String::from_utf8(manifest_bytes(&manifest).unwrap()).unwrap(),
3869 "{\n \"format_version\": 1,\n \"database\": \"data.basalt\"\n}\n"
3870 );
3871 }
3872
3873 #[test]
3874 fn open_or_init_does_not_replace_existing_directories() {
3875 let root = std::env::temp_dir().join(format!(
3876 "basalt-workspace-open-or-init-test-{}-{}",
3877 std::process::id(),
3878 SystemTime::now()
3879 .duration_since(UNIX_EPOCH)
3880 .unwrap()
3881 .as_nanos()
3882 ));
3883 fs::create_dir_all(&root).unwrap();
3884 let marker = root.join("keep.txt");
3885 fs::write(&marker, b"keep").unwrap();
3886
3887 let error = Workspace::open_or_init(&root).unwrap_err();
3888 assert!(error.to_string().contains("not a Basalt workspace"));
3889 assert_eq!(fs::read(&marker).unwrap(), b"keep");
3890 fs::remove_dir_all(root).unwrap();
3891 }
3892
3893 #[test]
3894 fn rejects_a_workspace_with_a_missing_database() {
3895 let root = std::env::temp_dir().join(format!(
3896 "basalt-workspace-missing-database-test-{}-{}",
3897 std::process::id(),
3898 SystemTime::now()
3899 .duration_since(UNIX_EPOCH)
3900 .unwrap()
3901 .as_nanos()
3902 ));
3903 let workspace = Workspace::init(&root).unwrap();
3904 drop(workspace);
3905 fs::remove_file(root.join(DATABASE_FILE)).unwrap();
3906
3907 let error = Workspace::open(&root).unwrap_err();
3908 assert!(error.to_string().contains("workspace database is missing"));
3909 assert!(!root.join(DATABASE_FILE).exists());
3910 fs::remove_dir_all(root).unwrap();
3911 }
3912
3913 #[test]
3914 fn resolves_path_aliases() {
3915 let root = std::env::temp_dir().join(format!(
3916 "basalt-same-path-test-{}-{}",
3917 std::process::id(),
3918 SystemTime::now()
3919 .duration_since(UNIX_EPOCH)
3920 .unwrap()
3921 .as_nanos()
3922 ));
3923 fs::create_dir_all(root.join("workspace")).unwrap();
3924 let direct = root.join("workspace/data.basalt");
3925 let alias = root.join("workspace/../workspace/data.basalt");
3926 fs::write(&direct, b"database").unwrap();
3927 assert!(same_path(&alias, &direct));
3928 fs::remove_dir_all(root).unwrap();
3929 }
3930
3931 #[test]
3932 fn json_values_keep_nested_data_as_text() {
3933 assert!(matches!(
3934 json_cell(&serde_json::json!({"nested": true})),
3935 ImportedCell::Text(value) if value == "{\"nested\":true}"
3936 ));
3937 }
3938
3939 #[test]
3940 fn mcp_import_limits_cover_rows_columns_and_cells() {
3941 let limits = ImportLimits::mcp();
3942
3943 let rows = enforce_import_limits(MAX_MCP_IMPORT_ROWS + 1, 1, limits).unwrap_err();
3944 assert!(rows.to_string().contains("limited to 10000 rows"));
3945
3946 let columns = enforce_import_limits(1, MAX_MCP_IMPORT_COLUMNS + 1, limits).unwrap_err();
3947 assert!(columns.to_string().contains("limited to 256 columns"));
3948
3949 let cells = enforce_import_limits(10_000, 101, limits).unwrap_err();
3950 assert!(cells.to_string().contains("limited to 1000000 cells"));
3951 }
3952
3953 #[test]
3954 fn mcp_mutation_row_limit_is_cumulative() {
3955 let mut total = 0;
3956 enforce_mutation_row_limit(
3957 &mut total,
3958 &StatementResult::Update {
3959 rows_affected: 6_000,
3960 },
3961 Some(MAX_MCP_MUTATION_ROWS),
3962 )
3963 .unwrap();
3964 let error = enforce_mutation_row_limit(
3965 &mut total,
3966 &StatementResult::Delete {
3967 rows_affected: 4_001,
3968 },
3969 Some(MAX_MCP_MUTATION_ROWS),
3970 )
3971 .unwrap_err();
3972 assert!(error.to_string().contains("limited to 10000 affected rows"));
3973 }
3974
3975 #[test]
3976 fn row_delta_counts_ignore_order_and_preserve_duplicates() {
3977 let before = vec![
3978 vec![Value::Integer(1), Value::Text("Ada".into())],
3979 vec![Value::Integer(2), Value::Text("Grace".into())],
3980 vec![Value::Integer(2), Value::Text("Grace".into())],
3981 ];
3982 let after = vec![
3983 vec![Value::Integer(2), Value::Text("Grace".into())],
3984 vec![Value::Integer(1), Value::Text("Augusta".into())],
3985 ];
3986
3987 assert_eq!(row_delta_counts(&before, &after), (1, 2));
3988 assert_eq!(row_delta_counts(&before, &before), (0, 0));
3989 }
3990
3991 #[test]
3992 fn mcp_rejects_an_over_limit_mutation_without_persisting_or_committing_it() {
3993 let root = std::env::temp_dir().join(format!(
3994 "basalt-workspace-mutation-limit-test-{}-{}",
3995 std::process::id(),
3996 SystemTime::now()
3997 .duration_since(UNIX_EPOCH)
3998 .unwrap()
3999 .as_nanos()
4000 ));
4001 let workspace = Workspace::init(&root).unwrap();
4002 let database = workspace.database().unwrap();
4003 database
4004 .execute_sql("CREATE TABLE events (id INTEGER)")
4005 .unwrap();
4006 let values = (1..=MAX_MCP_MUTATION_ROWS + 1)
4007 .map(|id| format!("({id})"))
4008 .collect::<Vec<_>>()
4009 .join(", ");
4010 database
4011 .execute_sql(&format!("INSERT INTO events VALUES {values}"))
4012 .unwrap();
4013 database.checkpoint().unwrap();
4014 drop(database);
4015
4016 let error = mcp_preview(&workspace, "DELETE FROM events", 1_048_576).unwrap_err();
4017 assert!(error.to_string().contains("limited to 10000 affected rows"));
4018 assert!(!root.join(HISTORY_DIR).exists());
4019
4020 let plan = preview_plan(&workspace, "DELETE FROM events").unwrap();
4021 let error = mcp_apply(&workspace, &plan.plan_id).unwrap_err();
4022 assert!(error.to_string().contains("limited to 10000 affected rows"));
4023 let changes = load_changes(&workspace).unwrap();
4024 assert_eq!(changes.len(), 1);
4025 assert_eq!(changes[0].status, ChangeStatus::Failed);
4026
4027 let database = workspace.database().unwrap();
4028 assert_eq!(
4029 database.row_count("events").unwrap(),
4030 MAX_MCP_MUTATION_ROWS + 1
4031 );
4032 drop(database);
4033 drop(workspace);
4034 fs::remove_dir_all(root).unwrap();
4035 }
4036
4037 #[test]
4038 fn rejects_an_oversized_mcp_preview_before_persisting_its_plan() {
4039 let root = std::env::temp_dir().join(format!(
4040 "basalt-workspace-preview-limit-test-{}-{}",
4041 std::process::id(),
4042 SystemTime::now()
4043 .duration_since(UNIX_EPOCH)
4044 .unwrap()
4045 .as_nanos()
4046 ));
4047 let workspace = Workspace::init(&root).unwrap();
4048 let error = mcp_preview(&workspace, "CREATE TABLE users (id INTEGER)", 1).unwrap_err();
4049 assert!(error.to_string().contains("response limit is 1 bytes"));
4050 assert!(!root.join(HISTORY_DIR).exists());
4051 drop(workspace);
4052 fs::remove_dir_all(root).unwrap();
4053 }
4054
4055 #[test]
4056 fn retries_a_failed_mcp_import_when_the_base_state_is_unchanged() {
4057 let root = std::env::temp_dir().join(format!(
4058 "basalt-workspace-retry-test-{}-{}",
4059 std::process::id(),
4060 SystemTime::now()
4061 .duration_since(UNIX_EPOCH)
4062 .unwrap()
4063 .as_nanos()
4064 ));
4065 let workspace = Workspace::init(&root).unwrap();
4066 let content = "id,name\n1,Ada\n";
4067 let format = DataFormat::Csv;
4068 let table = "users";
4069 let database = workspace.database().unwrap();
4070 database.checkpoint().unwrap();
4071 let base_state = state_fingerprint(&workspace.database_path()).unwrap();
4072 let base_generation = database.generation();
4073 drop(database);
4074
4075 ensure_history_dirs(&workspace).unwrap();
4076 let change_id = import_change_id(base_state.as_str(), format, table, content.as_bytes());
4077 copy_atomic(
4078 &workspace.database_path(),
4079 &snapshot_path(&workspace, &change_id),
4080 )
4081 .unwrap();
4082 let request_key = import_request_key(format, table, content.as_bytes());
4083 let summary =
4084 validate_import(format, table, content.as_bytes(), ImportLimits::mcp()).unwrap();
4085 let failed = ChangeRecord {
4086 format_version: FORMAT_VERSION,
4087 sequence: 1,
4088 change_id: change_id.clone(),
4089 kind: ChangeKind::Apply,
4090 plan_id: None,
4091 target_change_id: None,
4092 base_generation,
4093 base_state: base_state.clone(),
4094 expected_state: None,
4095 snapshot_id: change_id.clone(),
4096 sql: None,
4097 status: ChangeStatus::Failed,
4098 committed_generation: None,
4099 after_state: None,
4100 error: Some("transient test failure".to_string()),
4101 import: Some(ImportMetadata {
4102 request_key,
4103 format: format.name().to_string(),
4104 table: Some(table.to_string()),
4105 bytes: content.len(),
4106 summary,
4107 }),
4108 };
4109 write_new_json(&change_path(&workspace, &change_id), &failed).unwrap();
4110
4111 let report = mcp_import(&workspace, Some(table), format.name(), content).unwrap();
4112 assert_eq!(report.change_id, change_id);
4113 assert_eq!(report.summary, "table users (1 rows, 2 columns)");
4114 let changes = load_changes(&workspace).unwrap();
4115 assert_eq!(changes[0].status, ChangeStatus::Committed);
4116 assert_eq!(changes[0].sequence, 1);
4117
4118 let database = workspace.database().unwrap();
4119 let rows = database.execute_sql("SELECT id, name FROM users").unwrap();
4120 assert!(matches!(
4121 &rows[0],
4122 StatementResult::Select { rows, .. } if rows.len() == 1
4123 ));
4124 drop(database);
4125 drop(workspace);
4126 fs::remove_dir_all(root).unwrap();
4127 }
4128}