diff --git a/packages/void/src/auth/drizzle.ts b/packages/void/src/auth/drizzle.ts index 3e495fa36..57e5e2767 100644 --- a/packages/void/src/auth/drizzle.ts +++ b/packages/void/src/auth/drizzle.ts @@ -180,21 +180,27 @@ function usesUnboundedMySqlText( } /** - * The default of a required field when the database can store it as a literal. + * The default of a field when it can be written as a literal. * Function, date and JSON defaults have none. */ -function resolveFixedDefault( - field: DBFieldAttribute, -): Pick { - if (field.required === false || field.type === 'date' || isJsonField(field)) { - return {}; +function resolveLiteralDefault(field: DBFieldAttribute): string | number | boolean | undefined { + if (field.type === 'date' || isJsonField(field)) { + return undefined; } const value = field.defaultValue; return typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value)) - ? { defaultValue: value } - : {}; + ? value + : undefined; +} + +/** The default of a required field when the database can store it as a literal. */ +function resolveFixedDefault( + field: DBFieldAttribute, +): Pick { + const value = field.required === false ? undefined : resolveLiteralDefault(field); + return value === undefined ? {} : { defaultValue: value }; } function resolveReferenceFieldName( @@ -301,7 +307,23 @@ function createColumnBuilder( } } +function hasBuiltInUpdateHook(options: BetterAuthOptions, model: string, field: string): boolean { + if ( + field !== 'updatedAt' || + (model !== 'user' && model !== 'session' && model !== 'account' && model !== 'verification') + ) { + return false; + } + // Only Better Auth's built-in hooks are known to mean the current time. + // A configured field replaces that definition, including its callback. + return ( + options[model]?.additionalFields?.updatedAt === undefined && + !options.plugins?.some((plugin) => plugin.schema?.[model]?.fields.updatedAt !== undefined) + ); +} + function createColumnDefinition( + options: BetterAuthOptions, dialect: 'sqlite' | 'postgresql' | 'mysql', schema: BetterAuthDBSchema, idStrategy: AuthIdStrategy, @@ -360,6 +382,19 @@ function createColumnDefinition( if (field.unique && actualFieldName !== 'id') { parts.push('.unique()'); } + // Better Auth applies these on its own writes. Direct Drizzle writes need them on the column, + // and these runtime-only modifiers leave the DDL, and so existing migrations, unchanged. + const literalDefault = resolveLiteralDefault(field); + if (literalDefault !== undefined) { + parts.push(`.$defaultFn(() => ${JSON.stringify(literalDefault)})`); + } + if ( + field.onUpdate && + field.type === 'date' && + hasBuiltInUpdateHook(options, defaultModelName, defaultFieldName) + ) { + parts.push('.$onUpdate(() => new Date())'); + } return parts.join(''); } @@ -466,6 +501,7 @@ export function generateVoidAuthDrizzleSchema( const fieldEntries = Object.entries(table.fields); const renderedFields = [ ` "id": ${createColumnDefinition( + options, runtime.dialect, schema, idStrategy, @@ -477,6 +513,7 @@ export function generateVoidAuthDrizzleSchema( ...fieldEntries.map(([defaultFieldName, field]) => { const actualFieldName = resolveFieldName(defaultFieldName, field); return ` ${JSON.stringify(actualFieldName)}: ${createColumnDefinition( + options, runtime.dialect, schema, idStrategy, diff --git a/packages/void/test/unit/auth-drizzle.test.ts b/packages/void/test/unit/auth-drizzle.test.ts index 199b55131..863324624 100644 --- a/packages/void/test/unit/auth-drizzle.test.ts +++ b/packages/void/test/unit/auth-drizzle.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it } from 'vite-plus/test'; +import { describe, expect, it, vi } from 'vite-plus/test'; import { generateVoidAuthDatabaseModule, generateVoidAuthDrizzleSchema, + resolveVoidAuthMigrationSchema, } from '../../src/auth/drizzle.ts'; describe('Better Auth Drizzle codegen', () => { @@ -106,6 +107,159 @@ describe('Better Auth Drizzle codegen', () => { ); }); + it('carries Better Auth update hooks and literal defaults onto the Drizzle columns', () => { + const code = generateVoidAuthDrizzleSchema({ + dialect: 'sqlite', + providers: null, + userConfig: ({ defaults }) => ({ + ...defaults, + user: { + additionalFields: { + banned: { type: 'boolean', required: false, defaultValue: false }, + plan: { type: 'string', required: false, defaultValue: 'free' }, + credits: { type: 'number', required: false, defaultValue: 10 }, + trialEndsAt: { type: 'date', required: false, defaultValue: () => new Date() }, + tags: { type: 'string[]', required: false, defaultValue: '[]' }, + }, + }, + }), + }); + + expect(code).toContain( + '"emailVerified": integer("emailVerified", { mode: "boolean" }).notNull().$defaultFn(() => false)', + ); + expect(code).toContain( + '"updatedAt": integer("updatedAt", { mode: "timestamp_ms" }).notNull().$onUpdate(() => new Date())', + ); + expect(code).toContain( + '"banned": integer("banned", { mode: "boolean" }).$defaultFn(() => false)', + ); + expect(code).toContain('"plan": text("plan").$defaultFn(() => "free")'); + expect(code).toContain('"credits": integer("credits").$defaultFn(() => 10)'); + expect(code).toContain('"trialEndsAt": integer("trialEndsAt", { mode: "timestamp_ms" }),'); + expect(code).toContain('"tags": text("tags", { mode: "json" }),'); + expect(code).toContain( + '"createdAt": integer("createdAt", { mode: "timestamp_ms" }).notNull(),', + ); + }); + + it('emits update hooks in every dialect', () => { + expect(generateVoidAuthDrizzleSchema({ dialect: 'postgresql', providers: null })).toContain( + '"updatedAt": timestamp("updatedAt", { mode: "date", withTimezone: true }).notNull().$onUpdate(() => new Date())', + ); + expect(generateVoidAuthDrizzleSchema({ dialect: 'mysql', providers: null })).toContain( + '"updatedAt": timestamp("updatedAt", { mode: "date", fsp: 3 }).notNull().$onUpdate(() => new Date())', + ); + }); + + it.each(['sqlite', 'postgresql', 'mysql'] as const)( + 'leaves custom date update hooks out of the generated %s schema', + (dialect) => { + const onUpdate = vi.fn(() => new Date('2035-01-01T00:00:00.000Z')); + const code = generateVoidAuthDrizzleSchema({ + dialect, + providers: null, + userConfig: ({ defaults }) => ({ + ...defaults, + user: { + additionalFields: { + expiresAt: { type: 'date', required: false, onUpdate }, + }, + }, + plugins: [ + { + id: 'custom-dates', + schema: { + audit: { + fields: { updatedAt: { type: 'date', onUpdate } }, + }, + }, + }, + ], + }), + }); + + expect(code.split('\n').find((line) => line.includes('"expiresAt":'))).not.toContain( + '.$onUpdate', + ); + const audit = code.slice(code.indexOf('export const auditTable')); + expect(audit.split('\n').find((line) => line.includes('"updatedAt":'))).not.toContain( + '.$onUpdate', + ); + expect(code.match(/\.\$onUpdate\(/g)).toHaveLength(4); + expect(onUpdate).not.toHaveBeenCalled(); + }, + ); + + it.each(['user', 'session', 'account', 'verification'] as const)( + 'does not replace a configured %s.updatedAt hook', + (model) => { + const onUpdate = vi.fn(() => new Date('2035-01-01T00:00:00.000Z')); + for (const source of ['additionalFields', 'plugin'] as const) { + const field = { type: 'date' as const, onUpdate }; + const code = generateVoidAuthDrizzleSchema({ + dialect: 'sqlite', + providers: null, + userConfig: ({ defaults }) => ({ + ...defaults, + ...(source === 'additionalFields' + ? { [model]: { additionalFields: { updatedAt: field } } } + : { + plugins: [ + { + id: 'custom-updated-at', + schema: { [model]: { fields: { updatedAt: field } } }, + }, + ], + }), + }), + }); + + const table = code.slice(code.indexOf(`export const ${model}Table`)); + expect(table.split('\n').find((line) => line.includes('"updatedAt":'))).not.toContain( + '.$onUpdate', + ); + expect(code.match(/\.\$onUpdate\(/g)).toHaveLength(3); + } + expect(onUpdate).not.toHaveBeenCalled(); + }, + ); + + it('keeps the built-in update hook when its table and column are renamed', () => { + const code = generateVoidAuthDrizzleSchema({ + dialect: 'sqlite', + providers: null, + userConfig: ({ defaults }) => ({ + ...defaults, + user: { modelName: 'members', fields: { updatedAt: 'modifiedAt' } }, + }), + }); + + expect(code).toContain( + '"modifiedAt": integer("modifiedAt", { mode: "timestamp_ms" }).notNull().$onUpdate(() => new Date())', + ); + expect(code.match(/\.\$onUpdate\(/g)).toHaveLength(4); + }); + + it('keeps optional defaults out of the migration metadata', () => { + const schema = resolveVoidAuthMigrationSchema({ + dialect: 'sqlite', + providers: null, + userConfig: ({ defaults }) => ({ + ...defaults, + user: { + additionalFields: { + banned: { type: 'boolean', required: false, defaultValue: false }, + }, + }, + }), + }); + const user = schema.tables.find((table) => table.name === 'user'); + + expect(user?.fields.banned).not.toHaveProperty('defaultValue'); + expect(user?.fields.emailVerified).toHaveProperty('defaultValue', false); + }); + it('does not add MySQL storage metadata to PostgreSQL or SQLite schemas', () => { for (const dialect of ['postgresql', 'sqlite'] as const) { const code = generateVoidAuthDrizzleSchema({ dialect, providers: ['google'] });