Skip to content

Repro'd #762: migration race on D1#857

Merged
ascorbic merged 5 commits intomainfrom
opencode/issue762-20260430142220
Apr 30, 2026
Merged

Repro'd #762: migration race on D1#857
ascorbic merged 5 commits intomainfrom
opencode/issue762-20260430142220

Conversation

@ask-bonk
Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot commented Apr 30, 2026

Reproduced the issue and posted findings: #762 (comment)

Summary:

  • Root cause: Concurrent Workers isolates race in runMigrations (packages/core/src/database/migrations/runner.ts:136). The Kysely SqliteAdapter.acquireMigrationLock is a no-op, inherited by kysely-d1's D1Dialect and EmDash's EmDashD1Dialect. The losing isolate fails on INSERT INTO _emdash_migrations with UNIQUE constraint failed, throws before reaching the auto-seed at emdash-runtime.ts:1031, and never seeds collections.

  • Why the symptom is "API works, admin UI says not found": _buildManifest swallows errors (emdash-runtime.ts:1405-1407) and returns collections: {} if listing fails, then _loadManifest persists that empty object to options['emdash:manifest_cache']. The admin sidebar (Sidebar.tsx:177) and content route guards (router.tsx:393, 504, 878) read exclusively from this cached manifest. /api/schema/collections reads the DB live with no cache, so it works.

  • Reproduction: Added a failing test at packages/core/tests/integration/database/migration-race.test.ts that races two runMigrations calls on the same SQLite file and reproduces the exact error string from the report: Migration failed: UNIQUE constraint failed: _emdash_migrations.name (migration: 001_initial).

The repro test is committed in this branch. The fix is correct when the test passes.

Closes #762

github run

Co-authored-by: ascorbic <ascorbic@users.noreply.github.com>
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 30, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-perf-coordinator 6142678 Apr 30 2026, 04:44 PM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 30, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-i18n 6142678 Apr 30 2026, 04:44 PM

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 30, 2026

🦋 Changeset detected

Latest commit: 6142678

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

This PR includes changesets to release 13 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-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

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 30, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
docs 6142678 Apr 30 2026, 04:44 PM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 30, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-cache 6142678 Apr 30 2026, 04:45 PM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 30, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground 6142678 Apr 30 2026, 04:45 PM

@ascorbic
Copy link
Copy Markdown
Collaborator

@ask-bonk fix this

@github-actions
Copy link
Copy Markdown
Contributor

PR template validation failed

Please fix the following issues by editing your PR description:

  • This PR does not use the required PR template. Please edit the description to use the PR template. Copy it into your PR description and fill out all sections.

See CONTRIBUTING.md for the full contribution policy.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 30, 2026

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

emdash

npm i https://pkg.pr.new/emdash@857

create-emdash

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

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

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 6142678

Co-authored-by: ascorbic <ascorbic@users.noreply.github.com>
@ask-bonk
Copy link
Copy Markdown
Contributor Author

ask-bonk Bot commented Apr 30, 2026

All green. Summary of the fix:

Summary

Root cause: Kysely's SqliteAdapter.acquireMigrationLock is a no-op (inherited by kysely-d1 and EmDashD1Dialect). Two Workers isolates booting concurrently both ran the migrator, and the loser tripped UNIQUE constraint failed: _emdash_migrations.name. That error threw out of runMigrations, skipped auto-seeding at emdash-runtime.ts:1031, persisted an empty manifest cache, and the admin UI reported collections as not found.

Fix in packages/core/src/database/migrations/runner.ts:

  1. Detect the unique-constraint error with a precise pattern (MIGRATION_RACE_PATTERN) — table-scoped so it works across SQLite drivers (better-sqlite3, D1).
  2. When matched, poll _emdash_migrations for up to 10s waiting for the concurrent migrator to finish. If row count reaches MIGRATION_COUNT, treat the race as benign and return success.
  3. Walk error.cause via deepErrorMessage so wrapped/empty Kysely errors still surface readable text.
  4. Any non-race error still throws as before — verified by a regression test.

Tests in packages/core/tests/integration/database/migration-race.test.ts:

  • The original repro test now passes, and additionally asserts the database is fully migrated afterwards (so the fix isn't just swallowing errors).
  • New test should still surface unrelated migration errors exercises the non-race error path to guard against regressions that broaden the swallow.

The fix is conservative — it only suppresses the exact symptom of the race, not all migration errors — and it's a no-op for sequential callers (only the error path engages the new logic).

github run

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Addresses #762 by making runMigrations resilient to concurrent Workers isolate startup on D1/SQLite, preventing a benign migration bookkeeping race from aborting initialization and leaving the admin manifest cache empty.

Changes:

  • Add a regression test that reproduces the concurrent runMigrations race and asserts the DB ends fully migrated.
  • Update runMigrations to detect the specific _emdash_migrations.name UNIQUE-constraint race, wait for the other migrator to finish, and then succeed.
  • Add a patch changeset describing the fix and user-visible symptoms.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
packages/core/tests/integration/database/migration-race.test.ts New integration test reproducing and guarding against the migration race condition.
packages/core/src/database/migrations/runner.ts Implements race detection + bounded wait to make concurrent migration attempts safe on D1/SQLite.
.changeset/fix-migration-race-d1.md Patch release note describing the fix and the observed admin/UI symptom chain.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +164 to +172
async function waitForConcurrentMigrator(db: Kysely<Database>): Promise<boolean> {
const deadline = Date.now() + MIGRATION_RACE_WAIT_MS;
while (Date.now() < deadline) {
const count = await getAppliedMigrationCount(db);
if (count !== null && count === MIGRATION_COUNT) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, MIGRATION_RACE_POLL_MS));
}
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

waitForConcurrentMigrator polls SELECT COUNT(*) every 100ms for up to 10s. In a real “many isolates on deploy” scenario, multiple losing isolates could generate a burst of D1 queries while waiting. Consider using exponential backoff (+ jitter) and/or a larger initial poll interval to reduce query pressure while still converging quickly.

Copilot uses AI. Check for mistakes.
Comment on lines +148 to +156
async function getAppliedMigrationCount(db: Kysely<Database>): Promise<number | null> {
try {
const result = await sql<{ count: number }>`
SELECT COUNT(*) as count FROM ${sql.ref(MIGRATION_TABLE)}
`.execute(db);
return Number(result.rows[0]?.count ?? 0);
} catch {
return null;
}
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

getAppliedMigrationCount currently catches all errors and returns null. That’s fine for the “table missing” fast-path, but when used by waitForConcurrentMigrator it can also mask unexpected failures (e.g. permission/connection errors) and turn them into a 10s wait + fallback behavior. Consider only swallowing the specific “no such table” case (by matching the driver error) and rethrowing otherwise.

Copilot uses AI. Check for mistakes.
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.

@ask-bonk this is a good point

Comment on lines +140 to +141
const MIGRATION_RACE_PATTERN = /UNIQUE constraint failed: _emdash_migrations\.name/i;

Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

MIGRATION_RACE_PATTERN hardcodes the migration table name (_emdash_migrations). Since the table name is already defined as MIGRATION_TABLE, consider building the regex from that constant (with proper escaping) to avoid the detector silently drifting if the table name ever changes.

Suggested change
const MIGRATION_RACE_PATTERN = /UNIQUE constraint failed: _emdash_migrations\.name/i;
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const MIGRATION_RACE_PATTERN = new RegExp(
`UNIQUE constraint failed: ${escapeRegExp(MIGRATION_TABLE)}\\.name`,
"i",
);

Copilot uses AI. Check for mistakes.
@ask-bonk
Copy link
Copy Markdown
Contributor Author

ask-bonk Bot commented Apr 30, 2026

Good call. Tightened getAppliedMigrationCount to only swallow the specific "no such table: _emdash_migrations" case and rethrow everything else.

That was the right concern: the old catch {} was harmless on the fast-path (a missing table on first run is expected) but actively dangerous inside waitForConcurrentMigrator — a connection error, permission error, or anything else would have been silently retried for 10s and then masked behind a bogus "settled = false → propagate the original race error" verdict, instead of surfacing the real failure.

Change in packages/core/src/database/migrations/runner.ts:

  • Added MIGRATION_TABLE_MISSING_PATTERN, anchored to MIGRATION_TABLE so it works across both better-sqlite3 (no such table: _emdash_migrations) and D1 (D1_ERROR: no such table: _emdash_migrations: SQLITE_ERROR), and won't swallow "no such table" errors that name a different table.
  • getAppliedMigrationCount now uses deepErrorMessage (already in the file, walks error.cause for Kysely's wrapped driver errors) to test against that pattern; it returns null on table-missing and rethrows otherwise.
  • No other behavior change. Fast-path on first run still returns null cleanly; race-recovery still works.

Existing tests still pass:

  • should not throw when two callers run migrations concurrently (the original repro)
  • should still surface unrelated migration errors (the regression guard against over-broad swallowing)

I considered the other two Copilot suggestions on this review while I was in here:

  • Build the race regex from MIGRATION_TABLE: agreed it's nicer, but the constant is _emdash_migrations and the inline pattern matches it verbatim — a rename would be a multi-file change anyway (migrations themselves reference the name). I left it as a literal to avoid a regex-construction helper for a single use site. Happy to wire it up if you'd rather. (The new MIGRATION_TABLE_MISSING_PATTERN does build from the constant, so it's not entirely consistent — fine to make the same change to MIGRATION_RACE_PATTERN if you want.)
  • Exponential backoff for the polling loop: a fair point for the "many isolates on deploy" scenario, but in practice (a) most concurrent migrators settle in <1s because the migrations themselves are fast, (b) the dominant cost is the migrator that's doing the work, not the pollers, and (c) backoff makes the worst-case wait longer for everyone. I'd rather leave the simple constant interval and revisit if real telemetry shows query pressure. Open to changing it if you disagree.

Co-authored-by: ascorbic <ascorbic@users.noreply.github.com>
@ask-bonk
Copy link
Copy Markdown
Contributor Author

ask-bonk Bot commented Apr 30, 2026

Comment posted. Summary of what I did:

Changed packages/core/src/database/migrations/runner.ts:

  • Added MIGRATION_TABLE_MISSING_PATTERN regex anchored to MIGRATION_TABLE, matching the missing-table phrasing from both better-sqlite3 and D1.
  • Updated getAppliedMigrationCount to only swallow "no such table: _emdash_migrations" errors (using deepErrorMessage to walk error.cause) and rethrow anything else.

Why it matters: The old catch {} was fine on the fast-path (missing table = fresh DB, expected) but inside waitForConcurrentMigrator it would silently mask connection/permission errors behind a 10s wait, then return false and propagate the original UNIQUE-constraint error instead of the real driver failure. Now real errors surface immediately.

The existing two integration tests still pass (verified locally). Commit + push are handled by the automation.

github run

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +155 to +157
const MIGRATION_TABLE_MISSING_PATTERN = new RegExp(
`no such table:\\s*${MIGRATION_TABLE.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}\\b`,
"i",
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

MIGRATION_TABLE.replace(...) is using a regex escape character class that differs from the repo’s standard escape pattern (/[.*+?^${}()|[\]\\]/g is used elsewhere, e.g. packages/core/src/database/repositories/content.ts:86). Please reuse the shared escape pattern (or a small escapeRegExp helper) here so MIGRATION_TABLE_MISSING_PATTERN is reliably constructed and consistent across the codebase.

Copilot uses AI. Check for mistakes.
Comment on lines +186 to +199
* Resolves to `true` if the migration table reached `MIGRATION_COUNT` rows
* within the timeout, `false` otherwise.
*/
async function waitForConcurrentMigrator(db: Kysely<Database>): Promise<boolean> {
const deadline = Date.now() + MIGRATION_RACE_WAIT_MS;
while (Date.now() < deadline) {
const count = await getAppliedMigrationCount(db);
if (count !== null && count === MIGRATION_COUNT) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, MIGRATION_RACE_POLL_MS));
}
const finalCount = await getAppliedMigrationCount(db);
return finalCount === MIGRATION_COUNT;
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

waitForConcurrentMigrator treats the migration as “done” only when count === MIGRATION_COUNT. If the database ever contains more rows than the current MIGRATION_COUNT (e.g. a brief old/new isolate overlap during deploys, or a rollback to an older build after a newer one added migrations), this will never settle and will incorrectly time out. Consider using count >= MIGRATION_COUNT (and similarly for the final check) so “at least all known migrations are applied” is treated as success.

Suggested change
* Resolves to `true` if the migration table reached `MIGRATION_COUNT` rows
* within the timeout, `false` otherwise.
*/
async function waitForConcurrentMigrator(db: Kysely<Database>): Promise<boolean> {
const deadline = Date.now() + MIGRATION_RACE_WAIT_MS;
while (Date.now() < deadline) {
const count = await getAppliedMigrationCount(db);
if (count !== null && count === MIGRATION_COUNT) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, MIGRATION_RACE_POLL_MS));
}
const finalCount = await getAppliedMigrationCount(db);
return finalCount === MIGRATION_COUNT;
* Resolves to `true` if the migration table reached at least
* `MIGRATION_COUNT` rows within the timeout, `false` otherwise.
*/
async function waitForConcurrentMigrator(db: Kysely<Database>): Promise<boolean> {
const deadline = Date.now() + MIGRATION_RACE_WAIT_MS;
while (Date.now() < deadline) {
const count = await getAppliedMigrationCount(db);
if (count !== null && count >= MIGRATION_COUNT) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, MIGRATION_RACE_POLL_MS));
}
const finalCount = await getAppliedMigrationCount(db);
return finalCount !== null && finalCount >= MIGRATION_COUNT;

Copilot uses AI. Check for mistakes.
// Table doesn't exist yet (first run). Fall through to the Migrator
// which will create it.
const initialCount = await getAppliedMigrationCount(db);
if (initialCount === MIGRATION_COUNT) {
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

The fast-path check uses initialCount === MIGRATION_COUNT. If the DB has more applied migrations than this build knows about (e.g. after downgrading/rolling back, or overlapping isolates running different versions), this will fall through to the migrator unnecessarily and can interact badly with the race-wait logic. Using >= MIGRATION_COUNT here would make the fast-path robust to “extra” migration rows.

Suggested change
if (initialCount === MIGRATION_COUNT) {
if (initialCount >= MIGRATION_COUNT) {

Copilot uses AI. Check for mistakes.
Comment on lines +140 to +141
const MIGRATION_RACE_PATTERN = /UNIQUE constraint failed: _emdash_migrations\.name/i;

Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

MIGRATION_RACE_PATTERN hard-codes _emdash_migrations instead of being derived from MIGRATION_TABLE. If either the migration table name or column name ever changes, the race-detection logic will silently stop working. Consider building this regex from MIGRATION_TABLE (and escaping it) so it stays in sync with the migrator configuration.

Copilot uses AI. Check for mistakes.
@ascorbic
Copy link
Copy Markdown
Collaborator

@ask-bonk see the new reviews

@ask-bonk
Copy link
Copy Markdown
Contributor Author

ask-bonk Bot commented Apr 30, 2026

Addressed all four points from the new Copilot review.

Changes in packages/core/src/database/migrations/runner.ts:

  1. Derive both regex patterns from MIGRATION_TABLE. Added a small escapeRegExp helper using the same REGEX_ESCAPE_PATTERN as database/repositories/content.ts:86 for consistency. Both MIGRATION_RACE_PATTERN and MIGRATION_TABLE_MISSING_PATTERN now build from the constant, so a rename of the table can't silently disable race detection or missing-table handling.
  2. Fixed the bogus escape character class in the previous MIGRATION_TABLE_MISSING_PATTERN (/[.*+?^${}()|[\\]\\\\]/g — latently buggy because MIGRATION_TABLE happens to contain none of the special chars, but wrong nonetheless). Now uses the canonical /[.*+?^${}()|[\]\\]/g via the shared helper.
  3. waitForConcurrentMigrator uses >= MIGRATION_COUNT instead of ===. The old equality check could time out spuriously if the database has more migration rows than this build knows about — e.g. a brief old/new isolate overlap during a deploy, or a rollback to an older build after a newer one has landed migrations. Both the polling check and the final check now treat "at least all known migrations applied" as success.
  4. Fast-path in runMigrations uses initialCount >= MIGRATION_COUNT for the same reason: an old isolate observing a newer schema should short-circuit, not fall through to the migrator and risk engaging the race-recovery path needlessly.

New test in migration-race.test.ts: should fast-path when the migration table has more rows than this build knows about. Pre-migrates the DB, inserts a phantom 999_future_build row to simulate a newer build, then asserts a second runMigrations call is a clean no-op (applied: []) and the extra row is preserved. Without the >= change this test would either return non-empty applied (if the migrator re-ran) or — more likely on this fixture — engage the wait-and-recover path. With the change it's a pure fast-path return.

All three migration-race tests pass locally:

  • should not throw when two callers run migrations concurrently
  • should fast-path when the migration table has more rows than this build knows about (new)
  • should still surface unrelated migration errors

Typecheck and lint are clean for the touched files. Commit + push handled by the automation.

@ask-bonk
Copy link
Copy Markdown
Contributor Author

ask-bonk Bot commented Apr 30, 2026

Done. Summary of what I addressed from the new Copilot review on PR #857:

Fixes applied in packages/core/src/database/migrations/runner.ts:

  1. Added escapeRegExp helper matching the shared pattern in database/repositories/content.ts.
  2. MIGRATION_RACE_PATTERN now derived from MIGRATION_TABLE via the helper.
  3. MIGRATION_TABLE_MISSING_PATTERN now uses the same helper (also fixes the latently-buggy escape char class from the prior commit).
  4. waitForConcurrentMigrator uses >= MIGRATION_COUNT (both the polling check and final check).
  5. runMigrations fast-path uses initialCount >= MIGRATION_COUNT.

New test should fast-path when the migration table has more rows than this build knows about guards the >= semantics (3 race-related tests now pass).

Comment posted: #857 (comment)

github run

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +160 to +168
* Pattern used to detect "table does not exist" errors from SQLite-family
* drivers. better-sqlite3 reports `no such table: ...`; D1 wraps the same
* message as `D1_ERROR: no such table: ...`. We match on the table name to
* avoid swallowing unrelated "no such table" errors from queries that touch
* other tables (defensive — `getAppliedMigrationCount` only references
* `MIGRATION_TABLE`, but downstream callers may grow).
*/
const MIGRATION_TABLE_MISSING_PATTERN = new RegExp(
`no such table:\\s*${escapeRegExp(MIGRATION_TABLE)}\\b`,
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

MIGRATION_TABLE_MISSING_PATTERN only matches SQLite/D1 "no such table" messages. Since runMigrations is also used with Postgres, a fresh Postgres database will throw on the COUNT query (e.g. "relation ... does not exist") and never reach the Migrator. Use the shared isMissingTableError() helper (optionally also checking for MIGRATION_TABLE in the message), or expand the pattern to include Postgres phrasing.

Suggested change
* Pattern used to detect "table does not exist" errors from SQLite-family
* drivers. better-sqlite3 reports `no such table: ...`; D1 wraps the same
* message as `D1_ERROR: no such table: ...`. We match on the table name to
* avoid swallowing unrelated "no such table" errors from queries that touch
* other tables (defensive `getAppliedMigrationCount` only references
* `MIGRATION_TABLE`, but downstream callers may grow).
*/
const MIGRATION_TABLE_MISSING_PATTERN = new RegExp(
`no such table:\\s*${escapeRegExp(MIGRATION_TABLE)}\\b`,
* Pattern used to detect "table does not exist" errors from supported
* drivers. SQLite-family drivers report `no such table: ...`; D1 wraps the
* same message as `D1_ERROR: no such table: ...`. Postgres reports
* `relation "..."" does not exist` (typically with the relation name quoted).
*
* We match on the migration table name to avoid swallowing unrelated missing
* table errors from queries that touch other tables (defensive
* `getAppliedMigrationCount` only references `MIGRATION_TABLE`, but
* downstream callers may grow).
*/
const MIGRATION_TABLE_MISSING_PATTERN = new RegExp(
`(?:no such table:\\s*${escapeRegExp(MIGRATION_TABLE)}\\b|relation\\s+"?${escapeRegExp(MIGRATION_TABLE)}"?\\s+does not exist\\b)`,

Copilot uses AI. Check for mistakes.
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.

Postgres reports a missing relation as "relation "foo" does not exist"
(or occasionally "table "foo" does not exist"), not the SQLite-family
"no such table: foo". Without this fix, a fresh Postgres database
would throw out of `getAppliedMigrationCount` on the fast-path probe
before the Kysely Migrator ever got a chance to create the migration
table, breaking initialization on Postgres entirely.

The pattern is still anchored to `MIGRATION_TABLE` to avoid swallowing
unrelated missing-table errors, and built via `escapeRegExp` so a
table rename cannot silently disable detection.

Race detection is unchanged: Kysely's PostgresAdapter uses a real
advisory lock (`pg_advisory_xact_lock`), so the concurrent-migration
race the rest of this PR addresses can only occur on D1/SQLite.
@ask-bonk
Copy link
Copy Markdown
Contributor Author

ask-bonk Bot commented Apr 30, 2026

Addressed the Postgres missing-table point from the latest Copilot review.

Problem: MIGRATION_TABLE_MISSING_PATTERN only matched the SQLite/D1 phrasing (no such table: _emdash_migrations). On Postgres a fresh database surfaces the same condition as relation "_emdash_migrations" does not exist, which means the fast-path probe in getAppliedMigrationCount would have rethrown on every fresh Postgres init — breaking the runtime path before the Kysely Migrator ever got a chance to create the table. This was a real regression introduced by the previous tightening of the catch.

Fix in packages/core/src/database/migrations/runner.ts:

  • Extended MIGRATION_TABLE_MISSING_PATTERN to also match (?:relation|table) "?_emdash_migrations"? does(?:n't| not) exist, with optional surrounding double quotes (Postgres includes them; some message variants don't).
  • Still derived from MIGRATION_TABLE via escapeRegExp so a rename can't drift, and still scoped to the migration table name so unrelated missing-table errors aren't swallowed. I considered just calling the shared isMissingTableError() helper from utils/db-errors.ts but kept the table-scoped check as cheap insurance against future edits to getAppliedMigrationCount.
  • Verified the regex with a quick property check: matches no such table: _emdash_migrations, D1_ERROR: no such table: _emdash_migrations: SQLITE_ERROR, relation "_emdash_migrations" does not exist, table "_emdash_migrations" does not exist; rejects no such table: ec_posts, relation "ec_posts" does not exist, connection refused.

Race detection unchanged. MIGRATION_RACE_PATTERN stays SQLite-only on purpose: Kysely's PostgresAdapter.acquireMigrationLock uses pg_advisory_xact_lock, so the concurrent-migration race this PR addresses physically can't occur on Postgres — there's nothing to detect.

Tests. All 3 race-related tests still pass locally (should not throw when two callers run migrations concurrently, should fast-path when the migration table has more rows than this build knows about, should still surface unrelated migration errors), plus the broader tests/database/migrations.test.ts (14 tests). Postgres tests are gated behind EMDASH_TEST_PG which isn't set in this sandbox, but the fix is the regex itself — the existing setupTestPostgresDatabase() helper runs runMigrations against an empty schema on every Postgres test, so the suite will exercise this path automatically when EMDASH_TEST_PG is set in CI.

Typecheck and lint are clean for the touched file (15 pre-existing diagnostics elsewhere, unchanged from baseline). Commit + push handled by the automation.

@ask-bonk
Copy link
Copy Markdown
Contributor Author

ask-bonk Bot commented Apr 30, 2026

Comment posted. Summary of what I addressed from the latest Copilot review on PR #857:

Fixed in packages/core/src/database/migrations/runner.ts:

  • Extended MIGRATION_TABLE_MISSING_PATTERN to also match Postgres phrasing (relation "_emdash_migrations" does not exist and table "_emdash_migrations" does not exist), with optional surrounding double quotes.
  • Still derived from MIGRATION_TABLE via escapeRegExp and scoped to the migration table name, so the defensive properties of the previous tightening are preserved.

Why it matters: Without this, the fast-path probe in getAppliedMigrationCount would rethrow on every fresh Postgres database (since the previous commit tightened the catch to only swallow the SQLite phrasing), breaking initialization on Postgres before the Kysely Migrator could create the table.

Did NOT change MIGRATION_RACE_PATTERN — it stays SQLite-only because Kysely's PostgresAdapter uses pg_advisory_xact_lock, so the race this PR fixes physically can't occur on Postgres.

Verified the regex covers all expected phrasings (better-sqlite3, D1, Postgres) without matching unrelated tables. All 3 race tests + 14 broader migration tests pass locally.

Comment: #857 (comment)

github run

@ascorbic ascorbic merged commit 2f22f57 into main Apr 30, 2026
35 of 36 checks passed
@ascorbic ascorbic deleted the opencode/issue762-20260430142220 branch April 30, 2026 16:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Admin sidebar does not show collections and /_emdash/admin/content/{slug} returns: Page Not Found Collection "pages" not found

2 participants