From 5a701c71c700443867ef7b1600e539c190e994f0 Mon Sep 17 00:00:00 2001
From: kaposke
Date: Mon, 25 Aug 2025 00:30:07 -0300
Subject: [PATCH] feat(adapters): add `postgresjs` adapter
---
.github/ISSUE_TEMPLATE/3_bug_adapter.yml | 1 +
.github/workflows/release.yml | 1 +
docs/pages/data/manifest.json | 1 +
docs/pages/getting-started/adapters/_meta.js | 3 +-
docs/public/img/adapters/postgresjs.svg | 17 ++
packages/adapter-postgresjs/README.md | 28 ++
packages/adapter-postgresjs/package.json | 51 ++++
packages/adapter-postgresjs/schema.sql | 49 ++++
packages/adapter-postgresjs/src/index.ts | 272 ++++++++++++++++++
packages/adapter-postgresjs/src/types.ts | 99 +++++++
.../adapter-postgresjs/test/index.test.ts | 62 ++++
packages/adapter-postgresjs/test/test.sh | 22 ++
packages/adapter-postgresjs/tsconfig.json | 9 +
.../adapter-postgresjs/typedoc.config.cjs | 14 +
pnpm-lock.yaml | 10 +
turbo.json | 1 +
16 files changed, 639 insertions(+), 1 deletion(-)
create mode 100644 docs/public/img/adapters/postgresjs.svg
create mode 100644 packages/adapter-postgresjs/README.md
create mode 100644 packages/adapter-postgresjs/package.json
create mode 100644 packages/adapter-postgresjs/schema.sql
create mode 100644 packages/adapter-postgresjs/src/index.ts
create mode 100644 packages/adapter-postgresjs/src/types.ts
create mode 100644 packages/adapter-postgresjs/test/index.test.ts
create mode 100755 packages/adapter-postgresjs/test/test.sh
create mode 100644 packages/adapter-postgresjs/tsconfig.json
create mode 100644 packages/adapter-postgresjs/typedoc.config.cjs
diff --git a/.github/ISSUE_TEMPLATE/3_bug_adapter.yml b/.github/ISSUE_TEMPLATE/3_bug_adapter.yml
index 46696d17c2..fb18ee22b1 100644
--- a/.github/ISSUE_TEMPLATE/3_bug_adapter.yml
+++ b/.github/ISSUE_TEMPLATE/3_bug_adapter.yml
@@ -35,6 +35,7 @@ body:
- "@auth/mongodb-adapter"
- "@auth/neo4j-adapter"
- "@auth/pg-adapter"
+ - "@auth/postgresjs-adapter"
- "@auth/pouchdb-adapter"
- "@auth/prisma-adapter"
- "@auth/sequelize-adapter"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1ee78afa19..34728aea7f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -36,6 +36,7 @@ on:
- "@auth/mongodb-adapter"
- "@auth/neo4j-adapter"
- "@auth/pg-adapter"
+ - "@auth/postgresjs-adapter"
- "@auth/pouchdb-adapter"
- "@auth/prisma-adapter"
- "@auth/sequelize-adapter"
diff --git a/docs/pages/data/manifest.json b/docs/pages/data/manifest.json
index 7fef03e029..48ff1ea209 100644
--- a/docs/pages/data/manifest.json
+++ b/docs/pages/data/manifest.json
@@ -36,6 +36,7 @@
"mongodb": "MongoDB",
"neo4j": "Neo4j",
"pg": "pg",
+ "postgresjs": "postgresjs",
"pouchdb": "PouchDB",
"sequelize": "Sequelize",
"surrealdb": "SurrealDB",
diff --git a/docs/pages/getting-started/adapters/_meta.js b/docs/pages/getting-started/adapters/_meta.js
index 153672f783..6dd6a56604 100644
--- a/docs/pages/getting-started/adapters/_meta.js
+++ b/docs/pages/getting-started/adapters/_meta.js
@@ -13,7 +13,8 @@ export default {
mongodb: "MongoDB",
neo4j: "Neo4j",
neon: "Neon",
- pg: "PostgreSQL",
+ pg: "PostgreSQL (pg)",
+ postgresjs: "Postgres.js",
pouchdb: "PouchDB",
prisma: "Prisma",
sequelize: "Sequelize",
diff --git a/docs/public/img/adapters/postgresjs.svg b/docs/public/img/adapters/postgresjs.svg
new file mode 100644
index 0000000000..5e1fd0210d
--- /dev/null
+++ b/docs/public/img/adapters/postgresjs.svg
@@ -0,0 +1,17 @@
+
diff --git a/packages/adapter-postgresjs/README.md b/packages/adapter-postgresjs/README.md
new file mode 100644
index 0000000000..abf543d3ad
--- /dev/null
+++ b/packages/adapter-postgresjs/README.md
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
Postgres.js Adapter - NextAuth.js / Auth.js
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+---
+
+Check out the documentation at [authjs.dev](https://authjs.dev/reference/adapter/postgresjs).
diff --git a/packages/adapter-postgresjs/package.json b/packages/adapter-postgresjs/package.json
new file mode 100644
index 0000000000..5ede85a6fc
--- /dev/null
+++ b/packages/adapter-postgresjs/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "@auth/postgresjs-adapter",
+ "version": "1.10.0",
+ "description": "Postgres.js adapter for next-auth.",
+ "homepage": "https://authjs.dev",
+ "repository": "https://github.com/nextauthjs/next-auth",
+ "bugs": {
+ "url": "https://github.com/nextauthjs/next-auth/issues"
+ },
+ "author": "Guilherme Bassa",
+ "license": "ISC",
+ "keywords": [
+ "next-auth",
+ "@auth",
+ "Auth.js",
+ "next.js",
+ "oauth",
+ "postgres",
+ "postgresjs"
+ ],
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./index.d.ts",
+ "import": "./index.js"
+ }
+ },
+ "files": [
+ "*.d.ts*",
+ "*.js",
+ "src"
+ ],
+ "private": false,
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "test": "./test/test.sh",
+ "build": "tsc",
+ "clean": "rm -rf *.js *.d.ts*"
+ },
+ "dependencies": {
+ "@auth/core": "workspace:*"
+ },
+ "devDependencies": {
+ "postgres": "^3.4.3"
+ },
+ "peerDependencies": {
+ "postgres": "^3"
+ }
+}
diff --git a/packages/adapter-postgresjs/schema.sql b/packages/adapter-postgresjs/schema.sql
new file mode 100644
index 0000000000..3c3544c03e
--- /dev/null
+++ b/packages/adapter-postgresjs/schema.sql
@@ -0,0 +1,49 @@
+\set ON_ERROR_STOP true
+
+CREATE TABLE verification_tokens
+(
+ identifier TEXT NOT NULL,
+ expires TIMESTAMPTZ NOT NULL,
+ token TEXT NOT NULL,
+
+ PRIMARY KEY (identifier, token)
+);
+
+CREATE TABLE accounts
+(
+ id UUID DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL,
+ type VARCHAR(255) NOT NULL,
+ provider VARCHAR(255) NOT NULL,
+ provider_account_id VARCHAR(255) NOT NULL,
+ refresh_token TEXT,
+ access_token TEXT,
+ expires_at BIGINT,
+ id_token TEXT,
+ scope TEXT,
+ session_state TEXT,
+ token_type TEXT,
+
+ PRIMARY KEY (id)
+);
+
+CREATE TABLE sessions
+(
+ id UUID DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL,
+ expires TIMESTAMPTZ NOT NULL,
+ session_token VARCHAR(255) NOT NULL,
+
+ PRIMARY KEY (id)
+);
+
+CREATE TABLE users
+(
+ id UUID DEFAULT gen_random_uuid(),
+ name VARCHAR(255),
+ email VARCHAR(255) NOT NULL,
+ email_verified TIMESTAMPTZ,
+ image TEXT,
+
+ PRIMARY KEY (id)
+);
diff --git a/packages/adapter-postgresjs/src/index.ts b/packages/adapter-postgresjs/src/index.ts
new file mode 100644
index 0000000000..17fed94fd1
--- /dev/null
+++ b/packages/adapter-postgresjs/src/index.ts
@@ -0,0 +1,272 @@
+/**
+ *
+ *
+ * ## Installation
+ *
+ * ```bash npm2yarn
+ * npm install next-auth @auth/postgresjs-adapter postgres
+ * ```
+ *
+ * @module @auth/postgresjs-adapter
+ */
+
+import type {
+ Adapter,
+ AdapterUser,
+ VerificationToken,
+ AdapterSession,
+ AdapterAccount,
+} from "@auth/core/adapters"
+import postgresjs from "postgres"
+import {
+ DBAccount,
+ DBSession,
+ DBUser,
+ DBVerificationToken,
+ fromPartialAdapterUser,
+ toAdapterAccount,
+ toAdapterSession,
+ toAdapterUser,
+ toAdapterVerificationToken,
+} from "./types.js"
+
+function buildPartialSetStatement(partial: object, sql: postgresjs.Sql) {
+ const fieldsToSet = Object.entries(partial).filter(
+ ([key, value]) => value !== undefined && key !== "id"
+ )
+
+ return sql(Object.fromEntries(fieldsToSet))
+}
+
+export default function PostgresjsAdapter(sql: postgresjs.Sql): Adapter {
+ return {
+ async createVerificationToken(
+ verificationToken: VerificationToken
+ ): Promise {
+ const { identifier, expires, token } = verificationToken
+
+ await sql`
+ INSERT INTO verification_tokens ( identifier, expires, token )
+ VALUES (${identifier}, ${expires}, ${token})
+ RETURNING identifier, expires, token;
+ `
+
+ return verificationToken
+ },
+
+ async useVerificationToken({
+ identifier,
+ token,
+ }: {
+ identifier: string
+ token: string
+ }) {
+ const [verificationToken] = await sql`
+ DELETE FROM verification_tokens
+ WHERE identifier = ${identifier} AND token = ${token}
+ RETURNING identifier, expires, token;
+ `
+
+ return verificationToken
+ ? toAdapterVerificationToken(verificationToken)
+ : null
+ },
+
+ async createUser(user: Omit) {
+ const { name, email, emailVerified, image } = user
+
+ const [newUser] = await sql`
+ INSERT INTO users (name, email, email_verified, image)
+ VALUES (${name ?? null}, ${email}, ${emailVerified}, ${image ?? null})
+ RETURNING id, name, email, email_verified, image`
+
+ return toAdapterUser(newUser)
+ },
+
+ async getUser(id) {
+ const [dbUser] = await sql<
+ DBUser[]
+ >`SELECT * FROM users WHERE id = ${id} LIMIT 1`
+
+ return dbUser ? toAdapterUser(dbUser) : null
+ },
+
+ async getUserByEmail(email) {
+ const [dbUser] = await sql`
+ SELECT * FROM users WHERE email = ${email} LIMIT 1
+ `
+
+ return dbUser ? toAdapterUser(dbUser) : null
+ },
+
+ async getUserByAccount({ providerAccountId, provider }) {
+ const [dbUser] = await sql`
+ SELECT u.*
+ FROM users u
+ JOIN accounts a ON u.id = a.user_id
+ WHERE a.provider = ${provider}
+ AND a.provider_account_id = ${providerAccountId}
+ LIMIT 1`
+
+ return dbUser ? toAdapterUser(dbUser) : null
+ },
+
+ async updateUser(user: Partial & Pick) {
+ const { id } = user
+
+ if (id === undefined) {
+ throw new Error("No user id.")
+ }
+
+ const setStatement = buildPartialSetStatement(
+ fromPartialAdapterUser(user),
+ sql
+ )
+
+ const [updatedUser] = await sql`
+ UPDATE users
+ SET ${setStatement}
+ WHERE id = ${id}
+ RETURNING name, id, email, email_verified, image
+ `
+
+ return toAdapterUser(updatedUser)
+ },
+
+ async linkAccount(account: AdapterAccount) {
+ const [newAccount] = await sql`
+ INSERT INTO accounts
+ (
+ user_id,
+ provider,
+ type,
+ provider_account_id,
+ access_token,
+ expires_at,
+ refresh_token,
+ id_token,
+ scope,
+ session_state,
+ token_type
+ )
+ VALUES (
+ ${account.userId},
+ ${account.provider},
+ ${account.type},
+ ${account.providerAccountId},
+ ${account.access_token ?? null},
+ ${account.expires_at ?? null},
+ ${account.refresh_token ?? null},
+ ${account.id_token ?? null},
+ ${account.scope ?? null},
+ ${account.session_state?.toString() ?? null},
+ ${account.token_type ?? null}
+ )
+ RETURNING
+ *
+ `
+
+ return toAdapterAccount(newAccount)
+ },
+
+ async createSession({ sessionToken, userId, expires }) {
+ const [session] = await sql<
+ DBSession[]
+ >`INSERT INTO sessions (user_id, expires, session_token)
+ VALUES (${userId}, ${expires}, ${sessionToken})
+ RETURNING id, session_token, user_id, expires`
+
+ return toAdapterSession(session)
+ },
+
+ async getSessionAndUser(sessionToken: string | undefined): Promise<{
+ session: AdapterSession
+ user: AdapterUser
+ } | null> {
+ if (sessionToken === undefined) {
+ return null
+ }
+ const [session] = await sql<
+ DBSession[]
+ >`SELECT * FROM sessions WHERE session_token = ${sessionToken} LIMIT 1`
+
+ if (!session) {
+ return null
+ }
+
+ const [user] = await sql<
+ DBUser[]
+ >`SELECT * FROM users WHERE id = ${session.user_id} LIMIT 1`
+
+ if (!user) {
+ return null
+ }
+
+ return {
+ session: toAdapterSession(session),
+ user: toAdapterUser(user),
+ }
+ },
+
+ async updateSession(
+ session: Partial & Pick
+ ): Promise {
+ const { sessionToken, expires } = session
+
+ if (!expires) {
+ const [session] = await sql`
+ SELECT * FROM sessions WHERE session_token = ${sessionToken} LIMIT 1
+ `
+ return session ? toAdapterSession(session) : null
+ }
+
+ const [updatedSession] = await sql`
+ UPDATE sessions
+ SET expires = ${expires}
+ WHERE session_token = ${sessionToken}
+ `
+ return updatedSession ? toAdapterSession(updatedSession) : null
+ },
+
+ async deleteSession(sessionToken) {
+ await sql`
+ DELETE FROM sessions
+ WHERE session_token = ${sessionToken}
+ RETURNING *
+ `
+ },
+
+ async unlinkAccount(partialAccount) {
+ const { provider, providerAccountId } = partialAccount
+ await sql`
+ DELETE FROM accounts
+ WHERE provider_account_id = ${providerAccountId}
+ AND provider = ${provider}
+ RETURNING *
+ `
+ },
+
+ async deleteUser(userId: string) {
+ await sql`
+ DELETE FROM users
+ WHERE id = ${userId}
+ RETURNING *
+ `
+ await sql`
+ DELETE FROM sessions
+ WHERE user_id = ${userId}
+ RETURNING *
+ `
+ await sql`
+ DELETE FROM accounts
+ WHERE user_id = ${userId}
+ RETURNING *
+ `
+ },
+ }
+}
diff --git a/packages/adapter-postgresjs/src/types.ts b/packages/adapter-postgresjs/src/types.ts
new file mode 100644
index 0000000000..ff0b8e857f
--- /dev/null
+++ b/packages/adapter-postgresjs/src/types.ts
@@ -0,0 +1,99 @@
+import {
+ AdapterAccount,
+ AdapterAccountType,
+ AdapterSession,
+ AdapterUser,
+ VerificationToken,
+} from "@auth/core/adapters"
+
+export type DBUser = {
+ id: string
+ name: string | null
+ email: string
+ email_verified: Date | null
+ image: string | null
+}
+
+export function fromPartialAdapterUser(
+ user: Partial
+): Partial {
+ return {
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ email_verified: user.emailVerified,
+ image: user.image,
+ }
+}
+
+export function toAdapterUser(user: DBUser): AdapterUser {
+ return {
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ emailVerified: user.email_verified,
+ image: user.image,
+ }
+}
+
+export type DBAccount = {
+ id: string
+ user_id: string
+ type: string
+ provider: string
+ provider_account_id: string
+ refresh_token: string | null
+ access_token: string | null
+ expires_at: string | null
+ id_token: string | null
+ scope: string | null
+ session_state: string | null
+ token_type: string | null
+}
+
+export function toAdapterAccount(account: DBAccount): AdapterAccount {
+ return {
+ id: account.id,
+ userId: account.user_id,
+ type: account.type as AdapterAccountType,
+ provider: account.provider,
+ providerAccountId: account.provider_account_id,
+ access_token: account.access_token ?? undefined,
+ expires_at: account.expires_at ? parseInt(account.expires_at) : undefined,
+ refresh_token: account.refresh_token ?? undefined,
+ id_token: account.id_token ?? undefined,
+ scope: account.scope ?? undefined,
+ session_state: account.session_state ?? undefined,
+ token_type:
+ (account.token_type?.toLowerCase() as Lowercase) ?? undefined,
+ }
+}
+
+export type DBSession = {
+ id: string
+ user_id: string
+ expires: Date
+ session_token: string
+}
+
+export function toAdapterSession(session: DBSession): AdapterSession {
+ return {
+ userId: session.user_id,
+ expires: session.expires,
+ sessionToken: session.session_token,
+ }
+}
+
+export type DBVerificationToken = {
+ id: string
+ identifier: string
+ expires: Date
+ token: string
+}
+
+export function toAdapterVerificationToken(
+ verificationToken: DBVerificationToken
+): VerificationToken {
+ const { id, ...rest } = verificationToken
+ return rest
+}
diff --git a/packages/adapter-postgresjs/test/index.test.ts b/packages/adapter-postgresjs/test/index.test.ts
new file mode 100644
index 0000000000..3964b43272
--- /dev/null
+++ b/packages/adapter-postgresjs/test/index.test.ts
@@ -0,0 +1,62 @@
+import { runBasicTests } from "utils/adapter"
+import postgresjs from "postgres"
+import PostgresjsAdapter from "../src"
+import {
+ DBAccount,
+ DBUser,
+ toAdapterAccount,
+ toAdapterUser,
+ toAdapterVerificationToken,
+} from "../src/types"
+import { DBSession } from "../src/types"
+import { toAdapterSession } from "../src/types"
+import { DBVerificationToken } from "../src/types"
+
+const POOL_SIZE = 20
+
+const sql = postgresjs({
+ host: "127.0.0.1",
+ port: 5432,
+ database: "adapter-postgresjs-test",
+ user: "postgresjs",
+ password: "postgresjs",
+ max: POOL_SIZE,
+})
+
+runBasicTests({
+ adapter: PostgresjsAdapter(sql),
+ db: {
+ disconnect: async () => {
+ await sql.end()
+ },
+ user: async (id: string) => {
+ const [user] = await sql<
+ DBUser[]
+ >`SELECT * FROM users WHERE id = ${id} LIMIT 1`
+ return user ? toAdapterUser(user) : null
+ },
+ account: async ({ providerAccountId }) => {
+ const [account] = await sql<
+ DBAccount[]
+ >`SELECT * FROM accounts WHERE provider_account_id = ${providerAccountId} LIMIT 1`
+
+ return account ? toAdapterAccount(account) : null
+ },
+ session: async (sessionToken) => {
+ const [session] = await sql<
+ DBSession[]
+ >`SELECT * FROM sessions WHERE session_token = ${sessionToken} LIMIT 1`
+ return session ? toAdapterSession(session) : null
+ },
+ async verificationToken(identifier_token) {
+ const { identifier, token } = identifier_token
+ const [verificationToken] = await sql<
+ DBVerificationToken[]
+ >`SELECT * FROM verification_tokens WHERE identifier = ${identifier} AND token = ${token} LIMIT 1`
+
+ return verificationToken
+ ? toAdapterVerificationToken(verificationToken)
+ : null
+ },
+ },
+})
diff --git a/packages/adapter-postgresjs/test/test.sh b/packages/adapter-postgresjs/test/test.sh
new file mode 100755
index 0000000000..279fbd7acd
--- /dev/null
+++ b/packages/adapter-postgresjs/test/test.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+
+CONTAINER_NAME=authjs-postgresjs-test
+
+docker run -d --rm \
+ --name ${CONTAINER_NAME} \
+ -e POSTGRES_DB=adapter-postgresjs-test \
+ -e POSTGRES_USER=postgresjs \
+ -e POSTGRES_PASSWORD=postgresjs \
+ -p 5432:5432 \
+ -v "$(pwd)"/schema.sql:/docker-entrypoint-initdb.d/schema.sql \
+ postgres:latest
+
+echo "waiting 5s for db to start..."
+sleep 5
+
+# Always stop container, but exit with 1 when tests are failing
+if vitest run -c ../utils/vitest.config.ts; then
+ docker stop ${CONTAINER_NAME}
+else
+ docker stop ${CONTAINER_NAME} && exit 1
+fi
diff --git a/packages/adapter-postgresjs/tsconfig.json b/packages/adapter-postgresjs/tsconfig.json
new file mode 100644
index 0000000000..6f3b51d36f
--- /dev/null
+++ b/packages/adapter-postgresjs/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../utils/tsconfig.json",
+ "compilerOptions": {
+ "outDir": ".",
+ "rootDir": "src"
+ },
+ "exclude": ["*.js", "*.d.ts"],
+ "include": ["src/**/*"]
+}
diff --git a/packages/adapter-postgresjs/typedoc.config.cjs b/packages/adapter-postgresjs/typedoc.config.cjs
new file mode 100644
index 0000000000..1e259dae28
--- /dev/null
+++ b/packages/adapter-postgresjs/typedoc.config.cjs
@@ -0,0 +1,14 @@
+// @ts-check
+
+/**
+ * @type {import('typedoc').TypeDocOptions & import('typedoc-plugin-markdown').MarkdownTheme}
+ */
+module.exports = {
+ entryPoints: ["src/index.ts"],
+ entryPointStrategy: "expand",
+ tsconfig: "./tsconfig.json",
+ entryModule: "@auth/postgresjs-adapter",
+ entryFileName: "../postgresjs-adapter.mdx",
+ includeVersion: true,
+ readme: "none",
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f6fe6e4264..02516c5947 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -540,6 +540,16 @@ importers:
specifier: ^8.7.1
version: 8.11.3
+ packages/adapter-postgresjs:
+ dependencies:
+ '@auth/core':
+ specifier: workspace:*
+ version: link:../core
+ devDependencies:
+ postgres:
+ specifier: ^3.4.3
+ version: 3.4.3
+
packages/adapter-pouchdb:
dependencies:
'@auth/core':
diff --git a/turbo.json b/turbo.json
index 240427e652..65d944011b 100644
--- a/turbo.json
+++ b/turbo.json
@@ -107,6 +107,7 @@
"@auth/mongodb-adapter#build",
"@auth/neo4j-adapter#build",
"@auth/pg-adapter#build",
+ "@auth/postgresjs-adapter#build",
"@auth/pouchdb-adapter#build",
"@auth/prisma-adapter#build",
"@auth/sequelize-adapter#build",