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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+---
+
+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