From 0cbddb8fa4eb19fbca75e9822134f89b3604286a Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Tue, 12 May 2026 08:44:07 +0100 Subject: [PATCH] refactor(db): rename `claimOne` adapter primitive to `consumeOne` (#9568) --- ...ive.md => atomic-consume-one-primitive.md} | 2 +- .../better-auth/src/db/internal-adapter.ts | 8 ++-- packages/better-auth/src/db/with-hooks.ts | 22 +++++----- packages/cli/src/commands/generate.ts | 2 +- packages/cli/test/generate.test.ts | 4 +- packages/core/src/db/adapter/factory.test.ts | 16 ++++--- packages/core/src/db/adapter/factory.ts | 43 ++++++++++--------- packages/core/src/db/adapter/index.ts | 16 +++---- packages/core/src/db/adapter/types.ts | 2 +- .../drizzle-adapter/src/drizzle-adapter.ts | 2 +- .../kysely-adapter/src/kysely-adapter.test.ts | 4 +- packages/kysely-adapter/src/kysely-adapter.ts | 2 +- packages/memory-adapter/src/memory-adapter.ts | 2 +- .../mongo-adapter/src/mongodb-adapter.test.ts | 4 +- packages/mongo-adapter/src/mongodb-adapter.ts | 2 +- .../prisma-adapter/src/prisma-adapter.test.ts | 8 ++-- packages/prisma-adapter/src/prisma-adapter.ts | 4 +- 17 files changed, 74 insertions(+), 69 deletions(-) rename .changeset/{atomic-claim-one-primitive.md => atomic-consume-one-primitive.md} (56%) diff --git a/.changeset/atomic-claim-one-primitive.md b/.changeset/atomic-consume-one-primitive.md similarity index 56% rename from .changeset/atomic-claim-one-primitive.md rename to .changeset/atomic-consume-one-primitive.md index 8240707d28..8597d3a39f 100644 --- a/.changeset/atomic-claim-one-primitive.md +++ b/.changeset/atomic-consume-one-primitive.md @@ -9,4 +9,4 @@ "better-auth": patch --- -Add `internalAdapter.consumeVerificationValue(identifier)`: atomically consume a verification row keyed by identifier. The first concurrent caller receives the row; later racers receive `null`. Backed by a new `DBAdapter.claimOne` primitive implemented natively per adapter (memory, mongo, drizzle, kysely, prisma), with a `transaction(findMany + delete)` factory fallback. `SecondaryStorage.getAndDelete` is added as an optional companion; Redis ships it via an atomic Lua get-and-delete operation for compatibility with Redis versions before 6.2. +Add `internalAdapter.consumeVerificationValue(identifier)`: atomically consume a verification row keyed by identifier. The first concurrent caller receives the row; later racers receive `null`. Backed by a new `DBAdapter.consumeOne` primitive implemented natively per adapter (memory, mongo, drizzle, kysely, prisma), with a `transaction(findMany + delete)` factory fallback. `SecondaryStorage.getAndDelete` is added as an optional companion; Redis ships it via an atomic Lua get-and-delete operation for compatibility with Redis versions before 6.2. diff --git a/packages/better-auth/src/db/internal-adapter.ts b/packages/better-auth/src/db/internal-adapter.ts index 0ad1f792fe..4dc1dbe6e0 100644 --- a/packages/better-auth/src/db/internal-adapter.ts +++ b/packages/better-auth/src/db/internal-adapter.ts @@ -1315,23 +1315,23 @@ export const createInternalAdapter = ( // FIXME(consume-identifier-atomic): add an adapter primitive that // deletes all rows for an identifier and returns the latest row in - // one operation. Until then, claim the latest row as the race gate + // one operation. Until then, consume the latest row as the race gate // and invalidate stale rows inside the same transaction/local lock. const hookWhere = [{ field: "id", value: latest.id }]; return consumeOneWithHooks( "verification", hookWhere, async () => { - const claimed = await txAdapter.claimOne({ + const consumed = await txAdapter.consumeOne({ model: "verification", where: hookWhere, }); - if (!claimed) return null; + if (!consumed) return null; await txAdapter.deleteMany({ model: "verification", where, }); - return claimed; + return consumed; }, latest, ); diff --git a/packages/better-auth/src/db/with-hooks.ts b/packages/better-auth/src/db/with-hooks.ts index 83188674b7..912e35dc9b 100644 --- a/packages/better-auth/src/db/with-hooks.ts +++ b/packages/better-auth/src/db/with-hooks.ts @@ -416,25 +416,25 @@ export function getWithHooks( } /** - * Wraps an atomic claim operation in the plugin `delete.before` and - * `delete.after` hook lifecycle. The caller supplies a `claimFn` that + * Wraps an atomic consume operation in the plugin `delete.before` and + * `delete.after` hook lifecycle. The caller supplies a `consumeFn` that * performs the actual single-row delete-and-return (typically the - * adapter's `claimOne`). The first concurrent caller wins, subsequent + * adapter's `consumeOne`). The first concurrent caller wins, subsequent * racers resolve to `null` without firing `delete.after` hooks. * * `preSnapshot` lets the caller hand in a row it already fetched so * `delete.before` hooks don't trigger a second read. Without it, the * helper falls back to a best-effort `findMany` against `hookWhere`. - * The snapshot only feeds `delete.before`; the `claimFn` return value + * The snapshot only feeds `delete.before`; the `consumeFn` return value * is the race gate. * - * Returning `false` from a `delete.before` hook aborts the claim and - * the helper resolves to `null` (no `claimFn` call, no after hooks). + * Returning `false` from a `delete.before` hook aborts the consume and + * the helper resolves to `null` (no `consumeFn` call, no after hooks). */ async function consumeOneWithHooks>( model: BaseModelNames, hookWhere: Where[], - claimFn: () => Promise, + consumeFn: () => Promise, preSnapshot?: T | null, ): Promise { const context = await getCurrentAuthContext().catch(() => null); @@ -476,8 +476,8 @@ export function getWithHooks( } } - const claimed = await claimFn(); - if (!claimed) return null; + const consumed = await consumeFn(); + if (!consumed) return null; for (const { source, hooks } of hooksEntries) { const toRun = hooks[model]?.delete?.after; @@ -492,13 +492,13 @@ export function getWithHooks( }, () => // @ts-expect-error context type mismatch - toRun(claimed as any, context), + toRun(consumed as any, context), ); }); } } - return claimed; + return consumed; } return { diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index 5cc6fe7fc8..874e4913bc 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -61,7 +61,7 @@ function createMockAdapter(adapterId: string, dialect?: string): DBAdapter { deleteMany: async () => { throw new Error("Mock adapter methods should not be called"); }, - claimOne: async () => { + consumeOne: async () => { throw new Error("Mock adapter methods should not be called"); }, transaction: async (callback) => { diff --git a/packages/cli/test/generate.test.ts b/packages/cli/test/generate.test.ts index e242243520..0cd41a3dc5 100644 --- a/packages/cli/test/generate.test.ts +++ b/packages/cli/test/generate.test.ts @@ -1093,7 +1093,7 @@ describe("--adapter flag support (mock adapter)", () => { deleteMany: async () => { throw new Error("Mock adapter methods should not be called"); }, - claimOne: async () => { + consumeOne: async () => { throw new Error("Mock adapter methods should not be called"); }, transaction: async (callback) => { @@ -1335,7 +1335,7 @@ describe("--dialect flag support", () => { deleteMany: async () => { throw new Error("Mock adapter methods should not be called"); }, - claimOne: async () => { + consumeOne: async () => { throw new Error("Mock adapter methods should not be called"); }, transaction: async (callback) => { diff --git a/packages/core/src/db/adapter/factory.test.ts b/packages/core/src/db/adapter/factory.test.ts index 8998e502c9..5d7386f6b9 100644 --- a/packages/core/src/db/adapter/factory.test.ts +++ b/packages/core/src/db/adapter/factory.test.ts @@ -56,7 +56,7 @@ function createTestAdapter({ }); } -describe("createAdapterFactory claimOne fallback", () => { +describe("createAdapterFactory consumeOne fallback", () => { it("uses transaction adapter methods without double-transforming input or output", async () => { const findMany: CustomAdapter["findMany"] = async ( params: Parameters[0], @@ -111,16 +111,18 @@ describe("createAdapterFactory claimOne fallback", () => { }), }); - const result = await adapter.claimOne<{ id: string; identifier: string }>({ - model: "verification", - where: [{ field: "identifier", value: "token" }], - }); + const result = await adapter.consumeOne<{ id: string; identifier: string }>( + { + model: "verification", + where: [{ field: "identifier", value: "token" }], + }, + ); expect(result?.id).toBe("verification-id"); expect(result?.identifier).toBe("stored-token:output"); }); - it("returns null when the delete loses the claim race", async () => { + it("returns null when the delete loses the consume race", async () => { const adapter = createTestAdapter({ adapter: createCustomAdapter({ findMany: async () => @@ -134,7 +136,7 @@ describe("createAdapterFactory claimOne fallback", () => { }), }); - const result = await adapter.claimOne({ + const result = await adapter.consumeOne({ model: "verification", where: [{ field: "identifier", value: "token" }], }); diff --git a/packages/core/src/db/adapter/factory.ts b/packages/core/src/db/adapter/factory.ts index a0d8b19dff..8742e2119a 100644 --- a/packages/core/src/db/adapter/factory.ts +++ b/packages/core/src/db/adapter/factory.ts @@ -133,7 +133,10 @@ export const createAdapterFactory = !config.debugLogs.deleteMany ) { return; - } else if (method === "claimOne" && !config.debugLogs.claimOne) { + } else if ( + method === "consumeOne" && + !config.debugLogs.consumeOne + ) { return; } else if (method === "count" && !config.debugLogs.count) { return; @@ -487,7 +490,7 @@ export const createAdapterFactory = | "updateMany" | "delete" | "deleteMany" - | "claimOne" + | "consumeOne" | "count"; }): W extends undefined ? undefined : CleanedWhere[] => { if (!where) return undefined as any; @@ -1315,7 +1318,7 @@ export const createAdapterFactory = ); return res; }, - claimOne: async ({ + consumeOne: async ({ model: unsafeModel, where: unsafeWhere, }: { @@ -1328,42 +1331,42 @@ export const createAdapterFactory = const where = transformWhereClause({ model: unsafeModel, where: unsafeWhere, - action: "claimOne", + action: "consumeOne", }); unsafeModel = getDefaultModelName(unsafeModel); debugLog( - { method: "claimOne" }, + { method: "consumeOne" }, `${formatTransactionId(thisTransactionId)} ${formatStep(1, 3)}`, - `${formatMethod("claimOne")} ${formatAction("ClaimOne")}:`, + `${formatMethod("consumeOne")} ${formatAction("ConsumeOne")}:`, { model, where }, ); let res: T | null; let resultNeedsOutputTransform = true; - if (adapterInstance.claimOne) { + if (adapterInstance.consumeOne) { res = await withSpan( - `db claimOne ${model}`, + `db consumeOne ${model}`, { - [ATTR_DB_OPERATION_NAME]: "claimOne", + [ATTR_DB_OPERATION_NAME]: "consumeOne", [ATTR_DB_COLLECTION_NAME]: model, }, - () => adapterInstance.claimOne!({ model, where }), + () => adapterInstance.consumeOne!({ model, where }), ); } else { - // TODO(claim-one-required): adapters without native `claimOne` + // TODO(consume-one-required): adapters without native `consumeOne` // fall back to `transaction(findMany + deleteMany)`. Race-safe on // engines with real transaction isolation; race window narrows // (does not close) on adapters that fall through to sequential - // execution. Remove this branch when claimOne becomes required. - // FIXME(claim-one-nested-transaction): custom adapters without a - // native claimOne have no portable signal for "already inside a + // execution. Remove this branch when consumeOne becomes required. + // FIXME(consume-one-nested-transaction): custom adapters without a + // native consumeOne have no portable signal for "already inside a // transaction". First-party adapters mark transaction-scoped // adapters as as-is; make that capability explicit in the next // breaking adapter contract. res = await withSpan( - `db claimOne ${model}`, + `db consumeOne ${model}`, { - [ATTR_DB_OPERATION_NAME]: "claimOne", + [ATTR_DB_OPERATION_NAME]: "consumeOne", [ATTR_DB_COLLECTION_NAME]: model, }, () => @@ -1395,9 +1398,9 @@ export const createAdapterFactory = } debugLog( - { method: "claimOne" }, + { method: "consumeOne" }, `${formatTransactionId(thisTransactionId)} ${formatStep(2, 3)}`, - `${formatMethod("claimOne")} ${formatAction("DB Result")}:`, + `${formatMethod("consumeOne")} ${formatAction("DB Result")}:`, { model, data: res }, ); let transformed: any = res; @@ -1414,9 +1417,9 @@ export const createAdapterFactory = ); } debugLog( - { method: "claimOne" }, + { method: "consumeOne" }, `${formatTransactionId(thisTransactionId)} ${formatStep(3, 3)}`, - `${formatMethod("claimOne")} ${formatAction("Parsed Result")}:`, + `${formatMethod("consumeOne")} ${formatAction("Parsed Result")}:`, { model, data: transformed }, ); return transformed as T | null; diff --git a/packages/core/src/db/adapter/index.ts b/packages/core/src/db/adapter/index.ts index 631527ce60..46a2459889 100644 --- a/packages/core/src/db/adapter/index.ts +++ b/packages/core/src/db/adapter/index.ts @@ -15,7 +15,7 @@ export type DBAdapterDebugLogOption = findMany?: boolean | undefined; delete?: boolean | undefined; deleteMany?: boolean | undefined; - claimOne?: boolean | undefined; + consumeOne?: boolean | undefined; count?: boolean | undefined; } | { @@ -212,7 +212,7 @@ export interface DBAdapterFactoryConfig< | "updateMany" | "delete" | "deleteMany" - | "claimOne" + | "consumeOne" | "count"; /** * The model name. @@ -448,7 +448,7 @@ export type DBAdapter = { delete: <_T>(data: { model: string; where: Where[] }) => Promise; deleteMany: (data: { model: string; where: Where[] }) => Promise; /** - * Atomically claim a single row matching the where clause: delete it and + * Atomically consume a single row matching the where clause: delete it and * return the deleted row, or return `null` if no row matched. * Implementations MUST NOT delete any additional rows that also match a * non-unique predicate. @@ -459,11 +459,11 @@ export type DBAdapter = { * (verification tokens, authorization codes, one-time tokens). * * Always defined on the factory-wrapped adapter. When the underlying - * `CustomAdapter` does not implement `claimOne`, the factory provides + * `CustomAdapter` does not implement `consumeOne`, the factory provides * a fallback that wraps `findMany + deleteMany` in `transaction(...)` * and returns the row only when the delete reports an affected row. */ - claimOne: (data: { model: string; where: Where[] }) => Promise; + consumeOne: (data: { model: string; where: Where[] }) => Promise; /** * Execute multiple operations in a transaction. * If the adapter doesn't support transactions, operations will be executed sequentially. @@ -551,15 +551,15 @@ export interface CustomAdapter { where: CleanedWhere[]; }) => Promise; /** - * Optional native atomic single-row claim. When omitted, the adapter + * Optional native atomic single-row consume. When omitted, the adapter * factory falls back to `transaction(findMany + deleteMany)`. * Implementing this method natively (e.g. `DELETE ... RETURNING *`, * `findOneAndDelete`, `OUTPUT deleted.*`) gives one round trip and the * strongest race-safety guarantee. Implementations must delete at most - * one matching row. TODO(claim-one-required): tighten to required in the + * one matching row. TODO(consume-one-required): tighten to required in the * next minor on `next`. */ - claimOne?: (data: { + consumeOne?: (data: { model: string; where: CleanedWhere[]; }) => Promise; diff --git a/packages/core/src/db/adapter/types.ts b/packages/core/src/db/adapter/types.ts index aa517558a3..f5879aed52 100644 --- a/packages/core/src/db/adapter/types.ts +++ b/packages/core/src/db/adapter/types.ts @@ -122,7 +122,7 @@ export type AdapterFactoryCustomizeAdapterCreator = (config: { | "updateMany" | "delete" | "deleteMany" - | "claimOne" + | "consumeOne" | "count"; }) => W extends undefined ? undefined : CleanedWhere[]; }) => CustomAdapter; diff --git a/packages/drizzle-adapter/src/drizzle-adapter.ts b/packages/drizzle-adapter/src/drizzle-adapter.ts index 01daea710c..b2ebb04e4a 100644 --- a/packages/drizzle-adapter/src/drizzle-adapter.ts +++ b/packages/drizzle-adapter/src/drizzle-adapter.ts @@ -836,7 +836,7 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => { } return count; }, - async claimOne({ model, where }) { + async consumeOne({ model, where }) { const schemaModel = getSchema(model); const clause = convertWhereClause(where, model); const idField = getFieldName({ model, field: "id" }); diff --git a/packages/kysely-adapter/src/kysely-adapter.test.ts b/packages/kysely-adapter/src/kysely-adapter.test.ts index 79bc05b10d..e809cc1738 100644 --- a/packages/kysely-adapter/src/kysely-adapter.test.ts +++ b/packages/kysely-adapter/src/kysely-adapter.test.ts @@ -22,7 +22,7 @@ describe("kysely-adapter", () => { expect(adapter).toBeDefined(); }); - it("claimOne deletes only the selected row for non-unique predicates", async () => { + it("consumeOne deletes only the selected row for non-unique predicates", async () => { const selectQuery = { select: vi.fn(() => selectQuery), where: vi.fn(() => selectQuery), @@ -44,7 +44,7 @@ describe("kysely-adapter", () => { } as any; const adapter = kyselyAdapter(db)({}); - const result = await adapter.claimOne({ + const result = await adapter.consumeOne({ model: "verification", where: [{ field: "identifier", value: "same-identifier" }], }); diff --git a/packages/kysely-adapter/src/kysely-adapter.ts b/packages/kysely-adapter/src/kysely-adapter.ts index 7d44cf5feb..1619519823 100644 --- a/packages/kysely-adapter/src/kysely-adapter.ts +++ b/packages/kysely-adapter/src/kysely-adapter.ts @@ -663,7 +663,7 @@ export const kyselyAdapter = ( ? Number.MAX_SAFE_INTEGER : Number(res); }, - async claimOne({ model, where }) { + async consumeOne({ model, where }) { const { and, or } = convertWhereClause(model, where); const applyWhere = (query: any) => { if (and) { diff --git a/packages/memory-adapter/src/memory-adapter.ts b/packages/memory-adapter/src/memory-adapter.ts index c635892ba4..bbe96abbfa 100644 --- a/packages/memory-adapter/src/memory-adapter.ts +++ b/packages/memory-adapter/src/memory-adapter.ts @@ -386,7 +386,7 @@ export const memoryAdapter = ( }); return count; }, - claimOne: async ({ model, where }) => { + consumeOne: async ({ model, where }) => { const table = db[model]!; const matches = convertWhereClause(where, model); const target = matches[0]; diff --git a/packages/mongo-adapter/src/mongodb-adapter.test.ts b/packages/mongo-adapter/src/mongodb-adapter.test.ts index c1fdacc1bf..b56350f397 100644 --- a/packages/mongo-adapter/src/mongodb-adapter.test.ts +++ b/packages/mongo-adapter/src/mongodb-adapter.test.ts @@ -11,7 +11,7 @@ describe("mongodb-adapter", () => { expect(adapter).toBeDefined(); }); - it("claimOne returns the deleted document from Mongo metadata", async () => { + it("consumeOne returns the deleted document from Mongo metadata", async () => { const deleted = { _id: "verification-id", identifier: "magic-link-token", @@ -24,7 +24,7 @@ describe("mongodb-adapter", () => { } as any; const adapter = mongodbAdapter(db, { transaction: false })({}); - const result = await adapter.claimOne({ + const result = await adapter.consumeOne({ model: "verification", where: [{ field: "identifier", value: "magic-link-token" }], }); diff --git a/packages/mongo-adapter/src/mongodb-adapter.ts b/packages/mongo-adapter/src/mongodb-adapter.ts index 9d411303d6..beef26a5c5 100644 --- a/packages/mongo-adapter/src/mongodb-adapter.ts +++ b/packages/mongo-adapter/src/mongodb-adapter.ts @@ -630,7 +630,7 @@ export const mongodbAdapter = ( .deleteMany(clause, { session }); return res.deletedCount; }, - async claimOne({ model, where }) { + async consumeOne({ model, where }) { const clause = convertWhereClause({ where, model }); const doc = await db.collection(model).findOneAndDelete(clause, { session, diff --git a/packages/prisma-adapter/src/prisma-adapter.test.ts b/packages/prisma-adapter/src/prisma-adapter.test.ts index d9479b44f7..3c06e2ff9b 100644 --- a/packages/prisma-adapter/src/prisma-adapter.test.ts +++ b/packages/prisma-adapter/src/prisma-adapter.test.ts @@ -214,7 +214,7 @@ describe("prisma-adapter", () => { }); }); - it("claimOne rechecks non-unique predicates before deleting", async () => { + it("consumeOne rechecks non-unique predicates before deleting", async () => { const target = { id: "verification-id", identifier: "magic-link-token", @@ -234,7 +234,7 @@ describe("prisma-adapter", () => { }, }); - const result = await adapter.claimOne({ + const result = await adapter.consumeOne({ model: "verification", where: [{ field: "identifier", value: "magic-link-token" }], }); @@ -251,7 +251,7 @@ describe("prisma-adapter", () => { }); }); - it("claimOne does not open a nested transaction from a transaction adapter", async () => { + it("consumeOne does not open a nested transaction from a transaction adapter", async () => { const target = { id: "verification-id", identifier: "magic-link-token", @@ -274,7 +274,7 @@ describe("prisma-adapter", () => { )({} as BetterAuthOptions); await adapter.transaction(async (trx) => { - await trx.claimOne({ + await trx.consumeOne({ model: "verification", where: [{ field: "identifier", value: "magic-link-token" }], }); diff --git a/packages/prisma-adapter/src/prisma-adapter.ts b/packages/prisma-adapter/src/prisma-adapter.ts index c325064227..5ab3140db8 100644 --- a/packages/prisma-adapter/src/prisma-adapter.ts +++ b/packages/prisma-adapter/src/prisma-adapter.ts @@ -618,7 +618,7 @@ export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) => { }); return result ? (result.count as number) : 0; }, - async claimOne({ model, where }) { + async consumeOne({ model, where }) { if (!db[model]) { throw new BetterAuthError( `Model ${model} does not exist in the database. If you haven't generated the Prisma client, you need to run 'npx prisma generate'`, @@ -630,7 +630,7 @@ export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) => { // otherwise we fall back to find-then-deleteMany inside a // transaction. deleteMany rechecks the original predicate with the // selected id so we do not delete a row that stopped matching. - // FIXME(claim-one-prisma-locking): Prisma has no portable + // FIXME(consume-one-prisma-locking): Prisma has no portable // row-locking API for findFirst. Add provider-specific locking // when a breaking adapter contract can expose it cleanly. const hasIdField = where?.some((w) => w.field === "id");