fix(db): defer cached session cleanup until deletion

This commit is contained in:
Gustavo Valverde
2026-08-04 18:54:22 -04:00
parent 8dee276dfd
commit fd49908b48
2 changed files with 100 additions and 10 deletions
+26 -10
View File
@@ -180,14 +180,18 @@ export const createInternalAdapter = (
await secondaryStorage.delete(activeSessionsKey);
}
async function queueCachedUserSessionDeletion(userId: string) {
async function queueCachedUserSessionDeletion(
userId: string,
sessionReferences?: readonly ActiveSessionReference[],
) {
if (!secondaryStorage) return;
// Callers may create a replacement session before queued hooks run.
// Capture the revocation set now so that replacement remains active.
const sessionReferences = await getActiveSessionReferences(userId);
// Callers may capture the revocation set before destructive work because a
// replacement session created by a later hook must remain active.
const references =
sessionReferences ?? (await getActiveSessionReferences(userId));
await queueAfterTransactionHook(
() => deleteCachedUserSessions(userId, sessionReferences),
() => deleteCachedUserSessions(userId, references),
{
onError(error) {
logger.error(
@@ -421,7 +425,7 @@ export const createInternalAdapter = (
return total;
},
deleteUser: async (userId: string) => {
await queueCachedUserSessionDeletion(userId);
const sessionReferences = await getActiveSessionReferences(userId);
if (!secondaryStorage || options.session?.storeSessionInDatabase) {
await deleteManyWithHooks(
[
@@ -445,7 +449,7 @@ export const createInternalAdapter = (
undefined,
);
await deleteWithHooks(
const deletedUser = await deleteWithHooks(
[
{
field: "id",
@@ -455,6 +459,9 @@ export const createInternalAdapter = (
"user",
undefined,
);
if (deletedUser !== null) {
await queueCachedUserSessionDeletion(userId, sessionReferences);
}
},
createSession: async (
userId: string,
@@ -934,17 +941,23 @@ export const createInternalAdapter = (
);
},
deleteUserSessions: async (userId: string) => {
await queueCachedUserSessionDeletion(userId);
const sessionReferences = await getActiveSessionReferences(userId);
if (secondaryStorage) {
if (!options.session?.storeSessionInDatabase) {
await queueCachedUserSessionDeletion(userId, sessionReferences);
return;
}
if (ctx.options.session?.preserveSessionInDatabase) {
await endPreservedSessions([{ field: "userId", value: userId }]);
const endedSessions = await endPreservedSessions([
{ field: "userId", value: userId },
]);
if (endedSessions !== null) {
await queueCachedUserSessionDeletion(userId, sessionReferences);
}
return;
}
}
await deleteManyWithHooks(
const deletedSessions = await deleteManyWithHooks(
[
{
field: "userId",
@@ -954,6 +967,9 @@ export const createInternalAdapter = (
"session",
undefined,
);
if (deletedSessions !== null) {
await queueCachedUserSessionDeletion(userId, sessionReferences);
}
},
deleteSessions: async (sessionTokens: string[]) => {
if (secondaryStorage) {
@@ -300,6 +300,80 @@ describe("secondary storage - storeSessionInDatabase", () => {
});
describe("secondary storage - deleteUser", () => {
/**
* @see https://github.com/better-auth/better-auth/pull/10687#discussion_r3716703104
*/
it("keeps cached sessions when user deletion is vetoed", async () => {
const store = new Map<string, string>();
const { auth } = await getTestInstance({
secondaryStorage: createStringSecondaryStorage(store),
databaseHooks: {
user: {
delete: {
before: async () => false,
},
},
},
rateLimit: { enabled: false },
});
const { internalAdapter } = await auth.$context;
const user = await internalAdapter.createUser(
{
name: "Retained User",
email: "retained-user@test.com",
},
{ method: "test" },
);
const session = await internalAdapter.createSession(user.id);
const activeSessionsKey = `active-sessions-${user.id}`;
await internalAdapter.deleteUser(user.id);
expect(await internalAdapter.findUserById(user.id)).not.toBeNull();
expect(store.has(session.token)).toBe(true);
expect(store.has(activeSessionsKey)).toBe(true);
});
/**
* @see https://github.com/better-auth/better-auth/pull/10687#discussion_r3716703104
*/
it("keeps cached sessions when session deletion is vetoed", async () => {
const store = new Map<string, string>();
const { auth, db } = await getTestInstance({
secondaryStorage: createStringSecondaryStorage(store),
session: { storeSessionInDatabase: true },
databaseHooks: {
session: {
delete: {
before: async () => false,
},
},
},
rateLimit: { enabled: false },
});
const { internalAdapter } = await auth.$context;
const user = await internalAdapter.createUser(
{
name: "Retained Session User",
email: "retained-session-user@test.com",
},
{ method: "test" },
);
const session = await internalAdapter.createSession(user.id);
const activeSessionsKey = `active-sessions-${user.id}`;
await internalAdapter.deleteUserSessions(user.id);
expect(
await db.findMany({
model: "session",
where: [{ field: "userId", value: user.id }],
}),
).toHaveLength(1);
expect(store.has(session.token)).toBe(true);
expect(store.has(activeSessionsKey)).toBe(true);
});
it("deletes user sessions from secondary storage and database", async () => {
const store = new Map<string, string>();
const { auth, db } = await getTestInstance({