feat(backend)!: run on Postgres with a migration-owned schema
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
This commit is contained in:
+135
-80
@@ -18,7 +18,7 @@ the checkout cannot take the backups with them.
|
||||
```
|
||||
/opt/
|
||||
├── bookmarkmanager/ <- the checkout (this repo)
|
||||
└── bookmarkmanager-backups/ <- bookmarks-YYYYmmdd-HHMMSS.db
|
||||
└── bookmarkmanager-backups/ <- bookmarks-YYYYmmdd-HHMMSS.dump
|
||||
```
|
||||
|
||||
---
|
||||
@@ -53,78 +53,97 @@ echo "$BACKUP_DIR" # -> /opt/bookmarkmanager-backups
|
||||
|
||||
## 1. Back up the database
|
||||
|
||||
The database is a single SQLite file in the named Docker volume, at
|
||||
`/data/bookmarks.db` inside the container. Find the volume's real name — Compose
|
||||
prefixes it with the project directory:
|
||||
The database is Postgres, running as the `postgres` service on the named volume
|
||||
`postgres-data`. It has **no published port** — nothing outside the internal `db`
|
||||
network can reach it — so every command below goes in through the container:
|
||||
|
||||
```bash
|
||||
docker volume ls --filter name=bookmarks-data
|
||||
# -> local bookmarkmanager_bookmarks-data
|
||||
VOL=$(docker volume ls --filter name=bookmarks-data -q | head -1)
|
||||
$COMPOSE exec -T postgres psql -U bookmarks -d bookmarks -c '\dt'
|
||||
# -> bookmarks, schema_migrations
|
||||
```
|
||||
|
||||
### Preferred: hot backup, no downtime
|
||||
Inside the container that connects over the local socket as the `bookmarks`
|
||||
superuser, so no password is needed anywhere in this section. `-T` is not
|
||||
optional: without it Compose allocates a TTY, which rewrites `\n` to `\r\n` and
|
||||
silently corrupts any binary stream flowing back out — see the dump below.
|
||||
|
||||
The store runs in **WAL mode**, so recent writes may still be sitting in
|
||||
`bookmarks.db-wal`. Copying `bookmarks.db` alone while the container runs can
|
||||
therefore silently drop the newest bookmarks. `VACUUM INTO` folds the WAL in and
|
||||
writes one consistent file, safe to run against a live database:
|
||||
### Preferred: hot dump, no downtime
|
||||
|
||||
`pg_dump` runs in a single repeatable-read transaction, so it writes one
|
||||
point-in-time-consistent snapshot while the API keeps serving. No stopping, no
|
||||
WAL to worry about — that is the server's problem, not yours.
|
||||
|
||||
```bash
|
||||
STAMP=$(date -u +%Y%m%d-%H%M%S) # UTC, sorts chronologically as text
|
||||
docker run --rm \
|
||||
-v "$VOL":/data \
|
||||
-v "$BACKUP_DIR":/backup \
|
||||
alpine sh -c "apk add -q sqlite &&
|
||||
sqlite3 /data/bookmarks.db \"VACUUM INTO '/backup/bookmarks-$STAMP.db'\""
|
||||
$COMPOSE exec -T postgres pg_dump -U bookmarks -d bookmarks -Fc \
|
||||
> "$BACKUP_DIR/bookmarks-$STAMP.dump"
|
||||
|
||||
ls -lh "$BACKUP_DIR"/bookmarks-$STAMP.db
|
||||
ls -lh "$BACKUP_DIR"/bookmarks-$STAMP.dump
|
||||
```
|
||||
|
||||
`$STAMP` is the "time in the name" — `bookmarks-20260730-014233.db`. UTC, so the
|
||||
files sort in real order and never collide across a DST shift.
|
||||
`-Fc` is the custom archive format rather than plain SQL: it is compressed, and
|
||||
`pg_restore` can inspect and replay it selectively — list its table of contents,
|
||||
restore one table, restore schema without data, reorder. A plain `.sql` dump can
|
||||
only be piped into `psql` whole, and gives you no way to check what is in it
|
||||
short of reading it.
|
||||
|
||||
Note the source volume is mounted **read-write**, which looks wrong for a backup
|
||||
and is not. Opening a WAL database requires creating the `-shm` shared-memory
|
||||
file; with `:ro` the command fails with `unable to open database file` and no
|
||||
backup is produced. `VACUUM INTO` never writes to the source itself.
|
||||
`$STAMP` is the "time in the name" — `bookmarks-20260730-014233.dump`. UTC, so
|
||||
the files sort in real order and never collide across a DST shift.
|
||||
|
||||
Verify it before you trust it. An unreadable backup is worse than none, because
|
||||
you will act as though you have one:
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$BACKUP_DIR":/backup alpine sh -c "apk add -q sqlite &&
|
||||
sqlite3 /backup/bookmarks-$STAMP.db 'PRAGMA integrity_check;' &&
|
||||
sqlite3 /backup/bookmarks-$STAMP.db 'SELECT count(*) FROM bookmarks;'"
|
||||
# -> ok
|
||||
# 1. The dump parses and contains the tables. Uses the same image compose
|
||||
# already pulls, so nothing new to install.
|
||||
docker run --rm -v "$BACKUP_DIR":/backup postgres:17-alpine \
|
||||
pg_restore --list "/backup/bookmarks-$STAMP.dump" | grep 'TABLE DATA'
|
||||
# -> 1234; 0 0 TABLE DATA public bookmarks bookmarks
|
||||
# -> 1235; 0 0 TABLE DATA public schema_migrations bookmarks
|
||||
|
||||
# 2. Sanity-check the live row count you just captured.
|
||||
$COMPOSE exec -T postgres psql -U bookmarks -d bookmarks \
|
||||
-c 'select count(*) from bookmarks'
|
||||
# -> 37
|
||||
```
|
||||
|
||||
The count should match what the web UI shows. Zero rows on a server you know has
|
||||
bookmarks means you backed up the wrong volume.
|
||||
A custom-format archive stores row counts nowhere, so step 1 proves the file is
|
||||
a readable archive with the right tables in it, not that the rows are there;
|
||||
step 2 is the number those rows should be. It should match what the web UI
|
||||
shows. Zero on a server you know has bookmarks means the API and your `psql`
|
||||
are looking at different databases — check `DATABASE_URL`.
|
||||
|
||||
### Fallback: cold copy (no network for `apk add sqlite`)
|
||||
### Fallback: cold volume archive
|
||||
|
||||
Stop the service first, then copy the database **and its sidecars** — the `-wal`
|
||||
is not optional, it is where the newest writes are:
|
||||
Use this when you want the whole data directory rather than a logical dump — a
|
||||
like-for-like restore of the same Postgres major version onto the same host.
|
||||
|
||||
**The stack must be stopped first.** A running Postgres has dirty pages in
|
||||
shared buffers and WAL that has not been replayed into the data files, and `tar`
|
||||
walks the directory over several seconds while the server keeps writing to it.
|
||||
The archive you get is torn: files from different instants, possibly a
|
||||
half-written page. It may restore, start, and be quietly wrong. Online
|
||||
filesystem-level backup is `pg_basebackup`'s job, not `tar`'s; with the
|
||||
container stopped the shutdown checkpoint has already flushed everything and a
|
||||
plain archive of the volume is consistent.
|
||||
|
||||
```bash
|
||||
VOL=$(docker volume ls --filter name=postgres-data -q | head -1)
|
||||
echo "$VOL" # -> bookmarkmanager_postgres-data
|
||||
|
||||
$COMPOSE stop
|
||||
docker run --rm -v "$VOL":/data:ro -v "$BACKUP_DIR":/backup alpine sh -c "
|
||||
cp /data/bookmarks.db /backup/bookmarks-$STAMP.db
|
||||
[ -f /data/bookmarks.db-wal ] && cp /data/bookmarks.db-wal /backup/bookmarks-$STAMP.db-wal
|
||||
[ -f /data/bookmarks.db-shm ] && cp /data/bookmarks.db-shm /backup/bookmarks-$STAMP.db-shm
|
||||
ls -1 /backup"
|
||||
docker run --rm -v "$VOL":/from:ro -v "$BACKUP_DIR":/to alpine \
|
||||
tar czf "/to/postgres-data-$STAMP.tgz" -C /from .
|
||||
$COMPOSE start
|
||||
|
||||
ls -lh "$BACKUP_DIR"/postgres-data-$STAMP.tgz
|
||||
```
|
||||
|
||||
Costs ~10 seconds of downtime. A clean shutdown usually checkpoints the WAL away,
|
||||
so seeing only the `.db` file is normal and fine — the `[ -f ]` guards exist for
|
||||
the case where it did not. Restoring this variant means putting whichever files
|
||||
you got back together, under their original names.
|
||||
|
||||
Read-only is safe here precisely because nothing opens the database: it is a file
|
||||
copy, not a SQLite connection.
|
||||
Costs ~15 seconds of downtime. Read-only on the source is safe here precisely
|
||||
because nothing is running against it. Restoring this variant means untarring it
|
||||
back into an *empty* `postgres-data` volume with the stack down — it is a whole
|
||||
data directory, not a file you can drop next to the live one, and it will only
|
||||
start under `postgres:17`.
|
||||
|
||||
### Retention
|
||||
|
||||
@@ -132,7 +151,19 @@ Keep a month, drop the rest — a bookmark database this small compresses the
|
||||
decision to "disk is free, but not infinite":
|
||||
|
||||
```bash
|
||||
ls -1t "$BACKUP_DIR"/bookmarks-*.db | tail -n +31 | xargs -r rm -v
|
||||
ls -1t "$BACKUP_DIR"/bookmarks-*.dump | tail -n +31 | xargs -r rm -v
|
||||
```
|
||||
|
||||
### A note on the old `bookmarks-data` volume
|
||||
|
||||
`bookmarks-data` is the **pre-migration SQLite volume**. It is deliberately not
|
||||
declared in `docker-compose.yml` any more, which is what keeps `docker compose
|
||||
down -v` from taking it with the rest of the stack. It is not the live database
|
||||
and nothing reads it. Once the Postgres data has been trusted for a while,
|
||||
remove it by hand — nothing else will:
|
||||
|
||||
```bash
|
||||
docker volume rm bookmarkmanager_bookmarks-data
|
||||
```
|
||||
|
||||
---
|
||||
@@ -171,11 +202,15 @@ rebuilt. The one exception is `userscript/manga-bookmark.user.js`, which is
|
||||
bindmounted read-only and read fresh per request.
|
||||
|
||||
```bash
|
||||
$COMPOSE ps # Up, and recently (re)created
|
||||
$COMPOSE ps # bookmark-api Up; postgres Up (healthy)
|
||||
docker logs bookmark-api --tail 20 # -> "listening on :8080 ..."
|
||||
```
|
||||
|
||||
Nothing in the log about the database or the poller failing. The image is tagged
|
||||
Nothing in the log about the database, the migrations or the poller failing.
|
||||
`bookmark-api` waits on `postgres` reporting healthy before it starts and the
|
||||
binary applies any pending migration before it listens, so an API that never
|
||||
says "listening" is usually the database, not the code — `$COMPOSE logs
|
||||
postgres` first. The image is tagged
|
||||
`bookmarkmanager-backend:latest`, so the previous image is still on disk untagged —
|
||||
that is what makes the rollback in §6 quick.
|
||||
|
||||
@@ -199,9 +234,16 @@ curl -s -i -X OPTIONS -H 'Origin: https://asurascans.com' \
|
||||
$API/bookmarks/x | grep -i access-control # -> allow-origin echoed
|
||||
```
|
||||
|
||||
`[]` from the third call is the alarm that matters: the volume is not attached
|
||||
and you are looking at an empty database. Stop and check `$COMPOSE config
|
||||
--volumes` before touching anything else.
|
||||
`[]` from the third call is the alarm that matters: you are talking to an empty
|
||||
database, which means the API found a *different* Postgres than the one holding
|
||||
your data — a renamed project directory, a fresh `postgres-data`, or a
|
||||
`DATABASE_URL` override in `.env` pointing elsewhere. Stop and check, before
|
||||
touching anything else:
|
||||
|
||||
```bash
|
||||
$COMPOSE config --volumes # -> postgres-data
|
||||
$COMPOSE exec -T postgres psql -U bookmarks -d bookmarks -c 'select count(*) from bookmarks'
|
||||
```
|
||||
|
||||
Web UI and its assets:
|
||||
|
||||
@@ -267,33 +309,41 @@ git checkout <previous-hash>
|
||||
$COMPOSE up -d --build
|
||||
```
|
||||
|
||||
**Database damaged** — restore the backup from §1. Stop first: the running
|
||||
process holds the WAL, and dropping a file under a live SQLite connection
|
||||
corrupts what you were trying to save.
|
||||
**Database damaged** — restore the dump from §1. Stop **only the API**, not the
|
||||
whole stack: `pg_restore` needs the server up to restore into, and it needs
|
||||
`bookmark-api`'s connection pool gone, because `--clean` cannot drop a table
|
||||
other sessions are holding open.
|
||||
|
||||
```bash
|
||||
$COMPOSE stop
|
||||
$COMPOSE stop bookmark-api
|
||||
|
||||
docker run --rm -v "$VOL":/data -v "$BACKUP_DIR":/backup alpine sh -c '
|
||||
rm -f /data/bookmarks.db /data/bookmarks.db-wal /data/bookmarks.db-shm &&
|
||||
cp /backup/bookmarks-<STAMP>.db /data/bookmarks.db &&
|
||||
chown 65532:65532 /data/bookmarks.db &&
|
||||
ls -l /data'
|
||||
$COMPOSE exec -T postgres pg_restore -U bookmarks -d bookmarks --clean --if-exists \
|
||||
< "$BACKUP_DIR/bookmarks-<STAMP>.dump"
|
||||
|
||||
$COMPOSE start
|
||||
$COMPOSE start bookmark-api
|
||||
docker logs bookmark-api --tail 20
|
||||
curl -s -H "Authorization: Bearer $TOKEN" $API/bookmarks | head -c 200
|
||||
```
|
||||
|
||||
Two steps here are easy to skip and both bite:
|
||||
Three things here are easy to skip and all three bite:
|
||||
|
||||
- **Delete the stale `-wal` and `-shm`.** Leaving them beside a restored database
|
||||
mixes two different histories; SQLite will either refuse to open it or quietly
|
||||
reapply writes you meant to discard.
|
||||
- **`chown 65532:65532`.** The image is `distroless/static:nonroot` and runs as
|
||||
that uid, while the helper container above writes as root. A root-owned
|
||||
database opens read-only-ish: reads work, so `/bookmarks` looks fine, and then
|
||||
every write fails. That is the worst possible failure mode — it looks restored.
|
||||
- **`--clean --if-exists`.** Without `--clean` the dump's rows land *on top of*
|
||||
what is already there and you get primary-key collisions half way through, a
|
||||
partially restored database, and a non-zero exit you may not notice.
|
||||
`--if-exists` only suppresses the "does not exist" noise when the target is
|
||||
already empty; it is not the part doing the work.
|
||||
- **`-T` again.** Feeding a custom-format archive into a TTY-allocated `exec`
|
||||
corrupts it in flight and `pg_restore` fails with a garbled-header error on a
|
||||
file that is perfectly fine on disk.
|
||||
- **Stop the API, not Postgres.** `$COMPOSE stop` (everything) leaves you with
|
||||
nothing to restore into; leaving `bookmark-api` running leaves connections
|
||||
that block the drops *and* lets the poller write into a half-restored table.
|
||||
|
||||
No ownership fixing is needed any more — the Postgres image owns `postgres-data`
|
||||
itself and `pg_restore` writes through the server, not the filesystem.
|
||||
`schema_migrations` is inside the dump, so the database comes back at whatever
|
||||
schema version the backup was taken at; the migration runner applies anything
|
||||
newer the next time `bookmark-api` starts.
|
||||
|
||||
---
|
||||
|
||||
@@ -305,21 +355,24 @@ For a routine redeploy where nothing needs deciding:
|
||||
cd /opt/bookmarkmanager
|
||||
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||||
BACKUP_DIR="$(cd .. && pwd)/bookmarkmanager-backups"; mkdir -p "$BACKUP_DIR"
|
||||
VOL=$(docker volume ls --filter name=bookmarks-data -q | head -1)
|
||||
STAMP=$(date -u +%Y%m%d-%H%M%S)
|
||||
|
||||
docker run --rm -v "$VOL":/data -v "$BACKUP_DIR":/backup alpine sh -c \
|
||||
"apk add -q sqlite && sqlite3 /data/bookmarks.db \"VACUUM INTO '/backup/bookmarks-$STAMP.db'\" &&
|
||||
sqlite3 /backup/bookmarks-$STAMP.db 'PRAGMA integrity_check;'" &&
|
||||
$COMPOSE exec -T postgres pg_dump -U bookmarks -d bookmarks -Fc \
|
||||
> "$BACKUP_DIR/bookmarks-$STAMP.dump" &&
|
||||
docker run --rm -v "$BACKUP_DIR":/backup postgres:17-alpine \
|
||||
pg_restore --list "/backup/bookmarks-$STAMP.dump" > /dev/null &&
|
||||
git pull --ff-only &&
|
||||
$COMPOSE up -d --build &&
|
||||
sleep 5 &&
|
||||
curl -sf https://bookmark-api.violetcrown.my.id/healthz && echo " deploy ok"
|
||||
```
|
||||
|
||||
The `&&` chain is deliberate: if the backup or its integrity check fails,
|
||||
nothing is pulled and nothing is rebuilt. Then still do §5 by hand — no shell
|
||||
command can tell you the panel works on the phone.
|
||||
The `&&` chain is deliberate: if the dump or its `pg_restore --list` check
|
||||
fails, nothing is pulled and nothing is rebuilt. A failed dump still leaves a
|
||||
short or empty `.dump` behind — the shell creates the file before `pg_dump`
|
||||
runs — so delete it rather than letting it sit in the backup directory looking
|
||||
like a backup. Then still do §5 by hand — no shell command can tell you the
|
||||
panel works on the phone.
|
||||
|
||||
---
|
||||
|
||||
@@ -327,16 +380,18 @@ command can tell you the panel works on the phone.
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
|---|---|
|
||||
| `/bookmarks` returns `[]` after redeploy | Volume not attached — check `$COMPOSE config --volumes` and that you passed both `-f` files. Do **not** re-bookmark; the data is still in the volume. |
|
||||
| `/bookmarks` returns `[]` after redeploy | You are on an empty Postgres. Check `$COMPOSE config --volumes` lists `postgres-data`, that you passed both `-f` files, and that `.env` has no stray `DATABASE_URL` override. Do **not** re-bookmark; the data is still in the volume. |
|
||||
| UI looks like plain Georgia / system sans | `static/fonts/` missing from the image, or the browser cached an old `style.css`. `/static/*` is served `max-age=3600`, so hard-reload or wait an hour. |
|
||||
| CSS or template change did not appear | You restarted without `--build`. Assets are `//go:embed`ed. |
|
||||
| Font answers `application/octet-stream` | Old binary — the `.woff2` MIME registration is in `web.go`. Rebuild. |
|
||||
| Everyone logged out of the web UI | `API_TOKEN` or `WEB_PASSWORD` changed; sessions are derived from both. Expected, just log in again. |
|
||||
| `compose` errors about `BOOKMARK_WEB_HOST` | Run from the directory holding `.env`. Both host vars are required even when the web UI is unused. |
|
||||
| Userscript did not update on the phone | Violentmonkey polls on its own schedule; force a check. `@version` comes from the file's mtime, so confirm the pull actually touched it. |
|
||||
| `apk add sqlite` fails (no network) | Use the cold-copy fallback in §1 — and copy `bookmarks.db-wal` too. |
|
||||
| Reads work but every write fails after a restore | Restored file is root-owned; the container is uid 65532. `chown 65532:65532` it (§6). |
|
||||
| Backup command: `unable to open database file` | Source volume mounted `:ro`. WAL needs to create `-shm`; mount it read-write (§1). |
|
||||
| `bookmark-api` crash-loops, log says `password authentication failed for user "bookmarks"` | `POSTGRES_PASSWORD` in `.env` no longer matches the one burned into `postgres-data` at first init — Postgres reads that variable only when initialising an empty volume. Put the old value back, or reset the role: `$COMPOSE exec postgres psql -U bookmarks -d bookmarks -c '\password bookmarks'` (prompts, so nothing lands in shell history) and then match `.env` to it. |
|
||||
| `compose` errors `set POSTGRES_PASSWORD in .env` | Unset. Compose builds the backend's `DATABASE_URL` out of it, so it is required even though you never write that URL yourself. Run from the directory holding `.env`. |
|
||||
| `postgres` never leaves `starting`; `bookmark-api` never starts either | The healthcheck (`pg_isready`) is failing and `bookmark-api` waits on it. `$COMPOSE logs postgres` — usually `postgres-data` was initialised by a different major version ("database files are incompatible with server"), or the disk is full. |
|
||||
| `pg_restore`: `cannot drop … other objects depend on it` / `being accessed by other users` | Live connections block `--clean`. `$COMPOSE stop bookmark-api` first (§6). If they persist: `$COMPOSE exec -T postgres psql -U bookmarks -d postgres -c "select pg_terminate_backend(pid) from pg_stat_activity where datname='bookmarks' and pid <> pg_backend_pid()"`. |
|
||||
| Dump is 0 bytes, or `pg_restore`: `did not find magic string in file header` | You ran `exec` without `-T`. The allocated TTY rewrites newlines in the binary stream and corrupts the archive in flight (§1). |
|
||||
|
||||
Full first-time setup: `DEPLOY.md`. Config reference and endpoints: `README.md`.
|
||||
UI conventions: `docs/design-system.md`.
|
||||
|
||||
Reference in New Issue
Block a user