Merge the wipe (#190)
V-494 part 1. Store.Wipe drops every table and rebuilds from the migrations; mavend -wipe is a dry run and -confirm-wipe deletes. QA isolation and onboarding are the remaining two thirds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
This commit is contained in:
@@ -128,6 +128,8 @@ func run(args []string) error {
|
||||
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
|
||||
reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap; the daemon does not answer until it finishes)")
|
||||
allowSeed := flag.Bool("allow-seed", false, "enable the backdated seed_event write path (QA only: it lets a caller place a fact in the past and mint a routine the tick loop will then act on; off means the method has nothing to write with)")
|
||||
wipe := flag.Bool("wipe", false, "print every table and its row count, then exit without serving; add -confirm-wipe to delete all of it")
|
||||
confirmWipe := flag.Bool("confirm-wipe", false, "with -wipe, actually remove every piece of personal data (facts, notes, vectors, events, tasks, sessions, traces, voiceprints). config, models, passkeys and the encryption key are files and survive")
|
||||
flag.CommandLine.Parse(args)
|
||||
reembedOnStart = *reembed
|
||||
allowSeedOnStart = *allowSeed
|
||||
@@ -210,6 +212,14 @@ func run(args []string) error {
|
||||
}()
|
||||
}
|
||||
|
||||
// ----- wipe: never serves, exits when it is done (Vikunja #494) -----
|
||||
if *wipe {
|
||||
if locked {
|
||||
return fmt.Errorf("wipe: the store is locked and there is no key to open it with")
|
||||
}
|
||||
return runWipe(ctx, st, os.Stdout, *confirmWipe)
|
||||
}
|
||||
|
||||
// ----- daemon components (only wired when unlocked) -----
|
||||
// Pre-declare so the unlock path can wire them later.
|
||||
var (
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// runWipe implements the -wipe flag: it prints what the database holds, and
|
||||
// removes it only when the operator also passed -confirm-wipe (Vikunja #494).
|
||||
//
|
||||
// Two flags rather than one, because the destructive reading of a single flag
|
||||
// is the reading a mistyped command gets. Without the confirmation this is a
|
||||
// dry run that costs nothing and answers the question a QA session actually
|
||||
// has — what is on this box right now.
|
||||
//
|
||||
// It runs before any daemon component is wired, so nothing is writing while
|
||||
// the tables go. The daemon exits afterwards rather than serving a store it
|
||||
// just emptied, because every component that read the old rows at boot would
|
||||
// still be holding them.
|
||||
func runWipe(ctx context.Context, st *store.Store, out io.Writer, confirmed bool) error {
|
||||
counts, err := st.WipeCounts(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wipe: read counts: %w", err)
|
||||
}
|
||||
total := 0
|
||||
for _, c := range counts {
|
||||
total += c.Rows
|
||||
fmt.Fprintf(out, " %-24s %d\n", c.Table, c.Rows)
|
||||
}
|
||||
fmt.Fprintf(out, " %-24s %d rows in %d tables\n", "TOTAL", total, len(counts))
|
||||
|
||||
if !confirmed {
|
||||
fmt.Fprintln(out, "\nnothing was deleted. pass -confirm-wipe to delete all of it.")
|
||||
fmt.Fprintln(out, "config, models, passkeys and the encryption key are files and are never touched.")
|
||||
return nil
|
||||
}
|
||||
if err := st.Wipe(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(out, "\nwiped. %d rows gone, the schema is intact, mavend knows nobody.\n", total)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WipeCount is one table and the number of rows a wipe would remove from it.
|
||||
type WipeCount struct {
|
||||
Table string
|
||||
Rows int
|
||||
}
|
||||
|
||||
// WipeCounts reports every table in the database and its row count, so a caller
|
||||
// can show what a wipe is about to destroy before it destroys it. Tables with
|
||||
// no rows are included: a QA reader needs to see that the table was considered,
|
||||
// not guess whether it was missed.
|
||||
func (s *Store) WipeCounts(ctx context.Context) ([]WipeCount, error) {
|
||||
names, err := userTables(ctx, s.db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]WipeCount, 0, len(names))
|
||||
for _, name := range names {
|
||||
var n int
|
||||
// name comes from sqlite_master, not from a caller, and is quoted.
|
||||
q := fmt.Sprintf(`SELECT count(*) FROM %s`, quoteIdent(name))
|
||||
if err := s.db.QueryRowContext(ctx, q).Scan(&n); err != nil {
|
||||
return nil, fmt.Errorf("count %s: %w", name, err)
|
||||
}
|
||||
out = append(out, WipeCount{Table: name, Rows: n})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Wipe removes every piece of personal data and leaves the install standing.
|
||||
//
|
||||
// It drops every object in the database and rebuilds the schema from
|
||||
// schema.sql plus the migration list, rather than deleting from a hand-written
|
||||
// list of tables. That is the whole point: a list has to be edited every time a
|
||||
// table is added, and the one time it is not, a wipe leaves personal data
|
||||
// behind while reporting success. Dropping what the database says exists cannot
|
||||
// miss a table (Vikunja #494).
|
||||
//
|
||||
// Nothing outside the database is touched. Config, models, the WebAuthn
|
||||
// credentials in passkeys.json and the encryption key all live in files, so
|
||||
// this leaves a working install with no memory of anyone.
|
||||
//
|
||||
// The caller is responsible for the daemon being the only thing holding the
|
||||
// store, and for asking whoever ran it whether they meant it.
|
||||
func (s *Store) Wipe(ctx context.Context) error {
|
||||
names, err := userTables(ctx, s.db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Foreign keys are on per connection. Dropping tables in an arbitrary order
|
||||
// trips them, and the drop order does not matter once everything goes.
|
||||
if _, err := s.db.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil {
|
||||
return fmt.Errorf("wipe: disable foreign keys: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if _, err := s.db.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil {
|
||||
// The handle is about to be closed by the caller either way.
|
||||
_ = err
|
||||
}
|
||||
}()
|
||||
|
||||
var drops strings.Builder
|
||||
for _, name := range names {
|
||||
fmt.Fprintf(&drops, "DROP TABLE IF EXISTS %s;\n", quoteIdent(name))
|
||||
}
|
||||
// user_version back to zero, or migrate() applies nothing to the fresh
|
||||
// schema and every ALTER that a later migration assumes is missing.
|
||||
drops.WriteString("PRAGMA user_version = 0;\n")
|
||||
if _, err := s.db.ExecContext(ctx, drops.String()); err != nil {
|
||||
return fmt.Errorf("wipe: drop tables: %w", err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, schemaSQL); err != nil {
|
||||
return fmt.Errorf("wipe: reapply schema: %w", err)
|
||||
}
|
||||
if err := migrate(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("wipe: re-migrate: %w", err)
|
||||
}
|
||||
// The file keeps the pages the dropped rows used until it is vacuumed, and
|
||||
// a wipe that leaves readable text in free pages is not a wipe.
|
||||
if _, err := s.db.ExecContext(ctx, `VACUUM`); err != nil {
|
||||
return fmt.Errorf("wipe: vacuum: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// userTables lists every table the schema owns, sorted, excluding sqlite's own
|
||||
// internal tables (sqlite_sequence and friends, which cannot be dropped).
|
||||
func userTables(ctx context.Context, db *sql.DB) ([]string, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tables: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var names []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, fmt.Errorf("list tables: %w", err)
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list tables: %w", err)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func quoteIdent(name string) string {
|
||||
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A wipe has to leave a working, empty install: every table still there, every
|
||||
// migration still applied, and not one row of his anywhere (Vikunja #494).
|
||||
func TestWipeEmptiesEveryTableAndLeavesTheSchemaUsable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "wipe.db")
|
||||
s, err := Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
now := time.Now()
|
||||
if _, err := s.WriteFact(ctx, now, KindSelf, "monitor", "новый", "tap:test", 1, sql.NullInt64{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.WriteNote(ctx, now, "купил монитор", []float32{0.1, 0.2}, "tap:test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.CaptureTask(ctx, Task{Text: "вернуть монитор", Source: "tap:test", Status: TaskOpen, CreatedTs: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
before, err := s.WipeCounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
populated := 0
|
||||
for _, c := range before {
|
||||
if c.Rows > 0 {
|
||||
populated++
|
||||
}
|
||||
}
|
||||
if populated == 0 {
|
||||
t.Fatal("nothing was written, so the wipe proves nothing")
|
||||
}
|
||||
|
||||
if err := s.Wipe(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
after, err := s.WipeCounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(after) != len(before) {
|
||||
t.Errorf("table count changed across the wipe: %d before, %d after", len(before), len(after))
|
||||
}
|
||||
for _, c := range after {
|
||||
if c.Rows != 0 {
|
||||
t.Errorf("table %s still holds %d rows after the wipe", c.Table, c.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
// The migrations are what make the schema usable, so the check that matters
|
||||
// is a write through the newest columns, not a version number.
|
||||
if _, err := s.CaptureTask(ctx, Task{Text: "новая задача", Source: "tap:test", Status: TaskOpen, CreatedTs: now}); err != nil {
|
||||
t.Errorf("the store is not usable after a wipe: %v", err)
|
||||
}
|
||||
if _, err := s.WriteFact(ctx, now, KindSelf, "monitor", "другой", "tap:test", 1, sql.NullInt64{}); err != nil {
|
||||
t.Errorf("the store is not usable after a wipe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A wipe that reports success while leaving a table behind is the failure the
|
||||
// drop-everything approach exists to prevent, so the count has to see the
|
||||
// tables migrations added, not only the ones schema.sql declares.
|
||||
func TestWipeCountsSeeMigratedTables(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := Open(ctx, filepath.Join(t.TempDir(), "counts.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
counts, err := s.WipeCounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range counts {
|
||||
seen[c.Table] = true
|
||||
}
|
||||
// One from schema.sql, three from migrations, one from a table rebuild.
|
||||
for _, want := range []string{"facts", "memory_vectors", "tasks", "ecosystem_traces", "nudges"} {
|
||||
if !seen[want] {
|
||||
t.Errorf("wipe does not see table %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user