File size: 9,062 Bytes
9697ede | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 | /* oxlint-disable */
import type { TablesRelationalConfig } from "drizzle-orm/_relations"
import type { MigrationMeta } from "drizzle-orm/migrator"
import type { AnyRelations } from "drizzle-orm/relations"
import { type SQL, sql } from "drizzle-orm/sql/sql"
import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core"
import type { SQLiteSession } from "drizzle-orm/sqlite-core/session"
import { GET_VERSION_FOR, MIGRATIONS_TABLE_VERSIONS, type UpgradeResult } from "./utils"
/** @internal */
export type SQLiteMigrationTableRow = { id: number | null; hash: string; created_at: number }
type AsyncSQLiteDatabaseWithSession = BaseSQLiteDatabase<"async", unknown, Record<string, unknown>> & {
session: {
all<T>(query: SQL): Promise<T[]>
}
transaction<T>(transaction: (tx: { run(query: SQL): Promise<unknown> }) => Promise<T>): Promise<T>
}
type SQLiteMigrationBackfillEntry = {
name: string
selector:
| { column: "id"; value: number }
| { column: "created_at"; value: number }
| { column: "hash"; value: string }
}
function unmatchedMigrationError(unmatched: SQLiteMigrationTableRow[]) {
return new Error(
`While upgrading your database migrations table we found ${unmatched.length} (${unmatched
.map((it) => `[id: ${it.id}, created_at: ${it.created_at}]`)
.join(
", ",
)}) migrations in the database that do not match any local migration. This means that some migrations were applied to the database but are missing from the local environment`,
)
}
/** @internal */
export function prepareSQLiteMigrationBackfill(
dbRows: SQLiteMigrationTableRow[],
localMigrations: MigrationMeta[],
): SQLiteMigrationBackfillEntry[] {
const sortedLocalMigrations = [...localMigrations].sort((a, b) =>
a.folderMillis !== b.folderMillis ? a.folderMillis - b.folderMillis : (a.name ?? "").localeCompare(b.name ?? ""),
)
const byMillis = new Map<number, MigrationMeta[]>()
const byHash = new Map<string, MigrationMeta>()
for (const migration of sortedLocalMigrations) {
if (!byMillis.has(migration.folderMillis)) {
byMillis.set(migration.folderMillis, [])
}
byMillis.get(migration.folderMillis)!.push(migration)
byHash.set(migration.hash, migration)
}
const toApply: SQLiteMigrationBackfillEntry[] = []
const unmatched: SQLiteMigrationTableRow[] = []
for (const dbRow of dbRows) {
const stringified = String(dbRow.created_at)
const millis = Number(stringified.substring(0, stringified.length - 3) + "000")
const candidates = byMillis.get(millis)
const matchedByMillis = candidates?.length === 1 ? candidates[0] : undefined
const matchedByCandidateHash =
candidates && candidates.length > 1
? candidates.find((candidate) => candidate.hash && dbRow.hash && candidate.hash === dbRow.hash)
: undefined
const matchedByHash = matchedByMillis || matchedByCandidateHash ? undefined : byHash.get(dbRow.hash)
const matched = matchedByMillis ?? matchedByCandidateHash ?? matchedByHash
if (matched) {
toApply.push({
name: matched.name,
selector:
dbRow.id !== null
? { column: "id", value: dbRow.id }
: matchedByMillis
? { column: "created_at", value: dbRow.created_at }
: { column: "hash", value: dbRow.hash },
})
continue
}
unmatched.push(dbRow)
}
if (unmatched.length > 0) {
throw unmatchedMigrationError(unmatched)
}
return toApply
}
/** @internal */
export function buildSQLiteMigrationBackfillStatements(
migrationsTable: string,
backfillEntries: SQLiteMigrationBackfillEntry[],
) {
const table = sql`${sql.identifier(migrationsTable)}`
const statements: SQL[] = [
sql`ALTER TABLE ${table} ADD COLUMN ${sql.identifier("name")} text`,
sql`ALTER TABLE ${table} ADD COLUMN ${sql.identifier("applied_at")} TEXT`,
]
for (const backfillEntry of backfillEntries) {
const updateQuery = sql`UPDATE ${table} SET ${sql.identifier("name")} = ${backfillEntry.name}, ${sql.identifier(
"applied_at",
)} = NULL WHERE`
updateQuery.append(sql` ${sql.identifier(backfillEntry.selector.column)} = ${backfillEntry.selector.value}`)
statements.push(updateQuery)
}
return statements
}
/**
* Detects the current version of the migrations table schema and upgrades it if needed.
*
* Version 0: Original schema (id, hash, created_at)
* Version 1: Extended schema (id, hash, created_at, name, applied_at)
*/
export function upgradeSyncIfNeeded(
migrationsTable: string,
session: SQLiteSession<"sync", unknown, Record<string, unknown>, AnyRelations, TablesRelationalConfig>,
localMigrations: MigrationMeta[],
): UpgradeResult {
const tableExists = session.all(sql`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ${migrationsTable}`)
if (tableExists.length === 0) {
return { newDb: true }
}
// Table exists, check table shape
const rows = session.all<{ column_name: string }>(
sql`SELECT name as column_name FROM pragma_table_info(${migrationsTable})`,
)
const version = GET_VERSION_FOR.sqlite(rows.map((r) => r.column_name))
for (let v = version; v < MIGRATIONS_TABLE_VERSIONS.sqlite; v++) {
const upgradeFn = upgradeSyncFunctions[v]
if (!upgradeFn) {
throw new Error(`No upgrade path from migration table version ${v} to ${v + 1}`)
}
upgradeFn(migrationsTable, session, localMigrations)
}
return { newDb: false }
}
const upgradeSyncFunctions: Record<
number,
(
migrationsTable: string,
session: SQLiteSession<"sync", unknown, Record<string, unknown>, AnyRelations, TablesRelationalConfig>,
localMigrations: MigrationMeta[],
) => void
> = {
/**
* Upgrade from version 0 to version 1:
* 1. Read all existing DB migrations
* 2. Sort localMigrations ASC by millis and if the same - sort by name
* 3. Match each DB row to a local migration
* If multiple migrations share the same second, use hash matching as a tiebreaker
* Not implemented for now -> If hash matching fails, fall back to serial id ordering
* 5. Create extra column and backfill names for matched migrations
*/
0: (migrationsTable, session, localMigrations) => {
const table = sql`${sql.identifier(migrationsTable)}`
const dbRows = session.all<SQLiteMigrationTableRow>(sql`SELECT id, hash, created_at FROM ${table} ORDER BY id ASC`)
const statements = buildSQLiteMigrationBackfillStatements(
migrationsTable,
prepareSQLiteMigrationBackfill(dbRows, localMigrations),
)
session.transaction((tx) => {
for (const statement of statements) {
tx.run(statement)
}
})
},
}
/**
* Detects the current version of the migrations table schema and upgrades it if needed.
*
* Version 0: Original schema (id, hash, created_at)
* Version 1: Extended schema (id, hash, created_at, name, applied_at)
*/
export async function upgradeAsyncIfNeeded(
migrationsTable: string,
db: AsyncSQLiteDatabaseWithSession,
localMigrations: MigrationMeta[],
): Promise<UpgradeResult> {
// Check if the table exists at all
const tableExists = await db.session.all(
sql`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ${migrationsTable}`,
)
if (tableExists.length === 0) {
return { newDb: true }
}
const rows = await db.session.all<{ column_name: string }>(
sql`SELECT name as column_name FROM pragma_table_info(${migrationsTable})`,
)
const version = GET_VERSION_FOR.sqlite(rows.map((r) => r.column_name))
for (let v = version; v < MIGRATIONS_TABLE_VERSIONS.sqlite; v++) {
const upgradeFn = upgradeAsyncFunctions[v]
if (!upgradeFn) {
throw new Error(`No upgrade path from migration table version ${v} to ${v + 1}`)
}
await upgradeFn(migrationsTable, db, localMigrations)
}
return { newDb: false }
}
const upgradeAsyncFunctions: Record<
number,
(migrationsTable: string, db: AsyncSQLiteDatabaseWithSession, localMigrations: MigrationMeta[]) => Promise<void>
> = {
/**
* Upgrade from version 0 to version 1:
* 1. Read all existing DB migrations
* 2. Sort localMigrations ASC by millis and if the same - sort by name
* 3. Match each DB row to a local migration
* If multiple migrations share the same second, use hash matching as a tiebreaker
* Not implemented for now -> If hash matching fails, fall back to serial id ordering
* 5. Create extra column and backfill names for matched migrations
*/
0: async (migrationsTable, db, localMigrations) => {
const table = sql`${sql.identifier(migrationsTable)}`
const dbRows = await db.session.all<SQLiteMigrationTableRow>(
sql`SELECT id, hash, created_at FROM ${table} ORDER BY id ASC`,
)
const statements = buildSQLiteMigrationBackfillStatements(
migrationsTable,
prepareSQLiteMigrationBackfill(dbRows, localMigrations),
)
await db.transaction(async (tx) => {
for (const statement of statements) {
await tx.run(statement)
}
})
},
}
|