feat(backend)!: run on Postgres with a migration-owned schema (#28)

Swap modernc.org/sqlite for jackc/pgx/v5 with no observable change:
same endpoints, same wire format, same updated_at ordering rule.

The schema now comes from numbered SQL embedded in the binary and
applied on startup, one transaction each, recorded in
schema_migrations. That replaces two pieces of SQLite-era machinery,
both deleted rather than ported: the column probing (Postgres has ADD
COLUMN IF NOT EXISTS, and there is no legacy database left to probe)
and the Asura key rewrite, which has run clean on every start for
months now that the userscripts strip build hashes before writing. Its
regexp survives as latest.asuraBuildHash, where the poller still needs
it to scope chapter links to a series whose slug carries a rotating
hash.

Types get real: favorite is a boolean, chapter numbers double
precision, timestamps stay unix-ms bigint. SQLite's null-safe IS NOT
becomes IS DISTINCT FROM, which is what implements the rule that only
reading progress reorders a list. Inside COALESCE/NULLIF the status
and kind parameters need an explicit ::text -- there is no target
column to infer from and Postgres refuses to guess.

Tests lose their free t.TempDir() database, so Docker is now a hard
prerequisite for `go test ./...`: internal/pgtest starts one
postgres:17-alpine per test binary and hands each test a database of
its own.

Also lands CONTEXT.md and the four ADRs written while scoping #18.

BREAKING CHANGE: DB_PATH is retired for DATABASE_URL, which is
required and has no default. Compose gains a postgres service on an
internal network with its own volume; POSTGRES_PASSWORD joins .env.
The old bookmarks-data volume is deliberately left undeclared so
`docker compose down -v` cannot take the pre-migration database with
it. main is not deployable until #25 and #26 land.

Closes #20

Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #28.
This commit is contained in:
2026-08-08 06:52:20 +07:00
committed by sulthan
parent b9f9aea82c
commit 08749df050
31 changed files with 961 additions and 966 deletions
+26 -311
View File
@@ -1,80 +1,54 @@
package store
import (
"database/sql"
"path/filepath"
"os"
"testing"
"time"
"bookmarkmanager/backend/internal/pgtest"
)
func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) }
func newTestStore(t *testing.T) *Store {
t.Helper()
store, err := Open(filepath.Join(t.TempDir(), "test.db"))
store, err := Open(pgtest.URL(t))
if err != nil {
t.Fatalf("OpenStore: %v", err)
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { store.Close() })
return store
}
func TestOpenStoreMigratesLegacySchema(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "legacy.db")
legacy, err := sql.Open("sqlite", dbPath)
// The migration runner runs on every start, so a second Open against a
// database it already built must be a no-op rather than a duplicate-table
// error, and must leave the rows alone.
func TestOpenIsIdempotent(t *testing.T) {
url := pgtest.URL(t)
first, err := Open(url)
if err != nil {
t.Fatalf("open legacy db: %v", err)
t.Fatalf("Open: %v", err)
}
if _, err := legacy.Exec(`
CREATE TABLE bookmarks (
key TEXT PRIMARY KEY,
site TEXT NOT NULL,
series_id TEXT NOT NULL,
title TEXT,
series_url TEXT,
cover TEXT,
last_chapter TEXT,
last_chapter_num REAL,
last_chapter_url TEXT,
updated_at INTEGER NOT NULL
)`); err != nil {
t.Fatalf("create legacy schema: %v", err)
}
if _, err := legacy.Exec(`
INSERT INTO bookmarks (key, site, series_id, title, last_chapter, last_chapter_num, updated_at)
VALUES ('asura:legacy', 'asura', 'legacy', 'Legacy Series', 'Chapter 7', 7, 123)`); err != nil {
t.Fatalf("seed legacy row: %v", err)
}
if err := legacy.Close(); err != nil {
t.Fatalf("close legacy db: %v", err)
if _, err := first.Upsert(Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed: %v", err)
}
first.Close()
store, err := Open(dbPath)
second, err := Open(url)
if err != nil {
t.Fatalf("OpenStore on legacy db: %v", err)
t.Fatalf("reopen: %v", err)
}
t.Cleanup(func() { store.Close() })
t.Cleanup(func() { second.Close() })
list, err := store.List()
list, err := second.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 1 || list[0].Key != "asura:legacy" {
t.Fatalf("legacy row lost: %+v", list)
if len(list) != 1 || list[0].Key != "asura:solo" {
t.Fatalf("rows after reopen = %+v, want only the seeded one", list)
}
got := list[0]
if got.Title != "Legacy Series" || got.LastChapterNum != 7 || got.UpdatedAt != 123 {
t.Fatalf("legacy data mangled: %+v", got)
}
if got.Favorite || got.LatestChapter != "" || got.LatestChapterNum != nil {
t.Fatalf("new columns should default empty, got %+v", got)
}
// Reopening an already-migrated database must be a no-op, not an error.
store2, err := Open(dbPath)
if err != nil {
t.Fatalf("OpenStore is not idempotent: %v", err)
}
store2.Close()
}
func TestStoreGet(t *testing.T) {
@@ -155,7 +129,7 @@ func readLatestCheckedAt(t *testing.T, s *Store, key string) int64 {
t.Helper()
var ts int64
if err := s.db.QueryRow(
`SELECT latest_checked_at FROM bookmarks WHERE key = ?`, key).Scan(&ts); err != nil {
`SELECT latest_checked_at FROM bookmarks WHERE key = $1`, key).Scan(&ts); err != nil {
t.Fatalf("read latest_checked_at %q: %v", key, err)
}
return ts
@@ -264,53 +238,6 @@ func TestUpsertPreservesLatestCheckedAt(t *testing.T) {
}
}
// migrateColumns must be able to bring a database created before this column up
// to date, not just create it fresh.
func TestMigrateAddsLatestCheckedAt(t *testing.T) {
path := filepath.Join(t.TempDir(), "old.db")
old, err := sql.Open("sqlite", path)
if err != nil {
t.Fatalf("open: %v", err)
}
// A pre-latest_checked_at table, matching the schema as it shipped before.
if _, err := old.Exec(`CREATE TABLE bookmarks (
key TEXT PRIMARY KEY, site TEXT NOT NULL, series_id TEXT NOT NULL,
title TEXT, series_url TEXT, cover TEXT,
last_chapter TEXT, last_chapter_num REAL, last_chapter_url TEXT,
favorite INTEGER NOT NULL DEFAULT 0,
latest_chapter TEXT NOT NULL DEFAULT '', latest_chapter_num REAL,
updated_at INTEGER NOT NULL)`); err != nil {
t.Fatalf("create old table: %v", err)
}
if _, err := old.Exec(
`INSERT INTO bookmarks (key, site, series_id, series_url, updated_at)
VALUES ('asura:x', 'asura', 'x', 'https://asurascans.com/comics/x', 5)`); err != nil {
t.Fatalf("seed old row: %v", err)
}
if err := old.Close(); err != nil {
t.Fatalf("close: %v", err)
}
s, err := Open(path)
if err != nil {
t.Fatalf("OpenStore on pre-existing db: %v", err)
}
t.Cleanup(func() { s.Close() })
// The migrated row must default to 0 (never checked) and so be due.
if got := readLatestCheckedAt(t, s, "asura:x"); got != 0 {
t.Fatalf("migrated latest_checked_at = %d, want 0", got)
}
due, err := s.DueForLatestCheck(1000, 10)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 1 {
t.Fatalf("got %d due rows after migration, want 1", len(due))
}
}
func TestUpsertDefaultsStatusToReading(t *testing.T) {
store := newTestStore(t)
stored, err := store.Upsert(Bookmark{
@@ -425,41 +352,6 @@ func TestUpsertStatusChangeKeepsUpdatedAt(t *testing.T) {
}
}
// A database written before the column existed must gain it, with every
// pre-existing row landing in the reading bucket.
func TestMigrationAddsStatusToLegacyDatabase(t *testing.T) {
path := filepath.Join(t.TempDir(), "legacy.db")
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatalf("open: %v", err)
}
if _, err := db.Exec(`
CREATE TABLE bookmarks (
key TEXT PRIMARY KEY, site TEXT NOT NULL, series_id TEXT NOT NULL,
title TEXT, series_url TEXT, cover TEXT,
last_chapter TEXT, last_chapter_num REAL, last_chapter_url TEXT,
updated_at INTEGER NOT NULL);
INSERT INTO bookmarks (key, site, series_id, updated_at)
VALUES ('asura:old', 'asura', 'old', 1)`); err != nil {
t.Fatalf("seed legacy: %v", err)
}
db.Close()
store, err := Open(path)
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer store.Close()
b, ok, err := store.Get("asura:old")
if err != nil || !ok {
t.Fatalf("Get: ok=%v err=%v", ok, err)
}
if b.Status != StatusReading {
t.Fatalf("Status = %q, want %q", b.Status, StatusReading)
}
}
// Archiving is the reason to keep polling — the point is to come back to a
// series that has moved on. A finished series has nothing left to publish.
func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
@@ -494,135 +386,6 @@ func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
}
}
// Asura slugs used to include the site build hash; rows keyed with it must
// be rewritten to the stable ID on open, merging hash-generations of the
// same series into the newest row.
func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "hash.db")
store, err := Open(dbPath)
if err != nil {
t.Fatalf("open: %v", err)
}
seed := []Bookmark{
{Key: "asura:swordmasters-youngest-son-f886a8af", Site: "asura",
SeriesID: "swordmasters-youngest-son-f886a8af", Title: "Old gen",
LastChapterNum: 50, UpdatedAt: 100},
{Key: "asura:swordmasters-youngest-son-059befe1", Site: "asura",
SeriesID: "swordmasters-youngest-son-059befe1", Title: "Re-bookmarked",
LastChapterNum: 60, UpdatedAt: 200},
{Key: "asura:overgeared-059befe1", Site: "asura",
SeriesID: "overgeared-059befe1", Title: "Single gen", UpdatedAt: 150},
// Hash-like suffix on another site must be left alone.
{Key: "demonic:x-deadbeef", Site: "demonic",
SeriesID: "x-deadbeef", Title: "Not asura", UpdatedAt: 300},
}
for _, b := range seed {
if _, err := store.Upsert(b); err != nil {
t.Fatalf("seed %s: %v", b.Key, err)
}
}
if err := store.Close(); err != nil {
t.Fatalf("close: %v", err)
}
reopened, err := Open(dbPath)
if err != nil {
t.Fatalf("reopen: %v", err)
}
t.Cleanup(func() { reopened.Close() })
list, err := reopened.List()
if err != nil {
t.Fatalf("List: %v", err)
}
byKey := map[string]Bookmark{}
for _, b := range list {
byKey[b.Key] = b
}
if len(list) != 3 {
t.Fatalf("want 3 rows after merge, got %d: %+v", len(list), list)
}
merged, ok := byKey["asura:swordmasters-youngest-son"]
if !ok {
t.Fatalf("merged key missing: %+v", byKey)
}
// Newest row wins the merge.
if merged.Title != "Re-bookmarked" || merged.LastChapterNum != 60 || merged.UpdatedAt != 200 {
t.Fatalf("merge kept wrong row: %+v", merged)
}
if merged.SeriesID != "swordmasters-youngest-son" {
t.Fatalf("series_id not stripped: %q", merged.SeriesID)
}
if _, ok := byKey["asura:overgeared-059befe1"]; ok {
t.Fatal("single-generation hashed key not rewritten")
}
if _, ok := byKey["asura:overgeared"]; !ok {
t.Fatal("single-generation row missing under stripped key")
}
if _, ok := byKey["demonic:x-deadbeef"]; !ok {
t.Fatal("non-asura row touched")
}
// Idempotent: a third open changes nothing.
third, err := Open(dbPath)
if err != nil {
t.Fatalf("third open: %v", err)
}
third.Close()
}
// A hashed row and a pre-existing hashless row of the same series collide on
// the stripped key. The winner rewrite must happen only after the loser is
// gone, or the UPDATE hits a primary-key collision and OpenStore fails.
func TestOpenStoreMigratesAsuraHashlessCollision(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "collision.db")
store, err := Open(dbPath)
if err != nil {
t.Fatalf("open: %v", err)
}
seed := []Bookmark{
{Key: "asura:overgeared", Site: "asura", SeriesID: "overgeared",
Title: "Hashless", LastChapterNum: 10, UpdatedAt: 100},
{Key: "asura:overgeared-059befe1", Site: "asura",
SeriesID: "overgeared-059befe1", Title: "Hashed newer",
LastChapterNum: 20, UpdatedAt: 200},
}
for _, b := range seed {
if _, err := store.Upsert(b); err != nil {
t.Fatalf("seed %s: %v", b.Key, err)
}
}
if err := store.Close(); err != nil {
t.Fatalf("close: %v", err)
}
reopened, err := Open(dbPath)
if err != nil {
t.Fatalf("reopen: %v", err)
}
t.Cleanup(func() { reopened.Close() })
list, err := reopened.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 1 {
t.Fatalf("want 1 row after merge, got %d: %+v", len(list), list)
}
merged := list[0]
if merged.Key != "asura:overgeared" {
t.Fatalf("merged key = %q, want asura:overgeared", merged.Key)
}
if merged.SeriesID != "overgeared" {
t.Fatalf("series_id = %q, want overgeared", merged.SeriesID)
}
if merged.Title != "Hashed newer" || merged.LastChapterNum != 20 || merged.UpdatedAt != 200 {
t.Fatalf("merge kept wrong row: %+v", merged)
}
}
func TestDisplayChapter(t *testing.T) {
cases := []struct {
name string
@@ -713,51 +476,3 @@ func TestUpsertEmptyKindKeepsStoredValue(t *testing.T) {
t.Fatalf("LastChapterNum = %v, want 11 — progress in the same request must still land", got.LastChapterNum)
}
}
// A database created before this column exists must gain it, backfilled as
// manga, without losing anything.
func TestLegacyDatabaseGainsKindAsManga(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "legacy.db")
legacy, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("open legacy db: %v", err)
}
if _, err := legacy.Exec(`
CREATE TABLE bookmarks (
key TEXT PRIMARY KEY,
site TEXT NOT NULL,
series_id TEXT NOT NULL,
title TEXT,
series_url TEXT,
cover TEXT,
last_chapter TEXT,
last_chapter_num REAL,
last_chapter_url TEXT,
updated_at INTEGER NOT NULL
)`); err != nil {
t.Fatalf("create legacy schema: %v", err)
}
if _, err := legacy.Exec(`
INSERT INTO bookmarks (key, site, series_id, title, updated_at)
VALUES ('asura:legacy', 'asura', 'legacy', 'Legacy Series', 123)`); err != nil {
t.Fatalf("seed legacy row: %v", err)
}
if err := legacy.Close(); err != nil {
t.Fatalf("close legacy db: %v", err)
}
store, err := Open(dbPath)
if err != nil {
t.Fatalf("Open on legacy db: %v", err)
}
t.Cleanup(func() { store.Close() })
list, err := store.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 1 || list[0].Kind != KindManga {
t.Fatalf("legacy row should backfill as manga, got %+v", list)
}
}