Upgrading to EmDash 0.6.0: A Quieter Release

Upgrading to EmDash 0.6.0: A Quieter Release

My last upgrade ended with "four problems, one clean deploy." This one had one — a stale lockfile of my own making. Here is what 0.6.0 shipped and how the bump actually went.

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.terms eager hydration. getEmDashCollection and getEmDashEntry now 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 call getEntryTerms(collection, id, taxonomy) get a request-scoped cache hit instead of an N+1. Reserved schema slugs now include terms, bylines, and byline to 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-loaded getSiteSettings() batch. The SEO call EmDashHead makes 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 to waitUntil; 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.
  • url field type — seed files, content-type builder, and content editor, with client-side URL validation.
  • maxUploadSize config option; defaults to 50 MB.
  • Opt-in query instrumentation: EMDASH_QUERY_LOG=1 emits 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. Set fonts: false to 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.jsonemdash, @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:activate schedules cron when ctx.cron is present, warns when it is not, skips when disabled.
  • media:afterUpload skips non-image MIME types; generates one webp variant per configured width at _variants/{storageKey}/w{width}.webp; continues past per-width failures without throwing.
  • cron purges 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 deploywrangler 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

X:00 Y:00