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

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
  1. The setup
  2. Symptoms
  3. Diagnosis: reproduce locally, read the real error
  4. Root cause: the bundler renamed a class the runtime matches by name
  5. The fix: one line via patch-package
  6. Bonus gotchas from the same session
  7. Plugin routes are admin-only unless you say otherwise
  8. GET query params never reach your route's input validation
  9. Lessons learned
  10. 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

  1. Every post URL returned 404 — even posts clearly listed in the sitemap.
  2. 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.
  3. The posts sitemap returned HTTP 500.
  4. The admin dashboard loaded its shell but showed "Could not load dashboard data — Refresh the page or try again."
  5. 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 there
emdashkits.com

If your counts look right, take a breath. This is a query bug, not data loss.

Read also:

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/blog
emdashkits.com

Two 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"
emdashkits.com

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
}
emdashkits.com

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();
  }
};
emdashkits.com

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")
emdashkits.com

"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" });
emdashkits.com

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
})"
emdashkits.com

3. Record it as a patch and wire it into every install:

npm install -D patch-package
npx patch-package emdash
emdashkits.com
// package.json
"scripts": {
  "postinstall": "patch-package"
}
emdashkits.com

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 ci
emdashkits.com

5. 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) => { /* ... */ },
  },
},
emdashkits.com

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" },
      });
    }
    // ...
  },
},
emdashkits.com

Lessons learned

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Share

Comments

Write a comment

Related Articles

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

July 16, 2026

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

July 16, 2026

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

July 16, 2026

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

TL;DR — A custom session-cookie login flow appeared to succeed on localhost (the OTP verified, the response looked fine) but every subsequent request to a login-gated page treated the visitor as logged out. Identical code worked fine on the live HTTPS site. The cookie's Secure attribute was hardcoded to true — and per the cookie spec, browsers never store or send a Secure cookie over a plain, non-HTTPS connection.

Table of Contents
  1. Diagnostic
  2. Root cause
  3. Fix
  4. Lessons learned

Diagnostic

Check the actual Set-Cookie response header and the browser's own cookie storage panel — on localhost over http://, the cookie is sent by the server but never actually stored by the browser.

Root cause

// before -- assumes the app is always served over HTTPS
setCookie("session", token, { secure: true, httpOnly: true });
emdashkits.com

A cookie config that quietly assumes "we're always on HTTPS" breaks the instant you test over plain HTTP, which local dev servers commonly are.

Read also:

Fix

// after -- derive secure from the actual request protocol
const isHttps = request.url.startsWith("https://");
setCookie("session", token, { secure: isHttps, httpOnly: true });
emdashkits.com

Lessons learned

  • Any Secure-flagged cookie needs to key off the real request scheme, not an assumption baked in once at cookie-creation time.
  • "Works in production, silently fails in local dev" is a strong signal to check cookie flags before anything else in an auth flow.
  • Check other cookies in the same codebase for the same hardcoded assumption — if one cookie has this bug, sibling cookies set the same way are worth auditing too.
Share
Previous Article

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Comments

Write a comment

Related Articles

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

July 16, 2026

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

July 16, 2026

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)

July 16, 2026

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

TL;DR — GA4's gtag.js snippet was installed correctly, the page loaded with no visible JavaScript error, and GA4's real-time report still showed zero activity. The CMS's default Content-Security-Policy had no allowance for analytics domains and no config option to add one — so every request to Google's tracking endpoints was blocked at the browser level before it could fail loudly.

Table of Contents
  1. Diagnostic
  2. Root cause
  3. Lessons learned

Diagnostic

Check the browser's dedicated CSP violation reporting, not the regular console error list — CSP blocks are reported through their own channel, not thrown as normal script errors, so "no console errors" doesn't mean nothing was blocked.

Root cause

The CSP's script-src and connect-src directives had no entry for googletagmanager.com or google-analytics.com, and the CMS exposed no configuration surface to add one — the only way in was patching the CSP directives directly.

// patch-package: add analytics domains to the existing CSP directives
scriptSrc.push("https://www.googletagmanager.com");
connectSrc.push("https://www.google-analytics.com", "https://www.googletagmanager.com");
emdashkits.com
Read also:

Lessons learned

  • "No console errors" is not proof nothing was blocked — CSP violations live in their own reporting surface and are easy to miss if you're only scanning for red error text.
  • Before adding any third-party script tag to a site with a CSP already in place, check the CSP's directives first rather than assuming a silently-empty analytics dashboard means a snippet-installation mistake.
Share
Previous Article

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Next Article

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Comments

Write a comment

Related Articles

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

July 16, 2026

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

July 16, 2026

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)

July 16, 2026

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)