carlos / platform Public

console store: app region columns carried through the apps-key rebuild (nearest-region §5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 3159f30fcf41cb160e052faafe3858fba49243c8 parent 5fa8296
2 files changed, +254 −10
  • internal/console/store.go +67 −10
  • internal/console/store_region_test.go +187 −0
diff --git a/internal/console/store.go b/internal/console/store.go
index 964b0884c..9e94c52da 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -384,6 +384,14 @@ func (s *Store) migrate() error {
// moment adoption swaps the symlink until something restarts the
// process. "" keeps today's header behaviour byte for byte.
`ALTER TABLE apps ADD COLUMN build_probe TEXT NOT NULL DEFAULT '';`,
+ // App region (nearest-region spec §5.1): the placement code new
+ // instances default to, in the RECORD vocabulary — "" is the
+ // deployment's home, which is also what every existing row reads, so
+ // the column default IS the backfill (§5.4). region_source is a label
+ // for people and logs: chosen | timezone | ip | default | "" (the row
+ // predates the feature). No timezone, IP or coordinate is stored.
+ `ALTER TABLE apps ADD COLUMN region TEXT NOT NULL DEFAULT '';`,
+ `ALTER TABLE apps ADD COLUMN region_source TEXT NOT NULL DEFAULT '';`,
} {
if _, err := s.db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -493,16 +501,19 @@ func (s *Store) migrateAppsKey() error {
placement TEXT NOT NULL DEFAULT '',
control_plane INTEGER NOT NULL DEFAULT 0,
build_probe TEXT NOT NULL DEFAULT '',
+ region TEXT NOT NULL DEFAULT '',
+ region_source TEXT NOT NULL DEFAULT '',
PRIMARY KEY (account_id, app)
);`,
// The ALTERs above run before migrateAppsKey in migrate(), so
- // deleted_at, placement, control_plane and build_probe already exist
- // on the old table by the time this rebuild copies them — a row
- // already trashed, already placed, already the control plane, or
- // already carrying a build probe, when the rebuild runs must not
- // have that state silently dropped.
- `INSERT INTO apps_new (app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe)
- SELECT app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe FROM apps;`,
+ // deleted_at, placement, control_plane, build_probe, region and
+ // region_source already exist on the old table by the time this
+ // rebuild copies them — a row already trashed, already placed,
+ // already the control plane, already carrying a build probe, or
+ // already carrying an app region, when the rebuild runs must not
+ // have that state silently dropped (nearest-region spec §5.2).
+ `INSERT INTO apps_new (app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe, region, region_source)
+ SELECT app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe, region, region_source FROM apps;`,
`DROP TABLE apps;`,
`ALTER TABLE apps_new RENAME TO apps;`,
} {
@@ -1347,9 +1358,16 @@ func (s *Store) CreateApp(app, createdBy string, now time.Time) (created bool, e
// when that account already has the name — another account holding it is not
// a conflict, because app names are unique per account, never globally.
func (s *Store) CreateAppIn(accountID int64, app, createdBy string, now time.Time) (created bool, err error) {
- res, err := s.db.Exec(`INSERT INTO apps (app, account_id, created_by, created_at)
-VALUES (?, ?, ?, ?) ON CONFLICT(account_id, app) DO NOTHING`,
- app, accountID, createdBy, now.Unix())
+ return s.CreateAppInRegion(accountID, app, createdBy, "", "", now)
+}
+
+// CreateAppInRegion claims an app name with its region in the SAME insert:
+// an app region has no repair verb, so a separate best-effort write that
+// failed would leave the app at the home for good, silently (spec §5.3).
+func (s *Store) CreateAppInRegion(accountID int64, app, createdBy, region, source string, now time.Time) (created bool, err error) {
+ res, err := s.db.Exec(`INSERT INTO apps (app, account_id, created_by, created_at, region, region_source)
+VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(account_id, app) DO NOTHING`,
+ app, accountID, createdBy, now.Unix(), region, source)
if err != nil {
return false, err
}
@@ -1357,6 +1375,45 @@ VALUES (?, ?, ?, ?) ON CONFLICT(account_id, app) DO NOTHING`,
return n > 0, err
}
+// AppRegion reads an app's region and its source label. A missing row
+// reads as the home with no source.
+func (s *Store) AppRegion(accountID int64, app string) (region, source string, err error) {
+ err = s.db.QueryRow(`SELECT region, region_source FROM apps WHERE account_id = ? AND app = ?`, accountID, app).
+ Scan(&region, &source)
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", "", nil
+ }
+ return region, source, err
+}
+
+// nonHomeAppRegion is one live app holding a non-home region, for the
+// roll-forward discrepancy log (spec §5.7).
+type nonHomeAppRegion struct {
+ Sqid, App, Region string
+ CreatedAt time.Time
+}
+
+func (s *Store) NonHomeAppRegions() ([]nonHomeAppRegion, error) {
+ rows, err := s.db.Query(`SELECT a.sqid, p.app, p.region, p.created_at FROM apps p
+JOIN accounts a ON a.id = p.account_id
+WHERE p.region != '' AND p.deleted_at = 0 ORDER BY a.sqid, p.app`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []nonHomeAppRegion
+ for rows.Next() {
+ var r nonHomeAppRegion
+ var created int64
+ if err := rows.Scan(&r.Sqid, &r.App, &r.Region, &created); err != nil {
+ return nil, err
+ }
+ r.CreatedAt = time.Unix(created, 0)
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
+
// AppsIn lists an account's claimed app names, sorted. Trashed apps are
// filtered out — every door this feeds (API resolution, ship claim, account
// page listings) is a door a trashed app must vanish from automatically,
diff --git a/internal/console/store_region_test.go b/internal/console/store_region_test.go
new file mode 100644
index 000000000..d8771b1e4
--- /dev/null
+++ b/internal/console/store_region_test.go
@@ -0,0 +1,187 @@
+package console
+
+import (
+ "database/sql"
+ "path/filepath"
+ "testing"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+func TestAppRegionRoundTripAndDefaults(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ if ok, err := s.CreateAppInRegion(OpsAccountID, "blog", "p@k.dev", "uk", "timezone", now); err != nil || !ok {
+ t.Fatalf("CreateAppInRegion = %v %v", ok, err)
+ }
+ if r, src, err := s.AppRegion(OpsAccountID, "blog"); err != nil || r != "uk" || src != "timezone" {
+ t.Fatalf("AppRegion = %q %q %v", r, src, err)
+ }
+ if ok, err := s.CreateAppIn(OpsAccountID, "old", "p@k.dev", now); err != nil || !ok {
+ t.Fatalf("CreateAppIn = %v %v", ok, err)
+ }
+ if r, src, _ := s.AppRegion(OpsAccountID, "old"); r != "" || src != "" {
+ t.Fatalf("CreateAppIn row = %q %q, want the home and no source", r, src)
+ }
+ if r, src, err := s.AppRegion(OpsAccountID, "missing"); err != nil || r != "" || src != "" {
+ t.Fatalf("missing row = %q %q %v", r, src, err)
+ }
+ // A lost claim race writes nothing.
+ if ok, _ := s.CreateAppInRegion(OpsAccountID, "blog", "x@k.dev", "au", "ip", now); ok {
+ t.Fatal("a second claim of the same name succeeded")
+ }
+ if r, _, _ := s.AppRegion(OpsAccountID, "blog"); r != "uk" {
+ t.Fatalf("a lost claim changed the region to %q", r)
+ }
+}
+
+// The fixture runs NewServer, whose backfill creates the ops account row
+// (sqid "bab") that NonHomeAppRegions joins on.
+func TestNonHomeAppRegions(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppInRegion(OpsAccountID, "blog", "p@k.dev", "uk", "chosen", now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppInRegion(OpsAccountID, "home", "p@k.dev", "", "default", now); err != nil {
+ t.Fatal(err)
+ }
+ rows, err := fx.st.NonHomeAppRegions()
+ if err != nil || len(rows) != 1 || rows[0].App != "blog" || rows[0].Region != "uk" || rows[0].Sqid != "bab" {
+ t.Fatalf("NonHomeAppRegions = %+v %v", rows, err)
+ }
+}
+
+// spec §5.2: a database from BEFORE migrateAppsKey whose rows already
+// carry an app region keeps it through the rebuild, a reopen, a rename and
+// a move to another account. An empty value would not prove the copy.
+func TestRegionValuesSurviveRebuildRenameAndMove(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "console.db")
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`CREATE TABLE apps (
+ app TEXT PRIMARY KEY, created_by TEXT NOT NULL, created_at INTEGER NOT NULL,
+ account_id INTEGER NOT NULL DEFAULT 1, deleted_at INTEGER NOT NULL DEFAULT 0,
+ placement TEXT NOT NULL DEFAULT '', control_plane INTEGER NOT NULL DEFAULT 0,
+ build_probe TEXT NOT NULL DEFAULT '', region TEXT NOT NULL DEFAULT '',
+ region_source TEXT NOT NULL DEFAULT '');
+INSERT INTO apps (app, created_by, created_at, region, region_source) VALUES ('legacy', 'p@k.dev', 1, 'uk', 'timezone');`); err != nil {
+ t.Fatal(err)
+ }
+ db.Close()
+ for i := 0; i < 2; i++ { // the rebuild, then a reopen of the rebuilt table
+ s, err := Open(path)
+ if err != nil {
+ t.Fatalf("open %d: %v", i, err)
+ }
+ if r, src, err := s.AppRegion(OpsAccountID, "legacy"); err != nil || r != "uk" || src != "timezone" {
+ t.Fatalf("open %d: legacy = %q %q %v, want uk/timezone carried", i, r, src, err)
+ }
+ s.Close()
+ }
+ s, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+ // Open migrates but does not backfill accounts (NewServer does), and
+ // rename and move both resolve the app's account row: seed the ops
+ // account the legacy row belongs to, and the move's destination.
+ if _, err := s.db.Exec(`INSERT OR IGNORE INTO accounts (id, sqid, name, created_at) VALUES (?, 'bab', 'Ops', 0)`, OpsAccountID); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.RenameAppIn(OpsAccountID, "legacy", "renamed"); err != nil {
+ t.Fatal(err)
+ }
+ if r, src, _ := s.AppRegion(OpsAccountID, "renamed"); r != "uk" || src != "timezone" {
+ t.Fatalf("after rename = %q %q", r, src)
+ }
+ const other = 2001
+ if _, err := s.db.Exec(`INSERT INTO accounts (id, sqid, name, created_at) VALUES (?, 'zzz', 'Other', 0)`, other); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.MoveApp("renamed", OpsAccountID, other); err != nil {
+ t.Fatal(err)
+ }
+ if r, src, _ := s.AppRegion(other, "renamed"); r != "uk" || src != "timezone" {
+ t.Fatalf("after move = %q %q", r, src)
+ }
+}
+
+// PF10: a database from BEFORE the region columns existed gains both on open,
+// and every row it already held reads "" (the home, no source) in both. The
+// column default is the backfill, so this is the proof there is none to run.
+// It is checked with raw SQL, not the accessors, so it asserts the schema and
+// the stored values rather than the accessor's own defaulting. Both shapes a
+// real database can be in: not yet rebuilt (PRIMARY KEY(app), so the ALTERs
+// and the migrateAppsKey copy both run) and already rebuilt (the ALTERs only).
+func TestPreRegionDatabaseGainsColumnsReadingEmpty(t *testing.T) {
+ shapes := map[string]string{
+ "pre-rebuild": `CREATE TABLE apps (
+ app TEXT PRIMARY KEY,
+ account_id INTEGER NOT NULL DEFAULT 1,
+ created_by TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ deleted_at INTEGER NOT NULL DEFAULT 0,
+ placement TEXT NOT NULL DEFAULT ''
+);`,
+ "already-rebuilt": `CREATE TABLE apps (
+ app TEXT NOT NULL,
+ account_id INTEGER NOT NULL DEFAULT 1,
+ created_by TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ deleted_at INTEGER NOT NULL DEFAULT 0,
+ placement TEXT NOT NULL DEFAULT '',
+ control_plane INTEGER NOT NULL DEFAULT 0,
+ build_probe TEXT NOT NULL DEFAULT '',
+ PRIMARY KEY (account_id, app)
+);`,
+ }
+ for name, ddl := range shapes {
+ t.Run(name, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "console.db")
+ raw, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := raw.Exec(ddl); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := raw.Exec(`INSERT INTO apps (app, created_by, created_at) VALUES ('one', 'p@k.dev', 1), ('two', 'p@k.dev', 2)`); err != nil {
+ t.Fatal(err)
+ }
+ raw.Close()
+
+ s, err := Open(path)
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ defer s.Close()
+ rows, err := s.db.Query(`SELECT app, region, region_source FROM apps ORDER BY app`)
+ if err != nil {
+ t.Fatalf("the region columns are missing after Open: %v", err)
+ }
+ defer rows.Close()
+ n := 0
+ for rows.Next() {
+ var app, region, source string
+ if err := rows.Scan(&app, &region, &source); err != nil {
+ t.Fatal(err)
+ }
+ if region != "" || source != "" {
+ t.Fatalf("%s reads region %q source %q, want both empty", app, region, source)
+ }
+ n++
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatal(err)
+ }
+ if n != 2 {
+ t.Fatalf("%d rows survived, want 2", n)
+ }
+ })
+ }
+}