Skip to content

feat: Add migration loader strategies with SQL grouping and docs#1582

Merged
Shinigami92 merged 8 commits into
salsita:mainfrom
peturv:feature/migration-loader-strategies
Mar 20, 2026
Merged

feat: Add migration loader strategies with SQL grouping and docs#1582
Shinigami92 merged 8 commits into
salsita:mainfrom
peturv:feature/migration-loader-strategies

Conversation

@peturv

@peturv peturv commented Mar 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Since database migrations are a sensitive area that should be treated with care, I've tried to make my changes to existing code as small as possible, while also putting effort into ensuring that existing behavior is not changed unless loading strategy configuration is applied.

  • Introduces configurable migrationLoaderStrategies for programmatic migration loading while preserving default behavior when not configured.
  • Adds a new SQL loader mode ("sql") that groups *.up.sql / *.down.sql into a single migration unit and keeps ordering stable by migration id/path. (addresses Finalize sql support #786)
  • Expands documentation for the new API surface and adds a dedicated guide for migration loading strategies, including legacy SQL behavior and marker fallback rules.
  • Addresses Finalize sql support #786
  • Addresses why feature of writing migrations in plain SQL is HIDDEN? #937

Behavior and compatibility

  • Default behavior remains intact if migrationLoaderStrategies is omitted.
  • Legacy .sql files still work as before (single-file SQL with marker-based parsing).
  • New grouped SQL mode is opt-in via strategy config (loader: "sql").

Test coverage added

  • test/migrationLoader.spec.ts
    • default legacy SQL behavior
    • grouped SQL behavior
    • .down.sql without .up.sql error
    • extension-bucket strategy routing
  • test/runner.loadMigrations.regression.spec.ts
    • regression checks for default loadMigrations path/name behavior
    • split migration ordering (001_init remains before 002_next)
    • unmatched extension fallback behavior
    • mixed .sql and .up/.down.sql conflict error
    • case-insensitive extension strategy matching

Docs

  • Updates docs/src/api.md with migrationLoaderStrategies
  • Adds docs/src/migration-loading-strategies.md
  • Adds docs sidebar entry in docs/.vitepress/config.mts
  • Adds discoverability link in docs/src/getting-started.md

Validation

  • pnpm run test:unit
  • pnpm run docs:build

Adding feature tests for custom loaders, regression tests and documentation.
@peturv peturv requested a review from Shinigami92 as a code owner March 19, 2026 14:29
@peturv peturv changed the title Add migration loader strategies with SQL grouping and docs feat Add migration loader strategies with SQL grouping and docs Mar 19, 2026
@peturv peturv changed the title feat Add migration loader strategies with SQL grouping and docs feat: Add migration loader strategies with SQL grouping and docs Mar 19, 2026
@peturv

peturv commented Mar 19, 2026

Copy link
Copy Markdown
Contributor Author

I did this on kind of short notice. Have some nodejs services I'm working on that I need to get to production state and db-versioning is part of that. So when I was looking around for good tools yesterday I stumbled upon this project that looks quite solid.

But since a lot of my workflow is based on DDL in sql-files, and I saw there were a couple of other questions around this, I figured I might as well suggest a simple solution that enables users to manage this by themselves if they want to.

Cheers and kudos for a nice tool!

@github-actions

github-actions Bot commented Mar 19, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 93.64% (🎯 90%)
⬆️ +0.31%
1548 / 1653
🔵 Statements 93.55% (🎯 90%)
⬆️ +0.15%
1568 / 1676
🔵 Functions 95.04% (🎯 90%)
⬆️ +0.31%
345 / 363
🔵 Branches 87.14% (🎯 85%)
⬇️ -0.08%
976 / 1120
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/index.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/migration.ts 76.53%
⬇️ -3.13%
69.11%
⬇️ -4.96%
80%
⬆️ +0.84%
76.28%
⬇️ -3.20%
129-131, 153-168, 233-268, 334, 361-363, 371, 389-391, 421
src/migrationLoader.ts 90.81% 82.69% 90% 93.54% 222, 361, 365, 368, 371, 405, 424, 431-432
src/runner.ts 78.33%
⬆️ +1.48%
62.31%
⬆️ +1.75%
82.6%
🟰 ±0%
77.58%
⬆️ +1.52%
210-212, 225, 278, 323, 333-336, 350, 362-364, 388, 429-440, 445-447, 468-469, 479, 481-489
src/utils/comparators.ts 100% 100% 100% 100%
src/utils/createTransformer.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/utils/fileNameUtils.ts 100% 100% 100% 100%
src/utils/index.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
Affected Files
src/sqlMigration.ts 100%
⬆️ +16.67%
100%
🟰 ±0%
100%
⬆️ +20.00%
100%
⬆️ +16.67%
Unchanged Files
src/db.ts 78.43% 75% 76.92% 78% 90-92, 125-137, 173, 177-178
src/logger.ts 100% 100% 100% 100%
src/migrationBuilder.ts 95.49% 62.5% 90% 95.49% 833-836, 976-980
src/migrationOptions.ts 100% 100% 100% 100%
src/pgType.ts 100% 100% 100% 100%
src/operations/sql.ts 100% 50% 100% 100%
src/operations/casts/createCast.ts 100% 100% 100% 100%
src/operations/casts/dropCast.ts 100% 100% 100% 100%
src/operations/casts/index.ts 100% 100% 100% 100%
src/operations/domains/alterDomain.ts 100% 100% 100% 100%
src/operations/domains/createDomain.ts 100% 95.45% 100% 100%
src/operations/domains/dropDomain.ts 100% 100% 100% 100%
src/operations/domains/index.ts 100% 100% 100% 100%
src/operations/domains/renameDomain.ts 100% 100% 100% 100%
src/operations/domains/shared.ts 100% 100% 100% 100%
src/operations/extensions/createExtension.ts 100% 100% 100% 100%
src/operations/extensions/dropExtension.ts 100% 100% 100% 100%
src/operations/extensions/index.ts 100% 100% 100% 100%
src/operations/extensions/shared.ts 100% 100% 100% 100%
src/operations/functions/createFunction.ts 96.29% 92% 100% 96.29% 71-73
src/operations/functions/dropFunction.ts 100% 100% 100% 100%
src/operations/functions/index.ts 100% 100% 100% 100%
src/operations/functions/renameFunction.ts 100% 100% 100% 100%
src/operations/functions/shared.ts 100% 100% 100% 100%
src/operations/grants/grantOnSchemas.ts 100% 100% 100% 100%
src/operations/grants/grantOnTables.ts 100% 100% 100% 100%
src/operations/grants/grantRoles.ts 100% 100% 100% 100%
src/operations/grants/index.ts 100% 100% 100% 100%
src/operations/grants/revokeOnSchemas.ts 100% 100% 100% 100%
src/operations/grants/revokeOnTables.ts 100% 100% 100% 100%
src/operations/grants/revokeRoles.ts 100% 100% 100% 100%
src/operations/grants/shared.ts 100% 75% 100% 100%
src/operations/indexes/createIndex.ts 100% 100% 100% 100%
src/operations/indexes/dropIndex.ts 100% 100% 100% 100%
src/operations/indexes/index.ts 100% 100% 100% 100%
src/operations/indexes/shared.ts 100% 88.23% 100% 100%
src/operations/materializedViews/alterMaterializedView.ts 100% 100% 100% 100%
src/operations/materializedViews/createMaterializedView.ts 100% 100% 100% 100%
src/operations/materializedViews/dropMaterializedView.ts 100% 100% 100% 100%
src/operations/materializedViews/index.ts 100% 100% 100% 100%
src/operations/materializedViews/refreshMaterializedView.ts 100% 100% 100% 100%
src/operations/materializedViews/renameMaterializedView.ts 100% 100% 100% 100%
src/operations/materializedViews/renameMaterializedViewColumn.ts 100% 100% 100% 100%
src/operations/materializedViews/shared.ts 100% 83.33% 100% 100%
src/operations/operators/addToOperatorFamily.ts 100% 100% 100% 100%
src/operations/operators/createOperator.ts 100% 88.88% 100% 100%
src/operations/operators/createOperatorClass.ts 100% 75% 100% 100%
src/operations/operators/createOperatorFamily.ts 100% 100% 100% 100%
src/operations/operators/dropOperator.ts 100% 100% 100% 100%
src/operations/operators/dropOperatorClass.ts 100% 100% 100% 100%
src/operations/operators/dropOperatorFamily.ts 100% 100% 100% 100%
src/operations/operators/index.ts 100% 100% 100% 100%
src/operations/operators/removeFromOperatorFamily.ts 100% 100% 100% 100%
src/operations/operators/renameOperatorClass.ts 100% 100% 100% 100%
src/operations/operators/renameOperatorFamily.ts 100% 100% 100% 100%
src/operations/operators/shared.ts 81.81% 77.77% 100% 81.81% 24, 38
src/operations/policies/alterPolicy.ts 100% 100% 100% 100%
src/operations/policies/createPolicy.ts 100% 100% 100% 100%
src/operations/policies/dropPolicy.ts 100% 100% 100% 100%
src/operations/policies/index.ts 100% 100% 100% 100%
src/operations/policies/renamePolicy.ts 100% 100% 100% 100%
src/operations/policies/shared.ts 100% 100% 100% 100%
src/operations/roles/alterRole.ts 100% 100% 100% 100%
src/operations/roles/createRole.ts 100% 92.3% 100% 100%
src/operations/roles/dropRole.ts 100% 100% 100% 100%
src/operations/roles/index.ts 100% 100% 100% 100%
src/operations/roles/renameRole.ts 100% 100% 100% 100%
src/operations/roles/shared.ts 100% 86.66% 100% 100%
src/operations/schemas/createSchema.ts 100% 100% 100% 100%
src/operations/schemas/dropSchema.ts 100% 100% 100% 100%
src/operations/schemas/index.ts 100% 100% 100% 100%
src/operations/schemas/renameSchema.ts 100% 100% 100% 100%
src/operations/sequences/alterSequence.ts 90% 83.33% 100% 90% 23
src/operations/sequences/createSequence.ts 100% 100% 100% 100%
src/operations/sequences/dropSequence.ts 100% 100% 100% 100%
src/operations/sequences/index.ts 100% 100% 100% 100%
src/operations/sequences/renameSequence.ts 100% 100% 100% 100%
src/operations/sequences/shared.ts 81.48% 83.33% 100% 81.48% 41, 43, 49, 63, 69
src/operations/tables/addColumns.ts 100% 85.71% 100% 100%
src/operations/tables/addConstraint.ts 100% 100% 100% 100%
src/operations/tables/alterColumn.ts 91.17% 81.25% 100% 91.17% 69, 76-83
src/operations/tables/alterTable.ts 100% 100% 100% 100%
src/operations/tables/createTable.ts 88% 82.14% 100% 88% 51-54, 92
src/operations/tables/dropColumns.ts 100% 100% 100% 100%
src/operations/tables/dropConstraint.ts 100% 100% 100% 100%
src/operations/tables/dropTable.ts 100% 100% 100% 100%
src/operations/tables/index.ts 100% 100% 100% 100%
src/operations/tables/renameColumn.ts 100% 100% 100% 100%
src/operations/tables/renameConstraint.ts 100% 100% 100% 100%
src/operations/tables/renameTable.ts 100% 100% 100% 100%
src/operations/tables/shared.ts 86.79% 76.76% 76.47% 86.79% 157, 241, 261, 287, 291-297, 301, 442-463
src/operations/triggers/createTrigger.ts 88.23% 71.79% 100% 88.23% 53, 66, 70, 74-76
src/operations/triggers/dropTrigger.ts 100% 100% 100% 100%
src/operations/triggers/index.ts 100% 100% 100% 100%
src/operations/triggers/renameTrigger.ts 100% 100% 100% 100%
src/operations/triggers/shared.ts 100% 100% 100% 100%
src/operations/types/addTypeAttribute.ts 100% 100% 100% 100%
src/operations/types/addTypeValue.ts 100% 100% 100% 100%
src/operations/types/createType.ts 100% 100% 100% 100%
src/operations/types/dropType.ts 100% 100% 100% 100%
src/operations/types/dropTypeAttribute.ts 100% 100% 100% 100%
src/operations/types/index.ts 100% 100% 100% 100%
src/operations/types/renameType.ts 100% 100% 100% 100%
src/operations/types/renameTypeAttribute.ts 100% 100% 100% 100%
src/operations/types/renameTypeValue.ts 100% 100% 100% 100%
src/operations/types/setTypeAttribute.ts 100% 100% 100% 100%
src/operations/views/alterView.ts 100% 100% 100% 100%
src/operations/views/alterViewColumn.ts 100% 100% 100% 100%
src/operations/views/createView.ts 100% 100% 100% 100%
src/operations/views/dropView.ts 100% 100% 100% 100%
src/operations/views/index.ts 100% 100% 100% 100%
src/operations/views/renameView.ts 100% 100% 100% 100%
src/operations/views/shared.ts 100% 50% 100% 100%
src/utils/PgLiteral.ts 100% 100% 100% 100%
src/utils/createSchemalize.ts 96.42% 93.54% 100% 96.29% 84
src/utils/decamelize.ts 100% 100% 100% 100%
src/utils/escapeValue.ts 100% 100% 100% 100%
src/utils/formatLines.ts 100% 100% 100% 100%
src/utils/formatParams.ts 100% 87.5% 100% 100%
src/utils/formatPartitionColumns.ts 100% 100% 100% 100%
src/utils/getMigrationTableSchema.ts 100% 100% 100% 100%
src/utils/getSchemas.ts 100% 100% 100% 100%
src/utils/identity.ts 100% 100% 100% 100%
src/utils/intersection.ts 100% 100% 100% 100%
src/utils/makeComment.ts 100% 100% 100% 100%
src/utils/quote.ts 100% 100% 100% 100%
src/utils/stringIdGenerator.ts 100% 100% 100% 100%
src/utils/toArray.ts 100% 100% 100% 100%
src/utils/types.ts 100% 100% 100% 100%
Generated in workflow #3035 for commit 70a7d2d by the Vitest Coverage Report Action

@Shinigami92 Shinigami92 added the c: feature Request for new feature label Mar 19, 2026
@Shinigami92

Copy link
Copy Markdown
Collaborator

Hey @peturv 🚀
I havn't reviewed it yet, but just made a scroll-over and it looks very promising

You should run pnpm run preflight locally, to check that formatting, linting and testing runs green

It looks like cockroach db tests are running into errors 🤔

when the pipeline runs green, I will do a deep review 🙂

…e-pg-migrate.ts to avoid ambiguity, harmonizing the import with that of runner.
@peturv

peturv commented Mar 19, 2026

Copy link
Copy Markdown
Contributor Author

Hey @peturv 🚀 I havn't reviewed it yet, but just made a scroll-over and it looks very promising

You should run pnpm run preflight locally, to check that formatting, linting and testing runs green

It looks like cockroach db tests are running into errors 🤔

when the pipeline runs green, I will do a deep review 🙂

Thanks for the quick look. Have addressed the issues that were reported, a build should be on the way.

@peturv

peturv commented Mar 19, 2026

Copy link
Copy Markdown
Contributor Author

I'll see what I can do about the failing the cockroach integration test. Didn't think I was close to touching that.

@Shinigami92

Copy link
Copy Markdown
Collaborator

the following comment is written by claude

CockroachDB Test Failure Analysis

Failing Step

Migration 062_view_test fails with error: relation "v" does not exist because it runs before 062_view (which creates the view).

Root Cause

loadMigrationUnits() in src/migrationLoader.ts:261-262 sorts migration units using bare localeCompare():

const sortedMigrationUnits = migrationUnits.toSorted((a, b) =>
  a.filePaths[0].localeCompare(b.filePaths[0])
);

The original sorting in src/migration.ts uses compareFileNamesByTimestamp (numeric prefix) + localeCompareStringsNumerically, which passes { numeric: true, sensitivity: 'variant', ignorePunctuation: true } to localeCompare.

This difference changes the sort order for files that share the same numeric prefix:

Sort method Result
Plain localeCompare 062_view_test.js < 062_view.js
localeCompare with ignorePunctuation: true 062_view.js < 062_view_test.js

With plain localeCompare, underscore (_) in _test sorts before the period (.) in .js, flipping the order.

Suggested Fix

Reuse the existing sort helpers from migration.ts, or at minimum match the sort options:

const sortedMigrationUnits = migrationUnits.toSorted((a, b) =>
  a.filePaths[0].localeCompare(b.filePaths[0], undefined, {
    numeric: true,
    sensitivity: 'variant',
    ignorePunctuation: true,
  })
);

Ideally, extract compareFileNamesByTimestamp and localeCompareStringsNumerically from migration.ts so both call sites share the same logic.

@peturv

peturv commented Mar 19, 2026

Copy link
Copy Markdown
Contributor Author

the following comment is written by claude

CockroachDB Test Failure Analysis

Failing Step

...

Yes sir, thank you! Had to fix dinner here at home :)
And of course I managed miss the sorting behavior there. Added unit test to reproduce the error and a fix that made it green.

Extracted the localeCompareStringsNumerically function and added a very specific test. Did not touch compareFileNamesByTimestamp for now. Have run against a local dockerized cockroach to validate integration as well.

I do hope that's the last of my mistakes. I'm going to blame my rush, and be thankful that you have a good test suite :)

@Shinigami92

Copy link
Copy Markdown
Collaborator

the following comment is written by claude

PR Review: feat: Add migration loader strategies with SQL grouping and docs

Overall

Good feature addition that addresses two long-standing issues (#786, #937). The strategy pattern is clean, backward-compatible by default, and well-documented. The fix for the CockroachDB sort regression in the latest commit shows responsiveness. Test coverage is solid.

A few items worth addressing before merge:


Architecture & Design

localeCompareStringsNumerically sort vs. the full two-pass sort

The original getMigrationFilePaths sorts with compareFileNamesByTimestamp (numeric prefix extraction) then falls back to localeCompareStringsNumerically. The new loadMigrationUnits only uses localeCompareStringsNumerically. This works today because the ignorePunctuation: true option happens to produce the right order, but it's a subtle divergence — two code paths that should agree on ordering use different sort logic. If a future migration set exposes a case where the numeric-prefix pass matters (e.g., 2_foo vs. 10_bar with locale quirks), the two sorts could disagree.

Consider reusing the full compareFileNamesByTimestamp || localeCompareStringsNumerically comparator from migration.ts in loadMigrationUnits as well, or extracting it into a shared utility alongside localeCompareStringsNumerically.


Code

Duplicate jiti instances

createJiti(process.cwd()) is now called in migrationLoader.ts (line 23), while bin/node-pg-migrate.ts also creates its own jiti instance. These are independent instances with potentially different cwd() at import time. Not a bug today, but worth noting — if jiti caching or config ever matters, a single shared factory would be safer.

associatePathsToExtensions silently drops ordering within extension groups

Map iteration order follows insertion order in JS, so extension groups come out in first-seen order. Within each group, file order is preserved from the input. This is fine, but it's an implicit invariant that isn't documented or tested. A comment noting that input order is preserved within each bucket would help future maintainers.

Promise.all in loaders

Both createDefaultMigrationLoader and createLegacySqlMigrationLoader use Promise.all to load files concurrently. This is fine for correctness (order is preserved in the output array), but for very large migration directories it could open many file handles simultaneously. Low risk in practice, but worth a comment or consideration of batching if this is intended to handle hundreds of migrations.

sqlGroupId normalizes .up.sql.sql in the id

This means that if a user switches from a single 001_init.sql to split 001_init.up.sql + 001_init.down.sql, the migration id stays 001_init.sql and the migration table won't double-track it. This is a nice design choice. However, it should be called out explicitly in the docs since it's a key compatibility detail users will care about.


Tests

Good coverage overall. A few gaps:

  • No test for compareFileNamesByTimestamp + localeCompareStringsNumerically divergence — Consider a test with migration names like 2_foo.js and 10_bar.js to verify ordering consistency between getMigrationFilePaths and loadMigrationUnits.
  • No test for .up.sql-only (no .down.sql) — The code handles this (down becomes undefined), but there's no explicit test.
  • stringComparison.spec.ts is minimal — Only one test case. Consider adding cases for numeric sort order (e.g., 2_foo vs. 10_bar) and equal strings.

Docs

  • docs/src/api.md line for decamelize has the markdown link syntax inverted — the rendered text will show [decamelize](url) literally instead of a clickable link. The square brackets and parens appear swapped compared to the original.
    # Before (working):
    [`decamelize`](https://github.com/...)
    # After (broken):
    `[decamelize](https://github.com/...)`
    
  • docs/src/migration-loading-strategies.md — the section header "Legacy SQL migrations" uses # (H1) instead of ## (H2), which breaks the page hierarchy since it comes after the H1 page title.
  • Consider adding a note about the .up.sql.sql id normalization behavior mentioned above, so users understand that switching formats won't cause re-execution of already-run migrations.

Nits

  • migrationLoader.ts line 87: double space in "...when used as an API, or a predefined...".
  • migrationLoader.ts line 145: typo "While is handles""While it handles".
  • readSqlFileGroup line 698-699: the filePaths array puts group.up first, then group.down, then group.single. For a single-file migration, the result is [undefined, undefined, single].filter(Boolean) which works but is slightly misleading — consider [group.single ?? group.up, group.down].filter(...) to match the sqlGroupId priority.

Summary

This is a solid, well-scoped feature. The main structural concern is the sort divergence between getMigrationFilePaths and loadMigrationUnits. The docs formatting issues are quick fixes. I'd suggest addressing those two items before merge; the rest are optional improvements.

@peturv

peturv commented Mar 19, 2026

Copy link
Copy Markdown
Contributor Author

Solid feedback. Will have a look either later tonight or tomorrow and get back with changes addressing these points.

peturv added 4 commits March 20, 2026 09:29
Corrections of how jiti is exported to node-pg-migrate for legacy build.
Documentation updates. Clarification of SQL migration loader behaviour.
Added more tests for utilities and loader. Moved tests to separate files.
Renamed testfiles to follow test target file name. "Nits" addressed.

@Shinigami92 Shinigami92 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.

First real human-read iteration done.
If I would ever get such clean contributions, my life would be so much easier 💚

Only some small minor things/chors left, after these I can merge and release

Comment thread bin/node-pg-migrate.ts Outdated
Comment thread docs/src/api.md Outdated
Comment thread docs/src/api.md Outdated
Comment thread docs/src/api.md Outdated
Comment thread src/migrationLoader.ts Outdated
Comment thread src/migrationLoader.ts Outdated
@peturv

peturv commented Mar 20, 2026

Copy link
Copy Markdown
Contributor Author

First real human-read iteration done. If I would ever get such clean contributions, my life would be so much easier 💚

Only some small minor things/chors left, after these I can merge and release

Thank you for the kind words. I've added a commit that addresses your feedback, but not clicking resolve on anything until the pipeline is finished.

@Shinigami92 Shinigami92 added this pull request to the merge queue Mar 20, 2026
Merged via the queue into salsita:main with commit 4423714 Mar 20, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c: feature Request for new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants