From 2152baeb4f8bce122aea0ff3ee2b12efe3750b30 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Wed, 20 May 2026 20:54:44 -0400 Subject: [PATCH] feat: add export and backup commands - nib export: dump all entities to JSON (stdout or --output file) - nib backup: atomic SQLite backup via VACUUM INTO (WAL-safe) - Store.Backup() method on db layer - Tests for both commands --- cmd/backup.go | 46 +++++++++++++++++++ cmd/cmd_test.go | 65 ++++++++++++++++++++++++++ cmd/export.go | 113 ++++++++++++++++++++++++++++++++++++++++++++++ internal/db/db.go | 5 ++ 4 files changed, 229 insertions(+) create mode 100644 cmd/backup.go create mode 100644 cmd/export.go diff --git a/cmd/backup.go b/cmd/backup.go new file mode 100644 index 0000000..a39bd02 --- /dev/null +++ b/cmd/backup.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "fmt" + "time" + + "github.com/lerko/nib/internal/db" + "github.com/spf13/cobra" +) + +var backupCmd = &cobra.Command{ + Use: "backup [path]", + Short: "create a safe backup of the database", + Long: "Creates an atomic backup using VACUUM INTO. Safe with WAL mode — no need to stop the server.", + Args: cobra.MaximumNArgs(1), + RunE: runBackup, +} + +func init() { + rootCmd.AddCommand(backupCmd) +} + +func runBackup(cmd *cobra.Command, args []string) error { + srcPath, err := db.DefaultPath() + if err != nil { + return err + } + + dst := fmt.Sprintf("%s.backup-%s", srcPath, time.Now().Format("20060102-150405")) + if len(args) > 0 { + dst = args[0] + } + + store, err := db.Open(srcPath) + if err != nil { + return err + } + defer store.Close() + + if err := store.Backup(dst); err != nil { + return fmt.Errorf("backup failed: %w", err) + } + + fmt.Printf("backed up to %s\n", dst) + return nil +} diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index f4b2364..2d6f9e6 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -3,6 +3,8 @@ package cmd import ( "bytes" "context" + "encoding/json" + "os" "path/filepath" "strings" "testing" @@ -219,3 +221,66 @@ func TestRunLsEmpty(t *testing.T) { t.Fatalf("runLs empty: %v", err) } } + +func TestRunExport(t *testing.T) { + store := testStore(t) + seedEntity(t, store, "export me", db.GlyphNote) + seedEntity(t, store, "export me too", db.GlyphTodo) + store.Close() + + outFile := filepath.Join(t.TempDir(), "export.json") + exportOutput = outFile + + err := runExport(newCmd(), nil) + if err != nil { + t.Fatalf("runExport: %v", err) + } + + data, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("read export: %v", err) + } + + var entities []exportEntity + if err := json.Unmarshal(data, &entities); err != nil { + t.Fatalf("unmarshal export: %v", err) + } + + if len(entities) != 2 { + t.Fatalf("expected 2 entities, got %d", len(entities)) + } +} + +func TestRunBackup(t *testing.T) { + store := testStore(t) + seedEntity(t, store, "backup me", db.GlyphNote) + store.Close() + + dst := filepath.Join(t.TempDir(), "backup.db") + err := runBackup(newCmd(), []string{dst}) + if err != nil { + t.Fatalf("runBackup: %v", err) + } + + info, err := os.Stat(dst) + if err != nil { + t.Fatalf("backup file missing: %v", err) + } + if info.Size() == 0 { + t.Fatal("backup file is empty") + } + + backed, err := db.Open(dst) + if err != nil { + t.Fatalf("open backup: %v", err) + } + defer backed.Close() + + entities, err := backed.List(context.Background(), db.DefaultListParams()) + if err != nil { + t.Fatalf("list backup: %v", err) + } + if len(entities) != 1 { + t.Fatalf("expected 1 entity in backup, got %d", len(entities)) + } +} diff --git a/cmd/export.go b/cmd/export.go new file mode 100644 index 0000000..14d3703 --- /dev/null +++ b/cmd/export.go @@ -0,0 +1,113 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/lerko/nib/internal/db" + "github.com/spf13/cobra" +) + +var exportOutput string + +var exportCmd = &cobra.Command{ + Use: "export", + Short: "dump all entities to JSON", + RunE: runExport, +} + +func init() { + exportCmd.Flags().StringVarP(&exportOutput, "output", "o", "", "write to file instead of stdout") + rootCmd.AddCommand(exportCmd) +} + +type exportEntity struct { + ID string `json:"id"` + CreatedAt string `json:"created_at"` + ModifiedAt string `json:"modified_at"` + Body string `json:"body"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Glyph string `json:"glyph"` + TimeAnchor *string `json:"time_anchor,omitempty"` + CompletedAt *string `json:"completed_at,omitempty"` + Pinned bool `json:"pinned"` + DeletedAt *string `json:"deleted_at,omitempty"` + Tags []string `json:"tags"` + CardType *string `json:"card_type,omitempty"` + CardData *string `json:"card_data,omitempty"` + UseCount int `json:"use_count"` + LastUsedAt *string `json:"last_used_at,omitempty"` +} + +func runExport(cmd *cobra.Command, _ []string) error { + store, err := openStore() + if err != nil { + return err + } + defer store.Close() + + ctx := cmd.Context() + + p := db.DefaultListParams() + p.IncludeDeleted = true + p.Limit = 10000 + + entities, err := store.List(ctx, p) + if err != nil { + return err + } + + out := make([]exportEntity, len(entities)) + for i, e := range entities { + out[i] = exportEntity{ + ID: e.ID, + CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + ModifiedAt: e.ModifiedAt.Format("2006-01-02T15:04:05Z07:00"), + Body: e.Body, + Title: e.Title, + Glyph: string(e.Glyph), + TimeAnchor: e.TimeAnchor, + Pinned: e.Pinned, + Tags: e.Tags, + CardData: e.CardData, + UseCount: e.UseCount, + } + if e.Description != nil { + out[i].Description = e.Description + } + if e.CompletedAt != nil { + s := e.CompletedAt.Format("2006-01-02T15:04:05Z07:00") + out[i].CompletedAt = &s + } + if e.DeletedAt != nil { + s := e.DeletedAt.Format("2006-01-02T15:04:05Z07:00") + out[i].DeletedAt = &s + } + if e.CardType != nil { + s := string(*e.CardType) + out[i].CardType = &s + } + if e.LastUsedAt != nil { + s := e.LastUsedAt.Format("2006-01-02T15:04:05Z07:00") + out[i].LastUsedAt = &s + } + } + + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + + if exportOutput != "" { + if err := os.WriteFile(exportOutput, data, 0o600); err != nil { + return err + } + fmt.Fprintf(cmd.ErrOrStderr(), "exported %d entities to %s\n", len(out), exportOutput) + return nil + } + + fmt.Println(string(data)) + return nil +} diff --git a/internal/db/db.go b/internal/db/db.go index e1f0c42..efb7040 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -51,6 +51,11 @@ func (s *Store) Close() error { return s.db.Close() } +func (s *Store) Backup(dst string) error { + _, err := s.db.Exec("VACUUM INTO ?", dst) + return err +} + const currentSchema = 4 var migrations = []func(db *sql.DB) error{