fix(db): name the custom widget secret foreign key within mysql limits

drizzle derives `custom_widget_secret_definition_id_custom_widget_definition_id_fk`,
which is 65 characters. MySQL caps identifiers at 64 and rejects it with
error 1059, so migration 0040 created the key under a shortened name. The
schema never declared that name, so every `migration:mysql:generate` run
re-proposed dropping and recreating the key under the long one, producing a
migration that cannot be applied.

Declaring the shortened name explicitly makes the schema match what is
actually in the database. Only the mysql schema changes; sqlite and
postgresql keep their derived names and need no migration.

The parity test matched keys across dialects by generated name, which no
longer holds, so it now matches on what a key links - its columns, target
table and target columns - and compares the referential actions. That also
means it reads keys through getTableConfig and so covers table-level
declarations, not just inline .references() ones.

Adds a guard test asserting no mysql table, foreign key or index name
exceeds 64 characters, so the next long pairing fails in CI instead of
producing an unappliable migration.
This commit is contained in:
Thomas Camlong committed 2026-08-07 18:14:23 +02:00
1 parent 7276bdad21
commit 09c56002d6
2 files changed
+68 -35

No files matched your search

+10 -3
View File
@@ -6,6 +6,7 @@ import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
import {
boolean,
customType,
foreignKey,
index,
int,
mediumtext,
@@ -597,14 +598,20 @@ export const customWidgetSecrets = mysqlTable(
updatedAt: timestamp()
.$onUpdateFn(() => new Date())
.notNull(),
definitionId: varchar({ length: 64 })
.notNull()
.references(() => customWidgetDefinitions.id, { onDelete: "cascade" }),
definitionId: varchar({ length: 64 }).notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.definitionId, table.kind],
}),
// The name drizzle derives here is 65 characters, one over MySQL's 64-character identifier
// limit, so migration 0040 created the key under this shortened name. Declaring it explicitly
// keeps `drizzle-kit generate` from re-proposing an ALTER that MySQL rejects with error 1059.
definitionReference: foreignKey({
name: "cw_secret_definition_id_cw_definition_id_fk",
columns: [table.definitionId],
foreignColumns: [customWidgetDefinitions.id],
}).onDelete("cascade"),
}),
);
+58 -32
View File
@@ -1,7 +1,10 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { is } from "drizzle-orm";
import type { Column, InferSelectModel } from "drizzle-orm";
import { MySqlTable, getTableConfig as getMysqlTableConfig } from "drizzle-orm/mysql-core";
import type { ForeignKey as MysqlForeignKey, MySqlTableWithColumns } from "drizzle-orm/mysql-core";
import type { PgTableWithColumns, ForeignKey as PostgresqlForeignKey } from "drizzle-orm/pg-core";
import { SQLiteTable, getTableConfig as getSqliteTableConfig } from "drizzle-orm/sqlite-core";
import type { ForeignKey as SqliteForeignKey, SQLiteTableWithColumns } from "drizzle-orm/sqlite-core";
import { expect, expectTypeOf, test } from "vitest";
@@ -54,6 +57,21 @@ type FixedSqliteConfig = {
};
};
/** Identifies a foreign key by what it links rather than by its generated name. */
const referenceSignature = (foreignKey: {
reference: () => { columns: { name: string }[]; foreignTable: object; foreignColumns: { name: string }[] };
}) => {
const reference = foreignKey.reference();
const target = Object.keys(reference.foreignTable).sort().join("+");
return `${reference.columns
.map((column) => column.name)
.sort()
.join(",")}->${target}(${reference.foreignColumns
.map((column) => column.name)
.sort()
.join(",")})`;
};
test("schemas should match", () => {
expectTypeOf<SqliteTables>().toEqualTypeOf<MysqlTables>();
expectTypeOf<MysqlTables>().toEqualTypeOf<SqliteTables>();
@@ -88,56 +106,64 @@ test("schemas should match", () => {
});
const mysqlTable = mysqlTables[tableName];
const sqliteForeignKeys = sqliteTable[Symbol.for("drizzle:SQLiteInlineForeignKeys") as keyof typeof sqliteTable] as
| SqliteForeignKey[]
| undefined;
const mysqlForeignKeys = mysqlTable[Symbol.for("drizzle:MySqlInlineForeignKeys") as keyof typeof mysqlTable] as
| MysqlForeignKey[]
| undefined;
// Read through getTableConfig so keys declared at table level count too, not just inline
// `.references()` ones. MySQL needs a table-level declaration wherever drizzle's derived name
// would exceed its 64-character identifier limit.
if (!is(sqliteTable, SQLiteTable) || !is(mysqlTable, MySqlTable)) return;
if (!sqliteForeignKeys && !mysqlForeignKeys) return;
const sqliteForeignKeys = getSqliteTableConfig(sqliteTable).foreignKeys as SqliteForeignKey[];
const mysqlForeignKeys = getMysqlTableConfig(mysqlTable).foreignKeys as MysqlForeignKey[];
expect(mysqlForeignKeys, `mysql foreign key for ${tableName} to be defined`).toBeDefined();
expect(sqliteForeignKeys, `sqlite foreign key for ${tableName} to be defined`).toBeDefined();
if (sqliteForeignKeys.length === 0 && mysqlForeignKeys.length === 0) return;
expect(
sqliteForeignKeys!.length,
sqliteForeignKeys.length,
`expect number of foreign keys in table ${tableName} to be the same for both schemas`,
).toEqual(mysqlForeignKeys!.length);
).toEqual(mysqlForeignKeys.length);
sqliteForeignKeys?.forEach((sqliteForeignKey) => {
sqliteForeignKey.getName();
const mysqlForeignKey = mysqlForeignKeys!.find((key) => key.getName() === sqliteForeignKey.getName());
expect(
mysqlForeignKey,
`expect foreign key ${sqliteForeignKey.getName()} to be defined in mysql schema`,
).toBeDefined();
sqliteForeignKeys.forEach((sqliteForeignKey) => {
const describeKey = `${tableName}.${referenceSignature(sqliteForeignKey)}`;
// Matched on what the key actually does rather than on its generated name, because the name
// is allowed to differ per dialect.
const mysqlForeignKey = mysqlForeignKeys.find(
(key) => referenceSignature(key) === referenceSignature(sqliteForeignKey),
);
expect(mysqlForeignKey, `expect foreign key ${describeKey} to be defined in mysql schema`).toBeDefined();
expect(
sqliteForeignKey.onDelete,
`expect foreign key (${sqliteForeignKey.getName()}) onDelete to be the same for both schemas`,
`expect foreign key (${describeKey}) onDelete to be the same for both schemas`,
).toEqual(mysqlForeignKey!.onDelete);
expect(
sqliteForeignKey.onUpdate,
`expect foreign key (${sqliteForeignKey.getName()}) onUpdate to be the same for both schemas`,
`expect foreign key (${describeKey}) onUpdate to be the same for both schemas`,
).toEqual(mysqlForeignKey!.onUpdate);
sqliteForeignKey.reference().foreignColumns.forEach((column) => {
expect(
mysqlForeignKey!.reference().foreignColumns.map((column) => column.name),
`expect foreign key (${sqliteForeignKey.getName()}) columns to be the same for both schemas`,
).toContainEqual(column.name);
});
expect(
Object.keys(sqliteForeignKey.reference().foreignTable),
`expect foreign key (${sqliteForeignKey.getName()}) table to be the same for both schemas`,
).toEqual(Object.keys(mysqlForeignKey!.reference().foreignTable));
});
});
});
test("mysql identifiers stay within the 64 character limit", () => {
// MySQL rejects longer identifiers with error 1059, and drizzle derives foreign key names from
// the table and column names, so a long pairing silently produces a migration that cannot apply.
const tooLong: string[] = [];
objectEntries(mysqlSchema).forEach(([, table]) => {
if (!is(table, MySqlTable)) return;
const config = getMysqlTableConfig(table);
if (config.name.length > 64) tooLong.push(config.name);
for (const foreignKey of config.foreignKeys) {
if (foreignKey.getName().length > 64) tooLong.push(foreignKey.getName());
}
for (const index of config.indexes) {
if (index.config.name.length > 64) tooLong.push(index.config.name);
}
});
expect(tooLong).toEqual([]);
});
test("schemas should match for postgresql", () => {
expectTypeOf<SqliteTables>().toEqualTypeOf<PostgresqlTables>();
expectTypeOf<PostgresqlTables>().toEqualTypeOf<SqliteTables>();