Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/cli-drizzle-migrations-out.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'stash': patch
---

`stash encrypt drop` and `stash decrypt drop` now write the Drizzle migration to the directory drizzle-kit reports (the `out` in `drizzle.config.ts`) instead of scanning `--migrations-dir`. Previously, when the two differed, the SQL could be written over an older migration with the same name while drizzle-kit journaled an empty stub in the real `out`, so the DROP never shipped. When a scan fallback is needed it matches the exact `_<name>.sql` suffix and refuses to overwrite any file that is not a drizzle-kit scaffold (blank or comment-only), and `--migrations-dir` now warns when drizzle-kit wrote elsewhere.
6 changes: 4 additions & 2 deletions languages/typescript/packages/cli/src/cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,8 @@ export const registry: CommandGroup[] = [
{
name: '--migrations-dir',
value: '<path>',
description: 'Directory to write the drop migration into.',
description:
'Directory to write the drop migration into (Drizzle projects: drizzle.config.ts `out` wins; this is the fallback).',
},
],
},
Expand Down Expand Up @@ -773,7 +774,8 @@ export const registry: CommandGroup[] = [
{
name: '--migrations-dir',
value: '<path>',
description: 'Directory to write the drop migration into.',
description:
'Directory to write the drop migration into (Drizzle projects: drizzle.config.ts `out` wins; this is the fallback).',
},
{
name: '--uninstall-eql',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { findGeneratedMigration } from '../../eql/migration.js'
import { findGeneratedMigration } from '../drizzle-migration.js'

/**
* `findGeneratedMigration` locates the custom migration scaffolded by
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { existsSync } from 'node:fs'
import { readdir } from 'node:fs/promises'
import { isAbsolute, join, resolve } from 'node:path'

/**
* Helpers for locating the file `drizzle-kit generate --custom` just wrote.
* Shared by `eql migration --drizzle` and the `encrypt drop` / `decrypt`
* scaffolds, so both agree on where drizzle-kit's `drizzle.config.ts` sent it.
*/

/**
* Find the most recently generated Drizzle migration matching the name.
*
* Matches the exact `_<name>.sql` suffix, so a longer name that merely contains
* the stem (`drop_users_email_backup`) is never mistaken for it.
*/
export async function findGeneratedMigration(
outDir: string,
migrationName: string,
): Promise<string> {
if (!existsSync(outDir)) {
throw new Error(
`Drizzle output directory not found: ${outDir}\nMake sure drizzle-kit is configured correctly.`,
)
}
const migrationSuffix = `_${migrationName}.sql`
const matchingFiles = (await readdir(outDir))
.filter((entry) => entry.endsWith(migrationSuffix))
.sort()
if (matchingFiles.length === 0) {
throw new Error(
`Could not find a migration matching "${migrationName}" in ${outDir}`,
)
}
return join(outDir, matchingFiles[matchingFiles.length - 1])
}

// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ESC is the point
const ANSI_ESCAPE = /\u001b\[[0-9;]*[A-Za-z]/g

function stripAnsi(text: string): string {
return text.replace(ANSI_ESCAPE, '')
}

/**
* Pull the migration path out of drizzle-kit's own success line:
*
* [✓] Your SQL migration file ➜ drizzle/0000_install-eql.sql 🚀
*
* Since we no longer pass `--out` (drizzle-kit rejects the run when we do —
* see `generateDrizzleEqlMigration`), `drizzle.config.ts` owns the output
* directory and this line is how we learn it. The path is printed relative to
* the cwd drizzle-kit ran in, which is ours.
*
* Returns `undefined` unless the file is actually there, so an unrecognised
* banner or a moved file falls through to the directory scan rather than
* pointing the SQL write at a path that does not exist.
*/
export function parseReportedMigrationPath(
stdout: string | undefined,
): string | undefined {
if (!stdout) return undefined
// Scanned per line, taking everything between the arrow and the LAST `.sql`
// on it, because an output directory may contain spaces — a `\S+` pattern
// truncates such a path, fails the existence check, and drops us into the
// fallback scan of an unrelated directory.
//
// Last line wins: one invocation writes one file, but a wrapper that echoes
// its own banner first should not shadow drizzle-kit's.
let found: string | undefined
for (const line of stdout.split(/\r?\n/)) {
const arrow = line.indexOf('➜')
if (arrow === -1) continue
// drizzle-kit colours its banner on a TTY-like stdout; escape codes inside
// the path would fail the existence check and drop us into the fallback.
const rest = stripAnsi(line.slice(arrow + 1))
const end = rest.lastIndexOf('.sql')
if (end === -1) continue
const candidate = rest.slice(0, end + '.sql'.length).trim()
if (!candidate) continue
const abs = isAbsolute(candidate) ? candidate : resolve(candidate)
if (existsSync(abs)) found = abs
}
return found
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const spawnSyncMock = vi.hoisted(() => vi.fn())
vi.mock('node:child_process', () => ({ spawnSync: spawnSyncMock }))
vi.mock('@/commands/init/utils.js', () => ({
detectPackageManager: () => 'npm',
runnerArgv: () => ({ command: 'npx', prefixArgs: [] }),
}))

import { scaffoldDrizzleMigration } from '../drizzle-helper.js'

/**
* drizzle.config.ts's `out` owns where drizzle-kit writes. The scaffold must
* follow the path drizzle-kit reports, never the --migrations-dir it scans as
* a fallback (CIP-5162).
*/
// What drizzle-kit's `generate --custom` actually writes.
const PLACEHOLDER = '-- Custom SQL migration file, put your code below! --\n'

describe('scaffoldDrizzleMigration', () => {
let root: string
let cwd: string
beforeEach(() => {
// realpath: process.cwd() reports the physical path after chdir, so a
// symlinked TMPDIR would otherwise break the expected-path assertions.
root = realpathSync(mkdtempSync(join(tmpdir(), 'stash-scaffold-')))
cwd = process.cwd()
process.chdir(root)
spawnSyncMock.mockReset()
})
afterEach(() => {
process.chdir(cwd)
rmSync(root, { recursive: true, force: true })
})

function drizzleKitWrites(relPath: string) {
spawnSyncMock.mockImplementation(() => {
mkdirSync(join(relPath, '..'), { recursive: true })
writeFileSync(relPath, PLACEHOLDER)
return {
status: 0,
stdout: `[✓] Your SQL migration file ➜ ${relPath} 🚀\n`,
stderr: '',
}
})
}

it('writes to the directory drizzle-kit reports, not the scanned one', async () => {
mkdirSync('drizzle')
writeFileSync(
'drizzle/0000_drop_users_email.sql',
'ALTER TABLE t DROP COLUMN c;',
)
drizzleKitWrites('src/db/migrations/0003_drop_users_email.sql')

const result = await scaffoldDrizzleMigration({
name: 'drop_users_email',
outDir: 'drizzle',
sql: 'ALTER TABLE users DROP COLUMN email;',
})

expect(result.path).toBe(
join(root, 'src/db/migrations/0003_drop_users_email.sql'),
)
expect(readFileSync(result.path, 'utf-8')).toContain('DROP COLUMN email')
expect(readFileSync('drizzle/0000_drop_users_email.sql', 'utf-8')).toBe(
'ALTER TABLE t DROP COLUMN c;',
)
expect(result.dir).not.toBe(result.requestedDir)
})

it('does not require the fallback directory to exist when the path is reported', async () => {
drizzleKitWrites('db/0000_drop_a.sql')
const result = await scaffoldDrizzleMigration({
name: 'drop_a',
outDir: 'drizzle',
sql: 'SELECT 1;',
})
expect(existsSync(result.path)).toBe(true)
})

it('falls back to the scan when the banner is unrecognised, taking only a new exact-suffix file', async () => {
mkdirSync('drizzle')
writeFileSync('drizzle/0000_drop_a.sql', 'ALTER TABLE t DROP COLUMN c;')
spawnSyncMock.mockImplementation(() => {
writeFileSync('drizzle/0001_drop_a.sql', PLACEHOLDER)
writeFileSync('drizzle/0002_drop_a_backup.sql', '')
return { status: 0, stdout: 'done', stderr: '' }
})
const result = await scaffoldDrizzleMigration({
name: 'drop_a',
outDir: 'drizzle',
sql: 'SELECT 1;',
})
expect(result.path).toBe(join(root, 'drizzle/0001_drop_a.sql'))
expect(readFileSync('drizzle/0000_drop_a.sql', 'utf-8')).toBe(
'ALTER TABLE t DROP COLUMN c;',
)
})

it('refuses to overwrite a pre-existing same-stem file when nothing new appeared', async () => {
mkdirSync('drizzle')
writeFileSync('drizzle/0000_drop_a.sql', 'ALTER TABLE t DROP COLUMN c;')
spawnSyncMock.mockReturnValue({ status: 0, stdout: 'done', stderr: '' })
await expect(
scaffoldDrizzleMigration({
name: 'drop_a',
outDir: 'drizzle',
sql: 'SELECT 1;',
}),
).rejects.toThrow(/Refusing to overwrite/)
expect(readFileSync('drizzle/0000_drop_a.sql', 'utf-8')).toBe(
'ALTER TABLE t DROP COLUMN c;',
)
})

it('follows a reported path even when the banner is colourised', async () => {
mkdirSync('db')
spawnSyncMock.mockImplementation(() => {
writeFileSync('db/0000_drop_a.sql', PLACEHOLDER)
return {
status: 0,
stdout:
'[✓] Your SQL migration file \u001b[2m➜\u001b[0m \u001b[1mdb/0000_drop_a.sql\u001b[0m 🚀',
stderr: '',
}
})
const result = await scaffoldDrizzleMigration({
name: 'drop_a',
outDir: 'drizzle',
sql: 'SELECT 1;',
})
expect(result.path).toBe(join(root, 'db/0000_drop_a.sql'))
})

it('refuses to overwrite a reported file that already has content', async () => {
mkdirSync('db')
writeFileSync('db/0000_old.sql', 'ALTER TABLE t DROP COLUMN c;')
spawnSyncMock.mockReturnValue({
status: 0,
stdout: '➜ db/0000_old.sql',
stderr: '',
})
await expect(
scaffoldDrizzleMigration({
name: 'x',
outDir: 'drizzle',
sql: 'SELECT 1;',
}),
).rejects.toThrow(/not a drizzle-kit scaffold/)
expect(readFileSync('db/0000_old.sql', 'utf-8')).toBe(
'ALTER TABLE t DROP COLUMN c;',
)
})

it('surfaces drizzle-kit stdout and stderr on failure', async () => {
spawnSyncMock.mockReturnValue({
status: 1,
stdout: 'config error',
stderr: 'boom',
})
await expect(
scaffoldDrizzleMigration({ name: 'x', outDir: 'drizzle', sql: '' }),
).rejects.toThrow(/config error\nboom/)
})
})
Loading
Loading