From 705f7434c7ab167a2cef80310bebde2f745f7ed0 Mon Sep 17 00:00:00 2001 From: Maxwell <145994855+ping-maxwell@users.noreply.github.com> Date: Wed, 12 Nov 2025 04:04:24 +1000 Subject: [PATCH] fix: treat generateId "serial" as numeric ID and correct UUID column types across adapters (#5823) --- .../src/__snapshots__/init.test.ts.snap | 1 + .../src/adapters/adapter-factory/index.ts | 126 +++++++++++++--- .../src/adapters/adapter-factory/types.ts | 3 - .../src/adapters/create-test-suite.ts | 13 +- .../drizzle-adapter/drizzle-adapter.ts | 1 + .../test/adapter.drizzle.mysql.test.ts | 2 + .../test/adapter.drizzle.pg.test.ts | 2 + .../test/adapter.drizzle.sqlite.test.ts | 2 + .../adapters/kysely-adapter/kysely-adapter.ts | 1 + ...> adapter.kysely.custom-schema-pg.test.ts} | 0 .../test/adapter.kysely.mssql.test.ts | 2 + .../test/adapter.kysely.mysql.test.ts | 2 + .../test/adapter.kysely.pg.test.ts | 5 +- .../test/adapter.kysely.sqlite.test.ts | 2 + .../memory-adapter/adapter.memory.test.ts | 4 +- .../adapters/memory-adapter/memory-adapter.ts | 18 +-- .../mongodb-adapter/adapter.mongo-db.test.ts | 3 +- .../adapters/prisma-adapter/prisma-adapter.ts | 1 + .../prisma-adapter/test/prisma.mysql.test.ts | 2 + .../prisma-adapter/test/prisma.pg.test.ts | 2 + .../prisma-adapter/test/prisma.sqlite.test.ts | 2 + packages/better-auth/src/adapters/test.ts | 4 +- .../better-auth/src/adapters/tests/index.ts | 1 + .../better-auth/src/adapters/tests/normal.ts | 39 +++-- .../src/adapters/tests/number-id.ts | 7 +- .../better-auth/src/adapters/tests/uuid.ts | 68 +++++++++ .../src/db/get-migration-schema.test.ts | 10 +- packages/better-auth/src/db/get-migration.ts | 49 +++---- packages/cli/src/generators/drizzle.ts | 40 +++++- packages/cli/src/generators/prisma.ts | 45 ++++-- .../__snapshots__/auth-schema-mysql-uuid.txt | 99 +++++++++++++ .../__snapshots__/auth-schema-pg-uuid.txt | 110 ++++++++++++++ .../__snapshots__/auth-schema-sqlite-uuid.txt | 109 ++++++++++++++ .../cli/test/__snapshots__/schema-uuid.prisma | 89 ++++++++++++ packages/cli/test/generate-all-db.test.ts | 135 +++++++++++++++++- packages/cli/test/generate.test.ts | 33 ++++- packages/core/src/db/adapter/index.ts | 8 +- .../src/detectors/detect-auth-config.ts | 4 +- 38 files changed, 943 insertions(+), 101 deletions(-) rename packages/better-auth/src/adapters/kysely-adapter/test/{adapter.kysely.pg-custom-schema.test.ts => adapter.kysely.custom-schema-pg.test.ts} (100%) create mode 100644 packages/better-auth/src/adapters/tests/uuid.ts create mode 100644 packages/cli/test/__snapshots__/auth-schema-mysql-uuid.txt create mode 100644 packages/cli/test/__snapshots__/auth-schema-pg-uuid.txt create mode 100644 packages/cli/test/__snapshots__/auth-schema-sqlite-uuid.txt create mode 100644 packages/cli/test/__snapshots__/schema-uuid.prisma diff --git a/packages/better-auth/src/__snapshots__/init.test.ts.snap b/packages/better-auth/src/__snapshots__/init.test.ts.snap index 5d97ff7f99..504c461df2 100644 --- a/packages/better-auth/src/__snapshots__/init.test.ts.snap +++ b/packages/better-auth/src/__snapshots__/init.test.ts.snap @@ -22,6 +22,7 @@ exports[`init > should match config 1`] = ` "supportsDates": false, "supportsJSON": false, "supportsNumericIds": true, + "supportsUUIDs": false, "transaction": false, "usePlural": undefined, }, diff --git a/packages/better-auth/src/adapters/adapter-factory/index.ts b/packages/better-auth/src/adapters/adapter-factory/index.ts index 5f74e383ff..fd115a900d 100644 --- a/packages/better-auth/src/adapters/adapter-factory/index.ts +++ b/packages/better-auth/src/adapters/adapter-factory/index.ts @@ -49,6 +49,7 @@ export const createAdapterFactory = supportsJSON: cfg.supportsJSON ?? false, adapterName: cfg.adapterName ?? cfg.adapterId, supportsNumericIds: cfg.supportsNumericIds ?? true, + supportsUUIDs: cfg.supportsUUIDs ?? false, transaction: cfg.transaction ?? false, disableTransformInput: cfg.disableTransformInput ?? false, disableTransformOutput: cfg.disableTransformOutput ?? false, @@ -257,8 +258,22 @@ export const createAdapterFactory = const useNumberId = options.advanced?.database?.useNumberId || options.advanced?.database?.generateId === "serial"; - const shouldGenerateId = - !config.disableIdGeneration && !useNumberId && !forceAllowId; + const useUUIDs = options.advanced?.database?.generateId === "uuid"; + + let shouldGenerateId: boolean = (() => { + if (config.disableIdGeneration) { + return false; + } else if (useNumberId && !forceAllowId) { + // if force allow is true, then we should be using their custom provided id. + return false; + } else if (useUUIDs && !config.supportsUUIDs) { + // should only generate UUIDs via JS if the database doesn't support natively generating UUIDs. + return true; + } else { + return true; + } + })(); + const model = getDefaultModelName(customModelName ?? "id"); return { type: useNumberId ? "number" : "string", @@ -280,16 +295,75 @@ export const createAdapterFactory = model, }); } - if (generateId === "uuid") { - return crypto.randomUUID(); - } if (config.customIdGenerator) { return config.customIdGenerator({ model }); } + if (generateId === "uuid") { + return crypto.randomUUID(); + } return defaultGenerateId(); }, } : {}), + transform: { + input: (value) => { + // Uncomment if need to debug id transformation + // console.log(`transforming id: `, { + // id: value, + // ...(useNumberId ? { useNumberId } : {}), + // ...(useUUIDs ? { useUUIDs } : {}), + // ...(forceAllowId ? { forceAllowId } : {}), + // }); + if (!value) return undefined; + + if (useNumberId) { + const numberValue = Number(value); + // if invalid number, fallback to DB generated number id. + if (isNaN(numberValue)) { + return undefined; + } + return numberValue; + } + + if (useUUIDs) { + // if it's generated by us, then we should return the value as is. + if (shouldGenerateId && !forceAllowId) return value; + if (config.disableIdGeneration) return undefined; + // if DB will handle UUID generation, then we should return undefined. + if (config.supportsUUIDs) return undefined; + // if forceAllowId is true, it means we should be using the ID provided during the adapter call. + if (forceAllowId && typeof value === "string") { + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + if (uuidRegex.test(value)) { + return value; + } else { + const err = new Error(); + const stack = err.stack + ?.split("\n") + .filter((_, i) => i !== 1) + .join("\n") + .replace("Error:", ""); + logger.warn( + "[Adapter Factory] - Invalid UUID value for field `id` provided when `forceAllowId` is true. Generating a new UUID.", + stack, + ); + } + } + // if the value is not a string, and the database doesn't support generating it's own UUIDs, then we should be generating the UUID. + if (typeof value !== "string" && !config.supportsUUIDs) { + return crypto.randomUUID(); + } + return undefined; + } + + return value; + }, + output: (value) => { + if (!value) return undefined; + return String(value); + }, + }, } satisfies DBFieldAttribute; }; @@ -325,15 +399,13 @@ export const createAdapterFactory = const fields = schema[defaultModelName]!.fields; const newMappedKeys = config.mapKeysTransformInput ?? {}; - if ( - !config.disableIdGeneration && - !options.advanced?.database?.useNumberId - ) { - fields.id = idField({ - customModelName: defaultModelName, - forceAllowId: forceAllowId && "id" in data, - }); - } + const useNumberId = + options.advanced?.database?.useNumberId || + options.advanced?.database?.generateId === "serial"; + fields.id = idField({ + customModelName: defaultModelName, + forceAllowId: forceAllowId && "id" in data, + }); for (const field in fields) { let value = data[field]; const fieldAttributes = fields[field]; @@ -378,10 +450,7 @@ export const createAdapterFactory = newValue = await fieldAttributes!.transform.input(newValue); } - if ( - fieldAttributes!.references?.field === "id" && - options.advanced?.database?.useNumberId - ) { + if (fieldAttributes!.references?.field === "id" && useNumberId) { if (Array.isArray(newValue)) { newValue = newValue.map((x) => (x !== null ? Number(x) : null)); } else { @@ -412,7 +481,7 @@ export const createAdapterFactory = action, field: newFieldName, fieldAttributes: fieldAttributes!, - model: defaultModelName, + model: getModelName(defaultModelName), schema, options, }); @@ -495,7 +564,7 @@ export const createAdapterFactory = field: newFieldName, fieldAttributes: field, select, - model: unsafe_model, + model: getModelName(unsafe_model), schema, options, }); @@ -549,11 +618,15 @@ export const createAdapterFactory = model: defaultModelName, }); + const useNumberId = + options.advanced?.database?.useNumberId || + options.advanced?.database?.generateId === "serial"; + if ( defaultFieldName === "id" || fieldAttr!.references?.field === "id" ) { - if (options.advanced?.database?.useNumberId) { + if (useNumberId) { if (Array.isArray(value)) { newValue = value.map(Number); } else { @@ -649,7 +722,16 @@ export const createAdapterFactory = let thisTransactionId = transactionId; const model = getModelName(unsafeModel); unsafeModel = getDefaultModelName(unsafeModel); - if ("id" in unsafeData && !forceAllowId) { + if ( + "id" in unsafeData && + typeof unsafeData.id !== "undefined" && + !forceAllowId + ) { + // The reason why `forceAllowId` was introduced was because we used to handle + // id generation ourselves (eg adapter.create({ data: { id: "123" } })) + // This was bad as certain things (such as number ids) would not work as expected. + // Since then, we have introduced the `forceAllowId` parameter to allow users to + // bypass this check. Otherwise, we would throw a warning stating that the id will be ignored logger.warn( `[${config.adapterName}] - You are trying to create a record with an id. This is not allowed as we handle id generation for you, unless you pass in the \`forceAllowId\` parameter. The id will be ignored.`, ); diff --git a/packages/better-auth/src/adapters/adapter-factory/types.ts b/packages/better-auth/src/adapters/adapter-factory/types.ts index d6fc6f69d8..ce0ad36e2e 100644 --- a/packages/better-auth/src/adapters/adapter-factory/types.ts +++ b/packages/better-auth/src/adapters/adapter-factory/types.ts @@ -19,9 +19,6 @@ export type AdapterFactoryOptions = { adapter: AdapterFactoryCustomizeAdapterCreator; }; -/** - * @deprecated Use `DBAdapterFactoryConfig` from `@better-auth/core/db/adapter` instead. - */ export interface AdapterFactoryConfig extends Omit, "transaction"> { /** diff --git a/packages/better-auth/src/adapters/create-test-suite.ts b/packages/better-auth/src/adapters/create-test-suite.ts index d6defbc4c2..bb3d8a54a0 100644 --- a/packages/better-auth/src/adapters/create-test-suite.ts +++ b/packages/better-auth/src/adapters/create-test-suite.ts @@ -119,6 +119,7 @@ export const createTestSuite = < */ alwaysMigrate?: boolean | undefined; prefixTests?: string | undefined; + customIdGenerator?: () => any | Promise | undefined; }, tests: ( helpers: { @@ -305,8 +306,18 @@ export const createTestSuite = < return newData; }; + const idGenerator = async () => { + if (config.customIdGenerator) { + return config.customIdGenerator(); + } + if (helpers.customIdGenerator) { + return helpers.customIdGenerator(); + } + return generateId(); + }; + const generateModel: GenerateFn = async (model: string) => { - const id = (await helpers.customIdGenerator?.()) || generateId(); + const id = await idGenerator(); const randomDate = new Date( Date.now() - Math.random() * 1000 * 60 * 60 * 24 * 365, ); diff --git a/packages/better-auth/src/adapters/drizzle-adapter/drizzle-adapter.ts b/packages/better-auth/src/adapters/drizzle-adapter/drizzle-adapter.ts index b3a0e1b021..6cb5d6cdc2 100644 --- a/packages/better-auth/src/adapters/drizzle-adapter/drizzle-adapter.ts +++ b/packages/better-auth/src/adapters/drizzle-adapter/drizzle-adapter.ts @@ -453,6 +453,7 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => { adapterName: "Drizzle Adapter", usePlural: config.usePlural ?? false, debugLogs: config.debugLogs ?? false, + supportsUUIDs: config.provider === "pg" ? true : false, transaction: (config.transaction ?? false) ? (cb) => diff --git a/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.mysql.test.ts b/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.mysql.test.ts index 0162b929f3..d1194cf9a1 100644 --- a/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.mysql.test.ts +++ b/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.mysql.test.ts @@ -8,6 +8,7 @@ import { normalTestSuite, numberIdTestSuite, transactionsTestSuite, + uuidTestSuite, } from "../../tests"; import { drizzleAdapter } from "../drizzle-adapter"; import { generateDrizzleSchema, resetGenerationCount } from "./generate-schema"; @@ -65,6 +66,7 @@ const { execute } = await testAdapter({ transactionsTestSuite({ disableTests: { ALL: true } }), authFlowTestSuite(), numberIdTestSuite(), + uuidTestSuite(), ], async onFinish() { await mysqlDB.end(); diff --git a/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.pg.test.ts b/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.pg.test.ts index 3229d56acb..75f01f6182 100644 --- a/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.pg.test.ts +++ b/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.pg.test.ts @@ -7,6 +7,7 @@ import { normalTestSuite, numberIdTestSuite, transactionsTestSuite, + uuidTestSuite, } from "../../tests"; import { drizzleAdapter } from "../drizzle-adapter"; import { generateDrizzleSchema, resetGenerationCount } from "./generate-schema"; @@ -61,6 +62,7 @@ const { execute } = await testAdapter({ transactionsTestSuite({ disableTests: { ALL: true } }), authFlowTestSuite(), numberIdTestSuite(), + uuidTestSuite(), ], async onFinish() { await cleanupDatabase(true); diff --git a/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.sqlite.test.ts b/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.sqlite.test.ts index b7df877fff..9b93592b06 100644 --- a/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.sqlite.test.ts +++ b/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.sqlite.test.ts @@ -9,6 +9,7 @@ import { normalTestSuite, numberIdTestSuite, transactionsTestSuite, + uuidTestSuite, } from "../../tests"; import { drizzleAdapter } from "../drizzle-adapter"; import { @@ -65,6 +66,7 @@ const { execute } = await testAdapter({ transactionsTestSuite({ disableTests: { ALL: true } }), authFlowTestSuite(), numberIdTestSuite(), + uuidTestSuite(), ], async onFinish() { clearSchemaCache(); diff --git a/packages/better-auth/src/adapters/kysely-adapter/kysely-adapter.ts b/packages/better-auth/src/adapters/kysely-adapter/kysely-adapter.ts index c1e4c471d2..4a05f5cfa2 100644 --- a/packages/better-auth/src/adapters/kysely-adapter/kysely-adapter.ts +++ b/packages/better-auth/src/adapters/kysely-adapter/kysely-adapter.ts @@ -335,6 +335,7 @@ export const kyselyAdapter = ( ? false : true, supportsJSON: false, + supportsUUIDs: config?.type === "postgres" ? true : false, transaction: config?.transaction ? (cb) => db.transaction().execute((trx) => { diff --git a/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.pg-custom-schema.test.ts b/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.custom-schema-pg.test.ts similarity index 100% rename from packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.pg-custom-schema.test.ts rename to packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.custom-schema-pg.test.ts diff --git a/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.mssql.test.ts b/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.mssql.test.ts index fd0b53b91a..ee846bffac 100644 --- a/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.mssql.test.ts +++ b/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.mssql.test.ts @@ -9,6 +9,7 @@ import { normalTestSuite, numberIdTestSuite, transactionsTestSuite, + uuidTestSuite, } from "../../tests"; import { kyselyAdapter } from "../kysely-adapter"; @@ -307,6 +308,7 @@ const { execute } = await testAdapter({ transactionsTestSuite({ disableTests: { ALL: true } }), authFlowTestSuite({ showDB }), numberIdTestSuite(), + uuidTestSuite(), ], async onFinish() { kyselyDB.destroy(); diff --git a/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.mysql.test.ts b/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.mysql.test.ts index 123a57acad..b3c05521f8 100644 --- a/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.mysql.test.ts +++ b/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.mysql.test.ts @@ -8,6 +8,7 @@ import { normalTestSuite, numberIdTestSuite, transactionsTestSuite, + uuidTestSuite, } from "../../tests"; import { kyselyAdapter } from "../kysely-adapter"; @@ -47,6 +48,7 @@ const { execute } = await testAdapter({ transactionsTestSuite({ disableTests: { ALL: true } }), authFlowTestSuite(), numberIdTestSuite(), + uuidTestSuite(), ], async onFinish() { await mysqlDB.end(); diff --git a/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.pg.test.ts b/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.pg.test.ts index ef3c4d2200..eb204d4bb3 100644 --- a/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.pg.test.ts +++ b/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.pg.test.ts @@ -8,6 +8,7 @@ import { normalTestSuite, numberIdTestSuite, transactionsTestSuite, + uuidTestSuite, } from "../../tests"; import { kyselyAdapter } from "../kysely-adapter"; @@ -35,7 +36,8 @@ const { execute } = await testAdapter({ const opts = Object.assign(betterAuthOptions, { database: pgDB, } satisfies BetterAuthOptions); - const { runMigrations } = await getMigrations(opts); + const { runMigrations, compileMigrations } = await getMigrations(opts); + // console.log(await compileMigrations()); await runMigrations(); }, tests: [ @@ -43,6 +45,7 @@ const { execute } = await testAdapter({ transactionsTestSuite({ disableTests: { ALL: true } }), authFlowTestSuite(), numberIdTestSuite(), + uuidTestSuite({}), ], async onFinish() { await pgDB.end(); diff --git a/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.sqlite.test.ts b/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.sqlite.test.ts index 0db92711ad..8ee2139a3b 100644 --- a/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.sqlite.test.ts +++ b/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.sqlite.test.ts @@ -9,6 +9,7 @@ import { normalTestSuite, numberIdTestSuite, transactionsTestSuite, + uuidTestSuite, } from "../../tests"; import { kyselyAdapter } from "../kysely-adapter"; @@ -45,6 +46,7 @@ const { execute } = await testAdapter({ transactionsTestSuite({ disableTests: { ALL: true } }), authFlowTestSuite(), numberIdTestSuite(), + uuidTestSuite({}), ], async onFinish() { database.close(); diff --git a/packages/better-auth/src/adapters/memory-adapter/adapter.memory.test.ts b/packages/better-auth/src/adapters/memory-adapter/adapter.memory.test.ts index 45143faad6..29873577a9 100644 --- a/packages/better-auth/src/adapters/memory-adapter/adapter.memory.test.ts +++ b/packages/better-auth/src/adapters/memory-adapter/adapter.memory.test.ts @@ -4,8 +4,8 @@ import { authFlowTestSuite, normalTestSuite, numberIdTestSuite, - performanceTestSuite, transactionsTestSuite, + uuidTestSuite, } from "../tests"; import { memoryAdapter } from "./memory-adapter"; @@ -29,7 +29,7 @@ const { execute } = await testAdapter({ transactionsTestSuite({ disableTests: { ALL: true } }), authFlowTestSuite(), numberIdTestSuite(), - performanceTestSuite(), + uuidTestSuite(), ], async onFinish() {}, }); diff --git a/packages/better-auth/src/adapters/memory-adapter/memory-adapter.ts b/packages/better-auth/src/adapters/memory-adapter/memory-adapter.ts index e9d9fe8ba9..63edbd3e47 100644 --- a/packages/better-auth/src/adapters/memory-adapter/memory-adapter.ts +++ b/packages/better-auth/src/adapters/memory-adapter/memory-adapter.ts @@ -26,11 +26,10 @@ export const memoryAdapter = ( usePlural: false, debugLogs: config?.debugLogs || false, customTransformInput(props) { - if ( - props.options.advanced?.database?.useNumberId && - props.field === "id" && - props.action === "create" - ) { + const useNumberId = + props.options.advanced?.database?.useNumberId || + props.options.advanced?.database?.generateId === "serial"; + if (useNumberId && props.field === "id" && props.action === "create") { return db[props.model]!.length + 1; } return props.data; @@ -49,7 +48,7 @@ export const memoryAdapter = ( } }, }, - adapter: ({ getFieldName, options, debugLog }) => { + adapter: ({ getFieldName, options, debugLog, getModelName }) => { function convertWhereClause(where: CleanedWhere[], model: string) { const table = db[model]; if (!table) { @@ -117,9 +116,12 @@ export const memoryAdapter = ( } return { create: async ({ model, data }) => { - if (options.advanced?.database?.useNumberId) { + const useNumberId = + options.advanced?.database?.useNumberId || + options.advanced?.database?.generateId === "serial"; + if (useNumberId) { // @ts-expect-error - data.id = db[model]!.length + 1; + data.id = db[getModelName(model)]!.length + 1; } if (!db[model]) { db[model] = []; diff --git a/packages/better-auth/src/adapters/mongodb-adapter/adapter.mongo-db.test.ts b/packages/better-auth/src/adapters/mongodb-adapter/adapter.mongo-db.test.ts index dab8a679e1..eab841cc23 100644 --- a/packages/better-auth/src/adapters/mongodb-adapter/adapter.mongo-db.test.ts +++ b/packages/better-auth/src/adapters/mongodb-adapter/adapter.mongo-db.test.ts @@ -30,7 +30,8 @@ const { execute } = await testAdapter({ normalTestSuite(), authFlowTestSuite(), transactionsTestSuite(), - // numberIdTestSuite(), // Mongo doesn't support number ids + // numberIdTestSuite(), // no support + // uuidTestSuite() // no support ], customIdGenerator: () => new ObjectId().toHexString(), }); diff --git a/packages/better-auth/src/adapters/prisma-adapter/prisma-adapter.ts b/packages/better-auth/src/adapters/prisma-adapter/prisma-adapter.ts index 54bb7e793a..c060de8c2a 100644 --- a/packages/better-auth/src/adapters/prisma-adapter/prisma-adapter.ts +++ b/packages/better-auth/src/adapters/prisma-adapter/prisma-adapter.ts @@ -264,6 +264,7 @@ export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) => { adapterName: "Prisma Adapter", usePlural: config.usePlural ?? false, debugLogs: config.debugLogs ?? false, + supportsUUIDs: config.provider === "postgresql" ? true : false, transaction: (config.transaction ?? false) ? (cb) => diff --git a/packages/better-auth/src/adapters/prisma-adapter/test/prisma.mysql.test.ts b/packages/better-auth/src/adapters/prisma-adapter/test/prisma.mysql.test.ts index ea0d99285b..aa7f5f9c02 100644 --- a/packages/better-auth/src/adapters/prisma-adapter/test/prisma.mysql.test.ts +++ b/packages/better-auth/src/adapters/prisma-adapter/test/prisma.mysql.test.ts @@ -6,6 +6,7 @@ import { normalTestSuite, numberIdTestSuite, transactionsTestSuite, + uuidTestSuite, } from "../../tests"; import { prismaAdapter } from "../prisma-adapter"; import { generateAuthConfigFile } from "./generate-auth-config"; @@ -46,6 +47,7 @@ const { execute } = await testAdapter({ transactionsTestSuite(), authFlowTestSuite(), numberIdTestSuite(), + uuidTestSuite(), ], onFinish: async () => {}, prefixTests: dialect, diff --git a/packages/better-auth/src/adapters/prisma-adapter/test/prisma.pg.test.ts b/packages/better-auth/src/adapters/prisma-adapter/test/prisma.pg.test.ts index 71f9dfa539..174d76f465 100644 --- a/packages/better-auth/src/adapters/prisma-adapter/test/prisma.pg.test.ts +++ b/packages/better-auth/src/adapters/prisma-adapter/test/prisma.pg.test.ts @@ -6,6 +6,7 @@ import { normalTestSuite, numberIdTestSuite, transactionsTestSuite, + uuidTestSuite, } from "../../tests"; import { prismaAdapter } from "../prisma-adapter"; import { generateAuthConfigFile } from "./generate-auth-config"; @@ -44,6 +45,7 @@ const { execute } = await testAdapter({ transactionsTestSuite(), authFlowTestSuite(), numberIdTestSuite(), + uuidTestSuite({}), ], onFinish: async () => {}, prefixTests: "pg", diff --git a/packages/better-auth/src/adapters/prisma-adapter/test/prisma.sqlite.test.ts b/packages/better-auth/src/adapters/prisma-adapter/test/prisma.sqlite.test.ts index fc11b35196..58e14f3ea9 100644 --- a/packages/better-auth/src/adapters/prisma-adapter/test/prisma.sqlite.test.ts +++ b/packages/better-auth/src/adapters/prisma-adapter/test/prisma.sqlite.test.ts @@ -7,6 +7,7 @@ import { normalTestSuite, numberIdTestSuite, transactionsTestSuite, + uuidTestSuite, } from "../../tests"; import { prismaAdapter } from "../prisma-adapter"; import { generateAuthConfigFile } from "./generate-auth-config"; @@ -47,6 +48,7 @@ const { execute } = await testAdapter({ transactionsTestSuite(), authFlowTestSuite(), numberIdTestSuite({}), + uuidTestSuite(), ], onFinish: async () => {}, prefixTests: dialect, diff --git a/packages/better-auth/src/adapters/test.ts b/packages/better-auth/src/adapters/test.ts index 133cc434cb..2c64cb5157 100644 --- a/packages/better-auth/src/adapters/test.ts +++ b/packages/better-auth/src/adapters/test.ts @@ -1069,7 +1069,7 @@ export function runNumberIdAdapterTest(opts: NumberIdAdapterTestOptions) { await opts.getAdapter({ advanced: { database: { - useNumberId: true, + generateId: "serial", }, }, }); @@ -1151,7 +1151,7 @@ export function runNumberIdAdapterTest(opts: NumberIdAdapterTestOptions) { predefinedOptions: { advanced: { database: { - useNumberId: true, + generateId: "serial", }, }, }, diff --git a/packages/better-auth/src/adapters/tests/index.ts b/packages/better-auth/src/adapters/tests/index.ts index 7f80f1bb6d..6aac29d60c 100644 --- a/packages/better-auth/src/adapters/tests/index.ts +++ b/packages/better-auth/src/adapters/tests/index.ts @@ -3,3 +3,4 @@ export * from "./normal"; export * from "./number-id"; export * from "./performance"; export * from "./transactions"; +export * from "./uuid"; diff --git a/packages/better-auth/src/adapters/tests/normal.ts b/packages/better-auth/src/adapters/tests/normal.ts index 9e31d468d0..698da3d04d 100644 --- a/packages/better-auth/src/adapters/tests/normal.ts +++ b/packages/better-auth/src/adapters/tests/normal.ts @@ -11,7 +11,10 @@ export const normalTestSuite = createTestSuite("normal", {}, (helpers) => { return { "init - tests": async () => { const opts = helpers.getBetterAuthOptions(); - expect(opts.advanced?.database?.useNumberId).toBe(undefined); + expect( + !opts.advanced?.database?.useNumberId && + opts.advanced?.database?.generateId !== "serial", + ).toBeTruthy(); }, ...tests, }; @@ -37,12 +40,15 @@ export const getNormalTestSuiteTests = ({ forceAllowId: true, }); const options = getBetterAuthOptions(); - if (options.advanced?.database?.useNumberId) { - expect(typeof result.id).toEqual("string"); + if ( + options.advanced?.database?.useNumberId || + options.advanced?.database?.generateId === "serial" || + options.advanced?.database?.generateId === "uuid" + ) { user.id = result.id; - } else { - expect(typeof result.id).toEqual("string"); } + + expect(typeof result.id).toEqual("string"); const transformed = transformGeneratedModel(user); // console.log(`pre-transformed:`, user); // console.log(`transformed:`, transformed); @@ -295,9 +301,13 @@ export const getNormalTestSuiteTests = ({ expect(result).toEqual(session); }, "findOne - should not throw on record not found": async () => { + const options = getBetterAuthOptions(); + const useUUIDs = options.advanced?.database?.generateId === "uuid"; const result = await adapter.findOne({ model: "user", - where: [{ field: "id", value: "100000" }], + where: [ + { field: "id", value: useUUIDs ? crypto.randomUUID() : "100000" }, + ], }); expect(result).toBeNull(); }, @@ -422,9 +432,13 @@ export const getNormalTestSuiteTests = ({ }, "findMany - should return an empty array when no models are found": async () => { + const options = getBetterAuthOptions(); + const useUUIDs = options.advanced?.database?.generateId === "uuid"; const result = await adapter.findMany({ model: "user", - where: [{ field: "id", value: "100000" }], + where: [ + { field: "id", value: useUUIDs ? crypto.randomUUID() : "100000" }, + ], }); expect(result).toEqual([]); }, @@ -859,7 +873,10 @@ export const getNormalTestSuiteTests = ({ throw error; } const options = getBetterAuthOptions(); - if (options.advanced?.database?.useNumberId) { + if ( + options.advanced?.database?.useNumberId || + options.advanced?.database?.generateId === "serial" + ) { expect(Number(users[0]!.id)).not.toBeNaN(); } }, @@ -1166,10 +1183,14 @@ export const getNormalTestSuiteTests = ({ expect(result).toBeNull(); }, "delete - should not throw on record not found": async () => { + const options = getBetterAuthOptions(); + const useUUIDs = options.advanced?.database?.generateId === "uuid"; await expect( adapter.delete({ model: "user", - where: [{ field: "id", value: "100000" }], + where: [ + { field: "id", value: useUUIDs ? crypto.randomUUID() : "100000" }, + ], }), ).resolves.not.toThrow(); }, diff --git a/packages/better-auth/src/adapters/tests/number-id.ts b/packages/better-auth/src/adapters/tests/number-id.ts index c28c93c19d..6a5266fc3a 100644 --- a/packages/better-auth/src/adapters/tests/number-id.ts +++ b/packages/better-auth/src/adapters/tests/number-id.ts @@ -9,7 +9,7 @@ export const numberIdTestSuite = createTestSuite( defaultBetterAuthOptions: { advanced: { database: { - useNumberId: true, + generateId: "serial", }, }, }, @@ -23,7 +23,10 @@ export const numberIdTestSuite = createTestSuite( return { "init - tests": async () => { const opts = helpers.getBetterAuthOptions(); - expect(opts.advanced?.database?.useNumberId).toBe(true); + expect( + opts.advanced?.database?.useNumberId || + opts.advanced?.database?.generateId === "serial", + ).toBe(true); }, "create - should return a number id": async () => { const user = await helpers.generate("user"); diff --git a/packages/better-auth/src/adapters/tests/uuid.ts b/packages/better-auth/src/adapters/tests/uuid.ts new file mode 100644 index 0000000000..8302d81e65 --- /dev/null +++ b/packages/better-auth/src/adapters/tests/uuid.ts @@ -0,0 +1,68 @@ +import { expect } from "vitest"; +import type { User } from "../../../../core/src/db/schema/user"; +import { createTestSuite } from "../create-test-suite"; +import { getNormalTestSuiteTests } from "./normal"; + +export const uuidTestSuite = createTestSuite( + "uuid", + { + defaultBetterAuthOptions: { + advanced: { + database: { + generateId: "uuid", + }, + }, + }, + prefixTests: "uuid", + alwaysMigrate: true, + // This is here to overwrite `generateId` functions to generate UUIDs instead of the default. + // Since existing tests often use generated IDs as well as `forceAllowId` to be true, this is needed to ensure the tests pass. + customIdGenerator() { + return crypto.randomUUID(); + }, + }, + (helpers) => { + const { "create - should use generateId if provided": _, ...normalTests } = + getNormalTestSuiteTests(helpers); + return { + "init - tests": async () => { + const opts = helpers.getBetterAuthOptions(); + expect(opts.advanced?.database?.generateId === "uuid").toBe(true); + }, + "create - should return a uuid": async () => { + const user = await helpers.generate("user"); + const res = await helpers.adapter.create({ + model: "user", + data: { + ...user, + //@ts-expect-error - remove id from `user` + id: undefined, + }, + }); + expect(res).toHaveProperty("id"); + expect(typeof res.id).toBe("string"); + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + expect(res.id).toMatch(uuidRegex); + console.log(res); + }, + "findOne - should find a model using a uuid": async () => { + const { id: _, ...user } = await helpers.generate("user"); + const res = await helpers.adapter.create({ + model: "user", + data: user, + }); + + const result = await helpers.adapter.findOne({ + model: "user", + where: [{ field: "id", value: res.id }], + }); + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + expect(result?.id).toMatch(uuidRegex); + expect(result).toEqual(res); + }, + ...normalTests, + }; + }, +); diff --git a/packages/better-auth/src/db/get-migration-schema.test.ts b/packages/better-auth/src/db/get-migration-schema.test.ts index acabe8e541..e6a6abdd26 100644 --- a/packages/better-auth/src/db/get-migration-schema.test.ts +++ b/packages/better-auth/src/db/get-migration-schema.test.ts @@ -213,8 +213,10 @@ describe.runIf(isPostgresAvailable)( }, }, }; - const { runMigrations } = await getMigrations(config); + const { runMigrations, compileMigrations } = await getMigrations(config); await runMigrations(); + const migrations = await compileMigrations(); + console.log(migrations); const auth = betterAuth(config); const user = await auth.api.signUpEmail({ @@ -227,6 +229,12 @@ describe.runIf(isPostgresAvailable)( const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; expect(user.user.id).toMatch(uuidRegex); + + // run migrations again to ensure no migrations are needed & no errors are thrown + const { compileMigrations: round2Migrations } = + await getMigrations(config); + const secondRoundOfMigrations = await round2Migrations(); + expect(secondRoundOfMigrations).toEqual(";"); }); }, ); diff --git a/packages/better-auth/src/db/get-migration.ts b/packages/better-auth/src/db/get-migration.ts index 287de3e535..b93a51bda1 100644 --- a/packages/better-auth/src/db/get-migration.ts +++ b/packages/better-auth/src/db/get-migration.ts @@ -317,15 +317,30 @@ export async function getMigrations(config: BetterAuthOptions) { }, id: { postgres: useNumberId ? "serial" : useUUIDs ? "uuid" : "text", - mysql: useNumberId ? "integer" : useUUIDs ? "uuid" : "varchar(36)", - mssql: useNumberId ? "integer" : useUUIDs ? "uuid" : "varchar(36)", - + mysql: useNumberId + ? "integer" + : useUUIDs + ? "varchar(36)" + : "varchar(36)", + mssql: useNumberId + ? "integer" + : useUUIDs + ? "varchar(36)" + : "varchar(36)", sqlite: useNumberId ? "integer" : "text", }, foreignKeyId: { postgres: useNumberId ? "integer" : useUUIDs ? "uuid" : "text", - mysql: useNumberId ? "integer" : useUUIDs ? "uuid" : "varchar(36)", - mssql: useNumberId ? "integer" : useUUIDs ? "uuid" : "varchar(36)", + mysql: useNumberId + ? "integer" + : useUUIDs + ? "varchar(36)" + : "varchar(36)", + mssql: useNumberId + ? "integer" + : useUUIDs + ? "varchar(36)" /* Should be using `UNIQUEIDENTIFIER` but Kysely doesn't support it */ + : "varchar(36)", sqlite: useNumberId ? "integer" : "text", }, } as const; @@ -399,21 +414,10 @@ export async function getMigrations(config: BetterAuthOptions) { if (toBeCreated.length) { for (const table of toBeCreated) { - let dbT = db.schema.createTable(table.table).addColumn( - "id", - useNumberId - ? dbType === "postgres" - ? "serial" - : "integer" - : useUUIDs - ? dbType === "postgres" || dbType === "mysql" || dbType === "mssql" - ? "uuid" - : "text" - : dbType === "mysql" || dbType === "mssql" - ? "varchar(36)" - : "text", - - (col) => { + const idType = getType({ type: useNumberId ? "number" : "string" }, "id"); + let dbT = db.schema + .createTable(table.table) + .addColumn("id", idType, (col) => { if (useNumberId) { if (dbType === "postgres" || dbType === "sqlite") { return col.primaryKey().notNull(); @@ -428,14 +432,11 @@ export async function getMigrations(config: BetterAuthOptions) { .primaryKey() .defaultTo(sql`pg_catalog.gen_random_uuid()`) .notNull(); - } else if (dbType === "mysql" || dbType === "mssql") { - return col.primaryKey().defaultTo(sql`uuid()`).notNull(); } return col.primaryKey().notNull(); } return col.primaryKey().notNull(); - }, - ); + }); for (const [fieldName, field] of Object.entries(table.fields)) { const type = getType(field, fieldName); diff --git a/packages/cli/src/generators/drizzle.ts b/packages/cli/src/generators/drizzle.ts index a80eed54d6..30f821818e 100644 --- a/packages/cli/src/generators/drizzle.ts +++ b/packages/cli/src/generators/drizzle.ts @@ -52,7 +52,11 @@ export const generateDrizzleSchema: SchemaGenerator = async ({ } name = convertToSnakeCase(name, adapter.options?.camelCase); if (field.references?.field === "id") { - if (options.advanced?.database?.useNumberId) { + const useNumberId = + options.advanced?.database?.useNumberId || + options.advanced?.database?.generateId === "serial"; + const useUUIDs = options.advanced?.database?.generateId === "uuid"; + if (useNumberId) { if (databaseType === "pg") { return `integer('${name}')`; } else if (databaseType === "mysql") { @@ -62,6 +66,9 @@ export const generateDrizzleSchema: SchemaGenerator = async ({ return `integer('${name}')`; } } + if (useUUIDs && databaseType === "pg") { + return `uuid('${name}')`; + } if (field.references.field) { if (databaseType === "mysql") { return `varchar('${name}', { length: 36 })`; @@ -144,7 +151,14 @@ export const generateDrizzleSchema: SchemaGenerator = async ({ let id: string = ""; - if (options.advanced?.database?.useNumberId) { + const useNumberId = + options.advanced?.database?.useNumberId || + options.advanced?.database?.generateId === "serial"; + const useUUIDs = options.advanced?.database?.generateId === "uuid"; + + if (useUUIDs && databaseType === "pg") { + id = `uuid("id").default(sql\`pg_catalog.gen_random_uuid()\`).primaryKey()`; + } else if (useNumberId) { if (databaseType === "pg") { id = `serial("id").primaryKey()`; } else if (databaseType === "sqlite") { @@ -289,7 +303,11 @@ function generateImport({ if (hasJson && hasBigint) break; } - const useNumberId = options.advanced?.database?.useNumberId; + const useNumberId = + options.advanced?.database?.useNumberId || + options.advanced?.database?.generateId === "serial"; + + const useUUIDs = options.advanced?.database?.generateId === "uuid"; coreImports.push(`${databaseType}Table`); coreImports.push( @@ -328,6 +346,10 @@ function generateImport({ coreImports.push("mysqlEnum"); } } else if (databaseType === "pg") { + if (useUUIDs) { + rootImports.push("sql"); + } + // Only include integer for PG if actually needed const hasNonBigintNumber = Object.values(tables).some((table) => Object.values(table.fields).some( @@ -344,14 +366,22 @@ function generateImport({ // handles the references field with useNumberId const needsInteger = hasNonBigintNumber || - (options.advanced?.database?.useNumberId && hasFkToId); + ((options.advanced?.database?.useNumberId || + options.advanced?.database?.generateId === "serial") && + hasFkToId); if (needsInteger) { coreImports.push("integer"); } } else { coreImports.push("integer"); } - coreImports.push(useNumberId ? (databaseType === "pg" ? "serial" : "") : ""); + if (databaseType === "pg") { + if (useNumberId) { + coreImports.push("serial"); + } else if (useUUIDs) { + coreImports.push("uuid"); + } + } //handle json last on the import order if (hasJson) { diff --git a/packages/cli/src/generators/prisma.ts b/packages/cli/src/generators/prisma.ts index 4d8e73d8fc..1f27e3ede8 100644 --- a/packages/cli/src/generators/prisma.ts +++ b/packages/cli/src/generators/prisma.ts @@ -124,12 +124,25 @@ export const generatePrismaSchema: SchemaGenerator = async ({ .attribute("id") .attribute(`map("_id")`); } else { - if (options.advanced?.database?.useNumberId) { + const useNumberId = + options.advanced?.database?.useNumberId || + options.advanced?.database?.generateId === "serial"; + const useUUIDs = options.advanced?.database?.generateId === "uuid"; + if (useNumberId) { builder .model(modelName) .field("id", "Int") .attribute("id") .attribute("default(autoincrement())"); + } else if (useUUIDs && provider === "postgresql") { + builder + .model(modelName) + .field("id", "String") + .attribute("id") + .attribute("db.Uuid") + .attribute( + 'default(dbgenerated("pg_catalog.gen_random_uuid()"))', + ); } else { builder.model(modelName).field("id", "String").attribute("id"); } @@ -149,10 +162,13 @@ export const generatePrismaSchema: SchemaGenerator = async ({ continue; } } - + const useUUIDs = options.advanced?.database?.generateId === "uuid"; + const useNumberId = + options.advanced?.database?.useNumberId || + options.advanced?.database?.generateId === "serial"; const fieldBuilder = builder.model(modelName).field( fieldName, - field === "id" && options.advanced?.database?.useNumberId + field === "id" && useNumberId ? getType({ isBigint: false, isOptional: false, @@ -163,7 +179,7 @@ export const generatePrismaSchema: SchemaGenerator = async ({ isOptional: !attr?.required, type: attr.references?.field === "id" - ? options.advanced?.database?.useNumberId + ? useNumberId ? "number" : "string" : attr.type, @@ -202,6 +218,10 @@ export const generatePrismaSchema: SchemaGenerator = async ({ } if (attr.references) { + if (useUUIDs && provider === "postgresql") { + fieldBuilder.attribute(`db.Uuid`); + } + const referencedOriginalModelName = attr.references.model; const referencedCustomModelName = tables[referencedOriginalModelName]?.modelName || @@ -212,17 +232,17 @@ export const generatePrismaSchema: SchemaGenerator = async ({ else if (attr.references.onDelete === "set default") action = "SetDefault"; else if (attr.references.onDelete === "restrict") action = "Restrict"; + + const relationField = `relation(fields: [${fieldName}], references: [${attr.references.field}], onDelete: ${action})`; builder .model(modelName) .field( - `${referencedCustomModelName.toLowerCase()}`, + referencedCustomModelName.toLowerCase(), `${capitalizeFirstLetter(referencedCustomModelName)}${ !attr.required ? "?" : "" }`, ) - .attribute( - `relation(fields: [${fieldName}], references: [${attr.references.field}], onDelete: ${action})`, - ); + .attribute(relationField); } if ( !attr.unique && @@ -258,10 +278,11 @@ export const generatePrismaSchema: SchemaGenerator = async ({ let indexField = fieldName; if (provider === "mysql" && field && field.type === "string") { - if ( - field.references?.field === "id" && - options.advanced?.database?.useNumberId - ) { + const useNumberId = + options.advanced?.database?.useNumberId || + options.advanced?.database?.generateId === "serial"; + const useUUIDs = options.advanced?.database?.generateId === "uuid"; + if (field.references?.field === "id" && (useNumberId || useUUIDs)) { indexField = `${fieldName}`; } else { indexField = `${fieldName}(length: 191)`; // length of 191 because String in Prisma is varchar(191) diff --git a/packages/cli/test/__snapshots__/auth-schema-mysql-uuid.txt b/packages/cli/test/__snapshots__/auth-schema-mysql-uuid.txt new file mode 100644 index 0000000000..2ff7fdf2b8 --- /dev/null +++ b/packages/cli/test/__snapshots__/auth-schema-mysql-uuid.txt @@ -0,0 +1,99 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + boolean, + index, +} from "drizzle-orm/mysql-core"; + +export const custom_user = mysqlTable("custom_user", { + id: varchar("id", { length: 36 }).primaryKey(), + name: varchar("name", { length: 255 }).notNull(), + email: varchar("email", { length: 255 }).notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + createdAt: timestamp("created_at", { fsp: 3 }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { fsp: 3 }) + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + twoFactorEnabled: boolean("two_factor_enabled").default(false), + username: varchar("username", { length: 255 }).unique(), + displayUsername: text("display_username"), +}); + +export const custom_session = mysqlTable( + "custom_session", + { + id: varchar("id", { length: 36 }).primaryKey(), + expiresAt: timestamp("expires_at", { fsp: 3 }).notNull(), + token: varchar("token", { length: 255 }).notNull().unique(), + createdAt: timestamp("created_at", { fsp: 3 }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { fsp: 3 }) + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: varchar("user_id", { length: 36 }) + .notNull() + .references(() => custom_user.id, { onDelete: "cascade" }), + }, + (table) => [index("custom_session_userId_idx").on(table.userId)], +); + +export const custom_account = mysqlTable( + "custom_account", + { + id: varchar("id", { length: 36 }).primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: varchar("user_id", { length: 36 }) + .notNull() + .references(() => custom_user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at", { fsp: 3 }), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { fsp: 3 }), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at", { fsp: 3 }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { fsp: 3 }) + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("custom_account_userId_idx").on(table.userId)], +); + +export const custom_verification = mysqlTable( + "custom_verification", + { + id: varchar("id", { length: 36 }).primaryKey(), + identifier: varchar("identifier", { length: 255 }).notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at", { fsp: 3 }).notNull(), + createdAt: timestamp("created_at", { fsp: 3 }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { fsp: 3 }) + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("custom_verification_identifier_idx").on(table.identifier)], +); + +export const twoFactor = mysqlTable( + "two_factor", + { + id: varchar("id", { length: 36 }).primaryKey(), + secret: varchar("secret", { length: 255 }).notNull(), + backupCodes: text("backup_codes").notNull(), + userId: varchar("user_id", { length: 36 }) + .notNull() + .references(() => custom_user.id, { onDelete: "cascade" }), + }, + (table) => [ + index("twoFactor_secret_idx").on(table.secret), + index("twoFactor_userId_idx").on(table.userId), + ], +); diff --git a/packages/cli/test/__snapshots__/auth-schema-pg-uuid.txt b/packages/cli/test/__snapshots__/auth-schema-pg-uuid.txt new file mode 100644 index 0000000000..815ae0a9bc --- /dev/null +++ b/packages/cli/test/__snapshots__/auth-schema-pg-uuid.txt @@ -0,0 +1,110 @@ +import { sql } from "drizzle-orm"; +import { + pgTable, + text, + timestamp, + boolean, + uuid, + index, +} from "drizzle-orm/pg-core"; + +export const custom_user = pgTable("custom_user", { + id: uuid("id") + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + twoFactorEnabled: boolean("two_factor_enabled").default(false), + username: text("username").unique(), + displayUsername: text("display_username"), +}); + +export const custom_session = pgTable( + "custom_session", + { + id: uuid("id") + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: uuid("user_id") + .notNull() + .references(() => custom_user.id, { onDelete: "cascade" }), + }, + (table) => [index("custom_session_userId_idx").on(table.userId)], +); + +export const custom_account = pgTable( + "custom_account", + { + id: uuid("id") + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: uuid("user_id") + .notNull() + .references(() => custom_user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("custom_account_userId_idx").on(table.userId)], +); + +export const custom_verification = pgTable( + "custom_verification", + { + id: uuid("id") + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("custom_verification_identifier_idx").on(table.identifier)], +); + +export const twoFactor = pgTable( + "two_factor", + { + id: uuid("id") + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey(), + secret: text("secret").notNull(), + backupCodes: text("backup_codes").notNull(), + userId: uuid("user_id") + .notNull() + .references(() => custom_user.id, { onDelete: "cascade" }), + }, + (table) => [ + index("twoFactor_secret_idx").on(table.secret), + index("twoFactor_userId_idx").on(table.userId), + ], +); diff --git a/packages/cli/test/__snapshots__/auth-schema-sqlite-uuid.txt b/packages/cli/test/__snapshots__/auth-schema-sqlite-uuid.txt new file mode 100644 index 0000000000..2f175c2dea --- /dev/null +++ b/packages/cli/test/__snapshots__/auth-schema-sqlite-uuid.txt @@ -0,0 +1,109 @@ +import { sql } from "drizzle-orm"; +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; + +export const custom_user = sqliteTable("custom_user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: integer("email_verified", { mode: "boolean" }) + .default(false) + .notNull(), + image: text("image"), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + twoFactorEnabled: integer("two_factor_enabled", { mode: "boolean" }).default( + false, + ), + username: text("username").unique(), + displayUsername: text("display_username"), +}); + +export const custom_session = sqliteTable( + "custom_session", + { + id: text("id").primaryKey(), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), + token: text("token").notNull().unique(), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }) + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => custom_user.id, { onDelete: "cascade" }), + }, + (table) => [index("custom_session_userId_idx").on(table.userId)], +); + +export const custom_account = sqliteTable( + "custom_account", + { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => custom_user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: integer("access_token_expires_at", { + mode: "timestamp_ms", + }), + refreshTokenExpiresAt: integer("refresh_token_expires_at", { + mode: "timestamp_ms", + }), + scope: text("scope"), + password: text("password"), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }) + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("custom_account_userId_idx").on(table.userId)], +); + +export const custom_verification = sqliteTable( + "custom_verification", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("custom_verification_identifier_idx").on(table.identifier)], +); + +export const twoFactor = sqliteTable( + "two_factor", + { + id: text("id").primaryKey(), + secret: text("secret").notNull(), + backupCodes: text("backup_codes").notNull(), + userId: text("user_id") + .notNull() + .references(() => custom_user.id, { onDelete: "cascade" }), + }, + (table) => [ + index("twoFactor_secret_idx").on(table.secret), + index("twoFactor_userId_idx").on(table.userId), + ], +); diff --git a/packages/cli/test/__snapshots__/schema-uuid.prisma b/packages/cli/test/__snapshots__/schema-uuid.prisma new file mode 100644 index 0000000000..8ba1ca9c1b --- /dev/null +++ b/packages/cli/test/__snapshots__/schema-uuid.prisma @@ -0,0 +1,89 @@ + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id @db.Uuid @default(dbgenerated("pg_catalog.gen_random_uuid()")) + name String + email String + emailVerified Boolean @default(false) + image String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + twoFactorEnabled Boolean? @default(false) + username String? + displayUsername String? + sessions Session[] + accounts Account[] + twofactors TwoFactor[] + + @@unique([email]) + @@unique([username]) + @@map("user") +} + +model Session { + id String @id @db.Uuid @default(dbgenerated("pg_catalog.gen_random_uuid()")) + expiresAt DateTime + token String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + ipAddress String? + userAgent String? + userId String @db.Uuid + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([token]) + @@index([userId]) + @@map("session") +} + +model Account { + id String @id @db.Uuid @default(dbgenerated("pg_catalog.gen_random_uuid()")) + accountId String + providerId String + userId String @db.Uuid + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + accessToken String? + refreshToken String? + idToken String? + accessTokenExpiresAt DateTime? + refreshTokenExpiresAt DateTime? + scope String? + password String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@map("account") +} + +model Verification { + id String @id @db.Uuid @default(dbgenerated("pg_catalog.gen_random_uuid()")) + identifier String + value String + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([identifier]) + @@map("verification") +} + +model TwoFactor { + id String @id @db.Uuid @default(dbgenerated("pg_catalog.gen_random_uuid()")) + secret String + backupCodes String + userId String @db.Uuid + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([secret]) + @@index([userId]) + @@map("twoFactor") +} diff --git a/packages/cli/test/generate-all-db.test.ts b/packages/cli/test/generate-all-db.test.ts index bed3c260d2..ad16423382 100644 --- a/packages/cli/test/generate-all-db.test.ts +++ b/packages/cli/test/generate-all-db.test.ts @@ -103,7 +103,7 @@ describe("generate drizzle schema for all databases", async () => { plugins: [twoFactor(), username()], advanced: { database: { - useNumberId: true, + generateId: "serial", }, }, user: { @@ -124,6 +124,133 @@ describe("generate drizzle schema for all databases", async () => { "./__snapshots__/auth-schema-mysql-number-id.txt", ); }); + it("should generate drizzle schema for MySQL with uuid id", async () => { + const schema = await generateDrizzleSchema({ + file: "test.drizzle", + adapter: drizzleAdapter( + {}, + { + provider: "mysql", + schema: {}, + }, + )({} as BetterAuthOptions), + options: { + database: drizzleAdapter( + {}, + { + provider: "mysql", + schema: {}, + }, + ), + plugins: [twoFactor(), username()], + advanced: { + database: { + generateId: "uuid", + }, + }, + user: { + modelName: "custom_user", + }, + account: { + modelName: "custom_account", + }, + session: { + modelName: "custom_session", + }, + verification: { + modelName: "custom_verification", + }, + }, + }); + expect(schema.code).toMatchFileSnapshot( + "./__snapshots__/auth-schema-mysql-uuid.txt", + ); + }); + + it("should generate drizzle schema for PostgreSQL with uuid id", async () => { + const schema = await generateDrizzleSchema({ + file: "test.drizzle", + adapter: drizzleAdapter( + {}, + { + provider: "pg", + schema: {}, + }, + )({} as BetterAuthOptions), + options: { + database: drizzleAdapter( + {}, + { + provider: "pg", + schema: {}, + }, + ), + plugins: [twoFactor(), username()], + advanced: { + database: { + generateId: "uuid", + }, + }, + user: { + modelName: "custom_user", + }, + account: { + modelName: "custom_account", + }, + session: { + modelName: "custom_session", + }, + verification: { + modelName: "custom_verification", + }, + }, + }); + expect(schema.code).toMatchFileSnapshot( + "./__snapshots__/auth-schema-pg-uuid.txt", + ); + }); + it("should generate drizzle schema for SQLite with uuid id", async () => { + const schema = await generateDrizzleSchema({ + file: "test.drizzle", + adapter: drizzleAdapter( + {}, + { + provider: "sqlite", + schema: {}, + }, + )({} as BetterAuthOptions), + options: { + database: drizzleAdapter( + {}, + { + provider: "sqlite", + schema: {}, + }, + ), + plugins: [twoFactor(), username()], + advanced: { + database: { + generateId: "uuid", + }, + }, + user: { + modelName: "custom_user", + }, + account: { + modelName: "custom_account", + }, + session: { + modelName: "custom_session", + }, + verification: { + modelName: "custom_verification", + }, + }, + }); + expect(schema.code).toMatchFileSnapshot( + "./__snapshots__/auth-schema-sqlite-uuid.txt", + ); + }); it("should generate drizzle schema for SQLite with number id", async () => { const schema = await generateDrizzleSchema({ @@ -146,7 +273,7 @@ describe("generate drizzle schema for all databases", async () => { plugins: [twoFactor(), username()], advanced: { database: { - useNumberId: true, + generateId: "serial", }, }, user: { @@ -305,7 +432,7 @@ describe("generate drizzle schema for all databases with passkey plugin", async plugins: [passkey()], advanced: { database: { - useNumberId: true, + generateId: "serial", }, }, user: { @@ -348,7 +475,7 @@ describe("generate drizzle schema for all databases with passkey plugin", async plugins: [passkey()], advanced: { database: { - useNumberId: true, + generateId: "serial", }, }, user: { diff --git a/packages/cli/test/generate.test.ts b/packages/cli/test/generate.test.ts index 46638796c2..3420d5518f 100644 --- a/packages/cli/test/generate.test.ts +++ b/packages/cli/test/generate.test.ts @@ -52,7 +52,7 @@ describe("generate", async () => { plugins: [twoFactor(), username()], advanced: { database: { - useNumberId: true, + generateId: "serial", }, }, }, @@ -62,6 +62,35 @@ describe("generate", async () => { ); }); + it("should generate prisma schema with uuid id", async () => { + const schema = await generatePrismaSchema({ + file: "test.prisma", + adapter: prismaAdapter( + {}, + { + provider: "postgresql", + }, + )({} as BetterAuthOptions), + options: { + database: prismaAdapter( + {}, + { + provider: "postgresql", + }, + ), + plugins: [twoFactor(), username()], + advanced: { + database: { + generateId: "uuid", + }, + }, + }, + }); + expect(schema.code).toMatchFileSnapshot( + "./__snapshots__/schema-uuid.prisma", + ); + }); + it("should generate prisma schema for mongodb", async () => { const schema = await generatePrismaSchema({ file: "test.prisma", @@ -204,7 +233,7 @@ describe("generate", async () => { plugins: [twoFactor(), username()], advanced: { database: { - useNumberId: true, + generateId: "serial", }, }, user: { diff --git a/packages/core/src/db/adapter/index.ts b/packages/core/src/db/adapter/index.ts index 96c2685c0d..40ec9f91ee 100644 --- a/packages/core/src/db/adapter/index.ts +++ b/packages/core/src/db/adapter/index.ts @@ -82,6 +82,12 @@ export interface DBAdapterFactoryConfig< * @default true */ supportsNumericIds?: boolean | undefined; + /** + * If the database supports natively generating UUIDs, set this to `true`. + * + * @default false + */ + supportsUUIDs?: boolean | undefined; /** * If the database doesn't support JSON columns, set this to `false`. * @@ -246,7 +252,7 @@ export interface DBAdapterFactoryConfig< * * * Notes: - * - If the user enabled `useNumberId`, then this option will be ignored. Unless this adapter config has `supportsNumericIds` set to `false`. + * - If the user enabled `useNumberId` or `generateId` set to `serial`, then this option will be ignored. Unless this adapter config has `supportsNumericIds` set to `false`. * - If `generateId` is `false` in the user's Better-Auth config, then this option will be ignored. * - If `generateId` is a function, then it will override this option. * diff --git a/packages/telemetry/src/detectors/detect-auth-config.ts b/packages/telemetry/src/detectors/detect-auth-config.ts index 59ac484c0e..857db37496 100644 --- a/packages/telemetry/src/detectors/detect-auth-config.ts +++ b/packages/telemetry/src/detectors/detect-auth-config.ts @@ -117,7 +117,9 @@ export function getTelemetryAuthConfig( options.advanced?.crossSubDomainCookies?.additionalCookies, }, database: { - useNumberId: !!options.advanced?.database?.useNumberId, + useNumberId: + !!options.advanced?.database?.useNumberId || + options.advanced?.database?.generateId === "serial", generateId: options.advanced?.database?.generateId, defaultFindManyLimit: options.advanced?.database?.defaultFindManyLimit, },