diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d05ca07c..be6fa214 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/examples/0.5-vanilla-esm/migrate.mjs b/examples/0.5-vanilla-esm/migrate.mjs new file mode 100644 index 00000000..03691b5f --- /dev/null +++ b/examples/0.5-vanilla-esm/migrate.mjs @@ -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(); diff --git a/examples/0.5-vanilla-esm/migrations/2023.11.03T16.52.04.users-table.mjs b/examples/0.5-vanilla-esm/migrations/2023.11.03T16.52.04.users-table.mjs new file mode 100644 index 00000000..d2047b07 --- /dev/null +++ b/examples/0.5-vanilla-esm/migrations/2023.11.03T16.52.04.users-table.mjs @@ -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'); +}; diff --git a/examples/0.5-vanilla-esm/readme.md b/examples/0.5-vanilla-esm/readme.md new file mode 100644 index 00000000..b8245546 --- /dev/null +++ b/examples/0.5-vanilla-esm/readme.md @@ -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 +``` diff --git a/examples/2-es-modules/umzug.mjs b/examples/2-es-modules/umzug.mjs index ff7c9768..56b7954e 100644 --- a/examples/2-es-modules/umzug.mjs +++ b/examples/2-es-modules/umzug.mjs @@ -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', @@ -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({ @@ -38,4 +19,4 @@ export const migrator = new Umzug({ logger: console, }); -migrator.runAsCLI() +migrator.runAsCLI(); diff --git a/package-lock.json b/package-lock.json index 3125fb89..dea661f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "umzug", - "version": "3.4.0", + "version": "3.5.0-0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "umzug", - "version": "3.4.0", + "version": "3.5.0-0", "license": "MIT", "dependencies": { "@rushstack/ts-command-line": "^4.12.2", diff --git a/package.json b/package.json index 376eb49e..e7f1b913 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "umzug", - "version": "3.4.0", + "version": "3.5.0-0", "description": "Framework-agnostic migration tool for Node", "keywords": [ "migrate", diff --git a/src/umzug.ts b/src/umzug.ts index c4fe6c2d..0bb31cf1 100644 --- a/src/umzug.ts +++ b/src/umzug.ts @@ -107,39 +107,46 @@ export class Umzug extends emittery = { '.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>; - 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; + } else if (jsExt === '.js' || jsExt === '.mjs') { + loadModule = async () => import(filepath) as Promise>; + } 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 }), }; }; @@ -352,7 +359,7 @@ export class Umzug extends emittery extends emittery { 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]]; } @@ -499,3 +506,9 @@ export class Umzug extends emittery>.users-table.mjs' } +{ + event: 'migrated', + name: '<>.users-table.mjs', + durationSeconds: ??? +} +{ event: 'up', message: 'applied 1 migrations.' } + +\`node migrate.mjs down\` output: + +{ event: 'reverting', name: '<>.users-table.mjs' } +{ + event: 'reverted', + name: '<>.users-table.mjs', + durationSeconds: ??? +} +{ event: 'down', message: 'reverted 1 migrations.' } + +\`node migrate.mjs create --name new-migration.mjs\` output: + +{ + event: 'created', + path: '<>/examples/0.5-vanilla-esm/migrations/<>.new-migration.mjs' +} + +\`node migrate.mjs up\` output: + +{ event: 'migrating', name: '<>.users-table.mjs' } +{ + event: 'migrated', + name: '<>.users-table.mjs', + durationSeconds: ??? +} +{ event: 'migrating', name: '<>.new-migration.mjs' } +{ + event: 'migrated', + name: '<>.new-migration.mjs', + durationSeconds: ??? +} +{ event: 'up', message: 'applied 2 migrations.' } + +\`node migrate.mjs down --to 0\` output: + +{ event: 'reverting', name: '<>.new-migration.mjs' } +{ + event: 'reverted', + name: '<>.new-migration.mjs', + durationSeconds: ??? +} +{ event: 'reverting', name: '<>.users-table.mjs' } +{ + event: 'reverted', + name: '<>.users-table.mjs', + durationSeconds: ??? +} +{ event: 'down', message: 'reverted 2 migrations.' }" +`; + exports[`example 0-vanilla 1`] = ` "\`node migrate --help\` output: diff --git a/test/cli.test.ts b/test/cli.test.ts index 203429d0..d0db5212 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -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} */ - exports.up = async params => {}; + { + "2000.01.02T00.00.00.m1.js": "/** @type {import('umzug').MigrationFn} */ + export const up = async params => {}; - /** @type {import('umzug').MigrationFn} */ - exports.down = async params => {}; - ", - } - `); + /** @type {import('umzug').MigrationFn} */ + 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(` @@ -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( diff --git a/test/examples.test.ts b/test/examples.test.ts index ef145d31..1bf14179 100644 --- a/test/examples.test.ts +++ b/test/examples.test.ts @@ -2,7 +2,11 @@ import * as fs from 'fs'; import * as path from 'path'; import stripAnsi from 'strip-ansi'; import execa from 'execa'; -import { test, expect } from 'vitest'; +import { test, expect, beforeAll } from 'vitest'; + +beforeAll(async () => { + await execa('npm', ['run', 'compile']); +}); const examplesDir = path.join(__dirname, '../examples'); const examples = fs.readdirSync(examplesDir).filter(ex => /^\d/.exec(ex)); diff --git a/test/umzug.test.ts b/test/umzug.test.ts index 9a8e013b..d45579db 100644 --- a/test/umzug.test.ts +++ b/test/umzug.test.ts @@ -42,6 +42,67 @@ describe('basic usage', () => { path: path.join(syncer.baseDir, 'm1.js'), }); }); + + test('imports esm files', async () => { + const spy = jest.spyOn(console, 'log').mockReset(); + + const syncer = fsSyncer(path.join(__dirname, 'generated/umzug/esm'), { + 'm1.mjs': ` + export const up = async params => console.log('up1', params) + export const down = async params => console.log('down1', params) + `, + }); + syncer.sync(); + + const umzug = new Umzug({ + migrations: { + glob: ['*.mjs', { cwd: syncer.baseDir }], + }, + context: { someCustomSqlClient: {} }, + logger: undefined, + }); + + await umzug.up(); + + expect(names(await umzug.executed())).toEqual(['m1.mjs']); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenNthCalledWith(1, 'up1', { + context: { someCustomSqlClient: {} }, + name: 'm1.mjs', + path: path.join(syncer.baseDir, 'm1.mjs'), + }); + + await umzug.down(); + + expect(names(await umzug.executed())).toEqual([]); + }); + + test('imports typescript esm files', async () => { + const spy = jest.spyOn(console, 'log').mockReset(); + + const syncer = fsSyncer(path.join(__dirname, 'generated/umzug/esm'), { + 'm1.mts': `export const up = async (params: {}) => console.log('up1', params)`, + }); + syncer.sync(); + + const umzug = new Umzug({ + migrations: { + glob: ['*.mts', { cwd: syncer.baseDir }], + }, + context: { someCustomSqlClient: {} }, + logger: undefined, + }); + + await umzug.up(); + + expect(names(await umzug.executed())).toEqual(['m1.mts']); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenNthCalledWith(1, 'up1', { + context: { someCustomSqlClient: {} }, + name: 'm1.mts', + path: path.join(syncer.baseDir, 'm1.mts'), + }); + }); }); describe('custom context', () => { diff --git a/tsconfig.json b/tsconfig.json index 6efab84d..160cca95 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,6 @@ "compilerOptions": { "noEmit": true, "allowJs": true, - "outDir": "lib", "target": "es2018", "module": "commonjs", "moduleResolution": "node", diff --git a/tsconfig.lib.json b/tsconfig.lib.json index 5b86d30f..5b2ad4bc 100644 --- a/tsconfig.lib.json +++ b/tsconfig.lib.json @@ -4,6 +4,8 @@ "src" ], "compilerOptions": { - "noEmit": false + "outDir": "lib", + "noEmit": false, + "moduleResolution": "Node16" } }