mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-28 17:42:01 -05:00
chore: resolve main to next sync validation
This commit is contained in:
@@ -2392,10 +2392,14 @@ describe("account resolution in stateless mode", async () => {
|
||||
const signIn = async (auth: ReturnType<typeof makeStatelessAuth>) => {
|
||||
const jar: Jar = new Map();
|
||||
let res = await auth.handler(
|
||||
new Request("http://localhost:3000/api/auth/sign-in/oauth2", {
|
||||
new Request("http://localhost:3000/api/auth/sign-in/social", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ providerId: "idp", callbackURL: "/" }),
|
||||
body: JSON.stringify({
|
||||
provider: "idp",
|
||||
callbackURL: "/",
|
||||
disableRedirect: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
collectCookies(res, jar);
|
||||
@@ -2405,7 +2409,7 @@ describe("account resolution in stateless mode", async () => {
|
||||
|
||||
res = await auth.handler(
|
||||
new Request(
|
||||
`http://localhost:3000/api/auth/oauth2/callback/idp?code=test-code&state=${state}`,
|
||||
`http://localhost:3000/api/auth/callback/idp?code=test-code&state=${state}`,
|
||||
{ headers: requestHeaders(jar), redirect: "manual" },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -241,12 +241,12 @@ const oauthPopupStart = createAuthEndpoint(
|
||||
marker.attributes,
|
||||
);
|
||||
|
||||
url = await provider.createAuthorizationURL({
|
||||
({ url } = await provider.createAuthorizationURL({
|
||||
state,
|
||||
codeVerifier,
|
||||
redirectURI: `${c.context.baseURL}/callback/${provider.id}`,
|
||||
scopes: c.query.scopes ? c.query.scopes.split(",") : undefined,
|
||||
});
|
||||
}));
|
||||
} catch (error) {
|
||||
c.context.logger.error("OAuth popup failed to start", error);
|
||||
return fail("popup_sign_in_failed", "Failed to start the OAuth flow.");
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
// @vitest-environment happy-dom
|
||||
import type { BetterFetch } from "@better-fetch/fetch";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSignInPopup, popupBearerFetchPlugin } from "./client";
|
||||
import { OAUTH_POPUP_MESSAGE_TYPE, POPUP_TOKEN_STORAGE_KEY } from "./constants";
|
||||
|
||||
const AUTH_ORIGIN = "https://auth.example.com";
|
||||
|
||||
function createStorage(): Storage {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
getItem(key) {
|
||||
return store.get(key) ?? null;
|
||||
},
|
||||
key(index) {
|
||||
return [...store.keys()][index] ?? null;
|
||||
},
|
||||
removeItem(key) {
|
||||
store.delete(key);
|
||||
},
|
||||
setItem(key, value) {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakePopup() {
|
||||
return {
|
||||
closed: false,
|
||||
@@ -52,7 +76,14 @@ function post(origin: string, data: unknown) {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.clear();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
configurable: true,
|
||||
value: createStorage(),
|
||||
});
|
||||
});
|
||||
|
||||
describe("signIn.popup completion gate", () => {
|
||||
@@ -77,7 +108,7 @@ describe("signIn.popup completion gate", () => {
|
||||
data: { success: true },
|
||||
error: null,
|
||||
});
|
||||
expect(localStorage.getItem(POPUP_TOKEN_STORAGE_KEY)).toBe("real");
|
||||
expect(window.localStorage.getItem(POPUP_TOKEN_STORAGE_KEY)).toBe("real");
|
||||
});
|
||||
|
||||
it("ignores a wrong message type", async () => {
|
||||
@@ -87,7 +118,7 @@ describe("signIn.popup completion gate", () => {
|
||||
post(AUTH_ORIGIN, { type: OAUTH_POPUP_MESSAGE_TYPE, nonce, token: "real" });
|
||||
|
||||
await result;
|
||||
expect(localStorage.getItem(POPUP_TOKEN_STORAGE_KEY)).toBe("real");
|
||||
expect(window.localStorage.getItem(POPUP_TOKEN_STORAGE_KEY)).toBe("real");
|
||||
});
|
||||
|
||||
it("relays a completion error from the auth origin", async () => {
|
||||
@@ -110,7 +141,7 @@ describe("popup bearer fetch plugin", () => {
|
||||
({ request: new Request(`${AUTH_ORIGIN}/api/auth${path}`) }) as never;
|
||||
|
||||
it("attaches the stored token only when embedded", async () => {
|
||||
localStorage.setItem(POPUP_TOKEN_STORAGE_KEY, "tok");
|
||||
window.localStorage.setItem(POPUP_TOKEN_STORAGE_KEY, "tok");
|
||||
|
||||
const topLevel = await popupBearerFetchPlugin.hooks!.onRequest!({
|
||||
headers: new Headers(),
|
||||
@@ -129,8 +160,8 @@ describe("popup bearer fetch plugin", () => {
|
||||
});
|
||||
|
||||
it("clears the token when the session ends", async () => {
|
||||
localStorage.setItem(POPUP_TOKEN_STORAGE_KEY, "tok");
|
||||
window.localStorage.setItem(POPUP_TOKEN_STORAGE_KEY, "tok");
|
||||
await popupBearerFetchPlugin.hooks!.onSuccess!(request("/sign-out"));
|
||||
expect(localStorage.getItem(POPUP_TOKEN_STORAGE_KEY)).toBeNull();
|
||||
expect(window.localStorage.getItem(POPUP_TOKEN_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,10 @@ import { createHash } from "node:crypto";
|
||||
import { betterFetch } from "@better-fetch/fetch";
|
||||
import { OAuth2Server } from "oauth2-mock-server";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createAuthClient } from "../../client";
|
||||
import { parseSetCookieHeader } from "../../cookies";
|
||||
import { getTestInstance } from "../../test-utils/test-instance";
|
||||
import { bearer } from "../bearer";
|
||||
import { genericOAuth } from "../generic-oauth";
|
||||
import { genericOAuthClient } from "../generic-oauth/client";
|
||||
import {
|
||||
OAUTH_POPUP_COMPLETE_SCRIPT,
|
||||
OAUTH_POPUP_DATA_ELEMENT_ID,
|
||||
@@ -67,12 +65,6 @@ describe("oauth popup server flow", async () => {
|
||||
],
|
||||
});
|
||||
|
||||
const authClient = createAuthClient({
|
||||
plugins: [genericOAuthClient()],
|
||||
baseURL: popupOrigin,
|
||||
fetchOptions: { customFetchImpl },
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
await server.issuer.keys.generate("RS256");
|
||||
});
|
||||
@@ -161,14 +153,26 @@ describe("oauth popup server flow", async () => {
|
||||
|
||||
it("keeps the redirect when it is not a popup flow", async () => {
|
||||
const signInHeaders = new Headers();
|
||||
const signInRes = await authClient.signIn.oauth2({
|
||||
providerId,
|
||||
callbackURL: `${popupOrigin}/dashboard`,
|
||||
fetchOptions: { onSuccess: cookieSetter(signInHeaders) },
|
||||
const signInRes = await customFetchImpl(
|
||||
`${popupOrigin}/api/auth/sign-in/social`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
provider: providerId,
|
||||
callbackURL: `${popupOrigin}/dashboard`,
|
||||
disableRedirect: true,
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
redirect: "manual",
|
||||
},
|
||||
);
|
||||
cookieSetter(signInHeaders)({
|
||||
response: signInRes,
|
||||
});
|
||||
const signInBody = (await signInRes.json()) as { url?: string };
|
||||
|
||||
let location = "";
|
||||
await betterFetch(signInRes.data?.url || "", {
|
||||
await betterFetch(signInBody.url || "", {
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
onError(context) {
|
||||
@@ -199,7 +203,7 @@ describe("oauth popup server flow", async () => {
|
||||
).searchParams.get("state");
|
||||
|
||||
const callbackRes = await customFetchImpl(
|
||||
`${popupOrigin}/api/auth/oauth2/callback/${providerId}?state=${state}&error=access_denied`,
|
||||
`${popupOrigin}/api/auth/callback/${providerId}?state=${state}&error=access_denied`,
|
||||
{ method: "GET", headers: cookies, redirect: "manual" },
|
||||
);
|
||||
|
||||
|
||||
+53
-32
@@ -6,6 +6,7 @@ import { promisify } from "node:util";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const DECLARATION_EMIT_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* @see https://github.com/better-auth/better-auth/issues/7039
|
||||
@@ -70,8 +71,10 @@ describe("organizationClient declaration emit with additionalFields", () => {
|
||||
};
|
||||
};
|
||||
|
||||
it("should not produce TS2742 when organizationClient uses additionalFields", async () => {
|
||||
const { dir, cleanup } = createTempProject(`
|
||||
it(
|
||||
"should not produce TS2742 when organizationClient uses additionalFields",
|
||||
async () => {
|
||||
const { dir, cleanup } = createTempProject(`
|
||||
import { createAuthClient } from "better-auth/client";
|
||||
import { organizationClient } from "better-auth/client/plugins";
|
||||
|
||||
@@ -95,23 +98,33 @@ export const authClient = createAuthClient({
|
||||
});
|
||||
`);
|
||||
|
||||
try {
|
||||
const tscPath = path.resolve(__dirname, "../../../node_modules/.bin/tsc");
|
||||
const { stderr } = await execAsync(`${tscPath} --project tsconfig.json`, {
|
||||
cwd: dir,
|
||||
});
|
||||
expect(stderr).toBe("");
|
||||
} catch (error: unknown) {
|
||||
const err = error as { stdout: string; stderr: string };
|
||||
const output = (err.stdout || "") + (err.stderr || "");
|
||||
expect(output).not.toContain("TS2742");
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
try {
|
||||
const tscPath = path.resolve(
|
||||
__dirname,
|
||||
"../../../node_modules/.bin/tsc",
|
||||
);
|
||||
const { stderr } = await execAsync(
|
||||
`${tscPath} --project tsconfig.json`,
|
||||
{
|
||||
cwd: dir,
|
||||
},
|
||||
);
|
||||
expect(stderr).toBe("");
|
||||
} catch (error: unknown) {
|
||||
const err = error as { stdout: string; stderr: string };
|
||||
const output = (err.stdout || "") + (err.stderr || "");
|
||||
expect(output).not.toContain("TS2742");
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
},
|
||||
DECLARATION_EMIT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it("should not produce TS2742 when organizationClient is used without additionalFields", async () => {
|
||||
const { dir, cleanup } = createTempProject(`
|
||||
it(
|
||||
"should not produce TS2742 when organizationClient is used without additionalFields",
|
||||
async () => {
|
||||
const { dir, cleanup } = createTempProject(`
|
||||
import { createAuthClient } from "better-auth/client";
|
||||
import { organizationClient } from "better-auth/client/plugins";
|
||||
|
||||
@@ -125,18 +138,26 @@ export const authClient = createAuthClient({
|
||||
});
|
||||
`);
|
||||
|
||||
try {
|
||||
const tscPath = path.resolve(__dirname, "../../../node_modules/.bin/tsc");
|
||||
const { stderr } = await execAsync(`${tscPath} --project tsconfig.json`, {
|
||||
cwd: dir,
|
||||
});
|
||||
expect(stderr).toBe("");
|
||||
} catch (error: unknown) {
|
||||
const err = error as { stdout: string; stderr: string };
|
||||
const output = (err.stdout || "") + (err.stderr || "");
|
||||
expect(output).not.toContain("TS2742");
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
try {
|
||||
const tscPath = path.resolve(
|
||||
__dirname,
|
||||
"../../../node_modules/.bin/tsc",
|
||||
);
|
||||
const { stderr } = await execAsync(
|
||||
`${tscPath} --project tsconfig.json`,
|
||||
{
|
||||
cwd: dir,
|
||||
},
|
||||
);
|
||||
expect(stderr).toBe("");
|
||||
} catch (error: unknown) {
|
||||
const err = error as { stdout: string; stderr: string };
|
||||
const output = (err.stdout || "") + (err.stderr || "");
|
||||
expect(output).not.toContain("TS2742");
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
},
|
||||
DECLARATION_EMIT_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -756,21 +756,21 @@ export const acceptInvitation = <O extends OrganizationOptions>(options: O) =>
|
||||
if (
|
||||
ctx.context.orgOptions.teams &&
|
||||
ctx.context.orgOptions.teams.enabled &&
|
||||
"teamId" in invitation &&
|
||||
invitation.teamId
|
||||
"teamId" in acceptedI &&
|
||||
acceptedI.teamId
|
||||
) {
|
||||
const teamIds = (invitation.teamId as string).split(",");
|
||||
const teamIds = (acceptedI.teamId as string).split(",");
|
||||
const onlyOne = teamIds.length === 1;
|
||||
|
||||
for (const teamId of teamIds) {
|
||||
// Confirm the team still belongs to the invitation's
|
||||
// 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: invitation.organizationId,
|
||||
organizationId: acceptedI.organizationId,
|
||||
});
|
||||
if (!team) {
|
||||
throw APIError.from(
|
||||
@@ -789,7 +789,7 @@ export const acceptInvitation = <O extends OrganizationOptions>(options: O) =>
|
||||
? await ctx.context.orgOptions.teams.maximumMembersPerTeam({
|
||||
teamId,
|
||||
session: session,
|
||||
organizationId: invitation.organizationId,
|
||||
organizationId: acceptedI.organizationId,
|
||||
})
|
||||
: ctx.context.orgOptions.teams.maximumMembersPerTeam;
|
||||
|
||||
@@ -828,15 +828,15 @@ export const acceptInvitation = <O extends OrganizationOptions>(options: O) =>
|
||||
}
|
||||
|
||||
const createdMember = await adapter.createMember({
|
||||
organizationId: invitation.organizationId,
|
||||
organizationId: acceptedI.organizationId,
|
||||
userId: session.user.id,
|
||||
role: invitation.role,
|
||||
role: acceptedI.role,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
await adapter.setActiveOrganization(
|
||||
session.session.token,
|
||||
invitation.organizationId,
|
||||
acceptedI.organizationId,
|
||||
ctx,
|
||||
);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as z from "zod";
|
||||
import { getSessionFromCtx, sessionMiddleware } from "../../../api";
|
||||
import type { InferAdditionalFieldsFromPluginOptions } from "../../../db";
|
||||
import { toZodSchema } from "../../../db/to-zod";
|
||||
import { defaultRoles } from "../access/statement";
|
||||
import { getOrgAdapter } from "../adapter";
|
||||
import { orgMiddleware, orgSessionMiddleware } from "../call";
|
||||
import { ORGANIZATION_ERROR_CODES } from "../error-codes";
|
||||
@@ -569,11 +570,52 @@ export const updateMemberRole = <O extends OrganizationOptions>(option: O) =>
|
||||
}
|
||||
|
||||
const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions);
|
||||
const roleToSet: string[] = Array.isArray(ctx.body.role)
|
||||
? ctx.body.role
|
||||
: ctx.body.role
|
||||
? [ctx.body.role]
|
||||
: [];
|
||||
const roleToSet: string[] = (
|
||||
Array.isArray(ctx.body.role) ? ctx.body.role : [ctx.body.role]
|
||||
)
|
||||
.flatMap((role) => role.split(","))
|
||||
.map((role) => role.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (roleToSet.length === 0) {
|
||||
throw APIError.fromStatus("BAD_REQUEST");
|
||||
}
|
||||
|
||||
const validStaticRoles = new Set([
|
||||
...Object.keys(defaultRoles),
|
||||
...Object.keys(ctx.context.orgOptions.roles || {}),
|
||||
]);
|
||||
const unknownRoles = roleToSet.filter(
|
||||
(role) => !validStaticRoles.has(role),
|
||||
);
|
||||
if (unknownRoles.length > 0) {
|
||||
if (ctx.context.orgOptions.dynamicAccessControl?.enabled) {
|
||||
const foundRoles = await ctx.context.adapter.findMany<{
|
||||
role: string;
|
||||
}>({
|
||||
model: "organizationRole",
|
||||
where: [
|
||||
{ field: "organizationId", value: organizationId },
|
||||
{ field: "role", value: unknownRoles, operator: "in" },
|
||||
],
|
||||
});
|
||||
const foundRoleNames = foundRoles.map((role) => role.role);
|
||||
const stillInvalid = unknownRoles.filter(
|
||||
(role) => !foundRoleNames.includes(role),
|
||||
);
|
||||
if (stillInvalid.length > 0) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
code: ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND.code,
|
||||
message: `${ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND.code}: ${stillInvalid.join(", ")}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
code: ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND.code,
|
||||
message: `${ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND.code}: ${unknownRoles.join(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const member = await adapter.findMemberByOrgId({
|
||||
userId: session.user.id,
|
||||
@@ -691,7 +733,7 @@ export const updateMemberRole = <O extends OrganizationOptions>(option: O) =>
|
||||
}
|
||||
|
||||
const previousRole = toBeUpdatedMember.role;
|
||||
const newRole = parseRoles(ctx.body.role as string | string[]);
|
||||
const newRole = parseRoles(roleToSet);
|
||||
|
||||
// Run beforeUpdateMemberRole hook
|
||||
if (option?.organizationHooks?.beforeUpdateMemberRole) {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { createAuthEndpoint } from "@better-auth/core/api";
|
||||
import {
|
||||
getCurrentAdapter,
|
||||
runWithTransaction,
|
||||
} from "@better-auth/core/context";
|
||||
import { APIError } from "@better-auth/core/error";
|
||||
import * as z from "zod";
|
||||
import { getSessionFromCtx } from "../../../api";
|
||||
@@ -343,7 +347,39 @@ export const removeTeam = <O extends OrganizationOptions>(options: O) =>
|
||||
});
|
||||
}
|
||||
|
||||
await adapter.deleteTeam(team.id);
|
||||
await runWithTransaction(ctx.context.adapter, async () => {
|
||||
await adapter.deleteTeam(team.id);
|
||||
|
||||
// Drop the removed team from pending invitations so they stay
|
||||
// acceptable; an emptied list degrades to an org-level invitation.
|
||||
const pendingInvitations = await adapter.findPendingInvitations({
|
||||
organizationId,
|
||||
});
|
||||
const trx = await getCurrentAdapter(ctx.context.adapter);
|
||||
for (const invitation of pendingInvitations) {
|
||||
if (!("teamId" in invitation) || !invitation.teamId) {
|
||||
continue;
|
||||
}
|
||||
const teamIds = (invitation.teamId as string).split(",");
|
||||
if (!teamIds.includes(team.id)) {
|
||||
continue;
|
||||
}
|
||||
const remainingTeamIds = teamIds.filter((id) => id !== team.id);
|
||||
await trx.update({
|
||||
model: "invitation",
|
||||
where: [
|
||||
{
|
||||
field: "id",
|
||||
value: invitation.id,
|
||||
},
|
||||
],
|
||||
update: {
|
||||
teamId:
|
||||
remainingTeamIds.length > 0 ? remainingTeamIds.join(",") : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Run afterDeleteTeam hook
|
||||
if (options?.organizationHooks?.afterDeleteTeam) {
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { BetterAuthOptions } from "../../types";
|
||||
import { createAdapterFactory } from "./factory";
|
||||
import type { CleanedWhere, CustomAdapter, Where } from "./index";
|
||||
|
||||
type Row = Record<string, any>;
|
||||
|
||||
/**
|
||||
* A minimal in-memory adapter backed by a plain `Record<string, Row[]>` store.
|
||||
* It deliberately does NOT implement `incrementOne`, so the factory's
|
||||
* transaction-based fallback runs. It DOES provide a `transaction`
|
||||
* implementation (operations run against the same single-process store), which
|
||||
* matches the "no real isolation" scenario the compare-and-swap guard is built
|
||||
* for.
|
||||
*/
|
||||
function createMemoryAdapter(initial: Record<string, Row[]> = {}) {
|
||||
const store: Record<string, Row[]> = {};
|
||||
for (const [model, rows] of Object.entries(initial)) {
|
||||
store[model] = rows.map((row) => ({ ...row }));
|
||||
}
|
||||
|
||||
const getTable = (model: string): Row[] => {
|
||||
if (!store[model]) store[model] = [];
|
||||
return store[model];
|
||||
};
|
||||
|
||||
const matches = (row: Row, where: CleanedWhere[] | undefined): boolean => {
|
||||
if (!where || where.length === 0) return true;
|
||||
return where.every((clause) => {
|
||||
const value = row[clause.field];
|
||||
switch (clause.operator) {
|
||||
case "gt":
|
||||
return typeof value === "number" && value > (clause.value as number);
|
||||
case "gte":
|
||||
return typeof value === "number" && value >= (clause.value as number);
|
||||
case "lt":
|
||||
return typeof value === "number" && value < (clause.value as number);
|
||||
case "lte":
|
||||
return typeof value === "number" && value <= (clause.value as number);
|
||||
case "ne":
|
||||
return value !== clause.value;
|
||||
default:
|
||||
return value === clause.value;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
const adapter: CustomAdapter = {
|
||||
create: async <T extends Row>({
|
||||
model,
|
||||
data,
|
||||
}: {
|
||||
model: string;
|
||||
data: T;
|
||||
}) => {
|
||||
const row: Row = { ...data };
|
||||
if (row.id === undefined || row.id === null) {
|
||||
idCounter += 1;
|
||||
row.id = `row-${idCounter}`;
|
||||
}
|
||||
getTable(model).push(row);
|
||||
return row as T;
|
||||
},
|
||||
update: async <T>({
|
||||
model,
|
||||
where,
|
||||
update,
|
||||
}: {
|
||||
model: string;
|
||||
where: CleanedWhere[];
|
||||
update: T;
|
||||
}) => {
|
||||
const table = getTable(model);
|
||||
const row = table.find((r) => matches(r, where));
|
||||
if (!row) return null as T | null;
|
||||
Object.assign(row, update as Row);
|
||||
return row as T;
|
||||
},
|
||||
updateMany: async ({
|
||||
model,
|
||||
where,
|
||||
update,
|
||||
}: {
|
||||
model: string;
|
||||
where: CleanedWhere[];
|
||||
update: Row;
|
||||
}) => {
|
||||
const table = getTable(model);
|
||||
let count = 0;
|
||||
for (const row of table) {
|
||||
if (matches(row, where)) {
|
||||
Object.assign(row, update);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
},
|
||||
findOne: async <T>({
|
||||
model,
|
||||
where,
|
||||
}: {
|
||||
model: string;
|
||||
where: CleanedWhere[];
|
||||
}) => {
|
||||
const row = getTable(model).find((r) => matches(r, where));
|
||||
return (row ? { ...row } : null) as T | null;
|
||||
},
|
||||
findMany: async <T>({
|
||||
model,
|
||||
where,
|
||||
limit,
|
||||
}: {
|
||||
model: string;
|
||||
where?: CleanedWhere[] | undefined;
|
||||
limit: number;
|
||||
}) => {
|
||||
const rows = getTable(model)
|
||||
.filter((r) => matches(r, where))
|
||||
.slice(0, limit)
|
||||
.map((r) => ({ ...r }));
|
||||
return rows as T[];
|
||||
},
|
||||
delete: async ({
|
||||
model,
|
||||
where,
|
||||
}: {
|
||||
model: string;
|
||||
where: CleanedWhere[];
|
||||
}) => {
|
||||
const table = getTable(model);
|
||||
const index = table.findIndex((r) => matches(r, where));
|
||||
if (index !== -1) table.splice(index, 1);
|
||||
},
|
||||
deleteMany: async ({
|
||||
model,
|
||||
where,
|
||||
}: {
|
||||
model: string;
|
||||
where: CleanedWhere[];
|
||||
}) => {
|
||||
const table = getTable(model);
|
||||
let count = 0;
|
||||
for (let i = table.length - 1; i >= 0; i--) {
|
||||
const row = table[i];
|
||||
if (row && matches(row, where)) {
|
||||
table.splice(i, 1);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
},
|
||||
count: async ({
|
||||
model,
|
||||
where,
|
||||
}: {
|
||||
model: string;
|
||||
where?: CleanedWhere[] | undefined;
|
||||
}) => getTable(model).filter((r) => matches(r, where)).length,
|
||||
};
|
||||
|
||||
return { adapter, store };
|
||||
}
|
||||
|
||||
// The `rateLimit` table is the simplest always-numeric core model: `key`
|
||||
// (string selector) and `count` (number) plus `lastRequest` (number). Enabling
|
||||
// database storage registers it in the resolved schema.
|
||||
const counterModel = "rateLimit";
|
||||
const options: BetterAuthOptions = {
|
||||
rateLimit: { storage: "database" },
|
||||
};
|
||||
|
||||
function createTestAdapter(adapter: CustomAdapter) {
|
||||
return createAdapterFactory<BetterAuthOptions>({
|
||||
config: {
|
||||
adapterId: "memory-test-adapter",
|
||||
adapterName: "Memory Test Adapter",
|
||||
usePlural: false,
|
||||
transaction: (callback) => callback(adapter as any),
|
||||
},
|
||||
adapter: () => adapter,
|
||||
})(options);
|
||||
}
|
||||
|
||||
const seed = (rows: Row[]): Record<string, Row[]> => ({ [counterModel]: rows });
|
||||
|
||||
const firstRow = (store: Record<string, Row[]>): Row => {
|
||||
const row = store[counterModel]?.[0];
|
||||
if (!row) throw new Error(`expected at least one ${counterModel} row`);
|
||||
return row;
|
||||
};
|
||||
|
||||
describe("createAdapterFactory incrementOne fallback", () => {
|
||||
it("applies a positive delta and returns the updated row", async () => {
|
||||
const { adapter } = createMemoryAdapter(seed([{ key: "a", count: 0 }]));
|
||||
const factory = createTestAdapter(adapter);
|
||||
|
||||
const result = await factory.incrementOne<{ key: string; count: number }>({
|
||||
model: counterModel,
|
||||
where: [{ field: "key", value: "a" }],
|
||||
increment: { count: 1 },
|
||||
});
|
||||
|
||||
expect(result?.count).toBe(1);
|
||||
});
|
||||
|
||||
it("applies a negative delta to decrement", async () => {
|
||||
const { adapter } = createMemoryAdapter(seed([{ key: "a", count: 5 }]));
|
||||
const factory = createTestAdapter(adapter);
|
||||
|
||||
const result = await factory.incrementOne<{ key: string; count: number }>({
|
||||
model: counterModel,
|
||||
where: [{ field: "key", value: "a" }],
|
||||
increment: { count: -2 },
|
||||
});
|
||||
|
||||
expect(result?.count).toBe(3);
|
||||
});
|
||||
|
||||
it("assigns absolute values via `set` in the same call", async () => {
|
||||
const { adapter, store } = createMemoryAdapter(
|
||||
seed([{ key: "a", count: 0, lastRequest: 100 }]),
|
||||
);
|
||||
const factory = createTestAdapter(adapter);
|
||||
|
||||
const result = await factory.incrementOne<{
|
||||
key: string;
|
||||
count: number;
|
||||
lastRequest: number;
|
||||
}>({
|
||||
model: counterModel,
|
||||
where: [{ field: "key", value: "a" }],
|
||||
increment: { count: 1 },
|
||||
set: { lastRequest: 999 },
|
||||
});
|
||||
|
||||
expect(result?.count).toBe(1);
|
||||
expect(result?.lastRequest).toBe(999);
|
||||
expect(firstRow(store).lastRequest).toBe(999);
|
||||
});
|
||||
|
||||
it("returns null and does not mutate when the guard matches no row", async () => {
|
||||
const { adapter, store } = createMemoryAdapter(
|
||||
seed([{ key: "a", count: 0 }]),
|
||||
);
|
||||
const factory = createTestAdapter(adapter);
|
||||
|
||||
const result = await factory.incrementOne({
|
||||
model: counterModel,
|
||||
where: [{ field: "count", operator: "gt", value: 0 }],
|
||||
increment: { count: -1 },
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(firstRow(store).count).toBe(0);
|
||||
});
|
||||
|
||||
it("yields a single winner under contention and never goes negative", async () => {
|
||||
const { adapter, store } = createMemoryAdapter(
|
||||
seed([{ key: "a", count: 1 }]),
|
||||
);
|
||||
const factory = createTestAdapter(adapter);
|
||||
|
||||
const claim = () =>
|
||||
factory.incrementOne<{ key: string; count: number }>({
|
||||
model: counterModel,
|
||||
where: [{ field: "count", operator: "gt", value: 0 }],
|
||||
increment: { count: -1 },
|
||||
});
|
||||
|
||||
const results = await Promise.all([claim(), claim()]);
|
||||
const winners = results.filter((r) => r !== null);
|
||||
|
||||
expect(winners).toHaveLength(1);
|
||||
expect(firstRow(store).count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAdapterFactory incrementOne native path", () => {
|
||||
it("delegates to a native incrementOne when implemented", async () => {
|
||||
const { adapter } = createMemoryAdapter(seed([{ key: "a", count: 10 }]));
|
||||
let nativeCalls = 0;
|
||||
const nativeAdapter: CustomAdapter = {
|
||||
...adapter,
|
||||
incrementOne: async <T>({
|
||||
model,
|
||||
where,
|
||||
increment,
|
||||
set,
|
||||
}: {
|
||||
model: string;
|
||||
where: CleanedWhere[];
|
||||
increment: Record<string, number>;
|
||||
set?: Record<string, unknown> | undefined;
|
||||
}) => {
|
||||
nativeCalls += 1;
|
||||
const rows = await adapter.findMany<Row>({ model, where, limit: 1 });
|
||||
const target = rows[0];
|
||||
if (!target) return null as T | null;
|
||||
const next: Row = { ...(set ?? {}) };
|
||||
for (const [field, delta] of Object.entries(increment)) {
|
||||
const current = typeof target[field] === "number" ? target[field] : 0;
|
||||
next[field] = current + delta;
|
||||
}
|
||||
const idWhere: CleanedWhere[] = [
|
||||
{
|
||||
field: "id",
|
||||
value: target.id,
|
||||
operator: "eq",
|
||||
connector: "AND",
|
||||
mode: "sensitive",
|
||||
},
|
||||
];
|
||||
await adapter.updateMany({ model, where: idWhere, update: next });
|
||||
return { ...target, ...next } as T;
|
||||
},
|
||||
};
|
||||
|
||||
const factory = createTestAdapter(nativeAdapter);
|
||||
|
||||
const result = await factory.incrementOne<{ key: string; count: number }>({
|
||||
model: counterModel,
|
||||
where: [{ field: "key", value: "a" }] satisfies Where[],
|
||||
increment: { count: 5 },
|
||||
});
|
||||
|
||||
expect(nativeCalls).toBe(1);
|
||||
expect(result?.count).toBe(15);
|
||||
});
|
||||
});
|
||||
@@ -608,25 +608,6 @@ export interface CustomAdapter {
|
||||
increment: Record<string, number>;
|
||||
set?: Record<string, unknown> | undefined;
|
||||
}) => Promise<T | null>;
|
||||
/**
|
||||
* Optional native atomic guarded counter mutation. Applies
|
||||
* `field = field + delta` for each entry in `increment` (negative deltas
|
||||
* decrement), with `where` acting as both selector and guard and `set`
|
||||
* assigning absolute values in the same operation. Returns the updated row,
|
||||
* or `null` when the guard matched no row.
|
||||
*
|
||||
* Implementing this natively (e.g. `UPDATE ... SET n = n + $delta WHERE ...
|
||||
* RETURNING *`) gives one round trip and the strongest race-safety
|
||||
* guarantee. When omitted, the adapter factory provides a transaction-based
|
||||
* fallback over `findMany + updateMany`. TODO(increment-one-required):
|
||||
* tighten to required in the next minor on `next`.
|
||||
*/
|
||||
incrementOne?: <T>(data: {
|
||||
model: string;
|
||||
where: CleanedWhere[];
|
||||
increment: Record<string, number>;
|
||||
set?: Record<string, unknown> | undefined;
|
||||
}) => Promise<T | null>;
|
||||
count: ({
|
||||
model,
|
||||
where,
|
||||
|
||||
Reference in New Issue
Block a user