mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-18 11:32:15 -05:00
fix(db): preserve foreign key identity in migrations (#11505)
This commit is contained in:
@@ -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")',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
Reference in New Issue
Block a user