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/term-counts-missing-tables.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Fixes phantom `no such table: ec_*` database errors filling the logs on sites whose taxonomies declare a collection that doesn't exist — including the default `category`/`tag` taxonomies, which are bound to `posts` even when that collection was never created. Term counts now resolve the existing content tables upfront (cached per isolate) instead of probing the missing table and retrying on every uncached render.
76 changes: 76 additions & 0 deletions packages/core/src/database/content-tables-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Per-isolate cache of the existing `ec_*` content-table names.
*
* Term counting (`taxonomies/term-counts.ts`) needs to know which of a
* taxonomy's declared collections actually have a content table before it can
* count: migration 006 seeds the default `category`/`tag` defs bound to a
* `posts` collection that only exists if a seed creates it, and defs can also
* drift when a collection is deleted. Content tables change only through
* `SchemaRegistry.createCollection`/`deleteCollection`, so the lookup is
* cached for the isolate lifetime and reset by those write paths — the same
* pattern as the loader's taxonomy-names cache. Other isolates converge on
* recycle; readers that still hit a dropped table refresh explicitly via
* `resetContentTableNamesCache()`.
*
* Stored on globalThis behind a Symbol key (same pattern as
* `taxonomies/index.ts`) so a bundler duplicating this module across SSR
* chunks can't produce two independent caches.
*
* **Isolated databases bypass the cache.** Playground / DO preview requests
* set `requestContext.dbIsIsolated`; they point at a divergent schema, so we
* skip both reading and writing the holder (same precedent as the loader's
* `getTaxonomyNames`).
*/

import type { Kysely } from "kysely";

import { getRequestContext } from "../request-context.js";
import { listTablesLike } from "./dialect-helpers.js";
import type { Database } from "./types.js";

interface ContentTablesHolder {
promise: Promise<Set<string>> | null;
}

const CACHE_KEY = Symbol.for("emdash:content-tables");
const contentTablesStore = globalThis as Record<symbol, unknown>;
const holder: ContentTablesHolder =
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis singleton pattern (see taxonomies/index.ts)
(contentTablesStore[CACHE_KEY] as ContentTablesHolder | undefined) ??
(() => {
const h: ContentTablesHolder = { promise: null };
contentTablesStore[CACHE_KEY] = h;
return h;
})();

async function fetchContentTableNames(db: Kysely<Database>): Promise<Set<string>> {
return new Set(await listTablesLike(db, "ec_%"));
}

/**
* Names of the `ec_*` tables that exist in the database. The promise is
* cached (not the resolved value) so concurrent cold-isolate readers share
* one in-flight query; a rejection evicts the entry so the next caller
* retries.
*/
export function getContentTableNames(db: Kysely<Database>): Promise<Set<string>> {
if (getRequestContext()?.dbIsIsolated === true) {
return fetchContentTableNames(db);
}
if (holder.promise) return holder.promise;
const promise = fetchContentTableNames(db).catch((error: unknown) => {
if (holder.promise === promise) holder.promise = null;
throw error;
});
holder.promise = promise;
return promise;
}

/**
* Reset the per-isolate content-table-names cache. Called from every path
* that creates or drops an `ec_*` table (`SchemaRegistry`) and by readers
* that observe a missing table despite the cached list (stale isolate).
*/
export function resetContentTableNamesCache(): void {
holder.promise = null;
}
7 changes: 7 additions & 0 deletions packages/core/src/schema/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Selectable } from "kysely";
import { sql } from "kysely";
import { ulid } from "ulidx";

import { resetContentTableNamesCache } from "../database/content-tables-cache.js";
import { currentTimestamp, listTablesLike, tableExists } from "../database/dialect-helpers.js";
import { withTransaction } from "../database/transaction.js";
import type { CollectionTable, Database, FieldTable } from "../database/types.js";
Expand Down Expand Up @@ -255,6 +256,7 @@ export class SchemaRegistry {
// Create the content table for this collection
await this.createContentTable(input.slug, trx);
});
resetContentTableNamesCache();

const collection = await this.getCollection(input.slug);
if (!collection) {
Expand Down Expand Up @@ -390,6 +392,11 @@ export class SchemaRegistry {
await deleteContentMediaUsageCollection(this.db, slug);
}
throw error;
} finally {
// Even a failed delete may have dropped the ec_* table (D1 has no
// real transactions) — over-invalidation is harmless, a stale list
// is not.
if (contentTableDropped) resetContentTableNamesCache();
}
}

Expand Down
51 changes: 31 additions & 20 deletions packages/core/src/taxonomies/term-counts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
import type { Kysely } from "kysely";
import { sql } from "kysely";

import {
getContentTableNames,
resetContentTableNamesCache,
} from "../database/content-tables-cache.js";
import { buildStatusCondition } from "../database/dialect-helpers.js";
import type { Database } from "../database/types.js";
import { validateIdentifier } from "../database/validate.js";
Expand Down Expand Up @@ -77,14 +81,23 @@ async function runCounts(
*
* Counts are scoped to the taxonomy's declared collections — pass
* `TaxonomyDef.collections` (`_emdash_taxonomy_defs.collections`). Collections
* whose `ec_*` table doesn't exist (pre-migration drift, a declared collection
* that was never created) are skipped, yielding a partial-but-correct count
* rather than a throw.
* whose `ec_*` table doesn't exist are skipped, yielding a partial-but-correct
* count rather than a throw. That case is not exotic: migration 006 seeds the
* default `category`/`tag` defs bound to a `posts` collection that only exists
* if a seed creates it, so any site without `posts` hits it on every count.
*
* Missing tables are resolved upfront rather than by querying optimistically
* and retrying on a missing-table error: the database logs every failed
* statement (on D1 each one surfaces as an error span in Workers
* Observability), so the probe-and-retry approach floods the logs with one
* phantom error per taxonomy per uncached render.
*
* One database round-trip for the whole taxonomy (UNION ALL across
* collections). Callers on the public render path should go through the
* request-cached wrapper in `taxonomies/index.ts` so a page rendering both the
* widget and a term detail shares one computation.
* collections, never one query per collection) — the existing-table lookup is
* cached per isolate (`database/content-tables-cache.ts`) so it costs a query
* only on a cold isolate. Callers on the public render path should go through
* the request-cached wrapper in `taxonomies/index.ts` so a page rendering
* both the widget and a term detail shares one computation.
*/
export async function fetchVisibleTermCounts(
db: Kysely<Database>,
Expand All @@ -95,23 +108,21 @@ export async function fetchVisibleTermCounts(
for (const collection of unique) validateIdentifier(collection, "collection slug");
if (unique.length === 0) return new Map();

const tables = await getContentTableNames(db);
const present = unique.filter((collection) => tables.has(`ec_${collection}`));
if (present.length === 0) return new Map();

try {
return await runCounts(db, taxonomyName, unique);
return await runCounts(db, taxonomyName, present);
} catch (error) {
if (!isMissingTableError(error)) throw error;
}

// A declared collection has no ec_* table — retry per collection so the
// existing tables still contribute (still scheduled-aware + deleted_at).
const counts = new Map<string, number>();
for (const collection of unique) {
try {
for (const [group, count] of await runCounts(db, taxonomyName, [collection])) {
counts.set(group, (counts.get(group) ?? 0) + count);
}
} catch (error) {
if (!isMissingTableError(error)) throw error;
}
}
return counts;
// The isolate-cached table list was stale — a collection was dropped after
// it was populated (another isolate's delete). Refresh it and retry once.
resetContentTableNamesCache();
const fresh = await getContentTableNames(db);
const remaining = present.filter((collection) => fresh.has(`ec_${collection}`));
if (remaining.length === 0) return new Map();
return runCounts(db, taxonomyName, remaining);
}
6 changes: 6 additions & 0 deletions packages/core/tests/unit/taxonomies/get-term.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
} from "kysely";
import { afterEach, beforeEach, expect, it, vi } from "vitest";

import { getContentTableNames } from "../../../src/database/content-tables-cache.js";
import { ContentRepository } from "../../../src/database/repositories/content.js";
import { TaxonomyRepository } from "../../../src/database/repositories/taxonomy.js";
import {
Expand Down Expand Up @@ -175,6 +176,11 @@ describeEachDialect("getTerm", (dialect) => {
});
await taxRepo.attachToEntry("post", post.id, parent.id);

// The count path resolves the existing ec_* tables once per isolate
// (content-tables cache); prime it so the budget below reflects the
// steady state rather than the one-off cold-isolate lookup.
await getContentTableNames(ctx.db);

counter.count = 0;
const term = await getTerm("category", "tech");

Expand Down
48 changes: 48 additions & 0 deletions packages/core/tests/unit/taxonomies/term-counts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,54 @@ describeEachDialect("visible term counts (#581)", (dialect) => {
expect(termPage?.count).toBe(1);
});

it("never sends a query referencing a missing ec_* table", async () => {
// Skipping a missing table must happen by resolving the existing tables
// upfront — not by probing it and retrying on the error. The database
// logs every failed statement (on D1 each one becomes an error span in
// Workers Observability), so a probe-and-retry produces one phantom
// `no such table: ec_*` error per taxonomy per uncached render. The
// migration-seeded defs make this the default experience: `category` and
// `tag` declare `["posts"]` whether or not that collection exists.
const term = await taxRepo.create({ name: "category", slug: "tech", label: "Technology" });
const post = await createEntry("post", "p1");
await taxRepo.attachToEntry("post", post.id, term.id);

const executor = ctx.db.getExecutor();
const executed: string[] = [];
const originalExecuteQuery = executor.executeQuery.bind(executor);
vi.spyOn(executor, "executeQuery").mockImplementation((compiledQuery, options) => {
executed.push(compiledQuery.sql);
return originalExecuteQuery(compiledQuery, options);
});

const counts = await fetchVisibleTermCounts(ctx.db, "category", ["ghost", "post"]);
expect(counts.get(term.translationGroup ?? term.id)).toBe(1);
expect(executed.filter((statement) => statement.includes("ec_ghost"))).toEqual([]);
});

it("recovers when the isolate-cached table list goes stale", async () => {
// The existing-table lookup is cached per isolate; a table dropped
// behind its back (another isolate's collection delete) must trigger a
// refresh-and-retry, not a throw.
await insertDef("topic", ["post", "page"]);
const term = await taxRepo.create({ name: "topic", slug: "science", label: "Science" });
const post = await createEntry("post", "p1");
const page = await createEntry("page", "g1");
await taxRepo.attachToEntry("post", post.id, term.id);
await taxRepo.attachToEntry("page", page.id, term.id);

// Warm the cache with both tables present.
const group = term.translationGroup ?? term.id;
const warm = await fetchVisibleTermCounts(ctx.db, "topic", ["post", "page"]);
expect(warm.get(group)).toBe(2);

// Drop ec_page without going through the registry — the cache is stale.
await sql`DROP TABLE ${sql.ref("ec_page")}`.execute(ctx.db);

const counts = await fetchVisibleTermCounts(ctx.db, "topic", ["post", "page"]);
expect(counts.get(group)).toBe(1);
});

it("does not share request-cached counts across differing collection scopes", async () => {
// Nothing forces per-locale rows of the same def to declare identical
// collections. When they drift, a request that renders both locales must
Expand Down
2 changes: 2 additions & 0 deletions packages/core/tests/utils/test-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Kysely, SqliteDialect } from "kysely";
import { Pool } from "pg";
import { describe } from "vitest";

import { resetContentTableNamesCache } from "../../src/database/content-tables-cache.js";
import { getMigrationStatus, runMigrations } from "../../src/database/migrations/runner.js";
import type { MigrationStatus } from "../../src/database/migrations/runner.js";
import { FailFastPostgresDialect } from "../../src/database/pg-migration-lock.js";
Expand All @@ -25,6 +26,7 @@ import { resetTaxonomyDefsCacheForTests } from "../../src/taxonomies/index.js";
*/
function resetSchemaCachesForTests(): void {
resetTaxonomyDefsCacheForTests();
resetContentTableNamesCache();
}

// ---------------------------------------------------------------------------
Expand Down
3 changes: 3 additions & 0 deletions scripts/query-counts.queries.d1.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"select count(*) as \"count\" from \"_emdash_collections\"": 1,
"SELECT COUNT(*) as count FROM \"_emdash_migrations\"": 1,
"select distinct \"translation_group\" from \"taxonomies\" where \"name\" = ? and \"slug\" in (...)": 1,
"SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE ?": 1,
"SELECT taxonomy_id, SUM(count) AS count FROM ( SELECT ct.taxonomy_id AS taxonomy_id, COUNT(*) AS count FROM content_taxonomies AS ct INNER JOIN \"ec_posts\" AS e ON e.id = ct.entry_id WHERE ct.collection = ? AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ?) AND (\"e\".\"status\" = 'published' OR (\"e\".\"status\" = 'scheduled' AND \"e\".\"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) AND e.deleted_at IS NULL GROUP BY ct.taxonomy_id) AS per_collection GROUP BY taxonomy_id": 1,
"UPDATE _emdash_cron_tasks SET status = 'idle', locked_at = NULL WHERE status = 'running' AND locked_at < ?": 1,
"WITH picked AS ( SELECT ct.entry_id AS entry_id, \"ct\".\"published_at\" AS sortval FROM content_taxonomies ct WHERE ct.collection = ? AND \"ct\".\"taxonomy_id\" = ? AND ct.deleted_at IS NULL AND (\"ct\".\"status\" = 'published' OR (\"ct\".\"status\" = 'scheduled' AND \"ct\".\"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY sortval DESC, ct.entry_id DESC ) SELECT r.*, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct CROSS JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"r\".id AND t.locale = \"r\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"r\".id AND b.locale = \"r\".locale) AS \"_emdash_bylines\" FROM picked JOIN \"ec_posts\" AS r ON r.id = picked.entry_id WHERE r.deleted_at IS NULL AND (\"r\".\"status\" = 'published' OR (\"r\".\"status\" = 'scheduled' AND \"r\".\"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY picked.sortval DESC, picked.entry_id DESC": 1
Expand Down Expand Up @@ -184,6 +185,7 @@
"select count(\"id\") as \"count\" from \"_emdash_comments\" where \"collection\" = ? and \"content_id\" = ? and \"status\" = ?": 1,
"select count(*) as \"count\" from \"_emdash_collections\"": 1,
"SELECT COUNT(*) as count FROM \"_emdash_migrations\"": 1,
"SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE ?": 1,
"SELECT taxonomy_id, SUM(count) AS count FROM ( SELECT ct.taxonomy_id AS taxonomy_id, COUNT(*) AS count FROM content_taxonomies AS ct INNER JOIN \"ec_posts\" AS e ON e.id = ct.entry_id WHERE ct.collection = ? AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ?) AND (\"e\".\"status\" = 'published' OR (\"e\".\"status\" = 'scheduled' AND \"e\".\"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) AND e.deleted_at IS NULL GROUP BY ct.taxonomy_id) AS per_collection GROUP BY taxonomy_id": 2,
"UPDATE _emdash_cron_tasks SET status = 'idle', locked_at = NULL WHERE status = 'running' AND locked_at < ?": 1
},
Expand Down Expand Up @@ -273,6 +275,7 @@
"select count(*) as \"count\" from \"_emdash_collections\"": 1,
"SELECT COUNT(*) as count FROM \"_emdash_migrations\"": 1,
"select distinct \"translation_group\" from \"taxonomies\" where \"name\" = ? and \"slug\" in (...)": 1,
"SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE ?": 1,
"SELECT taxonomy_id, SUM(count) AS count FROM ( SELECT ct.taxonomy_id AS taxonomy_id, COUNT(*) AS count FROM content_taxonomies AS ct INNER JOIN \"ec_posts\" AS e ON e.id = ct.entry_id WHERE ct.collection = ? AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ?) AND (\"e\".\"status\" = 'published' OR (\"e\".\"status\" = 'scheduled' AND \"e\".\"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) AND e.deleted_at IS NULL GROUP BY ct.taxonomy_id) AS per_collection GROUP BY taxonomy_id": 1,
"UPDATE _emdash_cron_tasks SET status = 'idle', locked_at = NULL WHERE status = 'running' AND locked_at < ?": 1,
"WITH picked AS ( SELECT ct.entry_id AS entry_id, \"ct\".\"published_at\" AS sortval FROM content_taxonomies ct WHERE ct.collection = ? AND \"ct\".\"taxonomy_id\" = ? AND ct.deleted_at IS NULL AND (\"ct\".\"status\" = 'published' OR (\"ct\".\"status\" = 'scheduled' AND \"ct\".\"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY sortval DESC, ct.entry_id DESC ) SELECT r.*, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct CROSS JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"r\".id AND t.locale = \"r\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"r\".id AND b.locale = \"r\".locale) AS \"_emdash_bylines\" FROM picked JOIN \"ec_posts\" AS r ON r.id = picked.entry_id WHERE r.deleted_at IS NULL AND (\"r\".\"status\" = 'published' OR (\"r\".\"status\" = 'scheduled' AND \"r\".\"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY picked.sortval DESC, picked.entry_id DESC": 1
Expand Down
Loading
Loading