diff --git a/.changeset/migration-reference-identity.md b/.changeset/migration-reference-identity.md new file mode 100644 index 0000000000..fc7c099363 --- /dev/null +++ b/.changeset/migration-reference-identity.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +Preserve the referenced model and field keys when generating Kysely migrations, so custom table names that match another model key produce the intended foreign keys. diff --git a/packages/better-auth/src/db/get-migration.test.ts b/packages/better-auth/src/db/get-migration.test.ts index efcc7c176a..fada96a99f 100644 --- a/packages/better-auth/src/db/get-migration.test.ts +++ b/packages/better-auth/src/db/get-migration.test.ts @@ -6,6 +6,7 @@ import { PostgresAdapter, PostgresQueryCompiler } from "kysely"; import { describe, expect, it } from "vitest"; import { organization } from "../plugins/organization"; import { getMigrations, UnsafeMigrationError } from "./get-migration"; +import { getSchema } from "./get-schema"; // A 1.6-shape team/teamMember schema: the 1.7 `memberCount` and `membershipKey` // columns are missing, so getMigrations must ADD them to a populated table. @@ -963,3 +964,161 @@ describe("get-migration: configured PostgreSQL schema", () => { expect(toBeCreated.map(({ table }) => table)).toContain("user"); }); }); + +/** + * @see https://github.com/better-auth/better-auth/issues/8111 + */ +describe("get-migration: foreign key model identity", () => { + it("references the user table when its name matches another model key", async () => { + const database = new DatabaseSync(":memory:"); + const config: BetterAuthOptions = { + database, + user: { modelName: "account" }, + account: { modelName: "identity" }, + }; + + const { runMigrations } = await getMigrations(config); + await runMigrations(); + + const sessionForeignKeys = database + .prepare('PRAGMA foreign_key_list("session")') + .all(); + const identityForeignKeys = database + .prepare('PRAGMA foreign_key_list("identity")') + .all(); + expect(sessionForeignKeys).toContainEqual( + expect.objectContaining({ table: "account", from: "userId", to: "id" }), + ); + expect(identityForeignKeys).toContainEqual( + expect.objectContaining({ table: "account", from: "userId", to: "id" }), + ); + }); + + it("references the user table when adding a column", async () => { + const database = new DatabaseSync(":memory:"); + const config: BetterAuthOptions = { + database, + user: { modelName: "account" }, + account: { modelName: "identity" }, + }; + await (await getMigrations(config)).runMigrations(); + + const { compileMigrations } = await getMigrations({ + ...config, + account: { + modelName: "identity", + additionalFields: { + inviterId: { + type: "string", + required: false, + references: { model: "user", field: "id" }, + }, + }, + }, + }); + + expect(await compileMigrations()).toContain( + 'alter table "identity" add column "inviterId" text references "account" ("id")', + ); + }); + + it("retains the reference key after reading the public schema", async () => { + const config: BetterAuthOptions = { + database: new DatabaseSync(":memory:"), + user: { + modelName: "account", + fields: { email: "email_address" }, + }, + account: { modelName: "identity" }, + plugins: [ + { + id: "audit", + schema: { + audit: { + fields: { + userEmail: { + type: "string", + references: { model: "user", field: "email" }, + }, + }, + }, + }, + }, + ], + }; + + expect(getSchema(config).audit?.fields.userEmail?.references).toMatchObject( + { + model: "account", + field: "email", + }, + ); + const { compileMigrations } = await getMigrations(config); + + expect(await compileMigrations()).toContain( + '"userEmail" text not null references "account" ("email_address")', + ); + }); + + it("keeps the referenced key when two models share a table", async () => { + const config: BetterAuthOptions = { + database: new DatabaseSync(":memory:"), + plugins: [ + { + id: "shared-table", + schema: { + first: { + modelName: "shared", + fields: { name: { type: "string" } }, + }, + second: { + modelName: "shared", + fields: { + code: { type: "string", fieldName: "shared_code" }, + }, + }, + audit: { + fields: { + sharedCode: { + type: "string", + references: { model: "second", field: "code" }, + }, + }, + }, + }, + }, + ], + }; + + const { compileMigrations } = await getMigrations(config); + expect(await compileMigrations()).toContain( + '"sharedCode" text not null references "shared" ("shared_code")', + ); + }); +}); + +it("preserves external table references in generated migrations", async () => { + const config: BetterAuthOptions = { + database: new DatabaseSync(":memory:"), + plugins: [ + { + id: "audit", + schema: { + audit: { + fields: { + externalUserId: { + type: "string", + references: { model: "external_user", field: "id" }, + }, + }, + }, + }, + }, + ], + }; + + const { compileMigrations } = await getMigrations(config); + expect(await compileMigrations()).toContain( + '"externalUserId" text not null references "external_user" ("id")', + ); +}); diff --git a/packages/better-auth/src/db/get-migration.ts b/packages/better-auth/src/db/get-migration.ts index 5a7ec22adc..028c961ad2 100644 --- a/packages/better-auth/src/db/get-migration.ts +++ b/packages/better-auth/src/db/get-migration.ts @@ -1,6 +1,5 @@ import type { BetterAuthOptions } from "@better-auth/core"; import type { DBFieldAttribute, DBFieldType } from "@better-auth/core/db"; -import { getAuthTables } from "@better-auth/core/db"; import { initGetFieldName, initGetModelName, @@ -39,7 +38,7 @@ import type { RawBuilder, } from "kysely"; import { sql } from "kysely"; -import { getSchema } from "./get-schema"; +import { buildSchema } from "./get-schema"; const postgresMap = { string: ["character varying", "varchar", "text", "uuid"], @@ -576,8 +575,11 @@ export async function getMigrations( config: BetterAuthOptions, { throwOnUnsafe = true }: { throwOnUnsafe?: boolean } = {}, ) { - const betterAuthSchema = getSchema(config); - const authTables = getAuthTables(config); + const { + schema: betterAuthSchema, + tables: authTables, + referenceKeys, + } = buildSchema(config); const logger = createLogger(config.logger); const unsafeChanges: string[] = []; const reportUnsafeChange = (message: string) => { @@ -968,14 +970,27 @@ export async function getMigrations( // Helper function to safely resolve model and field names, falling back to // user-supplied strings for external tables not in the BetterAuth schema - function getReferencePath(model: string, field: string): string { + function getReferencePath(field: DBFieldAttribute): string { + const reference = field.references; + if (!reference) { + throw new BetterAuthError("Cannot resolve a field without references."); + } + const keys = referenceKeys.get(field); + if (!keys) { + throw new BetterAuthError( + `Missing migration reference metadata for "${reference.model}.${reference.field}".`, + ); + } try { - const modelName = getModelName(model); - const fieldName = getFieldName({ model, field }); + const modelName = getModelName(keys.modelKey); + const fieldName = getFieldName({ + model: keys.modelKey, + field: keys.fieldKey, + }); return `${modelName}.${fieldName}`; } catch { // If resolution fails (external table), fall back to user-supplied references - return `${model}.${field}`; + return `${reference.model}.${reference.field}`; } } @@ -1061,12 +1076,7 @@ export async function getMigrations( col = field.required !== false ? col.notNull() : col; if (field.references) { col = col - .references( - getReferencePath( - field.references.model, - field.references.field, - ), - ) + .references(getReferencePath(field)) .onDelete(field.references.onDelete || "cascade"); } if (timestampDefault) { @@ -1132,12 +1142,7 @@ export async function getMigrations( col = field.required !== false ? col.notNull() : col; if (field.references) { col = col - .references( - getReferencePath( - field.references.model, - field.references.field, - ), - ) + .references(getReferencePath(field)) .onDelete(field.references.onDelete || "cascade"); } diff --git a/packages/better-auth/src/db/get-schema.ts b/packages/better-auth/src/db/get-schema.ts index c05aef44ab..afbba6c14c 100644 --- a/packages/better-auth/src/db/get-schema.ts +++ b/packages/better-auth/src/db/get-schema.ts @@ -4,7 +4,21 @@ import type { ResolvedDBTableIndex } from "@better-auth/core/db/internal"; import { getAuthTablesWithResolvedIndexes } from "@better-auth/core/db/internal"; export function getSchema(config: BetterAuthOptions) { + return buildSchema(config).schema; +} + +/** + * @internal + */ +export function buildSchema(config: BetterAuthOptions) { const { indexesByTable, tables } = getAuthTablesWithResolvedIndexes(config); + const referenceKeys = new Map< + DBFieldAttribute, + { + modelKey: string; + fieldKey: string; + } + >(); const schema: Record< string, { @@ -19,16 +33,24 @@ export function getSchema(config: BetterAuthOptions) { const fields = table.fields; const actualFields: Record = {}; Object.entries(fields).forEach(([key, field]) => { - actualFields[field.fieldName || key] = field; - if (field.references) { - const refTable = tables[field.references.model]; - if (refTable) { - actualFields[field.fieldName || key]!.references = { - ...field.references, - model: refTable.modelName, - field: field.references.field, - }; - } + const reference = field.references; + const refTable = reference ? tables[reference.model] : undefined; + const actualField: DBFieldAttribute = + reference && refTable + ? { + ...field, + references: { + ...reference, + model: refTable.modelName, + }, + } + : field; + actualFields[field.fieldName || key] = actualField; + if (reference) { + referenceKeys.set(actualField, { + modelKey: reference.model, + fieldKey: reference.field, + }); } }); if (schema[table.modelName]) { @@ -52,5 +74,10 @@ export function getSchema(config: BetterAuthOptions) { schema[tableName].indexes = indexes; } } - return schema; + + return { + schema, + tables, + referenceKeys, + }; } diff --git a/packages/better-auth/src/db/index.ts b/packages/better-auth/src/db/index.ts index b44bc6f69e..3856ba4bc4 100644 --- a/packages/better-auth/src/db/index.ts +++ b/packages/better-auth/src/db/index.ts @@ -1,7 +1,7 @@ export * from "@better-auth/core/db"; export * from "./field"; export * from "./field-converter"; -export * from "./get-schema"; +export { getSchema } from "./get-schema"; export * from "./internal-adapter"; export * from "./revoke-unproven-account-access"; export * from "./schema";