fix(db): preserve foreign key identity in migrations (#11505)

This commit is contained in:
Taesu
2026-10-01 15:07:41 +00:00
committed by GitHub
parent 9b104a22e7
commit c46992f6ce
5 changed files with 228 additions and 32 deletions
@@ -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.
@@ -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")',
);
});
+25 -20
View File
@@ -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");
}
+38 -11
View File
@@ -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<string, DBFieldAttribute> = {};
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,
};
}
+1 -1
View File
@@ -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";