Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions bin/node-pg-migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import type { DotenvConfigOptions } from 'dotenv';
// Import as node-pg-migrate, so tsup does not self-reference as '../dist'
// otherwise this could not be imported by esm
// @ts-ignore: when a clean was made, the types are not present in the first run
Comment thread
Shinigami92 marked this conversation as resolved.
Outdated
import { createJiti } from 'jiti';
import type { RunnerOption } from 'node-pg-migrate';
import {
jiti,
Migration,
PG_MIGRATE_LOCK_ID,
runner as migrationRunner,
PG_MIGRATE_LOCK_ID,
} from 'node-pg-migrate';
import { join, resolve } from 'node:path';
import { cwd } from 'node:process';
Expand All @@ -19,7 +20,6 @@ import type ConnectionParametersType from 'pg/lib/connection-parameters';
// @ts-expect-error type exports from @types/pg doesn't match importing
import ConnectionParameters from 'pg/lib/connection-parameters.js';
import yargs from 'yargs/yargs';
import type { RunnerOption } from '../src';
import type { FilenameFormat } from '../src/migration';

process.on('uncaughtException', (err) => {
Expand Down Expand Up @@ -404,7 +404,6 @@ process.env.SUPPRESS_NO_CONFIG_WARNING = oldSuppressWarning;

const configFileName: string | undefined = argv[configFileArg];
if (configFileName) {
const jiti = createJiti(process.cwd());
const configModule: unknown = await jiti.import(resolve(configFileName));

let json: unknown;
Expand Down
4 changes: 4 additions & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ function sidebarReference(): DefaultTheme.SidebarItem[] {
text: 'Programmatic API',
link: 'api',
},
{
text: 'Migration Loading Strategies',
link: 'migration-loading-strategies',
},
];
}

Expand Down
85 changes: 59 additions & 26 deletions docs/src/api.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/src/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,4 @@ Run `npm run migrate up` and there will be a new column in `posts` table :tada:
- [CLI commands](/cli)
- [Programmatic API](/api)
- [Migration files](/migrations/)
- [Migration loading strategies (including SQL)](/migration-loading-strategies)
109 changes: 109 additions & 0 deletions docs/src/migration-loading-strategies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Migration Loading Strategies

`migrationLoaderStrategies` lets you control how migration files are loaded based on file extension.

This is useful when you need custom loading behavior, or when you want SQL files to use the new grouped `.up.sql` / `.down.sql` strategy.

## Default Behavior

If `migrationLoaderStrategies` is not provided, the loader uses built-in defaults:

- `.sql` files use the legacy SQL loader (`legacySql`)
- `.js` and `.ts` files use the default loader (`default`)
- unsupported extensions fall back to `default`

This keeps existing behavior intact.

## Configuration Shape

```ts
type MigrationLoader = (filePaths: string[]) => Promise<MigrationUnit[]>;

interface MigrationLoaderStrategy {
extensions: string[];
loader: MigrationLoader | 'default' | 'legacySql' | 'sql';
}
```

## Example: Use Grouped SQL Loader

This enables grouping `*.up.sql` and `*.down.sql` into one migration unit:

```ts
import { runner } from 'node-pg-migrate';

await runner({
databaseUrl: process.env.DATABASE_URL!,
dir: 'migrations',
direction: 'up',
migrationsTable: 'pgmigrations',
migrationLoaderStrategies: [{ extensions: ['.sql'], loader: 'sql' }],
});
```

With this configuration:

- `001_init.up.sql` + `001_init.down.sql` are treated as one migration (`001_init`)
- The migration `id` is normalized to the equivalent `.sql` form (`001_init.up.sql` / `001_init.down.sql` -> `001_init.sql`). This means you can switch from a single `001_init.sql` migration to split `.up/.down` files (or vice versa) without creating a second entry in `migrationsTable`.
- `001_init.sql` still works as a single-file SQL migration
- mixing `001_init.sql` with `001_init.up.sql` / `001_init.down.sql` throws an error

## Example: Custom Loader

You can provide a loader function directly:

```ts
import type { MigrationLoader } from 'node-pg-migrate';
import { runner } from 'node-pg-migrate';

const customLoader: MigrationLoader = async (filePaths) => {
// map files to migration units
return [];
};

await runner({
databaseUrl: process.env.DATABASE_URL!,
dir: 'migrations',
direction: 'up',
migrationsTable: 'pgmigrations',
migrationLoaderStrategies: [
{ extensions: ['.sql'], loader: 'sql' },
{ extensions: ['.mjs'], loader: customLoader },
],
});
```

## Strategy Matching Rules

- Extension matching is case-insensitive
- Each strategy handles one or more extensions
- If no strategy matches an extension, the `default` loader is used

## Legacy SQL migrations

### Why it exists

The legacy SQL loader has been supported for a long time, even when it was less visible in the docs.

Common use cases include:

- onboarding an existing project by importing an initial schema dump as the first migration
- keeping specific advanced migrations as pure SQL when that is cleaner than a builder-based migration

So if your team already relies on plain `.sql` files, that workflow is still supported.

### Markers and default fallback

The classic SQL template uses marker comments:

```sql
-- Up Migration

-- Down Migration
```

Behavior for a single `.sql` file:

- when both markers are present, `up` and `down` sections are extracted
- when no markers are present, the full file is treated as an `up` migration
- if there is no `down` section, there is no actionable `down` migration
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { Migration } from './migration';
export { MigrationBuilder } from './migrationBuilder';
export { jiti } from './migrationLoader';
export type {
CreateCast,
CreateCastFn,
Expand Down
84 changes: 8 additions & 76 deletions src/migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@ import { MigrationBuilder } from './migrationBuilder';
import type { ColumnDefinitions } from './operations/tables';
import type { MigrationDirection, RunnerOption } from './runner';
import type { MigrationBuilderActions } from './sqlMigration';
import { getMigrationTableSchema } from './utils';

import {
compareMigrationFileNames,
getMigrationTableSchema,
getNumericPrefix,
getSuffixFromFileName,
} from './utils';
/*
* A new Migration is instantiated for each migration file.
*
Expand Down Expand Up @@ -55,26 +59,6 @@ export type CreateOptions = {

const SEPARATOR = '_';

function localeCompareStringsNumerically(a: string, b: string): number {
return a.localeCompare(b, undefined, {
usage: 'sort',
numeric: true,
sensitivity: 'variant',
ignorePunctuation: true,
});
}

function compareFileNamesByTimestamp(
a: string,
b: string,
logger?: Logger
): number {
const aTimestamp = getNumericPrefix(a, logger);
const bTimestamp = getNumericPrefix(b, logger);

return aTimestamp - bTimestamp;
}

interface LoadMigrationFilesOptions {
/**
* Regex pattern for file names to ignore (ignores files starting with `.` by default).
Expand Down Expand Up @@ -137,11 +121,7 @@ export async function getMigrationFilePaths(
});

return globMatches
.toSorted(
(a, b) =>
compareFileNamesByTimestamp(a.name, b.name, logger) ||
localeCompareStringsNumerically(a.name, b.name)
)
.toSorted((a, b) => compareMigrationFileNames(a.name, b.name, logger))
.map((pathScurry) => pathScurry.fullpath());
}

Expand All @@ -162,18 +142,10 @@ export async function getMigrationFilePaths(
(dirent.isFile() || dirent.isSymbolicLink()) &&
!ignoreRegexp.test(dirent.name)
)
.toSorted(
(a, b) =>
compareFileNamesByTimestamp(a.name, b.name, logger) ||
localeCompareStringsNumerically(a.name, b.name)
)
.toSorted((a, b) => compareMigrationFileNames(a.name, b.name, logger))
.map((dirent) => resolve(dir, dirent.name));
}

function getSuffixFromFileName(fileName: string): string {
return extname(fileName).slice(1);
}

async function getLastSuffix(
dir: string,
ignorePattern?: string
Expand All @@ -188,46 +160,6 @@ async function getLastSuffix(
}
}

/**
* Extracts numeric value from everything in `filename` before `SEPARATOR`.
* 17 digit numbers are interpreted as UTC date and converted to the number
* representation of that date. 1...4 digit numbers are interpreted as index
* based naming scheme.
*
* @param filename filename to extract the prefix from
* @param logger Redirect messages to this logger object, rather than `console`.
* @returns numeric value of the filename prefix (everything before `SEPARATOR`).
*/
export function getNumericPrefix(
filename: string,
logger: Logger = console
): number {
const prefix = (/^(\d+)/.exec(filename) || '')[0];
const value = Number(prefix);

if (!/^\d+$/.test(prefix) || Number.isNaN(value)) {
logger.error(`Cannot determine numeric prefix for "${filename}"`);
throw new Error(`Cannot determine numeric prefix for "${filename}"`);
}

// Special case for UTC timestamp
if (prefix.length === 17) {
// utc: 20200513070724505
const year = prefix.slice(0, 4);
const month = prefix.slice(4, 6);
const date = prefix.slice(6, 8);
const hours = prefix.slice(8, 10);
const minutes = prefix.slice(10, 12);
const seconds = prefix.slice(12, 14);
const ms = prefix.slice(14, 17);
return new Date(
`${year}-${month}-${date}T${hours}:${minutes}:${seconds}.${ms}Z`
).valueOf();
}

return value;
}

async function resolveSuffix(
directory: string,
options: CreateOptionsDefault
Expand Down
Loading
Loading