diff --git a/db/knex_migrations/2025-09-02-0000-add-domain-expiry.js b/db/knex_migrations/2025-09-02-0000-add-domain-expiry.js index ede2e1889..b204a44d3 100644 --- a/db/knex_migrations/2025-09-02-0000-add-domain-expiry.js +++ b/db/knex_migrations/2025-09-02-0000-add-domain-expiry.js @@ -6,7 +6,9 @@ exports.up = function (knex) { .createTable("domain_expiry", (table) => { table.increments("id"); table.datetime("last_check"); - table.text("domain").unique().notNullable(); + // Use VARCHAR(255) for MySQL/MariaDB compatibility with unique constraint + // Maximum domain name length is 253 characters (255 octets on the wire) + table.string("domain", 255).unique().notNullable(); table.datetime("expiry"); table.integer("last_expiry_notification_sent").defaultTo(null); }); diff --git a/db/knex_migrations/2026-01-06-0000-fix-domain-expiry-column-type.js b/db/knex_migrations/2026-01-06-0000-fix-domain-expiry-column-type.js new file mode 100644 index 000000000..1dc936595 --- /dev/null +++ b/db/knex_migrations/2026-01-06-0000-fix-domain-expiry-column-type.js @@ -0,0 +1,69 @@ +// Fix domain column type from TEXT to VARCHAR(255) for MySQL/MariaDB compatibility +// TEXT columns cannot have UNIQUE constraints in MySQL 8.0 and older MariaDB versions +// Maximum domain name length is 253 characters (255 octets on the wire) +exports.up = async function (knex) { + const isSQLite = knex.client.dialect === "sqlite3"; + + if (isSQLite) { + // For SQLite, we need to recreate the table since ALTER COLUMN is limited + // Check if the column type needs to be changed by checking if it's currently TEXT + const tableInfo = await knex.raw("PRAGMA table_info('domain_expiry')"); + const domainColumn = tableInfo.find(col => col.name === "domain"); + + if (domainColumn && domainColumn.type.toUpperCase() === "TEXT") { + // Create new table with correct column type + await knex.schema.createTable("domain_expiry_new", (table) => { + table.increments("id"); + table.datetime("last_check"); + table.string("domain", 255).unique().notNullable(); + table.datetime("expiry"); + table.integer("last_expiry_notification_sent").defaultTo(null); + }); + + // Copy data from old table to new table + await knex.raw(` + INSERT INTO domain_expiry_new (id, last_check, domain, expiry, last_expiry_notification_sent) + SELECT id, last_check, domain, expiry, last_expiry_notification_sent + FROM domain_expiry + `); + + // Drop old table and rename new table + await knex.schema.dropTable("domain_expiry"); + await knex.schema.renameTable("domain_expiry_new", "domain_expiry"); + } + } else { + // For MySQL/MariaDB + // Check if column is TEXT type and alter to VARCHAR(255) if needed + const dbName = knex.client.database(); + const columnInfo = await knex.raw(` + SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'domain_expiry' AND COLUMN_NAME = 'domain' + `, [ dbName ]); + + const dataType = columnInfo[0]?.[0]?.DATA_TYPE?.toUpperCase(); + + if (dataType === "TEXT") { + // Drop the unique constraint first (if it exists) + try { + await knex.raw("ALTER TABLE domain_expiry DROP INDEX domain_expiry_domain_unique"); + } catch (e) { + // Index might not exist, ignore error + } + + // Alter column type to VARCHAR(255) + await knex.schema.alterTable("domain_expiry", function (table) { + table.string("domain", 255).notNullable().alter(); + }); + + // Re-add unique constraint + await knex.schema.alterTable("domain_expiry", function (table) { + table.unique("domain"); + }); + } + } +}; + +exports.down = async function (knex) { + // No rollback needed - keeping VARCHAR(255) is the correct state + // Rolling back to TEXT would cause issues with unique constraints +}; diff --git a/test/backend-test/test-migration.js b/test/backend-test/test-migration.js index 0ed494fae..15ca9b66b 100644 --- a/test/backend-test/test-migration.js +++ b/test/backend-test/test-migration.js @@ -130,4 +130,79 @@ describe("Database Migration", () => { } } ); + + test( + "MySQL migrations run successfully from fresh database", + { + skip: + !!process.env.CI && + (process.platform !== "linux" || process.arch !== "x64"), + }, + async () => { + // Start MySQL 8.0 container (the version mentioned in the issue) + const mysqlContainer = await new GenericContainer("mysql:8.0") + .withEnvironment({ + "MYSQL_ROOT_PASSWORD": "root", + "MYSQL_DATABASE": "kuma_test", + "MYSQL_USER": "kuma", + "MYSQL_PASSWORD": "kuma" + }) + .withExposedPorts(3306) + .withWaitStrategy(Wait.forLogMessage("/usr/sbin/mysqld: ready for connections", 2)) + .withStartupTimeout(120000) + .start(); + + // Wait a bit more to ensure MySQL is fully ready + await new Promise(resolve => setTimeout(resolve, 5000)); + + const knex = require("knex"); + const knexInstance = knex({ + client: "mysql2", + connection: { + host: mysqlContainer.getHost(), + port: mysqlContainer.getMappedPort(3306), + user: "kuma", + password: "kuma", + database: "kuma_test", + connectTimeout: 60000, + }, + pool: { + min: 0, + max: 10, + acquireTimeoutMillis: 60000, + idleTimeoutMillis: 60000, + }, + }); + + // Setup R (redbean) with knex instance like production code does + const { R } = require("redbean-node"); + R.setup(knexInstance); + + try { + // Use production code to initialize MySQL tables + const { createTables } = require("../../db/knex_init_db.js"); + await createTables(); + + // Run all migrations like production code does + await R.knex.migrate.latest({ + directory: path.join(__dirname, "../../db/knex_migrations") + }); + + // Test passes if migrations complete successfully without errors + + } finally { + // Clean up + try { + await R.knex.destroy(); + } catch (e) { + // Ignore cleanup errors + } + try { + await mysqlContainer.stop(); + } catch (e) { + // Ignore cleanup errors + } + } + } + ); });