Skip to content

fix(taxonomies): never query a declared collection whose ec_* table is missing#2043

Open
swissky wants to merge 3 commits into
emdash-cms:mainfrom
swissky:fix/term-counts-skip-missing-tables
Open

fix(taxonomies): never query a declared collection whose ec_* table is missing#2043
swissky wants to merge 3 commits into
emdash-cms:mainfrom
swissky:fix/term-counts-skip-missing-tables

Conversation

@swissky

@swissky swissky commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Stops term counting from issuing queries against ec_* tables that don't exist, which currently floods D1 logs with phantom no such table: ec_posts errors on any site without a posts collection.

The bug: migration 006_taxonomy_defs seeds the default category/tag defs bound to ["posts"] whether or not that collection is ever created. fetchVisibleTermCounts then builds a UNION that joins ec_posts, catches the missing-table error, and retries per collection — the result is correct, but the failed statement itself is logged by the database. On D1 each one surfaces as a no such table: ec_posts: SQLITE_ERROR error span in Workers Observability, once per taxonomy per uncached render, drowning out real errors. (Observed in production on a Workers + D1 site with Logs/Traces enabled; the same drift also occurs when a declared collection is deleted.)

The fix: resolve the set of existing ec_* tables upfront (via the dialect-aware listTablesLike, same helper migration 051 uses) and scope the count to collections whose table actually exists — a failing statement is never sent. To keep the count path at one round-trip per taxonomy, the table list is cached per isolate in a new database/content-tables-cache.ts (same pattern as the loader's taxonomy-names cache, including the dbIsIsolated bypass) and reset by SchemaRegistry.createCollection/deleteCollection. A reader that still hits a dropped table (stale list from another isolate's delete) resets the cache and retries once, preserving the previous partial-but-correct behavior.

The getTerm query-budget test stays at 4 queries; it now primes the once-per-isolate table lookup so the budget reflects the steady state.

Tests:

  • never sends a query referencing a missing ec_* table — asserts on the SQL sent through the Kysely executor; fails on main, passes with the fix.
  • recovers when the isolate-cached table list goes stale — covers the refresh-and-retry fallback.

Possible follow-up (out of scope here): stop hardcoding ["posts"] in the migration-seeded defaults and let seeds declare the binding — happy to open a Discussion if that's of interest.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Cursor + Claude Fable 5

Screenshots / test output

 Test Files  355 passed | 1 skipped (356)
      Tests  4723 passed | 3 skipped (4726)

Production log entry that motivated the fix (Workers Observability, one per taxonomy per uncached render):

error scl-lauerz d1_all ERROR
error.type: "no such table: ec_posts: SQLITE_ERROR"
db.query.text: "SELECT taxonomy_id, SUM(count) AS count FROM ( SELECT ct.taxonomy_id ... INNER JOIN \"ec_posts\" AS e ON e.id = ct.entry_id ...)"

… of probing missing ones

The migration-seeded category/tag defs declare ["posts"] whether or not
that collection exists, so on any site without a posts collection every
visible-term-count computation issued a UNION that joined ec_posts and
failed. The retry path made the result correct, but the failed statement
itself is logged by the database — on D1 it surfaces as a
"no such table: ec_posts" error span in Workers Observability, once per
taxonomy per uncached render, drowning out real errors.

Resolve the set of existing ec_* tables upfront (cached per isolate,
reset by SchemaRegistry create/delete and by readers that observe a
stale list) and scope the count to collections whose table exists, so a
query referencing a missing table is never sent.
@changeset-bot

changeset-bot Bot commented Jul 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 05ed138

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet area/core size/M labels Jul 14, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jul 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/admin@2043

@emdash-cms/auth

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/auth@2043

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/auth-atproto@2043

@emdash-cms/blocks

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/blocks@2043

@emdash-cms/cloudflare

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/cloudflare@2043

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/contentful-to-portable-text@2043

emdash

npm i https://pkg.pr.new/emdash-cms/emdash@2043

create-emdash

npm i https://pkg.pr.new/emdash-cms/emdash/create-emdash@2043

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/gutenberg-to-portable-text@2043

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-cli@2043

@emdash-cms/plugin-types

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-types@2043

@emdash-cms/registry-client

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-client@2043

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-lexicons@2043

@emdash-cms/registry-verification

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-verification@2043

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/sandbox-workerd@2043

@emdash-cms/x402

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/x402@2043

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-ai-moderation@2043

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-atproto@2043

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-audit-log@2043

@emdash-cms/plugin-color

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-color@2043

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-embeds@2043

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-field-kit@2043

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-forms@2043

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-webhook-notifier@2043

commit: 05ed138

@github-actions github-actions Bot added the query-count changed PR diff modifies query-count snapshot files label Jul 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Query-count snapshot changes

4 routes changed, total Δ +4 queries.

SQLite

Route Before After Δ
GET /posts/building-for-the-long-term (cold) 18 19 +1

D1

Route Before After Δ
GET /category/development (cold) 23 24 +1
GET /posts/building-for-the-long-term (cold) 29 30 +1
GET /tag/webdev (cold) 23 24 +1

Comparing snapshot files between base and head. Updated automatically on each push.

@github-actions github-actions Bot added size/L and removed size/M labels Jul 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is it possible to do this without adding an extra query to the hot path?

@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 17, 2026

@emdashbot emdashbot Bot left a comment

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.

This is a sound, well-scoped bug fix. The problem is real and clearly diagnosed: migration 006 seeds category/tag bound to ["posts"], so any site without a posts collection was hitting a missing-table error on every uncached term-count render. Moving from "query optimistically and retry" to "resolve existing ec_* tables up front" eliminates the phantom D1/SQLite error spans, and the per-isolate cache matches the existing taxonomy-definitions cache pattern. Invalidation is wired into SchemaRegistry.createCollection and deleteCollection, which are the only production paths that create or drop content tables (seeds, admin API, MCP, and WP import all go through the registry).

I checked the changed files, traced call sites, verified listTablesLike handles both SQLite/D1 and Postgres safely, and confirmed that the query-count snapshots and test DB cache reset are consistent. The changeset is present and correctly targeted at the emdash package. No AGENTS.md convention violations (i18n, API envelope, locale filtering, SQL safety, index discipline) were introduced.

One issue: the stale-cache fallback in fetchVisibleTermCounts refreshes the table list and retries once, but the final runCounts call is unprotected. If another isolate drops a table between the fresh lookup and the retry, the function still throws a missing-table error instead of returning the partial counts it used to. Fixing that preserves both the primary goal (no failed statements in the steady state) and the old partial-but-correct recovery behavior in the race case.

Comment on lines +123 to +127
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);

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] The stale-cache fallback refreshes getContentTableNames and filters to remaining, but the final return runCounts(...) is unprotected. If a collection is dropped by another isolate after the fresh lookup but before the retry executes, this still throws a missing-table error and surfaces the exact kind of phantom DB error the PR is eliminating. The original per-collection loop skipped individual missing tables and returned partial counts; the retry path should do the same.

Suggested change
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);
resetContentTableNamesCache();
const fresh = await getContentTableNames(db);
const remaining = present.filter((collection) => fresh.has(`ec_${collection}`));
if (remaining.length === 0) return new Map();
// A table could still be dropped between the fresh lookup and the count
// query (another concurrent isolate). Count each remaining collection
// individually so a missing table is skipped rather than failing the request.
const counts = new Map<string, number>();
for (const collection of remaining) {
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;
}

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.

Done — the retry now counts each remaining collection individually inside its own try/catch and skips missing tables, matching the original per-collection partial-count behavior instead of re-throwing on a concurrent drop.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 17, 2026
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Jul 17, 2026
The stale-cache retry re-ran the whole batch, so a table dropped between
the fresh lookup and the retry still threw. Count each remaining
collection individually and skip missing tables, matching the original
per-collection skip behavior (emdashbot review).
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 19, 2026
@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Jul 19, 2026

@emdashbot emdashbot Bot left a comment

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.

This is a sound, well-scoped bug fix, and the issue raised in the prior review has been addressed.

Approach judgment: The problem is real and the fix is the right shape. Default taxonomy definitions are seeded bound to posts, so sites without a posts collection previously hit a missing-table error on every uncached term-count render. Moving from "query optimistically and retry" to "resolve existing ec_* tables upfront" eliminates the phantom D1/SQLite error spans, and the per-isolate cache follows the same globalThis/Symbol pattern already used for taxonomy definitions. Invalidation is wired into SchemaRegistry.createCollection and deleteCollection, the production paths that create or drop content tables.

What I checked:

  • fetchVisibleTermCounts now resolves the table set once per isolate, skips missing tables without sending a failing statement, and refreshes the cache if it discovers a stale entry.
  • The stale-cache fallback now iterates per remaining collection with a missing-table guard, so a table dropped between the fresh lookup and the retry is skipped rather than failing the request — this directly fixes the prior review finding.
  • content-tables-cache.ts uses the same globalThis + Symbol.for singleton pattern as the existing taxonomy-definitions cache and correctly bypasses the cache for isolated DBs.
  • SQL is safe: collection slugs are validated before building ec_${slug}, identifiers use sql.ref, and listTablesLike is parameterized for both SQLite and Postgres.
  • The query-count snapshots correctly show the one-off cold-isolate table lookup on term-page routes and no extra query on warm renders.
  • Tests cover the no-failed-statement guarantee and the stale-cache recovery path; test-db.ts resets the isolate cache when creating a fresh test database.
  • Changeset is present and user-facing.

No AGENTS.md violations (i18n, API envelope, auth, locale filtering, SQL safety, index discipline, changeset) were introduced. I did not run the test suite, linter, or builds; I reviewed statically as instructed, and the PR author reports the full test suite passes.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 19, 2026
@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Jul 19, 2026

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I've been looking a bit more deeply into how this could avoid the extra query, and found another issue. The per-isolate table cache introduces a correctness problem after collection creation. If isolate A has cached the table set and isolate B creates a collection, A never refreshes: unlike deletion, creation produces no missing-table error to trigger recovery. A taxonomy that already declares that collection (notably the migration-seeded posts defaults) will therefore undercount on A until the isolate is recycled. Resetting the cache in SchemaRegistry.createCollection() only fixes the isolate that handled the write.

Can we avoid adding this independent, permanently stale schema cache? Runtime initialisation already queries _emdash_collections for the auto-seed gate. One approach would be to select the registered slugs instead of only COUNT(*), derive the count from those rows, and populate an invalidation-aware collection cache that the taxonomy-definition read can use. handleTaxonomyList() already treats _emdash_collections as the filter for orphan references. Collection create/delete should compensate partial D1 operations so the registry/table invariant remains reliable. Contexts that do not perform that initialization could retain an explicit fallback.

This would also avoid the extra cold-isolate query on the normal path. If physical-table discovery remains necessary, it at least needs bounded or cross-isolate revalidation so newly created collections become visible without waiting for isolate eviction.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/approved Approved; no new commits since labels Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core overlap query-count changed PR diff modifies query-count snapshot files review/awaiting-author Reviewed; waiting on the author to respond size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants