Skip to content
This repository has been archived by the owner on Jul 17, 2022. It is now read-only.

feat(sequelize): Add Sequelize adapter #237

Merged
merged 13 commits into from
Nov 17, 2021
3 changes: 3 additions & 0 deletions .github/labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ firebase:
pouchdb:
- packages/pouchdb/**/*

sequelize:
- packages/sequelize/**/*

documentation:
- ./**/*.md

Expand Down
4 changes: 4 additions & 0 deletions packages/sequelize/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Change Log

All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
96 changes: 96 additions & 0 deletions packages/sequelize/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<p align="center">
<br/>
<a href="https://next-auth.js.org" target="_blank"><img height="64px" src="https://next-auth.js.org/img/logo/logo-sm.png" /></a>&nbsp;&nbsp;&nbsp;&nbsp;<img height="64px" src="https://raw.githubusercontent.com/nextauthjs/adapters/main/packages/sequelize/logo.svg" />
<h3 align="center"><b>Sequelize Adapter</b> - NextAuth.js</h3>
<p align="center">
Open Source. Full Stack. Own Your Data.
</p>
<p align="center" style="align: center;">
<img src="https://github.com/nextauthjs/adapters/actions/workflows/release.yml/badge.svg" alt="CI Test" />
<img src="https://img.shields.io/bundlephobia/minzip/@next-auth/sequelize-adapter" alt="Bundle Size"/>
<img src="https://img.shields.io/npm/v/@next-auth/sequelize-adapter" alt="@next-auth/sequelize-adapter Version" />
</p>
</p>

## Overview

This is the Sequelize Adapter for [`next-auth`](https://next-auth.js.org). This package can only be used in conjunction with the primary `next-auth` package. It is not a standalone package.

You can find the Sequelize schema in the docs at [next-auth.js.org/adapters/sequelize](https://next-auth.js.org/adapters/sequelize).

## Getting Started

1. Install `next-auth` and `@next-auth/sequelize-adapter` as well as `sequelize` and your [database driver](https://sequelize.org/master/manual/getting-started.html) of choice.

```js
npm install next-auth @next-auth/sequeluze-adapter sequelize sqlite3
npm install --save-dev sequelize
```

2. Add this adapter to your `pages/api/[...nextauth].js` next-auth configuration object.

```js
import NextAuth from "next-auth"
import SequelizeAdapter from "@next-auth/sequelize-adapter"
import Sequelize from 'sequelize'

const sequelize = new Sequelize("sqlite::memory:")

// For more information on each option (and a full list of options) go to
// https://next-auth.js.org/configuration/options
export default NextAuth({
...
adapter: SequelizeAdapter(sequelize)
...
})
```

## Updating the database schema

In development, the sequelize adapter will create the necessary tables, foreign keys and indexes in your database. In production, synchronization is disabled. Best practice is to create the [required tables](https://next-auth.js.org/adapters/models) in your database via [migrations](https://sequelize.org/master/manual/migrations.html).

In development, if you do not want the adapter to automatically create tables, you are able to pass `{ synchronize: false }` as the second option to `SequelizeAdapter` to disable this behavior:

```js
import NextAuth from "next-auth"
import SequelizeAdapter from "@next-auth/sequelize-adapter"
import Sequelize from 'sequelize'

const sequelize = new Sequelize("sqlite::memory:")

export default NextAuth({
...
adapter: SequelizeAdapter(sequelize, { synchronize: false })
...
})
```

## Using custom models

Sequelize models are option to customization like so:

```js
import NextAuth from "next-auth"
import SequelizeAdapter, { models } from "@next-auth/sequelize-adapter"
import Sequelize, { DataTypes } from 'sequelize'

const sequelize = new Sequelize("sqlite::memory:")

export default NextAuth({
...
adapter: SequelizeAdapter(sequelize, {
models: {
User: sequelize.define('user', { ...models.User, phoneNumber: DataTypes.STRING })
}
})
...
})
```

## Contributing

We're open to all community contributions! If you'd like to contribute in any way, please read our [Contributing Guide](https://github.com/nextauthjs/adapters/blob/main/CONTRIBUTING.md).

## License

ISC
1 change: 1 addition & 0 deletions packages/sequelize/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require("../../jest.config")
1 change: 1 addition & 0 deletions packages/sequelize/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 38 additions & 0 deletions packages/sequelize/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "@next-auth/sequelize-adapter",
"version": "1.0.0",
"description": "Sequelize adapter for next-auth.",
"homepage": "https://next-auth.js.org",
"repository": "https://github.com/nextauthjs/adapters",
"bugs": {
"url": "https://github.com/nextauthjs/adapters/issues"
},
"author": "github.com/luke-j",
"main": "dist/index.js",
"license": "ISC",
"keywords": [
"next-auth",
"next.js",
"oauth",
"sequelize"
],
"private": false,
"publishConfig": {
"access": "public"
},
"scripts": {
"test": "jest",
Copy link
Member

Choose a reason for hiding this comment

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

Don't need to run build on test, we will build before publishing anyway.

"build": "tsc"
},
"files": [
"README.md",
"dist"
],
"peerDependencies": {
"next-auth": ">4 || 4.0.0-beta.1 - 4.0.0-beta.x",
Copy link
Member

Choose a reason for hiding this comment

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

Corrected this as per #274

"sequelize": "^6.6.5"
},
"devDependencies": {
"sequelize": "^6.6.5"
}
}
219 changes: 219 additions & 0 deletions packages/sequelize/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import type { Account as ApadterAccount } from "next-auth"
import type {
Adapter,
AdapterUser,
AdapterSession,
VerificationToken,
} from "next-auth/adapters"
import { Sequelize, Model, ModelCtor } from "sequelize"
import * as defaultModels from "./models"

export { defaultModels as models }

// @see https://sequelize.org/master/manual/typescript.html
interface AccountInstance
extends Model<ApadterAccount, Partial<ApadterAccount>>,
ApadterAccount {}
interface UserInstance
extends Model<AdapterUser, Partial<AdapterUser>>,
AdapterUser {}
interface SessionInstance
extends Model<AdapterSession, Partial<AdapterSession>>,
AdapterSession {}
interface VerificationTokenInstance
extends Model<VerificationToken, Partial<VerificationToken>>,
VerificationToken {}

interface SequelizeAdapterOptions {
synchronize?: boolean
models?: Partial<{
User: ModelCtor<UserInstance>
Account: ModelCtor<AccountInstance>
Session: ModelCtor<SessionInstance>
VerificationToken: ModelCtor<VerificationTokenInstance>
}>
}

export default function SequelizeAdapter(
client: Sequelize,
options?: SequelizeAdapterOptions
): Adapter {
const { models, synchronize = true } = options ?? {}
const defaultModelOptions = { underscored: true, timestamps: false }
const { User, Account, Session, VerificationToken } = {
User:
models?.User ??
client.define<UserInstance>(
"user",
defaultModels.User,
defaultModelOptions
),
Account:
models?.Account ??
client.define<AccountInstance>(
"account",
defaultModels.Account,
defaultModelOptions
),
Session:
models?.Session ??
client.define<SessionInstance>(
"session",
defaultModels.Session,
defaultModelOptions
),
VerificationToken:
models?.VerificationToken ??
client.define<VerificationTokenInstance>(
"verificationToken",
defaultModels.VerificationToken,
defaultModelOptions
),
}
let _synced = false
const sync = async () => {
if (process.env.NODE_ENV !== "production" && synchronize && !_synced) {
const syncOptions =
typeof synchronize === "object" ? synchronize : undefined

await Promise.all([
User.sync(syncOptions),
Account.sync(syncOptions),
Session.sync(syncOptions),
VerificationToken.sync(syncOptions),
])

_synced = true
}
}

Account.belongsTo(User, { onDelete: "cascade" })
Session.belongsTo(User, { onDelete: "cascade" })
Comment on lines +90 to +91
Copy link
Member

Choose a reason for hiding this comment

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

Is this necessarily done runtime? I am not familiar with Sequelize, but usually, you would do some kind of seeding/setup step to define the database schema, right? Does Sequelize have anything similar to TypeORM's syncrhonize: true (https://github.com/typeorm/typeorm/blob/master/docs/faq.md#how-do-i-update-a-database-schema)?

We have to be careful not to update things in production to prevent data loss. In development though, auto-syncing model changes might be useful. #224 how we handled this for the TypeORM adapter recently.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sequelize has the sequelize.sync() function similar to TypeORM's synchronize option. Defining these associations at runtime is recommended and doesn't produce any SQL unless sync() is called.

It would be best practice for the user to define the tables as migrations, but sequelize will automatically create the necessary tables/FKs/indexes with sync like so:

const sequelize = new Sequelize('sqlite:memory:')
const adapter = SequelizeAdapter(sequelize)

sequelize.sync()

export default NextAuth({
  ...
  adapter
  ...
})

I've added a section in the README about how to create tables like this with a mention that best practice is still migrations in e0b1dae.

Copy link
Member

Choose a reason for hiding this comment

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

I think we should default to synchronizing if we detect process.env.NODE_ENV !== "production", but make it so that users can opt-out even then, with a config option like synchronize: false.

We could reuse this warning by some rewording: https://next-auth.js.org/warnings#adapter_typeorm_updating_entities

I would add client.sync() in an if statement with the warning similar to this c2559b7 (#224) in the body of the Adapter.

Otherwise, we should create explain how to migrate manually: https://sequelize.org/master/manual/migrations.html

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@balazsorban44 I'm thinking about how we might achieve this. I remembered that sync actually returns a promise, in which case we'd have to make the adapter async which would mess with the user-facing API.

eg.

if (process.env.NODE_ENV !== 'production' && synchronize) {
  await Promise.all([
    User.sync(),
    Account.sync(),
    Session.sync(),
    VerificationToken.sync()
  ])
}

I'm wondering if including sync, which is potentially dangerous or unexpected from the user's perspective, is worth it in this regard? There's not a huge advantage over just leaving this to user land code, right?

Copy link
Member

@balazsorban44 balazsorban44 Sep 21, 2021

Choose a reason for hiding this comment

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

I see. Ultimately, this is why we had to get a connection manager in each adapter method with TypeORM, because getting a manager was an async operation. 😅

https://github.com/nextauthjs/adapters/blob/next/packages/typeorm-legacy/src/index.ts

I don't want the adapter to be async as that would complicate the initialization.

I see there is an all-in-one .sync() method as well.

https://sequelize.org/master/class/lib/sequelize.js~Sequelize.html#instance-method-sync

My idea is that we should create a wrapper for it, and add a sync: boolean | SyncOptions to the adapter options, and do something similar to TypeORM, and call it in each method first.

I feel like we should stretch here, as the DX would be better. Especially useful if you would need to debug, I think.

I am thinking of something like this:

function SequelizeAdapter(client, options) {
  // Make sure we only sync once per invocation
  let _synced = false
  const sync = async () => {
    if (
      process.env.NODE_ENV === "production" ||
      _synced ||
      options.sync === false
    )
      return

    const syncOptions =
      typeof options.sync === "object"
        ? options.sync
        : {
            // ... some default options, if we need it
          }
    await client.sync(syncOptions)
    _synced = true
  }

  return {
    async createUser() {
      await sync() // call this first in each method
      //...
    },
  }
}

Copy link
Contributor Author

@luke-j luke-j Sep 23, 2021

Choose a reason for hiding this comment

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

Yeah I think if we want to do this this is probably the best option. I've done this in 48961d1 along with some tests. Unfortunately it's not the prettiest solution.

If this is occurring in multiple adapters I think it's reasonable to consider an optional connect or init function returned from an adapter which is then called by the main next-auth package prior to calling the other functions.

Copy link
Member

Choose a reason for hiding this comment

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

Most adapters seem to connect/disconnect automatically, actually. In my experience, it is only TypeORM that needs manual connection handling so far. 😕


return {
async createUser(user) {
await sync()

return await User.create(user)
},
async getUser(id) {
await sync()

const userInstance = await User.findByPk(id)

return userInstance?.get({ plain: true }) ?? null
Copy link
Member

Choose a reason for hiding this comment

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

I love plain: true 😍! This reminds me of the Prisma Adapter, which is by far the easiest to understand IMO, since it can return plain JavaScript objects, instead of convoluted custom formats for dates and stuff.

All ORMs should have an option like this! 🙏

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agree! Having an instance returned can be useful but the option for a plain object is always great.

},
async getUserByEmail(email) {
await sync()

const userInstance = await User.findOne({
where: { email },
})

return userInstance?.get({ plain: true }) ?? null
},
async getUserByAccount({ provider, providerAccountId }) {
await sync()

const accountInstance = await Account.findOne({
where: { provider, providerAccountId },
})

if (!accountInstance) {
return null
}

const userInstance = await User.findByPk(accountInstance.userId)

return userInstance?.get({ plain: true }) ?? null
},
async updateUser(user) {
await sync()

await User.update(user, { where: { id: user.id } })
const userInstance = await User.findByPk(user.id)

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return userInstance!
},
async deleteUser(userId) {
await sync()

const userInstance = await User.findByPk(userId)

await User.destroy({ where: { id: userId } })

return userInstance
},
async linkAccount(account) {
await sync()

await Account.create(account)
},
async unlinkAccount({ provider, providerAccountId }) {
await sync()

await Account.destroy({
where: { provider, providerAccountId },
})
},
async createSession(session) {
await sync()

return await Session.create(session)
},
async getSessionAndUser(sessionToken) {
await sync()

const sessionInstance = await Session.findOne({
where: { sessionToken },
})

if (!sessionInstance) {
return null
}

const userInstance = await User.findByPk(sessionInstance.userId)

if (!userInstance) {
return null
}

return {
session: sessionInstance?.get({ plain: true }),
user: userInstance?.get({ plain: true }),
}
},
async updateSession({ sessionToken, expires }) {
await sync()

await Session.update(
{ expires, sessionToken },
{ where: { sessionToken } }
)

return await Session.findOne({ where: { sessionToken } })
},
async deleteSession(sessionToken) {
await sync()

await Session.destroy({ where: { sessionToken } })
},
async createVerificationToken(token) {
await sync()

return await VerificationToken.create(token)
},
async useVerificationToken({ identifier, token }) {
await sync()

const tokenInstance = await VerificationToken.findOne({
where: { identifier, token },
})

await VerificationToken.destroy({ where: { identifier } })

return tokenInstance?.get({ plain: true }) ?? null
},
}
}
Loading