import buildColumnDefinition, { fieldRequiresNotNull, } from "./build-column-definition"; import createTable from "./create-table"; import getTableColumns, {} from "./get-table-columns"; import isVectorField from "./is-vector-field"; import mapDataType from "./map-data-types"; import MariaDBQuoteGen from "./mariadb-quote-gen"; import runSchemaQuery, { querySchemaRows } from "./run-schema-query"; import schemaCondition from "./schema-condition"; import { dropForeignKeysOnColumns } from "./sync-foreign-keys"; /** * Compare live COLUMN_TYPE with schema-mapped type. * Live types often include display widths (e.g. bigint(20) vs BIGINT). */ function columnTypesMatch(liveType, expectedType) { const live = liveType.toLowerCase().replace(/\s+/g, ""); const expected = expectedType.toLowerCase().replace(/\s+/g, ""); if (live === expected) return true; // live may include display width: bigint(20) vs bigint if (live.startsWith(`${expected}(`)) return true; // expected may include length live omits in some versions if (expected.startsWith(`${live}(`)) return true; return false; } function vectorTypeDiverged(liveType, liveComment, field) { const dimensions = field.vectorSize || 1536; const expectedNative = `vector(${dimensions})`; const live = liveType.toLowerCase().replace(/\s+/g, ""); if (live === expectedNative) return false; // Legacy LONGTEXT storage with vector_size comment if (live.startsWith("longtext") || live.startsWith("text")) { const match = liveComment.match(/vector_size\s*=\s*(\d+)/i); if (match && Number(match[1]) === dimensions) { // Still legacy storage — treat as diverged so we can migrate to VECTOR return true; } return true; } return true; } function normalizeDefault(value) { if (value == null) return ""; let v = String(value).trim(); // Strip surrounding quotes MariaDB may include if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) { v = v.slice(1, -1); } return v.toLowerCase().replace(/\s+/g, " "); } function expectedDefault(field) { if (field.defaultValue !== undefined) { return String(field.defaultValue); } if (field.defaultValueLiteral) { return field.defaultValueLiteral; } return null; } function expectedOnUpdate(field) { if (field.onUpdate) return field.onUpdate; if (field.onUpdateLiteral) return field.onUpdateLiteral; return null; } function defaultsMatch(liveDefault, expected) { if (liveDefault === expected) return true; if (expected.includes("current_timestamp") && liveDefault.includes("current_timestamp")) { return true; } return false; } function columnAttributesDiverged(live, field) { const wantsNotNull = fieldRequiresNotNull(field); if (wantsNotNull && live.isNullable) { return true; } if (!wantsNotNull && !live.isNullable && !field.primaryKey) { return true; } const liveExtra = (live.extra || "").toLowerCase(); const wantsAi = Boolean(field.autoIncrement); const hasAi = liveExtra.includes("auto_increment"); if (wantsAi !== hasAi) { return true; } const wantsDefault = expectedDefault(field); if (wantsDefault != null) { const liveDefault = normalizeDefault(live.columnDefault); const expected = normalizeDefault(wantsDefault); if (!defaultsMatch(liveDefault, expected)) { return true; } } const wantsOnUpdate = expectedOnUpdate(field); const hasOnUpdate = /on update/i.test(live.extra || ""); if (wantsOnUpdate && !hasOnUpdate) { return true; } if (!wantsOnUpdate && hasOnUpdate) { return true; } if (wantsOnUpdate && hasOnUpdate) { const liveOnUpdate = normalizeDefault((live.extra.match(/on update\s+(.+)/i) || [])[1] || ""); const expectedOu = normalizeDefault(wantsOnUpdate); if (liveOnUpdate && expectedOu && !defaultsMatch(liveOnUpdate, expectedOu)) { return true; } } return false; } async function addColumn({ tableName, field, config, }) { console.log(`Adding column: ${tableName}.${field.fieldName}`); // omitUnique — unique constraints are applied by syncUniqueConstraints const columnDef = buildColumnDefinition(field, { omitUnique: true }).trim(); await runSchemaQuery({ query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} ADD COLUMN IF NOT EXISTS ${columnDef}`, config, }); } async function modifyColumn({ tableName, field, config, }) { console.log(`Modifying column: ${tableName}.${field.fieldName}`); const columnDef = buildColumnDefinition(field, { omitUnique: true }).trim(); await runSchemaQuery({ query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} MODIFY COLUMN ${columnDef}`, config, }); } async function dropColumn({ tableName, fieldName, config, }) { console.log(`Dropping column: ${tableName}.${fieldName}`); await runSchemaQuery({ query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} DROP COLUMN ${MariaDBQuoteGen(fieldName)}`, config, }); } /** * Drop + re-add a column (values discarded). Used when VECTOR dimensions change — * MODIFY cannot resize VECTOR, and a full table rebuild is unnecessary. */ async function recreateColumn({ tableName, field, config, }) { if (!field.fieldName) return; console.log(`Recreating column: ${tableName}.${field.fieldName} (values will be cleared)`); await dropForeignKeysOnColumns({ tableName, columns: [field.fieldName], config, }); const schemaCond = schemaCondition(config); const indexRows = await querySchemaRows({ query: `SELECT INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS WHERE ${schemaCond.where} AND TABLE_NAME = ? AND INDEX_NAME <> 'PRIMARY'`, values: [...schemaCond.values, tableName], config, }); const indexesToDrop = new Set(); for (const row of indexRows) { if (row.COLUMN_NAME === field.fieldName) { indexesToDrop.add(row.INDEX_NAME); } } for (const indexName of indexesToDrop) { console.log(`Dropping index ${indexName} because column ${field.fieldName} is being recreated`); try { await runSchemaQuery({ query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(tableName)}`, config, }); } catch (err) { if (String(err?.message || "").includes("needed in a foreign key constraint")) { console.warn(`Skipping drop of index ${indexName}: required by a foreign key constraint`); continue; } throw err; } } await dropColumn({ tableName, fieldName: field.fieldName, config }); await addColumn({ tableName, field, config }); } export default async function updateTable({ table, config, }) { const existingColumns = await getTableColumns({ tableName: table.tableName, config, }); if (existingColumns.length === 0) { await createTable({ table, config }); return; } const liveFieldsMap = new Map(existingColumns.map((col) => [col.name, col])); const codeFieldsMap = new Map((table.fields || []).map((f) => [f.fieldName, f])); const fieldsToAdd = []; const fieldsToModify = []; const fieldsToRecreate = []; const fieldsToDrop = []; for (const field of table.fields || []) { if (!field.fieldName) continue; const liveField = liveFieldsMap.get(field.fieldName); if (!liveField) { fieldsToAdd.push(field); } else { let typeDiverged = !columnTypesMatch(liveField.type, mapDataType(field)); if (isVectorField(field)) { typeDiverged = vectorTypeDiverged(liveField.type, liveField.comment || "", field); if (typeDiverged) { // VECTOR dimensions / storage cannot be MODIFYed — drop + re-add column fieldsToRecreate.push(field); continue; } } const attrsDiverged = !typeDiverged && columnAttributesDiverged(liveField, field); if (typeDiverged || attrsDiverged) { fieldsToModify.push(field); } } } for (const col of existingColumns) { if (!codeFieldsMap.has(col.name)) { fieldsToDrop.push(col.name); } } if (fieldsToAdd.length === 0 && fieldsToModify.length === 0 && fieldsToRecreate.length === 0 && fieldsToDrop.length === 0) { return; } console.log(`Surgically updating table structure from database layout: ${table.tableName}`); if (fieldsToDrop.length > 0) { // Drop FKs that reference columns being removed await dropForeignKeysOnColumns({ tableName: table.tableName, columns: fieldsToDrop, config, }); const schemaCond = schemaCondition(config); const pkRows = await querySchemaRows({ query: `SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE ${schemaCond.where} AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'`, values: [...schemaCond.values, table.tableName], config, }); const pkColumnNames = pkRows.map((r) => r.COLUMN_NAME); if (fieldsToDrop.some((f) => pkColumnNames.includes(f))) { console.log(`Dropping primary key because a PK column is being dropped`); await runSchemaQuery({ query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} DROP PRIMARY KEY`, config, }); } const indexRows = await querySchemaRows({ query: `SELECT INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS WHERE ${schemaCond.where} AND TABLE_NAME = ? AND INDEX_NAME <> 'PRIMARY'`, values: [...schemaCond.values, table.tableName], config, }); const indexesToDrop = new Set(); for (const row of indexRows) { if (fieldsToDrop.includes(row.COLUMN_NAME)) { indexesToDrop.add(row.INDEX_NAME); } } for (const indexName of indexesToDrop) { console.log(`Dropping index ${indexName} because it contains a dropped column`); try { await runSchemaQuery({ query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`, config, }); } catch (err) { if (String(err?.message || "").includes("needed in a foreign key constraint")) { console.warn(`Skipping drop of index ${indexName}: required by a foreign key constraint`); continue; } throw err; } } } // Drop FKs on columns being modified (MODIFY can fail with FK present) if (fieldsToModify.length > 0) { await dropForeignKeysOnColumns({ tableName: table.tableName, columns: fieldsToModify .map((f) => f.fieldName) .filter((n) => Boolean(n)), config, }); } for (const field of fieldsToAdd) { await addColumn({ tableName: table.tableName, field, config }); } for (const field of fieldsToModify) { await modifyColumn({ tableName: table.tableName, field, config }); } for (const field of fieldsToRecreate) { await recreateColumn({ tableName: table.tableName, field, config, }); } for (const fieldName of fieldsToDrop) { await dropColumn({ tableName: table.tableName, fieldName, config }); } }