From 79b698cbae04fb38f57b2d299e2cd63cf417e01e Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Thu, 11 Jun 2026 19:27:46 -0700 Subject: [PATCH] chore: resolve main to next sync validation --- .../src/api/routes/account.test.ts | 10 +- .../src/plugins/oauth-popup/index.ts | 4 +- .../oauth-popup/oauth-popup.client.test.ts | 45 ++- .../plugins/oauth-popup/oauth-popup.test.ts | 32 +- .../organization-client-declaration.test.ts | 85 +++-- .../organization/routes/crud-invites.ts | 18 +- .../organization/routes/crud-members.ts | 54 ++- .../plugins/organization/routes/crud-team.ts | 38 +- .../core/src/db/adapter/increment-one.test.ts | 331 ------------------ packages/core/src/db/adapter/index.ts | 19 - 10 files changed, 212 insertions(+), 424 deletions(-) delete mode 100644 packages/core/src/db/adapter/increment-one.test.ts diff --git a/packages/better-auth/src/api/routes/account.test.ts b/packages/better-auth/src/api/routes/account.test.ts index a7e890e648..078df2fd32 100644 --- a/packages/better-auth/src/api/routes/account.test.ts +++ b/packages/better-auth/src/api/routes/account.test.ts @@ -2392,10 +2392,14 @@ describe("account resolution in stateless mode", async () => { const signIn = async (auth: ReturnType) => { 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" }, ), ); diff --git a/packages/better-auth/src/plugins/oauth-popup/index.ts b/packages/better-auth/src/plugins/oauth-popup/index.ts index d2a7beb0e2..a05dbc04b8 100644 --- a/packages/better-auth/src/plugins/oauth-popup/index.ts +++ b/packages/better-auth/src/plugins/oauth-popup/index.ts @@ -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."); diff --git a/packages/better-auth/src/plugins/oauth-popup/oauth-popup.client.test.ts b/packages/better-auth/src/plugins/oauth-popup/oauth-popup.client.test.ts index f94c118caa..c8c247670f 100644 --- a/packages/better-auth/src/plugins/oauth-popup/oauth-popup.client.test.ts +++ b/packages/better-auth/src/plugins/oauth-popup/oauth-popup.client.test.ts @@ -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(); + 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(); }); }); diff --git a/packages/better-auth/src/plugins/oauth-popup/oauth-popup.test.ts b/packages/better-auth/src/plugins/oauth-popup/oauth-popup.test.ts index f406a56517..d5c10f6d54 100644 --- a/packages/better-auth/src/plugins/oauth-popup/oauth-popup.test.ts +++ b/packages/better-auth/src/plugins/oauth-popup/oauth-popup.test.ts @@ -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" }, ); diff --git a/packages/better-auth/src/plugins/organization/organization-client-declaration.test.ts b/packages/better-auth/src/plugins/organization/organization-client-declaration.test.ts index e081237467..bc0faeeec7 100644 --- a/packages/better-auth/src/plugins/organization/organization-client-declaration.test.ts +++ b/packages/better-auth/src/plugins/organization/organization-client-declaration.test.ts @@ -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, + ); }); diff --git a/packages/better-auth/src/plugins/organization/routes/crud-invites.ts b/packages/better-auth/src/plugins/organization/routes/crud-invites.ts index 49b5a691d8..f4bfe80b85 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-invites.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-invites.ts @@ -756,21 +756,21 @@ export const acceptInvitation = (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 = (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 = (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, ); diff --git a/packages/better-auth/src/plugins/organization/routes/crud-members.ts b/packages/better-auth/src/plugins/organization/routes/crud-members.ts index 6b8d649dd9..c4b63f45c5 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-members.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-members.ts @@ -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 = (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 = (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) { diff --git a/packages/better-auth/src/plugins/organization/routes/crud-team.ts b/packages/better-auth/src/plugins/organization/routes/crud-team.ts index 57c53a00f3..a77186b15d 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-team.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-team.ts @@ -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 = (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) { diff --git a/packages/core/src/db/adapter/increment-one.test.ts b/packages/core/src/db/adapter/increment-one.test.ts deleted file mode 100644 index 9efe3c58d7..0000000000 --- a/packages/core/src/db/adapter/increment-one.test.ts +++ /dev/null @@ -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; - -/** - * A minimal in-memory adapter backed by a plain `Record` 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 = {}) { - const store: Record = {}; - 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 ({ - 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 ({ - 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 ({ - model, - where, - }: { - model: string; - where: CleanedWhere[]; - }) => { - const row = getTable(model).find((r) => matches(r, where)); - return (row ? { ...row } : null) as T | null; - }, - findMany: async ({ - 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({ - 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 => ({ [counterModel]: rows }); - -const firstRow = (store: Record): 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 ({ - model, - where, - increment, - set, - }: { - model: string; - where: CleanedWhere[]; - increment: Record; - set?: Record | undefined; - }) => { - nativeCalls += 1; - const rows = await adapter.findMany({ 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); - }); -}); diff --git a/packages/core/src/db/adapter/index.ts b/packages/core/src/db/adapter/index.ts index 00b8e78351..3893a87fe7 100644 --- a/packages/core/src/db/adapter/index.ts +++ b/packages/core/src/db/adapter/index.ts @@ -608,25 +608,6 @@ export interface CustomAdapter { increment: Record; set?: Record | undefined; }) => Promise; - /** - * 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?: (data: { - model: string; - where: CleanedWhere[]; - increment: Record; - set?: Record | undefined; - }) => Promise; count: ({ model, where,