Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/byline-hydration-empty-table.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Fixes sites that don't use bylines paying dead byline lookup queries on every content read. When the bylines table is empty, entries with an author no longer send hydration down the byline query path — the folded result is served directly, removing the wasted round trips from anonymous page renders.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ export function getSiteSetting(key: string) {

**Module-scope singletons must live on `globalThis`.** Vite duplicates modules across SSR chunks; a plain `let cache = null` becomes two variables. Use a `Symbol.for` key on `globalThis`. See `packages/core/src/settings/index.ts` (versioned) and `packages/core/src/request-context.ts` / `request-cache.ts` (per-request).

**Prefer the batch query to a "has any" probe.** Don't add a `SELECT id FROM foo LIMIT 1` to skip work on empty sites -- on live sites you pay the extra query every request for no gain. Handle missing tables with `isMissingTableError`.
**Prefer the batch query to a "has any" probe.** Don't add a `SELECT id FROM foo LIMIT 1` to skip work on empty sites -- on live sites you pay the extra query every request for no gain. Handle missing tables with `isMissingTableError`. The exception is a probe folded into a query the request already runs (an uncorrelated scalar subquery in an existing select list): that adds zero round trips, so it's fine when an empty table lets the request skip follow-up queries entirely.

**Defer bookkeeping with `after(fn)`.** Maintenance writes don't need to block TTFB. `after()` uses workerd's `waitUntil` when available, fire-and-forgets on Node. Wrap your function body in try/catch with a module-specific log prefix.

Expand Down
64 changes: 43 additions & 21 deletions packages/core/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,19 @@ const SYSTEM_COLUMNS = new Set([
// SEO_ALIAS_COLUMNS, never as flat fields.
"_emdash_terms",
"_emdash_bylines",
"_emdash_bylines_exist",
SEO_FOLDED_COLUMN,
]);

/** Markers for byline/taxonomy hydration folded into the content query. */
export const FOLDED_TERMS = Symbol.for("emdash:foldedTerms");
export const FOLDED_BYLINES = Symbol.for("emdash:foldedBylines");
/**
* Marker for whether `_emdash_bylines` has any rows at all (`false` = table
* empty). Lets byline hydration trust an empty fold instead of re-checking
* via the byline query path on sites that never use bylines.
*/
export const FOLDED_BYLINES_EXIST = Symbol.for("emdash:foldedBylinesExist");

/**
* Correlated JSON-array subqueries that fold taxonomy-term and byline hydration
Expand Down Expand Up @@ -144,7 +151,12 @@ function foldedHydrationSelects(db: Kysely<any>, type: string, outer: string) {
: sql.raw("json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', ");
const credit = sql`${creditObj}${bylineInner})`;
const bylines = sql`(SELECT ${agg(credit)} FROM ${sql.ref("_emdash_content_bylines")} AS cb ${foldJoin} ${sql.ref("_emdash_bylines")} AS b ON b.translation_group = cb.byline_id LEFT JOIN ${sql.ref("media")} AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ${type} AND cb.content_id = ${o}.id AND b.locale = ${o}.locale) AS ${sql.ref("_emdash_bylines")}`;
return { terms, bylines };
// Uncorrelated existence probe (evaluated once per statement, not per row):
// 1 when `_emdash_bylines` has any row, NULL when empty. An empty table
// means an empty fold is authoritative — no credit in any locale, no
// author-fallback byline — so hydration can skip the byline query path.
const bylinesExist = sql`(SELECT 1 FROM ${sql.ref("_emdash_bylines")} LIMIT 1) AS ${sql.ref("_emdash_bylines_exist")}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] Adds (SELECT 1 FROM _emdash_bylines LIMIT 1) as a folded existence probe. AGENTS.md states: "Don't add a SELECT id FROM foo LIMIT 1 to skip work on empty sites — on live sites you pay the extra query every request for no gain." This pattern matches that rule verbatim. While the PR reasonably folds it into the existing SELECT to avoid an extra round trip, the guideline as written prohibits any such probe. Please either update AGENTS.md to explicitly permit same-statement existence probes when they reduce total queries, or find a different mechanism (for example, a cheap per-epoch/bylines count cached at the loader level) that does not add a LIMIT 1 probe to every logged-out content fetch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair reading of the rule as written. I've amended AGENTS.md in this PR to carve out the same-statement shape explicitly: the rule's cost rationale (an extra round trip on every live request) doesn't apply to an uncorrelated scalar subquery folded into a select list the request already runs — SQLite and Postgres both evaluate it once per statement. The CI query-count snapshots on this PR confirm the shape: the recorded query texts changed, the per-route counts did not.

The alternative (a per-epoch cached count) was considered and rejected: it pays a real per-request options read on the query path and needs cross-isolate invalidation wiring on byline creation, which is strictly worse than a zero-round-trip probe with no invalidation at all. Happy to adjust the AGENTS.md wording if maintainers prefer different phrasing.

return { terms, bylines, bylinesExist };
}

/**
Expand Down Expand Up @@ -227,6 +239,16 @@ function stashFolded(data: Record<string, unknown>, row: Record<string, unknown>
}
Object.defineProperty(data, sym, { value, enumerable: false, configurable: true });
}
// Existence probe: 1 = table has rows, NULL = empty (both dialects). A row
// without the column (e.g. a cached snapshot) leaves the marker unset,
// which hydration treats as "unknown" and falls back conservatively.
if ("_emdash_bylines_exist" in row) {
Object.defineProperty(data, FOLDED_BYLINES_EXIST, {
value: row["_emdash_bylines_exist"] != null,
enumerable: false,
configurable: true,
});
}
}

/** Resolved SEO shape attached to `entry.data.seo`. Mirrors `ContentSeo`. */
Expand Down Expand Up @@ -826,11 +848,11 @@ export function buildTaxonomyPivotQuery(
: sql``;

const firstGroupCond = pivotGroupCondition("ct.taxonomy_id", firstGroups);
const { terms: termsSelect, bylines: bylinesSelect } = foldedHydrationSelects(
db,
collection,
"r",
);
const {
terms: termsSelect,
bylines: bylinesSelect,
bylinesExist: bylinesExistSelect,
} = foldedHydrationSelects(db, collection, "r");

// Authoritative re-check on the joined `ec_*` row.
const deletedR = deletedIsNull ? sql`r.deleted_at IS NULL` : sql`r.deleted_at IS NOT NULL`;
Expand Down Expand Up @@ -871,7 +893,7 @@ export function buildTaxonomyPivotQuery(
ORDER BY sortval ${dir}, ct.entry_id ${dir}
${limitClause}
)
SELECT r.*, ${termsSelect}, ${bylinesSelect}
SELECT r.*, ${termsSelect}, ${bylinesSelect}, ${bylinesExistSelect}
FROM picked JOIN ${sql.ref(tableName)} AS r ON r.id = picked.entry_id
WHERE ${deletedR} ${statusR} ${localeR}
ORDER BY picked.sortval ${dir}, picked.entry_id ${dir}
Expand All @@ -894,7 +916,7 @@ export function buildTaxonomyPivotQuery(
${residual}
${bylineCt}
)
SELECT r.*, ${termsSelect}, ${bylinesSelect}
SELECT r.*, ${termsSelect}, ${bylinesSelect}, ${bylinesExistSelect}
FROM picked JOIN ${sql.ref(tableName)} AS r ON r.id = picked.entry_id
WHERE ${deletedR} ${statusR} ${localeR}
${cursorCond}
Expand Down Expand Up @@ -1257,11 +1279,11 @@ export function emdashLoader(): LiveLoader<EntryData, EntryFilter, CollectionFil
: sql``;

// Fold byline + taxonomy hydration into the list query.
const { terms: termsSelect, bylines: bylinesSelect } = foldedHydrationSelects(
db,
type,
tableName,
);
const {
terms: termsSelect,
bylines: bylinesSelect,
bylinesExist: bylinesExistSelect,
} = foldedHydrationSelects(db, type, tableName);

// LIMIT/OFFSET clause. SQLite only accepts OFFSET when a
// LIMIT is present, so a bare offset uses `LIMIT -1`
Expand All @@ -1277,7 +1299,7 @@ export function emdashLoader(): LiveLoader<EntryData, EntryFilter, CollectionFil
: sql`LIMIT -1 OFFSET ${offset}`;
}
result = await sql<Record<string, unknown>>`
SELECT *, ${termsSelect}, ${bylinesSelect} FROM ${sql.ref(tableName)}
SELECT *, ${termsSelect}, ${bylinesSelect}, ${bylinesExistSelect} FROM ${sql.ref(tableName)}
WHERE deleted_at IS NULL
AND ${statusCondition}
${localeFilter}
Expand Down Expand Up @@ -1413,22 +1435,22 @@ export function emdashLoader(): LiveLoader<EntryData, EntryFilter, CollectionFil
// per-result-set column limit, surfacing as a silent null entry. One
// JSON column is one column, so the join stays safe at any width and
// we keep the single round trip.
const { terms: termsSelect, bylines: bylinesSelect } = foldedHydrationSelects(
db,
type,
"c",
);
const {
terms: termsSelect,
bylines: bylinesSelect,
bylinesExist: bylinesExistSelect,
} = foldedHydrationSelects(db, type, "c");
const seoSelect = foldedSeoSelect(db, type, "c");
const result = locale
? await sql<Record<string, unknown>>`
SELECT c.*, ${seoSelect}, ${termsSelect}, ${bylinesSelect}
SELECT c.*, ${seoSelect}, ${termsSelect}, ${bylinesSelect}, ${bylinesExistSelect}
FROM ${sql.ref(tableName)} AS c
WHERE c.deleted_at IS NULL
AND ((c.slug = ${id} AND c.locale = ${locale}) OR c.id = ${id})
LIMIT 1
`.execute(db)
: await sql<Record<string, unknown>>`
SELECT c.*, ${seoSelect}, ${termsSelect}, ${bylinesSelect}
SELECT c.*, ${seoSelect}, ${termsSelect}, ${bylinesSelect}, ${bylinesExistSelect}
FROM ${sql.ref(tableName)} AS c
WHERE c.deleted_at IS NULL
AND (c.slug = ${id} OR c.id = ${id})
Expand Down
24 changes: 18 additions & 6 deletions packages/core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { getFallbackChain, getI18nConfig, isI18nEnabled } from "./i18n/config.js
import {
CURSOR_RAW_VALUES,
FOLDED_BYLINES,
FOLDED_BYLINES_EXIST,
FOLDED_TERMS,
type WhereRange,
type WhereValue,
Expand Down Expand Up @@ -1036,18 +1037,29 @@ async function hydrateEntryBylines<D>(type: string, entries: ContentEntry<D>[]):
return { data, credits };
});

// An empty `_emdash_bylines` table makes an empty fold authoritative: no
// credit can exist in any locale and the author fallback has no byline
// to resolve to, so skip the query path and the custom-fields probe
// entirely. A missing marker (older cached rows) means "unknown" and
// keeps the conservative fallback below.
const knownEmpty = entries.every(
(e) => Reflect.get(entryData(e), FOLDED_BYLINES_EXIST) === false,
);

// Fall back to the full query path when the fold can't be trusted to be
// complete: an entry with a byline reference (explicit primary, or an
// author for the author-fallback) but no folded credits — e.g. a credit
// in a different locale than the row, which the locale-correlated subquery
// skips, or the author-fallback path which the fold doesn't express.
let needsQueryPath = parsed.some(
(p) =>
p.credits.length === 0 &&
(dataStr(p.data, "authorId") !== "" || dataStr(p.data, "primaryBylineId") !== ""),
);
let needsQueryPath =
!knownEmpty &&
parsed.some(
(p) =>
p.credits.length === 0 &&
(dataStr(p.data, "authorId") !== "" || dataStr(p.data, "primaryBylineId") !== ""),
);
let hasCustomFields = false;
if (!needsQueryPath) {
if (!needsQueryPath && !knownEmpty) {
try {
const { getDb } = await import("./loader.js");
const db = await getDb();
Expand Down
138 changes: 138 additions & 0 deletions packages/core/tests/unit/bylines/hydration-empty-table.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* Byline hydration on sites with no bylines.
*
* When `_emdash_bylines` is empty, the folded byline JSON is authoritative:
* no credit can exist in any locale and the author fallback has no byline to
* resolve to. Entries that merely have an `author_id` must not send the
* batch down the byline query path — those lookups can only return zero
* rows, and they run on every logged-out render. Once a byline row exists,
* the author fallback must still take the query path.
*/

import BetterSqlite3 from "better-sqlite3";
import { Kysely, SqliteDialect } from "kysely";
import { afterEach, describe, expect, it, vi } from "vitest";

import { runMigrations } from "../../../src/database/migrations/runner.js";
import { BylineRepository } from "../../../src/database/repositories/byline.js";
import { ContentRepository } from "../../../src/database/repositories/content.js";
import type { Database } from "../../../src/database/types.js";
import { emdashLoader } from "../../../src/loader.js";
import { getEmDashCollection } from "../../../src/query.js";
import { runWithContext } from "../../../src/request-context.js";
import { SchemaRegistry } from "../../../src/schema/registry.js";

vi.mock("astro:content", () => ({
getLiveCollection: vi.fn(),
getLiveEntry: vi.fn(),
}));

import { getLiveCollection } from "astro:content";

const openDbs: Kysely<Database>[] = [];

/**
* In-memory db with a query counter on Kysely's `log` hook, so the tests can
* assert "no byline query was issued" against real SQL (no repository mocks).
*/
async function setupCountingDb(): Promise<{
db: Kysely<Database>;
queries: string[];
reset: () => void;
}> {
const sqlite = new BetterSqlite3(":memory:");
const queries: string[] = [];
const db = new Kysely<Database>({
dialect: new SqliteDialect({ database: sqlite }),
log: (event) => {
if (event.level === "query") queries.push(event.query.sql);
},
});
openDbs.push(db);
await runMigrations(db);
const registry = new SchemaRegistry(db);
await registry.createCollection({ slug: "post", label: "Posts", labelSingular: "Post" });
await registry.createField("post", { slug: "title", label: "Title", type: "string" });
return { db, queries, reset: () => queries.splice(0, queries.length) };
}

/** Route getLiveCollection through the real loader so folded columns flow. */
function delegateToLoader() {
const loader = emdashLoader();
vi.mocked(getLiveCollection).mockImplementation(async (_name: string, filter: unknown) =>
// eslint-disable-next-line typescript/no-explicit-any -- loader filter is a runtime-validated union
loader.loadCollection!({ filter: filter as any }),
);
}

/** Byline lookups issued by the query path (the folded content SELECT reads
* the byline tables too, but only inside the `ec_post` query). */
function bylineQueries(queries: string[]): string[] {
return queries.filter(
(q) =>
!q.includes("ec_post") &&
(q.includes("_emdash_content_bylines") || q.includes(`"_emdash_bylines"`)),
);
}

describe("byline hydration with an empty bylines table", () => {
afterEach(async () => {
vi.mocked(getLiveCollection).mockReset();
for (const db of openDbs.splice(0)) {
await db.destroy();
}
});

it("skips the byline query path for authored entries when no bylines exist", async () => {
const { db, queries, reset } = await setupCountingDb();
const repo = new ContentRepository(db);
await repo.create({
type: "post",
slug: "authored",
data: { title: "Authored Post" },
status: "published",
authorId: "user-1",
});
delegateToLoader();

const entries = await runWithContext({ editMode: false, db }, async () => {
reset();
const result = await getEmDashCollection("post", { status: "published" });
return result.entries;
});

expect(entries).toHaveLength(1);
const data = entries[0]!.data as Record<string, unknown>;
expect(data.bylines).toEqual([]);
expect(data.byline).toBeNull();
expect(bylineQueries(queries)).toEqual([]);
});

it("still resolves the author fallback through the query path when a byline exists", async () => {
const { db } = await setupCountingDb();
await db
.insertInto("users")
.values({ id: "user-1", email: "ada@example.com", name: "Ada" })
.execute();
const bylineRepo = new BylineRepository(db);
await bylineRepo.create({ slug: "ada", displayName: "Ada Lovelace", userId: "user-1" });
const repo = new ContentRepository(db);
await repo.create({
type: "post",
slug: "authored-with-byline",
data: { title: "Authored Post" },
status: "published",
authorId: "user-1",
});
delegateToLoader();

const entries = await runWithContext({ editMode: false, db }, async () => {
const result = await getEmDashCollection("post", { status: "published" });
return result.entries;
});

expect(entries).toHaveLength(1);
const data = entries[0]!.data as { byline?: { displayName?: string } | null };
expect(data.byline?.displayName).toBe("Ada Lovelace");
});
});
Loading
Loading