mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 23:53:17 -05:00
fix(org): make invitation acceptance idempotent
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"better-auth": patch
|
||||
"@better-auth/kysely-adapter": patch
|
||||
---
|
||||
|
||||
Fix organization invitation acceptance retries so concurrent accepts converge on one member and accepted invitations can be replayed safely. Direct SQLite/MySQL/Postgres Kysely databases now use real adapter transactions where supported, and SQLite update/delete mutations no longer report stale insert IDs.
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
InferOrganization,
|
||||
InferTeam,
|
||||
InvitationInput,
|
||||
InvitationStatus,
|
||||
Member,
|
||||
MemberInput,
|
||||
OrganizationInput,
|
||||
@@ -940,8 +941,9 @@ export const getOrgAdapter = <O extends OrganizationOptions>(
|
||||
}): Promise<
|
||||
{ status: "added"; member: TeamMember } | { status: "limitReached" }
|
||||
> => {
|
||||
return runWithTransaction(baseAdapter, async () => {
|
||||
const adapter = await getCurrentAdapter(baseAdapter);
|
||||
const addTeamMember = async (
|
||||
adapter: Awaited<ReturnType<typeof getCurrentAdapter>>,
|
||||
) => {
|
||||
const existing = await adapter.findOne<TeamMember>({
|
||||
model: "teamMember",
|
||||
where: [
|
||||
@@ -950,14 +952,14 @@ export const getOrgAdapter = <O extends OrganizationOptions>(
|
||||
],
|
||||
});
|
||||
if (existing) {
|
||||
return { status: "added", member: existing };
|
||||
return { status: "added" as const, member: existing };
|
||||
}
|
||||
const count = await adapter.count({
|
||||
model: "teamMember",
|
||||
where: [{ field: "teamId", value: data.teamId }],
|
||||
});
|
||||
if (count >= data.maximumMembersPerTeam) {
|
||||
return { status: "limitReached" };
|
||||
return { status: "limitReached" as const };
|
||||
}
|
||||
const member = await adapter.create<Omit<TeamMember, "id">, TeamMember>(
|
||||
{
|
||||
@@ -969,7 +971,15 @@ export const getOrgAdapter = <O extends OrganizationOptions>(
|
||||
},
|
||||
},
|
||||
);
|
||||
return { status: "added", member };
|
||||
return { status: "added" as const, member };
|
||||
};
|
||||
|
||||
const currentAdapter = await getCurrentAdapter(baseAdapter);
|
||||
if (currentAdapter !== baseAdapter) {
|
||||
return await addTeamMember(currentAdapter);
|
||||
}
|
||||
return await runWithTransaction(baseAdapter, async () => {
|
||||
return await addTeamMember(await getCurrentAdapter(baseAdapter));
|
||||
});
|
||||
},
|
||||
removeTeamMember: async (data: { teamId: string; userId: string }) => {
|
||||
@@ -1133,13 +1143,13 @@ export const getOrgAdapter = <O extends OrganizationOptions>(
|
||||
},
|
||||
updateInvitation: async (data: {
|
||||
invitationId: string;
|
||||
status: "pending" | "accepted" | "canceled" | "rejected";
|
||||
status: InvitationStatus;
|
||||
/**
|
||||
* Only transition when the invitation is currently in this status. The
|
||||
* guarded update is atomic, so a concurrent caller racing the same
|
||||
* transition gets `null` instead of both proceeding.
|
||||
*/
|
||||
fromStatus?: "pending";
|
||||
fromStatus?: InvitationStatus;
|
||||
}) => {
|
||||
const adapter = await getCurrentAdapter(baseAdapter);
|
||||
const where = [{ field: "id", value: data.invitationId }];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { BetterAuthOptions, GenerateIdFn } from "@better-auth/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getTestInstance } from "../../../test-utils/test-instance";
|
||||
import { organizationClient } from "../client";
|
||||
import { organization } from "../organization";
|
||||
@@ -372,6 +372,224 @@ describe("organization invitation recipient ownership gates", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("organization invitation acceptance idempotency", async () => {
|
||||
const INVITEE_EMAIL = "invitee-idempotent@example.com";
|
||||
const CONCURRENT_INVITEE_EMAIL = "invitee-concurrent@example.com";
|
||||
const PASSWORD = "test-password-123";
|
||||
|
||||
const wait = (ms: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* @see https://github.com/better-auth/better-auth/issues/9154
|
||||
*/
|
||||
it("replays an accepted invitation after expiration without creating another member", async () => {
|
||||
const { client, signInWithTestUser, signInWithUser, cookieSetter, db } =
|
||||
await getTestInstance(
|
||||
{
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
before: async (user) => ({
|
||||
data: { ...user, emailVerified: true },
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
organization({
|
||||
async sendInvitationEmail() {},
|
||||
}),
|
||||
],
|
||||
},
|
||||
{ clientOptions: { plugins: [organizationClient()] } },
|
||||
);
|
||||
|
||||
const { headers: ownerHeaders } = await signInWithTestUser();
|
||||
const createdOrganization = await client.organization.create({
|
||||
name: "Replay Org",
|
||||
slug: "replay-org",
|
||||
fetchOptions: {
|
||||
headers: ownerHeaders,
|
||||
onSuccess: cookieSetter(ownerHeaders),
|
||||
},
|
||||
});
|
||||
const invitation = await client.organization.inviteMember({
|
||||
organizationId: createdOrganization.data!.id,
|
||||
email: INVITEE_EMAIL,
|
||||
role: "member",
|
||||
fetchOptions: { headers: ownerHeaders },
|
||||
});
|
||||
const invitationId = String(invitation.data!.id);
|
||||
|
||||
await client.signUp.email({
|
||||
email: INVITEE_EMAIL,
|
||||
password: PASSWORD,
|
||||
name: "Invitee",
|
||||
});
|
||||
const { headers: inviteeHeaders, res: invitee } = await signInWithUser(
|
||||
INVITEE_EMAIL,
|
||||
PASSWORD,
|
||||
);
|
||||
|
||||
const firstAccept = await client.organization.acceptInvitation({
|
||||
invitationId,
|
||||
fetchOptions: { headers: inviteeHeaders },
|
||||
});
|
||||
expect(firstAccept.error).toBeNull();
|
||||
expect(firstAccept.data?.invitation.status).toBe("accepted");
|
||||
|
||||
await db.update({
|
||||
model: "invitation",
|
||||
where: [{ field: "id", value: invitationId }],
|
||||
update: { expiresAt: new Date(Date.now() - 1000) },
|
||||
});
|
||||
|
||||
const replay = await client.organization.acceptInvitation({
|
||||
invitationId,
|
||||
fetchOptions: { headers: inviteeHeaders },
|
||||
});
|
||||
|
||||
expect(replay.error).toBeNull();
|
||||
expect(replay.data?.invitation.status).toBe("accepted");
|
||||
expect(replay.data?.member.id).toBe(firstAccept.data?.member.id);
|
||||
|
||||
const members = await db.findMany<{ id: string }>({
|
||||
model: "member",
|
||||
where: [
|
||||
{ field: "organizationId", value: createdOrganization.data!.id },
|
||||
{ field: "userId", value: invitee.user.id },
|
||||
],
|
||||
});
|
||||
expect(members).toHaveLength(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* @see https://github.com/better-auth/better-auth/issues/9154
|
||||
*/
|
||||
it("replays concurrent accepts while creating the member once", async () => {
|
||||
let inviteeUserId = "";
|
||||
let delayedInviteeMemberCreates = 0;
|
||||
const {
|
||||
auth,
|
||||
client,
|
||||
signInWithTestUser,
|
||||
signInWithUser,
|
||||
cookieSetter,
|
||||
db,
|
||||
} = await getTestInstance(
|
||||
{
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
before: async (user) => ({
|
||||
data: { ...user, emailVerified: true },
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
organization({
|
||||
async sendInvitationEmail() {},
|
||||
}),
|
||||
],
|
||||
},
|
||||
{ clientOptions: { plugins: [organizationClient()] } },
|
||||
);
|
||||
|
||||
const { headers: ownerHeaders } = await signInWithTestUser();
|
||||
const createdOrganization = await client.organization.create({
|
||||
name: "Concurrent Replay Org",
|
||||
slug: "concurrent-replay-org",
|
||||
fetchOptions: {
|
||||
headers: ownerHeaders,
|
||||
onSuccess: cookieSetter(ownerHeaders),
|
||||
},
|
||||
});
|
||||
const invitation = await client.organization.inviteMember({
|
||||
organizationId: createdOrganization.data!.id,
|
||||
email: CONCURRENT_INVITEE_EMAIL,
|
||||
role: "member",
|
||||
fetchOptions: { headers: ownerHeaders },
|
||||
});
|
||||
const invitationId = String(invitation.data!.id);
|
||||
|
||||
await client.signUp.email({
|
||||
email: CONCURRENT_INVITEE_EMAIL,
|
||||
password: PASSWORD,
|
||||
name: "Concurrent Invitee",
|
||||
});
|
||||
const { headers: inviteeHeaders, res: invitee } = await signInWithUser(
|
||||
CONCURRENT_INVITEE_EMAIL,
|
||||
PASSWORD,
|
||||
);
|
||||
inviteeUserId = invitee.user.id;
|
||||
|
||||
const ctx = await auth.$context;
|
||||
const originalTransaction = ctx.adapter.transaction.bind(ctx.adapter);
|
||||
const transactionSpy = vi
|
||||
.spyOn(ctx.adapter, "transaction")
|
||||
.mockImplementation(async (callback) => {
|
||||
return await originalTransaction(async (transactionAdapter) => {
|
||||
const originalCreate =
|
||||
transactionAdapter.create.bind(transactionAdapter);
|
||||
const createSpy = vi
|
||||
.spyOn(transactionAdapter, "create")
|
||||
.mockImplementation(async (createArgs) => {
|
||||
if (
|
||||
createArgs.model === "member" &&
|
||||
createArgs.data.userId === inviteeUserId
|
||||
) {
|
||||
delayedInviteeMemberCreates++;
|
||||
await wait(50);
|
||||
}
|
||||
return await originalCreate(createArgs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await callback(transactionAdapter);
|
||||
} finally {
|
||||
createSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const acceptResponses = await Promise.all([
|
||||
client.organization.acceptInvitation({
|
||||
invitationId,
|
||||
fetchOptions: { headers: inviteeHeaders },
|
||||
}),
|
||||
client.organization.acceptInvitation({
|
||||
invitationId,
|
||||
fetchOptions: { headers: inviteeHeaders },
|
||||
}),
|
||||
]);
|
||||
|
||||
for (const acceptResponse of acceptResponses) {
|
||||
expect(acceptResponse.error).toBeNull();
|
||||
expect(acceptResponse.data?.invitation.status).toBe("accepted");
|
||||
}
|
||||
expect(
|
||||
new Set(acceptResponses.map((response) => response.data?.member.id))
|
||||
.size,
|
||||
).toBe(1);
|
||||
expect(delayedInviteeMemberCreates).toBe(1);
|
||||
} finally {
|
||||
transactionSpy.mockRestore();
|
||||
}
|
||||
|
||||
const members = await db.findMany<{ id: string }>({
|
||||
model: "member",
|
||||
where: [
|
||||
{ field: "organizationId", value: createdOrganization.data!.id },
|
||||
{ field: "userId", value: invitee.user.id },
|
||||
],
|
||||
});
|
||||
expect(members).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* An invitation's teamId must be scoped to the invitation's organization at
|
||||
* creation AND acceptance, and team read endpoints must verify organization
|
||||
|
||||
@@ -658,12 +658,90 @@ export const acceptInvitation = <O extends OrganizationOptions>(options: O) =>
|
||||
const invitation = await adapter.findInvitationById(
|
||||
ctx.body.invitationId,
|
||||
);
|
||||
type AcceptedInvitation = NonNullable<typeof invitation>;
|
||||
type AcceptedInvitationMember = Member &
|
||||
InferAdditionalFieldsFromPluginOptions<"member", O, false>;
|
||||
|
||||
if (
|
||||
!invitation ||
|
||||
invitation.expiresAt < new Date() ||
|
||||
invitation.status !== "pending"
|
||||
) {
|
||||
const findAcceptedInvitationMember = async (
|
||||
acceptedInvitation: AcceptedInvitation,
|
||||
) => {
|
||||
return await ctx.context.adapter.findOne<AcceptedInvitationMember>({
|
||||
model: "member",
|
||||
where: [
|
||||
{
|
||||
field: "userId",
|
||||
value: session.user.id,
|
||||
},
|
||||
{
|
||||
field: "organizationId",
|
||||
value: acceptedInvitation.organizationId,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const restoreAcceptedInvitationTeamSession = async (
|
||||
acceptedInvitation: AcceptedInvitation,
|
||||
) => {
|
||||
if (
|
||||
!ctx.context.orgOptions.teams?.enabled ||
|
||||
!("teamId" in acceptedInvitation) ||
|
||||
!acceptedInvitation.teamId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const teamIds = (acceptedInvitation.teamId as string).split(",");
|
||||
if (teamIds.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const teamId = teamIds[0]!;
|
||||
const teamMember = await adapter.findTeamMember({
|
||||
teamId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
if (!teamMember) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSession = await adapter.setActiveTeam(
|
||||
session.session.token,
|
||||
teamId,
|
||||
ctx,
|
||||
);
|
||||
|
||||
await setSessionCookie(ctx, {
|
||||
session: updatedSession,
|
||||
user: session.user,
|
||||
});
|
||||
};
|
||||
|
||||
const completeAcceptedInvitationReplay = async (
|
||||
acceptedInvitation: AcceptedInvitation,
|
||||
) => {
|
||||
const member = await findAcceptedInvitationMember(acceptedInvitation);
|
||||
if (!member) {
|
||||
throw APIError.from(
|
||||
"BAD_REQUEST",
|
||||
ORGANIZATION_ERROR_CODES.FAILED_TO_RETRIEVE_INVITATION,
|
||||
);
|
||||
}
|
||||
|
||||
await restoreAcceptedInvitationTeamSession(acceptedInvitation);
|
||||
await adapter.setActiveOrganization(
|
||||
session.session.token,
|
||||
acceptedInvitation.organizationId,
|
||||
ctx,
|
||||
);
|
||||
|
||||
return ctx.json({
|
||||
invitation: acceptedInvitation,
|
||||
member,
|
||||
});
|
||||
};
|
||||
|
||||
if (!invitation) {
|
||||
throw APIError.from(
|
||||
"BAD_REQUEST",
|
||||
ORGANIZATION_ERROR_CODES.INVITATION_NOT_FOUND,
|
||||
@@ -697,6 +775,20 @@ export const acceptInvitation = <O extends OrganizationOptions>(options: O) =>
|
||||
);
|
||||
}
|
||||
|
||||
if (invitation.status === "accepted") {
|
||||
return await completeAcceptedInvitationReplay(invitation);
|
||||
}
|
||||
|
||||
if (
|
||||
invitation.expiresAt < new Date() ||
|
||||
invitation.status !== "pending"
|
||||
) {
|
||||
throw APIError.from(
|
||||
"BAD_REQUEST",
|
||||
ORGANIZATION_ERROR_CODES.INVITATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const membershipLimit = ctx.context.orgOptions?.membershipLimit || 100;
|
||||
const membersCount = await adapter.countMembers({
|
||||
organizationId: invitation.organizationId,
|
||||
@@ -733,118 +825,137 @@ export const acceptInvitation = <O extends OrganizationOptions>(options: O) =>
|
||||
});
|
||||
}
|
||||
|
||||
// Claim the invitation atomically so only one concurrent accept wins the
|
||||
// pending -> accepted transition; the guarded update is a single statement
|
||||
// and atomic on every adapter. The membership work then runs in a
|
||||
// transaction so it is all-or-nothing where the adapter supports it, and if
|
||||
// it fails the claim is released back to pending so the invitee can retry
|
||||
// instead of being stranded as accepted with no membership.
|
||||
const acceptedI = await adapter.updateInvitation({
|
||||
invitationId: ctx.body.invitationId,
|
||||
status: "accepted",
|
||||
fromStatus: "pending",
|
||||
let didClaimInvitation = false;
|
||||
const acceptance = await runWithTransaction(
|
||||
ctx.context.adapter,
|
||||
async () => {
|
||||
// Claim the invitation inside the transaction so transactional adapters
|
||||
// do not expose an accepted invitation before the member row exists.
|
||||
const acceptedI = await adapter.updateInvitation({
|
||||
invitationId: ctx.body.invitationId,
|
||||
status: "accepted",
|
||||
fromStatus: "pending",
|
||||
});
|
||||
if (!acceptedI) {
|
||||
return { status: "replay" as const };
|
||||
}
|
||||
didClaimInvitation = true;
|
||||
|
||||
if (
|
||||
ctx.context.orgOptions.teams &&
|
||||
ctx.context.orgOptions.teams.enabled &&
|
||||
"teamId" in acceptedI &&
|
||||
acceptedI.teamId
|
||||
) {
|
||||
const teamIds = (acceptedI.teamId as string).split(",");
|
||||
const onlyOne = teamIds.length === 1;
|
||||
|
||||
for (const teamId of teamIds) {
|
||||
// Confirm the team still belongs to the accepted invitation's
|
||||
// organization before adding the member. This keeps team
|
||||
// membership consistent with the invitation's organization,
|
||||
// including for older invitations and for teams that were
|
||||
// moved or removed between invite and accept.
|
||||
const team = await adapter.findTeamById({
|
||||
teamId,
|
||||
organizationId: acceptedI.organizationId,
|
||||
});
|
||||
if (!team) {
|
||||
throw APIError.from(
|
||||
"BAD_REQUEST",
|
||||
ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const maximumMembersPerTeam = await resolveMaximumMembersPerTeam(
|
||||
ctx.context.orgOptions.teams,
|
||||
{
|
||||
teamId,
|
||||
organizationId: acceptedI.organizationId,
|
||||
session,
|
||||
},
|
||||
);
|
||||
if (maximumMembersPerTeam !== undefined) {
|
||||
const result = await adapter.addTeamMemberWithLimit({
|
||||
teamId,
|
||||
userId: session.user.id,
|
||||
maximumMembersPerTeam,
|
||||
});
|
||||
if (result.status === "limitReached") {
|
||||
throw APIError.from(
|
||||
"FORBIDDEN",
|
||||
ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await adapter.findOrCreateTeamMember({
|
||||
teamId: teamId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (onlyOne) {
|
||||
const teamId = teamIds[0]!;
|
||||
const updatedSession = await adapter.setActiveTeam(
|
||||
session.session.token,
|
||||
teamId,
|
||||
ctx,
|
||||
);
|
||||
|
||||
await setSessionCookie(ctx, {
|
||||
session: updatedSession,
|
||||
user: session.user,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const createdMember = await adapter.createMember({
|
||||
organizationId: acceptedI.organizationId,
|
||||
userId: session.user.id,
|
||||
role: acceptedI.role,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
await adapter.setActiveOrganization(
|
||||
session.session.token,
|
||||
acceptedI.organizationId,
|
||||
ctx,
|
||||
);
|
||||
|
||||
return {
|
||||
status: "accepted" as const,
|
||||
invitation: acceptedI,
|
||||
member: createdMember,
|
||||
};
|
||||
},
|
||||
).catch(async (error) => {
|
||||
// The membership work failed; release the claim so the invitation is
|
||||
// pending again and the invitee can retry.
|
||||
if (didClaimInvitation) {
|
||||
await adapter.updateInvitation({
|
||||
invitationId: ctx.body.invitationId,
|
||||
status: "pending",
|
||||
fromStatus: "accepted",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (!acceptedI) {
|
||||
// Another request already accepted this invitation.
|
||||
|
||||
if (acceptance.status === "replay") {
|
||||
const latestInvitation = await adapter.findInvitationById(
|
||||
ctx.body.invitationId,
|
||||
);
|
||||
if (latestInvitation?.status === "accepted") {
|
||||
return await completeAcceptedInvitationReplay(latestInvitation);
|
||||
}
|
||||
throw APIError.from(
|
||||
"BAD_REQUEST",
|
||||
ORGANIZATION_ERROR_CODES.INVITATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const member = await runWithTransaction(ctx.context.adapter, async () => {
|
||||
if (
|
||||
ctx.context.orgOptions.teams &&
|
||||
ctx.context.orgOptions.teams.enabled &&
|
||||
"teamId" in acceptedI &&
|
||||
acceptedI.teamId
|
||||
) {
|
||||
const teamIds = (acceptedI.teamId as string).split(",");
|
||||
const onlyOne = teamIds.length === 1;
|
||||
|
||||
for (const teamId of teamIds) {
|
||||
// Confirm the team still belongs to the accepted invitation's
|
||||
// organization before adding the member. This keeps team
|
||||
// membership consistent with the invitation's organization,
|
||||
// including for older invitations and for teams that were
|
||||
// moved or removed between invite and accept.
|
||||
const team = await adapter.findTeamById({
|
||||
teamId,
|
||||
organizationId: acceptedI.organizationId,
|
||||
});
|
||||
if (!team) {
|
||||
throw APIError.from(
|
||||
"BAD_REQUEST",
|
||||
ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const maximumMembersPerTeam = await resolveMaximumMembersPerTeam(
|
||||
ctx.context.orgOptions.teams,
|
||||
{
|
||||
teamId,
|
||||
organizationId: acceptedI.organizationId,
|
||||
session,
|
||||
},
|
||||
);
|
||||
if (maximumMembersPerTeam !== undefined) {
|
||||
const result = await adapter.addTeamMemberWithLimit({
|
||||
teamId,
|
||||
userId: session.user.id,
|
||||
maximumMembersPerTeam,
|
||||
});
|
||||
if (result.status === "limitReached") {
|
||||
throw APIError.from(
|
||||
"FORBIDDEN",
|
||||
ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await adapter.findOrCreateTeamMember({
|
||||
teamId: teamId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (onlyOne) {
|
||||
const teamId = teamIds[0]!;
|
||||
const updatedSession = await adapter.setActiveTeam(
|
||||
session.session.token,
|
||||
teamId,
|
||||
ctx,
|
||||
);
|
||||
|
||||
await setSessionCookie(ctx, {
|
||||
session: updatedSession,
|
||||
user: session.user,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const createdMember = await adapter.createMember({
|
||||
organizationId: acceptedI.organizationId,
|
||||
userId: session.user.id,
|
||||
role: acceptedI.role,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
await adapter.setActiveOrganization(
|
||||
session.session.token,
|
||||
acceptedI.organizationId,
|
||||
ctx,
|
||||
);
|
||||
|
||||
return createdMember;
|
||||
}).catch(async (error) => {
|
||||
// The membership work failed; release the claim so the invitation is
|
||||
// pending again and the invitee can retry.
|
||||
await adapter.updateInvitation({
|
||||
invitationId: ctx.body.invitationId,
|
||||
status: "pending",
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
const { invitation: acceptedI, member } = acceptance;
|
||||
|
||||
if (options?.organizationHooks?.afterAcceptInvitation) {
|
||||
await options?.organizationHooks.afterAcceptInvitation({
|
||||
|
||||
@@ -1653,7 +1653,10 @@ describe("accept-invitation validates team capacity before adding the member", a
|
||||
expect(orgMembers.some((m) => m.userId === userId)).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts only once when the same invitation is accepted concurrently", async () => {
|
||||
/**
|
||||
* @see https://github.com/better-auth/better-auth/issues/9154
|
||||
*/
|
||||
it("replays the accepted member when the same invitation is accepted concurrently", async () => {
|
||||
const team = await auth.api.createTeam({
|
||||
headers: owner.headers,
|
||||
body: { name: "Concurrent Team", organizationId: org.id },
|
||||
@@ -1674,10 +1677,13 @@ describe("accept-invitation validates team capacity before adding the member", a
|
||||
}),
|
||||
]);
|
||||
|
||||
// One request wins the claim; the other observes the invitation is no
|
||||
// longer pending and is rejected, never running the side effects twice.
|
||||
expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1);
|
||||
expect(results.filter((r) => r.status === "rejected")).toHaveLength(1);
|
||||
const acceptedResults = results.flatMap((result) =>
|
||||
result.status === "fulfilled" ? [result.value] : [],
|
||||
);
|
||||
expect(acceptedResults).toHaveLength(2);
|
||||
expect(
|
||||
new Set(acceptedResults.map((result) => result.member.id)).size,
|
||||
).toBe(1);
|
||||
|
||||
const orgMembers = await ctx.adapter.findMany<{ userId: string }>({
|
||||
model: "member",
|
||||
|
||||
@@ -143,15 +143,19 @@ class BunSqliteConnection implements DatabaseConnection {
|
||||
});
|
||||
}
|
||||
|
||||
const firstSqlToken = sql.trimStart().split(/\s+/, 1)[0]?.toLowerCase();
|
||||
const shouldReturnInsertId =
|
||||
firstSqlToken === "insert" || firstSqlToken === "replace";
|
||||
const { changes, lastInsertRowid } = stmt.run(...params);
|
||||
const insertId =
|
||||
typeof lastInsertRowid === "bigint"
|
||||
? lastInsertRowid
|
||||
: BigInt(lastInsertRowid);
|
||||
|
||||
return Promise.resolve({
|
||||
rows: [],
|
||||
numAffectedRows: BigInt(changes),
|
||||
insertId:
|
||||
typeof lastInsertRowid === "bigint"
|
||||
? lastInsertRowid
|
||||
: BigInt(lastInsertRowid),
|
||||
...(shouldReturnInsertId ? { insertId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ export const createKyselyAdapter = async (config: BetterAuthOptions) => {
|
||||
}
|
||||
|
||||
let dialect: Dialect | undefined = undefined;
|
||||
let transaction: boolean | undefined = undefined;
|
||||
|
||||
const databaseType = getKyselyDatabaseType(db);
|
||||
|
||||
@@ -94,17 +95,20 @@ export const createKyselyAdapter = async (config: BetterAuthOptions) => {
|
||||
dialect = new SqliteDialect({
|
||||
database: db,
|
||||
});
|
||||
transaction = true;
|
||||
}
|
||||
|
||||
if ("getConnection" in db) {
|
||||
// @ts-expect-error - mysql2/promise
|
||||
dialect = new MysqlDialect(db);
|
||||
transaction = true;
|
||||
}
|
||||
|
||||
if ("connect" in db) {
|
||||
dialect = new PostgresDialect({
|
||||
pool: db,
|
||||
});
|
||||
transaction = true;
|
||||
}
|
||||
|
||||
if ("fileControl" in db) {
|
||||
@@ -112,6 +116,7 @@ export const createKyselyAdapter = async (config: BetterAuthOptions) => {
|
||||
dialect = new BunSqliteDialect({
|
||||
database: db,
|
||||
});
|
||||
transaction = true;
|
||||
}
|
||||
|
||||
if ("createSession" in db) {
|
||||
@@ -141,6 +146,7 @@ export const createKyselyAdapter = async (config: BetterAuthOptions) => {
|
||||
dialect = new NodeSqliteDialect({
|
||||
database: db,
|
||||
});
|
||||
transaction = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,11 +156,12 @@ export const createKyselyAdapter = async (config: BetterAuthOptions) => {
|
||||
dialect = new D1SqliteDialect({
|
||||
database: db,
|
||||
});
|
||||
transaction = false;
|
||||
}
|
||||
|
||||
return {
|
||||
kysely: dialect ? new Kysely<any>({ dialect }) : null,
|
||||
databaseType,
|
||||
transaction: undefined,
|
||||
transaction,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -143,15 +143,19 @@ class NodeSqliteConnection implements DatabaseConnection {
|
||||
});
|
||||
}
|
||||
|
||||
const firstSqlToken = sql.trimStart().split(/\s+/, 1)[0]?.toLowerCase();
|
||||
const shouldReturnInsertId =
|
||||
firstSqlToken === "insert" || firstSqlToken === "replace";
|
||||
const { changes, lastInsertRowid } = stmt.run(...params);
|
||||
const insertId =
|
||||
typeof lastInsertRowid === "bigint"
|
||||
? lastInsertRowid
|
||||
: BigInt(lastInsertRowid);
|
||||
|
||||
return Promise.resolve({
|
||||
rows: [],
|
||||
numAffectedRows: BigInt(changes),
|
||||
insertId:
|
||||
typeof lastInsertRowid === "bigint"
|
||||
? lastInsertRowid
|
||||
: BigInt(lastInsertRowid),
|
||||
...(shouldReturnInsertId ? { insertId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { SQLInputValue } from "node:sqlite";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import type { Generated } from "kysely";
|
||||
import { Kysely } from "kysely";
|
||||
import type { BetterAuthOptions } from "@better-auth/core";
|
||||
import type { Dialect, Generated } from "kysely";
|
||||
import { CompiledQuery, Kysely } from "kysely";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BunSqliteDialect } from "./bun-sqlite-dialect";
|
||||
import { createKyselyAdapter } from "./dialect";
|
||||
import { NodeSqliteDialect } from "./node-sqlite-dialect";
|
||||
|
||||
interface UsersTable {
|
||||
@@ -52,7 +54,54 @@ function asBunLikeDatabase(db: DatabaseSync) {
|
||||
} as unknown as ConstructorParameters<typeof BunSqliteDialect>[0]["database"];
|
||||
}
|
||||
|
||||
async function expectNoStaleInsertIdForUpdateOrDelete(dialect: Dialect) {
|
||||
const driver = dialect.createDriver();
|
||||
await driver.init();
|
||||
const connection = await driver.acquireConnection();
|
||||
|
||||
try {
|
||||
await connection.executeQuery(
|
||||
CompiledQuery.raw(
|
||||
"CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, value TEXT)",
|
||||
),
|
||||
);
|
||||
const insert = await connection.executeQuery(
|
||||
CompiledQuery.raw(
|
||||
"INSERT INTO users (name, value) VALUES ('alice', 'first')",
|
||||
),
|
||||
);
|
||||
expect(insert.insertId).toBe(1n);
|
||||
|
||||
const update = await connection.executeQuery(
|
||||
CompiledQuery.raw(
|
||||
"UPDATE users SET value = 'second' WHERE name = 'alice'",
|
||||
),
|
||||
);
|
||||
expect(update.numAffectedRows).toBe(1n);
|
||||
expect(update.insertId).toBeUndefined();
|
||||
|
||||
const remove = await connection.executeQuery(
|
||||
CompiledQuery.raw("DELETE FROM users WHERE name = 'alice'"),
|
||||
);
|
||||
expect(remove.numAffectedRows).toBe(1n);
|
||||
expect(remove.insertId).toBeUndefined();
|
||||
} finally {
|
||||
await driver.releaseConnection(connection);
|
||||
await driver.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
describe("NodeSqliteDialect mutation metadata", () => {
|
||||
it("enables real transactions for direct node:sqlite databases", async () => {
|
||||
const sqlite = new DatabaseSync(":memory:");
|
||||
const adapter = await createKyselyAdapter({
|
||||
database: sqlite,
|
||||
} satisfies BetterAuthOptions);
|
||||
|
||||
expect(adapter.transaction).toBe(true);
|
||||
await adapter.kysely?.destroy();
|
||||
});
|
||||
|
||||
it("binds multiple parameters and surfaces insert/update/delete metadata", async () => {
|
||||
const sqlite = new DatabaseSync(":memory:");
|
||||
createSchema(sqlite);
|
||||
@@ -108,6 +157,45 @@ describe("NodeSqliteDialect mutation metadata", () => {
|
||||
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
it("does not report stale insertId for update and delete", async () => {
|
||||
await expectNoStaleInsertIdForUpdateOrDelete(
|
||||
new NodeSqliteDialect({ database: new DatabaseSync(":memory:") }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("D1SqliteDialect transaction metadata", () => {
|
||||
it("keeps interactive transactions disabled for D1", async () => {
|
||||
const d1LikeDatabase = {
|
||||
batch() {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
exec() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
prepare() {
|
||||
return {
|
||||
bind() {
|
||||
return {
|
||||
all() {
|
||||
return Promise.resolve({
|
||||
results: [],
|
||||
meta: {},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as BetterAuthOptions["database"];
|
||||
const adapter = await createKyselyAdapter({
|
||||
database: d1LikeDatabase,
|
||||
} satisfies BetterAuthOptions);
|
||||
|
||||
expect(adapter.transaction).toBe(false);
|
||||
await adapter.kysely?.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BunSqliteDialect mutation metadata", () => {
|
||||
@@ -165,4 +253,12 @@ describe("BunSqliteDialect mutation metadata", () => {
|
||||
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
it("does not report stale insertId for update and delete", async () => {
|
||||
await expectNoStaleInsertIdForUpdateOrDelete(
|
||||
new BunSqliteDialect({
|
||||
database: asBunLikeDatabase(new DatabaseSync(":memory:")),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user