diff --git a/.changeset/query-counts-harness.md b/.changeset/query-counts-harness.md
new file mode 100644
index 000000000..7abd5c3b3
--- /dev/null
+++ b/.changeset/query-counts-harness.md
@@ -0,0 +1,8 @@
+---
+"emdash": patch
+"@emdash-cms/cloudflare": patch
+---
+
+Adds opt-in query instrumentation for performance regression testing. Setting `EMDASH_QUERY_LOG=1` causes the Kysely log hook to emit `[emdash-query-log]`-prefixed NDJSON on stdout for every DB query executed inside a request, tagged with the route, method, and an `X-Perf-Phase` header value. Zero runtime overhead when the flag is unset — the log option is only attached to Kysely when enabled.
+
+Also exposes the helpers at `emdash/database/instrumentation` so first-party adapters (e.g. `@emdash-cms/cloudflare`) can wire the same hook into their per-request Kysely instances.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dd7282db8..430083a75 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -33,6 +33,24 @@ jobs:
- run: pnpm run --filter emdash-demo --filter @emdash-cms/demo-cloudflare typecheck
- run: pnpm typecheck:templates
+ query-counts:
+ name: Query Counts
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
+ - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version: 22
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm build
+ - run: node scripts/query-counts.mjs --target sqlite
+ - run: node scripts/query-counts.mjs --target d1
+
lint:
name: Lint
runs-on: ubuntu-latest
diff --git a/.gitignore b/.gitignore
index 1718de2d7..3a19abc94 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,3 +67,5 @@ __screenshots__/
# Downloaded test data (fetched on demand in CI)
examples/wp-theme-unit-test/
.opencode
+
+.perf-query-counts
diff --git a/fixtures/perf-site/astro.config.mjs b/fixtures/perf-site/astro.config.mjs
new file mode 100644
index 000000000..f4415f6e3
--- /dev/null
+++ b/fixtures/perf-site/astro.config.mjs
@@ -0,0 +1,34 @@
+import cloudflare from "@astrojs/cloudflare";
+import node from "@astrojs/node";
+import react from "@astrojs/react";
+import { d1, r2 } from "@emdash-cms/cloudflare";
+import { defineConfig } from "astro/config";
+import emdash, { local } from "emdash/astro";
+import { sqlite } from "emdash/db";
+
+const target = process.env.EMDASH_FIXTURE_TARGET ?? "sqlite";
+
+const sqliteIntegration = emdash({
+ database: sqlite({ url: "file:./data.db" }),
+ storage: local({
+ directory: "./uploads",
+ baseUrl: "/_emdash/api/media/file",
+ }),
+});
+
+const d1Integration = emdash({
+ database: d1({ binding: "DB", session: "auto" }),
+ storage: r2({ binding: "MEDIA" }),
+});
+
+export default defineConfig({
+ output: "server",
+ adapter:
+ target === "d1"
+ ? cloudflare()
+ : node({
+ mode: "standalone",
+ }),
+ integrations: [react(), target === "d1" ? d1Integration : sqliteIntegration],
+ devToolbar: { enabled: false },
+});
diff --git a/fixtures/perf-site/emdash-env.d.ts b/fixtures/perf-site/emdash-env.d.ts
new file mode 100644
index 000000000..abb26262f
--- /dev/null
+++ b/fixtures/perf-site/emdash-env.d.ts
@@ -0,0 +1,39 @@
+// Generated by EmDash on dev server start
+// Do not edit manually
+
+///
+
+import type { ContentBylineCredit, PortableTextBlock } from "emdash";
+
+export interface Page {
+ id: string;
+ slug: string | null;
+ status: string;
+ title: string;
+ content?: PortableTextBlock[];
+ createdAt: Date;
+ updatedAt: Date;
+ publishedAt: Date | null;
+ bylines?: ContentBylineCredit[];
+}
+
+export interface Post {
+ id: string;
+ slug: string | null;
+ status: string;
+ title: string;
+ featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number };
+ content?: PortableTextBlock[];
+ excerpt?: string;
+ createdAt: Date;
+ updatedAt: Date;
+ publishedAt: Date | null;
+ bylines?: ContentBylineCredit[];
+}
+
+declare module "emdash" {
+ interface EmDashCollections {
+ pages: Page;
+ posts: Post;
+ }
+}
\ No newline at end of file
diff --git a/fixtures/perf-site/package.json b/fixtures/perf-site/package.json
new file mode 100644
index 000000000..a9965406d
--- /dev/null
+++ b/fixtures/perf-site/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@emdash-cms/fixture-perf-site",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "description": "Fixture site for query-count perf snapshots. Runs under sqlite+node or d1+cloudflare based on EMDASH_FIXTURE_TARGET.",
+ "emdash": {
+ "seed": "seed/seed.json"
+ },
+ "scripts": {
+ "dev": "astro dev",
+ "build": "astro build",
+ "bootstrap": "emdash init && emdash seed",
+ "typecheck": "astro check"
+ },
+ "dependencies": {
+ "@astrojs/cloudflare": "catalog:",
+ "@astrojs/node": "catalog:",
+ "@astrojs/react": "catalog:",
+ "@emdash-cms/cloudflare": "workspace:*",
+ "astro": "catalog:",
+ "better-sqlite3": "catalog:",
+ "emdash": "workspace:*",
+ "kysely": "^0.27.0",
+ "react": "catalog:",
+ "react-dom": "catalog:"
+ },
+ "devDependencies": {
+ "@astrojs/check": "catalog:",
+ "@cloudflare/workers-types": "catalog:",
+ "wrangler": "catalog:"
+ },
+ "pnpm": {
+ "onlyBuiltDependencies": [
+ "better-sqlite3",
+ "esbuild",
+ "workerd"
+ ]
+ }
+}
diff --git a/fixtures/perf-site/seed/seed.json b/fixtures/perf-site/seed/seed.json
new file mode 100644
index 000000000..b6c7845bc
--- /dev/null
+++ b/fixtures/perf-site/seed/seed.json
@@ -0,0 +1,778 @@
+{
+ "$schema": "https://emdashcms.com/seed.schema.json",
+ "version": "1",
+ "meta": {
+ "name": "Blog Starter",
+ "description": "A blog with posts and pages",
+ "author": "EmDash"
+ },
+
+ "settings": {
+ "title": "My Blog",
+ "tagline": "Thoughts on building for the web"
+ },
+
+ "collections": [
+ {
+ "slug": "posts",
+ "label": "Posts",
+ "labelSingular": "Post",
+ "supports": ["drafts", "revisions", "search", "seo"],
+ "commentsEnabled": true,
+ "fields": [
+ {
+ "slug": "title",
+ "label": "Title",
+ "type": "string",
+ "required": true,
+ "searchable": true
+ },
+ {
+ "slug": "featured_image",
+ "label": "Featured Image",
+ "type": "image"
+ },
+ {
+ "slug": "content",
+ "label": "Content",
+ "type": "portableText",
+ "searchable": true
+ },
+ {
+ "slug": "excerpt",
+ "label": "Excerpt",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "slug": "pages",
+ "label": "Pages",
+ "labelSingular": "Page",
+ "supports": ["drafts", "revisions", "search"],
+ "fields": [
+ {
+ "slug": "title",
+ "label": "Title",
+ "type": "string",
+ "required": true,
+ "searchable": true
+ },
+ {
+ "slug": "content",
+ "label": "Content",
+ "type": "portableText",
+ "searchable": true
+ }
+ ]
+ }
+ ],
+
+ "taxonomies": [
+ {
+ "name": "category",
+ "label": "Categories",
+ "labelSingular": "Category",
+ "hierarchical": true,
+ "collections": ["posts"],
+ "terms": [
+ { "slug": "development", "label": "Development" },
+ { "slug": "design", "label": "Design" },
+ { "slug": "notes", "label": "Notes" }
+ ]
+ },
+ {
+ "name": "tag",
+ "label": "Tags",
+ "labelSingular": "Tag",
+ "hierarchical": false,
+ "collections": ["posts"],
+ "terms": [
+ { "slug": "webdev", "label": "Web Development" },
+ { "slug": "opinion", "label": "Opinion" },
+ { "slug": "tools", "label": "Tools" },
+ { "slug": "creativity", "label": "Creativity" }
+ ]
+ }
+ ],
+
+ "bylines": [
+ {
+ "id": "byline-editorial",
+ "slug": "emdash-editorial",
+ "displayName": "EmDash Editorial"
+ },
+ {
+ "id": "byline-guest",
+ "slug": "guest-contributor",
+ "displayName": "Guest Contributor",
+ "isGuest": true
+ }
+ ],
+
+ "menus": [
+ {
+ "name": "primary",
+ "label": "Primary Navigation",
+ "items": [
+ { "type": "custom", "label": "Home", "url": "/" },
+ { "type": "custom", "label": "About", "url": "/pages/about" },
+ { "type": "custom", "label": "Posts", "url": "/posts" }
+ ]
+ }
+ ],
+
+ "widgetAreas": [
+ {
+ "name": "sidebar",
+ "label": "Sidebar",
+ "description": "Widget area displayed on single post pages",
+ "widgets": [
+ {
+ "type": "component",
+ "componentId": "core:search",
+ "title": "Search"
+ },
+ {
+ "type": "component",
+ "componentId": "core:categories",
+ "title": "Categories"
+ },
+ {
+ "type": "component",
+ "componentId": "core:tags",
+ "title": "Tags"
+ },
+ {
+ "type": "component",
+ "componentId": "core:recent-posts",
+ "title": "Recent Posts",
+ "settings": {
+ "count": 5,
+ "showDate": true
+ }
+ },
+ {
+ "type": "component",
+ "componentId": "core:archives",
+ "title": "Archives",
+ "settings": {
+ "type": "monthly",
+ "limit": 6
+ }
+ }
+ ]
+ },
+ {
+ "name": "footer",
+ "label": "Footer",
+ "description": "Widget area displayed in the site footer",
+ "widgets": [
+ {
+ "type": "content",
+ "title": "About",
+ "content": [
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "A blog about software, design, and the occasional stray thought."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+
+ "sections": [
+ {
+ "slug": "newsletter-signup",
+ "title": "Newsletter Signup",
+ "description": "A call-to-action block for newsletter subscriptions",
+ "keywords": ["newsletter", "subscribe", "email", "cta"],
+ "source": "theme",
+ "content": [
+ {
+ "_type": "block",
+ "style": "h3",
+ "children": [{ "_type": "span", "text": "Stay in the loop" }]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "Get notified when new posts are published. No spam, unsubscribe anytime."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "slug": "about-author",
+ "title": "About the Author",
+ "description": "Brief author bio for use in posts or pages",
+ "keywords": ["author", "bio", "about"],
+ "source": "theme",
+ "content": [
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "A software developer who writes about building things on the web. Based somewhere with good coffee and reliable internet."
+ }
+ ]
+ }
+ ]
+ }
+ ],
+
+ "content": {
+ "pages": [
+ {
+ "id": "about",
+ "slug": "about",
+ "status": "published",
+ "data": {
+ "title": "About",
+ "content": [
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "A place for writing about software, design, and the occasional stray thought. No posting schedule, no newsletter funnel. Just things I wanted to write down."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "Built with Astro and EmDash. The source is open if you want to see how it works."
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ],
+ "posts": [
+ {
+ "id": "post-1",
+ "slug": "building-for-the-long-term",
+ "status": "published",
+ "data": {
+ "title": "Building for the Long Term",
+ "excerpt": "The frameworks will change. The databases will change. What survives is the clarity of your thinking.",
+ "featured_image": {
+ "$media": {
+ "url": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=1200&h=800&fit=crop",
+ "alt": "Code on a monitor in a dark room",
+ "filename": "building-long-term.jpg"
+ }
+ },
+ "content": [
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "Every few years the industry collectively decides that everything we've been doing is wrong and there's a better way. New frameworks, new paradigms, new build tools. The churn is relentless, and if you're not careful, you spend more time migrating than building."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "I've been writing software long enough to have seen several of these cycles. jQuery to Backbone to Angular to React to whatever comes next. Each transition felt urgent at the time. Looking back, the things that actually mattered were rarely about the framework."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "h2",
+ "children": [{ "_type": "span", "text": "What survives" }]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "Clean data models survive. Clear boundaries between systems survive. Good naming survives. The decision to keep things simple when you could have made them clever - that definitely survives."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "What doesn't survive is code that was written to impress, abstractions built for problems that never materialized, and architectures designed around a framework's opinions rather than the domain's reality."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "The best code I've written is boring. It reads like prose, does one thing well, and doesn't require a PhD in category theory to understand. The worst code I've written was technically impressive at the time."
+ }
+ ]
+ }
+ ]
+ },
+ "bylines": [
+ { "byline": "byline-editorial" },
+ { "byline": "byline-guest", "roleLabel": "Guest essay" }
+ ],
+ "taxonomies": {
+ "category": ["development"],
+ "tag": ["opinion"]
+ }
+ },
+ {
+ "id": "post-2",
+ "slug": "the-case-for-static",
+ "status": "published",
+ "data": {
+ "title": "The Case for Static",
+ "excerpt": "Static sites aren't a step backwards. They're what you get when you take performance and simplicity seriously.",
+ "featured_image": {
+ "$media": {
+ "url": "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200&h=800&fit=crop",
+ "alt": "Laptop and coffee on a wooden table",
+ "filename": "case-for-static.jpg"
+ }
+ },
+ "content": [
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "There's a certain irony in the fact that the web started static, went dynamic, and is now swinging back toward static again. But the static sites of today aren't the hand-coded HTML pages of 1998. They're generated, optimized, and deployed to edge networks that serve them in milliseconds."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "The pitch for server-rendered everything was compelling: dynamic content, personalization, real-time data. But most sites don't need most of that most of the time. A blog post doesn't need to be rendered on every request. A product page doesn't change every second."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "h2",
+ "children": [{ "_type": "span", "text": "The performance argument" }]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "A static file served from a CDN is as fast as the web gets. No cold starts, no database queries, no server-side rendering overhead. The Time to First Byte is essentially the network latency to your nearest edge node. You can't beat physics."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "And when you do need dynamic behavior, you can add it surgically. An island of interactivity in a sea of static HTML. The best of both worlds, without paying the cost of either at all times."
+ }
+ ]
+ }
+ ]
+ },
+ "bylines": [{ "byline": "byline-editorial" }],
+ "taxonomies": {
+ "category": ["development"],
+ "tag": ["webdev", "opinion"]
+ }
+ },
+ {
+ "id": "post-3",
+ "slug": "learning-in-public",
+ "status": "published",
+ "data": {
+ "title": "Learning in Public",
+ "excerpt": "Writing about what you're learning is the fastest way to find out what you don't actually understand.",
+ "featured_image": {
+ "$media": {
+ "url": "https://images.unsplash.com/photo-1432821596592-e2c18b78144f?w=1200&h=800&fit=crop",
+ "alt": "Notebook and pen on a desk",
+ "filename": "learning-in-public.jpg"
+ }
+ },
+ "content": [
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "I started writing about things I was learning not because I had anything original to say, but because I kept forgetting what I'd figured out. The blog posts were notes to my future self, published publicly more out of laziness than courage."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "What I didn't expect was how much the writing itself would accelerate the learning. There's a particular kind of clarity that comes from trying to explain something to someone else. The gaps in your understanding, which you can happily ignore when the knowledge lives only in your head, become painfully obvious when you try to put it into sentences."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "h2",
+ "children": [{ "_type": "span", "text": "The fear of being wrong" }]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "The biggest barrier isn't time or writing skill. It's the fear of publishing something that turns out to be wrong. But here's the thing: being wrong publicly is one of the most efficient ways to learn. Someone will correct you, often kindly, and you'll remember that correction forever."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "The posts that helped me most weren't written by experts. They were written by people one step ahead of me on the same path, in language that hadn't yet been polished into abstraction. There's a place for that kind of writing, and it's more valuable than most people realize."
+ }
+ ]
+ }
+ ]
+ },
+ "taxonomies": {
+ "category": ["notes"],
+ "tag": ["opinion"]
+ }
+ },
+ {
+ "id": "post-4",
+ "slug": "small-tools-big-impact",
+ "status": "published",
+ "data": {
+ "title": "Small Tools, Big Impact",
+ "excerpt": "The best developer tools do one thing well and get out of your way. A love letter to focused software.",
+ "featured_image": {
+ "$media": {
+ "url": "https://images.unsplash.com/photo-1575026615908-666710ae5e47?w=1200&h=800&fit=crop",
+ "alt": "Wrenches and hand tools hanging on a workshop wall",
+ "filename": "small-tools.jpg"
+ }
+ },
+ "content": [
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "There's a class of software that doesn't get enough appreciation. Not the frameworks or the platforms or the IDEs, but the small, sharp tools that solve one problem so well you stop thinking about them. They become invisible, which is the highest compliment you can pay a tool."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "I'm talking about things like ripgrep, which searches code so fast it changed how I think about searching. Or jq, which makes JSON feel like a first-class data format in the terminal. Or curl, which has been quietly powering the internet's plumbing for decades."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "h2",
+ "children": [{ "_type": "span", "text": "The Unix philosophy, revisited" }]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "Do one thing well. The advice is old enough to be a cliche, but the best modern tools still follow it. They don't try to be platforms. They don't have plugin ecosystems or configuration languages or startup wizards. They do their job and they compose with other tools that do theirs."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "The temptation is always to add more. One more feature, one more option, one more integration. But every addition is a decision someone has to make, a path through the code that has to be maintained, a thing that can break. The best tools resist this. They stay small, and in staying small, they stay reliable."
+ }
+ ]
+ }
+ ]
+ },
+ "taxonomies": {
+ "category": ["development"],
+ "tag": ["tools"]
+ }
+ },
+ {
+ "id": "post-5",
+ "slug": "designing-with-constraints",
+ "status": "published",
+ "data": {
+ "title": "Designing with Constraints",
+ "excerpt": "Limitations aren't obstacles to creativity. They're the structure that makes creativity possible.",
+ "featured_image": {
+ "$media": {
+ "url": "https://images.unsplash.com/photo-1513542789411-b6a5d4f31634?w=1200&h=800&fit=crop",
+ "alt": "Pencils and design tools on a desk",
+ "filename": "designing-with-constraints.jpg"
+ }
+ },
+ "content": [
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "Give a designer a blank canvas and unlimited time, and they'll often produce something mediocre. Give them a tight brief, a small screen, and a deadline, and they'll surprise you. This isn't a paradox - it's how creativity actually works."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "Constraints force decisions. When you can't use more than two typefaces, you have to choose carefully. When the page has to load in under a second, every element earns its place. When the interface has to work on a 320px screen, you discover what's truly essential."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "h2",
+ "children": [{ "_type": "span", "text": "Embracing the box" }]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "The web itself is a constraint. HTML flows in one direction. CSS has a box model. Browsers have viewport sizes and font rendering quirks. You can fight these constraints or you can work with them, and the results are dramatically different."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "The designs I admire most don't look like they were forced through a framework. They look like they grew naturally from the medium, respecting its grain rather than working against it. That only happens when you treat constraints as creative partners rather than enemies."
+ }
+ ]
+ }
+ ]
+ },
+ "taxonomies": {
+ "category": ["design"],
+ "tag": ["creativity"]
+ }
+ },
+ {
+ "id": "post-6",
+ "slug": "a-weekend-with-a-side-project",
+ "status": "published",
+ "data": {
+ "title": "A Weekend with a Side Project",
+ "excerpt": "No stakeholders, no deadlines, no Jira tickets. Just you and a dumb idea that might turn into something.",
+ "featured_image": {
+ "$media": {
+ "url": "https://images.unsplash.com/photo-1542831371-29b0f74f9713?w=1200&h=800&fit=crop",
+ "alt": "Code on a screen with a dark theme",
+ "filename": "weekend-side-project.jpg"
+ }
+ },
+ "content": [
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "Saturday morning. Coffee's made, the house is quiet, and I've got an idea that's been nagging at me all week. Not a good idea, necessarily - just a persistent one. A small tool that does a thing I keep doing manually. How hard could it be?"
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "This is the best kind of programming. No requirements document, no sprint planning, no pull request reviews. Just a text editor and a problem. The freedom to make terrible architectural decisions, rewrite everything twice, and follow tangents that turn out to be dead ends."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "h2",
+ "children": [{ "_type": "span", "text": "Why side projects matter" }]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "Side projects are where you learn things your day job would never teach you. Not because the problems are harder, but because you're free to take risks. Try a language you've never used. Build something without a framework. Deploy to a platform you've only read about. The stakes are zero, which makes the learning maximum."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "By Sunday evening, the thing sort of works. It's rough, the error handling is nonexistent, and the README is a single sentence. But it solves the problem I set out to solve, and I learned three things I didn't know on Friday. Not a bad weekend."
+ }
+ ]
+ }
+ ]
+ },
+ "taxonomies": {
+ "category": ["development"],
+ "tag": ["creativity"]
+ }
+ },
+ {
+ "id": "post-7",
+ "slug": "notes-on-simplicity",
+ "status": "published",
+ "data": {
+ "title": "Notes on Simplicity",
+ "excerpt": "Simplicity isn't the absence of complexity. It's the result of understanding a problem well enough to solve it cleanly.",
+ "featured_image": {
+ "$media": {
+ "url": "https://images.unsplash.com/photo-1559051668-e1fa58f25786?w=1200&h=800&fit=crop",
+ "alt": "Geometric pattern carved into white paper",
+ "filename": "notes-on-simplicity.jpg"
+ }
+ },
+ "content": [
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "Every piece of software starts simple. A few files, a clear purpose, a small surface area. Then features get added, edge cases get handled, and before long you're looking at something that requires a diagram to understand. This isn't inevitable, but it takes discipline to prevent."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "The hard part of simplicity isn't the initial design. It's the ongoing resistance to complication. Every feature request, every bug fix, every refactor is an opportunity to add complexity. Saying no is the most important design skill, and the least celebrated."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "h2",
+ "children": [{ "_type": "span", "text": "Removing as a feature" }]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "The best version of a product often has fewer features than the previous one. Not because features were missing, but because someone had the courage to remove things that weren't earning their keep. Every feature has a cost - in maintenance, in cognitive load, in the weight of the interface."
+ }
+ ]
+ },
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "Simplicity is a practice, not a destination. You never arrive at simple. You just keep asking: is this necessary? Could this be clearer? Is there a way to solve this problem by removing something instead of adding something? The answer is yes more often than you'd expect."
+ }
+ ]
+ }
+ ]
+ },
+ "taxonomies": {
+ "category": ["notes"],
+ "tag": ["opinion"]
+ }
+ },
+ {
+ "id": "post-draft",
+ "slug": "work-in-progress",
+ "status": "draft",
+ "data": {
+ "title": "Work in Progress",
+ "excerpt": "This post is still being written.",
+ "content": [
+ {
+ "_type": "block",
+ "style": "normal",
+ "children": [
+ {
+ "_type": "span",
+ "text": "This is a draft post that won't appear in the public listing."
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/fixtures/perf-site/src/components/PostCard.astro b/fixtures/perf-site/src/components/PostCard.astro
new file mode 100644
index 000000000..500fbac6e
--- /dev/null
+++ b/fixtures/perf-site/src/components/PostCard.astro
@@ -0,0 +1,279 @@
+---
+import type { MediaValue, ContentBylineCredit } from "emdash";
+import { Image } from "emdash/ui";
+
+interface Props {
+ title: string;
+ excerpt?: string;
+ featuredImage?: MediaValue | string;
+ href: string;
+ date?: Date;
+ readingTime?: number;
+ tags?: Array<{ slug: string; label: string }>;
+ bylines?: ContentBylineCredit[];
+}
+
+const {
+ title,
+ excerpt,
+ featuredImage,
+ href,
+ date,
+ readingTime,
+ tags,
+ bylines,
+} = Astro.props;
+
+const formattedDate = date
+ ? date.toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ })
+ : null;
+---
+
+
+
+ {
+ featuredImage ? (
+
+
+
+ ) : (
+
+ )
+ }
+
+
+
{title}
+ {excerpt &&
{excerpt}
}
+
+
+ {
+ tags && tags.length > 0 && (
+
+ )
+ }
+
+
+
diff --git a/fixtures/perf-site/src/components/TagList.astro b/fixtures/perf-site/src/components/TagList.astro
new file mode 100644
index 000000000..d93122d26
--- /dev/null
+++ b/fixtures/perf-site/src/components/TagList.astro
@@ -0,0 +1,45 @@
+---
+interface Props {
+ tags: Array<{ slug: string; label: string }>;
+ class?: string;
+}
+
+const { tags, class: className } = Astro.props;
+---
+
+{tags.length > 0 && (
+
+)}
+
+
diff --git a/fixtures/perf-site/src/layouts/Base.astro b/fixtures/perf-site/src/layouts/Base.astro
new file mode 100644
index 000000000..b1184b45f
--- /dev/null
+++ b/fixtures/perf-site/src/layouts/Base.astro
@@ -0,0 +1,1008 @@
+---
+import { getMenu, getEmDashCollection, getSiteSettings } from "emdash";
+import {
+ WidgetArea,
+ EmDashHead,
+ EmDashBodyStart,
+ EmDashBodyEnd,
+} from "emdash/ui";
+import { createPublicPageContext } from "emdash/page";
+import LiveSearch from "emdash/ui/search";
+import { resolveBlogSiteIdentity } from "../utils/site-identity";
+import "../styles/theme.css";
+
+interface Props {
+ title: string;
+ pageTitle?: string | null;
+ description?: string | null;
+ image?: string | null;
+ canonical?: string | null;
+ robots?: string | null;
+ type?: "website" | "article";
+ publishedTime?: string | null;
+ modifiedTime?: string | null;
+ author?: string | null;
+ /** Pass content reference for plugin page contributions on content pages */
+ content?: { collection: string; id: string; slug?: string | null };
+}
+
+const {
+ title,
+ pageTitle,
+ description,
+ image,
+ canonical,
+ robots,
+ type = "website",
+ publishedTime,
+ modifiedTime,
+ author,
+ content,
+} = Astro.props;
+const { siteTitle, siteTagline, siteLogo, siteFavicon } = resolveBlogSiteIdentity(await getSiteSettings());
+// If title already includes site title (from getSeoMeta), use as-is
+const fullTitle = title.includes(siteTitle) ? title : `${title} — ${siteTitle}`;
+
+// Fetch primary menu defined in seed
+const menu = await getMenu("primary");
+
+// Fetch pages for footer
+const { entries: pages } = await getEmDashCollection("pages");
+
+// Build public page context for plugin contributions
+// SEO data is passed here and rendered securely by EmDashHead
+const pageCtx = createPublicPageContext({
+ Astro,
+ kind: content ? "content" : "custom",
+ pageType: type,
+ title: fullTitle,
+ pageTitle: pageTitle ?? title,
+ description,
+ canonical,
+ image,
+ content,
+ seo: { ogImage: image, robots },
+ articleMeta: { publishedTime, modifiedTime, author },
+ siteName: siteTitle,
+});
+
+// Check if user is logged in (for showing admin link)
+const isLoggedIn = !!Astro.locals.user;
+---
+
+
+
+
+
+
+ {fullTitle}
+ {siteFavicon && }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fixtures/perf-site/src/live.config.ts b/fixtures/perf-site/src/live.config.ts
new file mode 100644
index 000000000..c8c819d40
--- /dev/null
+++ b/fixtures/perf-site/src/live.config.ts
@@ -0,0 +1,13 @@
+/**
+ * EmDash Live Content Collections
+ *
+ * Defines the _emdash collection that handles all content types from the database.
+ * Query specific types using getEmDashCollection() and getEmDashEntry().
+ */
+
+import { defineLiveCollection } from "astro:content";
+import { emdashLoader } from "emdash/runtime";
+
+export const collections = {
+ _emdash: defineLiveCollection({ loader: emdashLoader() }),
+};
diff --git a/fixtures/perf-site/src/pages/404.astro b/fixtures/perf-site/src/pages/404.astro
new file mode 100644
index 000000000..0078a14ab
--- /dev/null
+++ b/fixtures/perf-site/src/pages/404.astro
@@ -0,0 +1,33 @@
+---
+import Base from "../layouts/Base.astro";
+---
+
+
+
+
404
+
The page you're looking for doesn't exist.
+
Go back home
+
+
+
+
diff --git a/fixtures/perf-site/src/pages/category/[slug].astro b/fixtures/perf-site/src/pages/category/[slug].astro
new file mode 100644
index 000000000..e0a94ef5a
--- /dev/null
+++ b/fixtures/perf-site/src/pages/category/[slug].astro
@@ -0,0 +1,117 @@
+---
+import { getTerm, getEmDashCollection, getEntryTerms, decodeSlug } from "emdash";
+import Base from "../../layouts/Base.astro";
+import PostCard from "../../components/PostCard.astro";
+import { getReadingTime } from "../../utils/reading-time";
+
+const slug = decodeSlug(Astro.params.slug);
+const term = slug ? await getTerm("category", slug) : null;
+
+if (!term) {
+ return Astro.redirect("/404");
+}
+
+const { entries: posts } = await getEmDashCollection("posts", {
+ where: { category: term.slug },
+ orderBy: { published_at: "desc" },
+});
+
+// Fetch tags for display on each post card
+const filteredPosts = await Promise.all(
+ posts.map(async (post) => {
+ const tags = await getEntryTerms("posts", post.data.id, "tag");
+ return { post, tags };
+ })
+);
+---
+
+
+
+
+
+ {
+ filteredPosts.length === 0 ? (
+ No posts in this category yet.
+ ) : (
+
+ {filteredPosts.map(({ post, tags }) => (
+
({ slug: t.slug, label: t.label }))}
+ />
+ ))}
+
+ )
+ }
+
+
+
+
diff --git a/fixtures/perf-site/src/pages/index.astro b/fixtures/perf-site/src/pages/index.astro
new file mode 100644
index 000000000..22683ab85
--- /dev/null
+++ b/fixtures/perf-site/src/pages/index.astro
@@ -0,0 +1,450 @@
+---
+import { getEmDashCollection, getEntryTerms, getSiteSettings } from "emdash";
+import { Image } from "emdash/ui";
+import Base from "../layouts/Base.astro";
+import PostCard from "../components/PostCard.astro";
+import { getReadingTime } from "../utils/reading-time";
+import { resolveBlogSiteIdentity } from "../utils/site-identity";
+
+const { entries: posts, cacheHint } = await getEmDashCollection("posts");
+const { siteTitle, siteTagline } = resolveBlogSiteIdentity(await getSiteSettings());
+
+Astro.cache.set(cacheHint);
+
+const sortedPosts = posts.toSorted((a, b) => {
+ const dateA = a.data.publishedAt?.getTime() ?? 0;
+ const dateB = b.data.publishedAt?.getTime() ?? 0;
+ return dateB - dateA;
+});
+
+// Find the first post with a featured image for the hero
+const featuredPost = sortedPosts.find((p) => p.data.featured_image);
+const featuredIndex = featuredPost ? sortedPosts.indexOf(featuredPost) : -1;
+
+// Get remaining posts (exclude featured if found, limit to 6 for grid)
+const gridPosts = sortedPosts.filter((_, i) => i !== featuredIndex).slice(0, 6);
+
+// Total posts shown = featured (if any) + grid posts
+const totalShown = (featuredPost ? 1 : 0) + gridPosts.length;
+const hasMorePosts = sortedPosts.length > totalShown;
+
+// Fetch tags for featured post (bylines are already hydrated by getEmDashCollection)
+let featuredTags: Array<{ slug: string; label: string }> = [];
+const featuredBylines = featuredPost?.data.bylines ?? [];
+if (featuredPost) {
+ const tags = await getEntryTerms("posts", featuredPost.data.id, "tag");
+ featuredTags = tags.map((t) => ({ slug: t.slug, label: t.label }));
+}
+
+// Fetch tags for grid posts (bylines are already hydrated by getEmDashCollection)
+const gridPostsWithTags = await Promise.all(
+ gridPosts.map(async (post) => {
+ const tags = await getEntryTerms("posts", post.data.id, "tag");
+ const bylines = post.data.bylines ?? [];
+ return {
+ post,
+ tags: tags.map((t) => ({ slug: t.slug, label: t.label })),
+ bylines,
+ };
+ })
+);
+
+// Format date helper
+function formatDate(date: Date | null | undefined) {
+ if (!date) return null;
+ return date.toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+}
+---
+
+
+ {
+ posts.length === 0 ? (
+
+ ) : (
+
+ {/* Featured Post - Side by side */}
+ {featuredPost && (
+
+ )}
+
+ {/* Latest Posts */}
+ {gridPostsWithTags.length > 0 && (
+
+
+
+ {gridPostsWithTags.map(({ post, tags, bylines }) => (
+
+ ))}
+
+
+ )}
+
+ )
+ }
+
+
+
diff --git a/fixtures/perf-site/src/pages/pages/[slug].astro b/fixtures/perf-site/src/pages/pages/[slug].astro
new file mode 100644
index 000000000..2e816258b
--- /dev/null
+++ b/fixtures/perf-site/src/pages/pages/[slug].astro
@@ -0,0 +1,108 @@
+---
+import { getEmDashEntry, decodeSlug } from "emdash";
+import { PortableText } from "emdash/ui";
+import Base from "../../layouts/Base.astro";
+
+const slug = decodeSlug(Astro.params.slug);
+
+if (!slug) {
+ return Astro.redirect("/404");
+}
+
+const { entry: page, cacheHint } = await getEmDashEntry("pages", slug);
+
+if (!page) {
+ return Astro.redirect("/404");
+}
+
+Astro.cache.set(cacheHint);
+---
+
+
+
+
+
+
+
+
+
+
diff --git a/fixtures/perf-site/src/pages/posts/[slug].astro b/fixtures/perf-site/src/pages/posts/[slug].astro
new file mode 100644
index 000000000..bcc4c3d43
--- /dev/null
+++ b/fixtures/perf-site/src/pages/posts/[slug].astro
@@ -0,0 +1,963 @@
+---
+import {
+ getEmDashEntry,
+ getEmDashCollection,
+ getEntryTerms,
+ getSeoMeta,
+ decodeSlug,
+ getSiteSettings,
+} from "emdash";
+import {
+ Image,
+ PortableText,
+ Comments,
+ CommentForm,
+ WidgetArea,
+} from "emdash/ui";
+import Base from "../../layouts/Base.astro";
+import PostCard from "../../components/PostCard.astro";
+import { getReadingTime } from "../../utils/reading-time";
+import { resolveBlogSiteIdentity } from "../../utils/site-identity";
+
+const slug = decodeSlug(Astro.params.slug);
+
+if (!slug) {
+ return Astro.redirect("/404");
+}
+
+const { entry: post, cacheHint } = await getEmDashEntry("posts", slug);
+
+if (!post) {
+ return Astro.redirect("/404");
+}
+
+Astro.cache.set(cacheHint);
+
+// Get featured image URL for OG fallback
+// The image may have src (external) or meta.storageKey (local)
+function getImageUrl(img: unknown): string | undefined {
+ if (!img || typeof img !== "object") return undefined;
+ const image = img as Record;
+ // Check for direct src
+ if (typeof image.src === "string" && image.src) {
+ return image.src.startsWith("http")
+ ? image.src
+ : `${Astro.url.origin}${image.src}`;
+ }
+ // Build from storageKey for local images
+ const meta = image.meta as Record | undefined;
+ const storageKey =
+ (typeof meta?.storageKey === "string" ? meta.storageKey : undefined) ||
+ (typeof image.id === "string" ? image.id : undefined);
+ if (storageKey) {
+ return `${Astro.url.origin}/_emdash/api/media/file/${storageKey}`;
+ }
+ return undefined;
+}
+const featuredImageUrl = getImageUrl(post.data.featured_image);
+const { siteTitle } = resolveBlogSiteIdentity(await getSiteSettings());
+
+// Generate SEO meta from content
+const seo = getSeoMeta(post, {
+ siteTitle,
+ siteUrl: Astro.url.origin,
+ path: `/posts/${slug}`,
+ defaultOgImage: featuredImageUrl,
+});
+
+// Get tags for this post
+// Note: post.id is the slug, post.data.id is the database ULID
+const tags = await getEntryTerms("posts", post.data.id, "tag");
+
+// Bylines are already hydrated by getEmDashEntry
+const bylines = post.data.bylines ?? [];
+
+// Get reading time
+const readingTime = getReadingTime(post.data.content);
+
+// Get other posts for "More posts" section, with their tags
+// Fetch a few extra in case the current post is among them
+const { entries: recentPosts } = await getEmDashCollection("posts", {
+ orderBy: { published_at: "desc" },
+ limit: 4,
+});
+const otherPosts = recentPosts.filter((p) => p.id !== post.id).slice(0, 3);
+
+// Fetch tags for related posts (bylines are already hydrated by getEmDashCollection)
+const otherPostsWithTags = await Promise.all(
+ otherPosts.map(async (p) => {
+ const postTags = await getEntryTerms("posts", p.data.id, "tag");
+ const postBylines = p.data.bylines ?? [];
+ return { post: p, tags: postTags, bylines: postBylines };
+ })
+);
+
+const publishDate =
+ post.data.publishedAt?.toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }) ?? null;
+---
+
+
+
+ {/* Hero: Full-width featured image */}
+ {
+ post.data.featured_image && (
+
+
+
+ )
+ }
+
+ {/* Three-column layout */}
+
+ {/* Left gutter: Meta information */}
+
+
+ {/* Main content */}
+
+
+
+ {
+ bylines.length > 0 && (
+ <>
+
+ {bylines.map((credit, i) => (
+ <>
+ {i > 0 && ", "}
+ {credit.byline.displayName}
+ >
+ ))}
+
+
+ >
+ )
+ }
+ {
+ publishDate && (
+ <>
+ {publishDate}
+
+ >
+ )
+ }
+ {readingTime} min read
+
+ {post.data.title}
+ {
+ post.data.excerpt && (
+ {post.data.excerpt}
+ )
+ }
+
+
+
+
+
+
+
+
+
+
+ {/* Right gutter: TOC + Sidebar widgets */}
+
+
+
+
+ {
+ otherPostsWithTags.length > 0 && (
+
+
+
Continue reading
+
+ {otherPostsWithTags.map(
+ ({ post: p, tags: postTags, bylines: postBylines }) => (
+
({ slug: t.slug, label: t.label }))}
+ bylines={postBylines}
+ />
+ )
+ )}
+
+
+
+ )
+ }
+
+
+
+
+
diff --git a/fixtures/perf-site/src/pages/posts/index.astro b/fixtures/perf-site/src/pages/posts/index.astro
new file mode 100644
index 000000000..26355921f
--- /dev/null
+++ b/fixtures/perf-site/src/pages/posts/index.astro
@@ -0,0 +1,268 @@
+---
+import { getEmDashCollection, getEntryTerms } from "emdash";
+import Base from "../../layouts/Base.astro";
+import { getReadingTime } from "../../utils/reading-time";
+
+const { entries: posts, cacheHint } = await getEmDashCollection("posts");
+
+Astro.cache.set(cacheHint);
+
+const sortedPosts = posts.toSorted((a, b) => {
+ const dateA = a.data.publishedAt?.getTime() ?? 0;
+ const dateB = b.data.publishedAt?.getTime() ?? 0;
+ return dateB - dateA;
+});
+
+// Fetch tags for each post (bylines are already hydrated by getEmDashCollection)
+const postsWithTags = await Promise.all(
+ sortedPosts.map(async (post) => {
+ const tags = await getEntryTerms("posts", post.data.id, "tag");
+ const bylines = post.data.bylines ?? [];
+ return { post, tags, bylines };
+ })
+);
+
+const formatDate = (date: Date) =>
+ date.toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+---
+
+
+
+
+
+ {
+ sortedPosts.length === 0 ? (
+
No posts yet.
+ ) : (
+
+ )
+ }
+
+
+
+
diff --git a/fixtures/perf-site/src/pages/rss.xml.ts b/fixtures/perf-site/src/pages/rss.xml.ts
new file mode 100644
index 000000000..2ea40dbdc
--- /dev/null
+++ b/fixtures/perf-site/src/pages/rss.xml.ts
@@ -0,0 +1,70 @@
+import type { APIRoute } from "astro";
+import { getEmDashCollection, getSiteSettings } from "emdash";
+
+import { resolveBlogSiteIdentity } from "../utils/site-identity";
+
+export const GET: APIRoute = async ({ site, url }) => {
+ const siteUrl = site?.toString() || url.origin;
+ const { siteTitle, siteTagline } = resolveBlogSiteIdentity(await getSiteSettings());
+
+ const { entries: posts } = await getEmDashCollection("posts", {
+ orderBy: { published_at: "desc" },
+ limit: 20,
+ });
+
+ const items = posts
+ .map((post) => {
+ if (!post.data.publishedAt) return null;
+ const pubDate = post.data.publishedAt.toUTCString();
+
+ const postUrl = `${siteUrl}/posts/${post.id}`;
+ const title = escapeXml(post.data.title || "Untitled");
+ const description = escapeXml(post.data.excerpt || "");
+
+ return ` -
+
${title}
+ ${postUrl}
+ ${postUrl}
+ ${pubDate}
+ ${description}
+ `;
+ })
+ .filter(Boolean)
+ .join("\n");
+
+ const rss = `
+
+
+ ${escapeXml(siteTitle)}
+ ${escapeXml(siteTagline)}
+ ${siteUrl}
+
+ en-us
+ ${new Date().toUTCString()}
+${items}
+
+ `;
+
+ return new Response(rss, {
+ headers: {
+ "Content-Type": "application/rss+xml; charset=utf-8",
+ "Cache-Control": "public, max-age=3600",
+ },
+ });
+};
+
+const XML_ESCAPE_PATTERNS = [
+ [/&/g, "&"],
+ [//g, ">"],
+ [/"/g, """],
+ [/'/g, "'"],
+] as const;
+
+function escapeXml(str: string): string {
+ let result = str;
+ for (const [pattern, replacement] of XML_ESCAPE_PATTERNS) {
+ result = result.replace(pattern, replacement);
+ }
+ return result;
+}
diff --git a/fixtures/perf-site/src/pages/search.astro b/fixtures/perf-site/src/pages/search.astro
new file mode 100644
index 000000000..fd296062d
--- /dev/null
+++ b/fixtures/perf-site/src/pages/search.astro
@@ -0,0 +1,141 @@
+---
+export const prerender = false;
+
+import { getEmDashCollection } from "emdash";
+import Base from "../layouts/Base.astro";
+import PostCard from "../components/PostCard.astro";
+import { getReadingTime, extractText } from "../utils/reading-time";
+
+const query = Astro.url.searchParams.get("q")?.trim() || "";
+
+const { entries: allPosts } = await getEmDashCollection("posts");
+
+// Simple search: match query against title, excerpt, and content
+function matchesQuery(post: (typeof allPosts)[0], q: string): boolean {
+ if (!q) return false;
+ const lower = q.toLowerCase();
+ const title = (post.data.title || "").toLowerCase();
+ const excerpt = (post.data.excerpt || "").toLowerCase();
+ // Extract plain text from portable text blocks (avoids matching on _type, _key, etc.)
+ const content = extractText(post.data.content).toLowerCase();
+ return (
+ title.includes(lower) || excerpt.includes(lower) || content.includes(lower)
+ );
+}
+
+const results = query ? allPosts.filter((p) => matchesQuery(p, query)) : [];
+---
+
+
+
+ Search
+
+
+
+ {
+ query && (
+
+ {results.length === 0
+ ? `No results for "${query}"`
+ : `${results.length} result${results.length === 1 ? "" : "s"} for "${query}"`}
+
+ )
+ }
+
+ {
+ results.length > 0 && (
+
+ {results.map((post) => (
+
+ ))}
+
+ )
+ }
+
+ {!query && Enter a search term to find posts.
}
+
+
+
+
diff --git a/fixtures/perf-site/src/pages/tag/[slug].astro b/fixtures/perf-site/src/pages/tag/[slug].astro
new file mode 100644
index 000000000..34f3cc75d
--- /dev/null
+++ b/fixtures/perf-site/src/pages/tag/[slug].astro
@@ -0,0 +1,120 @@
+---
+import { getTerm, getEmDashCollection, getEntryTerms, decodeSlug } from "emdash";
+import Base from "../../layouts/Base.astro";
+import PostCard from "../../components/PostCard.astro";
+import { getReadingTime } from "../../utils/reading-time";
+
+const slug = decodeSlug(Astro.params.slug);
+const term = slug ? await getTerm("tag", slug) : null;
+
+if (!term) {
+ return Astro.redirect("/404");
+}
+
+const { entries: posts } = await getEmDashCollection("posts", {
+ where: { tag: term.slug },
+ orderBy: { published_at: "desc" },
+});
+
+// Fetch tags for display on each post card
+const filteredPosts = await Promise.all(
+ posts.map(async (post) => {
+ const tags = await getEntryTerms("posts", post.data.id, "tag");
+ return { post, tags };
+ })
+);
+---
+
+
+
+
+
+ {
+ filteredPosts.length === 0 ? (
+ No posts with this tag yet.
+ ) : (
+
+ {filteredPosts.map(({ post, tags }) => (
+
({ slug: t.slug, label: t.label }))}
+ />
+ ))}
+
+ )
+ }
+
+
+
+
diff --git a/fixtures/perf-site/src/styles/theme.css b/fixtures/perf-site/src/styles/theme.css
new file mode 100644
index 000000000..2f7249b27
--- /dev/null
+++ b/fixtures/perf-site/src/styles/theme.css
@@ -0,0 +1,108 @@
+/*
+ theme.css -- override any :root variable here to retheme the blog.
+
+ This is the only file you need to edit to customize the site's visual
+ appearance. All defaults are listed below as comments. Uncomment and
+ change any value to override it.
+
+ Base.astro puts its defaults inside @layer base, so declarations here
+ (which are unlayered) always take priority -- no specificity tricks needed.
+
+ Note: this template defines explicit dark mode colors in Base.astro.
+ Overriding light-mode --color-* variables here won't affect dark mode.
+ To customize dark mode, also override --color-* variables inside a
+ @media (prefers-color-scheme: dark) block and/or in the :root.dark rule.
+*/
+
+:root {
+ /* --- Colors ---
+ --color-bg: #ffffff;
+ --color-bg-subtle: #fafafa;
+ --color-text: #1a1a1a;
+ --color-text-secondary: #525252;
+ --color-muted: #8b8b8b;
+ --color-border: #e5e5e5;
+ --color-border-subtle: #f0f0f0;
+ --color-surface: #f7f7f7;
+ --color-accent: #0066cc;
+ --color-accent-hover: #0052a3;
+ --color-on-accent: white;
+ --color-accent-ring: color-mix(in srgb, var(--color-accent) 25%, transparent);
+ */
+
+ /* --- Type scale ---
+ --font-size-xs: 0.8125rem;
+ --font-size-sm: 0.875rem;
+ --font-size-base: 1rem;
+ --font-size-lg: 1.125rem;
+ --font-size-xl: 1.25rem;
+ --font-size-2xl: 1.5rem;
+ --font-size-3xl: 2rem;
+ --font-size-4xl: 2.5rem;
+ --font-size-5xl: 3.5rem;
+ */
+
+ /* --- Line heights ---
+ --leading-tight: 1.15;
+ --leading-snug: 1.3;
+ --leading-normal: 1.5;
+ --leading-relaxed: 1.7;
+ */
+
+ /* --- Letter spacing ---
+ --tracking-tight: -0.03em; used on h1 and large titles
+ --tracking-snug: -0.02em; used on h2–h6, site/card titles
+ --tracking-wide: 0.06em; used on meta labels, TOC/widget titles
+ --tracking-wider: 0.08em; used on footer headings, section labels
+ */
+
+ /* --- Spacing ---
+ --spacing-1: 0.25rem;
+ --spacing-2: 0.5rem;
+ --spacing-3: 0.75rem;
+ --spacing-4: 1rem;
+ --spacing-5: 1.25rem;
+ --spacing-6: 1.5rem;
+ --spacing-8: 2rem;
+ --spacing-10: 2.5rem;
+ --spacing-12: 3rem;
+ --spacing-16: 4rem;
+ --spacing-20: 5rem;
+ --spacing-24: 6rem;
+ */
+
+ /* --- Layout ---
+ --content-width: 680px; article/page body column width
+ --wide-width: 1200px; max container width (home, archives)
+ --gutter-width: 200px; right sidebar column (TOC) on article pages
+ --meta-col-width: 180px; left meta column on article pages
+ --nav-height: 64px; sticky header height
+ --search-input-width: 180px; nav search box width
+ */
+
+ /* --- Borders & radius ---
+ --radius: 4px;
+ --radius-lg: 8px;
+ */
+
+ /* --- Transitions ---
+ --transition-fast: 120ms ease;
+ --transition-base: 180ms ease;
+ */
+
+ /* --- Avatars ---
+ --avatar-size-xs: 18px; card byline avatars
+ --avatar-size-sm: 20px; post list byline avatars
+ --avatar-size-md: 24px; featured post byline avatars
+ --avatar-size-lg: 32px; single post byline avatars
+ */
+
+ /* --- Shadows ---
+ --shadow-dropdown: 0 8px 30px rgba(0, 0, 0, 0.12);
+ --shadow-btn-active: 0 1px 2px rgba(0, 0, 0, 0.05);
+ */
+
+ /* --- Misc ---
+ --tag-padding-y: 2px; vertical padding on tag pills
+ */
+}
diff --git a/fixtures/perf-site/src/utils/reading-time.ts b/fixtures/perf-site/src/utils/reading-time.ts
new file mode 100644
index 000000000..c17794e15
--- /dev/null
+++ b/fixtures/perf-site/src/utils/reading-time.ts
@@ -0,0 +1,66 @@
+import type { PortableTextBlock } from "emdash";
+
+const WORDS_PER_MINUTE = 200;
+const CJK_CHARACTERS_PER_MINUTE = 500;
+const WHITESPACE_REGEX = /\s+/;
+const CJK_CHARACTER_REGEX =
+ /\p{Script=Han}|\p{Script=Hangul}|\p{Script=Hiragana}|\p{Script=Katakana}/gu;
+
+type PortableTextSpan = {
+ _type: string;
+ text?: string;
+};
+
+type PortableTextTextBlock = PortableTextBlock & {
+ _type: "block";
+ children: PortableTextSpan[];
+};
+
+function isTextBlock(block: PortableTextBlock): block is PortableTextTextBlock {
+ return block._type === "block" && Array.isArray(block.children);
+}
+
+function countWords(text: string): number {
+ return text.split(WHITESPACE_REGEX).filter(Boolean).length;
+}
+
+function countCjkCharacters(text: string): number {
+ return text.match(CJK_CHARACTER_REGEX)?.length ?? 0;
+}
+
+/**
+ * Extract plain text from Portable Text blocks
+ */
+export function extractText(blocks: PortableTextBlock[] | undefined): string {
+ if (!blocks || !Array.isArray(blocks)) return "";
+
+ return blocks
+ .filter(isTextBlock)
+ .map((block) =>
+ block.children
+ .filter((child) => child._type === "span" && typeof child.text === "string")
+ .map((span) => span.text)
+ .join(""),
+ )
+ .join(" ");
+}
+
+/**
+ * Calculate reading time in minutes from Portable Text content
+ */
+export function getReadingTime(content: PortableTextBlock[] | undefined): number {
+ const text = extractText(content);
+ const cjkCharacterCount = countCjkCharacters(text);
+ const wordCount = countWords(text.replace(CJK_CHARACTER_REGEX, " "));
+ const minutes = Math.ceil(
+ wordCount / WORDS_PER_MINUTE + cjkCharacterCount / CJK_CHARACTERS_PER_MINUTE,
+ );
+ return Math.max(1, minutes);
+}
+
+/**
+ * Format reading time for display
+ */
+export function formatReadingTime(minutes: number): string {
+ return `${minutes} min read`;
+}
diff --git a/fixtures/perf-site/src/utils/site-identity.ts b/fixtures/perf-site/src/utils/site-identity.ts
new file mode 100644
index 000000000..0dfa7a5d1
--- /dev/null
+++ b/fixtures/perf-site/src/utils/site-identity.ts
@@ -0,0 +1,25 @@
+/** Resolved media reference from getSiteSettings() */
+export interface MediaReference {
+ mediaId: string;
+ alt?: string;
+ url?: string;
+}
+
+export interface BlogSiteIdentitySettings {
+ title?: string;
+ tagline?: string;
+ logo?: MediaReference;
+ favicon?: MediaReference;
+}
+
+const DEFAULT_SITE_TITLE = "My Blog";
+const DEFAULT_SITE_TAGLINE = "Thoughts, stories, and ideas.";
+
+export function resolveBlogSiteIdentity(settings?: BlogSiteIdentitySettings) {
+ return {
+ siteTitle: settings?.title ?? DEFAULT_SITE_TITLE,
+ siteTagline: settings?.tagline ?? DEFAULT_SITE_TAGLINE,
+ siteLogo: settings?.logo?.url ? settings.logo : null,
+ siteFavicon: settings?.favicon?.url ?? null,
+ };
+}
diff --git a/fixtures/perf-site/src/worker.ts b/fixtures/perf-site/src/worker.ts
new file mode 100644
index 000000000..53a1af943
--- /dev/null
+++ b/fixtures/perf-site/src/worker.ts
@@ -0,0 +1,3 @@
+import handler from "@astrojs/cloudflare/entrypoints/server";
+
+export default handler;
diff --git a/fixtures/perf-site/tsconfig.json b/fixtures/perf-site/tsconfig.json
new file mode 100644
index 000000000..090375311
--- /dev/null
+++ b/fixtures/perf-site/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "astro/tsconfigs/base",
+ "compilerOptions": {
+ "types": ["node"]
+ },
+ "include": ["src", ".astro/types.d.ts", "emdash-env.d.ts"]
+}
diff --git a/fixtures/perf-site/wrangler.jsonc b/fixtures/perf-site/wrangler.jsonc
new file mode 100644
index 000000000..d60fabafc
--- /dev/null
+++ b/fixtures/perf-site/wrangler.jsonc
@@ -0,0 +1,26 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "emdash-perf-fixture",
+ "main": "./src/worker.ts",
+ "compatibility_date": "2026-02-24",
+ "compatibility_flags": ["nodejs_compat"],
+ // Query instrumentation is always on for this fixture — it exists
+ // specifically to run under the query-count harness. The flag costs a
+ // per-query Kysely log dispatch; it's intentional here.
+ "vars": {
+ "EMDASH_QUERY_LOG": "1",
+ },
+ "d1_databases": [
+ {
+ "binding": "DB",
+ "database_name": "emdash-perf-fixture",
+ "database_id": "local",
+ },
+ ],
+ "r2_buckets": [
+ {
+ "binding": "MEDIA",
+ "bucket_name": "emdash-perf-fixture-media",
+ },
+ ],
+}
diff --git a/package.json b/package.json
index 3e5825337..207ff712c 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
"knip": "knip --no-exit-code --exclude unlisted,unresolved,exports,types,duplicates",
"new": "create-emdash",
"screenshots": "node scripts/screenshot-all-templates.mjs",
+ "query-counts": "node scripts/query-counts.mjs",
"locale:extract": "pnpm --filter @emdash-cms/admin locale:extract",
"locale:compile": "pnpm --filter @emdash-cms/admin locale:compile",
"changeset:version": "pnpm changeset version && pnpm install --no-frozen-lockfile"
diff --git a/packages/cloudflare/src/db/d1.ts b/packages/cloudflare/src/db/d1.ts
index a88be7ade..fca5b220f 100644
--- a/packages/cloudflare/src/db/d1.ts
+++ b/packages/cloudflare/src/db/d1.ts
@@ -9,6 +9,7 @@
*/
import { env } from "cloudflare:workers";
+import { kyselyLogOption } from "emdash/database/instrumentation";
import { type DatabaseIntrospector, type Dialect, Kysely } from "kysely";
import { D1Dialect } from "kysely-d1";
@@ -161,7 +162,10 @@ export function createRequestScopedDb(opts: RequestScopedDbOpts): RequestScopedD
// both of which D1DatabaseSession implements.
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- session is structurally compatible with the subset D1Dialect uses
const sessionAsDatabase = session as unknown as D1Database;
- const db = new Kysely({ dialect: new EmDashD1Dialect({ database: sessionAsDatabase }) });
+ const db = new Kysely({
+ dialect: new EmDashD1Dialect({ database: sessionAsDatabase }),
+ log: kyselyLogOption(),
+ });
return {
db,
diff --git a/packages/core/package.json b/packages/core/package.json
index bb9791203..97d6510c2 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -61,6 +61,10 @@
"types": "./dist/db/postgres.d.mts",
"default": "./dist/db/postgres.mjs"
},
+ "./database/instrumentation": {
+ "types": "./dist/database/instrumentation.d.mts",
+ "default": "./dist/database/instrumentation.mjs"
+ },
"./storage/local": {
"types": "./dist/storage/local.d.mts",
"default": "./dist/storage/local.mjs"
diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts
index e8e46992e..315e9c469 100644
--- a/packages/core/src/astro/middleware.ts
+++ b/packages/core/src/astro/middleware.ts
@@ -30,6 +30,11 @@ import { sandboxedPlugins as virtualSandboxedPlugins } from "virtual:emdash/sand
// @ts-ignore - virtual module
import { createStorage as virtualCreateStorage } from "virtual:emdash/storage";
+import {
+ createRecorder,
+ flushRecorder,
+ isInstrumentationEnabled,
+} from "../database/instrumentation.js";
import {
EmDashRuntime,
type RuntimeDependencies,
@@ -227,280 +232,299 @@ export const onRequest = defineMiddleware(async (context, next) => {
const { request, locals, cookies } = context;
const url = context.url;
- // Process /_emdash routes and public routes with an active session
- // (logged-in editors need the runtime for toolbar/visual editing on public pages)
- const isEmDashRoute = url.pathname.startsWith("/_emdash");
- const isPublicRuntimeRoute =
- PUBLIC_RUNTIME_ROUTES.has(url.pathname) || SITEMAP_COLLECTION_RE.test(url.pathname);
-
- // Check for edit mode cookie - editors viewing public pages need the runtime
- // so auth middleware can verify their session for visual editing
- const hasEditCookie = cookies.get("emdash-edit-mode")?.value === "true";
- const hasPreviewToken = url.searchParams.has("_preview");
-
- // Playground mode: the playground middleware stashes the per-session DO database
- // on locals.__playgroundDb. When present, use runWithContext() to make it
- // available to getDb() and the runtime's db getter via the correct ALS instance.
- const playgroundDb = locals.__playgroundDb;
-
- // Read the Astro session user once up-front. Both the anonymous fast path
- // and the full doInit path need this, and the session store is network-backed
- // (KV / Durable Object) so we want to avoid re-fetching on the hot path.
- // Skipped entirely for prerendered requests — they have no session.
- const sessionUser = context.isPrerendered ? null : await context.session?.get("user");
-
- if (!isEmDashRoute && !isPublicRuntimeRoute && !hasEditCookie && !hasPreviewToken) {
- if (!sessionUser && !playgroundDb) {
- const timings: Array<{ name: string; dur: number; desc?: string }> = [];
- const mwStart = performance.now();
+ const queryRecorder = isInstrumentationEnabled()
+ ? createRecorder(url.pathname, request.method, request.headers.get("x-perf-phase") ?? "default")
+ : undefined;
+
+ const run = async (): Promise => {
+ // Process /_emdash routes and public routes with an active session
+ // (logged-in editors need the runtime for toolbar/visual editing on public pages)
+ const isEmDashRoute = url.pathname.startsWith("/_emdash");
+ const isPublicRuntimeRoute =
+ PUBLIC_RUNTIME_ROUTES.has(url.pathname) || SITEMAP_COLLECTION_RE.test(url.pathname);
+
+ // Check for edit mode cookie - editors viewing public pages need the runtime
+ // so auth middleware can verify their session for visual editing
+ const hasEditCookie = cookies.get("emdash-edit-mode")?.value === "true";
+ const hasPreviewToken = url.searchParams.has("_preview");
+
+ // Playground mode: the playground middleware stashes the per-session DO database
+ // on locals.__playgroundDb. When present, use runWithContext() to make it
+ // available to getDb() and the runtime's db getter via the correct ALS instance.
+ const playgroundDb = locals.__playgroundDb;
+
+ // Read the Astro session user once up-front. Both the anonymous fast path
+ // and the full doInit path need this, and the session store is network-backed
+ // (KV / Durable Object) so we want to avoid re-fetching on the hot path.
+ // Skipped entirely for prerendered requests — they have no session.
+ const sessionUser = context.isPrerendered ? null : await context.session?.get("user");
+
+ if (!isEmDashRoute && !isPublicRuntimeRoute && !hasEditCookie && !hasPreviewToken) {
+ if (!sessionUser && !playgroundDb) {
+ const timings: Array<{ name: string; dur: number; desc?: string }> = [];
+ const mwStart = performance.now();
+
+ // On a fresh deployment the database may be completely empty.
+ // Public pages call getSiteSettings() / getMenu() via getDb(), which
+ // bypasses runtime init and would crash with "no such table: options".
+ // Do a one-time lightweight probe using the same getDb() instance the
+ // page will use: if the migrations table doesn't exist, no migrations
+ // have ever run -- redirect to the setup wizard.
+ if (!setupVerified) {
+ const t0 = performance.now();
+ try {
+ const { getDb } = await import("../loader.js");
+ const db = await getDb();
+ await db
+ .selectFrom("_emdash_migrations" as keyof Database)
+ .selectAll()
+ .limit(1)
+ .execute();
+ setupVerified = true;
+ } catch {
+ // Table doesn't exist -> fresh database, redirect to setup
+ return context.redirect("/_emdash/admin/setup");
+ }
+ timings.push({ name: "setup", dur: performance.now() - t0, desc: "Setup probe" });
+ }
- // On a fresh deployment the database may be completely empty.
- // Public pages call getSiteSettings() / getMenu() via getDb(), which
- // bypasses runtime init and would crash with "no such table: options".
- // Do a one-time lightweight probe using the same getDb() instance the
- // page will use: if the migrations table doesn't exist, no migrations
- // have ever run -- redirect to the setup wizard.
- if (!setupVerified) {
- const t0 = performance.now();
- try {
- const { getDb } = await import("../loader.js");
- const db = await getDb();
- await db
- .selectFrom("_emdash_migrations" as keyof Database)
- .selectAll()
- .limit(1)
- .execute();
- setupVerified = true;
- } catch {
- // Table doesn't exist -> fresh database, redirect to setup
- return context.redirect("/_emdash/admin/setup");
+ // Initialize the runtime for page:metadata and page:fragments hooks.
+ // The runtime is a cached singleton — after the first request,
+ // getRuntime() is just a null-check. This enables SEO plugins to
+ // contribute meta tags for all visitors, not just logged-in editors.
+ const config = getConfig();
+ if (config) {
+ // Sub-phase timings are populated only on the cold init. Warm
+ // requests hit the cached runtime and leave this empty.
+ const initSubTimings: Array<{ name: string; dur: number; desc?: string }> = [];
+ const t0 = performance.now();
+ try {
+ const runtime = await getRuntime(config, initSubTimings);
+ setupVerified = true;
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- partial object; getPageRuntime() only checks for these two methods
+ locals.emdash = {
+ collectPageMetadata: runtime.collectPageMetadata.bind(runtime),
+ collectPageFragments: runtime.collectPageFragments.bind(runtime),
+ } as EmDashHandlers;
+ } catch {
+ // Non-fatal — EmDashHead will fall back to base SEO contributions
+ }
+ timings.push({ name: "rt", dur: performance.now() - t0, desc: "Runtime init" });
+ // Append cold-only sub-phase timings so the breakdown is visible
+ // in Server-Timing (rt.db, rt.fts, rt.plugins, rt.site,
+ // rt.sandbox, rt.market, rt.hooks, rt.cron).
+ for (const sub of initSubTimings) timings.push(sub);
+ }
+
+ // Even on the anonymous fast path we ask the adapter for a per-request
+ // scoped db. For D1 with read replication this routes anonymous reads
+ // to the nearest replica; for other adapters it's a no-op.
+ const anonScoped = createRequestScopedDb({
+ config: config?.database?.config,
+ isAuthenticated: false,
+ isWrite: request.method !== "GET" && request.method !== "HEAD",
+ cookies,
+ url,
+ });
+ const runAnon = async () => {
+ const t0 = performance.now();
+ const response = await next();
+ timings.push({ name: "render", dur: performance.now() - t0, desc: "Page render" });
+ timings.push({ name: "mw", dur: performance.now() - mwStart, desc: "Total middleware" });
+ return finalizeResponse(response, timings);
+ };
+ if (anonScoped) {
+ const parent = getRequestContext();
+ const ctx = parent
+ ? { ...parent, db: anonScoped.db }
+ : { editMode: false, db: anonScoped.db };
+ return runWithContext(ctx, async () => {
+ const response = await runAnon();
+ anonScoped.commit();
+ return response;
+ });
}
- timings.push({ name: "setup", dur: performance.now() - t0, desc: "Setup probe" });
+ return runAnon();
}
+ }
+
+ const config = getConfig();
+ if (!config) {
+ console.error("EmDash: No configuration found");
+ return finalizeResponse(await next());
+ }
+
+ // In playground mode, wrap the entire runtime init + request handling in
+ // runWithContext so that getDatabase() and all init queries use the real
+ // DO database via the same AsyncLocalStorage instance as the loader.
+ const doInit = async () => {
+ const timings: Array<{ name: string; dur: number; desc?: string }> = [];
+ const mwStart = performance.now();
- // Initialize the runtime for page:metadata and page:fragments hooks.
- // The runtime is a cached singleton — after the first request,
- // getRuntime() is just a null-check. This enables SEO plugins to
- // contribute meta tags for all visitors, not just logged-in editors.
- const config = getConfig();
- if (config) {
- // Sub-phase timings are populated only on the cold init. Warm
- // requests hit the cached runtime and leave this empty.
+ try {
+ // Get or create runtime. Sub-phase timings (rt.db, rt.fts, rt.plugins,
+ // rt.site, rt.sandbox, rt.market, rt.hooks, rt.cron) are populated
+ // only on the cold init — subsequent warm calls find the cached
+ // instance and `initSubTimings` stays empty.
const initSubTimings: Array<{ name: string; dur: number; desc?: string }> = [];
- const t0 = performance.now();
- try {
- const runtime = await getRuntime(config, initSubTimings);
- setupVerified = true;
- // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- partial object; getPageRuntime() only checks for these two methods
- locals.emdash = {
- collectPageMetadata: runtime.collectPageMetadata.bind(runtime),
- collectPageFragments: runtime.collectPageFragments.bind(runtime),
- } as EmDashHandlers;
- } catch {
- // Non-fatal — EmDashHead will fall back to base SEO contributions
- }
+ let t0 = performance.now();
+ const runtime = await getRuntime(config, initSubTimings);
timings.push({ name: "rt", dur: performance.now() - t0, desc: "Runtime init" });
- // Append cold-only sub-phase timings so the breakdown is visible
- // in Server-Timing (rt.db, rt.fts, rt.plugins, rt.site,
- // rt.sandbox, rt.market, rt.hooks, rt.cron).
+ // Forward any sub-phase samples so cold-start breakdown is visible
+ // in Server-Timing. Each phase appears prefixed "rt." to distinguish
+ // from the aggregate "rt" timing above.
for (const sub of initSubTimings) timings.push(sub);
+
+ // Runtime init runs migrations, so the DB is guaranteed set up
+ setupVerified = true;
+
+ // Get manifest (cached after first call)
+ t0 = performance.now();
+ const manifest = await runtime.getManifest();
+ timings.push({ name: "manifest", dur: performance.now() - t0, desc: "Manifest" });
+
+ // Attach to locals for route handlers
+ locals.emdashManifest = manifest;
+ locals.emdash = {
+ // Content handlers
+ handleContentList: runtime.handleContentList.bind(runtime),
+ handleContentGet: runtime.handleContentGet.bind(runtime),
+ handleContentCreate: runtime.handleContentCreate.bind(runtime),
+ handleContentUpdate: runtime.handleContentUpdate.bind(runtime),
+ handleContentDelete: runtime.handleContentDelete.bind(runtime),
+
+ // Trash handlers
+ handleContentListTrashed: runtime.handleContentListTrashed.bind(runtime),
+ handleContentRestore: runtime.handleContentRestore.bind(runtime),
+ handleContentPermanentDelete: runtime.handleContentPermanentDelete.bind(runtime),
+ handleContentCountTrashed: runtime.handleContentCountTrashed.bind(runtime),
+ handleContentGetIncludingTrashed: runtime.handleContentGetIncludingTrashed.bind(runtime),
+
+ // Duplicate handler
+ handleContentDuplicate: runtime.handleContentDuplicate.bind(runtime),
+
+ // Publishing & Scheduling handlers
+ handleContentPublish: runtime.handleContentPublish.bind(runtime),
+ handleContentUnpublish: runtime.handleContentUnpublish.bind(runtime),
+ handleContentSchedule: runtime.handleContentSchedule.bind(runtime),
+ handleContentUnschedule: runtime.handleContentUnschedule.bind(runtime),
+ handleContentCountScheduled: runtime.handleContentCountScheduled.bind(runtime),
+ handleContentDiscardDraft: runtime.handleContentDiscardDraft.bind(runtime),
+ handleContentCompare: runtime.handleContentCompare.bind(runtime),
+ handleContentTranslations: runtime.handleContentTranslations.bind(runtime),
+
+ // Media handlers
+ handleMediaList: runtime.handleMediaList.bind(runtime),
+ handleMediaGet: runtime.handleMediaGet.bind(runtime),
+ handleMediaCreate: runtime.handleMediaCreate.bind(runtime),
+ handleMediaUpdate: runtime.handleMediaUpdate.bind(runtime),
+ handleMediaDelete: runtime.handleMediaDelete.bind(runtime),
+
+ // Revision handlers
+ handleRevisionList: runtime.handleRevisionList.bind(runtime),
+ handleRevisionGet: runtime.handleRevisionGet.bind(runtime),
+ handleRevisionRestore: runtime.handleRevisionRestore.bind(runtime),
+
+ // Plugin routes
+ handlePluginApiRoute: runtime.handlePluginApiRoute.bind(runtime),
+ getPluginRouteMeta: runtime.getPluginRouteMeta.bind(runtime),
+
+ // Media provider methods
+ getMediaProvider: runtime.getMediaProvider.bind(runtime),
+ getMediaProviderList: runtime.getMediaProviderList.bind(runtime),
+
+ // Page contribution methods (for EmDashHead/EmDashBodyStart/EmDashBodyEnd)
+ collectPageMetadata: runtime.collectPageMetadata.bind(runtime),
+ collectPageFragments: runtime.collectPageFragments.bind(runtime),
+
+ // Lazy search index health check — search endpoints call this
+ // before querying so a crash-corrupted index gets repaired on
+ // first use rather than stalling every cold start.
+ ensureSearchHealthy: runtime.ensureSearchHealthy.bind(runtime),
+
+ // Direct access (for advanced use cases)
+ storage: runtime.storage,
+ db: runtime.db,
+ hooks: runtime.hooks,
+ email: runtime.email,
+ configuredPlugins: runtime.configuredPlugins,
+
+ // Configuration (for checking database type, auth mode, etc.)
+ config,
+
+ // Manifest invalidation (call after schema changes)
+ invalidateManifest: runtime.invalidateManifest.bind(runtime),
+
+ // Sandbox runner (for marketplace plugin install/update)
+ getSandboxRunner: runtime.getSandboxRunner.bind(runtime),
+
+ // Sync marketplace plugin states (after install/update/uninstall)
+ syncMarketplacePlugins: runtime.syncMarketplacePlugins.bind(runtime),
+
+ // Update plugin enabled/disabled status and rebuild hook pipeline
+ setPluginStatus: runtime.setPluginStatus.bind(runtime),
+ };
+ } catch (error) {
+ console.error("EmDash middleware error:", error);
}
- // Even on the anonymous fast path we ask the adapter for a per-request
- // scoped db. For D1 with read replication this routes anonymous reads
- // to the nearest replica; for other adapters it's a no-op.
- const anonScoped = createRequestScopedDb({
+ // Ask the adapter for a request-scoped db. When it returns one, we stash
+ // it in ALS so the runtime's db getter and loader's getDb() pick it up,
+ // then call commit() after next() so the adapter can persist any
+ // per-request state (e.g. a D1 bookmark cookie for read-your-writes).
+ const scoped = createRequestScopedDb({
config: config?.database?.config,
- isAuthenticated: false,
+ isAuthenticated: !!sessionUser,
isWrite: request.method !== "GET" && request.method !== "HEAD",
- cookies,
+ cookies: context.cookies,
url,
});
- const runAnon = async () => {
+
+ const renderAndFinalize = async () => {
const t0 = performance.now();
const response = await next();
timings.push({ name: "render", dur: performance.now() - t0, desc: "Page render" });
timings.push({ name: "mw", dur: performance.now() - mwStart, desc: "Total middleware" });
return finalizeResponse(response, timings);
};
- if (anonScoped) {
+
+ if (scoped) {
const parent = getRequestContext();
- const ctx = parent
- ? { ...parent, db: anonScoped.db }
- : { editMode: false, db: anonScoped.db };
+ const ctx = parent ? { ...parent, db: scoped.db } : { editMode: false, db: scoped.db };
return runWithContext(ctx, async () => {
- const response = await runAnon();
- anonScoped.commit();
+ const response = await renderAndFinalize();
+ scoped.commit();
return response;
});
}
- return runAnon();
- }
- }
-
- const config = getConfig();
- if (!config) {
- console.error("EmDash: No configuration found");
- return finalizeResponse(await next());
- }
- // In playground mode, wrap the entire runtime init + request handling in
- // runWithContext so that getDatabase() and all init queries use the real
- // DO database via the same AsyncLocalStorage instance as the loader.
- const doInit = async () => {
- const timings: Array<{ name: string; dur: number; desc?: string }> = [];
- const mwStart = performance.now();
+ return renderAndFinalize();
+ }; // end doInit
- try {
- // Get or create runtime. Sub-phase timings (rt.db, rt.fts, rt.plugins,
- // rt.site, rt.sandbox, rt.market, rt.hooks, rt.cron) are populated
- // only on the cold init — subsequent warm calls find the cached
- // instance and `initSubTimings` stays empty.
- const initSubTimings: Array<{ name: string; dur: number; desc?: string }> = [];
- let t0 = performance.now();
- const runtime = await getRuntime(config, initSubTimings);
- timings.push({ name: "rt", dur: performance.now() - t0, desc: "Runtime init" });
- // Forward any sub-phase samples so cold-start breakdown is visible
- // in Server-Timing. Each phase appears prefixed "rt." to distinguish
- // from the aggregate "rt" timing above.
- for (const sub of initSubTimings) timings.push(sub);
-
- // Runtime init runs migrations, so the DB is guaranteed set up
- setupVerified = true;
-
- // Get manifest (cached after first call)
- t0 = performance.now();
- const manifest = await runtime.getManifest();
- timings.push({ name: "manifest", dur: performance.now() - t0, desc: "Manifest" });
-
- // Attach to locals for route handlers
- locals.emdashManifest = manifest;
- locals.emdash = {
- // Content handlers
- handleContentList: runtime.handleContentList.bind(runtime),
- handleContentGet: runtime.handleContentGet.bind(runtime),
- handleContentCreate: runtime.handleContentCreate.bind(runtime),
- handleContentUpdate: runtime.handleContentUpdate.bind(runtime),
- handleContentDelete: runtime.handleContentDelete.bind(runtime),
-
- // Trash handlers
- handleContentListTrashed: runtime.handleContentListTrashed.bind(runtime),
- handleContentRestore: runtime.handleContentRestore.bind(runtime),
- handleContentPermanentDelete: runtime.handleContentPermanentDelete.bind(runtime),
- handleContentCountTrashed: runtime.handleContentCountTrashed.bind(runtime),
- handleContentGetIncludingTrashed: runtime.handleContentGetIncludingTrashed.bind(runtime),
-
- // Duplicate handler
- handleContentDuplicate: runtime.handleContentDuplicate.bind(runtime),
-
- // Publishing & Scheduling handlers
- handleContentPublish: runtime.handleContentPublish.bind(runtime),
- handleContentUnpublish: runtime.handleContentUnpublish.bind(runtime),
- handleContentSchedule: runtime.handleContentSchedule.bind(runtime),
- handleContentUnschedule: runtime.handleContentUnschedule.bind(runtime),
- handleContentCountScheduled: runtime.handleContentCountScheduled.bind(runtime),
- handleContentDiscardDraft: runtime.handleContentDiscardDraft.bind(runtime),
- handleContentCompare: runtime.handleContentCompare.bind(runtime),
- handleContentTranslations: runtime.handleContentTranslations.bind(runtime),
-
- // Media handlers
- handleMediaList: runtime.handleMediaList.bind(runtime),
- handleMediaGet: runtime.handleMediaGet.bind(runtime),
- handleMediaCreate: runtime.handleMediaCreate.bind(runtime),
- handleMediaUpdate: runtime.handleMediaUpdate.bind(runtime),
- handleMediaDelete: runtime.handleMediaDelete.bind(runtime),
-
- // Revision handlers
- handleRevisionList: runtime.handleRevisionList.bind(runtime),
- handleRevisionGet: runtime.handleRevisionGet.bind(runtime),
- handleRevisionRestore: runtime.handleRevisionRestore.bind(runtime),
-
- // Plugin routes
- handlePluginApiRoute: runtime.handlePluginApiRoute.bind(runtime),
- getPluginRouteMeta: runtime.getPluginRouteMeta.bind(runtime),
-
- // Media provider methods
- getMediaProvider: runtime.getMediaProvider.bind(runtime),
- getMediaProviderList: runtime.getMediaProviderList.bind(runtime),
-
- // Page contribution methods (for EmDashHead/EmDashBodyStart/EmDashBodyEnd)
- collectPageMetadata: runtime.collectPageMetadata.bind(runtime),
- collectPageFragments: runtime.collectPageFragments.bind(runtime),
-
- // Lazy search index health check — search endpoints call this
- // before querying so a crash-corrupted index gets repaired on
- // first use rather than stalling every cold start.
- ensureSearchHealthy: runtime.ensureSearchHealthy.bind(runtime),
-
- // Direct access (for advanced use cases)
- storage: runtime.storage,
- db: runtime.db,
- hooks: runtime.hooks,
- email: runtime.email,
- configuredPlugins: runtime.configuredPlugins,
-
- // Configuration (for checking database type, auth mode, etc.)
- config,
-
- // Manifest invalidation (call after schema changes)
- invalidateManifest: runtime.invalidateManifest.bind(runtime),
-
- // Sandbox runner (for marketplace plugin install/update)
- getSandboxRunner: runtime.getSandboxRunner.bind(runtime),
-
- // Sync marketplace plugin states (after install/update/uninstall)
- syncMarketplacePlugins: runtime.syncMarketplacePlugins.bind(runtime),
-
- // Update plugin enabled/disabled status and rebuild hook pipeline
- setPluginStatus: runtime.setPluginStatus.bind(runtime),
- };
- } catch (error) {
- console.error("EmDash middleware error:", error);
- }
-
- // Ask the adapter for a request-scoped db. When it returns one, we stash
- // it in ALS so the runtime's db getter and loader's getDb() pick it up,
- // then call commit() after next() so the adapter can persist any
- // per-request state (e.g. a D1 bookmark cookie for read-your-writes).
- const scoped = createRequestScopedDb({
- config: config?.database?.config,
- isAuthenticated: !!sessionUser,
- isWrite: request.method !== "GET" && request.method !== "HEAD",
- cookies: context.cookies,
- url,
- });
-
- const renderAndFinalize = async () => {
- const t0 = performance.now();
- const response = await next();
- timings.push({ name: "render", dur: performance.now() - t0, desc: "Page render" });
- timings.push({ name: "mw", dur: performance.now() - mwStart, desc: "Total middleware" });
- return finalizeResponse(response, timings);
- };
-
- if (scoped) {
+ if (playgroundDb) {
+ // Read the edit-mode cookie to determine if visual editing is active.
+ // Default to false -- editing is opt-in via the playground toolbar toggle.
+ const editMode = context.cookies.get("emdash-edit-mode")?.value === "true";
+ // Playground DBs are per-session isolated instances whose schema is
+ // independent of the configured one — flag as isolated so schema-
+ // derived caches (manifest, taxonomy defs) rebuild against it.
const parent = getRequestContext();
- const ctx = parent ? { ...parent, db: scoped.db } : { editMode: false, db: scoped.db };
- return runWithContext(ctx, async () => {
- const response = await renderAndFinalize();
- scoped.commit();
- return response;
- });
+ const ctx = parent
+ ? { ...parent, editMode, db: playgroundDb, dbIsIsolated: true }
+ : { editMode, db: playgroundDb, dbIsIsolated: true };
+ return runWithContext(ctx, doInit);
}
+ return doInit();
+ };
- return renderAndFinalize();
- }; // end doInit
-
- if (playgroundDb) {
- // Read the edit-mode cookie to determine if visual editing is active.
- // Default to false -- editing is opt-in via the playground toolbar toggle.
- const editMode = context.cookies.get("emdash-edit-mode")?.value === "true";
- // Playground DBs are per-session isolated instances whose schema is
- // independent of the configured one — flag as isolated so schema-
- // derived caches (manifest, taxonomy defs) rebuild against it.
- return runWithContext({ editMode, db: playgroundDb, dbIsIsolated: true }, doInit);
+ if (queryRecorder) {
+ try {
+ return await runWithContext({ editMode: false, queryRecorder }, run);
+ } finally {
+ flushRecorder(queryRecorder);
+ }
}
- return doInit();
+ return run();
});
export default onRequest;
diff --git a/packages/core/src/database/connection.ts b/packages/core/src/database/connection.ts
index 7fdd1ea22..c7e034d8c 100644
--- a/packages/core/src/database/connection.ts
+++ b/packages/core/src/database/connection.ts
@@ -1,6 +1,7 @@
import BetterSqlite3 from "better-sqlite3";
import { Kysely, SqliteDialect } from "kysely";
+import { kyselyLogOption } from "./instrumentation.js";
import type { Database } from "./types.js";
export interface DatabaseConfig {
@@ -62,7 +63,7 @@ export function createDatabase(config: DatabaseConfig): Kysely {
database: sqlite,
});
- return new Kysely({ dialect });
+ return new Kysely({ dialect, log: kyselyLogOption() });
}
// Handle libSQL (Turso)
diff --git a/packages/core/src/database/instrumentation.ts b/packages/core/src/database/instrumentation.ts
new file mode 100644
index 000000000..187f53a50
--- /dev/null
+++ b/packages/core/src/database/instrumentation.ts
@@ -0,0 +1,98 @@
+/**
+ * Query instrumentation
+ *
+ * Dev/test-only: captures every Kysely query executed inside a request,
+ * tagged with the route, method, and a caller-supplied phase (e.g. "cold"
+ * or "warm"). Events are emitted as prefixed NDJSON on stdout so the
+ * harness can capture them from both Node and workerd — workerd has no
+ * filesystem access, but `console.log` is portable.
+ *
+ * The recorder lives on the request context (AsyncLocalStorage). The
+ * Kysely `log` hook reads the recorder at query time and appends an
+ * event. When no recorder is attached, the hook is a null check.
+ */
+
+import type { LogEvent, Logger } from "kysely";
+
+import { getRequestContext } from "../request-context.js";
+
+export const QUERY_LOG_ENV = "EMDASH_QUERY_LOG";
+export const QUERY_LOG_PREFIX = "[emdash-query-log]";
+
+export interface QueryEvent {
+ sql: string;
+ params: readonly unknown[];
+ durationMs: number;
+ route: string;
+ method: string;
+ phase: string;
+}
+
+export interface QueryRecorder {
+ events: QueryEvent[];
+ route: string;
+ method: string;
+ phase: string;
+}
+
+export function createRecorder(route: string, method: string, phase: string): QueryRecorder {
+ return { events: [], route, method, phase };
+}
+
+export function recordEvent(
+ rec: QueryRecorder,
+ sql: string,
+ params: readonly unknown[],
+ durationMs: number,
+): void {
+ rec.events.push({
+ sql,
+ params,
+ durationMs,
+ route: rec.route,
+ method: rec.method,
+ phase: rec.phase,
+ });
+}
+
+/**
+ * Emit all events from a recorder as prefixed NDJSON on stdout. The
+ * harness pipes the child's stdout, filters lines beginning with
+ * QUERY_LOG_PREFIX, and writes them to its own file. Using stdout means
+ * the sink works uniformly in Node and in workerd (which has no fs).
+ */
+export function flushRecorder(rec: QueryRecorder): void {
+ if (rec.events.length === 0) return;
+ for (const e of rec.events) {
+ console.log(`${QUERY_LOG_PREFIX} ${JSON.stringify(e)}`);
+ }
+}
+
+/**
+ * Whether query instrumentation is enabled. Read at Kysely construction
+ * time and middleware entry — the env var is a process-lifetime flag, not
+ * per-request. Gated via `process.env` so adapters that ship env through
+ * to the worker (e.g. Miniflare via wrangler.jsonc `vars` or host env
+ * pass-through) can enable it at runtime.
+ */
+export function isInstrumentationEnabled(): boolean {
+ return Boolean(
+ typeof process !== "undefined" && process.env && process.env[QUERY_LOG_ENV] === "1",
+ );
+}
+
+function kyselyLog(event: LogEvent): void {
+ if (event.level !== "query") return;
+ const rec = getRequestContext()?.queryRecorder;
+ if (!rec) return;
+ recordEvent(rec, event.query.sql, event.query.parameters, event.queryDurationMillis);
+}
+
+/**
+ * Returns a Kysely `log` option when instrumentation is enabled, or undefined.
+ * Pass as `new Kysely({ dialect, log: kyselyLogOption() })` so disabled mode
+ * has zero overhead — Kysely skips query timing entirely when `log` is absent.
+ */
+export function kyselyLogOption(): Logger | undefined {
+ return isInstrumentationEnabled() ? kyselyLog : undefined;
+}
diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts
index 0834e8738..c818a87e2 100644
--- a/packages/core/src/emdash-runtime.ts
+++ b/packages/core/src/emdash-runtime.ts
@@ -20,6 +20,7 @@ import type {
import type { EmDashManifest, ManifestCollection } from "./astro/types.js";
import { getAuthMode } from "./auth/mode.js";
import { isSqlite } from "./database/dialect-helpers.js";
+import { kyselyLogOption } from "./database/instrumentation.js";
import { runMigrations } from "./database/migrations/runner.js";
import { RevisionRepository } from "./database/repositories/revision.js";
import type { ContentItem as ContentItemInternal } from "./database/repositories/types.js";
@@ -934,7 +935,7 @@ export class EmDashRuntime {
dbInitPromise = (async () => {
const dialect = deps.createDialect(dbConfig.config);
- const db = new Kysely({ dialect });
+ const db = new Kysely({ dialect, log: kyselyLogOption() });
const { applied } = await runMigrations(db);
diff --git a/packages/core/src/loader.ts b/packages/core/src/loader.ts
index 0c7fc5bc1..dbc887552 100644
--- a/packages/core/src/loader.ts
+++ b/packages/core/src/loader.ts
@@ -15,6 +15,7 @@ import type { LiveLoader } from "astro/loaders";
import { Kysely, sql, type Dialect } from "kysely";
import { currentTimestampValue, isPostgres } from "./database/dialect-helpers.js";
+import { kyselyLogOption } from "./database/instrumentation.js";
import { decodeCursor, encodeCursor } from "./database/repositories/types.js";
import { validateIdentifier } from "./database/validate.js";
import type { Database } from "./index.js";
@@ -410,7 +411,7 @@ export async function getDb(): Promise> {
);
}
const dialect = virtualCreateDialect(virtualConfig.database.config);
- dbInstance = new Kysely({ dialect });
+ dbInstance = new Kysely({ dialect, log: kyselyLogOption() });
}
return dbInstance;
}
diff --git a/packages/core/src/request-context.ts b/packages/core/src/request-context.ts
index 40c4c994a..ada0868ec 100644
--- a/packages/core/src/request-context.ts
+++ b/packages/core/src/request-context.ts
@@ -17,6 +17,8 @@
import { AsyncLocalStorage } from "node:async_hooks";
+import type { QueryRecorder } from "./database/instrumentation.js";
+
export interface EmDashRequestContext {
/** Whether the current request is in visual editing mode */
editMode: boolean;
@@ -46,6 +48,12 @@ export interface EmDashRequestContext {
* cache remains valid.
*/
dbIsIsolated?: boolean;
+ /**
+ * Query recorder attached by middleware when EMDASH_QUERY_LOG_FILE is set.
+ * The Kysely `log` hook appends an event per query; middleware flushes
+ * to NDJSON after the response.
+ */
+ queryRecorder?: QueryRecorder;
}
const ALS_KEY = Symbol.for("emdash:request-context");
diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts
index e093b015d..85ebede42 100644
--- a/packages/core/tsdown.config.ts
+++ b/packages/core/tsdown.config.ts
@@ -30,6 +30,8 @@ export default defineConfig({
"src/db/sqlite.ts",
"src/db/libsql.ts",
"src/db/postgres.ts",
+ // Query instrumentation (used by first-party adapters like @emdash-cms/cloudflare)
+ "src/database/instrumentation.ts",
// Storage adapters (runtime - loaded via virtual:emdash/storage)
"src/storage/local.ts",
"src/storage/s3.ts",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 26603413d..293e52701 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -486,6 +486,49 @@ importers:
specifier: 'catalog:'
version: 19.2.4(react@19.2.4)
+ fixtures/perf-site:
+ dependencies:
+ '@astrojs/cloudflare':
+ specifier: 'catalog:'
+ version: 13.1.7(@types/node@24.10.13)(astro@6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@6.0.0-beta)(yaml@2.8.2))(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(wrangler@4.80.0(@cloudflare/workers-types@4.20260305.1))(yaml@2.8.2)
+ '@astrojs/node':
+ specifier: 'catalog:'
+ version: 10.0.0(astro@6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@6.0.0-beta)(yaml@2.8.2))
+ '@astrojs/react':
+ specifier: 'catalog:'
+ version: 5.0.0(@types/node@24.10.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@2.6.1)(lightningcss@1.31.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2)
+ '@emdash-cms/cloudflare':
+ specifier: workspace:*
+ version: link:../../packages/cloudflare
+ astro:
+ specifier: 'catalog:'
+ version: 6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@6.0.0-beta)(yaml@2.8.2)
+ better-sqlite3:
+ specifier: 'catalog:'
+ version: 12.8.0
+ emdash:
+ specifier: workspace:*
+ version: link:../../packages/core
+ kysely:
+ specifier: ^0.27.0
+ version: 0.27.6
+ react:
+ specifier: 'catalog:'
+ version: 19.2.4
+ react-dom:
+ specifier: 'catalog:'
+ version: 19.2.4(react@19.2.4)
+ devDependencies:
+ '@astrojs/check':
+ specifier: 'catalog:'
+ version: 0.9.7(prettier-plugin-astro@0.14.1)(prettier@3.8.1)(typescript@6.0.0-beta)
+ '@cloudflare/workers-types':
+ specifier: 'catalog:'
+ version: 4.20260305.1
+ wrangler:
+ specifier: 'catalog:'
+ version: 4.80.0(@cloudflare/workers-types@4.20260305.1)
+
i18n:
devDependencies:
'@types/node':
@@ -717,7 +760,7 @@ importers:
version: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
vitest:
specifier: 'catalog:'
- version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
+ version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
vitest-browser-react:
specifier: ^2.0.5
version: 2.0.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.0.18)
@@ -763,7 +806,7 @@ importers:
version: 5.9.3
vitest:
specifier: 'catalog:'
- version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
+ version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
packages/blocks:
dependencies:
@@ -818,7 +861,7 @@ importers:
version: 5.9.3
vitest:
specifier: 'catalog:'
- version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
+ version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
packages/blocks/playground:
dependencies:
@@ -901,7 +944,7 @@ importers:
version: 5.9.3
vitest:
specifier: 'catalog:'
- version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
+ version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
packages/core:
dependencies:
@@ -1130,7 +1173,7 @@ importers:
version: 5.9.3
vitest:
specifier: 'catalog:'
- version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
+ version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
packages/marketplace:
dependencies:
@@ -1161,7 +1204,7 @@ importers:
version: 5.9.3
vitest:
specifier: 'catalog:'
- version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
+ version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
wrangler:
specifier: 'catalog:'
version: 4.80.0(@cloudflare/workers-types@4.20260305.1)
@@ -1189,7 +1232,7 @@ importers:
version: 19.2.14
vitest:
specifier: 'catalog:'
- version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
+ version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
packages/plugins/api-test:
dependencies:
@@ -1217,7 +1260,7 @@ importers:
version: 5.9.3
vitest:
specifier: 'catalog:'
- version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
+ version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
packages/plugins/audit-log:
dependencies:
@@ -1346,7 +1389,7 @@ importers:
version: 5.9.3
vitest:
specifier: 'catalog:'
- version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
+ version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
optionalDependencies:
'@x402/svm':
specifier: ^2.8.0
@@ -12470,7 +12513,7 @@ snapshots:
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))
playwright: 1.58.2
tinyrainbow: 3.0.3
- vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
+ vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- bufferutil
- msw
@@ -12504,7 +12547,7 @@ snapshots:
pngjs: 7.0.0
sirv: 3.0.2
tinyrainbow: 3.0.3
- vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
+ vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
@@ -16964,7 +17007,7 @@ snapshots:
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
- vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
+ vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
@@ -17009,6 +17052,45 @@ snapshots:
- tsx
- yaml
+ vitest@4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2):
+ dependencies:
+ '@vitest/expect': 4.0.18
+ '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))
+ '@vitest/pretty-format': 4.0.18
+ '@vitest/runner': 4.0.18
+ '@vitest/snapshot': 4.0.18
+ '@vitest/spy': 4.0.18
+ '@vitest/utils': 4.0.18
+ es-module-lexer: 1.7.0
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ obug: 2.1.1
+ pathe: 2.0.3
+ picomatch: 4.0.3
+ std-env: 3.10.0
+ tinybench: 2.9.0
+ tinyexec: 1.0.2
+ tinyglobby: 0.2.15
+ tinyrainbow: 3.0.3
+ vite: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 24.10.13
+ '@vitest/browser-playwright': 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)
+ jsdom: 26.1.0
+ transitivePeerDependencies:
+ - jiti
+ - less
+ - lightningcss
+ - msw
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - terser
+ - tsx
+ - yaml
+
volar-service-css@0.0.68(@volar/language-service@2.4.27):
dependencies:
vscode-css-languageservice: 6.3.9
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index beb2d3825..e948aeeb0 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -5,6 +5,7 @@ packages:
- templates/*
- packages/blocks/playground
- e2e/fixture
+ - fixtures/*
- docs
- i18n
- infra/blog-demo
diff --git a/scripts/query-counts.mjs b/scripts/query-counts.mjs
new file mode 100644
index 000000000..7834d0ade
--- /dev/null
+++ b/scripts/query-counts.mjs
@@ -0,0 +1,456 @@
+#!/usr/bin/env node
+/**
+ * Query-count harness for the runtime perf fixture.
+ *
+ * Builds fixtures/perf-site with `astro build`, then serves it via the
+ * production adapter entry (node or wrangler, never `astro dev`) so the
+ * measured code paths match what real visitors hit. For each fixture
+ * route we record cold and warm phase queries — the Kysely log hook
+ * emits `[emdash-query-log]`-prefixed NDJSON on stdout, which the harness
+ * captures.
+ *
+ * Two targets, two server strategies:
+ * --target sqlite Node adapter standalone entry. One long-lived
+ * process. First request warms the runtime (migrations
+ * + auto-seed on first boot). Cold/warm is per-route
+ * first-vs-second hit.
+ *
+ * --target d1 Cloudflare adapter via `astro preview` (wrangler dev
+ * against the built worker). Because real D1 visitors
+ * often land on a fresh isolate, we measure that:
+ * seed once in a dedicated boot, stop; then spin a
+ * fresh preview per route for one cold + one warm
+ * hit, stop, next route.
+ *
+ * Seeding (per target):
+ * sqlite: `emdash init && emdash seed` via the CLI — writes directly to
+ * data.db, no HTTP layer involved.
+ * d1: astro dev + POST /_emdash/api/setup/dev-bypass. The dev-bypass
+ * endpoint is dead-code-eliminated from prod builds, so it's
+ * only reachable via dev mode. Local D1 state persists in
+ * .wrangler/state across dev → preview.
+ *
+ * Usage:
+ * node scripts/query-counts.mjs # sqlite, compare
+ * node scripts/query-counts.mjs --target d1 # d1, compare
+ * node scripts/query-counts.mjs --update # rewrite snapshot
+ * node scripts/query-counts.mjs --target d1 --update
+ * node scripts/query-counts.mjs --skip-seed # reuse existing db
+ * node scripts/query-counts.mjs --skip-build # reuse existing build
+ *
+ * --skip-seed and --skip-build compose. Passing both gives the fastest
+ * local iteration loop once the fixture is set up.
+ *
+ * Prerequisite: `pnpm build` has run (the emdash CLI lives in dist/).
+ */
+
+import { spawn, spawnSync } from "node:child_process";
+import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { createConnection } from "node:net";
+import { dirname, resolve } from "node:path";
+import { createInterface } from "node:readline";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const repoRoot = resolve(__dirname, "..");
+const fixtureDir = resolve(repoRoot, "fixtures/perf-site");
+
+const HOST = "127.0.0.1";
+const PORT = 14321;
+const BASE = `http://${HOST}:${PORT}`;
+
+const ROUTES = [
+ ["GET", "/"],
+ ["GET", "/posts"],
+ ["GET", "/posts/building-for-the-long-term"],
+ ["GET", "/pages/about"],
+ ["GET", "/category/development"],
+ ["GET", "/tag/webdev"],
+ ["GET", "/rss.xml"],
+ ["GET", "/search?q=static"],
+];
+
+const TRACKED_PHASES = new Set(["cold", "warm"]);
+const VALID_TARGETS = new Set(["sqlite", "d1"]);
+const QUERY_LOG_PREFIX = "[emdash-query-log] ";
+
+/**
+ * Resolve once a TCP connection to (host, port) succeeds, or reject on
+ * timeout. Uses a raw TCP connect rather than an HTTP request so we
+ * don't warm a fresh workerd isolate — workerd initialises the isolate
+ * on the first HTTP request, not on TCP accept. This keeps the
+ * per-route "cold" measurement genuinely cold on the D1 path.
+ */
+function waitForPort(host, port, timeoutMs = 120_000) {
+ const deadline = Date.now() + timeoutMs;
+ return new Promise((resolveReady, rejectReady) => {
+ const attempt = () => {
+ if (Date.now() > deadline) {
+ rejectReady(new Error(`port ${host}:${port} did not open within ${timeoutMs}ms`));
+ return;
+ }
+ const socket = createConnection({ host, port });
+ socket.once("connect", () => {
+ socket.destroy();
+ resolveReady();
+ });
+ socket.once("error", () => {
+ socket.destroy();
+ setTimeout(attempt, 100);
+ });
+ };
+ attempt();
+ });
+}
+
+function parseArgs(argv) {
+ const out = { target: "sqlite", update: false, skipBuild: false, skipSeed: false };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === "--update") out.update = true;
+ else if (a === "--skip-build") out.skipBuild = true;
+ else if (a === "--skip-seed") out.skipSeed = true;
+ else if (a === "--target") {
+ out.target = argv[++i];
+ } else if (a.startsWith("--target=")) {
+ out.target = a.slice("--target=".length);
+ } else {
+ throw new Error(`Unknown argument: ${a}`);
+ }
+ }
+ if (!VALID_TARGETS.has(out.target)) {
+ throw new Error(`--target must be one of: ${[...VALID_TARGETS].join(", ")}`);
+ }
+ return out;
+}
+
+const { target, update, skipBuild, skipSeed } = parseArgs(process.argv.slice(2));
+const snapshotPath = resolve(__dirname, `query-counts.snapshot.${target}.json`);
+
+function resetSqliteState() {
+ for (const f of ["data.db", "data.db-wal", "data.db-shm"]) {
+ rmSync(resolve(fixtureDir, f), { force: true });
+ }
+ rmSync(resolve(fixtureDir, "uploads"), { recursive: true, force: true });
+}
+
+function resetD1State() {
+ rmSync(resolve(fixtureDir, ".wrangler"), { recursive: true, force: true });
+}
+
+const buildMarkerPath = resolve(fixtureDir, "dist/.perf-target");
+
+function buildFixture() {
+ process.stdout.write(`$ (cd ${fixtureDir}) astro build\n`);
+ const r = spawnSync("pnpm", ["exec", "astro", "build"], {
+ cwd: fixtureDir,
+ stdio: "inherit",
+ env: { ...process.env, EMDASH_FIXTURE_TARGET: target },
+ });
+ if (r.status !== 0) throw new Error("astro build failed");
+ writeFileSync(buildMarkerPath, target + "\n");
+}
+
+function assertExistingBuildMatchesTarget() {
+ if (!existsSync(buildMarkerPath)) {
+ throw new Error(
+ `--skip-build was passed but dist/.perf-target is missing. Run without --skip-build to produce a build for target "${target}".`,
+ );
+ }
+ const built = readFileSync(buildMarkerPath, "utf8").trim();
+ if (built !== target) {
+ throw new Error(
+ `--skip-build was passed but existing build is for target "${built}", not "${target}". Drop --skip-build (or rebuild) to switch targets.`,
+ );
+ }
+}
+
+// SQLite: seed the file DB via the emdash CLI directly — it runs
+// migrations, applies the virtual-module seed, and sets
+// `emdash:setup_complete`, all without going through the HTTP layer.
+//
+// We invoke the CLI entry by absolute path rather than via `pnpm exec
+// emdash` so the harness works in CI, where pnpm's bin-linking step
+// isn't run (see scripts/relink-bins-if-needed.mjs — it early-exits
+// under CI, expecting the CI job to handle bin links, which this job
+// intentionally does not).
+const emdashCliPath = resolve(repoRoot, "packages/core/dist/cli/index.mjs");
+
+function seedSqliteCli() {
+ for (const step of ["init", "seed"]) {
+ process.stdout.write(`$ (cd ${fixtureDir}) node ${step}\n`);
+ const r = spawnSync("node", [emdashCliPath, step], {
+ cwd: fixtureDir,
+ stdio: "inherit",
+ env: { ...process.env, EMDASH_FIXTURE_TARGET: "sqlite" },
+ });
+ if (r.status !== 0) throw new Error(`emdash ${step} failed`);
+ }
+}
+
+// D1: the CLI can't reach D1 over the Workers protocol, so we seed by
+// running astro dev once (dev-bypass is gated on import.meta.env.DEV
+// and is stripped from prod builds) and hitting the dev-bypass endpoint.
+// Local D1 state persists in .wrangler/state across dev → preview.
+async function seedD1ViaDevBypass(events) {
+ process.stdout.write(`--- seeding via astro dev + dev-bypass ---\n`);
+ const child = spawn("pnpm", ["exec", "astro", "dev", "--host", HOST, "--port", String(PORT)], {
+ cwd: fixtureDir,
+ env: {
+ ...process.env,
+ EMDASH_FIXTURE_TARGET: "d1",
+ EMDASH_QUERY_LOG: "1",
+ },
+ stdio: ["ignore", "pipe", "inherit"],
+ });
+
+ const rl = createInterface({ input: child.stdout });
+ rl.on("line", (line) => {
+ const idx = line.indexOf(QUERY_LOG_PREFIX);
+ if (idx !== -1) {
+ const payload = line.slice(idx + QUERY_LOG_PREFIX.length);
+ try {
+ events.push(JSON.parse(payload));
+ } catch {
+ // ignore
+ }
+ return;
+ }
+ process.stdout.write(line + "\n");
+ });
+ const exited = new Promise((res) => child.once("exit", res));
+
+ try {
+ await waitForPort(HOST, PORT);
+ const r = await fetch(`${BASE}/_emdash/api/setup/dev-bypass`, {
+ method: "POST",
+ redirect: "manual",
+ });
+ if (!r.ok) {
+ const body = await r.text();
+ throw new Error(`dev-bypass failed: ${r.status} ${body.slice(0, 200)}`);
+ }
+ await r.arrayBuffer();
+ process.stdout.write(` seed via dev-bypass -> ${r.status}\n`);
+ } finally {
+ child.kill("SIGTERM");
+ await Promise.race([
+ exited,
+ new Promise((r) => setTimeout(r, 5_000)).then(() => child.kill("SIGKILL")),
+ ]);
+ await new Promise((r) => setTimeout(r, 250));
+ }
+}
+
+/**
+ * Spawn the prod server for the current target. Returns { ready, stop }.
+ * sqlite: node ./dist/server/entry.mjs (HOST/PORT env)
+ * d1: astro preview (cloudflare adapter → wrangler dev)
+ * `ready` resolves on a successful TCP connection — no HTTP probing,
+ * so a fresh workerd isolate stays cold until our first tagged request.
+ */
+function startServer({ collectedEvents }) {
+ let cmd;
+ let args;
+ if (target === "sqlite") {
+ cmd = "node";
+ args = ["./dist/server/entry.mjs"];
+ } else {
+ cmd = "pnpm";
+ args = ["exec", "astro", "preview", "--host", HOST, "--port", String(PORT)];
+ }
+
+ const child = spawn(cmd, args, {
+ cwd: fixtureDir,
+ env: {
+ ...process.env,
+ EMDASH_FIXTURE_TARGET: target,
+ EMDASH_QUERY_LOG: "1",
+ HOST,
+ PORT: String(PORT),
+ },
+ stdio: ["ignore", "pipe", "inherit"],
+ });
+
+ const ready = waitForPort(HOST, PORT);
+
+ const rl = createInterface({ input: child.stdout });
+ rl.on("line", (line) => {
+ const idx = line.indexOf(QUERY_LOG_PREFIX);
+ if (idx !== -1) {
+ const before = line.slice(0, idx);
+ if (before.trim().length > 0) process.stdout.write(before + "\n");
+ const payload = line.slice(idx + QUERY_LOG_PREFIX.length);
+ try {
+ collectedEvents.push(JSON.parse(payload));
+ } catch {
+ process.stderr.write(`bad query-log line: ${payload}\n`);
+ }
+ return;
+ }
+ process.stdout.write(line + "\n");
+ });
+
+ const exited = new Promise((res) => child.once("exit", res));
+ child.once("error", (err) => {
+ process.stderr.write(`server spawn error: ${err.message}\n`);
+ });
+
+ async function stop() {
+ child.kill("SIGTERM");
+ await Promise.race([
+ exited,
+ new Promise((r) => setTimeout(r, 5_000)).then(() => child.kill("SIGKILL")),
+ ]);
+ // Small pause for the OS to release the port before the next spawn.
+ await new Promise((r) => setTimeout(r, 250));
+ }
+
+ return { ready, stop };
+}
+
+async function hit(method, path, phase) {
+ // Tiny retry for the very first hit against a just-spawned wrangler
+ // preview — "ready" fires before the HTTP listener actually accepts
+ // on some runs. We're not measuring these retry attempts (they're
+ // in the "default" phase), just papering over a race.
+ let lastErr;
+ for (let i = 0; i < 10; i++) {
+ try {
+ const r = await fetch(`${BASE}${path}`, {
+ method,
+ headers: { "x-perf-phase": phase },
+ redirect: "manual",
+ });
+ await r.arrayBuffer();
+ process.stdout.write(` ${phase.padEnd(5)} ${method} ${path} -> ${r.status}\n`);
+ return r.status;
+ } catch (err) {
+ lastErr = err;
+ await new Promise((r) => setTimeout(r, 200));
+ }
+ }
+ throw lastErr;
+}
+
+// An untagged hit that triggers runtime init (migrations + auto-seed on
+// first boot). Events here land in "default" phase and are filtered out.
+async function warmup() {
+ const r = await fetch(BASE, { redirect: "manual" });
+ await r.arrayBuffer();
+ process.stdout.write(` warmup GET / -> ${r.status}\n`);
+}
+
+function aggregate(events) {
+ const counts = {};
+ for (const e of events) {
+ if (!TRACKED_PHASES.has(e.phase)) continue;
+ const key = `${e.method} ${e.route} (${e.phase})`;
+ counts[key] = (counts[key] ?? 0) + 1;
+ }
+ return Object.fromEntries(Object.entries(counts).toSorted(([a], [b]) => a.localeCompare(b)));
+}
+
+function diffSnapshot(actual) {
+ if (!existsSync(snapshotPath)) {
+ process.stderr.write(`No snapshot at ${snapshotPath}. Run with --update to create one.\n`);
+ return 1;
+ }
+ const expected = JSON.parse(readFileSync(snapshotPath, "utf8"));
+ const keys = [...new Set([...Object.keys(expected), ...Object.keys(actual)])].toSorted();
+ const diffs = [];
+ for (const k of keys) {
+ if (expected[k] !== actual[k]) {
+ diffs.push({ key: k, expected: expected[k], actual: actual[k] });
+ }
+ }
+ if (diffs.length === 0) {
+ process.stdout.write(`OK: query counts match ${snapshotPath}\n`);
+ return 0;
+ }
+ process.stderr.write(`Query counts differ from ${snapshotPath}:\n`);
+ for (const d of diffs) {
+ const e = d.expected ?? "(missing)";
+ const a = d.actual ?? "(missing)";
+ process.stderr.write(` ${d.key}: expected=${e} actual=${a}\n`);
+ }
+ process.stderr.write(
+ `\nIf the change is intentional, run: node scripts/query-counts.mjs --target ${target} --update\n`,
+ );
+ return 1;
+}
+
+// SQLite: seed the file DB via CLI, build, then run one long-lived node
+// entry. Warmup hit absorbs runtime init queries (filtered as "default"
+// phase). Tagged cold = first visit to route (runtime warm); warm = second.
+async function runSqlite(events) {
+ if (!skipSeed) {
+ resetSqliteState();
+ seedSqliteCli();
+ }
+ if (skipBuild) assertExistingBuildMatchesTarget();
+ else buildFixture();
+ const server = startServer({ collectedEvents: events });
+ try {
+ await server.ready;
+ await warmup();
+ for (const [m, p] of ROUTES) await hit(m, p, "cold");
+ for (const [m, p] of ROUTES) await hit(m, p, "warm");
+ } finally {
+ await server.stop();
+ }
+}
+
+// D1: seed via dev-bypass (dev mode only — dev-bypass is stripped from
+// prod builds), then build the worker, then for each route spin up a
+// fresh `astro preview` (cloudflare adapter runs wrangler dev). The
+// first tagged hit lands on a genuinely cold workerd isolate; the
+// second hit shares that isolate.
+//
+// Seed must precede build: `astro dev` leaves `.wrangler/deploy/`
+// without the build-time `config.json` that `astro preview` requires,
+// so building afterwards is what makes the subsequent previews work.
+async function runD1(events) {
+ if (!skipSeed) {
+ resetD1State();
+ // seeding uses its own event sink; we don't want to commingle
+ // those with the measurement events (they're all "default" phase
+ // anyway, but keeping them separate is tidier).
+ await seedD1ViaDevBypass([]);
+ }
+ if (skipBuild) assertExistingBuildMatchesTarget();
+ else buildFixture();
+
+ for (const [m, p] of ROUTES) {
+ process.stdout.write(`--- fresh isolate for ${m} ${p} ---\n`);
+ const server = startServer({ collectedEvents: events });
+ try {
+ await server.ready;
+ await hit(m, p, "cold");
+ await hit(m, p, "warm");
+ } finally {
+ await server.stop();
+ }
+ }
+}
+
+async function main() {
+ const events = [];
+ if (target === "sqlite") await runSqlite(events);
+ else await runD1(events);
+
+ const counts = aggregate(events);
+ if (update) {
+ writeFileSync(snapshotPath, JSON.stringify(counts, null, 2) + "\n");
+ process.stdout.write(`Wrote ${Object.keys(counts).length} entries to ${snapshotPath}\n`);
+ return 0;
+ }
+ return diffSnapshot(counts);
+}
+
+main()
+ .then((code) => process.exit(code ?? 0))
+ .catch((err) => {
+ process.stderr.write(`${err.stack ?? err.message ?? err}\n`);
+ process.exit(1);
+ });
diff --git a/scripts/query-counts.snapshot.d1.json b/scripts/query-counts.snapshot.d1.json
new file mode 100644
index 000000000..2b4edaa51
--- /dev/null
+++ b/scripts/query-counts.snapshot.d1.json
@@ -0,0 +1,18 @@
+{
+ "GET / (cold)": 24,
+ "GET / (warm)": 14,
+ "GET /category/development (cold)": 28,
+ "GET /category/development (warm)": 17,
+ "GET /pages/about (cold)": 24,
+ "GET /pages/about (warm)": 14,
+ "GET /posts (cold)": 24,
+ "GET /posts (warm)": 14,
+ "GET /posts/building-for-the-long-term (cold)": 45,
+ "GET /posts/building-for-the-long-term (warm)": 33,
+ "GET /rss.xml (cold)": 16,
+ "GET /rss.xml (warm)": 6,
+ "GET /search (cold)": 24,
+ "GET /search (warm)": 14,
+ "GET /tag/webdev (cold)": 27,
+ "GET /tag/webdev (warm)": 16
+}
diff --git a/scripts/query-counts.snapshot.sqlite.json b/scripts/query-counts.snapshot.sqlite.json
new file mode 100644
index 000000000..387899da4
--- /dev/null
+++ b/scripts/query-counts.snapshot.sqlite.json
@@ -0,0 +1,18 @@
+{
+ "GET / (cold)": 14,
+ "GET / (warm)": 14,
+ "GET /category/development (cold)": 18,
+ "GET /category/development (warm)": 17,
+ "GET /pages/about (cold)": 14,
+ "GET /pages/about (warm)": 14,
+ "GET /posts (cold)": 14,
+ "GET /posts (warm)": 14,
+ "GET /posts/building-for-the-long-term (cold)": 37,
+ "GET /posts/building-for-the-long-term (warm)": 35,
+ "GET /rss.xml (cold)": 6,
+ "GET /rss.xml (warm)": 6,
+ "GET /search (cold)": 14,
+ "GET /search (warm)": 14,
+ "GET /tag/webdev (cold)": 16,
+ "GET /tag/webdev (warm)": 16
+}