carlos / platform Public

console: migrate-app completes the move, box-side — and the rebuilds prove they lose nothing

Two things the coordinator flagged.

1. The migration gap, closed by layering rather than reach-across. The
   console's app→account row is CONSOLE state, so the carlos binary must not
   open the console's database to move it. `console migrate-app -app <a> -to
   <sqid>` does it box-side, exactly like `console grant`: idempotent, refusing
   an unknown app or account before anything is written, and refusing to guess
   when several accounts legitimately claim the same name (they may — names
   are unique per account, not globally). `carlos accounts migrate` now prints
   the exact follow-up command, on success and in -dry-run, with one line
   saying why there is a second half at all.

2. Both table rebuilds now have a test that seeds the OLD shape by hand,
   re-opens through the real migration, and asserts what survived — twice
   over, because the rebuild has to be idempotent across restarts.
   - console apps: every row keeps app/created_by/created_at, gains
     account_id=1, and the point of the rebuild (the same name in two
     accounts) works afterwards.
   - registry accounts: the ROUTES — the registry's actual data — come
     through untouched, ops is back as id 1 / sqid bab, the legacy
     assigned-id rows are gone (they are the bug), and an account
     materialized after the rebuild survives the next open.

   Both tests were checked against a deliberately broken migration to confirm
   they are not vacuous: disabling the legacy detector fails the registry one,
   skipping the rebuild fails the console one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell ede325ef86b8ba818cfd3c929a928f49ddfd594d parent 8e13480
8 files changed, +517 −7
  • accounts.go +8 −0
  • cmd/console/main.go +77 −1
  • cmd/console/main_test.go +106 −0
  • e2e_test.go +10 −1
  • internal/console/account_test.go +52 −0
  • internal/console/migrate_test.go +107 −0
  • internal/console/store.go +55 −5
  • internal/registry/registry_test.go +102 −0
diff --git a/accounts.go b/accounts.go
index 328741d64..6fb52326b 100644
--- a/accounts.go
+++ b/accounts.go
@@ -265,13 +265,21 @@ func runAccountsMigrate(args []string) {
fmt.Printf("stamped account %s on route %s\n", *to, rt.Host)
}
+ // The console keeps its OWN app→account row, and this binary deliberately
+ // does not open the console's database — console state is the console's
+ // authority, exactly like grants. So the move is not finished until the
+ // operator runs the box-side verb, and the exact command goes in the
+ // output rather than in a runbook somebody has to find.
+ followUp := fmt.Sprintf("console migrate-app -app %s -to %s", *app, *to)
if *dryRun {
fmt.Println("dry run: nothing was written")
+ fmt.Printf("\nafterwards, on the console's box: %s\n", followUp)
return
}
fmt.Printf("\n%s now lives under %s. The legacy objects at %s are UNTOUCHED —\n", *app, dst, src)
fmt.Println("verify first (every channel pointer resolves qualified, one adopt pass is a")
fmt.Println("no-op), and only then delete them by hand.")
+ fmt.Printf("\nThe console keeps its own app→account row: run this on the console's box to\ncomplete the move:\n\n %s\n", followUp)
}
// resolveAccount turns a -account value into an account sqid: an exact sqid
diff --git a/cmd/console/main.go b/cmd/console/main.go
index 0f4f8e077..ed0e2280a 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -13,6 +13,10 @@
// console grant -operator <address> make address a fleet operator
// console revoke -operator <address> remove that operator role
// console owners list every owner and their apps
+// console migrate-app -app <a> -to <sqid>
+// re-home an app onto another account —
+// the console half of
+// `carlos accounts migrate`
//
// serve reads: CONSOLE_ADDR (default 127.0.0.1:8090), CONSOLE_DB (default
// /data/console/console.db), CONSOLE_ORIGIN (required, e.g.
@@ -75,6 +79,8 @@ func main() {
if err != nil {
log.Fatal(err)
}
+ case "migrate-app":
+ migrateApp(os.Args[2:])
case "owners":
st := openConsoleDB()
owners, err := st.Owners()
@@ -95,7 +101,77 @@ func main() {
}
func usage() {
- log.Fatal("usage: console serve | grant [-operator] <address> [<app>] | revoke [-operator] <address> [<app>] | owners")
+ log.Fatal("usage: console serve | grant [-operator] <address> [<app>] | revoke [-operator] <address> [<app>] | owners | migrate-app -app <app> -to <sqid> [-from <sqid>]")
+}
+
+// migrateApp completes the second half of `carlos accounts migrate`: that
+// verb moves the app's bucket objects and re-stamps its registry routes, but
+// the console's own app→account row is CONSOLE state, and the carlos binary
+// has no business opening this database. So the move finishes here, box-side,
+// exactly like `console grant`.
+//
+// Idempotent, so an operator who is unsure whether it landed can run it
+// again; an unknown app or account is refused before anything is written.
+func migrateApp(args []string) {
+ fs := flag.NewFlagSet("migrate-app", flag.ExitOnError)
+ app := fs.String("app", "", "app to move")
+ to := fs.String("to", "", "destination account sqid")
+ from := fs.String("from", "", "source account sqid (default: the one account that claims the app)")
+ fs.Parse(args)
+ if *app == "" || *to == "" {
+ log.Fatal("usage: console migrate-app -app <app> -to <sqid> [-from <sqid>]")
+ }
+ st := openConsoleDB()
+ defer st.Close()
+
+ dst, err := st.AccountBySqid(*to)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if dst == nil {
+ log.Fatalf("no account %q in this console — create it first (the switcher's \"New account\", or carlos accounts create)", *to)
+ }
+ holders, err := st.AppAccounts(*app)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if len(holders) == 0 {
+ log.Fatalf("no app %q in this console", *app)
+ }
+ if len(holders) == 1 && holders[0] == dst.ID {
+ fmt.Printf("%s is already in %s (%s) — nothing to do\n", *app, *to, dst.Name)
+ return
+ }
+
+ var src int64
+ switch {
+ case *from != "":
+ a, err := st.AccountBySqid(*from)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if a == nil {
+ log.Fatalf("no account %q in this console", *from)
+ }
+ src = a.ID
+ case len(holders) == 1:
+ src = holders[0]
+ default:
+ // Several accounts claim this name — legitimate, since names are
+ // unique per account rather than globally. Picking one would re-home
+ // the wrong tenant's app, so say which ones and stop.
+ var names []string
+ for _, id := range holders {
+ if a, _ := st.AccountByID(id); a != nil {
+ names = append(names, a.Sqid+" ("+a.Name+")")
+ }
+ }
+ log.Fatalf("%q is claimed by several accounts — pass -from <sqid>: %s", *app, strings.Join(names, ", "))
+ }
+ if err := st.MoveApp(*app, src, dst.ID); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("moved %s to %s (%s)\n", *app, *to, dst.Name)
}
func serve() {
diff --git a/cmd/console/main_test.go b/cmd/console/main_test.go
new file mode 100644
index 000000000..706d5d22c
--- /dev/null
+++ b/cmd/console/main_test.go
@@ -0,0 +1,106 @@
+package main_test
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/console"
+)
+
+// migrate-app is the CONSOLE half of `carlos accounts migrate`. It lives here
+// rather than in the carlos binary on purpose: the console's app→account row
+// is console state, and the carlos CLI has no business opening the console's
+// database — box-side authority changes go through this binary, exactly like
+// `console grant`.
+func TestMigrateAppVerb(t *testing.T) {
+ goBin, err := exec.LookPath("go")
+ if err != nil {
+ t.Skip("go toolchain not found")
+ }
+ tmp := t.TempDir()
+ bin := filepath.Join(tmp, "console")
+ build := exec.Command(goBin, "build", "-o", bin, ".")
+ build.Env = os.Environ()
+ if out, err := build.CombinedOutput(); err != nil {
+ t.Fatalf("build console: %v\n%s", err, out)
+ }
+ dbPath := filepath.Join(tmp, "console.db")
+ env := append(os.Environ(), "CONSOLE_DB="+dbPath)
+ run := func(args ...string) (string, error) {
+ t.Helper()
+ cmd := exec.Command(bin, args...)
+ cmd.Env = env
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+
+ // A console in the shape a deployment is in before the migration: ops
+ // owns kass.
+ st, err := console.Open(dbPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now()
+ if err := st.CreateAccount(console.OpsAccountID, "bab", "ops", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.CreateAccount(793481920157, "kqmvexample", "Kass", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.CreateAppIn(console.OpsAccountID, "kass", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ st.Close()
+
+ // Unknown app and unknown account are refused, clearly, before anything
+ // is written.
+ if out, err := run("migrate-app", "-app", "ghost", "-to", "kqmvexample"); err == nil {
+ t.Errorf("migrating an unknown app succeeded: %s", out)
+ } else if !strings.Contains(out, "ghost") {
+ t.Errorf("unknown-app error does not name the app: %s", out)
+ }
+ if out, err := run("migrate-app", "-app", "kass", "-to", "nosuch"); err == nil {
+ t.Errorf("migrating to an unknown account succeeded: %s", out)
+ } else if !strings.Contains(out, "nosuch") {
+ t.Errorf("unknown-account error does not name the account: %s", out)
+ }
+
+ // The move.
+ if out, err := run("migrate-app", "-app", "kass", "-to", "kqmvexample"); err != nil {
+ t.Fatalf("migrate-app: %v\n%s", err, out)
+ }
+ st, err = console.Open(dbPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ apps, err := st.AppsIn(793481920157)
+ if err != nil || len(apps) != 1 || apps[0] != "kass" {
+ t.Fatalf("AppsIn(Kass) = %v %v, want [kass]", apps, err)
+ }
+ if apps, _ := st.AppsIn(console.OpsAccountID); len(apps) != 0 {
+ t.Errorf("app still in ops: %v", apps)
+ }
+ st.Close()
+
+ // Idempotent: an operator who is not sure whether it landed can just run
+ // it again.
+ out, err := run("migrate-app", "-app", "kass", "-to", "kqmvexample")
+ if err != nil {
+ t.Fatalf("re-running migrate-app must be a no-op: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "already") {
+ t.Errorf("re-run output does not say it was already done: %s", out)
+ }
+ st, err = console.Open(dbPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer st.Close()
+ if apps, _ := st.AppsIn(793481920157); len(apps) != 1 {
+ t.Errorf("re-run changed things: %v", apps)
+ }
+}
diff --git a/e2e_test.go b/e2e_test.go
index 3be8c847d..a7f5c497f 100644
--- a/e2e_test.go
+++ b/e2e_test.go
@@ -723,6 +723,13 @@ func TestAccountsCLI(t *testing.T) {
!strings.Contains(out, "apps/"+sqid+"/demo/channels/stable.json") {
t.Fatalf("dry run did not report the plan:\n%s", out)
}
+ // The move is not finished until the console's own app→account row moves,
+ // and this binary deliberately will not reach into the console's DB to do
+ // it — so it must tell the operator the exact command that does.
+ wantFollowUp := "console migrate-app -app demo -to " + sqid
+ if !strings.Contains(out, wantFollowUp) {
+ t.Fatalf("dry run does not name the console follow-up %q:\n%s", wantFollowUp, out)
+ }
after, err := store.List(ctx, "")
if err != nil {
t.Fatal(err)
@@ -733,7 +740,9 @@ func TestAccountsCLI(t *testing.T) {
// migrate for real: the qualified objects exist AND the legacy ones are
// still there — nothing in this plan deletes a legacy object.
- run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid)
+ if out := run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid); !strings.Contains(out, wantFollowUp) {
+ t.Fatalf("migrate does not name the console follow-up %q:\n%s", wantFollowUp, out)
+ }
for _, key := range []string{
"apps/" + sqid + "/demo/releases/v1/manifest.json",
"apps/" + sqid + "/demo/releases/v1/demo",
diff --git a/internal/console/account_test.go b/internal/console/account_test.go
index 7bd32d979..e15c1bef1 100644
--- a/internal/console/account_test.go
+++ b/internal/console/account_test.go
@@ -220,6 +220,58 @@ func TestLastAccountRoundTrips(t *testing.T) {
}
}
+// The console half of `carlos accounts migrate`: the app→account row moves,
+// and re-running is a no-op rather than an error — an operator who is not
+// sure whether the move landed must be able to just run it again.
+func TestMoveAppBetweenAccounts(t *testing.T) {
+ st := openTestStore(t)
+ now := time.Now()
+ if err := st.CreateAccount(OpsAccountID, opsSqid, "ops", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.CreateAppIn(OpsAccountID, "kass", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+
+ if got, err := st.AppAccounts("kass"); err != nil || len(got) != 1 || got[0] != OpsAccountID {
+ t.Fatalf("AppAccounts = %v %v, want [1]", got, err)
+ }
+ if err := st.MoveApp("kass", OpsAccountID, 42); err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := st.AppAccounts("kass"); len(got) != 1 || got[0] != 42 {
+ t.Fatalf("after move AppAccounts = %v, want [42]", got)
+ }
+ if apps, _ := st.AppsIn(OpsAccountID); len(apps) != 0 {
+ t.Errorf("app still in the source account: %v", apps)
+ }
+ // Idempotent: the row is already where it was asked to go.
+ if err := st.MoveApp("kass", OpsAccountID, 42); err != nil {
+ t.Fatalf("re-running the move must be a no-op: %v", err)
+ }
+ if got, _ := st.AppAccounts("kass"); len(got) != 1 || got[0] != 42 {
+ t.Fatalf("re-run changed things: %v", got)
+ }
+ // Moving onto a name the destination already holds is refused, not
+ // silently merged: two different apps would become one row.
+ if _, err := st.CreateAppIn(OpsAccountID, "kass", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.MoveApp("kass", OpsAccountID, 42); err == nil {
+ t.Error("moving onto an existing name must fail")
+ }
+ if got, _ := st.AppAccounts("kass"); len(got) != 2 {
+ t.Errorf("refused move lost a row: %v", got)
+ }
+ // The app's other rows are untouched by a move of one of them.
+ if got, err := st.AppAccounts("nosuchapp"); err != nil || len(got) != 0 {
+ t.Errorf("AppAccounts(absent) = %v %v, want empty nil", got, err)
+ }
+}
+
// Account names are deliberately not unique — identity is the sqid — and
// AccountsFor must order deterministically (creation order) so "your first
// membership" is a stable answer.
diff --git a/internal/console/migrate_test.go b/internal/console/migrate_test.go
new file mode 100644
index 000000000..6ab315691
--- /dev/null
+++ b/internal/console/migrate_test.go
@@ -0,0 +1,107 @@
+package console
+
+import (
+ "database/sql"
+ "path/filepath"
+ "testing"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+func nowForTest() time.Time { return time.Unix(1700000100, 0) }
+
+// The apps table is REBUILT on upgrade (SQLite cannot ALTER a primary key,
+// and "unique within an account" is not something PRIMARY KEY(app) can
+// express). A table rebuild is where data quietly dies, so this seeds the
+// pre-accounts shape by hand, re-opens through the real migration, and
+// asserts every row survived with every column intact.
+func TestAppsRebuildPreservesEveryRow(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "console.db")
+
+ // The exact pre-multi-account shape: PRIMARY KEY(app), no account_id.
+ raw, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := raw.Exec(`CREATE TABLE apps (
+ app TEXT PRIMARY KEY,
+ created_by TEXT NOT NULL,
+ created_at INTEGER NOT NULL
+);`); err != nil {
+ t.Fatal(err)
+ }
+ seed := map[string][2]any{
+ "kass": {"paul@keymail.dev", int64(1700000000)},
+ "keymail": {"paul@keymail.dev", int64(1700000001)},
+ "woodstar": {"sam@keymail.dev", int64(1700000002)},
+ }
+ for app, row := range seed {
+ if _, err := raw.Exec(`INSERT INTO apps (app, created_by, created_at) VALUES (?, ?, ?)`,
+ app, row[0], row[1]); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := raw.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ // Two opens: the rebuild must also be idempotent, so a console that
+ // restarts twice does not rebuild twice (or lose anything on the second).
+ for pass := 1; pass <= 2; pass++ {
+ st, err := Open(path)
+ if err != nil {
+ t.Fatalf("pass %d: open: %v", pass, err)
+ }
+ rows, err := st.db.Query(`SELECT app, account_id, created_by, created_at FROM apps ORDER BY app`)
+ if err != nil {
+ t.Fatalf("pass %d: %v", pass, err)
+ }
+ got := map[string][3]any{}
+ for rows.Next() {
+ var app, by string
+ var acct, at int64
+ if err := rows.Scan(&app, &acct, &by, &at); err != nil {
+ t.Fatal(err)
+ }
+ got[app] = [3]any{acct, by, at}
+ }
+ rows.Close()
+ st.Close()
+
+ if len(got) != len(seed) {
+ t.Fatalf("pass %d: %d rows survived, want %d: %+v", pass, len(got), len(seed), got)
+ }
+ for app, want := range seed {
+ g, ok := got[app]
+ if !ok {
+ t.Errorf("pass %d: row %q vanished in the rebuild", pass, app)
+ continue
+ }
+ // account_id defaults to 1 (ops): before accounts existed, every
+ // app was in ops by definition.
+ if g[0] != int64(OpsAccountID) {
+ t.Errorf("pass %d: %s account_id = %v, want %d", pass, app, g[0], OpsAccountID)
+ }
+ if g[1] != want[0] {
+ t.Errorf("pass %d: %s created_by = %v, want %v", pass, app, g[1], want[0])
+ }
+ if g[2] != want[1] {
+ t.Errorf("pass %d: %s created_at = %v, want %v", pass, app, g[2], want[1])
+ }
+ }
+ }
+
+ // And the point of the rebuild: the same name in two accounts.
+ st, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer st.Close()
+ if err := st.CreateAccount(42, "cdef", "Other", "sam@keymail.dev", nowForTest()); err != nil {
+ t.Fatal(err)
+ }
+ if created, err := st.CreateAppIn(42, "kass", "sam@keymail.dev", nowForTest()); err != nil || !created {
+ t.Fatalf("same name in another account = %v %v, want it to be free", created, err)
+ }
+}
diff --git a/internal/console/store.go b/internal/console/store.go
index e9cf5a966..2d2d19280 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -703,11 +703,61 @@ func (s *Store) HasAppIn(accountID int64, app string) (bool, error) {
return n > 0, err
}
-// SetAppAccount re-homes a claimed app onto another account — the console
-// half of `carlos accounts migrate`.
-func (s *Store) SetAppAccount(app string, accountID int64) error {
- _, err := s.db.Exec(`UPDATE apps SET account_id = ? WHERE app = ?`, accountID, app)
- return err
+// AppAccounts lists the accounts that claim an app NAME, ascending. Usually
+// one; several is legitimate (names are unique per account, not globally) and
+// is what makes MoveApp require an explicit source.
+func (s *Store) AppAccounts(app string) ([]int64, error) {
+ rows, err := s.db.Query(`SELECT account_id FROM apps WHERE app = ? ORDER BY account_id`, app)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []int64
+ for rows.Next() {
+ var id int64
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ out = append(out, id)
+ }
+ return out, rows.Err()
+}
+
+// MoveApp re-homes a claimed app from one account to another — the console
+// half of `carlos accounts migrate`, which moves the bucket objects and the
+// registry routes but deliberately never opens this database.
+//
+// Idempotent: if the row is already at the destination there is nothing to
+// do, so an operator who is unsure whether the move landed can simply run it
+// again. Moving onto a name the destination already claims is REFUSED rather
+// than merged — those are two different apps, and one of them would vanish.
+func (s *Store) MoveApp(app string, from, to int64) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ var atDest, atSrc int
+ if err := tx.QueryRow(`SELECT COUNT(*) FROM apps WHERE app = ? AND account_id = ?`, app, to).Scan(&atDest); err != nil {
+ return err
+ }
+ if err := tx.QueryRow(`SELECT COUNT(*) FROM apps WHERE app = ? AND account_id = ?`, app, from).Scan(&atSrc); err != nil {
+ return err
+ }
+ if atDest > 0 {
+ if atSrc > 0 {
+ return fmt.Errorf("console: account %d already claims %q — moving onto it would merge two different apps", to, app)
+ }
+ return nil // already moved
+ }
+ if atSrc == 0 {
+ return fmt.Errorf("console: account %d does not claim %q", from, app)
+ }
+ if _, err := tx.Exec(`UPDATE apps SET account_id = ? WHERE app = ? AND account_id = ?`,
+ to, app, from); err != nil {
+ return err
+ }
+ return tx.Commit()
}
// HasApp reports whether an app name is claimed in the apps table.
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go
index a633d2419..953a0e730 100644
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -293,6 +293,108 @@ func TestAccountNamesNeedNotBeUnique(t *testing.T) {
}
}
+// The accounts table is DROPPED and rebuilt on upgrade — the one deliberate
+// exception to additive-only migrations, because its legacy rows carry the
+// assigned ids this whole change exists to stop trusting, and its
+// UNIQUE(name) would refuse two accounts both called "Blog". A drop is where
+// data quietly dies, so this pins exactly what survives and what does not:
+// the ROUTES (the registry's actual data) are untouched, ops comes back as
+// id 1 / sqid bab, and re-opening is clean and idempotent.
+func TestAccountsRebuildKeepsRoutesAndRestoresOps(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "registry.db")
+
+ // A registry in the pre-multi-account shape: the legacy accounts table
+ // (AUTOINCREMENT id, UNIQUE name) with rows, plus real route data.
+ first, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := first.sql.Exec(`DROP TABLE accounts`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := first.sql.Exec(`CREATE TABLE accounts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT UNIQUE NOT NULL
+);`); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{"ops", "paul@keymail.dev"} {
+ if _, err := first.sql.Exec(`INSERT INTO accounts (name) VALUES (?)`, name); err != nil {
+ t.Fatal(err)
+ }
+ }
+ routes := []Route{
+ {Host: "kass.training", Kind: "instance", App: "kass", Channel: "stable",
+ Version: "v1", Addr: "127.0.0.1:9000", DBPath: "/data/kass.db", Hibernate: true},
+ {Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"},
+ }
+ for _, rt := range routes {
+ if err := first.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ first.Close()
+
+ // Two opens: the rebuild must be idempotent, so restarting twice neither
+ // rebuilds twice nor loses anything the second time.
+ for pass := 1; pass <= 2; pass++ {
+ r, err := Open(path)
+ if err != nil {
+ t.Fatalf("pass %d: open over the legacy shape: %v", pass, err)
+ }
+ id, name, err := r.AccountBySqid("bab")
+ if err != nil || id != 1 || name != "ops" {
+ t.Errorf("pass %d: ops = %d/%q (%v), want 1/ops", pass, id, name, err)
+ }
+ accts, err := r.Accounts()
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The legacy assigned-id rows are deliberately NOT carried across:
+ // they are the bug. Everything but ops re-materializes from the
+ // bucket's own manifests on the next reconcile pass.
+ for _, a := range accts {
+ if a.Name == "paul@keymail.dev" {
+ t.Errorf("pass %d: a legacy assigned-id row survived: %+v", pass, a)
+ }
+ }
+ // The routes — the registry's actual data — are untouched.
+ got, err := r.List()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != len(routes) {
+ t.Fatalf("pass %d: %d routes survived, want %d", pass, len(got), len(routes))
+ }
+ byHost := map[string]Route{}
+ for _, rt := range got {
+ byHost[rt.Host] = rt
+ }
+ for _, want := range routes {
+ g, ok := byHost[want.Host]
+ if !ok {
+ t.Errorf("pass %d: route %s vanished", pass, want.Host)
+ continue
+ }
+ if g.App != want.App || g.Channel != want.Channel || g.Version != want.Version ||
+ g.Addr != want.Addr || g.DBPath != want.DBPath || g.Hibernate != want.Hibernate ||
+ g.Kind != want.Kind {
+ t.Errorf("pass %d: route %s lost fields: %+v, want %+v", pass, want.Host, g, want)
+ }
+ }
+ // A materialized account survives the SECOND open: only the legacy
+ // shape is ever dropped, and it is gone after pass 1.
+ if pass == 1 {
+ if err := r.PutAccount(793481920157, "kqmvexample", "Kass"); err != nil {
+ t.Fatal(err)
+ }
+ } else if id, _, _ := r.AccountBySqid("kqmvexample"); id != 793481920157 {
+ t.Errorf("pass 2 dropped an account materialized after the rebuild (id %d)", id)
+ }
+ r.Close()
+ }
+}
+
func TestDomainColumnsRoundTrip(t *testing.T) {
reg := openTestRegistry(t)
r := Route{Host: "kass.abs.oncarlos.com", Kind: "instance", App: "kass",