chore: resolve main to next sync validation

This commit is contained in:
Gustavo Valverde
2026-06-11 19:27:46 -07:00
parent d2949a8700
commit 79b698cbae
10 changed files with 212 additions and 424 deletions
@@ -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" },
);
@@ -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);
});
});
-19
View File
@@ -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,