Skip to content

Add support for native ESM named exports#13689

Merged
sdepold merged 17 commits intosequelize:mainfrom
ephys:main
Dec 12, 2021
Merged

Add support for native ESM named exports#13689
sdepold merged 17 commits intosequelize:mainfrom
ephys:main

Conversation

@ephys
Copy link
Copy Markdown
Member

@ephys ephys commented Nov 22, 2021

Pull Request Checklist

Please make sure to review and check all of these items:

  • Have you added new tests to prevent regressions?
  • Does npm run test or npm run test-DIALECT pass with this change (including linting)?
  • Is a documentation update included (if this change modifies existing APIs, or introduces new ones)?
  • Did you update the typescript typings accordingly (if applicable)?
  • Does the description below contain a link to an existing issue (Closes #[issue]) or a description of the issue you are solving?
  • Did you follow the commit message conventions explained in CONTRIBUTING.md?

Description Of Change

This PR adds support for non-transpiled ESM named imports (Closes #13132)

Instead of converting the codebase to ESM and transpiling to CJS, this PR takes the simpler approach of adding a separate entry point (index.mjs) for ESM that imports the CJS and re-exports it as the default export, and its properties as named exports.

Note: while testing this I noticed that the TypeScript typing declares an export "validator", but the actual export is called "Validator".

exports has been set in package.json to make CommonJS require index.js & ESM import index.mjs

@ephys ephys changed the title Add support for native esm Add support for native ESM named exports Nov 22, 2021
@ephys
Copy link
Copy Markdown
Member Author

ephys commented Nov 24, 2021

Thanks for running the checks :)

Does the CI give any detail as to why the lint check failed? Replicating the steps locally gives me no errors
image

@WikiRik
Copy link
Copy Markdown
Member

WikiRik commented Nov 26, 2021

I think it was a temporary issue with GitHub Actions, @sdepold can you re-run the checks?

@sdepold
Copy link
Copy Markdown
Member

sdepold commented Nov 27, 2021

Assuming we eventually have full support for TS, will this still be needed? I'm somehow reluctant to merge since we would have to keep both index files in sync, right?!

@ephys
Copy link
Copy Markdown
Member Author

ephys commented Nov 29, 2021

Once you have a full TS implem that is ESM compatible (the only impact is I think that you need to specify the file extension in imports, and not use directory imports), then you should be able to transpile to both CommonJS and ESM.

Both index files would have to be kept in sync yes, this is more of a temporary solution that works today and can be removed once the TS migration is complete

Edit: transpiling for both is actually a bad idea, see #13689 (comment)

@sdepold
Copy link
Copy Markdown
Member

sdepold commented Nov 29, 2021

@Keimeno @allawesome497 --^

Opinion?

@sdepold
Copy link
Copy Markdown
Member

sdepold commented Nov 29, 2021

I think I would rather try to avoid merging this.

@Keimeno
Copy link
Copy Markdown
Member

Keimeno commented Nov 29, 2021

Will take a deeper look into this in the evening

@ephys
Copy link
Copy Markdown
Member Author

ephys commented Nov 29, 2021

I understand that having to maintain two exports file is annoying. If this does not get merged, do you have an estimate regarding how long it would take to fully migrate to an ESM compatible implementation?

More and more libraries are moving to native-esm only, forcing apps to migrate too and while we can import commonjs, every import must be changed from this

image

to this

image

(as well as having to split uses between the type and the value, making it incompatible with TS's emitDecoratorMetadata).

This temporary patch would remove the need for users to make the migration, and this patch can be removed once the TS migration is complete. (see #13689 (comment))


Edit: extra thought regarding this:

This method has another advantage over transpiling for both versions: A CommonJS app that uses both import('sequelize') and require('sequelize') will import the same instance. Whereas transpiling for both versions will result in two different instances.

image

(sequelize-esm in the above demo is the same wrapper as the one I put in this PR)

And regarding maintaining both files: I can add a test that ensures the ESM export has the same keys as the CJS export.

@wbourne0
Copy link
Copy Markdown
Member

I think I would rather try to avoid merging this.

I'd also opt to avoid merging this, I think its better to wait until we can convert this to TS.

If we want to get this working for mjs ASAP I think something like this should work:

// lib/sequelize.js

module.exports = Sequelize;
// We're changing the `Sequelize` object here, its just more readable as 
// module.exports + that matches what we already have in `lib/sequelize.js`
module.exports.Sequelize = Sequelize;
module.exports.default = Sequelize;
module.exports.__esModule = true; // tell ES modules that this is an esmodule.

@ephys
Copy link
Copy Markdown
Member Author

ephys commented Nov 30, 2021

But that is not how native ES modules work. Only bundlers respect the __esModule property.
When a module imports a commonjs script, only the default import is available (and equal to module.exports).

There are only 3 ways to support ES Modules:

Option 1: Convert the whole codebase to ES Modules and drop CommonJS support

cons:

  • This is a huge breaking change, a lot of apps are written using CommonJS and CommonJS can only import ES Modules using Dynamic Import (import(x): Promise)
  • It's a lot of work: You need to make sure every import is compatible with ESM. That means always specifying the file extension, and being careful when importing third-party libraries as CommonJS modules do not support named exports (you can only import module.exports as the default export)

Option 2: Convert the whole codebase to ES Modules and transpile to CommonJS

cons:

  • Similarly, a lot of work
  • doing import('sequelize') and require('sequelize') will return two different instances of Sequelize as import() will import the module version and require the script. This will cause issues. Especially since import() is available to CommonJS

Option 3: Keep the codebase as CommonJS and include a module that re-exports the CommonJS module using named exports

cons:

  • You need to maintain an export file (but that's what unit tests are for)

Closing note: Node does have some static analysis for exposing named exports from CommonJS modules but it should not be relied upon, it's also not available for node 12.

@ephys ephys mentioned this pull request Nov 30, 2021
7 tasks
@WikiRik WikiRik added the status: in discussion For issues and PRs. Community and maintainers are discussing the applicability of the issue. label Nov 30, 2021
@wbourne0
Copy link
Copy Markdown
Member

wbourne0 commented Dec 3, 2021

Alternatively, once we finish the migration to TypeScript we should be able to configure the package to allow us to build for both nodejs and cjs, as esbuild supports this.

We'd probably want to implement a clone of the index file though, since lib/seqeulize.js currently relies on module.exports (the export is the class Sequelize, but Transaction, DataTypes, etc are also exported).

(I believe we can include both an index.js and an index.mjs, though I'm not 100% sure if this is needed)

@ephys
Copy link
Copy Markdown
Member Author

ephys commented Dec 3, 2021

Transpiling to both CommonJS and ESM is exactly the option 2 I described and it is going to cause huge issues to CommonJS users:

doing import('sequelize') and require('sequelize') will return two different instances of Sequelize as import() will import the module version and require the script. This will cause issues. Especially since import() is available to CommonJS

You can include both index.js and index.mjs, that's exactly what this PR is doing. But it's doing it in a way that ensures all values exported by both CJS and ESM are the same instances.

@ephys
Copy link
Copy Markdown
Member Author

ephys commented Dec 6, 2021

Sorry for the ping but any update on this @Keimeno @sdepold? I think #13689 (comment) explains why this PR is the only way to support ESM named exports and CommonJS at the same time without causing weird bugs

@WikiRik WikiRik mentioned this pull request Dec 6, 2021
7 tasks
@wbourne0
Copy link
Copy Markdown
Member

wbourne0 commented Dec 6, 2021

doing import('sequelize') and require('sequelize') will return two different instances of Sequelize as import() will import the module version and require the script. This will cause issues.

What kinds of issues? I can't see why a module would be partially CJS and partially MJS at runtime.

I think its also better to have a full ESM module for performance & security rather than having a mixed source.

Especially since import() is available to CommonJS

In NodeJS v16.7.0 I get SyntaxError: Cannot use import statement outside a module.

And similarly, in NodeJS v16.7.0 I can't use require from a mjs module without using node's module api to create a require function, ie:

import Module from 'module';

const require = Module.createRequire('/full/path/to/file'); // __filename also isn't available in .mjs files otherwise that's what we'd use here.
const Sequelize = require('sequelize');

@wbourne0
Copy link
Copy Markdown
Member

wbourne0 commented Dec 6, 2021

I would be fine with supporting esm in the short term with this, I'd rather have something than nothing for anyone who wants to use ESM.

However I think that in the long term it'd be best to have both esm and cjs compile targets, as the source will be esm and I'd like to keep the transpiled code as close to its native runtime as possible.

@ephys this change doesn't account for esbuild and dist/, can you modify the build.js script to account for this? I believe a simple cp index.mjs dist/ (translated to use node's fs builtin if possible) should suffice.

Copy link
Copy Markdown
Member

@wbourne0 wbourne0 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't think to request changes in my previous comment, but:

This change doesn't account for dist/, it'll need to be modified to copy the index.mjs file to dist/ (I don't think any esbuild tanspiling is necessary here).

I believe package.json will also need to be updated a bit for this.

@ephys
Copy link
Copy Markdown
Member Author

ephys commented Dec 7, 2021

In NodeJS v16.7.0 I get SyntaxError: Cannot use import statement outside a module.

You're mistaking the static import with the dynamic import. As specified here, dynamic imports are available to CommonJS. Static imports are not.

image

Plus, as you said yourself, ESM could also access require through module.createRequire and load two different versions of Sequelize without realizing that they are different.


What kinds of issues? I can't see why a module would be partially CJS and partially MJS at runtime.

Most common case: by using any library written in CommonJS that depends Sequelize. They will all break for ESM users.

Say your write your app in full native esm.
Your app uses a library written in CommonJS, which itself depends on Sequelize (say https://github.com/ephys/sequelize-cursor-pagination).

Your app is therefore going to do this:

esm app → `import 'sequelize'` (imports the ESM version of Sequelize)
        → `import '@ephys/sequelize-find-by-cursor'` → `require('sequelize')` (imports the CommonJS version of Sequelize)

@ephys/sequelize-find-by-cursor has a method findByCursor which takes a Sequelize Model as a parameter.

For type safety, this method checks whether the parameter it received is indeed a sequelize model like so:
image

Using the solution of providing two completely separate transpilation outputs, that is going to return false as there will be two different Model classes. Whereas Solution 3 exposes the same Model class to both CJS and ESM, so this type safety check will pass.

Extra scenario

Alternative scenario: Many people are currently using the ESM import syntax but then transpiling and running it as CJS.
Given Dynamic ESM imports are available to CommonJS module, any transpiler could decide that if the target is node, then static imports are transpiled to CommonJS, but dynamic imports are not.

So the following code

import SequelizePkg from 'sequelize';

(async () => {
  console.log((await import('sequelize')).default === SequelizePkg);
})();

could be transpiled to this:

const SequelizePkg = require('sequelize');

(async () => {
  console.log((await import('sequelize')).default === SequelizePkg);
})();

which would output false using your solution, true using Solution 3.

So as long as you support CommonJS, Solution 3 is your bug free version.
Any other version is going to cause issues.
Once you drop CommonJS, sure, drop this file too.


I think its also better to have a full ESM module for performance & security rather than having a mixed source.

What extra security are you speaking of?
As for performance, the tiny improvement to startup time this would bring is definitely not worth the cost of introducing weird bugs like Sequelize from import() not being equal to Sequelize from require().


By the way, please note that the code that's currently being written for the TypeScript migration is not valid ESM.
To support ESM, the source code needs to specify the file extension when it imports a file. The TypeScript compiler is not going to add them: https://nodejs.org/api/esm.html#mandatory-file-extensions


This change doesn't account for dist/, it'll need to be modified to copy the index.mjs file to dist/ (I don't think any esbuild tanspiling is necessary here).

Will do

@sdepold
Copy link
Copy Markdown
Member

sdepold commented Dec 7, 2021

I have added a v7 branch now and we could start removing support for node 10 in that branch since we planned to do that anyway. Should we target that branch instead of main maybe?

@ephys
Copy link
Copy Markdown
Member Author

ephys commented Dec 7, 2021

I think we can land this in v6, node 10 users won't be able to import this file anyway so I can just skip the test for node 10.

esm is not available in node 10
@ephys
Copy link
Copy Markdown
Member Author

ephys commented Dec 8, 2021

The test has been disabled on node 10, only thing left to resolve is decide whether we handle the typing issues the test unearthed in this PR (#13689 (comment))

@WikiRik
Copy link
Copy Markdown
Member

WikiRik commented Dec 8, 2021

Let's do that in a separate PR if you don't mind and we will merge this PR after that one

@ephys ephys mentioned this pull request Dec 8, 2021
6 tasks
@ephys
Copy link
Copy Markdown
Member Author

ephys commented Dec 8, 2021

Here we go :) #13751

@sdepold
Copy link
Copy Markdown
Member

sdepold commented Dec 11, 2021

looks promising

@sdepold
Copy link
Copy Markdown
Member

sdepold commented Dec 11, 2021

I assume some update is needed now?

@ephys
Copy link
Copy Markdown
Member Author

ephys commented Dec 12, 2021

Yes, just need to tell the test that Sequelize.db2 is not an export. Should be good now :)

@sdepold sdepold merged commit e61e2cd into sequelize:main Dec 12, 2021
@sdepold
Copy link
Copy Markdown
Member

sdepold commented Dec 12, 2021

Woop

@ephys
Copy link
Copy Markdown
Member Author

ephys commented Dec 12, 2021

🥳
Thank you for merging :)

@sdepold
Copy link
Copy Markdown
Member

sdepold commented Dec 12, 2021

I'll release this as part of our beta releases in a moment :)

@github-actions
Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 6.12.0-beta.3 🎉

The release is available on:

Your semantic-release bot 📦🚀

@WikiRik
Copy link
Copy Markdown
Member

WikiRik commented Dec 12, 2021

Nice work on this! @ephys
I just thought it might be nice to include this to the docs somewhere, could you maybe take a look into that soon?

@github-actions
Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 6.12.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

aliatsis pushed a commit to creditiq/sequelize that referenced this pull request Jun 2, 2022
* feat: add support for native esm

* feat: add missing ESM Named exports

* build: move esm entry point to /dist/index.mjs

* fix: add missing esm exports

* test: add test to ensure esm & cjs export parity

* test: include non-enumerables in esm-cjs export partity test

* test: ignore "snowflake" property in esm-cjs parity test

* test: list all missing exports in one run (esm-named-exports test)

* test: skip esm-cjs compat test in node 10

esm is not available in node 10

* test: ensure esm & cjs export the same instances

* fix: add missing esm exports

* test: mark 'Sequelize.db2' as not a top level export

Co-authored-by: Sascha Depold <[email protected]>
Co-authored-by: Rik Smale <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

released on @v6-beta released status: in discussion For issues and PRs. Community and maintainers are discussing the applicability of the issue.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Declare types to match ESM usage

5 participants