Skip to content

feat: add ability to apply migrations in throwaway db to test if they apply successfully#676

Open
pieh wants to merge 7 commits into
mainfrom
feat/test-apply-migrations
Open

feat: add ability to apply migrations in throwaway db to test if they apply successfully#676
pieh wants to merge 7 commits into
mainfrom
feat/test-apply-migrations

Conversation

@pieh
Copy link
Copy Markdown
Contributor

@pieh pieh commented May 6, 2026

TODO:

  • re-add duplicate name specific error
  • tests

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 6, 2026

📝 Walkthrough

Summary by CodeRabbit

Release Notes

  • New Features

    • Enhanced database migration system with improved error handling, validation, and detailed error reporting.
    • Added utility to test migrations in ephemeral databases.
    • Expanded public API to expose migration types and related functions.
  • Tests

    • Added comprehensive test suite covering migration scenarios including duplicates, version conflicts, and syntax errors.

Walkthrough

This PR introduces a comprehensive typed database migration system with enhanced error handling and validation. Key changes include refactoring migrations.ts with stricter name/version pattern validation, new error classes (MissingMigrationDirectoryError, DuplicateMigrationVersionsError, MigrationsApplyError, MigrationFileError), and a MigrationIssue union type for detailed error reporting. The Migration interface now includes a version field extracted from naming conventions. New applyMigrationsWithDetails function provides detailed migration results with transaction handling. A new testMigrationsInEphemeralDatabase utility enables end-to-end testing against ephemeral databases. Test files are updated with new validation scenarios and error message expectations. Public APIs across multiple packages are expanded to export new types and utilities.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Detailed review notes

migrations.ts (primary complexity)

  • Core refactor introduces 5 new error classes with custom semantics; requires verification of error wrapping and propagation paths
  • New validation logic for duplicate names and versions; complex deduplication logic across directory and flat-file migrations must be verified against edge cases
  • Migration application flow significantly reworked with content loading, transaction handling, and detailed error capture; logic density is high
  • Utility functions (normalizePgError, issue builders, formatters) add multiple layers of error transformation

Test coverage additions

  • New comprehensive test suite (testMigrationsInEphemeralDatabase.test.ts) covers ~10 scenarios with multiple assertions per test; requires verification against actual migration behavior
  • migrations.test.ts updates reflect new error message contracts; must confirm alignment with implementation

API surface changes

  • New function signature applyMigrationsWithDetails requires careful review of delegation pattern in applyMigrations wrapper
  • Multiple type exports (PgErrorDetails, MigrationIssue, TestMigrationsInEphemeralDatabaseResult) across packages; verify consistency and completeness of re-exports

Cross-file dependencies

  • Changes span 5 files with interdependencies; main.ts and packages/dev/src/main.ts depend on migrations.ts exports
  • testMigrationsInEphemeralDatabase implementation relies on refactored applyMigrationsWithDetails; integration must be validated
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main feature being added: the ability to apply migrations in a throwaway database to test their successful application.
Description check ✅ Passed The description, though brief, is directly related to the changeset by referencing completed TODO items about re-adding duplicate name errors and adding tests, which match the implemented changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/test-apply-migrations

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.

}

export async function applyMigrations(
export class MissingMigrationDirectoryError extends Error {
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.

Adding few specific error classes to help with programmatic handling of some specific errors that can be thrown during applyMigrations runs.

For the most part the user-facing output will change slightly, but should contain pretty much same information as before and those specific errors just have more structured data for programmatic handling

Comment thread packages/database/dev/src/lib/migrations.ts Outdated
Comment on lines +112 to +115
export async function applyMigrations(...args: Parameters<typeof applyMigrationsWithDetails>): Promise<string[]> {
const migrations = await applyMigrationsWithDetails(...args)
return migrations.map((m) => m.name)
}
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.

I did make "inner" applyMigrations return more details about applied migrations and not just name and to preserve signature for existing consumers adding this small adapter.

@pieh pieh marked this pull request as ready for review May 6, 2026 10:56
@pieh pieh requested review from a team as code owners May 6, 2026 10:56
Copy link
Copy Markdown
Contributor

@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

🧹 Nitpick comments (1)
packages/database/dev/src/lib/migrations.ts (1)

254-263: ⚡ Quick win

MigrationsApplyError.cause overrides the super() cause and drops Postgres context.

The constructor sets cause twice: super(..., { cause: options.issue.pgError }) attaches the rich PgErrorDetails, then this.cause = new Error(options.issue.pgError.message) replaces it with a bare Error whose only data is the message — code, position, hint, etc. are lost from the cause chain (they're still reachable via error.issue.pgError, but error.cause is a strictly worse object). The explicit public readonly cause: Error field also shadows the inherited Error.cause: unknown, which is what makes the second assignment necessary in the first place.

Either drop the field declaration and rely on the super() cause, or drop the super() cause arg and assign the original error directly — the current double-assignment doesn't add value.

♻️ Suggested simplification
 export class MigrationsApplyError extends Error {
   public readonly issue: MigrationIssue & { kind: 'apply-failure' }
-  public readonly cause: Error
   constructor(options: { issue: MigrationIssue & { kind: 'apply-failure' } }) {
     super(`${options.issue.summary}\n${options.issue.remediation}`, { cause: options.issue.pgError })
     this.name = 'MigrationsApplyError'
     this.issue = options.issue
-    this.cause = new Error(options.issue.pgError.message)
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/database/dev/src/lib/migrations.ts` around lines 254 - 263, The
constructor is currently attaching the rich PgErrorDetails via super(..., {
cause: options.issue.pgError }) but then overrides it with this.cause = new
Error(...), losing Postgres context; remove the explicit public readonly cause:
Error property and the subsequent this.cause = new Error(...) assignment in
MigrationsApplyError so the inherited Error.cause (set by super) retains the
original options.issue.pgError; keep the super(...) cause argument and assign
only this.issue and this.name in the constructor.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/database/dev/src/lib/migrations.ts`:
- Around line 310-317: The catch around readdir is too broad; only convert a
missing-directory error into MissingMigrationDirectoryError. Change the catch in
the block that calls readdir(...)/direntToMigration to inspect the caught error
(as NodeJS.ErrnoException) and if error.code === 'ENOENT' (and optionally
'ENOTDIR' if you want to treat non-directory as missing) throw new
MissingMigrationDirectoryError({ migrationsDirectory, cause: error }); otherwise
rethrow the original error so errorToIssues/testMigrationsInEphemeralDatabase
can surface it as unknown/unreadable instead of mapping it to an empty success.
- Around line 414-421: The failure builds the "remaining" list from
migrationsToConsider which still contains migrations that were skipped as
already applied; instead compute the remaining migrations from the filtered list
that actually got considered for application (or compute the failing migration's
index within that filtered list) and pass filtered.slice(failingIndex + 1) into
buildApplyFailureIssue so the failing migration isn't duplicated in "remaining"
(refer to MigrationsApplyError, buildApplyFailureIssue, migration, applied,
migrationsToConsider); also add a test that pre-applies some migrations and then
calls applyMigrations a second time with a tail migration that fails to assert
the remaining list is correct.

In `@packages/database/dev/src/main.ts`:
- Around line 246-249: The current branch treats any empty array from
errorToIssues as a successful "no migrations" case which masks real upstream
I/O/permission errors; change the logic so that errorToIssues emits a specific
sentinel (or throws a specific MissingMigrationsDirectory error) for the
missing-directory case and only map that sentinel to { status: 'success',
applied: [] } here in applyMigrationsWithDetails; otherwise rethrow or propagate
the original error (or return a failure result) so genuine ENOENT/EACCES/IO
errors are not misclassified. Ensure you modify or check for the unique
identifier returned by errorToIssues (e.g., an issue with type
'missing-directory' or a MissingMigrationsDirectory class) rather than using
issues.length === 0, and update callers/tests accordingly.

---

Nitpick comments:
In `@packages/database/dev/src/lib/migrations.ts`:
- Around line 254-263: The constructor is currently attaching the rich
PgErrorDetails via super(..., { cause: options.issue.pgError }) but then
overrides it with this.cause = new Error(...), losing Postgres context; remove
the explicit public readonly cause: Error property and the subsequent this.cause
= new Error(...) assignment in MigrationsApplyError so the inherited Error.cause
(set by super) retains the original options.issue.pgError; keep the super(...)
cause argument and assign only this.issue and this.name in the constructor.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fad44527-c168-43cd-8ccb-4430ecc65934

📥 Commits

Reviewing files that changed from the base of the PR and between 62766dd and 54b2245.

📒 Files selected for processing (5)
  • packages/database/dev/src/lib/migrations.test.ts
  • packages/database/dev/src/lib/migrations.ts
  • packages/database/dev/src/lib/testMigrationsInEphemeralDatabase.test.ts
  • packages/database/dev/src/main.ts
  • packages/dev/src/main.ts

Comment on lines 310 to 317
try {
const dirents = await readdir(migrationsDirectory, { withFileTypes: true })
migrations = dirents
.map((entry) => toMigration(entry, migrationsDirectory))
.map((entry) => direntToMigration(entry, migrationsDirectory))
.filter((m): m is Migration => m !== null)
} catch {
throw new Error(`Migration directory not found: ${migrationsDirectory}`)
} catch (error) {
throw new MissingMigrationDirectoryError({ migrationsDirectory, cause: error })
}
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Catch is too broad — non-ENOENT errors get reported as “success: applied=[]”.

readdir can fail with EACCES, ENOTDIR, EMFILE, etc. All of those are currently translated to MissingMigrationDirectoryError, which errorToIssues (Lines 275-277) maps to an empty issues array, which testMigrationsInEphemeralDatabase then turns into { status: 'success', applied: [] }. A user with a permission-denied or otherwise-unreadable migrations dir will be told everything is fine.

Narrow the wrapping to the missing-directory case and let the rest surface as unknown issues (or as a distinct 'unreadable' case).

🛡️ Suggested guard
   try {
     const dirents = await readdir(migrationsDirectory, { withFileTypes: true })
     migrations = dirents
       .map((entry) => direntToMigration(entry, migrationsDirectory))
       .filter((m): m is Migration => m !== null)
   } catch (error) {
-    throw new MissingMigrationDirectoryError({ migrationsDirectory, cause: error })
+    if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
+      throw new MissingMigrationDirectoryError({ migrationsDirectory, cause: error })
+    }
+    throw error
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
const dirents = await readdir(migrationsDirectory, { withFileTypes: true })
migrations = dirents
.map((entry) => toMigration(entry, migrationsDirectory))
.map((entry) => direntToMigration(entry, migrationsDirectory))
.filter((m): m is Migration => m !== null)
} catch {
throw new Error(`Migration directory not found: ${migrationsDirectory}`)
} catch (error) {
throw new MissingMigrationDirectoryError({ migrationsDirectory, cause: error })
}
try {
const dirents = await readdir(migrationsDirectory, { withFileTypes: true })
migrations = dirents
.map((entry) => direntToMigration(entry, migrationsDirectory))
.filter((m): m is Migration => m !== null)
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
throw new MissingMigrationDirectoryError({ migrationsDirectory, cause: error })
}
throw error
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/database/dev/src/lib/migrations.ts` around lines 310 - 317, The
catch around readdir is too broad; only convert a missing-directory error into
MissingMigrationDirectoryError. Change the catch in the block that calls
readdir(...)/direntToMigration to inspect the caught error (as
NodeJS.ErrnoException) and if error.code === 'ENOENT' (and optionally 'ENOTDIR'
if you want to treat non-directory as missing) throw new
MissingMigrationDirectoryError({ migrationsDirectory, cause: error }); otherwise
rethrow the original error so errorToIssues/testMigrationsInEphemeralDatabase
can surface it as unknown/unreadable instead of mapping it to an empty success.

Comment on lines +414 to +421
throw new MigrationsApplyError({
issue: buildApplyFailureIssue({
migration,
appliedBefore: applied,
remaining: migrationsToConsider.slice(applied.length + 1),
cause: error,
}),
})
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

remaining is computed against the wrong list when some migrations were already applied.

applied only counts migrations applied in this invocation, but migrationsToConsider also contains migrations that had already been applied in a previous run and were skipped at Lines 385-388. migrationsToConsider.slice(applied.length + 1) therefore over-includes (and can include the failing migration itself) whenever the tracking table already has rows.

Example: migrationsToConsider = [m1 (already applied), m2, m3], m2 fails → applied = [], so remaining = migrationsToConsider.slice(1) = [m2, m3], but the intent is [m3].

Use the filtered list (or the failing migration's index) instead.

🐛 Suggested fix
-  const applied: Migration[] = []
-  for (const { migration, sql } of migrationsToApplyWithContent) {
+  const applied: Migration[] = []
+  for (let i = 0; i < migrationsToApplyWithContent.length; i++) {
+    const { migration, sql } = migrationsToApplyWithContent[i]
     try {
       await db.transaction(async (tx) => {
         await tx.exec(sql)
         await tx.query(`INSERT INTO ${TRACKING_TABLE} (name) VALUES ($1)`, [migration.name])
       })
     } catch (error) {
       throw new MigrationsApplyError({
         issue: buildApplyFailureIssue({
           migration,
           appliedBefore: applied,
-          remaining: migrationsToConsider.slice(applied.length + 1),
+          remaining: migrationsToApplyWithContent.slice(i + 1).map((entry) => entry.migration),
           cause: error,
         }),
       })
     }

The existing apply-failure tests don't pre-apply any migrations, so this case isn't covered — adding one that calls applyMigrations twice (with a failing tail migration on the second call) would lock this in.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/database/dev/src/lib/migrations.ts` around lines 414 - 421, The
failure builds the "remaining" list from migrationsToConsider which still
contains migrations that were skipped as already applied; instead compute the
remaining migrations from the filtered list that actually got considered for
application (or compute the failing migration's index within that filtered list)
and pass filtered.slice(failingIndex + 1) into buildApplyFailureIssue so the
failing migration isn't duplicated in "remaining" (refer to
MigrationsApplyError, buildApplyFailureIssue, migration, applied,
migrationsToConsider); also add a test that pre-applies some migrations and then
calls applyMigrations a second time with a tail migration that fails to assert
the remaining list is correct.

Comment on lines +246 to +249
const issues = errorToIssues(error)
if (issues.length === 0) {
return { status: 'success', applied: [] }
}
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Empty-issues fallback masks upstream classification bugs.

Mapping zero issues to { status: 'success', applied: [] } is fine for the missing-directory case, but it relies on errorToIssues returning [] only for that specific case. Combined with the over-broad catch in applyMigrationsWithDetails (see comment on migrations.ts Lines 310-317), permission-denied / I/O failures on the migrations directory currently surface here as success. Once the upstream catch is narrowed, this branch is correct as-is.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/database/dev/src/main.ts` around lines 246 - 249, The current branch
treats any empty array from errorToIssues as a successful "no migrations" case
which masks real upstream I/O/permission errors; change the logic so that
errorToIssues emits a specific sentinel (or throws a specific
MissingMigrationsDirectory error) for the missing-directory case and only map
that sentinel to { status: 'success', applied: [] } here in
applyMigrationsWithDetails; otherwise rethrow or propagate the original error
(or return a failure result) so genuine ENOENT/EACCES/IO errors are not
misclassified. Ensure you modify or check for the unique identifier returned by
errorToIssues (e.g., an issue with type 'missing-directory' or a
MissingMigrationsDirectory class) rather than using issues.length === 0, and
update callers/tests accordingly.

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.

2 participants