UNPKG

better-auth

Version:

The most comprehensive authentication framework for TypeScript.

687 lines (686 loc) • 29.9 kB
import { getSchema } from "./get-schema.mjs"; import { getAuthTables } from "@better-auth/core/db"; import { createLogger } from "@better-auth/core/env"; import { BetterAuthError } from "@better-auth/core/error"; import { createKyselyAdapter } from "@better-auth/kysely-adapter"; import { initGetFieldName, initGetModelName } from "@better-auth/core/db/adapter"; import { getDatabaseFieldIndexName, getDatabaseIndexStringLength, getPortableDatabaseIdentifierKey } from "@better-auth/core/db/internal"; import { sql } from "kysely"; //#region src/db/get-migration.ts const map = { postgres: { string: [ "character varying", "varchar", "text", "uuid" ], number: [ "int4", "integer", "bigint", "smallint", "numeric", "real", "double precision" ], boolean: ["bool", "boolean"], date: [ "timestamptz", "timestamp", "date" ], json: ["json", "jsonb"] }, mysql: { string: [ "varchar", "text", "uuid" ], number: [ "integer", "int", "bigint", "smallint", "decimal", "float", "double" ], boolean: ["boolean", "tinyint"], date: [ "timestamp", "datetime", "date" ], json: ["json"] }, sqlite: { string: ["TEXT"], number: [ "INTEGER", "REAL", "BIGINT" ], boolean: ["INTEGER", "BOOLEAN"], date: ["DATE", "INTEGER"], json: ["TEXT"] }, mssql: { string: [ "varchar", "nvarchar", "uniqueidentifier" ], number: [ "int", "bigint", "smallint", "decimal", "float", "double" ], boolean: ["bit", "smallint"], date: [ "datetime2", "date", "datetime" ], json: ["varchar", "nvarchar"] } }; function createDatabaseIndexKey(tableName, indexName) { return `${getPortableDatabaseIdentifierKey(tableName)}\u0000${getPortableDatabaseIdentifierKey(indexName)}`; } function createDatabaseColumnKey(tableName, columnName) { return `${tableName}\u0000${columnName}`; } function databaseIndexMatches(existing, configured) { return existing.unique === (configured.unique ?? false) && existing.validFullColumns && existing.columns.length === configured.columns.length && existing.columns.every((column, position) => column === configured.columns[position]); } function databaseValueIsTrue(value) { if (typeof value === "boolean") return value; if (typeof value === "number") return value !== 0; return value === "1" || value?.toLowerCase() === "true" || value === "t"; } function toDatabaseIndexMap(indexes) { return new Map(indexes.map((index) => { const columns = [...index.columns].sort((left, right) => left.position - right.position); return [createDatabaseIndexKey(index.table, index.name), { columns: columns.flatMap((column) => column.name === null ? [] : [column.name]), name: index.name, table: index.table, unique: index.unique, validFullColumns: index.valid && !index.partial && columns.length > 0 && columns.every((column) => column.name !== null && column.fullLength) }]; })); } async function getDatabaseIndexMap(db, dbType, schemaName, tableNames, introspectIndexes) { if (introspectIndexes) return toDatabaseIndexMap(await introspectIndexes(tableNames)); let rows; if (dbType === "sqlite") rows = (await sql` SELECT tables.name AS "tableName", index_list.name AS "indexName", index_info.name AS "columnName", index_list."unique" AS "isUnique", index_list.partial AS "isPartial", index_info.seqno AS "columnPosition" FROM sqlite_master AS tables INNER JOIN pragma_index_list(tables.name) AS index_list INNER JOIN pragma_index_info(index_list.name) AS index_info WHERE tables.type = 'table' `.execute(db)).rows; else if (dbType === "postgres") rows = (await sql` SELECT table_class.relname AS "tableName", index_class.relname AS "indexName", index_attribute.attname AS "columnName", index_data.indisunique AS "isUnique", index_data.indisvalid AS "isValid", (index_data.indpred IS NOT NULL) AS "isPartial", index_column.ordinality AS "columnPosition" FROM pg_class AS table_class INNER JOIN pg_namespace AS table_namespace ON table_namespace.oid = table_class.relnamespace INNER JOIN pg_index AS index_data ON index_data.indrelid = table_class.oid INNER JOIN pg_class AS index_class ON index_class.oid = index_data.indexrelid INNER JOIN LATERAL unnest(index_data.indkey) WITH ORDINALITY AS index_column(attribute_number, ordinality) ON TRUE LEFT JOIN pg_attribute AS index_attribute ON index_attribute.attrelid = table_class.oid AND index_attribute.attnum = index_column.attribute_number WHERE table_namespace.nspname = ${schemaName} AND table_class.relkind = 'r' AND index_column.ordinality <= index_data.indnkeyatts `.execute(db)).rows; else if (dbType === "mysql") rows = (await sql` SELECT table_name AS tableName, index_name AS indexName, column_name AS columnName, non_unique AS nonUnique, seq_in_index AS columnPosition, sub_part AS prefixLength, COALESCE(LOWER(comment) = 'disabled', FALSE) AS isDisabled FROM information_schema.statistics WHERE table_schema = DATABASE() `.execute(db)).rows; else rows = (await sql` SELECT tables.name AS "tableName", indexes.name AS "indexName", columns.name AS "columnName", indexes.is_unique AS "isUnique", indexes.is_disabled AS "isDisabled", indexes.is_hypothetical AS "isHypothetical", indexes.has_filter AS "isPartial", index_columns.key_ordinal AS "columnPosition" FROM sys.indexes AS indexes INNER JOIN sys.tables AS tables ON indexes.object_id = tables.object_id INNER JOIN sys.schemas AS table_schemas ON table_schemas.schema_id = tables.schema_id INNER JOIN sys.index_columns AS index_columns ON index_columns.object_id = indexes.object_id AND index_columns.index_id = indexes.index_id INNER JOIN sys.columns AS columns ON columns.object_id = index_columns.object_id AND columns.column_id = index_columns.column_id WHERE table_schemas.name = ${schemaName} AND indexes.name IS NOT NULL AND index_columns.key_ordinal > 0 `.execute(db)).rows; const indexMetadata = /* @__PURE__ */ new Map(); for (const row of rows) { const table = row.tableName ?? row.table_name ?? row.TABLE_NAME ?? row.tablename ?? row.tbl_name; const name = row.indexName ?? row.index_name ?? row.INDEX_NAME ?? row.name; const column = row.columnName ?? row.column_name ?? row.COLUMN_NAME; if (!table || !name) continue; const key = createDatabaseIndexKey(table, name); const nonUnique = row.nonUnique ?? row.non_unique ?? row.NON_UNIQUE; const unique = nonUnique === void 0 ? databaseValueIsTrue(row.isUnique ?? row.is_unique) : !databaseValueIsTrue(nonUnique); const position = Number(row.columnPosition ?? row.column_position ?? row.keyOrdinal ?? row.key_ordinal ?? row.ordinality ?? row.seqInIndex ?? row.seq_in_index ?? row.SEQ_IN_INDEX ?? row.seqno ?? 0); const indexColumn = { fullLength: column !== void 0 && column !== null && (row.prefixLength === void 0 || row.prefixLength === null), name: column ?? null, position }; const partial = databaseValueIsTrue(row.isPartial); const valid = !databaseValueIsTrue(row.isDisabled) && !databaseValueIsTrue(row.isHypothetical) && (row.isValid === void 0 || databaseValueIsTrue(row.isValid)); const existing = indexMetadata.get(key); indexMetadata.set(key, existing ? { ...existing, columns: [...existing.columns, indexColumn], partial: existing.partial || partial, valid: existing.valid && valid } : { columns: [indexColumn], name, partial, table, unique, valid }); } return toDatabaseIndexMap([...indexMetadata.values()]); } async function getDatabaseColumnBounds(db, dbType, schemaName) { if (dbType !== "mysql" && dbType !== "mssql") return /* @__PURE__ */ new Map(); let rows; if (dbType === "mysql") rows = (await sql` SELECT table_name AS tableName, column_name AS columnName, data_type AS dataType, character_maximum_length AS characterMaximumLength FROM information_schema.columns WHERE table_schema = DATABASE() `.execute(db)).rows; else rows = (await sql` SELECT tables.name AS "tableName", columns.name AS "columnName", types.name AS "dataType", columns.max_length AS "maxLength" FROM sys.columns AS columns INNER JOIN sys.tables AS tables ON tables.object_id = columns.object_id INNER JOIN sys.schemas AS table_schemas ON table_schemas.schema_id = tables.schema_id INNER JOIN sys.types AS types ON types.user_type_id = columns.user_type_id WHERE table_schemas.name = ${schemaName} `.execute(db)).rows; return new Map(rows.flatMap((row) => { const table = row.tableName ?? row.TABLE_NAME; const column = row.columnName ?? row.COLUMN_NAME; const dataType = (row.dataType ?? row.DATA_TYPE)?.toLowerCase(); if (!table || !column || !dataType) return []; if (dbType === "mysql") { const characterLength = row.characterMaximumLength ?? row.CHARACTER_MAXIMUM_LENGTH; const maxIndexBytes = characterLength === null || characterLength === void 0 ? null : Number(characterLength) * 4; return [[createDatabaseColumnKey(table, column), { maxIndexBytes }]]; } const maxLength = Number(row.maxLength ?? -1); return [[createDatabaseColumnKey(table, column), { maxIndexBytes: maxLength < 0 ? null : maxLength }]]; })); } function assertExistingTableIndexFits({ columnBounds, dbType, existingColumns, fields, indexes, index, table }) { const byteBudget = dbType === "mysql" ? 3072 : 1700; let requiredBytes = 0; for (const column of index.columns) { const field = fields[column]; if (!field) continue; if (field.type === "string" || Array.isArray(field.type)) { if (!existingColumns.has(column)) { const generatedLength = getDatabaseIndexStringLength({ columnName: column, dialect: dbType, fields, indexes }); requiredBytes += (generatedLength ?? 0) * (dbType === "mysql" ? 4 : 1); continue; } const bound = columnBounds.get(createDatabaseColumnKey(table, column)); if (!bound?.maxIndexBytes) throw new BetterAuthError(`Cannot create database index "${index.name}" on existing table "${table}" because column "${column}" is not bounded for ${dbType === "mysql" ? "MySQL" : "SQL Server"}. Change it to a bounded string column, resolve oversized values, then run the migration again.`); requiredBytes += bound.maxIndexBytes; } else requiredBytes += 16; } if (requiredBytes > byteBudget) throw new BetterAuthError(`Cannot create database index "${index.name}" on existing table "${table}" because its columns can exceed ${dbType === "mysql" ? "MySQL" : "SQL Server"}'s ${byteBudget}-byte index-key limit. Bound the indexed string columns to the generated schema lengths, resolve oversized values, then run the migration again.`); } const columnBackfillGuideUrl = "https://better-auth.com/docs/guides/1-7-upgrade-guide#account-identity-is-scoped-by-issuer"; /** * Thrown when {@link getMigrations} refuses to add a required column with no * default value to a populated table. Distinct from the plain * {@link BetterAuthError} thrown for index-definition conflicts, so callers * can tell the two apart without matching on message text. */ var UnsafeMigrationError = class extends BetterAuthError {}; function hasTimestampColumnDefault(field, dbType) { return field.type === "date" && typeof field.defaultValue === "function" && (dbType === "postgres" || dbType === "mysql" || dbType === "mssql"); } function hasStaticColumnDefault(field) { return !(field.unique && field.required === false) && (field.type === "string" || field.type === "number" || field.type === "boolean") && field.defaultValue !== void 0 && field.defaultValue !== null && typeof field.defaultValue !== "function"; } async function tableHasRows(db, dbType, table) { const probe = db.selectFrom(table).select(sql`1`.as("present")); return (await (dbType === "mssql" ? probe.top(1) : probe.limit(1)).execute()).length > 0; } function matchType(columnDataType, fieldType, dbType) { function normalize(type) { return type.toLowerCase().split("(")[0].trim(); } if (fieldType === "string[]" || fieldType === "number[]") return columnDataType.toLowerCase().includes("json"); const types = map[dbType]; return (Array.isArray(fieldType) ? types["string"].map((t) => t.toLowerCase()) : types[fieldType].map((t) => t.toLowerCase())).includes(normalize(columnDataType)); } /** * Get the current PostgreSQL schema (search_path) for the database connection * Returns the first schema in the search_path, defaulting to 'public' if not found */ async function getPostgresSchema(db) { try { const result = await sql`SHOW search_path`.execute(db); const searchPath = result.rows[0]?.search_path ?? result.rows[0]?.searchPath; if (searchPath) return searchPath.split(",").map((s) => s.trim()).map((s) => s.replace(/^["']|["']$/g, "")).filter((s) => !s.startsWith("$") && !s.startsWith("\\$"))[0] || "public"; } catch {} return "public"; } async function getMssqlSchema(db) { try { return (await sql` SELECT SCHEMA_NAME() AS "schemaName" `.execute(db)).rows[0]?.schemaName || "dbo"; } catch { return "dbo"; } } /** * Build the migration plan that `auth migrate` executes and `auth generate` * prints for the Kysely adapter. * * Adding a required column without a default to a populated table is refused: * existing rows have no value to backfill. `throwOnUnsafe` picks how that * refusal is delivered: executing callers get an {@link UnsafeMigrationError}, * read-only callers get the plan plus the same message in `unsafeChanges`. * * @throws {UnsafeMigrationError} when a required column cannot be migrated * safely and `throwOnUnsafe` is left on. * @throws {BetterAuthError} when an index definition conflicts with an * existing or already-planned index. */ async function getMigrations(config, { throwOnUnsafe = true } = {}) { const betterAuthSchema = getSchema(config); const authTables = getAuthTables(config); const accountIssuer = authTables.account && { table: authTables.account.modelName, column: authTables.account.fields.issuer?.fieldName || "issuer" }; const isAccountIssuerColumn = (table, column) => table === accountIssuer?.table && column === accountIssuer.column; const logger = createLogger(config.logger); const unsafeChanges = []; const reportUnsafeChange = (message) => { if (throwOnUnsafe) throw new UnsafeMigrationError(message); unsafeChanges.push(message); }; let { kysely: db, databaseType: dbType, introspectIndexes } = await createKyselyAdapter(config); if (!dbType) { logger.warn("Could not determine database type, defaulting to sqlite. Please provide a type in the database options to avoid this."); dbType = "sqlite"; } if (!db) { logger.error("Only kysely adapter is supported for migrations. You can use `generate` command to generate the schema, if you're using a different adapter."); process.exit(1); } let currentSchema = dbType === "mssql" ? await getMssqlSchema(db) : "public"; if (dbType === "postgres") { currentSchema = await getPostgresSchema(db); logger.debug(`PostgreSQL migration: Using schema '${currentSchema}' (from search_path)`); try { const schemaCheck = await sql` SELECT schema_name FROM information_schema.schemata WHERE schema_name = ${currentSchema} `.execute(db); if (!(schemaCheck.rows[0]?.schema_name ?? schemaCheck.rows[0]?.schemaName)) logger.warn(`Schema '${currentSchema}' does not exist. Tables will be inspected from available schemas. Consider creating the schema first or checking your database configuration.`); } catch (error) { logger.debug(`Could not verify schema existence: ${error instanceof Error ? error.message : String(error)}`); } } else if (dbType === "mssql") logger.debug(`SQL Server migration: Using schema '${currentSchema}' (from the current user's default schema)`); const allTableMetadata = await db.introspection.getTables(); const databaseIndexMap = await getDatabaseIndexMap(db, dbType, currentSchema, allTableMetadata.map((table) => table.name), introspectIndexes); const databaseColumnBounds = await getDatabaseColumnBounds(db, dbType, currentSchema); let tableMetadata = allTableMetadata; if (dbType === "postgres") try { const tablesInSchema = await sql` SELECT table_name FROM information_schema.tables WHERE table_schema = ${currentSchema} AND table_type = 'BASE TABLE' `.execute(db); const tableNamesInSchema = new Set(tablesInSchema.rows.map((row) => row.table_name ?? row.tableName)); tableMetadata = allTableMetadata.filter((table) => table.schema === currentSchema && tableNamesInSchema.has(table.name)); logger.debug(`Found ${tableMetadata.length} table(s) in schema '${currentSchema}': ${tableMetadata.map((t) => t.name).join(", ") || "(none)"}`); } catch (error) { logger.warn(`Could not filter tables by schema. Using all discovered tables. Error: ${error instanceof Error ? error.message : String(error)}`); } else if (dbType === "mssql") tableMetadata = allTableMetadata.filter((table) => table.schema === currentSchema); const toBeCreated = []; const toBeAdded = []; const toBeAddedIndexes = []; const plannedIndexes = /* @__PURE__ */ new Map(); for (const [key, value] of Object.entries(betterAuthSchema)) { if (value.disableMigrations) continue; const table = tableMetadata.find((table) => table.name === key); for (const index of value.indexes ?? []) { const name = index.name; const indexKey = createDatabaseIndexKey(key, name); const existingIndex = databaseIndexMap.get(indexKey); if (existingIndex) { if (!databaseIndexMatches(existingIndex, index)) throw new BetterAuthError(`Database index "${name}" on table "${key}" does not match the configured fields and uniqueness. Rename or replace the existing index, then run the migration again.`); continue; } if (dbType === "sqlite" || dbType === "postgres") { const indexOnAnotherTable = [...databaseIndexMap.values()].find((databaseIndex) => getPortableDatabaseIdentifierKey(databaseIndex.name) === getPortableDatabaseIdentifierKey(name) && getPortableDatabaseIdentifierKey(databaseIndex.table) !== getPortableDatabaseIdentifierKey(key)); if (indexOnAnotherTable) throw new BetterAuthError(`Database index name "${name}" is already used by table "${indexOnAnotherTable.table}". Index names must be unique across the schema.`); } const plannedIndex = plannedIndexes.get(indexKey); if (plannedIndex) { if (!databaseIndexMatches({ columns: plannedIndex.columns, name: plannedIndex.name, table: key, unique: plannedIndex.unique ?? false, validFullColumns: true }, index)) throw new BetterAuthError(`Database index name "${name}" identifies more than one index on table "${key}".`); continue; } if (table && (dbType === "mysql" || dbType === "mssql")) assertExistingTableIndexFits({ columnBounds: databaseColumnBounds, dbType, existingColumns: new Set(table.columns.map((column) => column.name)), fields: value.fields, index, indexes: value.indexes ?? [], table: key }); plannedIndexes.set(indexKey, index); toBeAddedIndexes.push({ table: key, index, name }); } if (!table) { const tIndex = toBeCreated.findIndex((t) => t.table === key); const tableData = { table: key, fields: value.fields, order: value.order || Infinity }; const insertIndex = toBeCreated.findIndex((t) => (t.order || Infinity) > tableData.order); if (insertIndex === -1) if (tIndex === -1) toBeCreated.push(tableData); else toBeCreated[tIndex].fields = { ...toBeCreated[tIndex].fields, ...value.fields }; else toBeCreated.splice(insertIndex, 0, tableData); continue; } const toBeAddedFields = {}; for (const [fieldName, field] of Object.entries(value.fields)) { const column = table.columns.find((c) => c.name === fieldName); if (!column) { toBeAddedFields[fieldName] = field; continue; } if (field.required !== false && column.isNullable) logger.warn(`Column "${fieldName}" on table "${key}" stays nullable while the schema declares the field required, so existing rows can still hold null. Backfill every row for this column and enforce NOT NULL to remove the drift.`); if (matchType(column.dataType, field.type, dbType)) continue; else logger.warn(`Field ${fieldName} in table ${key} has a different type in the database. Expected ${field.type} but got ${column.dataType}.`); } if (Object.keys(toBeAddedFields).length > 0) toBeAdded.push({ table: key, fields: toBeAddedFields, order: value.order || Infinity }); } const migrations = []; const useUUIDs = config.advanced?.database?.generateId === "uuid"; const useNumberId = config.advanced?.database?.generateId === "serial"; function getType(field, fieldName, tableIndexStringLength) { const type = field.type; const provider = dbType || "sqlite"; const typeMap = { string: { sqlite: "text", postgres: "text", mysql: tableIndexStringLength ? `varchar(${tableIndexStringLength})` : field.unique ? "varchar(255)" : field.references ? "varchar(36)" : field.sortable ? "varchar(255)" : field.index ? "varchar(255)" : "text", mssql: tableIndexStringLength ? `varchar(${tableIndexStringLength})` : field.unique || field.sortable ? "varchar(255)" : field.references ? "varchar(36)" : "varchar(8000)" }, boolean: { sqlite: "integer", postgres: "boolean", mysql: "boolean", mssql: "smallint" }, number: { sqlite: field.bigint ? "bigint" : "integer", postgres: field.bigint ? "bigint" : "integer", mysql: field.bigint ? "bigint" : "integer", mssql: field.bigint ? "bigint" : "integer" }, date: { sqlite: "date", postgres: "timestamptz", mysql: "timestamp(3)", mssql: sql`datetime2(3)` }, json: { sqlite: "text", postgres: "jsonb", mysql: "json", mssql: "varchar(8000)" }, id: { postgres: useNumberId ? sql`integer GENERATED BY DEFAULT AS IDENTITY` : useUUIDs ? "uuid" : "text", mysql: useNumberId ? "integer" : useUUIDs ? "varchar(36)" : "varchar(36)", mssql: useNumberId ? "integer" : useUUIDs ? "varchar(36)" : "varchar(36)", sqlite: useNumberId ? "integer" : "text" }, foreignKeyId: { postgres: useNumberId ? "integer" : useUUIDs ? "uuid" : "text", mysql: useNumberId ? "integer" : useUUIDs ? "varchar(36)" : "varchar(36)", mssql: useNumberId ? "integer" : useUUIDs ? "varchar(36)" : "varchar(36)", sqlite: useNumberId ? "integer" : "text" }, "string[]": { sqlite: "text", postgres: "jsonb", mysql: "json", mssql: "varchar(8000)" }, "number[]": { sqlite: "text", postgres: "jsonb", mysql: "json", mssql: "varchar(8000)" } }; if (fieldName === "id" || field.references?.field === "id") { if (fieldName === "id") return typeMap.id[provider]; return typeMap.foreignKeyId[provider]; } if (Array.isArray(type)) return "text"; if (!(type in typeMap)) throw new Error(`Unsupported field type '${String(type)}' for field '${fieldName}'. Allowed types are: string, number, boolean, date, string[], number[]. If you need to store structured data, store it as a JSON string (type: "string") or split it into primitive fields. See https://better-auth.com/docs/advanced/schema#additional-fields`); return typeMap[type][provider]; } const getModelName = initGetModelName({ schema: authTables, usePlural: false }); const getFieldName = initGetFieldName({ schema: authTables, usePlural: false }); function getReferencePath(model, field) { try { return `${getModelName(model)}.${getFieldName({ model, field })}`; } catch { return `${model}.${field}`; } } const deferredIndexes = []; const getTableIndexStringLength = (tableName, fieldName) => { if (dbType !== "mysql" && dbType !== "mssql") return void 0; const table = betterAuthSchema[tableName]; if (!table) return void 0; return getDatabaseIndexStringLength({ columnName: fieldName, dialect: dbType, fields: table.fields, indexes: table.indexes ?? [] }); }; if (toBeAdded.length) { const populatedTables = /* @__PURE__ */ new Map(); for (const table of toBeAdded) for (const [fieldName, field] of Object.entries(table.fields)) { const timestampDefault = hasTimestampColumnDefault(field, dbType); const staticDefault = hasStaticColumnDefault(field); if (field.required !== false && !timestampDefault && !staticDefault) { let populated = populatedTables.get(table.table); if (populated === void 0) { populated = await tableHasRows(db, dbType, table.table); populatedTables.set(table.table, populated); } if (populated) { const textDetail = field.type === "string" ? " For a text column, every existing row ends up with the same empty string." : ""; const guideLink = isAccountIssuerColumn(table.table, fieldName) ? ` See ${columnBackfillGuideUrl}` : ""; reportUnsafeChange(`Cannot add required column "${fieldName}" to populated table "${table.table}": the schema declares no default value, so existing rows have no value to backfill. MySQL accepts this statement instead of rejecting it and fills every existing row with an implicit default for the column type, reporting a successful migration over corrupted data.${textDetail} Add the column as nullable, backfill a correct value for every row, then make it NOT NULL.${guideLink}`); } } const type = getType(field, fieldName, getTableIndexStringLength(table.table, fieldName)); const builder = db.schema.alterTable(table.table); if (field.index || field.unique) { const indexName = getDatabaseFieldIndexName(table.table, fieldName, field.unique ?? false); let indexBuilder = db.schema.createIndex(indexName).on(table.table).columns([fieldName]); if (field.unique) { indexBuilder = indexBuilder.unique(); if (field.required === false && dbType === "mssql") indexBuilder = indexBuilder.where(fieldName, "is not", null); if (field.required !== false && field.defaultValue !== void 0 && field.defaultValue !== null && typeof field.defaultValue !== "function") logger.warn(`Adding unique column "${fieldName}" to existing table "${table.table}" backfills every existing row with its default value. If the table has more than one row, creating the unique index "${indexName}" will fail; backfill distinct values manually, then re-run the migration or create the index yourself.`); } deferredIndexes.push(indexBuilder); } const built = builder.addColumn(fieldName, type, (col) => { col = field.required !== false ? col.notNull() : col; if (field.references) col = col.references(getReferencePath(field.references.model, field.references.field)).onDelete(field.references.onDelete || "cascade"); if (timestampDefault) if (dbType === "mysql") col = col.defaultTo(sql`CURRENT_TIMESTAMP(3)`); else col = col.defaultTo(sql`CURRENT_TIMESTAMP`); else if (staticDefault) col = col.defaultTo(typeof field.defaultValue === "boolean" && (dbType === "sqlite" || dbType === "mssql") ? field.defaultValue ? 1 : 0 : field.defaultValue); return col; }); migrations.push(built); } } if (toBeCreated.length) for (const table of toBeCreated) { const idType = getType({ type: useNumberId ? "number" : "string" }, "id"); let dbT = db.schema.createTable(table.table).addColumn("id", idType, (col) => { if (useNumberId) { if (dbType === "postgres") return col.primaryKey().notNull(); else if (dbType === "sqlite") return col.primaryKey().notNull(); else if (dbType === "mssql") return col.identity().primaryKey().notNull(); return col.autoIncrement().primaryKey().notNull(); } if (useUUIDs) { if (dbType === "postgres") return col.primaryKey().defaultTo(sql`pg_catalog.gen_random_uuid()`).notNull(); return col.primaryKey().notNull(); } return col.primaryKey().notNull(); }); for (const [fieldName, field] of Object.entries(table.fields)) { const type = getType(field, fieldName, getTableIndexStringLength(table.table, fieldName)); dbT = dbT.addColumn(fieldName, type, (col) => { col = field.required !== false ? col.notNull() : col; if (field.references) col = col.references(getReferencePath(field.references.model, field.references.field)).onDelete(field.references.onDelete || "cascade"); if (field.unique) col = col.unique(); if (field.type === "date" && typeof field.defaultValue === "function" && (dbType === "postgres" || dbType === "mysql" || dbType === "mssql")) if (dbType === "mysql") col = col.defaultTo(sql`CURRENT_TIMESTAMP(3)`); else col = col.defaultTo(sql`CURRENT_TIMESTAMP`); return col; }); if (field.index && !field.unique) { const builder = db.schema.createIndex(getDatabaseFieldIndexName(table.table, fieldName, false)).on(table.table).columns([fieldName]); deferredIndexes.push(builder); } } migrations.push(dbT); } for (const { table, index, name } of toBeAddedIndexes) { let builder = db.schema.createIndex(name).on(table).columns([...index.columns]); if (index.unique) builder = builder.unique(); deferredIndexes.push(builder); } for (const index of deferredIndexes) migrations.push(index); async function runMigrations() { for (const migration of migrations) await migration.execute(); } async function compileMigrations() { return migrations.map((m) => m.compile().sql).join(";\n\n") + ";"; } return { toBeCreated, toBeAdded, toBeAddedIndexes, unsafeChanges, runMigrations, compileMigrations }; } //#endregion export { UnsafeMigrationError, getMigrations, matchType };