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