Skip to content

Add fallback migrations when schema creation is blocked#38

Merged
ponderingdemocritus merged 1 commit intomainfrom
ponderingdemocritus/expmap
Feb 8, 2026
Merged

Add fallback migrations when schema creation is blocked#38
ponderingdemocritus merged 1 commit intomainfrom
ponderingdemocritus/expmap

Conversation

@ponderingdemocritus
Copy link
Contributor

@ponderingdemocritus ponderingdemocritus commented Feb 8, 2026

This adds a migration fallback path for environments where Drizzle cannot run CREATE SCHEMA. On schema-bootstrap failure, the server now applies pending SQL migration files directly and records progress in public.__drizzle_migrations. It preserves existing behavior by rethrowing non-schema migration errors. Tests were expanded to cover schema failure detection, fallback execution, and non-schema error propagation.

Summary by CodeRabbit

  • New Features

    • Enhanced database migration system with automatic fallback mechanism and persistent journal tracking to gracefully handle schema creation failures.
  • Tests

    • Added comprehensive test coverage for migration utilities, error handling, and fallback behavior.

@coderabbitai
Copy link

coderabbitai bot commented Feb 8, 2026

📝 Walkthrough

Walkthrough

Added robust database migration handling with a fallback mechanism using a persistent journal to track migrations via hash-based tracking. Extended runMigrations with pluggable migrator support and automatic fallback invocation on schema-creation errors, with comprehensive test coverage for new utilities.

Changes

Cohort / File(s) Summary
Migration Utility Enhancement
examples/facilitator-server/src/db-migrate.ts
Introduced runMigrationsFallback for journal-based migration tracking, isSchemaCreateFailure helper for error detection, and parseMigrationSql for statement splitting. Extended runMigrations to accept optional migrateFn parameter with automatic fallback on schema-creation errors. Added constants for migrations table and statement breakpoints.
Test Coverage
examples/facilitator-server/tests/db-migrate.test.ts
Added comprehensive test suites for isSchemaCreateFailure, runMigrationsFallback, and updated runMigrations signature. Tests verify journal-based tracking, SQL file fallback behavior, schema-creation error handling, and transaction-based execution.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant RunMigrations
    participant Migrator
    participant FallbackHandler
    participant Pool as Database Pool
    participant Journal as Journal Table
    participant FSys as File System

    Client->>RunMigrations: runMigrations(pool, migrateFn?)
    
    RunMigrations->>Migrator: invoke primary migrator
    
    alt Schema Creation Error
        Migrator-->>RunMigrations: throws schema error
        RunMigrations->>FallbackHandler: invoke runMigrationsFallback
        
        FallbackHandler->>Journal: read migration journal
        Journal-->>FallbackHandler: pending migrations list
        
        FallbackHandler->>FSys: read SQL migration files
        FSys-->>FallbackHandler: SQL file contents
        
        FallbackHandler->>FallbackHandler: parse & split statements
        
        FallbackHandler->>Pool: begin transaction
        loop For each statement
            FallbackHandler->>Pool: execute statement
            Pool-->>FallbackHandler: success
        end
        
        FallbackHandler->>Pool: commit transaction
        Pool-->>FallbackHandler: transaction committed
        
        FallbackHandler->>Journal: record migration hash + timestamp
        Journal-->>FallbackHandler: confirmed
        
        FallbackHandler-->>RunMigrations: fallback complete
    else No Error
        Migrator-->>RunMigrations: migrations complete
    end
    
    RunMigrations-->>Client: success
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 A rabbit hops through schema lands,
Where migrations need steady hands,
With journal tracking, fallback care,
And hash-based proof, transactions fair—
When primary paths stumble and fall,
The journal remembers them all! 📋✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main objective of the changeset: adding a fallback migration mechanism for scenarios where schema creation is blocked.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ponderingdemocritus/expmap

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@examples/facilitator-server/src/db-migrate.ts`:
- Around line 62-72: The current filter uses strict greater-than (entry.when >
lastApplied) which skips journal entries that share the exact timestamp as the
last applied migration; update the filter logic in the pendingEntries
computation so entries with when equal to lastApplied are not unintentionally
dropped — for example change the predicate to include equals (entry.when >=
lastApplied) or adopt a more robust tie-breaker (e.g., sort by when then by
entry name and include entries where when === lastApplied) when constructing
pendingEntries; update references to migrationState, lastApplied, journalPath,
journal, pendingEntries and DRIZZLE_MIGRATIONS_TABLE accordingly so
same-timestamp journal entries are handled deterministically.
- Around line 50-60: runMigrationsFallback currently creates
${DRIZZLE_MIGRATIONS_TABLE} with only id/hash/created_at which is incompatible
with Drizzle ORM 0.44.0; update the CREATE TABLE in runMigrationsFallback to
define id as "int generated always as identity primary key", add a fourth column
"status varchar" (or "varchar NOT NULL DEFAULT 'applied'" if you want a
default), and then update the migration INSERT (the statement that inserts into
DRIZZLE_MIGRATIONS_TABLE) to include the status column or rely on the default so
every inserted row satisfies Drizzle's expected columns.

In `@examples/facilitator-server/tests/db-migrate.test.ts`:
- Around line 97-122: The test relies on runMigrations' hard-coded
migrationsFolder resolution (resolve(__dirname, "../drizzle")), causing an
implicit dependency on real files; update runMigrations to accept an optional
migrationsFolder path or options object (e.g., add a parameter like
migrationsFolder?: string) and use that instead of resolve(...) inside
runMigrations and any helper like runMigrationsFallback, then update the test to
pass a controlled temporary directory containing deterministic _journal.json and
SQL files so the test is self-contained and does not depend on the repo's
drizzle/ folder.
🧹 Nitpick comments (3)
examples/facilitator-server/src/db-migrate.ts (2)

32-35: Detection is case-insensitive but relies on a specific substring — may miss variant messages.

The check uppercases the message and looks for "CREATE SCHEMA IF NOT EXISTS". This works for the known Drizzle error format, but if Drizzle ever changes its error wording (e.g., omitting IF NOT EXISTS, or localizing the message), this would silently fail and rethrow. Consider adding a broader pattern or a secondary check on the PostgreSQL error code (e.g., 42501 for insufficient privilege) if available on the error object.


91-96: ROLLBACK could throw and mask the original migration error.

If the connection is broken (e.g., network failure), client.query("ROLLBACK") will throw, replacing the original error on line 93. Wrapping the ROLLBACK in a try/catch is a common defensive practice.

🛡️ Proposed defensive ROLLBACK
     } catch (error) {
-      await client.query("ROLLBACK");
+      try {
+        await client.query("ROLLBACK");
+      } catch {
+        // ROLLBACK failed (e.g., broken connection); original error is more useful
+      }
       throw error;
     } finally {
examples/facilitator-server/tests/db-migrate.test.ts (1)

21-35: Good coverage for both positive and negative cases.

Consider adding a test for non-Error inputs (e.g., a plain string or null) to exercise the toErrorMessage fallback path, since isSchemaCreateFailure accepts unknown.

Comment on lines +50 to +60
export async function runMigrationsFallback(
pool: Pool,
migrationsFolder: string
): Promise<void> {
await pool.query(`
CREATE TABLE IF NOT EXISTS ${DRIZZLE_MIGRATIONS_TABLE} (
id SERIAL PRIMARY KEY,
hash text NOT NULL,
created_at bigint
)
`);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, find and read the db-migrate.ts file to understand the context
find . -name "db-migrate.ts" -type f | head -20

Repository: daydreamsai/facilitator

Length of output: 114


🌐 Web query:

Drizzle ORM 0.44.0 __drizzle_migrations table schema columns

💡 Result:

In Drizzle ORM 0.44.0, the __drizzle_migrations table has these columns:

  • id
  • hash
  • created_at
  • status (tracks e.g. failed / applied / rollbacked) [1]

Dialect-specific schema (as defined by Drizzle’s migration process update):

PostgreSQL

CREATE TABLE __drizzle_migrations (
  id int generated always as identity,
  hash text not null,
  created_at bigint,
  status varchar
);

[1]

MySQL

CREATE TABLE __drizzle_migrations (
  id serial,
  hash text not null,
  created_at bigint,
  status varchar
);

[1]

SQLite

CREATE TABLE __drizzle_migrations (
  id integer primary key,
  hash text not null,
  created_at numeric,
  status text
);

[1]

By default, executed migrations are stored in the __drizzle_migrations table (and on PostgreSQL, by default under the drizzle schema unless configured otherwise). [2]

Sources: [1] [2]


🏁 Script executed:

cat -n ./examples/facilitator-server/src/db-migrate.ts

Repository: daydreamsai/facilitator

Length of output: 4398


Fix __drizzle_migrations table schema—missing status column required by Drizzle ORM 0.44.0.

The fallback creates a 3-column table but Drizzle ORM 0.44.0 expects 4 columns: id, hash, created_at, and status. The status column (type varchar) tracks migration state (applied, failed, rollbacked). Without it, the schema is incompatible with Drizzle's expectations. Additionally, for PostgreSQL, id should be int generated always as identity instead of SERIAL PRIMARY KEY. The INSERT on line 87 also needs to populate the status column (or specify a default). The hash algorithm (SHA-256 on line 78) is correct.

🤖 Prompt for AI Agents
In `@examples/facilitator-server/src/db-migrate.ts` around lines 50 - 60,
runMigrationsFallback currently creates ${DRIZZLE_MIGRATIONS_TABLE} with only
id/hash/created_at which is incompatible with Drizzle ORM 0.44.0; update the
CREATE TABLE in runMigrationsFallback to define id as "int generated always as
identity primary key", add a fourth column "status varchar" (or "varchar NOT
NULL DEFAULT 'applied'" if you want a default), and then update the migration
INSERT (the statement that inserts into DRIZZLE_MIGRATIONS_TABLE) to include the
status column or rely on the default so every inserted row satisfies Drizzle's
expected columns.

Comment on lines +62 to +72
const migrationState = await pool.query<{ created_at: string | number }>(
`SELECT created_at FROM ${DRIZZLE_MIGRATIONS_TABLE} ORDER BY created_at DESC LIMIT 1`
);
const lastApplied = Number(migrationState.rows[0]?.created_at ?? 0);

const journalPath = resolve(migrationsFolder, "meta", "_journal.json");
const journalContent = await readFile(journalPath, "utf8");
const journal = JSON.parse(journalContent) as MigrationJournal;
const pendingEntries = [...journal.entries]
.sort((a, b) => a.when - b.when)
.filter((entry) => entry.when > lastApplied);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Filtering by strict > on when assumes each journal entry has a unique timestamp.

If two journal entries share the same when value, only the first will be applied — the second would be filtered out on subsequent runs because lastApplied would equal its when. Drizzle journals typically have unique timestamps, so this is likely fine in practice, but worth noting as a latent edge case.

🤖 Prompt for AI Agents
In `@examples/facilitator-server/src/db-migrate.ts` around lines 62 - 72, The
current filter uses strict greater-than (entry.when > lastApplied) which skips
journal entries that share the exact timestamp as the last applied migration;
update the filter logic in the pendingEntries computation so entries with when
equal to lastApplied are not unintentionally dropped — for example change the
predicate to include equals (entry.when >= lastApplied) or adopt a more robust
tie-breaker (e.g., sort by when then by entry name and include entries where
when === lastApplied) when constructing pendingEntries; update references to
migrationState, lastApplied, journalPath, journal, pendingEntries and
DRIZZLE_MIGRATIONS_TABLE accordingly so same-timestamp journal entries are
handled deterministically.

Comment on lines +97 to +122
describe("runMigrations", () => {
it("falls back to SQL file execution when Drizzle schema bootstrap fails", async () => {
const clientQuery = mock(async () => ({ rows: [] as unknown[] }));
const pool = {
query: mock(async (sql: string) => {
if (sql.includes("SELECT created_at")) {
return { rows: [] as unknown[] };
}
return { rows: [] as unknown[] };
}),
connect: mock(async () => ({
query: clientQuery,
release: () => {},
})),
};

await runMigrations(
pool as any,
mock(async () => {
throw new Error('Failed query: CREATE SCHEMA IF NOT EXISTS "public"');
}) as any
);

expect(pool.connect).toHaveBeenCalledTimes(1);
expect(clientQuery).toHaveBeenCalled();
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

This test has an implicit dependency on real migration files in the drizzle/ folder.

runMigrations resolves migrationsFolder to resolve(__dirname, "../drizzle") internally. When the fallback is triggered, it reads the journal and SQL files from that path. This means the test will break if:

  • The drizzle/ directory doesn't exist or is empty
  • The migration files are renamed or reorganized
  • The journal's when values change relative to what's already "applied" (empty mock returns { rows: [] })

Consider extracting the migrationsFolder resolution or making it configurable so the test can use a controlled temp directory (similar to the runMigrationsFallback test above), making it fully self-contained.

#!/bin/bash
# Check if the drizzle folder and journal exist in the expected location
fd -t d "drizzle" --full-path "examples/facilitator-server"
fd "_journal.json" --full-path "examples/facilitator-server"
# Also check for SQL migration files
fd -e sql --full-path "examples/facilitator-server/drizzle"
🤖 Prompt for AI Agents
In `@examples/facilitator-server/tests/db-migrate.test.ts` around lines 97 - 122,
The test relies on runMigrations' hard-coded migrationsFolder resolution
(resolve(__dirname, "../drizzle")), causing an implicit dependency on real
files; update runMigrations to accept an optional migrationsFolder path or
options object (e.g., add a parameter like migrationsFolder?: string) and use
that instead of resolve(...) inside runMigrations and any helper like
runMigrationsFallback, then update the test to pass a controlled temporary
directory containing deterministic _journal.json and SQL files so the test is
self-contained and does not depend on the repo's drizzle/ folder.

@ponderingdemocritus ponderingdemocritus merged commit 80b6ea3 into main Feb 8, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant