fix(go): prefixed table names and a migrations ledger

The Carloku instance's database predates this build, so the schema must
not collide with tables or a user_version an earlier build left there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 607c7cceba670fe2d579076b9412b0bafb0a6501 parent c02e30a
3 files changed, +63 −18
  • apps/go/README.md +1 −1
  • apps/go/internal/todo/store.go +24 −17
  • apps/go/internal/todo/store_test.go +38 −0
diff --git a/apps/go/README.md b/apps/go/README.md
index 54655b3..4e962cd 100644
--- a/apps/go/README.md
+++ b/apps/go/README.md
@@ -17,7 +17,7 @@ that act without a page load.
| Templates | `html/template` | Escapes by context, so a todo titled `<img onerror=…>` shows as text. Templates and static files are embedded in the binary with `embed` |
| SQLite | `modernc.org/sqlite` | A pure-Go translation of SQLite, so the binary builds with `CGO_ENABLED=0` and cross-compiles to linux-arm64 with no C toolchain. It's the driver the CARLOS family uses |
| Database access | `database/sql`, hand-written SQL | Five short queries. Two pools: one connection for writes (SQLite allows one writer at a time, so writes queue in Go instead of fighting over the lock) and a few read-only connections |
-| Migrations | A Go slice of SQL strings, counted by `PRAGMA user_version` | Append-only by construction. No migration tool needed for one table |
+| Migrations | A Go slice of SQL strings, each recorded in a `todo_migrations` table once applied | Append-only by construction. No migration tool needed for one table |
| JavaScript | One ES module, no framework, no build step | It sends the same form POSTs with an `X-Todo-Fragment` header and swaps in the HTML the server returns, so all rendering stays in one template |
| Logging | `log/slog` | Standard library, structured, to stderr |
diff --git a/apps/go/internal/todo/store.go b/apps/go/internal/todo/store.go
index f83ae5a..780fb43 100644
--- a/apps/go/internal/todo/store.go
+++ b/apps/go/internal/todo/store.go
@@ -35,18 +35,19 @@ type Todo struct {
Completed bool
}
-// migrations only ever grow. Each entry runs once, in order, and
-// PRAGMA user_version records how many have run. Never edit or remove
-// one that has shipped; append a new one.
+// migrations only ever grow. Each entry runs once, in order, recorded in
+// todo_migrations. Never edit or remove one that has shipped; append a
+// new one. Names are prefixed so they can't collide with tables an
+// earlier build may have left in the same file.
var migrations = []string{
- `CREATE TABLE todos (
+ `CREATE TABLE todo_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
list BLOB NOT NULL,
title TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0,
created INTEGER NOT NULL DEFAULT (unixepoch())
);
- CREATE INDEX todos_by_list ON todos (list, id);`,
+ CREATE INDEX todo_items_by_list ON todo_items (list, id);`,
}
// Store is the todo database: one connection for writes, a small pool
@@ -92,8 +93,14 @@ func (s *Store) migrate(ctx context.Context) error {
return err
}
defer tx.Rollback()
+ if _, err := tx.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS todo_migrations (
+ version INTEGER PRIMARY KEY,
+ applied INTEGER NOT NULL DEFAULT (unixepoch())
+ )`); err != nil {
+ return err
+ }
var have int
- if err := tx.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&have); err != nil {
+ if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM todo_migrations`).Scan(&have); err != nil {
return err
}
if have > len(migrations) {
@@ -103,9 +110,9 @@ func (s *Store) migrate(ctx context.Context) error {
if _, err := tx.ExecContext(ctx, migrations[i]); err != nil {
return fmt.Errorf("todo: migration %d: %w", i+1, err)
}
- }
- if _, err := tx.ExecContext(ctx, fmt.Sprintf(`PRAGMA user_version = %d`, len(migrations))); err != nil {
- return err
+ if _, err := tx.ExecContext(ctx, `INSERT INTO todo_migrations (version) VALUES (?)`, i+1); err != nil {
+ return err
+ }
}
return tx.Commit()
}
@@ -121,7 +128,7 @@ func (s *Store) Ping(ctx context.Context) error { return s.r.PingContext(ctx) }
// List returns every todo in the list, oldest first.
func (s *Store) List(ctx context.Context, k ListKey) ([]Todo, error) {
rows, err := s.r.QueryContext(ctx,
- `SELECT id, title, completed FROM todos WHERE list = ? ORDER BY id`, k[:])
+ `SELECT id, title, completed FROM todo_items WHERE list = ? ORDER BY id`, k[:])
if err != nil {
return nil, err
}
@@ -148,14 +155,14 @@ func (s *Store) Add(ctx context.Context, k ListKey, title string) error {
if title = CleanTitle(title); title == "" {
return nil
}
- _, err := s.w.ExecContext(ctx, `INSERT INTO todos (list, title) VALUES (?, ?)`, k[:], title)
+ _, err := s.w.ExecContext(ctx, `INSERT INTO todo_items (list, title) VALUES (?, ?)`, k[:], title)
return err
}
// Toggle flips one todo between active and completed.
func (s *Store) Toggle(ctx context.Context, k ListKey, id int64) error {
return one(s.w.ExecContext(ctx,
- `UPDATE todos SET completed = 1 - completed WHERE id = ? AND list = ?`, id, k[:]))
+ `UPDATE todo_items SET completed = 1 - completed WHERE id = ? AND list = ?`, id, k[:]))
}
// Rename sets a todo's title. A title that is empty after cleaning
@@ -165,27 +172,27 @@ func (s *Store) Rename(ctx context.Context, k ListKey, id int64, title string) e
return s.Delete(ctx, k, id)
}
return one(s.w.ExecContext(ctx,
- `UPDATE todos SET title = ? WHERE id = ? AND list = ?`, title, id, k[:]))
+ `UPDATE todo_items SET title = ? WHERE id = ? AND list = ?`, title, id, k[:]))
}
// Delete removes one todo.
func (s *Store) Delete(ctx context.Context, k ListKey, id int64) error {
- return one(s.w.ExecContext(ctx, `DELETE FROM todos WHERE id = ? AND list = ?`, id, k[:]))
+ return one(s.w.ExecContext(ctx, `DELETE FROM todo_items WHERE id = ? AND list = ?`, id, k[:]))
}
// ToggleAll marks every todo completed, or, if every one already is,
// marks them all active.
func (s *Store) ToggleAll(ctx context.Context, k ListKey) error {
_, err := s.w.ExecContext(ctx,
- `UPDATE todos
- SET completed = EXISTS (SELECT 1 FROM todos WHERE list = ?1 AND completed = 0)
+ `UPDATE todo_items
+ SET completed = EXISTS (SELECT 1 FROM todo_items WHERE list = ?1 AND completed = 0)
WHERE list = ?1`, k[:])
return err
}
// ClearCompleted deletes every completed todo.
func (s *Store) ClearCompleted(ctx context.Context, k ListKey) error {
- _, err := s.w.ExecContext(ctx, `DELETE FROM todos WHERE list = ? AND completed = 1`, k[:])
+ _, err := s.w.ExecContext(ctx, `DELETE FROM todo_items WHERE list = ? AND completed = 1`, k[:])
return err
}
diff --git a/apps/go/internal/todo/store_test.go b/apps/go/internal/todo/store_test.go
index e132a70..3bbe1fb 100644
--- a/apps/go/internal/todo/store_test.go
+++ b/apps/go/internal/todo/store_test.go
@@ -149,3 +149,41 @@ func TestReopenKeepsData(t *testing.T) {
t.Fatalf("journal_mode = %q, want wal", mode)
}
}
+
+// A file an earlier build left behind, with its own tables and
+// user_version, opens cleanly and keeps that data untouched.
+func TestOpensBesideForeignTables(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "todo.db")
+ ctx := context.Background()
+ s, err := Open(ctx, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, q := range []string{
+ `DROP TABLE todo_items`, `DROP TABLE todo_migrations`,
+ `CREATE TABLE todos (id INTEGER PRIMARY KEY, text TEXT)`,
+ `INSERT INTO todos (text) VALUES ('old')`,
+ `PRAGMA user_version = 7`,
+ } {
+ if _, err := s.w.Exec(q); err != nil {
+ t.Fatal(err)
+ }
+ }
+ s.Close()
+
+ s, err = Open(ctx, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+ k := KeyFor([]byte("a"))
+ s.Add(ctx, k, "new")
+ if got := titles(t, s, k); got != "new;" {
+ t.Fatalf("got %q", got)
+ }
+ var old string
+ s.r.QueryRow(`SELECT text FROM todos`).Scan(&old)
+ if old != "old" {
+ t.Fatalf("earlier data lost: %q", old)
+ }
+}