next-auth icon indicating copy to clipboard operation
next-auth copied to clipboard

Kysely Adapter' Database interface mismatch with provided schema in docs and kysely schema rules

Open AchalS-iglu opened this issue 1 year ago • 5 comments

Adapter type

@auth/kysely-adapter

Environment

System: OS: Linux 6.7 EndeavourOS CPU: (8) x64 AMD Ryzen 7 3700U with Radeon Vega Mobile Gfx Memory: 1.15 GB / 6.72 GB Container: Yes Shell: 5.2.26 - /bin/bash Binaries: Node: 21.4.0 - ~/.nvm/versions/node/v21.4.0/bin/node Yarn: 1.22.21 - ~/.nvm/versions/node/v21.4.0/bin/yarn npm: 10.2.4 - ~/.nvm/versions/node/v21.4.0/bin/npm bun: 1.0.31 - ~/.bun/bin/bun Browsers: Chromium: 122.0.6261.128 npmPackages: @auth/drizzle-adapter: ^0.7.0 => 0.7.0 @auth/kysely-adapter: ^0.6.1 => 0.6.1 next: ^14.1.3 => 14.1.4 next-auth: ^4.24.6 => 4.24.7 react: 18.2.0 => 18.2.0

Reproduction URL

https://github.com/traveltoindia-co-in/ta

Describe the issue

So, I used the schema provided in the docs but it is incompatible with the adapter's constructor KyselyAuth. Look at src/types/authTypes.ts. These types are then put into the Database interface in src/types/database.ts. Then that interface is further passed into the Adapter's constructor in file src/server/database.ts, however it is incompatible. The Adapter uses standard typescript typing rather than Kysely's format, meanwhile the docs have proper schema.

The adapter's internal database type refers to the core types.

Suggested interface for said type -

  id: GeneratedAlways<string>;
  name: string | null;
  email: string;
  emailVerified: Date | null;
  image: string | null;
}

interface AccountTable {
  id: GeneratedAlways<string>;
  userId: string;
  type: "oauth" | "oidc" | "email" | "webauthn"; // or however it is used
  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;
}

interface SessionTable {
  id: GeneratedAlways<string>;
  userId: string;
  sessionToken: string;
  expires: Date;
}

interface VerificationTokenTable {
  identifier: string;
  token: string;
  expires: Date;
}

I hope this is appropriate in terms of Kysely (I have actually never used kysely before). Also, Kysely tells to | null instead of optional ? or undefined. If I were to use Selectable<T> instead of T (T being any of the table model above) then I believe it would match perfectly. However I am getting numerous errors on trying to assign Database schema to the adapter's constructor, there could be more, I do not know.

I hope I am correct I could be entirely wrong and using the lib incorrectly.

How to reproduce

Look at intellisense I suppose or run ts check

Expected behavior

No TS errors.

AchalS-iglu avatar Mar 29 '24 17:03 AchalS-iglu

The error

  The types of 'User.id' are incompatible between these types.
    Type 'GeneratedAlways<string>' is not assignable to type 'string'.ts(2344)
AND
	"resource": "/home/achals/repos/ta/src/server/auth.ts",
	"owner": "typescript",
	"code": "2322",
	"severity": 8,
	"message": "Type 'import(\"/home/achals/repos/ta/node_modules/@auth/kysely-adapter/node_modules/@auth/core/adapters\").Adapter' is not assignable to type 'import(\"/home/achals/repos/ta/node_modules/next-auth/adapters\").Adapter'.\n  Types of property 'createUser' are incompatible.\n    Type '((user: AdapterUser) => Awaitable<AdapterUser>) | undefined' is not assignable to type '((user: Omit<AdapterUser, \"id\">) => Awaitable<AdapterUser>) | undefined'.\n      Type '(user: AdapterUser) => Awaitable<AdapterUser>' is not assignable to type '(user: Omit<AdapterUser, \"id\">) => Awaitable<AdapterUser>'.\n        Types of parameters 'user' and 'user' are incompatible.\n          Property 'id' is missing in type 'Omit<AdapterUser, \"id\">' but required in type 'AdapterUser'.",
	"source": "ts",
	"startLineNumber": 50,
	"startColumn": 3,
	"endLineNumber": 50,
	"endColumn": 10,
	"relatedInformation": [
		{
			"startLineNumber": 174,
			"startColumn": 5,
			"endLineNumber": 174,
			"endColumn": 7,
			"message": "'id' is declared here.",
			"resource": "/home/achals/repos/ta/node_modules/@auth/kysely-adapter/node_modules/@auth/core/adapters.d.ts"
		},
		{
			"startLineNumber": 106,
			"startColumn": 5,
			"endLineNumber": 106,
			"endColumn": 12,
			"message": "The expected type comes from property 'adapter' which is declared here on type 'AuthOptions'",
			"resource": "/home/achals/repos/ta/node_modules/next-auth/core/types.d.ts"
		}
	]
},{
	"resource": "/home/achals/repos/ta/src/server/auth.ts",
	"owner": "typescript",
	"code": "2345",
	"severity": 8,
	"message": "Argument of type 'Kysely<Database>' is not assignable to parameter of type 'Kysely<DatabaseSchema>'.\n  The types of 'transaction().execute' are incompatible between these types.\n    Type '<T>(callback: (trx: Transaction<Database>) => Promise<T>) => Promise<T>' is not assignable to type '<T>(callback: (trx: Transaction<DatabaseSchema>) => Promise<T>) => Promise<T>'.\n      Types of parameters 'callback' and 'callback' are incompatible.\n        Types of parameters 'trx' and 'trx' are incompatible.\n          Type 'Transaction<Database>' is not assignable to type 'Transaction<DatabaseSchema>'.\n            The types returned by 'connection()' are incompatible between these types.\n              Type 'ConnectionBuilder<Database>' is not assignable to type 'ConnectionBuilder<DatabaseSchema>'.\n                Type 'DatabaseSchema' is not assignable to type 'Database'.",
	"source": "ts",
	"startLineNumber": 50,
	"startColumn": 26,
	"endLineNumber": 50,
	"endColumn": 28
},{
	"resource": "/home/achals/repos/ta/src/server/auth.ts",
	"owner": "eslint",
	"code": {
		"value": "@typescript-eslint/no-unused-vars",
		"target": {
			"$mid": 1,
			"path": "/rules/no-unused-vars",
			"scheme": "https",
			"authority": "typescript-eslint.io"
		}
	},
	"severity": 4,
	"message": "'DrizzleAdapter' is defined but never used.",
	"source": "eslint",
	"startLineNumber": 1,
	"startColumn": 10,
	"endLineNumber": 1,
	"endColumn": 24
},{
	"resource": "/home/achals/repos/ta/src/server/auth.ts",
	"owner": "eslint",
	"code": {
		"value": "@typescript-eslint/no-unused-vars",
		"target": {
			"$mid": 1,
			"path": "/rules/no-unused-vars",
			"scheme": "https",
			"authority": "typescript-eslint.io"
		}
	},
	"severity": 4,
	"message": "'Adapter' is defined but never used.",
	"source": "eslint",
	"startLineNumber": 8,
	"startColumn": 15,
	"endLineNumber": 8,
	"endColumn": 22
}]

AchalS-iglu avatar Mar 31 '24 16:03 AchalS-iglu

Are you using some kind of code gen? Then this snippet might help.

import type { Codegen } from "@auth/kysely-adapter"
new KyselyAuth<Database, Codegen>(...)

Found in the docs here: https://authjs.dev/reference/adapter/kysely#kyselyauthdb-t

Aryan3212 avatar Apr 07 '24 07:04 Aryan3212

same error:

 The types of 'User.id' are incompatible between these types.
    Type 'GeneratedAlways<string>' is not assignable to type 'string'.ts(2344)

I follow all the instructions in the guide, but still always gives error, even with CodeGen, try with different package manager, npm, pnpm and bun.

ga1az avatar Jul 08 '24 19:07 ga1az

/* eslint-disable @typescript-eslint/unbound-method */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Generated, GeneratedAlways, SqliteAdapter } from "kysely";
import { Kysely } from "kysely";
import { DB } from "kysely-codegen";
import { Adapter } from "next-auth/adapters";

const isoDateRE =
  /(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))/;
function isDate(value: any) {
  return value && isoDateRE.test(value) && !isNaN(Date.parse(value));
}

const format = {
  from<T>(object?: Record<string, any>): T {
    const newObject: Record<string, unknown> = {};
    for (const key in object) {
      const value = object[key];
      if (isDate(value)) newObject[key] = new Date(value);
      else newObject[key] = value;
    }
    return newObject as T;
  },
  to<T>(object: Record<string, any>): T {
    const newObject: Record<string, unknown> = {};
    for (const [key, value] of Object.entries(object))
      newObject[key] = value instanceof Date ? value.toISOString() : value;
    return newObject as T;
  },
};

export default function KyselyAdapter(db: Kysely<DB>): Adapter {
  const { adapter } = db.getExecutor();
  const { supportsReturning } = adapter;
  const isSqlite = adapter instanceof SqliteAdapter;
  /** If the database is SQLite, turn dates into an ISO string  */
  const to = isSqlite ? format.to : <T>(x: T) => x as T;
  /** If the database is SQLite, turn ISO strings into dates */
  const from = isSqlite ? format.from : <T>(x: T) => x as T;
  return {
    async createUser(data) {
      const user = { ...data, id: crypto.randomUUID() };
      await db
        .insertInto("User")
        .values(
          to({
            ...user,
            createdAt: new Date(),
            updatedAt: new Date(),
          }),
        )
        .executeTakeFirstOrThrow();
      return user;
    },
    async getUser(id) {
      const result = await db
        .selectFrom("User")
        .selectAll()
        .where("id", "=", id)
        .executeTakeFirst();
      if (!result) return null;
      return from(result);
    },
    async getUserByEmail(email) {
      const result = await db
        .selectFrom("User")
        .selectAll()
        .where("email", "=", email)
        .executeTakeFirst();
      if (!result) return null;
      return from(result);
    },
    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();
      if (!result) return null;
      return from(result);
    },
    async updateUser({ id, ...user }) {
      const userData = to(user);
      const query = db.updateTable("User").set(userData).where("id", "=", id);
      const result = supportsReturning
        ? query.returningAll().executeTakeFirstOrThrow()
        : query
            .executeTakeFirstOrThrow()
            .then(() =>
              db
                .selectFrom("User")
                .selectAll()
                .where("id", "=", id)
                .executeTakeFirstOrThrow(),
            );
      return from(await result);
    },
    async deleteUser(userId) {
      await db
        .deleteFrom("User")
        .where("User.id", "=", userId)
        .executeTakeFirst();
    },
    async linkAccount(account) {
      await db
        .insertInto("Account")
        .values(
          to({
            ...account,
            createdAt: new Date(),
            updatedAt: new Date(),
          }),
        )
        .executeTakeFirstOrThrow();
      return account;
    },
    async unlinkAccount({ providerAccountId, provider }) {
      await db
        .deleteFrom("Account")
        .where("Account.providerAccountId", "=", providerAccountId)
        .where("Account.provider", "=", provider)
        .executeTakeFirstOrThrow();
    },
    async createSession(session) {
      await db
        .insertInto("Session")
        .values(
          to({
            ...session,
            createdAt: new Date(),
            updatedAt: new Date(),
          }),
        )
        .execute();
      return session;
    },
    async getSessionAndUser(sessionToken) {
      const result = await db
        .selectFrom("Session")
        .innerJoin("User", "User.id", "Session.userId")
        .selectAll("User")
        .select(["Session.expires", "Session.userId"])
        .where("Session.sessionToken", "=", sessionToken)
        .executeTakeFirst();
      if (!result) return null;
      const { userId, expires, ...user } = result;
      const session = { sessionToken, userId, expires };
      return { user: from(user), session: from(session) };
    },
    async updateSession(session) {
      const sessionData = to(session);
      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 from(result);
    },
    async deleteSession(sessionToken) {
      await db
        .deleteFrom("Session")
        .where("Session.sessionToken", "=", sessionToken)
        .executeTakeFirstOrThrow();
    },
    async createVerificationToken(data) {
      await db.insertInto("VerificationToken").values(to(data)).execute();
      return data;
    },
    async useVerificationToken({ identifier, token }) {
      const query = db
        .deleteFrom("VerificationToken")
        .where("VerificationToken.token", "=", token)
        .where("VerificationToken.identifier", "=", identifier);

      const result = supportsReturning
        ? await query.returningAll().executeTakeFirst()
        : await db
            .selectFrom("VerificationToken")
            .selectAll()
            .where("token", "=", token)
            .executeTakeFirst()
            .then(async (res) => {
              await query.executeTakeFirst();
              return res;
            });
      if (!result) return null;
      return from(result);
    },
  };
}

I used custom adapter with custom schema, hope it helps

AchalS-iglu avatar Jul 08 '24 20:07 AchalS-iglu

I also encountered this problem and solved it by modifying the types from the documentation.

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";
import type { AdapterAccountType } from "@auth/core/adapters";

interface Database {
  User: {
    id: string;
    name: string | null;
    email: string;
    emailVerified: Date | null;
    image: string | null;
    password: string | null;
  };
  Account: {
    id: GeneratedAlways<string>;
    userId: string;
    type: AdapterAccountType;
    provider: string;
    providerAccountId: string;
    refresh_token?: string;
    access_token?: string;
    expires_at?: number;
    token_type?: Lowercase<string>;
    scope?: string;
    id_token?: string;
    session_state: string | null;
  };
  Session: {
    id: GeneratedAlways<string>;
    userId: string;
    sessionToken: string;
    expires: Date;
  };
  VerificationToken: {
    identifier: string;
    token: string;
    expires: Date;
  };
}
...

Anyway, I see 2 problems there.

The first is the modification of the type for User.id here: https://github.com/nextauthjs/next-auth/blob/a7491dcb9355ff2d01fb8e9236636605e2090145/packages/core/src/adapters.ts#L178

And edit the documentation for Account https://github.com/nextauthjs/next-auth/blob/a7491dcb9355ff2d01fb8e9236636605e2090145/docs/pages/getting-started/adapters/kysely.mdx?plain=1#L119

TomKalina avatar Oct 18 '24 07:10 TomKalina

I'm using kysely-codegen; it automatically exports your DB interface, so, if you're using it, i'd recommend instead re-implementing the custom adapter as suggested by AchalS-iglu in https://github.com/nextauthjs/next-auth/issues/10441#issuecomment-2215188319 because

  1. kysely-codegen doesn't let you override the interface key names as of v0.17.0)
  2. the adapter is fairly easy to rewrite (it's just one file that you copy its contents from). You won't need to use KyselyAuth or the Codegen type anymore, just the kysely docs itself.
  3. Finally, you may need to extend the adapter anyways in the future. It's good to control the auth and have that flexibility

Andrew-Chen-Wang avatar Nov 04 '24 18:11 Andrew-Chen-Wang

I also encountered this problem and solved it by modifying the types from the documentation.

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"; import type { AdapterAccountType } from "@auth/core/adapters";

interface Database { User: { id: string; name: string | null; email: string; emailVerified: Date | null; image: string | null; password: string | null; }; Account: { id: GeneratedAlways; userId: string; type: AdapterAccountType; provider: string; providerAccountId: string; refresh_token?: string; access_token?: string; expires_at?: number; token_type?: Lowercase; scope?: string; id_token?: string; session_state: string | null; }; Session: { id: GeneratedAlways; userId: string; sessionToken: string; expires: Date; }; VerificationToken: { identifier: string; token: string; expires: Date; }; } ... Anyway, I see 2 problems there.

The first is the modification of the type for User.id here:

next-auth/packages/core/src/adapters.ts

Line 178 in a7491dc

id: string And edit the documentation for Account

next-auth/docs/pages/getting-started/adapters/kysely.mdx

Line 119 in a7491dc

interface Database {

If you change GeneratedAlways<string> to string, you will get issues down the line when for example trying to insert a user

        await UserRepository.createUser({
            username: 'Jennifer',
            email: "[email protected]",
        })

It will tell you the id is missing

notflip avatar Mar 15 '25 15:03 notflip

I keep changing User.id to string, then Account.type to AdapterAccountType, now some string is not compatible with Lowercase<string>... Terrible experience with all these hacky things for someone who decided to try out Auth.js. Basic example from docs is not working. Seriously thinking about reporting the whole Auth.js as "not worth it".

milos201 avatar Jul 19 '25 21:07 milos201