Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 45 additions & 8 deletions packages/void/src/auth/drizzle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<VoidAuthMigrationField, 'defaultValue'> {
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<VoidAuthMigrationField, 'defaultValue'> {
const value = field.required === false ? undefined : resolveLiteralDefault(field);
return value === undefined ? {} : { defaultValue: value };
}

function resolveReferenceFieldName(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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('');
}
Expand Down Expand Up @@ -466,6 +501,7 @@ export function generateVoidAuthDrizzleSchema(
const fieldEntries = Object.entries(table.fields);
const renderedFields = [
` "id": ${createColumnDefinition(
options,
runtime.dialect,
schema,
idStrategy,
Expand All @@ -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,
Expand Down
156 changes: 155 additions & 1 deletion packages/void/test/unit/auth-drizzle.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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'] });
Expand Down