fix: code hardening from senior dev audit #40
@@ -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
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -219,3 +221,66 @@ func TestRunLsEmpty(t *testing.T) {
|
|||||||
t.Fatalf("runLs empty: %v", err)
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+113
@@ -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
|
||||||
|
}
|
||||||
@@ -51,6 +51,11 @@ func (s *Store) Close() error {
|
|||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) Backup(dst string) error {
|
||||||
|
_, err := s.db.Exec("VACUUM INTO ?", dst)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const currentSchema = 4
|
const currentSchema = 4
|
||||||
|
|
||||||
var migrations = []func(db *sql.DB) error{
|
var migrations = []func(db *sql.DB) error{
|
||||||
|
|||||||
Reference in New Issue
Block a user