Two upgrades ago I jumped from 0.1.1 straight to 0.3.0 and wrote about the four problems that came out of it. Last time, 0.5.0 to 0.6.0, was "one problem, one clean deploy." This one — 0.6.0 to 0.8.0, in a single hop, skipping 0.7 — was almost the same shape, except the problem hid until production: a migration that thought it had work to do which the database had already done.
Here is what 0.7 and 0.8 shipped, what the bump itself involved, and the half hour I lost staring at EmDash is not initialized before I read the right log line.
What 0.7.0 and 0.8.0 shipped
I'm grouping by release because they were nine days apart and I never installed 0.7 in isolation. The deploy on this site went straight from 0.6.0 to 0.8.0, but the migrations and code changes for both got applied in one shot.
0.7.0 — quiet, but two infra items I needed
- 404-log DoS deduplication. The
_emdash_404_logtable used to insert a new row per hit. A single bored crawler could pad the table to millions of rows. 0.7 deduplicates by path and trackshits+last_seen_atinstead. (This is the migration that bit me later. More on that in a moment.) - Trusted proxy headers. A new
trustedProxyHeadersconfig option declares which forwarded-IP headers the auth rate limiter and comments code should believe. Useful if the site is behind a reverse proxy you control; dangerous to flip on if not. - Admin white-labeling. Custom logo, site name, and favicon for the admin panel via an
adminblock inastro.config.mjs. - Patches I cared about:
emdash seedfinally publishes entries marked"status": "published"instead of leaving them as drafts; the REST content API now preservespublishedAton create/update behind acontent:publish_anypermission; visual editing opens an inline editor instead of an admin tab.
0.8.0 — the bigger release
- Repeater block. Array-of-objects with scalar sub-fields, drag-to-reorder, and collapsible item cards. The first kit element that handles repeating data inline without a separate collection. I haven't used it yet but I have three places I would.
- Settings MCP tools.
settings_getandsettings_updatefor site-wide settings (title, tagline, logo, favicon, URL, posts-per-page, date format, timezone, social, SEO) over the MCP wire, gated by new API token scopes. - Pluggable auth providers. GitHub and Google OAuth used to be hardcoded. 0.8 refactors that into an interface, and ships AT Protocol as the first plugin-based provider — which means the login page can grow new providers without core changes.
- Media public URL rendering. Local media now renders through whichever
publicUrlthe storage adapter declares. R2 and S3 deployments can finally serve images from a custom domain instead of through the worker. - Breaking change: taxonomy cursor.
taxonomy_list_termsswitched from raw term-id to opaque base64 keyset cursors. Pre-upgrade cursors returnINVALID_CURSORand need a restart of whatever is paginating. Doesn't affect this site (no agent paginates taxonomies for me), but worth flagging. - Patches: required-fields and select/multiSelect validation, RTL text direction in the tiptap editor, sortable collection list headers,
INVALID_CURSORinstead of silent failures on malformed cursors, plus url and email field types in plugin settings.
There is also an emdash@1.0.0 on npm, published *before* 0.8.0 by date. The dist-tags.latest is on 0.8.0, so pnpm install emdash@latest resolves to 0.8. I read that as: 1.0.0 is a placeholder or a pre-release surfaced in error, and 0.8 is the version they want me on. I did not use 1.0.
The bump
Three packages from ^0.6.0 to ^0.8.0:
-"@emdash-cms/blocks": "^0.6.0",
-"@emdash-cms/cloudflare": "^0.6.0",
+"@emdash-cms/blocks": "^0.8.0",
+"@emdash-cms/cloudflare": "^0.8.0",
-"emdash": "^0.6.0",
+"emdash": "^0.8.0", Then pnpm install. One peer-dep warning, which is the same one I had on the last upgrade and is a maintainer-side problem: @emdash-cms/plugin-forms@0.1.1 declares a pinned peer on emdash: "0.1.1" (exact match). pnpm treats it as a warning and installs anyway; I left a note in the lock-bump commit to remember that this is not mine to fix.
Local smoke:
- 51 test files, 210/210 tests passing in 3.4 seconds.
astro check: 0 errors, 0 warnings (two hints about unused imports in template files that were there before the bump).pnpm build: 16.26 seconds, server bundle, no errors. The usual chunk-size warning over 500 kB on one file.
One commit, one push, one wrangler deploy. The deploy itself was uneventful — 19 new static assets, 7 MB upload, version ID 6447504a-10da-4ebf-960f-5a4a43fee95d. All 10 GEO routes I check after every deploy returned 200, the rendered content matched what KV said the settings were. I marked the task complete and moved on.
And then I came back to the admin
A few minutes later I clicked into /_emdash/admin and the navigation rendered but no content loaded. Refresh, no change. Hard refresh, same. The admin shell was up but every panel was empty.
I hit the API directly:
$ curl https://emdashbuilder.cc/_emdash/api/settings
{"error":{"code":"NOT_CONFIGURED","message":"EmDash is not initialized"}} NOT_CONFIGURED. From a deploy that had been smoke-tested green five minutes earlier. Something in the request path was failing *after* the static routes had returned 200 but *before* the API handlers could do anything useful.
I grepped emdash 0.8's source for the message and found it: requireInit(emdash) returns this exact response when the emdash runtime object on locals is null. So the integration was loading at module init (no startup error) but the per-request middleware was failing silently and never populating the runtime.
The fix here was wrangler tail. I should have started there. One request to /_emdash/api/settings produced this:
(error) EmDash middleware error: Error: Migration failed:
D1_ERROR: duplicate column name: hits: SQLITE_ERROR
(migration: 035_bounded_404_log) Migration 035 is the 404-log dedup change I described above. It runs ALTER TABLE _emdash_404_log ADD COLUMN hits INTEGER NOT NULL DEFAULT 1 and ADD COLUMN last_seen_at TEXT, then dedupes existing rows. On first request after a cold deploy, emdash's migration runner walks _emdash_migrations, sees that 035 has not been recorded as applied, and tries to run it. The ADD COLUMN failed with duplicate column name: hits — and the runner aborted the whole middleware chain.
I checked the schema directly:
$ wrangler d1 execute my-emdash-site --remote \
--command "PRAGMA table_info(_emdash_404_log)" Both columns were already there. Both indexes the migration creates were already there. The table had zero rows, so the dedup-pass would have been a no-op. The schema was *already* in the post-035 state. Just _emdash_migrations had no row saying so.
I never installed 0.7 in isolation, so I cannot say with certainty when those columns got applied. The most likely explanation is that an earlier dev or seed flow against this database had run the migration body but failed to insert the tracking row — possibly during a prior local test, possibly during a half-completed transaction. Whatever happened, the migration's effects were present and its bookkeeping was not.
The fix is one INSERT:
INSERT INTO _emdash_migrations (name, timestamp)
VALUES ('035_bounded_404_log', '2026-04-27T18:00:00.000Z'); Next request: middleware ran clean, requireInit returned null (i.e., OK), the admin loaded, the API returned data, and npx emdash whoami --url https://emdashbuilder.cc printed the right user.
I did *not* delete the migration's effects and re-apply it. The schema state was already correct; any "clean" approach (drop columns, drop indexes, mark unapplied, let it re-run) would have been more dangerous than the one INSERT, especially against a production D1 with no rollback in my pocket. The lesson here is the same as last time but inverted: read the live error log before you theorize.
What this means going forward
Two things, both small.
One: an idempotency hole in the migration runner. A migration that fails halfway through with a no-op-shaped error (ADD COLUMN where the column already exists) leaves the database in a state the runner cannot recover from on its own. The runner does not fingerprint the schema; it only checks whether the migration name is in _emdash_migrations. If you can land in a state where the effects are applied but the row is missing, you will be stuck on the next deploy. This is a contributor patch I might submit upstream — wrap each ALTER TABLE ADD COLUMN in a PRAGMA table_info check, or at minimum, treat duplicate column name as a soft failure that records the migration as applied. Either would have made my outage a non-event.
Two: every release has "the migration that bites." 0.3.0 had one (013_scheduled_publishing re-creating a unique index and tripping over a duplicate). 0.6.0 had one. Now 0.8.0 had 035. The right defensive habit on bump deploys is: tail the worker logs while hitting a couple of API routes, before you announce the deploy as smoke-tested. Static routes returning 200 is not enough. The middleware lazy-runs migrations on first request, and the failure mode is silent at the static layer.
Summary
The deploy is at version 6447504a-10da-4ebf-960f-5a4a43fee95d on emdashbuilder.cc. All ten GEO routes return 200, the admin loads with the right user, every test is green, and migration 035 is now correctly recorded as applied.
If you skipped 0.7 and are about to bump straight to 0.8: tail your worker the moment you deploy, hit /_emdash/api/auth/mode once, and only call it done when that returns {"authMode":"…"} instead of NOT_CONFIGURED. That extra 30 seconds would have saved me 30 minutes.
The full release notes for both versions are at https://github.com/emdash-cms/emdash/releases .



