The publisher page called getPublisherProfile() with no try/catch. When D1
throws — a missing table from an unapplied migration, or an unavailable
binding — the exception escaped the frontmatter and Cloudflare returned an
unhandled 500. Reproduced locally by dropping the publishers table:
D1_ERROR: no such table: publishers: SQLITE_ERROR
GET /publisher/clausvalca -> 500 (GET / stayed 200)
The route was already null-safe, but a thrown error never reaches the null
check. Production returned 404 rather than 500 because its catalog is empty,
so the empty-data path masked the crash.
loadPublisherProfile() now returns a tagged result — ok / not-found / error —
instead of collapsing every failure to null, so callers can tell "no such
publisher" (404) from "the database failed" (503). The page renders a
"Publisher unavailable" fallback with a retry instead of crashing.
The same unguarded read pattern is fixed in the other routes that had it:
dashboard/publisher/[id], dashboard/settings, api/publishers/[id] (GET and
PATCH), and api/user/profile. This matches the convention already used by
index.astro, dashboard.astro, plugins.json.ts and download/[id].ts.
README notes that migrations are not applied by CI, so a schema change needs a
manual `npm run db:migrate:remote` — the state that produced this bug.
Tests: 45 passing (4 new, covering the error / not-found / success paths).
What changed
/publisher/<id>returned an unhandled HTTP 500. The page calledgetPublisherProfile()with notry/catch, so when D1 threw, the exception escaped the frontmatter and Cloudflare returned a 500./stayed 200, which pointed at this route specifically.Root cause
Reproduced locally by dropping the
publisherstable (the state an unapplied migration leaves behind):Two things hid it:
if (!profile) return 404), but a thrown error never reaches that check — only a returnednulldoes.The underlying operational gap: nothing in
.github/workflows/applies migrations to remote D1, so a migration can be recorded as applied while its table is missing.The fix
loadPublisherProfile()returns a tagged result instead of collapsing every failure tonull:Callers now distinguish the two failure modes and render 200 / 404 / 503.
The same unguarded-read pattern is fixed in the other routes that had it:
dashboard/publisher/[id],dashboard/settings,api/publishers/[id](GET and PATCH), andapi/user/profile. This matches the convention already used byindex.astro,dashboard.astro,plugins.json.ts, anddownload/[id].ts.Follow-up commit: resolve the binding inside the guard
CodeRabbit caught a real gap in the first commit.
getDB(locals)throws when theDBbinding is missing, and it was being evaluated as an argument — beforeloadPublisherProfileentered itstry. So a missing binding still produced a 500, and the comment claiming otherwise was wrong.Verified by commenting out
[[d1_databases]]and rebuilding:/publisher/<id>and/api/publishers/<id>both returned 500.loadPublisherProfilenow also accepts a getter, so resolving the binding happens inside the guarded region:The two PATCH handlers move
getDBinto their existingtry. A plain binding is still accepted, so this only widens the guarded region.Verification
Every row below was exercised against
wrangler pages devwith the built worker, in all three DB states (healthy, table dropped, binding removed):publisherstable missing (page)publisherstable missing (API)DBbinding missing (page)DBbinding missing (API)The page shows a real fallback — "Publisher unavailable" with a retry on a server error, "Publisher not found" otherwise.
npm run build— cleannpx tsc --noEmit—src/clean (only the pre-existingastro.config.mjsvite-plugin error remains)npm test— 47 passing (6 new: error / not-found / success / empty-profile / throwing-getter / plain-binding)Follow-up required (not in this PR)
This stops the 500 but does not restore the feature on production: the remote
publisherstable is genuinely missing, so the page will now render the 503 fallback. To actually fix the live site:npm run db:migrate:remote npm run db:seed:remote # if the catalog is emptyI could not run this —
wranglerneedsCLOUDFLARE_API_TOKENfor remote access, which isn't available in this environment. The README now documents that migrations are not applied by CI, so the next schema change does not hit the same trap.This PR was created by an AI agent (OpenHands) on behalf of the user.
Summary by CodeRabbit