The per-query log (EMDASH_QUERY_LOG=1) flushed its recorder when middleware
returned, i.e. when the response headers were ready but before the body
streamed. Queries issued by components during streaming were appended to the
recorder but never emitted, so the query-count harness only saw pre-header
queries. Astro 7's queued rendering moves more queries into the streaming
phase, which made the gap obvious (the post-detail fixture route reported 9
queries while the request actually ran 24).
Flush the recorder when the body finishes streaming instead (in the same
stream-end transform that already snapshots the metrics), and keep a fallback
flush in the middleware finally for bodyless responses (redirects, 304s). The
flush is now idempotent so the two paths can't double-emit.
Also adds a companion query-text snapshot to the harness
(query-counts.queries.{target}.json): a per-route map of the actual SQL to its
occurrence count, so a count change shows which query appeared or vanished, not
just that the number moved. Snapshots regenerated for both targets now reflect
full per-request query counts.
* perf(core): emit stream-end db metrics after body streaming completes
Server-Timing db.* counters are snapshotted when middleware's next()
returns, but Astro streams the body afterwards and components issue
more DB queries that headers can never report (85-320ms of hidden
post-header query time measured in production).
When query instrumentation is enabled (EMDASH_QUERY_LOG=1), pipe the
rendered response body through an identity TransformStream and emit a
final [emdash-stream-end] NDJSON snapshot (db count/total/offsets,
cache hits/misses, total elapsed) in flush(), once the body finishes
streaming. The metrics object is mutated in-place by the Kysely log
hook, so the flush-time read observes every post-header query. The
wrapper forwards the astro.cookies symbol and drops Content-Length;
it is a no-op when instrumentation is off, the body is null, or no
request metrics are attached.
The query-counts harness now parses the new prefix and prints a
per-route stream-end report (informational only; snapshot files are
unchanged since timings are machine-dependent).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: user-facing changeset wording
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* perf: resolve byline avatar storage key during hydration
Byline content-credit hydration now LEFT JOINs the media table so
entry.data.bylines carry avatarStorageKey/avatarAlt, letting list pages
build a direct avatar URL without a per-author MediaRepository.findById
(an N+1). The fields are additive/optional and null on the plain byline
finders. Inferred author bylines (findByUserIds) get the same join.
Also adds opt-in seed support for byline avatars (bylines[].avatar,
hermetic: no download) and a perf-fixture route plus query-count
snapshots measuring the optimized vs naive per-avatar-lookup paths.
* fix: harden seed avatar validation and cleanup per PR review
- deleteMediaRow is now best-effort (logs+swallows) so cleanup failure
can't mask the original byline-write error (emdashbot)
- reject leading/trailing whitespace on avatar storageKey/filename/mimeType,
which are used verbatim in the media lookup (Copilot)
- correct docstrings: storage keys aren't constrained to {ulid}{ext}, and
the new BylineSummary fields are null (not absent) on plain finders (Copilot)
* ci: auto-update query-count snapshots on PR instead of failing
Three workflows replace the single fail-on-drift job:
1. Query Counts (pull_request) — runs the harness, uploads the
regenerated snapshots as an artifact if they drift. Always passes,
runs with read-only permissions and PR-authored code.
2. Query Counts — Apply (workflow_run) — triggered when the measure
workflow completes. Downloads the artifact, cross-verifies the PR
number against the workflow_run's head SHA to guard against a
tampered artifact, and pushes the regenerated snapshots back to the
PR branch (same-repo directly, forks via GIT_ASKPASS). Never runs
PR-authored code, so it can hold the app token safely.
3. Query Counts — Label (pull_request_target, paths-filtered) —
applies the "query-count changed" label when a PR diff touches
either snapshot file (either because the author changed them, or
because workflow 2 just auto-pushed).
* ci(query-counts): fail if fork push fails
If the bot can't push snapshots to a fork PR (most likely because the
contributor has 'Allow edits by maintainers' disabled), fail the Apply
workflow loudly instead of silently succeeding. That way the PR shows
a red check and the reviewer knows to either ask the contributor to
enable maintainer edits or run the harness themselves.
* ci(query-counts): fix artifact upload and eliminate false-positive drift
Two issues in the first run on main:
1. actions/upload-artifact treats dirs starting with a dot as hidden and
skips them when include-hidden-files is false (the default), so the
staging dir .query-counts-out/ uploaded zero files and failed the
step. Renamed to query-counts-out/ (and gitignored).
2. The harness wrote snapshots with JSON.stringify's default 2-space
indent. oxfmt then reformatted the committed files to tabs (per the
repo's prettier config), which produced a permanent whitespace diff
every time CI regenerated the files. The drift check interpreted
that as a real count change. Switched the harness to tab indent so
its output matches the formatted file verbatim.
* ci(query-counts): address Copilot review
- Add actions:read to the Apply workflow's permissions so the API
calls to list and download artifacts are authorised. With explicit
permissions, everything not named becomes none.
- Check out the measured SHA from workflow_run.head_sha instead of
the branch ref. If the PR branch has advanced between measure and
apply, push HEAD:ref would fail non-fast-forward instead of
silently applying stale snapshots to a newer tree. The next PR
event kicks off a fresh cycle against the new head.
- Keep client-id (not app-id) for create-github-app-token — app-id
is deprecated in v3.1.1 in favour of client-id per the action's
own action.yml. Other workflows in the repo will migrate at their
next touch.
* feat: add query-count perf harness + instrumentation
Opt-in Kysely log hook gated behind EMDASH_QUERY_LOG=1 emits per-request
NDJSON on stdout so a harness can count DB queries per route. Zero
overhead when disabled. Exposed at emdash/database/instrumentation so
@emdash-cms/cloudflare can wire the same hook into its per-request D1
session Kysely.
Adds fixtures/perf-site (minimal blog-style fixture, dual sqlite/d1
config), scripts/query-counts.mjs (pnpm query-counts), committed
snapshot files for both targets, and a CI job that runs both.
* fix(perf): invoke emdash CLI directly in query-counts harness
pnpm exec emdash fails in CI because bin symlinks aren't linked for
workspace-local packages (see scripts/relink-bins-if-needed.mjs, which
early-exits under CI). Invoke the built CLI entry by absolute path
instead so the harness works in both CI and local dev.
* ci(perf): build all packages for query-counts job
The fixture config imports from @emdash-cms/cloudflare for the d1 path,
so `pnpm run --filter emdash... build` (which only walks emdash's
deps, not its dependents) leaves cloudflare unbuilt and astro fails
to resolve the import when loading the config.
* fix(perf): wait for TCP port instead of parsing stdout for ready
The ready-regex approach was fragile — in CI, the cloudflare adapter's
dev mode wraps output in [vite] prefixes and the "ready in" line
sometimes never matches (observed on the D1 seed step: typegen POST
succeeded but ready timeout still fired).
TCP-connect is the real question anyway ("is the server accepting
connections?"). It also doesn't warm a fresh workerd isolate —
workerd defers isolate creation to the first HTTP request — so the
per-route cold-isolate measurement stays honest.
* fix(perf): seed D1 before building for preview
`astro dev` (the seed step) leaves .wrangler/deploy/ without the
build-time config.json that cloudflare adapter's preview requires, so
running `astro build` after the seed is what makes the subsequent
`astro preview` spins work.