diff --git a/packages/app/drizzle-utils/README.md b/packages/app/drizzle-utils/README.md index 0395d8aa6..83239bd62 100644 --- a/packages/app/drizzle-utils/README.md +++ b/packages/app/drizzle-utils/README.md @@ -31,11 +31,11 @@ You need a way to tell Drizzle: "these migrations are already reflected in the d `markMigrationsApplied` populates Drizzle's internal `__drizzle_migrations` tracking table with records for all existing migration files, so that `drizzle-kit migrate` treats them as already applied. This establishes a baseline — from this point forward, only new migrations will be executed. The function: -- Reads the migration journal (`meta/_journal.json`) and SQL files from your migrations folder +- Reads migration files from your migrations folder — supports both the legacy journal format (`meta/_journal.json` with flat SQL files, from drizzle-kit 0.x) and the new folder-per-migration format (`_/migration.sql`, from drizzle-kit 1.0.0-beta) - Computes the SHA-256 hash for each migration (matching Drizzle's internal algorithm) - Inserts tracking records into the `__drizzle_migrations` table - Is **idempotent** — safe to run multiple times; already-tracked migrations are skipped -- Supports **PostgreSQL**, **MySQL**, and **CockroachDB**, with auto-detection from the journal +- Supports **PostgreSQL**, **MySQL**, and **CockroachDB**, with auto-detection from the journal or snapshot files #### CLI @@ -115,17 +115,19 @@ await sql.end() | Option | Type | Default | Description | |---|---|---|---| -| `migrationsFolder` | `string` | *(required)* | Path to the Drizzle migrations folder (containing `meta/_journal.json`) | +| `migrationsFolder` | `string` | *(required)* | Path to the Drizzle migrations folder. Supports both legacy format (with `meta/_journal.json`) and new folder-per-migration format (drizzle-kit 1.0.0-beta) | | `executor` | `SqlExecutor` | *(required)* | Object with `run(sql)` and `all(sql)` methods for executing raw SQL | -| `dialect` | `'postgresql' \| 'mysql' \| 'cockroachdb'` | *(auto-detected)* | Database dialect. Auto-detected from the journal's `dialect` field if omitted | +| `dialect` | `'postgresql' \| 'mysql' \| 'cockroachdb'` | *(auto-detected)* | Database dialect. Auto-detected from the journal or snapshot files if omitted | | `migrationsTable` | `string` | `'__drizzle_migrations'` | Name of the migrations tracking table | | `migrationsSchema` | `string` | `'drizzle'` | Schema for the migrations table (PostgreSQL and CockroachDB only) | #### Helper functions -`readMigrationJournal(migrationsFolder)` — reads and parses `meta/_journal.json`. +`detectMigrationFormat(migrationsFolder)` — returns `'journal'` (legacy format with `meta/_journal.json`) or `'folder'` (new folder-per-migration format). -`readMigrationEntries(migrationsFolder)` — reads all migration entries with their computed SHA-256 hashes. +`readMigrationJournal(migrationsFolder)` — reads and parses `meta/_journal.json` (legacy format only). + +`readMigrationEntries(migrationsFolder)` — reads all migration entries with their computed SHA-256 hashes. Automatically detects and handles both legacy and new formats. `computeMigrationHash(sqlContent)` — computes the SHA-256 hash of a migration SQL string, matching Drizzle's internal algorithm. diff --git a/packages/app/drizzle-utils/docs/migrating-from-prisma.md b/packages/app/drizzle-utils/docs/migrating-from-prisma.md index 17154fc38..71fa30ae4 100644 --- a/packages/app/drizzle-utils/docs/migrating-from-prisma.md +++ b/packages/app/drizzle-utils/docs/migrating-from-prisma.md @@ -38,6 +38,12 @@ npx drizzle-kit generate This produces SQL migration files that describe your full schema (`CREATE TABLE`, etc.). These describe the *target state*, which your database already has — they must NOT be executed directly. +The output format depends on your drizzle-kit version: +- **drizzle-kit 0.x (stable)**: flat SQL files with a `meta/_journal.json` index +- **drizzle-kit 1.0.0-beta**: folder-per-migration (`_/migration.sql`) + +Both formats are fully supported by `markMigrationsApplied`. + Review the generated SQL to verify it matches your existing database. If there are differences, fix your Drizzle schema and regenerate. ## Step 5: Mark migrations as applied (the baseline) diff --git a/packages/app/drizzle-utils/package.json b/packages/app/drizzle-utils/package.json index cfb3a5c32..a2baa1b9b 100644 --- a/packages/app/drizzle-utils/package.json +++ b/packages/app/drizzle-utils/package.json @@ -57,7 +57,8 @@ "@vitest/coverage-v8": "^4.0.15", "cli-testlab": "^6.0.1", "cross-env": "^10.0.0", - "drizzle-orm": "1.0.0-beta.19", + "drizzle-kit": "1.0.0-beta.20", + "drizzle-orm": "1.0.0-beta.20", "mysql2": "^3.14.0", "postgres": "^3.4.8", "rimraf": "^6.1.3", diff --git a/packages/app/drizzle-utils/src/index.ts b/packages/app/drizzle-utils/src/index.ts index d6c307f6b..b816c4825 100644 --- a/packages/app/drizzle-utils/src/index.ts +++ b/packages/app/drizzle-utils/src/index.ts @@ -2,9 +2,11 @@ export { type BulkUpdateEntry, drizzleFullBulkUpdate } from './drizzleFullBulkUp export { computeMigrationHash, type Dialect, + detectMigrationFormat, type MarkMigrationsAppliedOptions, type MarkMigrationsAppliedResult, type MigrationEntry, + type MigrationFormat, type MigrationJournal, type MigrationJournalEntry, markMigrationsApplied, diff --git a/packages/app/drizzle-utils/src/markMigrationsApplied.test.ts b/packages/app/drizzle-utils/src/markMigrationsApplied.test.ts index 964dbbce6..fd8fcef47 100644 --- a/packages/app/drizzle-utils/src/markMigrationsApplied.test.ts +++ b/packages/app/drizzle-utils/src/markMigrationsApplied.test.ts @@ -1,15 +1,20 @@ import { createHash } from 'node:crypto' -import { readFileSync } from 'node:fs' +import { readdirSync, readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { drizzle } from 'drizzle-orm/postgres-js' import mysql from 'mysql2/promise' import postgres from 'postgres' -import { afterAll, beforeEach, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { + cleanupGeneratedMigrations, + generateNewFormatMigrations, +} from '../test/generateMigrations.ts' import { getCockroachdbDatabaseUrl } from '../test/getCockroachdbDatabaseUrl.ts' import { getDatabaseUrl } from '../test/getDatabaseUrl.ts' import { getMysqlDatabaseUrl } from '../test/getMysqlDatabaseUrl.ts' import { computeMigrationHash, + detectMigrationFormat, markMigrationsApplied, readMigrationEntries, readMigrationJournal, @@ -20,6 +25,9 @@ const FIXTURES_DIR = resolve(__dirname, '../test/fixtures') const PG_MIGRATIONS_DIR = join(FIXTURES_DIR, 'migrations') const COCKROACHDB_MIGRATIONS_DIR = join(FIXTURES_DIR, 'migrations-cockroachdb') const MYSQL_MIGRATIONS_DIR = join(FIXTURES_DIR, 'migrations-mysql') +const PG_NEW_FORMAT_DIR = join(FIXTURES_DIR, 'migrations-new-format') +const COCKROACHDB_NEW_FORMAT_DIR = join(FIXTURES_DIR, 'migrations-new-format-cockroachdb') +const MYSQL_NEW_FORMAT_DIR = join(FIXTURES_DIR, 'migrations-new-format-mysql') // ─── Pure function tests (no database) ─── @@ -38,6 +46,16 @@ describe('computeMigrationHash', () => { }) }) +describe('detectMigrationFormat', () => { + it('returns "journal" for legacy format with meta/_journal.json', () => { + expect(detectMigrationFormat(PG_MIGRATIONS_DIR)).toBe('journal') + }) + + it('returns "folder" for new format without meta/_journal.json', () => { + expect(detectMigrationFormat(PG_NEW_FORMAT_DIR)).toBe('folder') + }) +}) + describe('readMigrationJournal', () => { it('reads and parses the journal file', () => { const journal = readMigrationJournal(PG_MIGRATIONS_DIR) @@ -52,7 +70,7 @@ describe('readMigrationJournal', () => { }) }) -describe('readMigrationEntries', () => { +describe('readMigrationEntries (legacy journal format)', () => { it('reads entries with computed hashes', () => { const entries = readMigrationEntries(PG_MIGRATIONS_DIR) expect(entries).toHaveLength(2) @@ -66,9 +84,36 @@ describe('readMigrationEntries', () => { }) }) -// ─── PostgreSQL integration tests ─── +describe('readMigrationEntries (new folder format)', () => { + it('reads entries from folder-per-migration structure', () => { + const entries = readMigrationEntries(PG_NEW_FORMAT_DIR) + expect(entries).toHaveLength(2) + + // Tags should be folder names, sorted alphabetically (timestamp prefix ensures order) + expect(entries[0]!.tag).toMatch(/^\d{14}_init$/) + expect(entries[1]!.tag).toMatch(/^\d{14}_add_users$/) + + // Verify hashes match the actual migration.sql content + const dirs = readdirSync(PG_NEW_FORMAT_DIR).sort() + const initSql = readFileSync(join(PG_NEW_FORMAT_DIR, dirs[0]!, 'migration.sql'), 'utf-8') + const expectedHash = createHash('sha256').update(initSql).digest('hex') + expect(entries[0]!.hash).toBe(expectedHash) + + // createdAt should be parsed from the folder timestamp + expect(entries[0]!.createdAt).toBeGreaterThan(0) + }) + + it('reads MySQL entries from folder-per-migration structure', () => { + const entries = readMigrationEntries(MYSQL_NEW_FORMAT_DIR) + expect(entries).toHaveLength(2) + expect(entries[0]!.tag).toMatch(/^\d{14}_init$/) + expect(entries[1]!.tag).toMatch(/^\d{14}_add_users$/) + }) +}) + +// ─── PostgreSQL integration tests (legacy format) ─── -describe('markMigrationsApplied (PostgreSQL)', () => { +describe('markMigrationsApplied (PostgreSQL, legacy format)', () => { const sql = postgres(getDatabaseUrl()) const db = drizzle({ client: sql }) @@ -195,6 +240,171 @@ describe('markMigrationsApplied (PostgreSQL)', () => { }) }) +// ─── PostgreSQL integration tests (new folder format) ─── + +describe('markMigrationsApplied (PostgreSQL, new folder format)', () => { + const sql = postgres(getDatabaseUrl()) + const db = drizzle({ client: sql }) + + const testSchema = 'drizzle_test_new' + const testTable = '__drizzle_migrations_test_new' + + const executor: SqlExecutor = { + run: (query: string) => sql.unsafe(query).then(() => {}), + all: (query: string) => sql.unsafe(query) as Promise[]>, + } + + beforeEach(async () => { + await db.execute(`DROP TABLE IF EXISTS "${testSchema}"."${testTable}"`) + await db.execute(`DROP SCHEMA IF EXISTS "${testSchema}" CASCADE`) + }) + + afterAll(async () => { + await db.execute(`DROP TABLE IF EXISTS "${testSchema}"."${testTable}"`) + await db.execute(`DROP SCHEMA IF EXISTS "${testSchema}" CASCADE`) + await sql.end() + }) + + it('creates schema, table, and inserts all migrations from new format', async () => { + const result = await markMigrationsApplied({ + migrationsFolder: PG_NEW_FORMAT_DIR, + executor, + dialect: 'postgresql', + migrationsTable: testTable, + migrationsSchema: testSchema, + }) + + expect(result.total).toBe(2) + expect(result.applied).toBe(2) + expect(result.skipped).toBe(0) + expect(result.entries[0]!.tag).toMatch(/^\d{14}_init$/) + expect(result.entries[1]!.tag).toMatch(/^\d{14}_add_users$/) + expect(result.entries[0]!.status).toBe('applied') + expect(result.entries[1]!.status).toBe('applied') + + const rows = await sql.unsafe( + `SELECT hash, created_at FROM "${testSchema}"."${testTable}" ORDER BY id`, + ) + expect(rows).toHaveLength(2) + // created_at should be a parsed timestamp, not 0 + expect(Number(rows[0]!.created_at)).toBeGreaterThan(0) + }) + + it('is idempotent — skips already tracked migrations', async () => { + await markMigrationsApplied({ + migrationsFolder: PG_NEW_FORMAT_DIR, + executor, + dialect: 'postgresql', + migrationsTable: testTable, + migrationsSchema: testSchema, + }) + + const result = await markMigrationsApplied({ + migrationsFolder: PG_NEW_FORMAT_DIR, + executor, + dialect: 'postgresql', + migrationsTable: testTable, + migrationsSchema: testSchema, + }) + + expect(result.total).toBe(2) + expect(result.applied).toBe(0) + expect(result.skipped).toBe(2) + + const rows = await sql.unsafe(`SELECT * FROM "${testSchema}"."${testTable}"`) + expect(rows).toHaveLength(2) + }) + + it('auto-detects postgresql dialect from snapshot', async () => { + const result = await markMigrationsApplied({ + migrationsFolder: PG_NEW_FORMAT_DIR, + executor, + migrationsTable: testTable, + migrationsSchema: testSchema, + }) + + // snapshot.json has dialect: "postgres" which should be normalized to "postgresql" + expect(result.applied).toBe(2) + }) +}) + +// ─── PostgreSQL integration tests (drizzle-kit generated new format) ─── + +describe('markMigrationsApplied (PostgreSQL, drizzle-kit generated)', () => { + const sql = postgres(getDatabaseUrl()) + const db = drizzle({ client: sql }) + + const testSchema = 'drizzle_test_gen' + const testTable = '__drizzle_migrations_test_gen' + let generatedDir: string + + const executor: SqlExecutor = { + run: (query: string) => sql.unsafe(query).then(() => {}), + all: (query: string) => sql.unsafe(query) as Promise[]>, + } + + beforeAll(() => { + generatedDir = generateNewFormatMigrations('postgresql') + }) + + beforeEach(async () => { + await db.execute(`DROP TABLE IF EXISTS "${testSchema}"."${testTable}"`) + await db.execute(`DROP SCHEMA IF EXISTS "${testSchema}" CASCADE`) + }) + + afterAll(async () => { + await db.execute(`DROP TABLE IF EXISTS "${testSchema}"."${testTable}"`) + await db.execute(`DROP SCHEMA IF EXISTS "${testSchema}" CASCADE`) + await sql.end() + cleanupGeneratedMigrations(generatedDir) + }) + + it('reads and applies freshly generated migrations', async () => { + const entries = readMigrationEntries(generatedDir) + expect(entries).toHaveLength(2) + expect(entries[0]!.tag).toMatch(/_init$/) + expect(entries[1]!.tag).toMatch(/_add_users$/) + + const result = await markMigrationsApplied({ + migrationsFolder: generatedDir, + executor, + dialect: 'postgresql', + migrationsTable: testTable, + migrationsSchema: testSchema, + }) + + expect(result.total).toBe(2) + expect(result.applied).toBe(2) + expect(result.skipped).toBe(0) + + const rows = await sql.unsafe( + `SELECT hash, created_at FROM "${testSchema}"."${testTable}" ORDER BY id`, + ) + expect(rows).toHaveLength(2) + }) + + it('is idempotent with generated migrations', async () => { + await markMigrationsApplied({ + migrationsFolder: generatedDir, + executor, + dialect: 'postgresql', + migrationsTable: testTable, + migrationsSchema: testSchema, + }) + + const result = await markMigrationsApplied({ + migrationsFolder: generatedDir, + executor, + dialect: 'postgresql', + migrationsTable: testTable, + migrationsSchema: testSchema, + }) + + expect(result.applied).toBe(0) + expect(result.skipped).toBe(2) + }) +}) + // ─── CockroachDB SQL generation tests (mock executor) ─── describe('markMigrationsApplied (CockroachDB SQL generation)', () => { @@ -250,7 +460,7 @@ describe('markMigrationsApplied (CockroachDB SQL generation)', () => { // ─── CockroachDB integration tests (real database) ─── -describe('markMigrationsApplied (CockroachDB integration)', () => { +describe('markMigrationsApplied (CockroachDB integration, legacy format)', () => { const sql = postgres(getCockroachdbDatabaseUrl()) const testSchema = 'drizzle_test' @@ -320,6 +530,71 @@ describe('markMigrationsApplied (CockroachDB integration)', () => { }) }) +// ─── CockroachDB integration tests (new folder format) ─── + +describe('markMigrationsApplied (CockroachDB integration, new folder format)', () => { + const sql = postgres(getCockroachdbDatabaseUrl()) + + const testSchema = 'drizzle_test_new' + const testTable = '__drizzle_migrations_test_new' + + const executor: SqlExecutor = { + run: (query: string) => sql.unsafe(query).then(() => {}), + all: (query: string) => sql.unsafe(query) as Promise[]>, + } + + beforeEach(async () => { + await sql.unsafe(`DROP TABLE IF EXISTS "${testSchema}"."${testTable}"`) + await sql.unsafe(`DROP SCHEMA IF EXISTS "${testSchema}" CASCADE`) + }) + + afterAll(async () => { + await sql.unsafe(`DROP TABLE IF EXISTS "${testSchema}"."${testTable}"`) + await sql.unsafe(`DROP SCHEMA IF EXISTS "${testSchema}" CASCADE`) + await sql.end() + }) + + it('creates schema, table, and inserts all migrations from new format', async () => { + const result = await markMigrationsApplied({ + migrationsFolder: COCKROACHDB_NEW_FORMAT_DIR, + executor, + dialect: 'cockroachdb', + migrationsTable: testTable, + migrationsSchema: testSchema, + }) + + expect(result.total).toBe(2) + expect(result.applied).toBe(2) + expect(result.skipped).toBe(0) + + const rows = await sql.unsafe( + `SELECT hash, created_at FROM "${testSchema}"."${testTable}" ORDER BY id`, + ) + expect(rows).toHaveLength(2) + }) + + it('is idempotent with new format', async () => { + await markMigrationsApplied({ + migrationsFolder: COCKROACHDB_NEW_FORMAT_DIR, + executor, + dialect: 'cockroachdb', + migrationsTable: testTable, + migrationsSchema: testSchema, + }) + + const result = await markMigrationsApplied({ + migrationsFolder: COCKROACHDB_NEW_FORMAT_DIR, + executor, + dialect: 'cockroachdb', + migrationsTable: testTable, + migrationsSchema: testSchema, + }) + + expect(result.applied).toBe(0) + expect(result.skipped).toBe(2) + }) +}) + // ─── MySQL SQL generation tests (mock executor) ─── describe('markMigrationsApplied (MySQL SQL generation)', () => { @@ -393,9 +668,58 @@ describe('markMigrationsApplied (MySQL SQL generation)', () => { }) }) -// ─── MySQL integration tests (real database) ─── +// ─── MySQL SQL generation tests (new folder format, mock executor) ─── + +describe('markMigrationsApplied (MySQL SQL generation, new folder format)', () => { + it('generates MySQL-compatible SQL from new format folders', async () => { + const executedQueries: string[] = [] + const mockExecutor: SqlExecutor = { + run: (query: string) => { + executedQueries.push(query) + return Promise.resolve() + }, + all: () => Promise.resolve([]), + } + + await markMigrationsApplied({ + migrationsFolder: MYSQL_NEW_FORMAT_DIR, + executor: mockExecutor, + dialect: 'mysql', + }) + + expect(executedQueries.some((q) => q.includes('CREATE SCHEMA'))).toBe(false) + + const createTable = executedQueries.find((q) => q.includes('CREATE TABLE')) + expect(createTable).toContain('`__drizzle_migrations`') + + const inserts = executedQueries.filter((q) => q.includes('INSERT')) + expect(inserts).toHaveLength(2) + }) + + it('auto-detects mysql dialect from snapshot.json', async () => { + const executedQueries: string[] = [] + const mockExecutor: SqlExecutor = { + run: (query: string) => { + executedQueries.push(query) + return Promise.resolve() + }, + all: () => Promise.resolve([]), + } + + // No explicit dialect — should read from snapshot.json + await markMigrationsApplied({ + migrationsFolder: MYSQL_NEW_FORMAT_DIR, + executor: mockExecutor, + }) + + const createTable = executedQueries.find((q) => q.includes('CREATE TABLE')) + expect(createTable).toContain('`__drizzle_migrations`') + }) +}) + +// ─── MySQL integration tests (real database, legacy format) ─── -describe('markMigrationsApplied (MySQL integration)', () => { +describe('markMigrationsApplied (MySQL integration, legacy format)', () => { const testTable = '__drizzle_migrations_test' const pool = mysql.createPool(getMysqlDatabaseUrl()) @@ -455,3 +779,125 @@ describe('markMigrationsApplied (MySQL integration)', () => { expect(rows).toHaveLength(1) }) }) + +// ─── MySQL integration tests (real database, new folder format) ─── + +describe('markMigrationsApplied (MySQL integration, new folder format)', () => { + const testTable = '__drizzle_migrations_test_new' + const pool = mysql.createPool(getMysqlDatabaseUrl()) + + const executor: SqlExecutor = { + run: (query: string) => pool.execute(query).then(() => {}), + all: (query: string) => pool.execute(query).then(([rows]) => rows as Record[]), + } + + beforeEach(async () => { + await pool.execute(`DROP TABLE IF EXISTS \`${testTable}\``) + }) + + afterAll(async () => { + await pool.execute(`DROP TABLE IF EXISTS \`${testTable}\``) + await pool.end() + }) + + it('creates table and inserts all migrations from new format', async () => { + const result = await markMigrationsApplied({ + migrationsFolder: MYSQL_NEW_FORMAT_DIR, + executor, + dialect: 'mysql', + migrationsTable: testTable, + }) + + expect(result.total).toBe(2) + expect(result.applied).toBe(2) + expect(result.skipped).toBe(0) + expect(result.entries[0]!.tag).toMatch(/^\d{14}_init$/) + expect(result.entries[1]!.tag).toMatch(/^\d{14}_add_users$/) + }) + + it('is idempotent with new format', async () => { + await markMigrationsApplied({ + migrationsFolder: MYSQL_NEW_FORMAT_DIR, + executor, + dialect: 'mysql', + migrationsTable: testTable, + }) + + const result = await markMigrationsApplied({ + migrationsFolder: MYSQL_NEW_FORMAT_DIR, + executor, + dialect: 'mysql', + migrationsTable: testTable, + }) + + expect(result.applied).toBe(0) + expect(result.skipped).toBe(2) + + const [rows] = await pool.execute(`SELECT * FROM \`${testTable}\``) + expect(rows).toHaveLength(2) + }) +}) + +// ─── MySQL integration tests (drizzle-kit generated new format) ─── + +describe('markMigrationsApplied (MySQL, drizzle-kit generated)', () => { + const testTable = '__drizzle_migrations_test_gen' + const pool = mysql.createPool(getMysqlDatabaseUrl()) + let generatedDir: string + + const executor: SqlExecutor = { + run: (query: string) => pool.execute(query).then(() => {}), + all: (query: string) => pool.execute(query).then(([rows]) => rows as Record[]), + } + + beforeAll(() => { + generatedDir = generateNewFormatMigrations('mysql') + }) + + beforeEach(async () => { + await pool.execute(`DROP TABLE IF EXISTS \`${testTable}\``) + }) + + afterAll(async () => { + await pool.execute(`DROP TABLE IF EXISTS \`${testTable}\``) + await pool.end() + cleanupGeneratedMigrations(generatedDir) + }) + + it('reads and applies freshly generated MySQL migrations', async () => { + const entries = readMigrationEntries(generatedDir) + expect(entries).toHaveLength(2) + expect(entries[0]!.tag).toMatch(/_init$/) + expect(entries[1]!.tag).toMatch(/_add_users$/) + + const result = await markMigrationsApplied({ + migrationsFolder: generatedDir, + executor, + dialect: 'mysql', + migrationsTable: testTable, + }) + + expect(result.total).toBe(2) + expect(result.applied).toBe(2) + expect(result.skipped).toBe(0) + }) + + it('is idempotent with generated MySQL migrations', async () => { + await markMigrationsApplied({ + migrationsFolder: generatedDir, + executor, + dialect: 'mysql', + migrationsTable: testTable, + }) + + const result = await markMigrationsApplied({ + migrationsFolder: generatedDir, + executor, + dialect: 'mysql', + migrationsTable: testTable, + }) + + expect(result.applied).toBe(0) + expect(result.skipped).toBe(2) + }) +}) diff --git a/packages/app/drizzle-utils/src/markMigrationsApplied.ts b/packages/app/drizzle-utils/src/markMigrationsApplied.ts index 47c0332c5..6a4fa09b9 100644 --- a/packages/app/drizzle-utils/src/markMigrationsApplied.ts +++ b/packages/app/drizzle-utils/src/markMigrationsApplied.ts @@ -1,9 +1,11 @@ import { createHash } from 'node:crypto' -import { readFileSync } from 'node:fs' +import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join, resolve } from 'node:path' export type Dialect = 'postgresql' | 'mysql' | 'cockroachdb' +export type MigrationFormat = 'journal' | 'folder' + export interface MigrationJournalEntry { idx: number version: string @@ -56,12 +58,16 @@ export interface SqlExecutor { } export interface MarkMigrationsAppliedOptions { - /** Path to the Drizzle migrations folder (containing meta/_journal.json). */ + /** + * Path to the Drizzle migrations folder. + * Supports both the legacy journal format (meta/_journal.json with flat SQL files) + * and the new folder-per-migration format (drizzle-kit 1.0.0-beta). + */ migrationsFolder: string /** SQL executor for running queries against the database. */ executor: SqlExecutor /** - * Database dialect. If omitted, auto-detected from the journal's `dialect` field. + * Database dialect. If omitted, auto-detected from the journal or snapshot files. * Must be 'postgresql', 'mysql', or 'cockroachdb'. */ dialect?: Dialect @@ -93,6 +99,15 @@ export interface MarkMigrationsAppliedResult { const SUPPORTED_DIALECTS = new Set(['postgresql', 'mysql', 'cockroachdb']) +const DIALECT_ALIASES: Record = { + pg: 'postgresql', + postgres: 'postgresql', +} + +function normalizeDialect(dialect: string): string { + return DIALECT_ALIASES[dialect] ?? dialect +} + function isPgLike(dialect: Dialect): boolean { return dialect === 'postgresql' || dialect === 'cockroachdb' } @@ -116,34 +131,90 @@ export function computeMigrationHash(sqlContent: string): string { return createHash('sha256').update(sqlContent).digest('hex') } -/** Read and parse the Drizzle migration journal from a migrations folder. */ +/** Detect whether a migrations folder uses the legacy journal format or the new folder-per-migration format. */ +export function detectMigrationFormat(migrationsFolder: string): MigrationFormat { + const journalPath = join(resolve(migrationsFolder), 'meta', '_journal.json') + return existsSync(journalPath) ? 'journal' : 'folder' +} + +/** Read and parse the Drizzle migration journal from a migrations folder (legacy format only). */ export function readMigrationJournal(migrationsFolder: string): MigrationJournal { const journalPath = join(resolve(migrationsFolder), 'meta', '_journal.json') const content = readFileSync(journalPath, 'utf-8') return JSON.parse(content) as MigrationJournal } +function parseFolderTimestamp(folderName: string): number { + const match = folderName.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/) + if (!match) return 0 + const [, y, m, d, h, min, s] = match + return Date.UTC(Number(y), Number(m) - 1, Number(d), Number(h), Number(min), Number(s)) +} + +function readNewFormatMigrations(folder: string): { entries: MigrationEntry[]; dialect: string } { + const items = readdirSync(folder, { withFileTypes: true }) + + const migrationDirs = items + .filter((item) => item.isDirectory() && existsSync(join(folder, item.name, 'migration.sql'))) + .map((item) => item.name) + .sort() + + let dialect: string | undefined + const entries: MigrationEntry[] = [] + + for (const dir of migrationDirs) { + const sqlPath = join(folder, dir, 'migration.sql') + const snapshotPath = join(folder, dir, 'snapshot.json') + const sqlContent = readFileSync(sqlPath, 'utf-8') + + if (!dialect && existsSync(snapshotPath)) { + const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8')) + if (snapshot.dialect) { + dialect = normalizeDialect(snapshot.dialect as string) + } + } + + entries.push({ + tag: dir, + hash: computeMigrationHash(sqlContent), + createdAt: parseFolderTimestamp(dir), + }) + } + + return { entries, dialect: dialect ?? 'postgresql' } +} + +function readMigrationsWithDialect(folder: string): { entries: MigrationEntry[]; dialect: string } { + const format = detectMigrationFormat(folder) + + if (format === 'journal') { + const journal = readMigrationJournal(folder) + const entries = journal.entries.map((entry) => { + const sqlPath = join(folder, `${entry.tag}.sql`) + const sqlContent = readFileSync(sqlPath, 'utf-8') + return { + tag: entry.tag, + hash: computeMigrationHash(sqlContent), + createdAt: entry.when, + } + }) + return { entries, dialect: journal.dialect } + } + + return readNewFormatMigrations(folder) +} + /** * Read all migration entries from a Drizzle migrations folder, * computing the SHA-256 hash for each migration SQL file. + * Supports both the legacy journal format and the new folder-per-migration format. */ export function readMigrationEntries(migrationsFolder: string): MigrationEntry[] { - const folder = resolve(migrationsFolder) - const journal = readMigrationJournal(folder) - - return journal.entries.map((entry) => { - const sqlPath = join(folder, `${entry.tag}.sql`) - const sqlContent = readFileSync(sqlPath, 'utf-8') - return { - tag: entry.tag, - hash: computeMigrationHash(sqlContent), - createdAt: entry.when, - } - }) + return readMigrationsWithDialect(resolve(migrationsFolder)).entries } -function resolveDialect(journal: MigrationJournal, explicit?: Dialect): Dialect { - const dialect = explicit ?? journal.dialect +function resolveDialect(detectedDialect: string, explicit?: Dialect): Dialect { + const dialect = explicit ?? normalizeDialect(detectedDialect) if (!SUPPORTED_DIALECTS.has(dialect)) { throw new Error( @@ -166,6 +237,9 @@ function resolveDialect(journal: MigrationJournal, explicit?: Dialect): Dialect * * The function is idempotent — safe to run multiple times. * Run once per environment (local, staging, production) during the ORM transition. + * + * Supports both the legacy journal format (drizzle-kit 0.x) and the new + * folder-per-migration format (drizzle-kit 1.0.0-beta). */ export async function markMigrationsApplied( options: MarkMigrationsAppliedOptions, @@ -178,9 +252,8 @@ export async function markMigrationsApplied( } = options const folder = resolve(migrationsFolder) - const journal = readMigrationJournal(folder) - const dialect = resolveDialect(journal, options.dialect) - const entries = readMigrationEntries(folder) + const { entries, dialect: detectedDialect } = readMigrationsWithDialect(folder) + const dialect = resolveDialect(detectedDialect, options.dialect) if (entries.length === 0) { return { total: 0, applied: 0, skipped: 0, entries: [] } @@ -216,7 +289,17 @@ export async function markMigrationsApplied( continue } - // Hash is a hex string, createdAt is a number — safe to interpolate + if (!/^[0-9a-f]{64}$/.test(entry.hash)) { + throw new Error( + `Migration "${entry.tag}" has invalid hash "${entry.hash}" — expected a 64-character lowercase hex string from computeMigrationHash`, + ) + } + if (!Number.isFinite(entry.createdAt)) { + throw new Error( + `Migration "${entry.tag}" has invalid createdAt "${entry.createdAt}" — expected a finite number`, + ) + } + await executor.run( `INSERT INTO ${tableName} (hash, created_at) VALUES ('${entry.hash}', ${entry.createdAt})`, ) diff --git a/packages/app/drizzle-utils/test/fixtures/migrations-new-format-cockroachdb/20260328163300_init/migration.sql b/packages/app/drizzle-utils/test/fixtures/migrations-new-format-cockroachdb/20260328163300_init/migration.sql new file mode 100644 index 000000000..673a6e9a1 --- /dev/null +++ b/packages/app/drizzle-utils/test/fixtures/migrations-new-format-cockroachdb/20260328163300_init/migration.sql @@ -0,0 +1,4 @@ +CREATE TABLE "projects" ( + "id" serial PRIMARY KEY, + "name" text NOT NULL +); diff --git a/packages/app/drizzle-utils/test/fixtures/migrations-new-format-cockroachdb/20260328163300_init/snapshot.json b/packages/app/drizzle-utils/test/fixtures/migrations-new-format-cockroachdb/20260328163300_init/snapshot.json new file mode 100644 index 000000000..c86a1b0b2 --- /dev/null +++ b/packages/app/drizzle-utils/test/fixtures/migrations-new-format-cockroachdb/20260328163300_init/snapshot.json @@ -0,0 +1,49 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "c2a3d085-f330-44ba-9472-c09bae32acd4", + "prevIds": ["00000000-0000-0000-0000-000000000000"], + "ddl": [ + { + "isRlsEnabled": false, + "name": "projects", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "projects_pkey", + "schema": "public", + "table": "projects", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/app/drizzle-utils/test/fixtures/migrations-new-format-cockroachdb/20260328163325_add_users/migration.sql b/packages/app/drizzle-utils/test/fixtures/migrations-new-format-cockroachdb/20260328163325_add_users/migration.sql new file mode 100644 index 000000000..75725d2cb --- /dev/null +++ b/packages/app/drizzle-utils/test/fixtures/migrations-new-format-cockroachdb/20260328163325_add_users/migration.sql @@ -0,0 +1,7 @@ +CREATE TABLE "users" ( + "id" serial PRIMARY KEY, + "name" text NOT NULL, + "project_id" integer +); +--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_project_id_projects_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id"); \ No newline at end of file diff --git a/packages/app/drizzle-utils/test/fixtures/migrations-new-format-cockroachdb/20260328163325_add_users/snapshot.json b/packages/app/drizzle-utils/test/fixtures/migrations-new-format-cockroachdb/20260328163325_add_users/snapshot.json new file mode 100644 index 000000000..77f9a2508 --- /dev/null +++ b/packages/app/drizzle-utils/test/fixtures/migrations-new-format-cockroachdb/20260328163325_add_users/snapshot.json @@ -0,0 +1,115 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "72925665-fc9e-46e3-9cfd-4f43a90ff8c5", + "prevIds": ["c2a3d085-f330-44ba-9472-c09bae32acd4"], + "ddl": [ + { + "isRlsEnabled": false, + "name": "projects", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "users", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "project_id", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": false, + "columns": ["project_id"], + "schemaTo": "public", + "tableTo": "projects", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "users_project_id_projects_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "users" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "projects_pkey", + "schema": "public", + "table": "projects", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "users_pkey", + "schema": "public", + "table": "users", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/app/drizzle-utils/test/fixtures/migrations-new-format-mysql/20260328163343_init/migration.sql b/packages/app/drizzle-utils/test/fixtures/migrations-new-format-mysql/20260328163343_init/migration.sql new file mode 100644 index 000000000..aa340fae8 --- /dev/null +++ b/packages/app/drizzle-utils/test/fixtures/migrations-new-format-mysql/20260328163343_init/migration.sql @@ -0,0 +1,4 @@ +CREATE TABLE `projects` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `name` varchar(255) NOT NULL +); diff --git a/packages/app/drizzle-utils/test/fixtures/migrations-new-format-mysql/20260328163343_init/snapshot.json b/packages/app/drizzle-utils/test/fixtures/migrations-new-format-mysql/20260328163343_init/snapshot.json new file mode 100644 index 000000000..20abcfa55 --- /dev/null +++ b/packages/app/drizzle-utils/test/fixtures/migrations-new-format-mysql/20260328163343_init/snapshot.json @@ -0,0 +1,47 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "05aa0f1f-bfb3-431f-8d5e-fec66a5d0edd", + "prevIds": ["00000000-0000-0000-0000-000000000000"], + "ddl": [ + { + "name": "projects", + "entityType": "tables" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "projects" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "projects" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "projects", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/app/drizzle-utils/test/fixtures/migrations-new-format-mysql/20260328163417_add_users/migration.sql b/packages/app/drizzle-utils/test/fixtures/migrations-new-format-mysql/20260328163417_add_users/migration.sql new file mode 100644 index 000000000..0d105fa64 --- /dev/null +++ b/packages/app/drizzle-utils/test/fixtures/migrations-new-format-mysql/20260328163417_add_users/migration.sql @@ -0,0 +1,6 @@ +CREATE TABLE `users` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `name` varchar(255) NOT NULL, + `project_id` int, + CONSTRAINT `users_project_id_projects_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) +); diff --git a/packages/app/drizzle-utils/test/fixtures/migrations-new-format-mysql/20260328163417_add_users/snapshot.json b/packages/app/drizzle-utils/test/fixtures/migrations-new-format-mysql/20260328163417_add_users/snapshot.json new file mode 100644 index 000000000..75b7bff9f --- /dev/null +++ b/packages/app/drizzle-utils/test/fixtures/migrations-new-format-mysql/20260328163417_add_users/snapshot.json @@ -0,0 +1,110 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "4883e037-b439-4dd4-8823-9cea12210c3d", + "prevIds": ["05aa0f1f-bfb3-431f-8d5e-fec66a5d0edd"], + "ddl": [ + { + "name": "projects", + "entityType": "tables" + }, + { + "name": "users", + "entityType": "tables" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "projects" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "projects" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": true, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "users" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "users" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "users" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "projects", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "users", + "entityType": "pks" + }, + { + "columns": ["project_id"], + "tableTo": "projects", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "users_project_id_projects_id_fkey", + "entityType": "fks", + "table": "users" + } + ], + "renames": [] +} diff --git a/packages/app/drizzle-utils/test/fixtures/migrations-new-format/20260328163300_init/migration.sql b/packages/app/drizzle-utils/test/fixtures/migrations-new-format/20260328163300_init/migration.sql new file mode 100644 index 000000000..673a6e9a1 --- /dev/null +++ b/packages/app/drizzle-utils/test/fixtures/migrations-new-format/20260328163300_init/migration.sql @@ -0,0 +1,4 @@ +CREATE TABLE "projects" ( + "id" serial PRIMARY KEY, + "name" text NOT NULL +); diff --git a/packages/app/drizzle-utils/test/fixtures/migrations-new-format/20260328163300_init/snapshot.json b/packages/app/drizzle-utils/test/fixtures/migrations-new-format/20260328163300_init/snapshot.json new file mode 100644 index 000000000..c86a1b0b2 --- /dev/null +++ b/packages/app/drizzle-utils/test/fixtures/migrations-new-format/20260328163300_init/snapshot.json @@ -0,0 +1,49 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "c2a3d085-f330-44ba-9472-c09bae32acd4", + "prevIds": ["00000000-0000-0000-0000-000000000000"], + "ddl": [ + { + "isRlsEnabled": false, + "name": "projects", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "projects_pkey", + "schema": "public", + "table": "projects", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/app/drizzle-utils/test/fixtures/migrations-new-format/20260328163325_add_users/migration.sql b/packages/app/drizzle-utils/test/fixtures/migrations-new-format/20260328163325_add_users/migration.sql new file mode 100644 index 000000000..75725d2cb --- /dev/null +++ b/packages/app/drizzle-utils/test/fixtures/migrations-new-format/20260328163325_add_users/migration.sql @@ -0,0 +1,7 @@ +CREATE TABLE "users" ( + "id" serial PRIMARY KEY, + "name" text NOT NULL, + "project_id" integer +); +--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_project_id_projects_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id"); \ No newline at end of file diff --git a/packages/app/drizzle-utils/test/fixtures/migrations-new-format/20260328163325_add_users/snapshot.json b/packages/app/drizzle-utils/test/fixtures/migrations-new-format/20260328163325_add_users/snapshot.json new file mode 100644 index 000000000..77f9a2508 --- /dev/null +++ b/packages/app/drizzle-utils/test/fixtures/migrations-new-format/20260328163325_add_users/snapshot.json @@ -0,0 +1,115 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "72925665-fc9e-46e3-9cfd-4f43a90ff8c5", + "prevIds": ["c2a3d085-f330-44ba-9472-c09bae32acd4"], + "ddl": [ + { + "isRlsEnabled": false, + "name": "projects", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "users", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "project_id", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": false, + "columns": ["project_id"], + "schemaTo": "public", + "tableTo": "projects", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "users_project_id_projects_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "users" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "projects_pkey", + "schema": "public", + "table": "projects", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "users_pkey", + "schema": "public", + "table": "users", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/packages/app/drizzle-utils/test/generateMigrations.ts b/packages/app/drizzle-utils/test/generateMigrations.ts new file mode 100644 index 000000000..04bf31945 --- /dev/null +++ b/packages/app/drizzle-utils/test/generateMigrations.ts @@ -0,0 +1,120 @@ +import { execSync } from 'node:child_process' +import { mkdtempSync, unlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { FileTestHelper } from 'cli-testlab' + +const PROJECT_ROOT = resolve(__dirname, '..') +const FIXTURES_DIR = join(PROJECT_ROOT, 'test', 'fixtures') +const NODE_MODULES = join(PROJECT_ROOT, 'node_modules') + +const PG_SCHEMA_V1 = ` +import { serial, text, pgTable } from 'drizzle-orm/pg-core' + +export const projects = pgTable('projects', { + id: serial().primaryKey(), + name: text().notNull(), +}) +` + +const PG_SCHEMA_V2 = ` +import { serial, text, integer, pgTable } from 'drizzle-orm/pg-core' + +export const projects = pgTable('projects', { + id: serial().primaryKey(), + name: text().notNull(), +}) + +export const users = pgTable('users', { + id: serial().primaryKey(), + name: text().notNull(), + project_id: integer('project_id').references(() => projects.id), +}) +` + +const MYSQL_SCHEMA_V1 = ` +import { int, varchar, mysqlTable } from 'drizzle-orm/mysql-core' + +export const projects = mysqlTable('projects', { + id: int().autoincrement().primaryKey(), + name: varchar({ length: 255 }).notNull(), +}) +` + +const MYSQL_SCHEMA_V2 = ` +import { int, varchar, mysqlTable } from 'drizzle-orm/mysql-core' + +export const projects = mysqlTable('projects', { + id: int().autoincrement().primaryKey(), + name: varchar({ length: 255 }).notNull(), +}) + +export const users = mysqlTable('users', { + id: int().autoincrement().primaryKey(), + name: varchar({ length: 255 }).notNull(), + project_id: int('project_id').references(() => projects.id), +}) +` + +/** + * Use drizzle-kit to generate real new-format (folder-per-migration) migrations. + * Generates two migrations: "init" (projects table) and "add_users" (adds users table). + * Returns the path to the output directory containing the migration folders. + * + * Schema and config files are written to the project directory (where node_modules lives) + * so drizzle-kit can resolve drizzle-orm imports. They are cleaned up after generation. + */ +export function generateNewFormatMigrations(dialect: 'postgresql' | 'mysql'): string { + const id = `${dialect}_${Date.now()}` + const outDir = mkdtempSync(join(tmpdir(), 'drizzle-migrations-')) + const schemaPath = join(FIXTURES_DIR, `_gen_schema_${id}.ts`) + const configFileName = `_gen_drizzle_${id}.config.ts` + const configPath = join(PROJECT_ROOT, configFileName) + + const [v1, v2] = + dialect === 'postgresql' ? [PG_SCHEMA_V1, PG_SCHEMA_V2] : [MYSQL_SCHEMA_V1, MYSQL_SCHEMA_V2] + + const outDirForConfig = outDir.replace(/\\/g, '/') + writeFileSync( + configPath, + `export default { dialect: '${dialect}', schema: './test/fixtures/_gen_schema_${id}.ts', out: '${outDirForConfig}' }\n`, + ) + + try { + writeFileSync(schemaPath, v1) + execSync(`npx drizzle-kit generate --name=init --config=${configFileName}`, { + cwd: PROJECT_ROOT, + stdio: 'pipe', + timeout: 30000, + env: { ...process.env, NODE_PATH: NODE_MODULES }, + }) + + writeFileSync(schemaPath, v2) + execSync(`npx drizzle-kit generate --name=add_users --config=${configFileName}`, { + cwd: PROJECT_ROOT, + stdio: 'pipe', + timeout: 30000, + env: { ...process.env, NODE_PATH: NODE_MODULES }, + }) + } finally { + try { + unlinkSync(schemaPath) + } catch (err) { + // biome-ignore lint/suspicious/noConsole: test infrastructure — warn on cleanup failure to aid debugging flaky tests + console.warn(`Failed to clean up schema file ${schemaPath}:`, err) + } + try { + unlinkSync(configPath) + } catch (err) { + // biome-ignore lint/suspicious/noConsole: test infrastructure — warn on cleanup failure to aid debugging flaky tests + console.warn(`Failed to clean up config file ${configPath}:`, err) + } + } + + return outDir +} + +export function cleanupGeneratedMigrations(outDir: string): void { + const helper = new FileTestHelper() + helper.deleteDir(outDir, { isPathAbsolute: true, maxRetries: 3, retryDelay: 100 }) +}