Skip to content

feat: non-breaking ESM support 🤝 #613

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 38 commits into from
Dec 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
bd35ab4
feat: support .mjs and .mts migrations
mmkal Aug 2, 2023
0bac97f
Merge branch 'main' into mjs
mmkal Aug 2, 2023
f90cfa9
allow mts and cts in create command
mmkal Aug 2, 2023
f312741
update snapshot
mmkal Aug 2, 2023
5e5c65c
move outDir to tsconfig.lib.json
mmkal Aug 2, 2023
2197ccf
eval
mmkal Aug 3, 2023
ce86cea
Merge remote-tracking branch 'origin/main' into mjs
mmkal Nov 4, 2023
bbb5243
Merge remote-tracking branch 'origin/main' into mjs
mmkal Nov 6, 2023
15a7668
Merge remote-tracking branch 'origin/main' into mjs
mmkal Nov 6, 2023
cdde307
don't use eval
mmkal Nov 6, 2023
e3aaf93
update lockfile
mmkal Nov 6, 2023
616cc02
drop createRequire for es-modules example
mmkal Nov 6, 2023
2e6e70e
add es-modules pkg test
mmkal Nov 6, 2023
17b01b5
more coverage
mmkal Nov 6, 2023
ccab497
more cjs -> esm sedding
mmkal Nov 6, 2023
30be924
sed __dirname too
mmkal Nov 6, 2023
694483b
new-migration.mjs
mmkal Nov 6, 2023
1f466e5
this has gotta be the last one
mmkal Nov 6, 2023
1c23f68
different name
mmkal Nov 6, 2023
8e42748
star
mmkal Nov 6, 2023
6f24edc
"type": "module"
mmkal Nov 6, 2023
3426656
make sure top-level await works
mmkal Nov 6, 2023
2efec35
Merge branch 'main' into mjs
mmkal Nov 6, 2023
8597771
future facing ci.yml
mmkal Nov 6, 2023
fa0f88b
Merge branch 'main' into mjs
mmkal Nov 6, 2023
991ea48
0.5-vanilla-esm
mmkal Nov 6, 2023
d046d56
fix vanilla-esm example + snapshot
mmkal Nov 6, 2023
8d2e6fa
rm trailing slash
mmkal Nov 6, 2023
b0ebc1d
check typeof module to see whether to use import or require
mmkal Nov 6, 2023
1fd5938
cleaner
mmkal Nov 6, 2023
dbce137
Add language specific help for other types of missing resolvers
mmkal Nov 6, 2023
41c0501
use the right template based on typeof module too
mmkal Nov 6, 2023
d82867e
typeof require.main instead
mmkal Nov 6, 2023
ead751a
update snapshot
mmkal Nov 6, 2023
95e78d4
compare created .js and .mjs
mmkal Nov 6, 2023
5eed86f
use .mjs in the readme still since it's execd by vitest
mmkal Nov 6, 2023
39062f4
rm star
mmkal Nov 6, 2023
1dfa1d1
3.5.0-0
mmkal Nov 6, 2023
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
28 changes: 23 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,35 @@ jobs:
with:
name: tarball
- run: ls
- name: install tarball in examples directory
- run: rm -rf examples/node_modules
- name: run vanilla example
working-directory: examples/0-vanilla
run: |
rm -rf ../node_modules
npm init -y
npm install ../../umzug.tgz
- name: run example
run: |
cd examples/0-vanilla

node migrate up
node migrate down
node migrate create --name new-migration.js
node migrate up
- name: run vanilla esm example
working-directory: examples/0.5-vanilla-esm
run: |
npm init -y
sed -i 's|"name"|"type": "module",\n "name"|g' package.json
npm install ../../umzug.tgz
cat package.json

node migrate.mjs up
node migrate.mjs down
node migrate.mjs create --name new-migration-1.mjs
node migrate.mjs create --name new-migration-2.js
node migrate.mjs up

cd migrations
cat $(ls . | grep new-migration-1)
cat $(ls . | grep new-migration-2)

# hard to test this with vitest transpiling stuff for us, so make sure .mjs and .js have same content
cmp $(ls . | grep new-migration-1) $(ls . | grep new-migration-2)
- run: ls -R
14 changes: 14 additions & 0 deletions examples/0.5-vanilla-esm/migrate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Umzug, JSONStorage } from 'umzug';

const __dirname = new URL('.', import.meta.url).pathname.replace(/\/$/, '');

export const migrator = new Umzug({
migrations: {
glob: 'migrations/*.*js',
},
context: { directory: __dirname + '/ignoreme' },
storage: new JSONStorage({ path: __dirname + '/ignoreme/storage.json' }),
logger: console,
});

await migrator.runAsCLI();
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { promises as fs } from 'fs';

/** @type {typeof import('../migrate.mjs').migrator['_types']['migration']} */
export const up = async ({ context }) => {
await fs.mkdir(context.directory, { recursive: true });
await fs.writeFile(context.directory + '/users.json', JSON.stringify([], null, 2));
};

/** @type {typeof import('../migrate.mjs').migrator['_types']['migration']} */
export const down = async ({ context }) => {
await fs.unlink(context.directory + '/users.json');
};
16 changes: 16 additions & 0 deletions examples/0.5-vanilla-esm/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
This example shows the simplest possible, node-only setup for Umzug. No typescript, no database, no dependencies.

Note:
- The `context` for the migrations just contains a (gitignored) directory.
- The example migration just writes an empty file to the directory

```bash
node migrate.mjs --help # show CLI help

node migrate.mjs up # apply migrations
node migrate.mjs down # revert the last migration
node migrate.mjs create --name new-migration.mjs # create a new migration file

node migrate.mjs up # apply migrations again
node migrate.mjs down --to 0 # revert all migrations
```
27 changes: 4 additions & 23 deletions examples/2-es-modules/umzug.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { createRequire } from "module";

const require = createRequire(import.meta.url);
const { Umzug, SequelizeStorage } = require('umzug');
const { Sequelize, DataTypes } = require('sequelize');
const path = require('path');
import { Umzug, SequelizeStorage } from 'umzug';
import { Sequelize, DataTypes } from 'sequelize';
import * as path from 'path';

const sequelize = new Sequelize({
dialect: 'sqlite',
Expand All @@ -14,22 +11,6 @@ const sequelize = new Sequelize({
export const migrator = new Umzug({
migrations: {
glob: ['migrations/*.{js,cjs,mjs}', { cwd: path.dirname(import.meta.url.replace('file://', '')) }],
resolve: params => {
if (params.path.endsWith('.mjs') || params.path.endsWith('.js')) {
const getModule = () => import(`file:///${params.path.replace(/\\/g, '/')}`)
return {
name: params.name,
path: params.path,
up: async upParams => (await getModule()).up(upParams),
down: async downParams => (await getModule()).down(downParams),
}
}
return {
name: params.name,
path: params.path,
...require(params.path),
}
}
},
context: { sequelize, DataTypes },
storage: new SequelizeStorage({
Expand All @@ -38,4 +19,4 @@ export const migrator = new Umzug({
logger: console,
});

migrator.runAsCLI()
migrator.runAsCLI();
29 changes: 2 additions & 27 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "umzug",
"version": "3.4.0",
"version": "3.5.0-0",
"description": "Framework-agnostic migration tool for Node",
"keywords": [
"migrate",
Expand Down
53 changes: 33 additions & 20 deletions src/umzug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,39 +107,46 @@ export class Umzug<Ctx extends object = object> extends emittery<UmzugEvents<Ctx
}

const ext = path.extname(filepath);
const canRequire = ext === '.js' || ext === '.cjs' || ext === '.ts';
const languageSpecificHelp: Record<string, string> = {
'.ts':
"TypeScript files can be required by adding `ts-node` as a dependency and calling `require('ts-node/register')` at the program entrypoint before running migrations.",
'.sql': 'Try writing a resolver which reads file content and executes it as a sql query.',
};
if (!canRequire) {
const errorParts = [
`No resolver specified for file ${filepath}.`,
languageSpecificHelp[ext],
`See docs for guidance on how to write a custom resolver.`,
];
throw new Error(errorParts.filter(Boolean).join(' '));
}
languageSpecificHelp['.cts'] = languageSpecificHelp['.ts'];
languageSpecificHelp['.mts'] = languageSpecificHelp['.ts'];

let loadModule: () => Promise<RunnableMigration<unknown>>;

const getModule = () => {
const jsExt = ext.replace(/\.([cm]?)ts$/, '.$1js');

const getModule = async () => {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return require(filepath);
return await loadModule();
} catch (e: unknown) {
if (e instanceof SyntaxError && filepath.endsWith('.ts')) {
e.message += '\n\n' + languageSpecificHelp['.ts'];
if ((e instanceof SyntaxError || e instanceof MissingResolverError) && ext in languageSpecificHelp) {
e.message += '\n\n' + languageSpecificHelp[ext];
}

throw e;
}
};

if ((jsExt === '.js' && typeof require.main === 'object') || jsExt === '.cjs') {
// eslint-disable-next-line @typescript-eslint/no-var-requires
loadModule = async () => require(filepath) as RunnableMigration<unknown>;
} else if (jsExt === '.js' || jsExt === '.mjs') {
loadModule = async () => import(filepath) as Promise<RunnableMigration<unknown>>;
} else {
loadModule = async () => {
throw new MissingResolverError(filepath);
};
}

return {
name,
path: filepath,
up: async ({ context }) => getModule().up({ path: filepath, name, context }) as unknown,
down: async ({ context }) => getModule().down({ path: filepath, name, context }) as unknown,
up: async ({ context }) => (await getModule()).up({ path: filepath, name, context }),
down: async ({ context }) => (await getModule()).down?.({ path: filepath, name, context }),
};
};

Expand Down Expand Up @@ -352,7 +359,7 @@ export class Umzug<Ctx extends object = object> extends emittery<UmzugEvents<Ctx

const allowedExtensions = options.allowExtension
? [options.allowExtension]
: ['.js', '.cjs', '.mjs', '.ts', '.sql'];
: ['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts', '.sql'];

const existing = await this.migrations(context);
const last = existing[existing.length - 1];
Expand Down Expand Up @@ -415,15 +422,15 @@ export class Umzug<Ctx extends object = object> extends emittery<UmzugEvents<Ctx

private static defaultCreationTemplate(filepath: string): Array<[string, string]> {
const ext = path.extname(filepath);
if (ext === '.js' || ext === '.cjs') {
if ((ext === '.js' && typeof require.main === 'object') || ext === '.cjs') {
return [[filepath, templates.js]];
}

if (ext === '.ts') {
if (ext === '.ts' || ext === '.mts' || ext === '.cts') {
return [[filepath, templates.ts]];
}

if (ext === '.mjs') {
if ((ext === '.js' && typeof require.main === 'undefined') || ext === '.mjs') {
return [[filepath, templates.mjs]];
}

Expand Down Expand Up @@ -499,3 +506,9 @@ export class Umzug<Ctx extends object = object> extends emittery<UmzugEvents<Ctx
};
}
}

class MissingResolverError extends Error {
constructor(filepath: string) {
super(`No resolver specified for file ${filepath}. See docs for guidance on how to write a custom resolver.`);
}
}
65 changes: 65 additions & 0 deletions test/__snapshots__/examples.test.ts.snap
Original file line number Diff line number Diff line change
@@ -1,5 +1,70 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`example 0.5-vanilla-esm 1`] = `
"\`node migrate.mjs --help\` output:

...

\`node migrate.mjs up\` output:

{ event: 'migrating', name: '<<timestamp>>.users-table.mjs' }
{
event: 'migrated',
name: '<<timestamp>>.users-table.mjs',
durationSeconds: ???
}
{ event: 'up', message: 'applied 1 migrations.' }

\`node migrate.mjs down\` output:

{ event: 'reverting', name: '<<timestamp>>.users-table.mjs' }
{
event: 'reverted',
name: '<<timestamp>>.users-table.mjs',
durationSeconds: ???
}
{ event: 'down', message: 'reverted 1 migrations.' }

\`node migrate.mjs create --name new-migration.mjs\` output:

{
event: 'created',
path: '<<cwd>>/examples/0.5-vanilla-esm/migrations/<<timestamp>>.new-migration.mjs'
}

\`node migrate.mjs up\` output:

{ event: 'migrating', name: '<<timestamp>>.users-table.mjs' }
{
event: 'migrated',
name: '<<timestamp>>.users-table.mjs',
durationSeconds: ???
}
{ event: 'migrating', name: '<<timestamp>>.new-migration.mjs' }
{
event: 'migrated',
name: '<<timestamp>>.new-migration.mjs',
durationSeconds: ???
}
{ event: 'up', message: 'applied 2 migrations.' }

\`node migrate.mjs down --to 0\` output:

{ event: 'reverting', name: '<<timestamp>>.new-migration.mjs' }
{
event: 'reverted',
name: '<<timestamp>>.new-migration.mjs',
durationSeconds: ???
}
{ event: 'reverting', name: '<<timestamp>>.users-table.mjs' }
{
event: 'reverted',
name: '<<timestamp>>.users-table.mjs',
durationSeconds: ???
}
{ event: 'down', message: 'reverted 2 migrations.' }"
`;

exports[`example 0-vanilla 1`] = `
"\`node migrate --help\` output:

Expand Down
18 changes: 9 additions & 9 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,15 +244,15 @@ describe('create migration file', () => {
// a folder must be specified for the first migration
await expect(runCLI(['create', '--name', 'm1.js', '--folder', path.join(syncer.baseDir, 'migrations')])).resolves
.toMatchInlineSnapshot(`
{
"2000.01.02T00.00.00.m1.js": "/** @type {import('umzug').MigrationFn<any>} */
exports.up = async params => {};
{
"2000.01.02T00.00.00.m1.js": "/** @type {import('umzug').MigrationFn<any>} */
export const up = async params => {};

/** @type {import('umzug').MigrationFn<any>} */
exports.down = async params => {};
",
}
`);
/** @type {import('umzug').MigrationFn<any>} */
export const down = async params => {};
",
}
`);

// for the second migration, the program should guess it's supposed to live next to the previous one.
await expect(runCLI(['create', '--name', 'm2.ts'])).resolves.toMatchInlineSnapshot(`
Expand All @@ -278,7 +278,7 @@ describe('create migration file', () => {
`);

await expect(runCLI(['create', '--name', 'm4.txt'])).rejects.toThrowErrorMatchingInlineSnapshot(
`"Extension .txt not allowed. Allowed extensions are .js, .cjs, .mjs, .ts, .sql. See help for --allow-extension to avoid this error."`
'"Extension .txt not allowed. Allowed extensions are .js, .cjs, .mjs, .ts, .cts, .mts, .sql. See help for --allow-extension to avoid this error."'
);

await expect(runCLI(['create', '--name', 'm4.txt', '--allow-extension', '.txt'])).rejects.toThrow(
Expand Down
Loading