My last upgrade was a leap from 0.1.1 to 0.3.0, and the post that came out of it read like a matryoshka doll of peer-dep problems. This one — 0.5.0 to 0.6.0 — was quieter. One real snag, and it was my fault.
Here is what the release shipped, what the upgrade actually involved, and the small paper-cut I left behind.
What 0.6.0 shipped
Two minor changes and a generous stack of patches. The ones that matter to me, grouped by where they land:
On the hot path, for free
entry.data.termseager hydration.getEmDashCollectionandgetEmDashEntrynow return terms keyed by taxonomy name (entry.data.terms.tag,entry.data.terms.category) via a single batched JOIN alongside byline hydration. Templates that still loop and callgetEntryTerms(collection, id, taxonomy)get a request-scoped cache hit instead of an N+1. Reserved schema slugs now includeterms,bylines, andbylineto stop new fields from shadowing the hydrated values.- Widget areas fetch in one query instead of two. The "has any bylines / has any taxonomy terms" probes that fired on every render are gone — they were saving one query on empty sites at the cost of one query on every loaded site, which is the wrong tradeoff when the batch hydration already handles the empty case cheaply. On the fixture post-detail page the query count drops from 34 to 32 on SQLite and 43 to 39 on D1.
- Manifest caching — memory plus database — so cold starts read one cached row instead of rebuilding from scratch.
getSiteSetting(key)is now request-cached and, better, piggybacks on any already-loadedgetSiteSettings()batch. The SEO callEmDashHeadmakes on every page render costs zero extra queries when the base layout has already pulled settings.- Anonymous public-page traffic routes to D1 read replicas through a per-request scoped Kysely instead of the primary-pinned singleton. A subtle one, and the fix the notes understate: on the blog-demo homepage, cold requests from Asia colos reportedly dropped from several seconds to under one.
New building blocks
after(fn)for deferring bookkeeping past the HTTP response. On Cloudflare it hands off towaitUntil; on Node it fire-and-forgets. First internal consumer is cron stale-lock recovery, which used to block the response with a D1 primary-routed write.urlfield type — seed files, content-type builder, and content editor, with client-side URL validation.maxUploadSizeconfig option; defaults to 50 MB.- Opt-in query instrumentation:
EMDASH_QUERY_LOG=1emits NDJSON for every DB query, tagged with route and perf phase. Zero overhead when the flag is unset.
Admin UI
- Noto Sans is the default admin font, downloaded at build time and self-hosted via the Astro Font API. The base covers Latin, Cyrillic, Greek, Devanagari, Vietnamese; extra scripts (Arabic, CJK, Hebrew, Thai, etc.) are opt-in via
fonts.scripts. Setfonts: falseto fall back to system fonts. - Inline term creation in the post editor's taxonomy sidebar — tags get a "Create" option when nothing matches, categories an "Add new" button below the list.
- Boolean-field checkbox correctly reflects state after publish.
- Google and Bing verification meta tags actually emitted in
<head>now. They were editable in the admin but had never been rendered.
The single feature I cared about most is entry.data.terms. I have five pages that call getEntryTerms in a loop — post detail, posts index, home, category page, tag page. They are still correct on 0.6.0 because the same PR plumbs getEntryTerms into the request-scoped cache as a fallback, so existing loops become cache reads after the first hit. I will migrate them to the direct field in a later pass, when I want to touch those pages for other reasons.
Where this one actually hurt
One problem, and I introduced it myself.
The stale lockfile
I bumped four packages in package.json — emdash, @emdash-cms/blocks, @emdash-cms/cloudflare from 0.5.0 to 0.6.0 and @emdash-cms/plugin-forms from 0.1.0 to 0.1.1 — and reflex-typed npm install. Which failed:
npm error ERESOLVE unable to resolve dependency tree
npm error peer emdash@"0.1.1" from @emdash-cms/plugin-forms@0.1.1
npm error Found: emdash@0.6.0 @emdash-cms/plugin-forms still declares a pinned peer on emdash: "0.1.1" — exact match, not a range. The previous release had the same shape pinned to 0.1.0. npm refuses to install; pnpm treats it as a warning. The repo has a pnpm-lock.yaml. I should have typed pnpm install.
The real irritation was not the refusal — it was that the failed npm install had already written a fresh package-lock.json, and when I checked git status the thing was listed as *modified*, not untracked. It had been tracked in the repo since the npm era, sitting stale for who-knows-how-many installs, overwritten briefly by my accident.
Fix: delete the lockfile, run pnpm install (re-resolves cleanly, the usual peer warning and nothing else), commit the deletion in its own commit with an explanation.
Lockfile hygiene is the kind of thing you do not think about until something forces you to. When it does, it is worth doing the cleanup properly so the next person does not step on the same rake.
A transient vite noise
Once the dev server started, the first render to / logged a cluster of The file does not exist at … chunk-XXXX.js errors, followed by two [WARN] [emdash] Failed to hydrate terms / bylines lines. I read those for longer than I should have, because terms is the headline new thing and it looked like the hydration path was broken.
It was not. Vite's SSR dep optimizer was churning through newly-versioned modules and had invalidated chunks mid-render. Second request: clean. Third: clean. The errors were first-load artifacts, not 0.6.0 regressions — but they are loud enough that the next person upgrading past a pnpm install will also stare at them for a minute.
The tests I added
My emdash-tracking plugin has 128 tests; my emdash-image-optimizer had zero. Running on a new emdash version, I wanted coverage for at least the three hooks it declares — plugin:activate, media:afterUpload, and cron — before trusting that the descriptor contract had not drifted.
I wrote eleven. They stub the cloudflare:workers env through vi.mock, give the plugin a fake R2 bucket and Images binding (put, get, head, list, delete; an input → transform → output chain), and call the handlers directly:
- Descriptor defaults and overrides (widths,
purgeSchedule: null). plugin:activateschedules cron whenctx.cronis present, warns when it is not, skips when disabled.media:afterUploadskips non-image MIME types; generates one webp variant per configured width at_variants/{storageKey}/w{width}.webp; continues past per-width failures without throwing.cronpurges variants whose originals are gone and keeps the rest; ignores unrelated task names.
With those in place, the site suite is at 139/139, astro check is clean, and I know the plugin's external contract will fail loudly on any future emdash bump that changes the hook signatures. That is the point of this kind of test — not to catch my own bugs, but to catch the CMS's.
The deploy
pnpm run deploy → wrangler deploy, one go:
- Bundle: 6032 KiB (gzip 1278 KiB)
- 270 modules, 22 assets (no changed asset uploads this run)
- Worker startup: 104 ms
- All six bindings resolving:
SESSION(KV),DB(D1),MEDIA(R2),IMAGES,ASSETS,LOADER
curl / → 200, the GA4 snippet injected by my tracking plugin is in the output, /_emdash/admin → 302 (redirect to login, as expected). Version ID 8bee6e66-b630-4b68-a394-6c1ba347c27e.
What I'd do differently
One thing, and it is the same mistake I keep making: read the lockfile before typing the package manager. If pnpm-lock.yaml exists in the repo, use pnpm. The muscle memory of reaching for npm is the muscle memory, and the symptoms of getting it wrong are low-grade — wasted seconds, a stale file to clean up, a slightly embarrassing commit. Worth a project-level nudge (a preinstall script that bails when npm_config_user_agent is not pnpm, probably) next time I am in here for anything else.
And a reading habit to reinforce: the 0.6.0 notes are dense in patch-level performance work — cold-start timing, D1 replica routing, manifest caching, query dedup — that individually look tiny and collectively reshape the latency floor of an EmDash site. "Patch" in a 0.x pre-1.0 release can mean "caching change that removes 30–100 ms per page"; it is not just bug fixes.
One problem. One clean deploy. One post.
Keep yourself updated on https://github.com/emdash-cms/emdash/releases



