package main // backup.go: the F4 backup/integrity command and the automatic pre-flight guard for the PAID boevoy // commands (backlog row 83). The guard lives at the boevoy COMMAND dispatch (run()), NOT inside translate() // or the pipeline Runner — so the "is this a real run?" discriminator carries NO provider-name magic string // (generality §0: Go must not branch on a fake-vs-real provider literal). A fake-provider test drives the // Runner or translate() directly and never enters run()'s dispatch, so the golden/fixture paths create zero // backup files without any special-casing here. import ( "errors" "fmt" "io" "io/fs" "os" "path/filepath" "time" "textmachine/backend/internal/config" "textmachine/backend/internal/pipeline" "textmachine/backend/internal/store" ) // backupStamp formats a UTC timestamp for a backup filename — sortable and filesystem-safe. func backupStamp(t time.Time) string { return t.UTC().Format("20060102T150405Z") } // backupDirFor returns the durable backup directory for a book: backups/ next to its project DB, which for // the stand book lands under ~/books//… — the ratified durable home ("persist, not scratch"). func backupDirFor(dbPath string) string { return filepath.Join(filepath.Dir(dbPath), "backups") } // backupCmd is the `tmctl backup` verb: after a green PRAGMA integrity_check it VACUUM-INTO-copies the book's // SQLite file to a fresh timestamped restore point (F4). $0, no provider keys — an operator safety command, // like status/report. It refuses loudly if the database is missing or fails integrity. func backupCmd(cfgPath string, w io.Writer) error { book, err := config.LoadBook(cfgPath) if err != nil { return err } if _, err := os.Stat(book.ProjectDB); err != nil { return fmt.Errorf("tmctl backup: project database %s does not exist yet — run `tmctl translate` first, there is nothing to back up", book.ProjectDB) } path, err := store.BackupSQLite(book.ProjectDB, backupDirFor(book.ProjectDB), backupStamp(time.Now())) if err != nil { return err } fmt.Fprintf(w, "backup OK: %s (integrity_check green, VACUUM INTO)\n", path) return nil } // preflightBackup is the automatic pre-run guard for the PAID boevoy commands (translate, non-dry-run // redrive): a paid run of a book must not start without a fresh restore point and a green PRAGMA // integrity_check (F4 — the SPOF is a silent loss of the owner's signed bank and checkpoints). A missing DB // is a fresh book with no signed bank to lose (skip, no error); an integrity failure is a LOUD refusal to // start, so a paid run never writes on top of a corrupt file. func preflightBackup(cfgPath string, w io.Writer) error { book, err := config.LoadBook(cfgPath) if err != nil { // This runs BEFORE the runner, so it is the first thing a broken config meets — and a refusal // classified here rather than collapsed onto exit 1 is the whole of PD-196 (see pipeline/refusal.go). return pipeline.RefuseConfig(err) } // Only ABSENT means "fresh book". Any other stat error read that way silently skips the guard. if _, err := os.Stat(book.ProjectDB); err != nil { if errors.Is(err, fs.ErrNotExist) { return nil // first run of a new book: no signed bank exists yet, nothing to back up } return fmt.Errorf("pre-flight guard: cannot stat the project database %s: %w", book.ProjectDB, err) } path, err := store.BackupSQLite(book.ProjectDB, backupDirFor(book.ProjectDB), backupStamp(time.Now())) if err != nil { return fmt.Errorf("pre-flight guard: refusing to start a paid run that could lose the signed bank: %w", err) } fmt.Fprintf(w, "pre-flight: backed up the project DB to %s (integrity_check green)\n", path) return nil }