From 33be9fa6f8f31f8a07ed9f0953d6925a84b0c5a1 Mon Sep 17 00:00:00 2001 From: kuldeepsinh19 Date: Mon, 2 Nov 2026 09:28:57 +0530 Subject: [PATCH 1/2] fix(better-auth): support compound where in update and delete The adapter turned a where with two or more conditions into `{ AND: [...] }` and passed it to ORM `update()`/`delete()`, which need a unique field at the top level. better-auth updates with compound wheres (e.g. @better-auth/sso row locks, device authorization claims on 1.6.x), so those calls failed with "At least one unique field or field set must be set at where", and compound deletes were silently skipped because `delete()` swallowed every error. `update()`/`delete()` now lift AND-ed equality conditions to the top level and keep the rest as filters. When a single-field unique key is present the row is updated/deleted directly; a missing row returns `null` from `update()` (better-auth's contract) and is ignored by `delete()`, while other errors propagate. Without a unique key, `update()` finds a matching row and updates it by id with the original conditions as a guard, and `delete()` uses `deleteMany()`. Fixes #2694 --- .../auth-adapters/better-auth/package.json | 1 + .../auth-adapters/better-auth/src/adapter.ts | 115 ++++++--- .../better-auth/test/adapter-db.test.ts | 238 ++++++++++++++++++ pnpm-lock.yaml | 3 + 4 files changed, 319 insertions(+), 38 deletions(-) create mode 100644 packages/auth-adapters/better-auth/test/adapter-db.test.ts diff --git a/packages/auth-adapters/better-auth/package.json b/packages/auth-adapters/better-auth/package.json index 8179a10a1..2922dc8dc 100644 --- a/packages/auth-adapters/better-auth/package.json +++ b/packages/auth-adapters/better-auth/package.json @@ -70,6 +70,7 @@ "@types/tmp": "catalog:", "@zenstackhq/cli": "workspace:*", "@zenstackhq/eslint-config": "workspace:*", + "@zenstackhq/testtools": "workspace:*", "@zenstackhq/tsdown-config": "workspace:*", "@zenstackhq/typescript-config": "workspace:*", "@zenstackhq/vitest-config": "workspace:*", diff --git a/packages/auth-adapters/better-auth/src/adapter.ts b/packages/auth-adapters/better-auth/src/adapter.ts index c393f3319..357cca2ce 100644 --- a/packages/auth-adapters/better-auth/src/adapter.ts +++ b/packages/auth-adapters/better-auth/src/adapter.ts @@ -1,5 +1,6 @@ import type { BetterAuthOptions, Where } from 'better-auth'; import { BetterAuthError } from '@better-auth/core/error'; +import { ORMErrorReason, QueryUtils } from '@zenstackhq/orm'; import type { ClientContract, ModelOperations, UpdateInput } from '@zenstackhq/orm'; import type { GetModels, SchemaDef } from '@zenstackhq/orm/schema'; import { @@ -44,6 +45,14 @@ export const zenstackAdapter = (db: ClientContract w.operator === 'eq' || !w.operator; + const convertWhereCondition = (model: string, w: Where) => ({ + [getFieldName({ model, field: w.field })]: isEqualityCondition(w) + ? w.value + : { + [operatorToORMOperator(w.operator!)]: w.value, + }, + }); const convertWhereClause = (model: string, where?: Where[]): any => { if (!where || !where.length) return {}; if (where.length === 1) { @@ -51,37 +60,12 @@ export const zenstackAdapter = (db: ClientContract w.connector === 'AND' || !w.connector); const or = where.filter((w) => w.connector === 'OR'); - const andClause = and.map((w) => { - return { - [getFieldName({ model, field: w.field })]: - w.operator === 'eq' || !w.operator - ? w.value - : { - [operatorToORMOperator(w.operator)]: w.value, - }, - }; - }); - const orClause = or.map((w) => { - return { - [getFieldName({ model, field: w.field })]: - w.operator === 'eq' || !w.operator - ? w.value - : { - [operatorToORMOperator(w.operator)]: w.value, - }, - }; - }); + const andClause = and.map((w) => convertWhereCondition(model, w)); + const orClause = or.map((w) => convertWhereCondition(model, w)); return { ...(andClause.length ? { AND: andClause } : {}), @@ -89,6 +73,37 @@ export const zenstackAdapter = (db: ClientContract { + const result: Record = {}; + const and: Record[] = []; + const or: Record[] = []; + for (const w of where) { + const field = getFieldName({ model, field: w.field }); + if (w.connector === 'OR') { + or.push(convertWhereCondition(model, w)); + } else if (isEqualityCondition(w) && !(field in result)) { + result[field] = w.value; + } else { + and.push(convertWhereCondition(model, w)); + } + } + if (and.length) result['AND'] = and; + if (or.length) result['OR'] = or; + + const isUnique = QueryUtils.getUniqueFields(db.$schema, model).some( + (unique) => 'def' in unique && result[unique.name] !== undefined && result[unique.name] !== null, + ); + return { where: result, isUnique }; + }; + + // checked by `reason` rather than `instanceof ORMError`, in case the app loads another copy of the ORM + const isNotFoundError = (err: unknown) => + (err as { reason?: unknown } | null)?.reason === ORMErrorReason.NOT_FOUND; + function requireModelDb(db: ClientContract, model: string) { const modelDb = db[model as keyof typeof db]; if (!modelDb) { @@ -145,11 +160,32 @@ export const zenstackAdapter = (db: ClientContract { const modelDb = requireModelDb(db, model); - const whereClause = convertWhereClause(model, where); - return await modelDb.update({ - where: whereClause, - data: update as UpdateInput, any>, - }); + const data = update as UpdateInput, any>; + const uniqueWhere = convertUniqueWhereClause(model, where); + let updateWhere = uniqueWhere.where; + if (!uniqueWhere.isUnique) { + // No unique field to select the row by: find a matching row, then update it by its id, + // keeping the original conditions as a guard in case the row changed in between. + const filter = convertWhereClause(model, where); + const idFields = QueryUtils.requireIdFields(db.$schema, model); + const found: any = await modelDb.findFirst({ + where: filter, + select: Object.fromEntries(idFields.map((f) => [f, true])), + }); + if (!found) return null; + const idWhere = + idFields.length === 1 + ? { [idFields[0]!]: found[idFields[0]!] } + : { [idFields.join('_')]: Object.fromEntries(idFields.map((f) => [f, found[f]])) }; + updateWhere = { ...idWhere, AND: [filter] }; + } + try { + return await modelDb.update({ where: updateWhere as any, data }); + } catch (err) { + // better-auth expects `null` when no row matches + if (isNotFoundError(err)) return null; + throw err; + } }, async updateMany({ model, where, update }) { @@ -164,13 +200,16 @@ export const zenstackAdapter = (db: ClientContract { const modelDb = requireModelDb(db, model); - const whereClause = convertWhereClause(model, where); + const uniqueWhere = convertUniqueWhereClause(model, where); + if (!uniqueWhere.isUnique) { + await modelDb.deleteMany({ where: convertWhereClause(model, where) }); + return; + } try { - await modelDb.delete({ - where: whereClause, - }); - } catch { + await modelDb.delete({ where: uniqueWhere.where as any }); + } catch (err) { // If the record doesn't exist, we don't want to throw an error + if (!isNotFoundError(err)) throw err; } }, diff --git a/packages/auth-adapters/better-auth/test/adapter-db.test.ts b/packages/auth-adapters/better-auth/test/adapter-db.test.ts new file mode 100644 index 000000000..a6ff14265 --- /dev/null +++ b/packages/auth-adapters/better-auth/test/adapter-db.test.ts @@ -0,0 +1,238 @@ +import type { BetterAuthOptions } from '@better-auth/core'; +import type { BetterAuthDBSchema } from 'better-auth/db'; +import { createTestClient } from '@zenstackhq/testtools'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { zenstackAdapter } from '../src/adapter'; + +const zmodel = ` +model SsoProvider { + id String @id + providerId String @unique + issuer String +} + +model DeviceCode { + id String @id + status String + userId String? +} + +model ApiToken { + id String @id + token String @unique + userId String +} +`; + +// Plugin models, so better-auth's adapter factory knows the fields and transforms `where` like it does at runtime. +const pluginSchema = { + ssoProvider: { + modelName: 'ssoProvider', + fields: { + providerId: { type: 'string', required: true, unique: true }, + issuer: { type: 'string', required: true }, + }, + }, + deviceCode: { + modelName: 'deviceCode', + fields: { + status: { type: 'string', required: true }, + userId: { type: 'string', required: false }, + }, + }, + apiToken: { + modelName: 'apiToken', + fields: { + token: { type: 'string', required: true, unique: true }, + userId: { type: 'string', required: true }, + }, + }, +} satisfies BetterAuthDBSchema; + +const authOptions = { + plugins: [{ id: 'test-models', schema: pluginSchema }], +} as unknown as BetterAuthOptions; + +describe('ZenStack Better Auth adapter with a database', () => { + let db: any; + let adapter: ReturnType>; + + beforeEach(async () => { + db = await createTestClient(zmodel); + const provider = db.$schema.provider.type === 'postgresql' ? 'postgresql' : 'sqlite'; + adapter = zenstackAdapter(db, { provider })(authOptions); + }); + + afterEach(async () => { + await db?.$disconnect(); + }); + + it('updates with a unique field plus extra conditions, like @better-auth/sso row locks (#2694)', async () => { + await db.ssoProvider.create({ data: { id: 'sso-1', providerId: 'acme', issuer: 'https://idp.acme.test' } }); + + const row = await adapter.update<{ id: string; providerId: string }>({ + model: 'ssoProvider', + where: [ + { field: 'id', value: 'sso-1' }, + { field: 'providerId', value: 'acme' }, + ], + update: { providerId: 'acme' }, + }); + + expect(row).toMatchObject({ id: 'sso-1', providerId: 'acme' }); + }); + + it('updates with a compound where inside a transaction (#2694)', async () => { + await db.ssoProvider.create({ data: { id: 'sso-1', providerId: 'acme', issuer: 'https://idp.acme.test' } }); + + const row = await adapter.transaction((trx) => + trx.update<{ id: string; issuer: string }>({ + model: 'ssoProvider', + where: [ + { field: 'id', value: 'sso-1' }, + { field: 'providerId', value: 'acme' }, + ], + update: { issuer: 'https://idp2.acme.test' }, + }), + ); + + expect(row).toMatchObject({ id: 'sso-1', issuer: 'https://idp2.acme.test' }); + }); + + it('updates a single row when the where has no unique field (#2694)', async () => { + await db.deviceCode.create({ data: { id: 'device-1', status: 'pending', userId: null } }); + await db.deviceCode.create({ data: { id: 'device-2', status: 'pending', userId: null } }); + + const row = await adapter.update<{ id: string }>({ + model: 'deviceCode', + where: [{ field: 'status', value: 'pending' }], + update: { status: 'approved' }, + }); + + await expect(db.deviceCode.count({ where: { status: 'approved' } })).resolves.toBe(1); + await expect(db.deviceCode.findUnique({ where: { id: row!.id } })).resolves.toMatchObject({ + status: 'approved', + }); + }); + + it('returns null when a compound update matches nothing (#2694)', async () => { + await db.ssoProvider.create({ data: { id: 'sso-1', providerId: 'acme', issuer: 'https://idp.acme.test' } }); + + const row = await adapter.update({ + model: 'ssoProvider', + where: [ + { field: 'id', value: 'sso-1' }, + { field: 'providerId', value: 'other' }, + ], + update: { providerId: 'other' }, + }); + + expect(row).toBeNull(); + await expect(db.ssoProvider.findUnique({ where: { id: 'sso-1' } })).resolves.toMatchObject({ + providerId: 'acme', + }); + }); + + it('claims a row only once with a compare-and-swap update, like device authorization (#2694)', async () => { + await db.deviceCode.create({ data: { id: 'device-1', status: 'pending', userId: null } }); + const claim = (userId: string) => + adapter.update<{ id: string; userId: string | null }>({ + model: 'deviceCode', + where: [ + { field: 'id', value: 'device-1' }, + { field: 'status', value: 'pending' }, + { field: 'userId', value: null }, + ], + update: { userId }, + }); + + await expect(claim('user-1')).resolves.toMatchObject({ id: 'device-1', userId: 'user-1' }); + await expect(claim('user-2')).resolves.toBeNull(); + await expect(db.deviceCode.findUnique({ where: { id: 'device-1' } })).resolves.toMatchObject({ + userId: 'user-1', + }); + }); + + it('updates without a unique field and returns the updated row (#2694)', async () => { + await db.deviceCode.create({ data: { id: 'device-1', status: 'pending', userId: null } }); + + const row = await adapter.update({ + model: 'deviceCode', + where: [{ field: 'status', value: 'pending' }], + update: { status: 'approved' }, + }); + + // the update changes the filtered field, so the row must not be read back with the original filter + expect(row).toMatchObject({ id: 'device-1', status: 'approved' }); + }); + + it('returns null when updating a missing row by id (#2694)', async () => { + const row = await adapter.update({ + model: 'deviceCode', + where: [{ field: 'id', value: 'missing' }], + update: { status: 'approved' }, + }); + + expect(row).toBeNull(); + }); + + it('updates rows matched with OR conditions (#2694)', async () => { + await db.deviceCode.create({ data: { id: 'device-1', status: 'pending', userId: null } }); + + const row = await adapter.update({ + model: 'deviceCode', + where: [ + { field: 'status', value: 'denied', connector: 'OR' }, + { field: 'status', value: 'pending', connector: 'OR' }, + ], + update: { status: 'approved' }, + }); + + expect(row).toMatchObject({ id: 'device-1', status: 'approved' }); + }); + + it('deletes with a unique field plus extra conditions (#2694)', async () => { + await db.apiToken.create({ data: { id: 'token-1', token: 'secret', userId: 'user-1' } }); + + await adapter.delete({ + model: 'apiToken', + where: [ + { field: 'id', value: 'token-1' }, + { field: 'userId', value: 'user-1' }, + ], + }); + + await expect(db.apiToken.findUnique({ where: { id: 'token-1' } })).resolves.toBeNull(); + }); + + it('keeps the row when a compound delete matches nothing (#2694)', async () => { + await db.apiToken.create({ data: { id: 'token-1', token: 'secret', userId: 'user-1' } }); + + await expect( + adapter.delete({ + model: 'apiToken', + where: [ + { field: 'id', value: 'token-1' }, + { field: 'userId', value: 'user-2' }, + ], + }), + ).resolves.toBeUndefined(); + + await expect(db.apiToken.findUnique({ where: { id: 'token-1' } })).resolves.not.toBeNull(); + }); + + it('deletes without a unique field (#2694)', async () => { + await db.apiToken.create({ data: { id: 'token-1', token: 'secret', userId: 'user-1' } }); + await db.apiToken.create({ data: { id: 'token-2', token: 'other', userId: 'user-2' } }); + + await adapter.delete({ model: 'apiToken', where: [{ field: 'userId', value: 'user-1' }] }); + + await expect(db.apiToken.findMany({ orderBy: { id: 'asc' } })).resolves.toMatchObject([{ id: 'token-2' }]); + }); + + it('does not throw when deleting a missing row by id', async () => { + await expect( + adapter.delete({ model: 'apiToken', where: [{ field: 'id', value: 'missing' }] }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f9f69741..b83bc40ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: '@zenstackhq/eslint-config': specifier: workspace:* version: link:../../config/eslint-config + '@zenstackhq/testtools': + specifier: workspace:* + version: link:../../testtools '@zenstackhq/tsdown-config': specifier: workspace:* version: link:../../config/tsdown-config From 70394493d42189813bed9f2d8d75dea77357fc61 Mon Sep 17 00:00:00 2001 From: kuldeepsinh19 Date: Mon, 2 Nov 2026 23:55:34 +0530 Subject: [PATCH 2/2] feat(better-auth): implement native consumeOne and incrementOne better-auth 1.7 added atomic consumeOne (single-use token consumption) and incrementOne (guarded counter updates) adapter methods. Without them, @better-auth/core 1.7.0-1.7.2 throws on password reset, and later versions fall back to a generic read + compare-and-swap path. Both methods locate the row and then write by id with the original conditions kept in the DELETE/UPDATE filter, inside a transaction. incrementOne uses updateMany because ORM update() drops the filter on dialects without RETURNING (MySQL). Also make the id helpers work with compound ids, including a named @@id. Closes #2820 --- .../auth-adapters/better-auth/src/adapter.ts | 80 ++++- .../better-auth/test/adapter-db.test.ts | 288 +++++++++++++++++- 2 files changed, 357 insertions(+), 11 deletions(-) diff --git a/packages/auth-adapters/better-auth/src/adapter.ts b/packages/auth-adapters/better-auth/src/adapter.ts index 357cca2ce..b8ee53e5f 100644 --- a/packages/auth-adapters/better-auth/src/adapter.ts +++ b/packages/auth-adapters/better-auth/src/adapter.ts @@ -114,6 +114,29 @@ export const zenstackAdapter = (db: ClientContract>; } + // plain filter on the id field(s) of `row`, usable in any `where` + const getIdFilter = (model: string, row: Record) => + Object.fromEntries(QueryUtils.requireIdFields(db.$schema, model).map((f) => [f, row[f]])); + + // unique `where` that selects `row` by its id field(s); a compound id goes under its unique key name + // (`a_b` by default, or the name given in `@@id(..., name: ...)`), as `update()`/`delete()` require + const getIdWhere = (model: string, row: Record) => { + const idFields = QueryUtils.requireIdFields(db.$schema, model); + const idFilter = getIdFilter(model, row); + if (idFields.length === 1) return idFilter; + const key = + QueryUtils.getUniqueFields(db.$schema, model).find( + (u) => + 'defs' in u && + Object.keys(u.defs).length === idFields.length && + idFields.every((f) => f in u.defs), + )?.name ?? idFields.join('_'); + return { [key]: idFilter }; + }; + + const selectIdFields = (model: string) => + Object.fromEntries(QueryUtils.requireIdFields(db.$schema, model).map((f) => [f, true])); + return { async create({ model, data: values, select }): Promise { const modelDb = requireModelDb(db, model); @@ -167,17 +190,9 @@ export const zenstackAdapter = (db: ClientContract [f, true])), - }); + const found: any = await modelDb.findFirst({ where: filter, select: selectIdFields(model) }); if (!found) return null; - const idWhere = - idFields.length === 1 - ? { [idFields[0]!]: found[idFields[0]!] } - : { [idFields.join('_')]: Object.fromEntries(idFields.map((f) => [f, found[f]])) }; - updateWhere = { ...idWhere, AND: [filter] }; + updateWhere = { ...getIdWhere(model, found), AND: [filter] }; } try { return await modelDb.update({ where: updateWhere as any, data }); @@ -222,6 +237,51 @@ export const zenstackAdapter = (db: ClientContract { + requireModelDb(db, model); + const filter = convertWhereClause(model, where); + return db.$transaction(async (tx) => { + const txModelDb = requireModelDb(tx as ClientContract, model); + const found: any = await txModelDb.findFirst({ where: filter }); + if (!found) return null; + const { count } = await txModelDb.deleteMany({ + where: { ...getIdFilter(model, found), AND: [filter] } as any, + }); + return count > 0 ? found : null; + }); + }, + + // Applies `field = field + delta` (plus absolute `set` values) to one row matching `where`, which + // is both the selector and the guard, and returns the updated row, or `null`. The guarded write is + // an `updateMany`, so for plain models the conditions stay in the UPDATE statement on every dialect + // (`update()` swaps them for the loaded ids where RETURNING isn't supported, e.g. MySQL). + // As in better-auth's Prisma adapter, a null counter stays null (SQL `NULL + n`). + async incrementOne({ model, where, increment, set }): Promise { + requireModelDb(db, model); + const filter = convertWhereClause(model, where); + const data: Record = { ...set }; + for (const [field, delta] of Object.entries(increment)) { + data[field] = { increment: delta }; + } + return db.$transaction(async (tx) => { + const txModelDb = requireModelDb(tx as ClientContract, model); + const found: any = await txModelDb.findFirst({ where: filter, select: selectIdFields(model) }); + if (!found) return null; + const idFilter = getIdFilter(model, found); + const { count } = await txModelDb.updateMany({ + where: { ...idFilter, AND: [filter] } as any, + data, + }); + if (count === 0) return null; + return txModelDb.findFirst({ where: idFilter as any }); + }); + }, + options: config, createSchema: async ({ file, tables }) => { diff --git a/packages/auth-adapters/better-auth/test/adapter-db.test.ts b/packages/auth-adapters/better-auth/test/adapter-db.test.ts index a6ff14265..ab328ba07 100644 --- a/packages/auth-adapters/better-auth/test/adapter-db.test.ts +++ b/packages/auth-adapters/better-auth/test/adapter-db.test.ts @@ -1,7 +1,7 @@ import type { BetterAuthOptions } from '@better-auth/core'; import type { BetterAuthDBSchema } from 'better-auth/db'; import { createTestClient } from '@zenstackhq/testtools'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { zenstackAdapter } from '../src/adapter'; const zmodel = ` @@ -22,6 +22,27 @@ model ApiToken { token String @unique userId String } + +model OneTimeToken { + id String @id + identifier String + value String + expiresAt DateTime +} + +model UsageCounter { + id String @id + key String + remaining Int + lastUsedAt DateTime? +} + +model ClientGrant { + clientId String + userId String + uses Int + @@id([clientId, userId], name: "grantKey") +} `; // Plugin models, so better-auth's adapter factory knows the fields and transforms `where` like it does at runtime. @@ -47,6 +68,30 @@ const pluginSchema = { userId: { type: 'string', required: true }, }, }, + oneTimeToken: { + modelName: 'oneTimeToken', + fields: { + identifier: { type: 'string', required: true }, + value: { type: 'string', required: true }, + expiresAt: { type: 'date', required: true }, + }, + }, + usageCounter: { + modelName: 'usageCounter', + fields: { + key: { type: 'string', required: true }, + remaining: { type: 'number', required: true }, + lastUsedAt: { type: 'date', required: false }, + }, + }, + clientGrant: { + modelName: 'clientGrant', + fields: { + clientId: { type: 'string', required: true }, + userId: { type: 'string', required: true }, + uses: { type: 'number', required: true }, + }, + }, } satisfies BetterAuthDBSchema; const authOptions = { @@ -235,4 +280,245 @@ describe('ZenStack Better Auth adapter with a database', () => { adapter.delete({ model: 'apiToken', where: [{ field: 'id', value: 'missing' }] }), ).resolves.toBeUndefined(); }); + + describe('consumeOne (#2820)', () => { + const future = () => new Date(Date.now() + 60_000); + + it('deletes and returns the row, then returns null on a second consume', async () => { + await db.oneTimeToken.create({ + data: { id: 'ott-1', identifier: 'reset-password:abc', value: 'user-1', expiresAt: future() }, + }); + const transaction = vi.spyOn(db, '$transaction'); + + const consume = () => + adapter.consumeOne<{ id: string; value: string }>({ + model: 'oneTimeToken', + where: [{ field: 'identifier', value: 'reset-password:abc' }], + }); + + await expect(consume()).resolves.toMatchObject({ id: 'ott-1', value: 'user-1' }); + await expect(consume()).resolves.toBeNull(); + await expect(db.oneTimeToken.count()).resolves.toBe(0); + // the adapter's own method ran, not better-auth's generic fallback + expect(transaction).toHaveBeenCalled(); + }); + + it('consumes by id', async () => { + await db.oneTimeToken.create({ + data: { id: 'ott-1', identifier: 'verify', value: 'v', expiresAt: future() }, + }); + const consume = () => + adapter.consumeOne({ model: 'oneTimeToken', where: [{ field: 'id', value: 'ott-1' }] }); + + await expect(consume()).resolves.toMatchObject({ id: 'ott-1' }); + await expect(consume()).resolves.toBeNull(); + }); + + it('deletes only one of several rows matching a non-unique where', async () => { + await db.oneTimeToken.create({ + data: { id: 'ott-1', identifier: 'same', value: 'a', expiresAt: future() }, + }); + await db.oneTimeToken.create({ + data: { id: 'ott-2', identifier: 'same', value: 'b', expiresAt: future() }, + }); + + const row = await adapter.consumeOne<{ id: string }>({ + model: 'oneTimeToken', + where: [{ field: 'identifier', value: 'same' }], + }); + + expect(row).not.toBeNull(); + await expect(db.oneTimeToken.count()).resolves.toBe(1); + await expect(db.oneTimeToken.findUnique({ where: { id: row!.id } })).resolves.toBeNull(); + }); + + it('keeps the row and returns null when the guard does not match', async () => { + await db.oneTimeToken.create({ + data: { id: 'ott-1', identifier: 'verify', value: 'v', expiresAt: new Date(Date.now() - 60_000) }, + }); + + await expect( + adapter.consumeOne({ + model: 'oneTimeToken', + where: [ + { field: 'identifier', value: 'verify' }, + { field: 'expiresAt', operator: 'gt', value: new Date() }, + ], + }), + ).resolves.toBeNull(); + await expect(db.oneTimeToken.count()).resolves.toBe(1); + }); + + it('hands the row to exactly one of several concurrent callers', async () => { + await db.oneTimeToken.create({ + data: { id: 'ott-1', identifier: 'verify', value: 'v', expiresAt: future() }, + }); + + const results = await Promise.all( + Array.from({ length: 5 }, () => + adapter.consumeOne({ model: 'oneTimeToken', where: [{ field: 'identifier', value: 'verify' }] }), + ), + ); + + expect(results.filter((r) => r !== null)).toHaveLength(1); + await expect(db.oneTimeToken.count()).resolves.toBe(0); + }); + + it('works inside an adapter transaction', async () => { + await db.oneTimeToken.create({ + data: { id: 'ott-1', identifier: 'verify', value: 'v', expiresAt: future() }, + }); + + const row = await adapter.transaction((trx) => + trx.consumeOne({ model: 'oneTimeToken', where: [{ field: 'identifier', value: 'verify' }] }), + ); + + expect(row).toMatchObject({ id: 'ott-1' }); + await expect(db.oneTimeToken.count()).resolves.toBe(0); + }); + + it('is rolled back with the adapter transaction it runs in', async () => { + await db.oneTimeToken.create({ + data: { id: 'ott-1', identifier: 'verify', value: 'v', expiresAt: future() }, + }); + + await expect( + adapter.transaction(async (trx) => { + await trx.consumeOne({ model: 'oneTimeToken', where: [{ field: 'identifier', value: 'verify' }] }); + throw new Error('rollback'); + }), + ).rejects.toThrow('rollback'); + + await expect(db.oneTimeToken.count()).resolves.toBe(1); + }); + }); + + describe('incrementOne (#2820)', () => { + beforeEach(async () => { + await db.usageCounter.create({ data: { id: 'counter-1', key: 'api-key-1', remaining: 2 } }); + }); + + const useOne = (a: Pick = adapter) => + a.incrementOne<{ id: string; remaining: number }>({ + model: 'usageCounter', + where: [ + { field: 'key', value: 'api-key-1' }, + { field: 'remaining', operator: 'gt', value: 0 }, + ], + increment: { remaining: -1 }, + set: { lastUsedAt: new Date() }, + }); + + it('decrements while the guard holds, then returns null without writing', async () => { + const transaction = vi.spyOn(db, '$transaction'); + + await expect(useOne()).resolves.toMatchObject({ id: 'counter-1', remaining: 1 }); + await expect(useOne()).resolves.toMatchObject({ id: 'counter-1', remaining: 0 }); + await expect(useOne()).resolves.toBeNull(); + + const row = await db.usageCounter.findUnique({ where: { id: 'counter-1' } }); + expect(row.remaining).toBe(0); + expect(row.lastUsedAt).toBeInstanceOf(Date); + // the adapter's own method ran, not better-auth's generic fallback + expect(transaction).toHaveBeenCalled(); + }); + + it('never passes the guard under concurrent callers', async () => { + const results = await Promise.all(Array.from({ length: 5 }, () => useOne())); + + expect(results.filter((r) => r !== null)).toHaveLength(2); + await expect(db.usageCounter.findUnique({ where: { id: 'counter-1' } })).resolves.toMatchObject({ + remaining: 0, + }); + }); + + it('increments by id', async () => { + await expect( + adapter.incrementOne({ + model: 'usageCounter', + where: [{ field: 'id', value: 'counter-1' }], + increment: { remaining: 3 }, + }), + ).resolves.toMatchObject({ remaining: 5 }); + }); + + it('updates only one of several rows matching a non-unique where', async () => { + await db.usageCounter.create({ data: { id: 'counter-2', key: 'api-key-1', remaining: 2 } }); + + await useOne(); + + const rows = await db.usageCounter.findMany({ orderBy: { id: 'asc' } }); + expect(rows.map((r: { remaining: number }) => r.remaining).sort()).toEqual([1, 2]); + }); + + it('returns null when no row matches', async () => { + await expect( + adapter.incrementOne({ + model: 'usageCounter', + where: [{ field: 'id', value: 'missing' }], + increment: { remaining: 1 }, + }), + ).resolves.toBeNull(); + }); + + it('works inside an adapter transaction and is rolled back with it', async () => { + await expect(adapter.transaction((trx) => useOne(trx))).resolves.toMatchObject({ remaining: 1 }); + + await expect( + adapter.transaction(async (trx) => { + await trx.incrementOne({ + model: 'usageCounter', + where: [{ field: 'id', value: 'counter-1' }], + increment: { remaining: -1 }, + }); + throw new Error('rollback'); + }), + ).rejects.toThrow('rollback'); + + await expect(db.usageCounter.findUnique({ where: { id: 'counter-1' } })).resolves.toMatchObject({ + remaining: 1, + }); + }); + }); + + describe('models with a compound id', () => { + beforeEach(async () => { + await db.clientGrant.create({ data: { clientId: 'client-1', userId: 'user-1', uses: 1 } }); + await db.clientGrant.create({ data: { clientId: 'client-1', userId: 'user-2', uses: 1 } }); + }); + + it('consumes one row', async () => { + await expect( + adapter.consumeOne({ model: 'clientGrant', where: [{ field: 'userId', value: 'user-1' }] }), + ).resolves.toMatchObject({ clientId: 'client-1', userId: 'user-1' }); + await expect(db.clientGrant.findMany()).resolves.toMatchObject([{ userId: 'user-2' }]); + }); + + it('increments one row', async () => { + await expect( + adapter.incrementOne({ + model: 'clientGrant', + where: [{ field: 'userId', value: 'user-1' }], + increment: { uses: 1 }, + }), + ).resolves.toMatchObject({ userId: 'user-1', uses: 2 }); + await expect( + db.clientGrant.findUnique({ where: { grantKey: { clientId: 'client-1', userId: 'user-2' } } }), + ).resolves.toMatchObject({ uses: 1 }); + }); + + it('updates one row without a unique field (named compound id)', async () => { + await expect( + adapter.update({ + model: 'clientGrant', + where: [ + { field: 'clientId', value: 'client-1' }, + { field: 'uses', value: 1 }, + ], + update: { uses: 5 }, + }), + ).resolves.toMatchObject({ uses: 5 }); + await expect(db.clientGrant.count({ where: { uses: 5 } })).resolves.toBe(1); + }); + }); });