EmDash 0.29 on PostgreSQL: Every Post 404s, Blog Empty, Admin Broken — Root Cause and Fix

TL;DR — After upgrading EmDash from 0.21 to 0.29 on a Node.js + PostgreSQL deployment, every blog post returned 404, the blog index showed "No posts published yet", the posts sitemap returned HTTP 500, and the admin dashboard failed with "Could not load dashboard data". No data was lost. The root cause is a dialect-detection bug in EmDash's published bundle: PostgreSQL gets misdetected as SQLite, so every generated query uses SQLite syntax against a Postgres database. The fix is a one-line patch applied with patch-package. This is the full incident write-up — symptoms, diagnosis, root cause, fix, and two bonus gotchas — so you don't lose an afternoon to the same bug.
Table of Contents
- The setup
- Symptoms
- Diagnosis: reproduce locally, read the real error
- Root cause: the bundler renamed a class the runtime matches by name
- The fix: one line via patch-package
- Bonus gotchas from the same session
- Plugin routes are admin-only unless you say otherwise
- GET query params never reach your route's input validation
- Lessons learned
- Affected versions
The setup
- EmDash upgraded 0.21.0 → 0.29.0
- Astro output: "server" with the @astrojs/node standalone adapter
Database: PostgreSQL via postgres({ ... }) from emdash/db- Deployed with Docker (multi-stage build, npm ci → astro build)
If you run EmDash on SQLite, libSQL/Turso, or Cloudflare D1, this bug does not affect you — which is probably why it shipped. It only bites Postgres deployments, and only through the published npm bundle. If you're planning a move to EmDash, our WordPress to EmDash migration guide covers the happy path; this article covers the day it goes wrong.
Symptoms
- Every post URL returned 404 — even posts clearly listed in the sitemap.
- The blog index rendered its empty state ("No posts published yet") instead of erroring — the query "succeeded" with zero rows as far as the template could tell.
- The posts sitemap returned HTTP 500.
- The admin dashboard loaded its shell but showed "Could not load dashboard data — Refresh the page or try again."
- Static/marketing pages that don't query the CMS rendered normally, which made the site look eerily half-alive.
The scary interpretation is "the upgrade wiped my content". It didn't. Before anything else, verify:
SELECT status, COUNT(*) FROM ec_posts GROUP BY status;
-- status | count
-- published | 85 <- all still thereIf your counts look right, take a breath. This is a query bug, not data loss.
Diagnosis: reproduce locally, read the real error
Build and run the production server locally against the same database, then hit an affected route:
npm run build
node ./dist/server/entry.mjs
# then: curl http://127.0.0.1:4321/blogTwo errors appear immediately:
[emdash] runtime init failed (page renders without CMS data):
Error: Migration failed: function datetime(unknown) does not exist
(migration: 044_comment_reactions)
Failed to load posts: Error: Failed to load collection:
syntax error at or near "ON"Both errors are the same disease with two faces: datetime('now') is a SQLite function that PostgreSQL doesn't have (hence the migration explodes), and the collection loader is likewise generating SQLite-flavored SQL that Postgres refuses to parse. So EmDash thinks it's talking to SQLite. Why?
Root cause: the bundler renamed a class the runtime matches by name
EmDash detects the active database dialect like this (dist/dialect-helpers-*.mjs):
/** Detect dialect type from a Kysely instance via the adapter class name. */
function detectDialect(db) {
if (db.getExecutor().adapter.constructor.name === "PostgresAdapter") return "postgres";
return "sqlite"; // <- the fallback
}Every dialect helper — currentTimestamp(), jsonExtractExpr(), buildStatusCondition() — branches on this. If detection fails, everything falls back to SQLite SQL. Now look at what actually ships in the npm package (dist/database/pg-migration-lock.mjs):
import { PostgresAdapter, PostgresDialect, sql } from "kysely";
/**
* Extends the stock adapter ... and keeps the class NAME `PostgresAdapter`
* because `detectDialect()` identifies the dialect via
* `adapter.constructor.name` — a differently-named adapter would make
* every dialect helper fall back to SQLite SQL against a Postgres database.
*/
var PostgresAdapter$1 = class extends PostgresAdapter {
async acquireMigrationLock(db, _opt) { /* ... */ }
};
var FailFastPostgresDialect = class extends PostgresDialect {
createAdapter() {
return new PostgresAdapter$1();
}
};The comment literally documents the invariant — and the line right below it violates it. In the TypeScript source the subclass is named PostgresAdapter, but the bundler renamed the binding to PostgresAdapter$1 to avoid colliding with the kysely import. Because var X = class extends Y {} is a class expression, JavaScript infers the class's .name from the variable binding:
const d = new FailFastPostgresDialect({ pool: {} });
console.log(d.createAdapter().constructor.name);
// -> "PostgresAdapter$1" (needs to be "PostgresAdapter")"PostgresAdapter$1" !== "PostgresAdapter" → detectDialect() returns "sqlite" → every query and every new migration is emitted in SQLite dialect → posts 404, blog empty, sitemap 500, dashboard dead. One renamed identifier, whole site down.
The fix: one line via patch-package
Until it's fixed upstream, pin the class name back.
1. Edit node_modules/emdash/dist/database/pg-migration-lock.mjs and add one line right before FailFastPostgresDialect:
Object.defineProperty(PostgresAdapter$1, "name", { value: "PostgresAdapter" });2. Verify the fix takes:
node -e "import('./node_modules/emdash/dist/database/pg-migration-lock.mjs').then(m => {
const d = new m.FailFastPostgresDialect({ pool: {} });
console.log(d.createAdapter().constructor.name); // -> PostgresAdapter
})"3. Record it as a patch and wire it into every install:
npm install -D patch-package
npx patch-package emdash// package.json
"scripts": {
"postinstall": "patch-package"
}This produces patches/emdash+0.29.0.patch — commit it. Every future npm install re-applies it automatically.
4. Docker users — this step matters. If your Dockerfile installs dependencies before copying the repo (the common cache-friendly layout), patches/ doesn't exist yet when postinstall runs, patch-package silently finds nothing, and production still ships the broken bundle while your local build works. Copy the patches in first:
COPY package.json package-lock.json ./
# patches/ must exist before npm ci — postinstall runs patch-package
COPY patches ./patches
RUN npm ci5. Rebuild, run the built server against your real database, and verify the routes that were broken: the blog lists posts again, individual posts render, the sitemap returns 200, and the admin dashboard loads. The previously failing migration (044_comment_reactions) also applies cleanly on the next boot, because it now generates Postgres SQL.
Bonus gotchas from the same session
Plugin routes are admin-only unless you say otherwise
If you register custom plugin routes and call them from the public site, anonymous requests get 401. Routes need an explicit public: true:
routes: {
counts: {
public: true, // <- without this: 401 for visitors
handler: async (ctx) => { /* ... */ },
},
},GET query params never reach your route's input validation
The docs say GET/DELETE input comes from query parameters, but the 0.29 route dispatcher only parses JSON bodies. A GET route with a zod input schema always fails with 400, because the input is forever undefined. Workaround — drop the input schema on GET routes and parse the URL yourself:
counts: {
public: true,
handler: async (ctx) => {
const slug = new URL(ctx.request.url).searchParams.get("slug") ?? "";
if (!slugSchema.safeParse(slug).success) {
throw new Response(JSON.stringify({ error: "Invalid slug" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// ...
},
},Lessons learned
- A green build is not a working deploy. astro build completed with zero errors — the bug only exists at runtime, against a real Postgres connection. For CMS or database upgrades, run the built server locally against a production-shaped database and click through the critical routes before promoting. If you keep a separate staging site, promote through it — see our guide on setting up a staging environment practices for EmDash sites.
- Don't panic-restore backups on "missing" content. Thirty seconds of SELECT COUNT(*) saved us from a pointless restore. Distinguish data gone from queries failing.
- Name-based type detection is fragile by construction. constructor.name breaks under bundlers and minifiers. If you maintain a library, prefer an explicit brand property or instanceof.
- Patches must travel with the deploy. A patch that applies on your laptop but not inside the Docker build is worse than no patch — it looks fixed. Check the layer order.
Site performance also recovers instantly once queries work again — if you're tuning further, start with Core Web Vitals and CMS performance.
Affected versions
Observed on emdash@0.29.0 (Node adapter + PostgreSQL). 0.21.0 is unaffected (the fail-fast Postgres dialect wrapper didn't exist yet). SQLite/libSQL/D1 deployments are unaffected on any version. If you're reading this after a newer release, check whether dist/database/pg-migration-lock.mjs still assigns the adapter subclass to a renamed binding — if the variable is no longer PostgresAdapter$1, or detection no longer uses constructor.name, you can drop the patch.




Comments