Run on Postgres with today's schema #28

Merged
sulthan merged 3 commits from feat/db-change into main 2026-08-08 06:52:21 +07:00
Owner

Closes #20.

Swaps modernc.org/sqlite for jackc/pgx/v5 with no observable change: same endpoints, same wire format, same updated_at ordering rule. The foundation the rest of #18 sits on, and its value is that it is boring.

Schema is migration-owned

Numbered SQL embedded via go:embed, applied on startup, one transaction each, recorded in schema_migrations. Files are append-only. That replaces two pieces of SQLite-era machinery, both deleted rather than ported:

  • Column probing — Postgres has ADD COLUMN IF NOT EXISTS, and there is no legacy database left to probe.
  • The Asura key rewrite — confirmed to have no remaining work, as the ticket predicted: it has run clean on every start for months and the userscripts strip build hashes before writing. Its regexp is not migration machinery, so it moved to unexported latest.asuraBuildHash, where the poller still needs it to scope chapter links for a series whose slug carries a rotating hash.

The six tests that seeded legacy SQLite schemas went with them; TestOpenIsIdempotent replaces the one assertion worth keeping.

Dialect

favorite is a real boolean, chapter numbers are double precision, timestamps stay unix-ms bigint. Every ? is $N; bookmarkColumns remains the only compile-time constant concatenated into query text. SQLite's null-safe IS NOT becomes IS DISTINCT FROM, which is what implements the rule that only reading progress reorders a list.

The ::text casts on $14/$15 are load-bearing: inside COALESCE/NULLIF there is no target column to infer the parameter type from and Postgres refuses to guess.

Tests need Docker now

internal/pgtest starts one postgres:17-alpine per test binary (TestMain → pgtest.Main) and hands each test a database of its own. Hand-rolled rather than testcontainers — one docker run, one docker port and a ping loop, against a module list that is otherwise stdlib. This is the permanent cost ADR-0001 flagged and the main reason the decision was close.

Verification

  • go test -count=1 ./... green in 7.3s; gofmt/go vet clean.
  • Smoke-tested against a live Postgres: healthz, 401 unauthenticated, GET/PUT/DELETE, 204 preflight with CORS headers, web UI. A favourite toggle left updated_at at …5958; a progress write moved it to …6013.
  • \d bookmarks confirms the column types. Restart on a populated database re-runs the migration silently.
  • Two-axis review: 0 standards violations, 11/11 spec criteria, no scope creep.

Deploying this

main is not deployable until #25 and #26. Compose now demands POSTGRES_PASSWORD and starts on an empty Postgres, so a git pull && docker compose up -d on the VPS before the import lands would serve an empty library. The 29 rows are safe — the pre-migration bookmarks-data volume is deliberately left undeclared so docker compose down -v cannot take it — but nothing reads them yet.

REDEPLOY.md and DEPLOY.md were rewritten off SQLite: pg_dump -Fc / pg_restore replace VACUUM INTO, and the -wal/-shm and chown 65532 advice is gone.

Not in scope

The reader/series/session split, Discord OAuth, per-Reader tokens and the 29-row import stay in #21–#26.

Closes #20. Swaps `modernc.org/sqlite` for `jackc/pgx/v5` with no observable change: same endpoints, same wire format, same `updated_at` ordering rule. The foundation the rest of #18 sits on, and its value is that it is boring. ### Schema is migration-owned Numbered SQL embedded via `go:embed`, applied on startup, one transaction each, recorded in `schema_migrations`. Files are append-only. That replaces two pieces of SQLite-era machinery, both deleted rather than ported: - **Column probing** — Postgres has `ADD COLUMN IF NOT EXISTS`, and there is no legacy database left to probe. - **The Asura key rewrite** — confirmed to have no remaining work, as the ticket predicted: it has run clean on every start for months and the userscripts strip build hashes before writing. Its regexp is *not* migration machinery, so it moved to unexported `latest.asuraBuildHash`, where the poller still needs it to scope chapter links for a series whose slug carries a rotating hash. The six tests that seeded legacy SQLite schemas went with them; `TestOpenIsIdempotent` replaces the one assertion worth keeping. ### Dialect `favorite` is a real boolean, chapter numbers are `double precision`, timestamps stay unix-ms `bigint`. Every `?` is `$N`; `bookmarkColumns` remains the only compile-time constant concatenated into query text. SQLite's null-safe `IS NOT` becomes `IS DISTINCT FROM`, which is what implements the rule that only reading progress reorders a list. The `::text` casts on `$14`/`$15` are load-bearing: inside `COALESCE`/`NULLIF` there is no target column to infer the parameter type from and Postgres refuses to guess. ### Tests need Docker now `internal/pgtest` starts one `postgres:17-alpine` per test binary (`TestMain` → `pgtest.Main`) and hands each test a database of its own. Hand-rolled rather than testcontainers — one `docker run`, one `docker port` and a ping loop, against a module list that is otherwise stdlib. This is the permanent cost ADR-0001 flagged and the main reason the decision was close. ### Verification - `go test -count=1 ./...` green in 7.3s; `gofmt`/`go vet` clean. - Smoke-tested against a live Postgres: healthz, 401 unauthenticated, `GET`/`PUT`/`DELETE`, 204 preflight with CORS headers, web UI. A favourite toggle left `updated_at` at `…5958`; a progress write moved it to `…6013`. - `\d bookmarks` confirms the column types. Restart on a populated database re-runs the migration silently. - Two-axis review: 0 standards violations, 11/11 spec criteria, no scope creep. ### Deploying this **`main` is not deployable until #25 and #26.** Compose now demands `POSTGRES_PASSWORD` and starts on an *empty* Postgres, so a `git pull && docker compose up -d` on the VPS before the import lands would serve an empty library. The 29 rows are safe — the pre-migration `bookmarks-data` volume is deliberately left undeclared so `docker compose down -v` cannot take it — but nothing reads them yet. `REDEPLOY.md` and `DEPLOY.md` were rewritten off SQLite: `pg_dump -Fc` / `pg_restore` replace `VACUUM INTO`, and the `-wal`/`-shm` and `chown 65532` advice is gone. ### Not in scope The reader/series/session split, Discord OAuth, per-Reader tokens and the 29-row import stay in #21–#26.
sulthan added 3 commits 2026-08-08 06:52:05 +07:00
Adds docs/agents/{issue-tracker,triage-labels,domain}.md so the engineering
skills know where issues live (Gitea via tea, not gh), which triage labels to
apply, and that domain docs are single-context.

Every CLAUDE.md was a stale subset of the AGENTS.md beside it, so each is now
a symlink and AGENTS.md is the single source of truth.
Written while scoping #18. CONTEXT.md pins the ubiquitous language
(Series, Reader, Bookmark, Progress, Latest Chapter, Poll) that the
schema split and the ordering rule are argued in; the four ADRs record
the decisions that follow from it, starting with Postgres over SQLite.

Refs #18
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. Hand-rolled rather than testcontainers — it is one docker
run, one docker port and a ping loop against a module list that is
otherwise stdlib.

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.

Closes #20
sulthan merged commit 08749df050 into main 2026-08-08 06:52:21 +07:00
Sign in to join this conversation.