@nuxthub/core
Version:
Build full-stack Nuxt applications, with zero configuration.
216 lines (185 loc) • 7.6 kB
JavaScript
import { defineCommand } from 'citty'
import { consola } from 'consola'
import { execa } from 'execa'
import { readFile, writeFile, rm } from 'node:fs/promises'
import { join, resolve } from 'pathe'
import { buildDatabaseSchema, createDrizzleClient } from '@nuxthub/core/db'
import { sql } from 'drizzle-orm'
import { getTsconfigAliases } from '../../utils/db.mjs'
export default defineCommand({
meta: {
name: 'squash',
description: 'Squash the last X (or selected) migrations into a single migration.'
},
args: {
last: {
type: 'string',
description: 'Number of migrations to squash starting from most recently applied. If not specified migrations can be interactively selected.',
required: false
},
cwd: {
type: 'option',
description: 'The directory to run the command in.',
required: false
},
verbose: {
alias: 'v',
type: 'boolean',
description: 'Show verbose output.',
required: false
}
},
async run({ args }) {
if (args.verbose) {
consola.level = 4
}
const cwd = args.cwd ? resolve(process.cwd(), args.cwd) : process.cwd()
const execaOptions = {
stdout: 'pipe',
stderr: 'pipe',
preferLocal: true,
cwd
}
// Parse the number of migrations to drop
const dropCount = args.last ? Number.parseInt(args.last, 10) : null
if (dropCount !== null && (Number.isNaN(dropCount) || dropCount < 1)) {
consola.error('Invalid value for --last. Please provide a positive number.')
process.exit(1)
}
// Ensure database schema is ready and get config
consola.info('Preparing database schema...')
await execa(execaOptions)`nuxt prepare`
const hubConfig = JSON.parse(await readFile(join(cwd, '.nuxt/hub/db/config.json'), 'utf-8'))
const migrationsDir = join(hubConfig.db.migrationsDirs?.[0], hubConfig.db.dialect)
if (!migrationsDir) {
consola.error('No migrations directory found in hub config.')
process.exit(1)
}
// Read the journal
const journalPath = join(migrationsDir, 'meta', '_journal.json')
let journal
try {
journal = JSON.parse(await readFile(journalPath, 'utf-8'))
} catch {
consola.error(`Could not read migrations journal at ${journalPath}`)
process.exit(1)
}
if (!journal.entries || journal.entries.length === 0) {
consola.info('No migrations found to squash.')
return
}
consola.info(`Found ${journal.entries.length} migration${journal.entries.length === 1 ? '' : 's'}`)
// Determine which migrations to drop
let migrationsToDrop
if (dropCount) {
// Auto-select the last X migrations
if (dropCount > journal.entries.length) {
consola.warn(`Only ${journal.entries.length} migration${journal.entries.length === 1 ? '' : 's'} available - squashing all`)
}
migrationsToDrop = journal.entries.slice(-Math.min(dropCount, journal.entries.length))
} else {
// Interactive multiselect
const options = journal.entries.map(entry => ({
value: entry.tag,
label: entry.tag,
hint: `${entry.idx}`
}))
const selected = await consola.prompt('Select migrations to squash:', {
type: 'multiselect',
required: false,
options,
cancel: 'null'
})
if (!selected || selected.length === 0) {
consola.info('No migrations selected.')
return
}
// Find the oldest selected migration and include all migrations from that point onwards
const selectedEntries = journal.entries.filter(entry => selected.includes(entry.tag))
const oldestSelectedIdx = Math.min(...selectedEntries.map(e => e.idx))
migrationsToDrop = journal.entries.filter(entry => entry.idx >= oldestSelectedIdx)
// Warn if we're including additional migrations
if (migrationsToDrop.length > selected.length) {
const additionalCount = migrationsToDrop.length - selected.length
consola.warn(`Including ${additionalCount} additional migration${additionalCount === 1 ? '' : 's'} - all migrations after the oldest selected migration must be squashed.`)
}
}
// Show confirmation
consola.log('')
consola.info(`The following ${migrationsToDrop.length} migration${migrationsToDrop.length === 1 ? '' : 's'} will be squashed:`)
for (const migration of migrationsToDrop) {
consola.log(` - ${migration.tag}`)
}
const confirmed = await consola.prompt('Confirm squash?', {
type: 'confirm',
initial: false,
cancel: 'null'
})
if (confirmed !== true) {
consola.info('Squash cancelled')
return
}
// Remove the selected migrations
consola.info('Squashing migrations...')
const tagsToRemove = new Set(migrationsToDrop.map(m => m.tag))
for (const migration of migrationsToDrop) {
const sqlFilePath = join(migrationsDir, `${migration.tag}.sql`)
const snapshotFilePath = join(migrationsDir, 'meta', `${migration.tag.split('_')[0]}_snapshot.json`)
try {
await rm(sqlFilePath, { force: true })
consola.debug(`Deleted ${sqlFilePath}`)
} catch (error) {
consola.debug(`Could not delete ${sqlFilePath}: ${error.message}`)
}
try {
await rm(snapshotFilePath, { force: true })
consola.debug(`Deleted ${snapshotFilePath}`)
} catch (error) {
consola.debug(`Could not delete ${snapshotFilePath}: ${error.message}`)
}
consola.success(`Removed migration \`${migration.tag}\``)
}
// Update the journal
const updatedJournal = {
...journal,
entries: journal.entries.filter(entry => !tagsToRemove.has(entry.tag))
}
await writeFile(journalPath, JSON.stringify(updatedJournal, null, 2))
consola.debug('Updated journal file')
// Build schema and generate fresh migration
const alias = await getTsconfigAliases(cwd)
await buildDatabaseSchema(join(cwd, '.nuxt'), { relativeDir: cwd, alias })
consola.info('Generating new migration...')
const { stderr } = await execa({
...execaOptions,
stdin: 'inherit',
stdout: 'inherit'
})`drizzle-kit generate --config=./.nuxt/hub/db/drizzle.config.ts`
if (stderr) {
consola.error(stderr)
process.exit(1)
}
consola.success('Migrations squashed successfully.')
// Read the updated journal to find the new migration
const updatedJournalContent = JSON.parse(await readFile(journalPath, 'utf-8'))
const newMigration = updatedJournalContent.entries[updatedJournalContent.entries.length - 1]
if (newMigration) {
const markAsApplied = await consola.prompt(`Mark new migration \`${newMigration.tag}\` as already applied?`, {
type: 'confirm',
initial: false,
cancel: 'null'
})
if (markAsApplied === true) {
const hubDir = join(cwd, hubConfig.dir)
const db = await createDrizzleClient(hubConfig.db, hubDir)
const dialect = hubConfig.db.dialect
const execute = dialect === 'sqlite' ? 'run' : 'execute'
await db[execute](sql.raw(`INSERT INTO "_hub_migrations" (name) values ('${newMigration.tag}');`))
await db.$client?.end?.()
consola.success(`Migration \`${newMigration.tag}\` marked as applied.`)
} else {
consola.info(`Run \`npx nuxt dev\` or \`npx nuxt db migrate\` to apply the new migration. Alternatively, to mark the migration as already applied, run \`npx nuxt db mark-as-migrated ${newMigration.tag}\`.`)
}
}
}
})