feat(db): delete hooks (#4792)

Co-authored-by: Alex Yang <himself65@outlook.com>
This commit is contained in:
KinfeMichael Tariku
2025-09-24 01:21:51 +03:00
committed by GitHub
parent 7eefc93c59
commit eb76a7fc68
6 changed files with 400 additions and 25 deletions

View File

@@ -501,7 +501,7 @@ There are two types of hooks you can define:
#### 1. Before Hook
- **Purpose**: This hook is called before the respective entity (user, session, or account) is created or updated.
- **Purpose**: This hook is called before the respective entity (user, session, or account) is created, updated, or deleted.
- **Behavior**: If the hook returns `false`, the operation will be aborted. And If it returns a data object, it'll replace the original payload.
#### 2. After Hook
@@ -532,6 +532,33 @@ export const auth = betterAuth({
//perform additional actions, like creating a stripe customer
},
},
delete: {
before: async (user, ctx) => {
console.log(`User ${user.email} is being deleted`);
if (user.email.includes("admin")) {
return false; // Abort deletion
}
return true; // Allow deletion
},
after: async (user) => {
console.log(`User ${user.email} has been deleted`);
},
},
},
session: {
delete: {
before: async (session, ctx) => {
console.log(`Session ${session.token} is being deleted`);
if (session.userId === "admin-user-id") {
return false; // Abort deletion
}
return true; // Allow deletion
},
after: async (session) => {
console.log(`Session ${session.token} has been deleted`);
},
},
},
},
});

View File

@@ -519,8 +519,8 @@ export const deleteUser = createAuthEndpoint(
if (beforeDelete) {
await beforeDelete(session.user, ctx.request);
}
await ctx.context.internalAdapter.deleteUser(session.user.id);
await ctx.context.internalAdapter.deleteSessions(session.user.id);
await ctx.context.internalAdapter.deleteUser(session.user.id, ctx);
await ctx.context.internalAdapter.deleteSessions(session.user.id, ctx);
await ctx.context.internalAdapter.deleteAccounts(session.user.id);
deleteSessionCookie(ctx);
const afterDelete = ctx.context.options.user.deleteUser?.afterDelete;
@@ -607,10 +607,10 @@ export const deleteUserCallback = createAuthEndpoint(
if (beforeDelete) {
await beforeDelete(session.user, ctx.request);
}
await ctx.context.internalAdapter.deleteUser(session.user.id);
await ctx.context.internalAdapter.deleteSessions(session.user.id);
await ctx.context.internalAdapter.deleteUser(session.user.id, ctx);
await ctx.context.internalAdapter.deleteSessions(session.user.id, ctx);
await ctx.context.internalAdapter.deleteAccounts(session.user.id);
await ctx.context.internalAdapter.deleteVerificationValue(token.id);
await ctx.context.internalAdapter.deleteVerificationValue(token.id, ctx);
deleteSessionCookie(ctx);

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { getTestInstance } from "../test-utils/test-instance";
describe("db", async () => {
@@ -96,4 +96,147 @@ describe("db", async () => {
});
expect(session?.user.email).toBe("test@email.com");
});
it("delete hooks", async () => {
const hookUserDeleteBefore = vi.fn();
const hookUserDeleteAfter = vi.fn();
const hookSessionDeleteBefore = vi.fn();
const hookSessionDeleteAfter = vi.fn();
const { client } = await getTestInstance({
session: {
storeSessionInDatabase: true,
},
user: {
deleteUser: {
enabled: true,
},
},
databaseHooks: {
user: {
delete: {
async before(user, context) {
hookUserDeleteBefore(user, context);
return true;
},
async after(user, context) {
hookUserDeleteAfter(user, context);
},
},
},
session: {
delete: {
async before(session, context) {
hookSessionDeleteBefore(session, context);
return true;
},
async after(session, context) {
hookSessionDeleteAfter(session, context);
},
},
},
},
});
const res = await client.signUp.email({
email: "delete-test@email.com",
password: "password",
name: "Delete Test User",
});
expect(res.data).toBeDefined();
const userId = res.data?.user.id;
await client.deleteUser({
fetchOptions: {
headers: {
Authorization: `Bearer ${res.data?.token}`,
},
throw: true,
},
});
expect(hookUserDeleteBefore).toHaveBeenCalledOnce();
expect(hookUserDeleteAfter).toHaveBeenCalledOnce();
expect(hookSessionDeleteBefore).toHaveBeenCalledOnce();
expect(hookSessionDeleteAfter).toHaveBeenCalledOnce();
expect(hookUserDeleteBefore).toHaveBeenCalledWith(
expect.objectContaining({
id: userId,
email: "delete-test@email.com",
name: "Delete Test User",
}),
expect.any(Object),
);
expect(hookUserDeleteAfter).toHaveBeenCalledWith(
expect.objectContaining({
id: userId,
email: "delete-test@email.com",
name: "Delete Test User",
}),
expect.any(Object),
);
});
it("delete hooks abort", async () => {
const hookUserDeleteBefore = vi.fn();
const hookUserDeleteAfter = vi.fn();
const { client } = await getTestInstance({
user: {
deleteUser: {
enabled: true,
},
},
databaseHooks: {
user: {
delete: {
async before(user, context) {
hookUserDeleteBefore(user, context);
return false;
},
async after(user, context) {
hookUserDeleteAfter(user, context);
},
},
},
},
});
const res = await client.signUp.email({
email: "abort-delete-test@email.com",
password: "password",
name: "Abort Delete Test User",
});
expect(res.data).toBeDefined();
const userId = res.data?.user.id;
try {
await client.deleteUser({
fetchOptions: {
headers: {
Authorization: `Bearer ${res.data?.token}`,
},
throw: true,
},
});
} catch (error) {
// Expected to fail due to hook returning false
}
expect(hookUserDeleteBefore).toHaveBeenCalledOnce();
expect(hookUserDeleteAfter).not.toHaveBeenCalled();
expect(hookUserDeleteBefore).toHaveBeenCalledWith(
expect.objectContaining({
id: userId,
email: "abort-delete-test@email.com",
name: "Abort Delete Test User",
}),
expect.any(Object),
);
});
});

View File

@@ -32,8 +32,13 @@ export const createInternalAdapter = (
const options = ctx.options;
const secondaryStorage = options.secondaryStorage;
const sessionExpiration = options.session?.expiresIn || 60 * 60 * 24 * 7; // 7 days
const { createWithHooks, updateWithHooks, updateManyWithHooks } =
getWithHooks(adapter, ctx);
const {
createWithHooks,
updateWithHooks,
updateManyWithHooks,
deleteWithHooks,
deleteManyWithHooks,
} = getWithHooks(adapter, ctx);
async function refreshUserSessions(user: User) {
if (!secondaryStorage) return;
@@ -222,21 +227,23 @@ export const createInternalAdapter = (
}
return total;
},
deleteUser: async (userId: string) => {
deleteUser: async (userId: string, context?: GenericEndpointContext) => {
if (secondaryStorage) {
await secondaryStorage.delete(`active-sessions-${userId}`);
}
if (!secondaryStorage || options.session?.storeSessionInDatabase) {
await (await getCurrentAdapter(adapter)).deleteMany({
model: "session",
where: [
await deleteManyWithHooks(
[
{
field: "userId",
value: userId,
},
],
});
"session",
undefined,
context,
);
}
await (await getCurrentAdapter(adapter)).deleteMany({
@@ -248,15 +255,17 @@ export const createInternalAdapter = (
},
],
});
await (await getCurrentAdapter(adapter)).delete({
model: "user",
where: [
await deleteWithHooks(
[
{
field: "id",
value: userId,
},
],
});
"user",
undefined,
context,
);
},
createSession: async (
userId: string,
@@ -589,7 +598,10 @@ export const createInternalAdapter = (
],
});
},
deleteSessions: async (userIdOrSessionTokens: string | string[]) => {
deleteSessions: async (
userIdOrSessionTokens: string | string[],
context?: GenericEndpointContext,
) => {
if (secondaryStorage) {
if (typeof userIdOrSessionTokens === "string") {
const activeSession = await secondaryStorage.get(
@@ -618,16 +630,18 @@ export const createInternalAdapter = (
return;
}
}
await (await getCurrentAdapter(adapter)).deleteMany({
model: "session",
where: [
await deleteManyWithHooks(
[
{
field: Array.isArray(userIdOrSessionTokens) ? "token" : "userId",
value: userIdOrSessionTokens,
operator: Array.isArray(userIdOrSessionTokens) ? "in" : undefined,
},
],
});
"session",
undefined,
context,
);
},
findOAuthUser: async (
email: string,
@@ -966,7 +980,10 @@ export const createInternalAdapter = (
const lastVerification = verification[0];
return lastVerification as Verification | null;
},
deleteVerificationValue: async (id: string) => {
deleteVerificationValue: async (
id: string,
context?: GenericEndpointContext,
) => {
await (await getCurrentAdapter(adapter)).delete<Verification>({
model: "verification",
where: [
@@ -977,7 +994,10 @@ export const createInternalAdapter = (
],
});
},
deleteVerificationByIdentifier: async (identifier: string) => {
deleteVerificationByIdentifier: async (
identifier: string,
context?: GenericEndpointContext,
) => {
await (await getCurrentAdapter(adapter)).delete<Verification>({
model: "verification",
where: [

View File

@@ -160,9 +160,126 @@ export function getWithHooks(
return updated;
}
async function deleteWithHooks<T extends Record<string, any>>(
where: Where[],
model: BaseModels,
customDeleteFn?: {
fn: (where: Where[]) => void | Promise<any>;
executeMainFn?: boolean;
},
context?: GenericEndpointContext,
) {
let entityToDelete: T | null = null;
try {
const entities = await (await getCurrentAdapter(adapter)).findMany<T>({
model,
where,
limit: 1,
});
entityToDelete = entities[0] || null;
} catch (error) {
// If we can't find the entity, we'll still proceed with deletion
}
if (entityToDelete) {
for (const hook of hooks || []) {
const toRun = hook[model]?.delete?.before;
if (toRun) {
const result = await toRun(entityToDelete as any, context);
if (result === false) {
return null;
}
}
}
}
const customDeleted = customDeleteFn
? await customDeleteFn.fn(where)
: null;
const deleted =
!customDeleteFn || customDeleteFn.executeMainFn
? await (await getCurrentAdapter(adapter)).delete({
model,
where,
})
: customDeleted;
if (entityToDelete) {
for (const hook of hooks || []) {
const toRun = hook[model]?.delete?.after;
if (toRun) {
await toRun(entityToDelete as any, context);
}
}
}
return deleted;
}
async function deleteManyWithHooks<T extends Record<string, any>>(
where: Where[],
model: BaseModels,
customDeleteFn?: {
fn: (where: Where[]) => void | Promise<any>;
executeMainFn?: boolean;
},
context?: GenericEndpointContext,
) {
let entitiesToDelete: T[] = [];
try {
entitiesToDelete = await (await getCurrentAdapter(adapter)).findMany<T>({
model,
where,
});
} catch (error) {
// If we can't find the entities, we'll still proceed with deletion
}
for (const entity of entitiesToDelete) {
for (const hook of hooks || []) {
const toRun = hook[model]?.delete?.before;
if (toRun) {
const result = await toRun(entity as any, context);
if (result === false) {
return null;
}
}
}
}
const customDeleted = customDeleteFn
? await customDeleteFn.fn(where)
: null;
const deleted =
!customDeleteFn || customDeleteFn.executeMainFn
? await (await getCurrentAdapter(adapter)).deleteMany({
model,
where,
})
: customDeleted;
for (const entity of entitiesToDelete) {
for (const hook of hooks || []) {
const toRun = hook[model]?.delete?.after;
if (toRun) {
await toRun(entity as any, context);
}
}
}
return deleted;
}
return {
createWithHooks,
updateWithHooks,
updateManyWithHooks,
deleteWithHooks,
deleteManyWithHooks,
};
}

View File

@@ -860,6 +860,23 @@ export type BetterAuthOptions = {
context?: GenericEndpointContext,
) => Promise<void>;
};
delete?: {
/**
* Hook that is called before a user is deleted.
* if the hook returns false, the user will not be deleted.
*/
before?: (
user: User & Record<string, unknown>,
context?: GenericEndpointContext,
) => Promise<boolean | void>;
/**
* Hook that is called after a user is deleted.
*/
after?: (
user: User & Record<string, unknown>,
context?: GenericEndpointContext,
) => Promise<void>;
};
};
/**
* Session Hook
@@ -916,6 +933,23 @@ export type BetterAuthOptions = {
context?: GenericEndpointContext,
) => Promise<void>;
};
delete?: {
/**
* Hook that is called before a session is deleted.
* if the hook returns false, the session will not be deleted.
*/
before?: (
session: Session & Record<string, unknown>,
context?: GenericEndpointContext,
) => Promise<boolean | void>;
/**
* Hook that is called after a session is deleted.
*/
after?: (
session: Session & Record<string, unknown>,
context?: GenericEndpointContext,
) => Promise<void>;
};
};
/**
* Account Hook
@@ -972,6 +1006,23 @@ export type BetterAuthOptions = {
context?: GenericEndpointContext,
) => Promise<void>;
};
delete?: {
/**
* Hook that is called before an account is deleted.
* if the hook returns false, the account will not be deleted.
*/
before?: (
account: Account & Record<string, unknown>,
context?: GenericEndpointContext,
) => Promise<boolean | void>;
/**
* Hook that is called after an account is deleted.
*/
after?: (
account: Account & Record<string, unknown>,
context?: GenericEndpointContext,
) => Promise<void>;
};
};
/**
* Verification Hook
@@ -1025,6 +1076,23 @@ export type BetterAuthOptions = {
context?: GenericEndpointContext,
) => Promise<void>;
};
delete?: {
/**
* Hook that is called before a verification is deleted.
* if the hook returns false, the verification will not be deleted.
*/
before?: (
verification: Verification & Record<string, unknown>,
context?: GenericEndpointContext,
) => Promise<boolean | void>;
/**
* Hook that is called after a verification is deleted.
*/
after?: (
verification: Verification & Record<string, unknown>,
context?: GenericEndpointContext,
) => Promise<void>;
};
};
};
/**