From 1eddcf643ceec9ba5e4415f402e3662c255ccea8 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 8 Aug 2023 18:01:59 -0500 Subject: [PATCH] feat(adapters): add Kysely adapter (#5464) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * chore: update docs Co-authored-by: Igal Klebanov * chore: update docs with links to new Kysely docs Co-authored-by: Jie Peng * feat: emailVerified shouldn't have a default Co-authored-by: Lars Graubner * 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 Co-authored-by: Igal Klebanov Co-authored-by: Jie Peng Co-authored-by: Lars Graubner Co-authored-by: Balázs Orbán --- .github/ISSUE_TEMPLATE/3_bug_adapter.yml | 2 + .github/issue-labeler.yml | 3 + .github/pr-labeler.yml | 1 + docs/docs/reference/adapters/index.md | 4 + docs/docusaurus.config.js | 1 + docs/sidebars.js | 1 + docs/static/img/adapters/kysely.svg | 14 + packages/adapter-kysely/README.md | 28 ++ packages/adapter-kysely/package.json | 56 +++ packages/adapter-kysely/src/index.ts | 472 ++++++++++++++++++ packages/adapter-kysely/tests/index.test.ts | 250 ++++++++++ .../tests/scripts/mysql-init.sql | 3 + packages/adapter-kysely/tests/test.sh | 30 ++ packages/adapter-kysely/tsconfig.json | 25 + pnpm-lock.yaml | 124 +++++ turbo.json | 4 +- 16 files changed, 1017 insertions(+), 1 deletion(-) create mode 100644 docs/static/img/adapters/kysely.svg create mode 100644 packages/adapter-kysely/README.md create mode 100644 packages/adapter-kysely/package.json create mode 100644 packages/adapter-kysely/src/index.ts create mode 100644 packages/adapter-kysely/tests/index.test.ts create mode 100644 packages/adapter-kysely/tests/scripts/mysql-init.sql create mode 100755 packages/adapter-kysely/tests/test.sh create mode 100644 packages/adapter-kysely/tsconfig.json diff --git a/.github/ISSUE_TEMPLATE/3_bug_adapter.yml b/.github/ISSUE_TEMPLATE/3_bug_adapter.yml index b46c770d..79dda93a 100644 --- a/.github/ISSUE_TEMPLATE/3_bug_adapter.yml +++ b/.github/ISSUE_TEMPLATE/3_bug_adapter.yml @@ -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" diff --git a/.github/issue-labeler.yml b/.github/issue-labeler.yml index 12abfcaf..3285fbbf 100644 --- a/.github/issue-labeler.yml +++ b/.github/issue-labeler.yml @@ -15,6 +15,9 @@ fauna: firebase: - "@auth/firebase-adapter" +kysely: + - "@auth/kysely-adapter" + mikro-orm: - "@auth/mikro-orm-adapter" diff --git a/.github/pr-labeler.yml b/.github/pr-labeler.yml index f219dc44..cfdab16d 100644 --- a/.github/pr-labeler.yml +++ b/.github/pr-labeler.yml @@ -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/**/*"] diff --git a/docs/docs/reference/adapters/index.md b/docs/docs/reference/adapters/index.md index 26b85f48..61bffaba 100644 --- a/docs/docs/reference/adapters/index.md +++ b/docs/docs/reference/adapters/index.md @@ -25,6 +25,10 @@ Using an Auth.js / NextAuth.js adapter you can connect to any database service o

Firebase Adapter

+ + +

Kysely Adapter

+

Mikro ORM Adapter

diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index e5add05e..9ae31dc9 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -269,6 +269,7 @@ const docusaurusConfig = { typedocAdapter("DynamoDB"), typedocAdapter("Fauna"), typedocAdapter("Firebase"), + typedocAdapter("Kysely"), typedocAdapter("Mikro ORM"), typedocAdapter("MongoDB"), typedocAdapter("Neo4j"), diff --git a/docs/sidebars.js b/docs/sidebars.js index 3c25bf17..b737cd87 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -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" }, diff --git a/docs/static/img/adapters/kysely.svg b/docs/static/img/adapters/kysely.svg new file mode 100644 index 00000000..b682365c --- /dev/null +++ b/docs/static/img/adapters/kysely.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/packages/adapter-kysely/README.md b/packages/adapter-kysely/README.md new file mode 100644 index 00000000..0fddfac0 --- /dev/null +++ b/packages/adapter-kysely/README.md @@ -0,0 +1,28 @@ +

+
+
+ + + + + +

Kysely Adapter - NextAuth.js / Auth.js

+

+ + TypeScript + + + npm + + + Downloads + + + Github Stars + +

+

+ +--- + +Check out the documentation at [authjs.dev](https://authjs.dev/reference/adapter/kysely). \ No newline at end of file diff --git a/packages/adapter-kysely/package.json b/packages/adapter-kysely/package.json new file mode 100644 index 00000000..d103a369 --- /dev/null +++ b/packages/adapter-kysely/package.json @@ -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 (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" + } +} diff --git a/packages/adapter-kysely/src/index.ts b/packages/adapter-kysely/src/index.ts new file mode 100644 index 00000000..9fcd7b5e --- /dev/null +++ b/packages/adapter-kysely/src/index.ts @@ -0,0 +1,472 @@ +/** + *
+ *

Official Kysely adapter for Auth.js / NextAuth.js.

+ * + * + * + *
+ * + * ## 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 + name: string | null + email: string + emailVerified: Date | string | null + image: string | null + } + Account: { + id: GeneratedAlways + 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 + 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>, 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 = Record + +/** + * 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, K extends keyof T>( + data: T, + key: K +): Omit & Record +function to>, K extends keyof T>( + data: T, + key: K +): Omit & Record +function to>, 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 + * name: string | null + * email: string + * emailVerified: Date | null + * image: string | null + * } + * Account: { + * id: GeneratedAlways + * 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 + * userId: string + * sessionToken: string + * expires: Date + * } + * VerificationToken: { + * identifier: string + * token: string + * expires: Date + * } + * } + * + * export const db = new KyselyAuth({ + * 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(...) + * ``` + * ::: + * ### Schema + * ```ts title="db/migrations/001_create_db.ts" + * import { Kysely, sql } from "kysely" + * + * export async function up(db: Kysely): Promise { + * 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): Promise { + * 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): 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 extends Kysely {} + +export type Codegen = { + [K in keyof Database]: { [J in keyof Database[K]]: unknown } +} diff --git a/packages/adapter-kysely/tests/index.test.ts b/packages/adapter-kysely/tests/index.test.ts new file mode 100644 index 00000000..ccb86ca9 --- /dev/null +++ b/packages/adapter-kysely/tests/index.test.ts @@ -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): Promise { + 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, + dialect: BuiltInDialect +): Promise { + 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, + 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({ + dialect: new PostgresDialect({ + pool: new Pool(DIALECT_CONFIGS.postgres), + }), + }) + runDialectBasicTests(db, "postgres") +}) + +describe("Testing MysqlDialect", () => { + const db = new KyselyAuth({ + dialect: new MysqlDialect({ + pool: createPool(DIALECT_CONFIGS.mysql), + }), + }) + runDialectBasicTests(db, "mysql") +}) + +describe("Testing SqliteDialect", () => { + const db = new KyselyAuth({ + dialect: new SqliteDialect({ + database: async () => + new SqliteDatabase(DIALECT_CONFIGS.sqlite.databasePath), + }), + }) + runDialectBasicTests(db, "sqlite") +}) diff --git a/packages/adapter-kysely/tests/scripts/mysql-init.sql b/packages/adapter-kysely/tests/scripts/mysql-init.sql new file mode 100644 index 00000000..b130b8ca --- /dev/null +++ b/packages/adapter-kysely/tests/scripts/mysql-init.sql @@ -0,0 +1,3 @@ +CREATE USER 'kysely'@'%' IDENTIFIED WITH mysql_native_password BY 'kysely'; +GRANT ALL ON *.* TO 'kysely'@'%'; +CREATE DATABASE kysely_test; \ No newline at end of file diff --git a/packages/adapter-kysely/tests/test.sh b/packages/adapter-kysely/tests/test.sh new file mode 100755 index 00000000..4cbbca96 --- /dev/null +++ b/packages/adapter-kysely/tests/test.sh @@ -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 diff --git a/packages/adapter-kysely/tsconfig.json b/packages/adapter-kysely/tsconfig.json new file mode 100644 index 00000000..726c2dc1 --- /dev/null +++ b/packages/adapter-kysely/tsconfig.json @@ -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", + ] +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1928f58d..0e9df117 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/turbo.json b/turbo.json index 408202b6..80f6123b 100644 --- a/turbo.json +++ b/turbo.json @@ -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 @@ ] } } -} +} \ No newline at end of file