Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/app/drizzle-utils/.env.test
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
DATABASE_URL=postgresql://testuser:pass@localhost:5432/test
MYSQL_DATABASE_URL=mysql://root:pass@localhost:3306/test
95 changes: 95 additions & 0 deletions packages/app/drizzle-utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,101 @@ Peer dependency: `drizzle-orm` (<2.0.0)

## Usage

### markMigrationsApplied

Sets the Drizzle migration baseline for an existing database.

#### Problem

When migrating from another ORM (e.g. Prisma, TypeORM, Sequelize, or raw SQL migrations) to Drizzle, you face a chicken-and-egg problem:

1. Your database already has the correct schema — tables, columns, indexes, etc. are all in place, created and maintained by the previous ORM.
2. You generate Drizzle migration files from your new Drizzle schema (`drizzle-kit generate`), but these migrations describe creating tables that already exist.
3. Running `drizzle-kit migrate` would fail, because it tries to execute `CREATE TABLE` statements against tables that are already there.

You need a way to tell Drizzle: "these migrations are already reflected in the database — don't run them, just record them as done."

#### Solution

`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
- 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 both **PostgreSQL** and **MySQL**, with auto-detection from the journal

#### CLI

If you already have a `drizzle.config.ts` with `dbCredentials`, you can run the baseline directly:

```bash
npx @lokalise/drizzle-utils mark-migrations-applied ./drizzle.config.ts
```

For a full step-by-step migration guide, see [Migrating from Prisma to Drizzle](docs/migrating-from-prisma.md).

#### PostgreSQL example (postgres.js)

```typescript
import { markMigrationsApplied } from '@lokalise/drizzle-utils'
import postgres from 'postgres'

const sql = postgres(DATABASE_URL)

const result = await markMigrationsApplied({
migrationsFolder: './drizzle/migrations',
executor: {
run: (query) => sql.unsafe(query).then(() => {}),
all: (query) => sql.unsafe(query) as Promise<Record<string, unknown>[]>,
},
})

console.log(`Applied: ${result.applied}, Skipped: ${result.skipped}`)
await sql.end()
```

#### MySQL example (mysql2)

```typescript
import { markMigrationsApplied } from '@lokalise/drizzle-utils'
import mysql from 'mysql2/promise'

const connection = await mysql.createConnection(DATABASE_URL)

const result = await markMigrationsApplied({
migrationsFolder: './drizzle/migrations',
executor: {
run: (query) => connection.execute(query).then(() => {}),
all: (query) => connection.execute(query).then(([rows]) => rows as Record<string, unknown>[]),
},
})

console.log(`Applied: ${result.applied}, Skipped: ${result.skipped}`)
await connection.end()
```

#### Options

| Option | Type | Default | Description |
|---|---|---|---|
| `migrationsFolder` | `string` | *(required)* | Path to the Drizzle migrations folder (containing `meta/_journal.json`) |
| `executor` | `SqlExecutor` | *(required)* | Object with `run(sql)` and `all(sql)` methods for executing raw SQL |
| `dialect` | `'postgresql' \| 'mysql'` | *(auto-detected)* | Database dialect. Auto-detected from the journal's `dialect` field if omitted |
| `migrationsTable` | `string` | `'__drizzle_migrations'` | Name of the migrations tracking table |
| `migrationsSchema` | `string` | `'drizzle'` | Schema for the migrations table (PostgreSQL only) |

#### Helper functions

`readMigrationJournal(migrationsFolder)` — reads and parses `meta/_journal.json`.

`readMigrationEntries(migrationsFolder)` — reads all migration entries with their computed SHA-256 hashes.

`computeMigrationHash(sqlContent)` — computes the SHA-256 hash of a migration SQL string, matching Drizzle's internal algorithm.

---

### drizzleFullBulkUpdate

Performs efficient bulk updates using a single SQL query with a `VALUES` clause. This is more efficient than executing multiple individual UPDATE statements and more effective than INSERT ON CONFLICT UPDATE (UPSERT) for update-only operations.
Expand Down
16 changes: 16 additions & 0 deletions packages/app/drizzle-utils/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,19 @@ services:
interval: 1s
timeout: 5s
retries: 10

mysql:
image: mysql:8.0.40
command: '--default-authentication-plugin=mysql_native_password'
ports:
- '3306:3306'
volumes:
- ./test/fixtures/mysql-init.sql:/docker-entrypoint-initdb.d/init.sql
environment:
- MYSQL_ROOT_PASSWORD=pass
- MYSQL_DATABASE=test
healthcheck:
test: ['CMD', 'mysql', '-h', '127.0.0.1', '-u', 'root', '-ppass', '-e', 'SELECT 1']
interval: 2s
timeout: 5s
retries: 30
108 changes: 108 additions & 0 deletions packages/app/drizzle-utils/docs/migrating-from-prisma.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Migrating from Prisma to Drizzle

This is the recommended workflow when migrating from Prisma. The same approach applies to any ORM.

## Step 1: Install Drizzle

```bash
npm install drizzle-orm drizzle-kit
```

## Step 2: Create your Drizzle schema

You have three options:

- **Introspect from the database** (recommended): Run `npx drizzle-kit introspect` to generate a Drizzle schema from your existing database. This is the safest option because it reflects the actual database state, not the Prisma schema which may have drifted.
- **Convert from Prisma schema manually**: Rewrite your `schema.prisma` models as Drizzle table definitions. Be careful to match column types, defaults, and constraints exactly.
- **Use a Prisma generator**: Community tools like [`prisma-generator-drizzle`](https://github.com/fdarian/prisma-generator-drizzle) can generate a Drizzle schema from your `schema.prisma`, but always review the output.

See also: [Drizzle official guide — Migrate from Prisma](https://orm.drizzle.team/docs/migrate/migrate-from-prisma)

## Step 3: Configure `drizzle.config.ts`

```typescript
import { defineConfig } from 'drizzle-kit'

export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle/migrations',
dialect: 'postgresql', // or 'mysql'
})
```

## Step 4: Generate the initial migration

```bash
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.

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)

Since your `drizzle.config.ts` already has the connection details and migrations folder, just run the CLI:

```bash
npx @lokalise/drizzle-utils mark-migrations-applied ./drizzle.config.ts
```

The CLI reads `dialect`, `dbCredentials`, and `out` from your config, connects to the database, and marks all existing migrations as applied.

Run this **once per environment** (local, staging, production). The command is idempotent, so running it again is safe — already-tracked migrations are skipped.

<details>
<summary>Alternative: use the function directly in a script</summary>

If you need more control (e.g. custom table name, schema, or executor), create a one-time script:

```typescript
import { markMigrationsApplied } from '@lokalise/drizzle-utils'
import postgres from 'postgres'

const sql = postgres(process.env.DATABASE_URL!)

const result = await markMigrationsApplied({
migrationsFolder: './drizzle/migrations',
executor: {
run: (query) => sql.unsafe(query).then(() => {}),
all: (query) => sql.unsafe(query) as Promise<Record<string, unknown>[]>,
},
})

console.log(`Baseline complete — Applied: ${result.applied}, Skipped: ${result.skipped}`)
await sql.end()
```

</details>

## Step 6: Verify the baseline

```bash
npx drizzle-kit migrate
```

This should be a **no-op** — all migrations are already tracked, so nothing is executed. If Drizzle tries to run migrations here, your baseline was not applied correctly.

## Step 7: Remove Prisma and deploy

- Remove `prisma` and `@prisma/client` from your dependencies
- Delete `schema.prisma` and the `prisma/` migrations folder
- Replace all `PrismaClient` usage with Drizzle queries
- Drop the Prisma shadow database and `_prisma_migrations` table when you are confident the migration is complete

From this point on, all new schema changes go through the normal Drizzle workflow:

```bash
# Edit your Drizzle schema, then:
npx drizzle-kit generate # generates a new migration
npx drizzle-kit migrate # applies only the new migration
```

## Important notes

- **Run the baseline before `drizzle-kit migrate`**: If you run `migrate` first, Drizzle will attempt to execute `CREATE TABLE` statements and fail.
- **Schema drift**: If your Prisma schema and actual database have drifted apart, use `drizzle-kit introspect` rather than converting from Prisma — the database is the source of truth.
- **Parallel ORM usage**: During the transition you can run Prisma and Drizzle side-by-side. Just make sure all new migrations go through one ORM only (Drizzle) to avoid conflicts.
- **CI/CD**: Add the baseline script to your deployment pipeline so it runs before `drizzle-kit migrate`. Since it's idempotent, it's safe to run on every deploy.
21 changes: 18 additions & 3 deletions packages/app/drizzle-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
"README.md",
"LICENSE.md"
],
"bin": {
"mark-migrations-applied": "./dist/cli/markMigrationsApplied.js"
},
"publishConfig": {
"access": "public"
},
Expand All @@ -27,23 +30,35 @@
"lint": "biome check . && tsc",
"lint:fix": "biome check --write",
"test": "vitest run",
"pretest:ci": "docker compose up -d --wait",
"pretest:ci": "docker compose up -d --quiet-pull --wait",
"test:ci": "npm run test -- --coverage",
"posttest:ci": "docker compose down",
"prepublishOnly": "npm run build",
"package-version": "echo $npm_package_version",
"postversion": "biome check --write package.json"
},
"peerDependencies": {
"drizzle-orm": "<2.0.0"
"drizzle-orm": "<2.0.0",
"mysql2": ">=3.0.0",
"postgres": ">=3.0.0"
},
"peerDependenciesMeta": {
"postgres": {
"optional": true
},
"mysql2": {
"optional": true
}
},
"devDependencies": {
"@biomejs/biome": "^2.3.7",
"@lokalise/biome-config": "^3.1.0",
"@lokalise/tsconfig": "^3.1.0",
"@vitest/coverage-v8": "^4.0.15",
"cli-testlab": "^6.0.0",
"cross-env": "^10.0.0",
"drizzle-orm": "^0.45.1",
"drizzle-orm": "1.0.0-beta.19",
"mysql2": "^3.14.0",
"postgres": "^3.4.8",
"rimraf": "^6.1.3",
"typescript": "5.9.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { resolve } from 'node:path'
import { execCommand } from 'cli-testlab'
import { describe, it } from 'vitest'

const CLI_PATH = resolve(__dirname, 'markMigrationsApplied.ts')
const FIXTURES_DIR = resolve(__dirname, '../../test/fixtures')
const CLI = `node --experimental-strip-types ${CLI_PATH}`

describe('mark-migrations-applied CLI', () => {
describe('argument validation', () => {
it('shows usage and exits with error when no arguments provided', async () => {
await execCommand(CLI, {
expectedErrorMessage: 'Usage:',
})
})

it('shows help with --help flag', async () => {
await execCommand(`${CLI} --help`, {
expectedOutput: ['Usage:', '--help'],
})
})

it('shows help with -h flag', async () => {
await execCommand(`${CLI} -h`, {
expectedOutput: 'Usage:',
})
})
})

describe('config validation', () => {
it('exits with error for unsupported dialect', async () => {
await execCommand(`${CLI} ${FIXTURES_DIR}/drizzle-invalid-dialect.config.ts`, {
expectedErrorMessage: 'Unsupported or missing dialect',
})
})

it('exits with error when dbCredentials is missing', async () => {
await execCommand(`${CLI} ${FIXTURES_DIR}/drizzle-no-credentials.config.ts`, {
expectedErrorMessage: 'Missing dbCredentials',
})
})

it('exits with error for non-existent config file', async () => {
await execCommand(`${CLI} ./nonexistent.config.ts`, {
expectedErrorMessage: 'nonexistent',
})
})
})

describe('PostgreSQL integration', () => {
it('marks migrations as applied', async () => {
await execCommand(`${CLI} ${FIXTURES_DIR}/drizzle-pg-url.config.ts`, {
expectedOutput: ['Dialect: postgresql', 'Done', '0000_init', '0001_add_users'],
env: { DATABASE_URL: process.env.DATABASE_URL },
})
})

it('is idempotent — skips already applied migrations on second run', async () => {
await execCommand(`${CLI} ${FIXTURES_DIR}/drizzle-pg-url.config.ts`, {
expectedOutput: ['Skipped: 2'],
env: { DATABASE_URL: process.env.DATABASE_URL },
})
})
})

describe('MySQL integration', () => {
it('marks migrations as applied', async () => {
await execCommand(`${CLI} ${FIXTURES_DIR}/drizzle-mysql-url.config.ts`, {
expectedOutput: ['Dialect: mysql', 'Done', '0000_init'],
env: { MYSQL_DATABASE_URL: process.env.MYSQL_DATABASE_URL },
})
})

it('is idempotent — skips already applied migrations on second run', async () => {
await execCommand(`${CLI} ${FIXTURES_DIR}/drizzle-mysql-url.config.ts`, {
expectedOutput: ['Skipped: 1'],
env: { MYSQL_DATABASE_URL: process.env.MYSQL_DATABASE_URL },
})
})
})
})
Loading
Loading