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 = normalizeSqlOutsideQuotes(liveType, ""); const expected = normalizeSqlOutsideQuotes(expectedType, ""); 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; } /** * Lowercase SQL and collapse whitespace to `space`, leaving the contents of * single-quoted literals untouched (ENUM options, string defaults). */ function normalizeSqlOutsideQuotes(sql, space) { return sql .trim() .split(/('(?:[^'\\]|''|\\.)*')/) .map((part, i) => i % 2 === 1 ? part : part.toLowerCase().replace(/\s+/g, space)) .join(""); } /** * Parse a default as MariaDB reports it in COLUMN_DEFAULT (or as written in * `defaultValueLiteral`): unquoted NULL, an escaped quoted string literal, * or an expression / number. */ function parseDefault(value) { if (value == null) return { kind: "none" }; const v = String(value).trim(); if (/^null$/i.test(v)) return { kind: "none" }; if (/^'(?:[^'\\]|''|\\.)*'$/s.test(v)) { const escapes = { n: "\n", t: "\t", r: "\r", "0": "\0", }; return { kind: "string", value: v .slice(1, -1) .replace(/''|\\(.)/gs, (m, ch) => m === "''" ? "'" : (escapes[ch] ?? ch)), }; } let expr = normalizeSqlOutsideQuotes(v, ""); // MariaDB may wrap expressions in parentheses while (/^\(.*\)$/.test(expr) && balancedParens(expr.slice(1, -1))) { expr = expr.slice(1, -1); } return { kind: "expr", value: expr }; } function balancedParens(s) { let depth = 0; for (const ch of s) { if (ch === "(") depth++; if (ch === ")" && --depth < 0) return false; } return depth === 0; } function expectedDefault(field) { if (field.defaultValue !== undefined) { // Strings are emitted as quoted literals; numbers verbatim if (typeof field.defaultValue === "string") { return { kind: "string", value: field.defaultValue }; } return parseDefault(String(field.defaultValue)); } if (field.defaultValueLiteral) { return parseDefault(field.defaultValueLiteral); } return { kind: "none" }; } function expectedOnUpdate(field) { if (field.onUpdate) return field.onUpdate; if (field.onUpdateLiteral) return field.onUpdateLiteral; return null; } function defaultsMatch(live, expected) { if (live.kind === "none" || expected.kind === "none") { return live.kind === expected.kind; } if (live.value === expected.value) { // A string never equals an expression of the same text, except for // numbers, which MariaDB reports unquoted on numeric columns return live.kind === expected.kind || isNumeric(live.value); } // DECIMAL defaults are reported padded: 1.5 → 1.50 if (isNumeric(live.value) && isNumeric(expected.value)) { return Number(live.value) === Number(expected.value); } if (live.kind === "expr" && expected.kind === "expr") { // now() / current_timestamp / current_timestamp(N) are aliases const ts = /^(current_timestamp|now|localtimestamp|localtime)(\(\d*\))?$/; return ts.test(live.value) && ts.test(expected.value); } return false; } function isNumeric(value) { return value.trim() !== "" && Number.isFinite(Number(value)); } 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); const liveDefault = parseDefault(live.columnDefault); // Older servers give TIMESTAMP columns an implicit default const implicitDefault = wantsDefault.kind === "none" && field.dataType?.toUpperCase() === "TIMESTAMP"; if (!implicitDefault && !defaultsMatch(liveDefault, wantsDefault)) { 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 = parseDefault((live.extra.match(/on update\s+(.+)/i) || [])[1] || ""); const expectedOu = parseDefault(wantsOnUpdate); if (!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 mapped_data_type = mapDataType(field); let typeDiverged = !columnTypesMatch(liveField.type, mapped_data_type); 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 }); } }