feat(adapters): add Kysely adapter (#5464)

* feat: kysely-adapter with PostgreSQL and MySQL support

* feat: kysely-adapter with SQLite support

* docs: add docs for kysely-adapter

* chore: cleanup

* chore: update adapter lists

* chore: update column types

* chore: remove pgcrypto install

* chore: add indexes

* chore: Object.assign and cleanup

* feat: add AuthedKysely wrapper

* docs: add Naming Conventions section

* chore: add coerceReturnData to reduce repitition

* chore: add coerceInputData to reduce repitition

* chore: move AuthedKysely export to end

* chore: cleanup

* docs: remove unused import

* feat: add support for using AuthedKysely with generated types from kysely-codegen

* docs: formatting

* chore: CodeGen --> Codegen

* docs: wording update, ts

Co-authored-by: Julius Marminge <julius0216@outlook.com>

* chore: use latest kysely version, update model

* docs: move content to source code

* chore: update deps

* chore: update logo location, add link in overview

* chore: bump kysely version

Co-authored-by: Igal Klebanov <igalklebanov@gmail.com>

* chore: update docs

Co-authored-by: Igal Klebanov <igalklebanov@gmail.com>

* chore: update docs with links to new Kysely docs

Co-authored-by: Jie Peng <dean.leehom@gmail.com>

* feat: emailVerified shouldn't have a default

Co-authored-by: Lars Graubner <lgraubner@users.noreply.github.com>

* simplify, update code

* add README.md

* clean up docs

* fix adapter name

* add to turbo

* fix test

* revert some changes

* test fixes

---------

Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: Igal Klebanov <igalklebanov@gmail.com>
Co-authored-by: Jie Peng <dean.leehom@gmail.com>
Co-authored-by: Lars Graubner <lgraubner@users.noreply.github.com>
Co-authored-by: Balázs Orbán <info@balazsorban.com>
This commit is contained in:
Mark
2023-08-08 18:01:59 -05:00
committed by GitHub
parent 17d71a04d6
commit 1eddcf643c
16 changed files with 1017 additions and 1 deletions

View File

@@ -24,8 +24,10 @@ body:
- "@auth/dgraph-adapter"
- "@auth/drizzle-adapter"
- "@auth/dynamodb-adapter"
- "@auth/drizzle-adapter"
- "@auth/fauna-adapter"
- "@auth/firebase-adapter"
- "@auth/kysely-adapter"
- "@auth/mikro-orm-adapter"
- "@auth/mongodb-adapter"
- "@auth/neo4j-adapter"

View File

@@ -15,6 +15,9 @@ fauna:
firebase:
- "@auth/firebase-adapter"
kysely:
- "@auth/kysely-adapter"
mikro-orm:
- "@auth/mikro-orm-adapter"

View File

@@ -15,6 +15,7 @@ neo4j: ["packages/adapter-neo4j/**/*"]
playgrounds: ["apps/playgrounds/**/*"]
pouchdb: ["packages/adapter-pouchdb/**/*"]
prisma: ["packages/adapter-prisma/**/*"]
kysely: ["packages/adapter-kysely/**/*"]
providers: ["packages/core/src/providers/**/*"]
sequelize: ["packages/adapter-sequelize/**/*"]
solidjs: ["packages/frameworks-solid-start/**/*"]

View File

@@ -25,6 +25,10 @@ Using an Auth.js / NextAuth.js adapter you can connect to any database service o
<img src="/img/adapters/firebase.svg" width="40" />
<h4 class="adapter-card__title">Firebase Adapter</h4>
</a>
<a href="/reference/adapter/kysely" class="adapter-card">
<img src="/img/adapters/kysely.svg" width="40" />
<h4 class="adapter-card__title">Kysely Adapter</h4>
</a>
<a href="/reference/adapter/mikro-orm" class="adapter-card">
<img src="/img/adapters/mikro-orm.png" width="30" />
<h4 class="adapter-card__title">Mikro ORM Adapter</h4>

View File

@@ -269,6 +269,7 @@ const docusaurusConfig = {
typedocAdapter("DynamoDB"),
typedocAdapter("Fauna"),
typedocAdapter("Firebase"),
typedocAdapter("Kysely"),
typedocAdapter("Mikro ORM"),
typedocAdapter("MongoDB"),
typedocAdapter("Neo4j"),

View File

@@ -59,6 +59,7 @@ module.exports = {
{ type: "doc", id: "reference/adapter/dynamodb/index" },
{ type: "doc", id: "reference/adapter/fauna/index" },
{ type: "doc", id: "reference/adapter/firebase/index" },
{ type: "doc", id: "reference/adapter/kysely/index" },
{ type: "doc", id: "reference/adapter/mikro-orm/index" },
{ type: "doc", id: "reference/adapter/mongodb/index" },
{ type: "doc", id: "reference/adapter/neo4j/index" },

14
docs/static/img/adapters/kysely.svg vendored Normal file
View File

@@ -0,0 +1,14 @@
<svg width="132" height="132" viewBox="0 0 132 132" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_8_3)">
<rect x="2" y="2" width="128" height="128" rx="16" fill="white" />
<path
d="M41.2983 109V23.9091H46.4918V73.31H47.0735L91.9457 23.9091H98.8427L61.9062 64.1694L98.5103 109H92.0288L58.5824 67.9087L46.4918 81.2873V109H41.2983Z"
fill="black" />
</g>
<rect x="2" y="2" width="128" height="128" rx="16" stroke="#121212" stroke-width="4" />
<defs>
<clipPath id="clip0_8_3">
<rect x="2" y="2" width="128" height="128" rx="16" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 637 B

View File

@@ -0,0 +1,28 @@
<p align="center">
<br/>
<a href="https://authjs.dev" target="_blank">
<img height="64px" src="https://authjs.dev/img/logo/logo-sm.png" />
</a>
<a href="https://kysely.io" target="_blank">
<img height="64px" src="https://authjs.dev/img/adapters/kysely.svg"/>
</a>
<h3 align="center"><b>Kysely Adapter</b> - NextAuth.js / Auth.js</a></h3>
<p align="center" style="align: center;">
<a href="https://npm.im/@auth/kysely-adapter">
<img src="https://img.shields.io/badge/TypeScript-blue?style=flat-square" alt="TypeScript" />
</a>
<a href="https://npm.im/@auth/kysely-adapter">
<img alt="npm" src="https://img.shields.io/npm/v/@auth/kysely-adapter?color=green&label=@auth/kysely-adapter&style=flat-square">
</a>
<a href="https://www.npmtrends.com/@auth/kysely-adapter">
<img src="https://img.shields.io/npm/dm/@auth/kysely-adapter?label=%20downloads&style=flat-square" alt="Downloads" />
</a>
<a href="https://github.com/nextauthjs/next-auth/stargazers">
<img src="https://img.shields.io/github/stars/nextauthjs/next-auth?style=flat-square" alt="Github Stars" />
</a>
</p>
</p>
---
Check out the documentation at [authjs.dev](https://authjs.dev/reference/adapter/kysely).

View File

@@ -0,0 +1,56 @@
{
"name": "@auth/kysely-adapter",
"version": "0.0.0",
"description": "Kysely adapter for Auth.js",
"homepage": "https://authjs.dev/reference/adapter/kysely",
"repository": "https://github.com/nextauthjs/next-auth",
"bugs": {
"url": "https://github.com/nextauthjs/next-auth/issues"
},
"author": "mwojtul <mark.wojtul@gmail.com> (https://github.com/mwojtul)",
"license": "ISC",
"keywords": [
"authjs",
"next-auth",
"next.js",
"oauth",
"kysely"
],
"type": "module",
"types": "./index.d.ts",
"files": [
"*.js",
"*.d.ts*",
"src"
],
"exports": {
".": {
"types": "./index.d.ts",
"import": "./index.js"
}
},
"scripts": {
"build": "tsc",
"test": "./tests/test.sh"
},
"dependencies": {
"@auth/core": "workspace:*"
},
"peerDependencies": {
"kysely": "^0.26.1"
},
"devDependencies": {
"@next-auth/adapter-test": "workspace:*",
"@next-auth/tsconfig": "workspace:*",
"@types/better-sqlite3": "^7.6.3",
"@types/pg": "^8.6.5",
"better-sqlite3": "^8.2.0",
"jest": "^27.4.3",
"kysely": "^0.24.2",
"mysql2": "^3.2.0",
"pg": "^8.10.0"
},
"jest": {
"preset": "@next-auth/adapter-test/jest"
}
}

View File

@@ -0,0 +1,472 @@
/**
* <div style={{display: "flex", justifyContent: "space-between", alignItems: "center", padding: 16}}>
* <p style={{fontWeight: "normal"}}>Official <a href="https://kysely.dev/">Kysely</a> adapter for Auth.js / NextAuth.js.</p>
* <a href="https://kysely.dev/">
* <img style={{display: "block"}} src="/img/adapters/kysely.svg" width="38" />
* </a>
* </div>
*
* ## Installation
*
* ```bash npm2yarn2pnpm
* npm install kysely @auth/kysely-adapter
* ```
*
* @module @auth/kysely-adapter
*/
import { Kysely, SqliteAdapter } from "kysely"
import type { Adapter } from "@auth/core/adapters"
import type { GeneratedAlways } from "kysely"
export interface Database {
User: {
id: GeneratedAlways<string>
name: string | null
email: string
emailVerified: Date | string | null
image: string | null
}
Account: {
id: GeneratedAlways<string>
userId: string
type: string
provider: string
providerAccountId: string
refresh_token: string | null
access_token: string | null
expires_at: number | null
token_type: string | null
scope: string | null
id_token: string | null
session_state: string | null
}
Session: {
id: GeneratedAlways<string>
userId: string
sessionToken: string
expires: Date | string
}
VerificationToken: {
identifier: string
token: string
expires: Date | string
}
}
export const format = {
/**
* Helper function to return the passed in object and its specified prop
* as an ISO string if SQLite is being used.
*/
from<T extends Partial<Record<K, Date | null>>, K extends keyof T>(
data: T,
key: K,
isSqlite: boolean
) {
const value = data[key]
return {
...data,
[key]: value && isSqlite ? value.toISOString() : value,
}
},
to,
}
type ReturnData<T = never> = Record<string, Date | string | T>
/**
* Helper function to return the passed in object and its specified prop as a date.
* Necessary because SQLite has no date type so we store dates as ISO strings.
*/
function to<T extends Partial<ReturnData>, K extends keyof T>(
data: T,
key: K
): Omit<T, K> & Record<K, Date>
function to<T extends Partial<ReturnData<null>>, K extends keyof T>(
data: T,
key: K
): Omit<T, K> & Record<K, Date | null>
function to<T extends Partial<ReturnData<null>>, K extends keyof T>(
data: T,
key: K
) {
const value = data[key]
return Object.assign(data, {
[key]: value && typeof value === "string" ? new Date(value) : value,
})
}
/**
*
* ## Setup
*
* This adapter supports the same first party dialects that Kysely (as of v0.24.2) supports: PostgreSQL, MySQL, and SQLite. The examples below use PostgreSQL with the [pg](https://www.npmjs.com/package/pg) client.
*
* ```bash npm2yarn2pnpm
* npm install pg
* npm install --save-dev @types/pg
* ```
*
* ```typescript title="pages/api/auth/[...nextauth].ts"
* import NextAuth from "next-auth"
* import GoogleProvider from "next-auth/providers/google"
* import { KyselyAdapter } from "@auth/kysely-adapter"
* import { db } from "../../../db"
*
* export default NextAuth({
* adapter: KyselyAdapter(db),
* providers: [
* GoogleProvider({
* clientId: process.env.GOOGLE_CLIENT_ID,
* clientSecret: process.env.GOOGLE_CLIENT_SECRET,
* }),
* ],
* })
* ```
*
* Kysely's constructor requires a database interface that contains an entry with an interface for each of your tables. You can define these types manually, or use `kysely-codegen` / `prisma-kysely` to automatically generate them. Check out the default [models](/reference/adapters#models) required by Auth.js.
*
* ```ts title="db.ts"
* import { PostgresDialect } from "kysely"
* import { Pool } from "pg"
*
* // This adapter exports a wrapper of the original `Kysely` class called `KyselyAuth`,
* // that can be used to provide additional type-safety.
* // While using it isn't required, it is recommended as it will verify
* // that the database interface has all the fields that Auth.js expects.
* import { KyselyAuth } from "@auth/kysely-adapter"
*
* import type { GeneratedAlways } from "kysely"
*
* interface Database {
* User: {
* id: GeneratedAlways<string>
* name: string | null
* email: string
* emailVerified: Date | null
* image: string | null
* }
* Account: {
* id: GeneratedAlways<string>
* userId: string
* type: string
* provider: string
* providerAccountId: string
* refresh_token: string | null
* access_token: string | null
* expires_at: number | null
* token_type: string | null
* scope: string | null
* id_token: string | null
* session_state: string | null
* }
* Session: {
* id: GeneratedAlways<string>
* userId: string
* sessionToken: string
* expires: Date
* }
* VerificationToken: {
* identifier: string
* token: string
* expires: Date
* }
* }
*
* export const db = new KyselyAuth<Database>({
* dialect: new PostgresDialect({
* pool: new Pool({
* host: process.env.DATABASE_HOST,
* database: process.env.DATABASE_NAME,
* user: process.env.DATABASE_USER,
* password: process.env.DATABASE_PASSWORD,
* }),
* }),
* })
```
*
*
* :::note
* An alternative to manually defining types is generating them from the database schema using [kysely-codegen](https://github.com/RobinBlomberg/kysely-codegen), or from Prisma schemas using [prisma-kysely](https://github.com/valtyr/prisma-kysely). When using generated types with `KyselyAuth`, import `Codegen` and pass it as the second generic arg:
* ```ts
* import type { Codegen } from "@auth/kysely-adapter"
* new KyselyAuth<Database, Codegen>(...)
* ```
* :::
* ### Schema
* ```ts title="db/migrations/001_create_db.ts"
* import { Kysely, sql } from "kysely"
*
* export async function up(db: Kysely<any>): Promise<void> {
* await db.schema
* .createTable("User")
* .addColumn("id", "uuid", (col) =>
* col.primaryKey().defaultTo(sql`gen_random_uuid()`)
* )
* .addColumn("name", "text")
* .addColumn("email", "text", (col) => col.unique().notNull())
* .addColumn("emailVerified", "timestamptz")
* .addColumn("image", "text")
* .execute()
*
* await db.schema
* .createTable("Account")
* .addColumn("id", "uuid", (col) =>
* col.primaryKey().defaultTo(sql`gen_random_uuid()`)
* )
* .addColumn("userId", "uuid", (col) =>
* col.references("User.id").onDelete("cascade").notNull()
* )
* .addColumn("type", "text", (col) => col.notNull())
* .addColumn("provider", "text", (col) => col.notNull())
* .addColumn("providerAccountId", "text", (col) => col.notNull())
* .addColumn("refresh_token", "text")
* .addColumn("access_token", "text")
* .addColumn("expires_at", "bigint")
* .addColumn("token_type", "text")
* .addColumn("scope", "text")
* .addColumn("id_token", "text")
* .addColumn("session_state", "text")
* .execute()
*
* await db.schema
* .createTable("Session")
* .addColumn("id", "uuid", (col) =>
* col.primaryKey().defaultTo(sql`gen_random_uuid()`)
* )
* .addColumn("userId", "uuid", (col) =>
* col.references("User.id").onDelete("cascade").notNull()
* )
* .addColumn("sessionToken", "text", (col) => col.notNull().unique())
* .addColumn("expires", "timestamptz", (col) => col.notNull())
* .execute()
*
* await db.schema
* .createTable("VerificationToken")
* .addColumn("identifier", "text", (col) => col.notNull())
* .addColumn("token", "text", (col) => col.notNull().unique())
* .addColumn("expires", "timestamptz", (col) => col.notNull())
* .execute()
*
* await db.schema
* .createIndex("Account_userId_index")
* .on("Account")
* .column("userId")
* .execute()
*
* await db.schema
* .createIndex("Session_userId_index")
* .on("Session")
* .column("userId")
* .execute()
* }
*
* export async function down(db: Kysely<any>): Promise<void> {
* await db.schema.dropTable("Account").ifExists().execute()
* await db.schema.dropTable("Session").ifExists().execute()
* await db.schema.dropTable("User").ifExists().execute()
* await db.schema.dropTable("VerificationToken").ifExists().execute()
* }
* ```
* > This schema is adapted for use in Kysely and is based upon our main [schema](/reference/adapters/models).
*
* For more information about creating and running migrations with Kysely, refer to the [Kysely migrations documentation](https://kysely.dev/docs/migrations).
*
* ### Naming conventions
* If mixed snake_case and camelCase column names is an issue for you and/or your underlying database system, we recommend using Kysely's `CamelCasePlugin` ([see the documentation here](https://kysely-org.github.io/kysely/classes/CamelCasePlugin.html)) feature to change the field names. This won't affect NextAuth.js, but will allow you to have consistent casing when using Kysely.
*/
export function KyselyAdapter(db: Kysely<Database>): Adapter {
const { adapter } = db.getExecutor()
const supportsReturning = adapter.supportsReturning
const isSqlite = adapter instanceof SqliteAdapter
return {
async createUser(data) {
const userData = format.from(data, "emailVerified", isSqlite)
const query = db.insertInto("User").values(userData)
const result = supportsReturning
? await query.returningAll().executeTakeFirstOrThrow()
: await query.executeTakeFirstOrThrow().then(async () => {
return await db
.selectFrom("User")
.selectAll()
.where("email", "=", `${userData.email}`)
.executeTakeFirstOrThrow()
})
return to(result, "emailVerified")
},
async getUser(id) {
const result =
(await db
.selectFrom("User")
.selectAll()
.where("id", "=", id)
.executeTakeFirst()) ?? null
if (!result) return null
return to(result, "emailVerified")
},
async getUserByEmail(email) {
const result =
(await db
.selectFrom("User")
.selectAll()
.where("email", "=", email)
.executeTakeFirst()) ?? null
if (!result) return null
return to(result, "emailVerified")
},
async getUserByAccount({ providerAccountId, provider }) {
const result =
(await db
.selectFrom("User")
.innerJoin("Account", "User.id", "Account.userId")
.selectAll("User")
.where("Account.providerAccountId", "=", providerAccountId)
.where("Account.provider", "=", provider)
.executeTakeFirst()) ?? null
if (!result) return null
return to(result, "emailVerified")
},
async updateUser({ id, ...user }) {
if (!id) throw new Error("User not found")
const userData = format.from(user, "emailVerified", isSqlite)
const query = db.updateTable("User").set(userData).where("id", "=", id)
const result = supportsReturning
? await query.returningAll().executeTakeFirstOrThrow()
: await query.executeTakeFirstOrThrow().then(async () => {
return await db
.selectFrom("User")
.selectAll()
.where("id", "=", id)
.executeTakeFirstOrThrow()
})
return to(result, "emailVerified")
},
async deleteUser(userId) {
await db.deleteFrom("User").where("User.id", "=", userId).execute()
},
async linkAccount(account) {
await db.insertInto("Account").values(account).executeTakeFirstOrThrow()
},
async unlinkAccount({ providerAccountId, provider }) {
await db
.deleteFrom("Account")
.where("Account.providerAccountId", "=", providerAccountId)
.where("Account.provider", "=", provider)
.executeTakeFirstOrThrow()
},
async createSession(data) {
const sessionData = format.from(data, "expires", isSqlite)
const query = db.insertInto("Session").values(sessionData)
const result = supportsReturning
? await query.returningAll().executeTakeFirstOrThrow()
: await (async () => {
await query.executeTakeFirstOrThrow()
return await db
.selectFrom("Session")
.selectAll()
.where("sessionToken", "=", sessionData.sessionToken)
.executeTakeFirstOrThrow()
})()
return to(result, "expires")
},
async getSessionAndUser(sessionTokenArg) {
const result = await db
.selectFrom("Session")
.innerJoin("User", "User.id", "Session.userId")
.selectAll("User")
.select([
"Session.id as sessionId",
"Session.userId",
"Session.sessionToken",
"Session.expires",
])
.where("Session.sessionToken", "=", sessionTokenArg)
.executeTakeFirst()
if (!result) return null
const { sessionId: id, userId, sessionToken, expires, ...user } = result
return {
user: to({ ...user }, "emailVerified"),
session: to({ id, userId, sessionToken, expires }, "expires"),
}
},
async updateSession(session) {
const sessionData = format.from(session, "expires", isSqlite)
const query = db
.updateTable("Session")
.set(sessionData)
.where("Session.sessionToken", "=", session.sessionToken)
const result = supportsReturning
? await query.returningAll().executeTakeFirstOrThrow()
: await query.executeTakeFirstOrThrow().then(async () => {
return await db
.selectFrom("Session")
.selectAll()
.where("Session.sessionToken", "=", sessionData.sessionToken)
.executeTakeFirstOrThrow()
})
return to(result, "expires")
},
async deleteSession(sessionToken) {
await db
.deleteFrom("Session")
.where("Session.sessionToken", "=", sessionToken)
.executeTakeFirstOrThrow()
},
async createVerificationToken(verificationToken) {
const verificationTokenData = format.from(
verificationToken,
"expires",
isSqlite
)
const query = db
.insertInto("VerificationToken")
.values(verificationTokenData)
const result = supportsReturning
? await query.returningAll().executeTakeFirstOrThrow()
: await query.executeTakeFirstOrThrow().then(async () => {
return await db
.selectFrom("VerificationToken")
.selectAll()
.where("token", "=", verificationTokenData.token)
.executeTakeFirstOrThrow()
})
return to(result, "expires")
},
async useVerificationToken({ identifier, token }) {
const query = db
.deleteFrom("VerificationToken")
.where("VerificationToken.token", "=", token)
.where("VerificationToken.identifier", "=", identifier)
const result = supportsReturning
? (await query.returningAll().executeTakeFirst()) ?? null
: await db
.selectFrom("VerificationToken")
.selectAll()
.where("token", "=", token)
.executeTakeFirst()
.then(async (res) => {
await query.executeTakeFirst()
return res
})
if (!result) return null
return to(result, "expires")
},
}
}
/**
* Wrapper over the original `Kysely` class in order to validate the passed in
* database interface. A regular Kysely instance may also be used, but wrapping
* it ensures the database interface implements the fields that Auth.js
* requires. When used with `kysely-codegen`, the `Codegen` type can be passed as
* the second generic argument. The generated types will be used, and
* `KyselyAuth` will only verify that the correct fields exist.
**/
export class KyselyAuth<DB extends T, T = Database> extends Kysely<DB> {}
export type Codegen = {
[K in keyof Database]: { [J in keyof Database[K]]: unknown }
}

View File

@@ -0,0 +1,250 @@
import { runBasicTests } from "@next-auth/adapter-test"
import { Pool } from "pg"
import {
Kysely,
MysqlDialect,
PostgresDialect,
SchemaModule,
sql,
SqliteAdapter,
SqliteDialect,
} from "kysely"
import { KyselyAdapter, KyselyAuth } from "../src"
import { createPool } from "mysql2"
import SqliteDatabase from "better-sqlite3"
import type { Database } from "../src"
import { DataTypeExpression } from "kysely/dist/cjs/parser/data-type-parser"
type BuiltInDialect = "postgres" | "mysql" | "sqlite"
const POOL_SIZE = 20
const DIALECT_CONFIGS = {
postgres: {
host: "localhost",
database: "kysely_test",
user: "kysely",
port: 5434,
max: POOL_SIZE,
},
mysql: {
database: "kysely_test",
host: "localhost",
user: "kysely",
password: "kysely",
port: 3308,
supportBigNumbers: true,
bigNumberStrings: true,
connectionLimit: POOL_SIZE,
},
sqlite: {
databasePath: ":memory:",
},
} as const
async function dropDatabase(db: Kysely<Database>): Promise<void> {
await Promise.all([
db.schema.dropTable("Account").ifExists().execute(),
db.schema.dropTable("Session").ifExists().execute(),
db.schema.dropTable("User").ifExists().execute(),
db.schema.dropTable("VerificationToken").ifExists().execute(),
])
}
export function createTableWithId(
schema: SchemaModule,
dialect: BuiltInDialect,
tableName: string
) {
const builder = schema.createTable(tableName)
if (dialect === "postgres") {
return builder.addColumn("id", "uuid", (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`)
)
} else if (dialect === "mysql") {
return builder.addColumn("id", "varchar(36)", (col) =>
col.primaryKey().defaultTo(sql`(UUID())`)
)
} else {
return builder.addColumn("id", "integer", (col) =>
col.autoIncrement().primaryKey()
)
}
}
async function createDatabase(
db: Kysely<Database>,
dialect: BuiltInDialect
): Promise<void> {
const defaultTimestamp = {
postgres: sql`NOW()`,
mysql: sql`NOW(3)`,
sqlite: sql`CURRENT_TIMESTAMP`,
}[dialect]
const uuidColumnType: DataTypeExpression =
dialect === "mysql" ? "varchar(36)" : "uuid"
const dateColumnType: DataTypeExpression =
dialect === "mysql" ? sql`DATETIME(3)` : "timestamptz"
const textColumnType: DataTypeExpression =
dialect === "mysql" ? "varchar(255)" : "text"
await dropDatabase(db)
await createTableWithId(db.schema, dialect, "User")
.addColumn("name", textColumnType)
.addColumn("email", textColumnType, (col) => col.unique().notNull())
.addColumn("emailVerified", dateColumnType, (col) =>
col.defaultTo(defaultTimestamp)
)
.addColumn("image", textColumnType)
.execute()
let createAccountTable = createTableWithId(db.schema, dialect, "Account")
.addColumn("userId", uuidColumnType, (col) =>
col.references("User.id").onDelete("cascade").notNull()
)
.addColumn("type", textColumnType, (col) => col.notNull())
.addColumn("provider", textColumnType, (col) => col.notNull())
.addColumn("providerAccountId", textColumnType, (col) => col.notNull())
.addColumn("refresh_token", textColumnType)
.addColumn("access_token", textColumnType)
.addColumn("expires_at", "bigint")
.addColumn("token_type", textColumnType)
.addColumn("scope", textColumnType)
.addColumn("id_token", textColumnType)
.addColumn("session_state", textColumnType)
if (dialect === "mysql")
createAccountTable = createAccountTable.addForeignKeyConstraint(
"Account_userId_fk",
["userId"],
"User",
["id"],
(cb) => cb.onDelete("cascade")
)
await createAccountTable.execute()
let createSessionTable = createTableWithId(db.schema, dialect, "Session")
.addColumn("userId", uuidColumnType, (col) =>
col.references("User.id").onDelete("cascade").notNull()
)
.addColumn("sessionToken", textColumnType, (col) => col.notNull().unique())
.addColumn("expires", dateColumnType, (col) => col.notNull())
if (dialect === "mysql")
createSessionTable = createSessionTable.addForeignKeyConstraint(
"Session_userId_fk",
["userId"],
"User",
["id"],
(cb) => cb.onDelete("cascade")
)
await createSessionTable.execute()
await db.schema
.createTable("VerificationToken")
.addColumn("identifier", textColumnType, (col) => col.notNull())
.addColumn("token", textColumnType, (col) => col.notNull().unique())
.addColumn("expires", dateColumnType, (col) => col.notNull())
.execute()
await db.schema
.createIndex("Account_userId_index")
.on("Account")
.column("userId")
.execute()
}
const runDialectBasicTests = (
db: Kysely<Database>,
dialect: BuiltInDialect
) => {
const datesStoredAsISOStrings =
db.getExecutor().adapter instanceof SqliteAdapter
runBasicTests({
adapter: KyselyAdapter(db),
db: {
async connect() {
await dropDatabase(db)
await createDatabase(db, dialect)
},
async disconnect() {
await db.destroy()
},
async user(userId) {
const user =
(await db
.selectFrom("User")
.selectAll()
.where("id", "=", userId)
.executeTakeFirst()) ?? null
if (datesStoredAsISOStrings && user?.emailVerified)
user.emailVerified = new Date(user.emailVerified)
return user
},
async account({ provider, providerAccountId }) {
const result = await db
.selectFrom("Account")
.selectAll()
.where("provider", "=", provider)
.where("providerAccountId", "=", providerAccountId)
.executeTakeFirst()
if (!result) return null
const { ...account } = result
if (typeof account.expires_at === "string")
account.expires_at = Number(account.expires_at)
return account
},
async session(sessionToken) {
const session =
(await db
.selectFrom("Session")
.selectAll()
.where("sessionToken", "=", sessionToken)
.executeTakeFirst()) ?? null
if (datesStoredAsISOStrings && session?.expires)
session.expires = new Date(session.expires)
return session
},
async verificationToken({ identifier, token }) {
const verificationToken = await db
.selectFrom("VerificationToken")
.selectAll()
.where("identifier", "=", identifier)
.where("token", "=", token)
.executeTakeFirstOrThrow()
if (datesStoredAsISOStrings)
verificationToken.expires = new Date(verificationToken.expires)
return verificationToken
},
},
})
}
describe("Testing PostgresDialect", () => {
const db = new KyselyAuth<Database>({
dialect: new PostgresDialect({
pool: new Pool(DIALECT_CONFIGS.postgres),
}),
})
runDialectBasicTests(db, "postgres")
})
describe("Testing MysqlDialect", () => {
const db = new KyselyAuth<Database>({
dialect: new MysqlDialect({
pool: createPool(DIALECT_CONFIGS.mysql),
}),
})
runDialectBasicTests(db, "mysql")
})
describe("Testing SqliteDialect", () => {
const db = new KyselyAuth<Database>({
dialect: new SqliteDialect({
database: async () =>
new SqliteDatabase(DIALECT_CONFIGS.sqlite.databasePath),
}),
})
runDialectBasicTests(db, "sqlite")
})

View File

@@ -0,0 +1,3 @@
CREATE USER 'kysely'@'%' IDENTIFIED WITH mysql_native_password BY 'kysely';
GRANT ALL ON *.* TO 'kysely'@'%';
CREATE DATABASE kysely_test;

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
docker run -d \
--name mysql \
--rm \
-e MYSQL_ROOT_PASSWORD=root \
-e MYSQL_DATABASE=kysely_test \
-p 3308:3306 \
-v "$(pwd)"/tests/scripts/mysql-init.sql:/data/application/init.sql \
mysql/mysql-server \
--init-file /data/application/init.sql
docker run -d \
--name postgres \
--rm \
-e POSTGRES_DB=kysely_test \
-e POSTGRES_USER=kysely \
-e POSTGRES_HOST_AUTH_METHOD=trust \
-p 5434:5432 \
postgres
echo "waiting 15 seconds for databases to start..."
sleep 15
# Always stop container, but exit with 1 when tests are failing
if npx jest tests; then
docker stop mysql && docker stop postgres
else
docker stop mysql && docker stop postgres && exit 1
fi

View File

@@ -0,0 +1,25 @@
{
"extends": "@next-auth/tsconfig/tsconfig.base.json",
"compilerOptions": {
"allowJs": true,
"baseUrl": ".",
"isolatedModules": true,
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"outDir": ".",
"rootDir": "src",
"skipDefaultLibCheck": true,
"strictNullChecks": true,
"stripInternal": true,
"declarationMap": true,
"declaration": true
},
"include": [
"src/**/*"
],
"exclude": [
"*.js",
"*.d.ts",
]
}

124
pnpm-lock.yaml generated
View File

@@ -318,6 +318,31 @@ importers:
firebase-tools: 11.16.1
jest: 29.3.1
packages/adapter-kysely:
specifiers:
'@auth/core': workspace:*
'@next-auth/adapter-test': workspace:*
'@next-auth/tsconfig': workspace:*
'@types/better-sqlite3': ^7.6.3
'@types/pg': ^8.6.5
better-sqlite3: ^8.2.0
jest: ^27.4.3
kysely: ^0.24.2
mysql2: ^3.2.0
pg: ^8.10.0
dependencies:
'@auth/core': link:../core
devDependencies:
'@next-auth/adapter-test': link:../adapter-test
'@next-auth/tsconfig': link:../tsconfig
'@types/better-sqlite3': 7.6.4
'@types/pg': 8.10.2
better-sqlite3: 8.5.0
jest: 27.5.1
kysely: 0.24.2
mysql2: 3.6.0
pg: 8.11.2
packages/adapter-mikro-orm:
specifiers:
'@auth/core': workspace:*
@@ -10139,6 +10164,14 @@ packages:
resolution: {integrity: sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==}
dev: true
/@types/pg/8.10.2:
resolution: {integrity: sha512-MKFs9P6nJ+LAeHLU3V0cODEOgyThJ3OAnmOlsZsxux6sfQs3HRXR5bBn7xG5DjckEFhTAxsXi7k7cd0pCMxpJw==}
dependencies:
'@types/node': 20.4.8
pg-protocol: 1.5.0
pg-types: 4.0.1
dev: true
/@types/phoenix/1.5.4:
resolution: {integrity: sha512-L5eZmzw89eXBKkiqVBcJfU1QGx9y+wurRIEgt0cuLH0hwNtVUxtx+6cu0R2STwWj468sjXyBYPYDtGclUd1kjQ==}
@@ -21590,6 +21623,11 @@ packages:
resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==}
dev: true
/kysely/0.24.2:
resolution: {integrity: sha512-+7eaTJNUYm2yRq1x+lEOZc+78TO35dTZ9b0dh49+Z9CTt2byMSbMiOKpwPlOyCAaHD4kILkAYWYZNywFlmBwRA==}
engines: {node: '>=14.0.0'}
dev: true
/latest-version/5.1.0:
resolution: {integrity: sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==}
engines: {node: '>=8'}
@@ -23801,15 +23839,30 @@ packages:
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
dev: true
/pg-cloudflare/1.1.1:
resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==}
requiresBuild: true
dev: true
optional: true
/pg-connection-string/2.5.0:
resolution: {integrity: sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==}
dev: true
/pg-connection-string/2.6.2:
resolution: {integrity: sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==}
dev: true
/pg-int8/1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
dev: true
/pg-numeric/1.0.2:
resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==}
engines: {node: '>=4'}
dev: true
/pg-pool/3.5.1_pg@8.7.3:
resolution: {integrity: sha512-6iCR0wVrro6OOHFsyavV+i6KYL4lVNyYAB9RD18w66xSzN+d8b66HiwuP30Gp1SH5O9T82fckkzsRjlrhD0ioQ==}
peerDependencies:
@@ -23818,10 +23871,22 @@ packages:
pg: 8.7.3
dev: true
/pg-pool/3.6.1_pg@8.11.2:
resolution: {integrity: sha512-jizsIzhkIitxCGfPRzJn1ZdcosIt3pz9Sh3V01fm1vZnbnCMgmGl5wvGGdNN2EL9Rmb0EcFoCkixH4Pu+sP9Og==}
peerDependencies:
pg: '>=8.0'
dependencies:
pg: 8.11.2
dev: true
/pg-protocol/1.5.0:
resolution: {integrity: sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ==}
dev: true
/pg-protocol/1.6.0:
resolution: {integrity: sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q==}
dev: true
/pg-types/2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
@@ -23833,6 +23898,39 @@ packages:
postgres-interval: 1.2.0
dev: true
/pg-types/4.0.1:
resolution: {integrity: sha512-hRCSDuLII9/LE3smys1hRHcu5QGcLs9ggT7I/TCs0IE+2Eesxi9+9RWAAwZ0yaGjxoWICF/YHLOEjydGujoJ+g==}
engines: {node: '>=10'}
dependencies:
pg-int8: 1.0.1
pg-numeric: 1.0.2
postgres-array: 3.0.2
postgres-bytea: 3.0.0
postgres-date: 2.0.1
postgres-interval: 3.0.0
postgres-range: 1.1.3
dev: true
/pg/8.11.2:
resolution: {integrity: sha512-l4rmVeV8qTIrrPrIR3kZQqBgSN93331s9i6wiUiLOSk0Q7PmUxZD/m1rQI622l3NfqBby9Ar5PABfS/SulfieQ==}
engines: {node: '>= 8.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
dependencies:
buffer-writer: 2.0.0
packet-reader: 1.0.0
pg-connection-string: 2.6.2
pg-pool: 3.6.1_pg@8.11.2
pg-protocol: 1.6.0
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.1.1
dev: true
/pg/8.7.3:
resolution: {integrity: sha512-HPmH4GH4H3AOprDJOazoIcpI49XFsHCe8xlrjHkWiapdbHK+HLtbm/GQzXYAZwmPju/kzKhjaSfMACG+8cgJcw==}
engines: {node: '>= 8.0.0'}
@@ -25032,16 +25130,33 @@ packages:
engines: {node: '>=4'}
dev: true
/postgres-array/3.0.2:
resolution: {integrity: sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==}
engines: {node: '>=12'}
dev: true
/postgres-bytea/1.0.0:
resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==}
engines: {node: '>=0.10.0'}
dev: true
/postgres-bytea/3.0.0:
resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==}
engines: {node: '>= 6'}
dependencies:
obuf: 1.1.2
dev: true
/postgres-date/1.0.7:
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
engines: {node: '>=0.10.0'}
dev: true
/postgres-date/2.0.1:
resolution: {integrity: sha512-YtMKdsDt5Ojv1wQRvUhnyDJNSr2dGIC96mQVKz7xufp07nfuFONzdaowrMHjlAzY6GDLd4f+LUHHAAM1h4MdUw==}
engines: {node: '>=12'}
dev: true
/postgres-interval/1.2.0:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
@@ -25049,6 +25164,15 @@ packages:
xtend: 4.0.2
dev: true
/postgres-interval/3.0.0:
resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==}
engines: {node: '>=12'}
dev: true
/postgres-range/1.1.3:
resolution: {integrity: sha512-VdlZoocy5lCP0c/t66xAfclglEapXPCIVhqqJRncYpvbCgImF0w67aPKfbqUMr72tO2k5q0TdTZwCLjPTI6C9g==}
dev: true
/postgres/3.3.5:
resolution: {integrity: sha512-+JD93VELV9gHkqpV5gdL5/70HdGtEw4/XE1S4BC8f1mcPmdib3K5XsKVbnR1XcAyC41zOnifJ+9YRKxdIsXiUw==}
dev: true

View File

@@ -64,6 +64,7 @@
"@auth/dynamodb-adapter#build",
"@auth/fauna-adapter#build",
"@auth/firebase-adapter#build",
"@auth/kysely-adapter#build",
"@auth/mikro-orm-adapter#build",
"@auth/mongodb-adapter#build",
"@auth/neo4j-adapter#build",
@@ -88,6 +89,7 @@
"@auth/dynamodb-adapter#build",
"@auth/fauna-adapter#build",
"@auth/firebase-adapter#build",
"@auth/kysely-adapter#build",
"@auth/mikro-orm-adapter#build",
"@auth/mongodb-adapter#build",
"@auth/neo4j-adapter#build",
@@ -109,4 +111,4 @@
]
}
}
}
}