diff --git a/packages/db/package.json b/packages/db/package.json index 2533aed5b995..a9341a4d5002 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -74,6 +74,7 @@ "open": "^10.0.3", "ora": "^7.0.1", "prompts": "^2.4.2", + "strip-ansi": "^7.1.0", "yargs-parser": "^21.1.1", "zod": "^3.22.4" }, diff --git a/packages/db/src/core/cli/commands/push/index.ts b/packages/db/src/core/cli/commands/push/index.ts index 59c3a014c201..f7359a8b3b51 100644 --- a/packages/db/src/core/cli/commands/push/index.ts +++ b/packages/db/src/core/cli/commands/push/index.ts @@ -6,9 +6,11 @@ import { getRemoteDatabaseUrl } from '../../../utils.js'; import { createCurrentSnapshot, createEmptySnapshot, + formatDataLossMessage, getMigrationQueries, getProductionCurrentSnapshot, } from '../../migration-queries.js'; +import { red } from 'kleur/colors'; export async function cmd({ dbConfig, @@ -24,7 +26,7 @@ export async function cmd({ const productionSnapshot = await getProductionCurrentSnapshot({ appToken: appToken.token }); const currentSnapshot = createCurrentSnapshot(dbConfig); const isFromScratch = isForceReset || JSON.stringify(productionSnapshot) === '{}'; - const { queries: migrationQueries } = await getMigrationQueries({ + const { queries: migrationQueries, confirmations } = await getMigrationQueries({ oldSnapshot: isFromScratch ? createEmptySnapshot() : productionSnapshot, newSnapshot: currentSnapshot, }); @@ -35,6 +37,14 @@ export async function cmd({ } else { console.log(`Database schema is out of date.`); } + + if (isForceReset) { + console.log(`Force-pushing to the database. All existing data will be erased.`); + } else if (confirmations.length > 0) { + console.log('\n' + formatDataLossMessage(confirmations) + '\n'); + throw new Error('Exiting.'); + } + if (isDryRun) { console.log('Statements:', JSON.stringify(migrationQueries, undefined, 2)); } else { diff --git a/packages/db/src/core/cli/commands/verify/index.ts b/packages/db/src/core/cli/commands/verify/index.ts index 55e45c3782ff..4bf8683b9dcc 100644 --- a/packages/db/src/core/cli/commands/verify/index.ts +++ b/packages/db/src/core/cli/commands/verify/index.ts @@ -5,6 +5,7 @@ import type { DBConfig } from '../../../types.js'; import { createCurrentSnapshot, createEmptySnapshot, + formatDataLossMessage, getMigrationQueries, getProductionCurrentSnapshot, } from '../../migration-queries.js'; @@ -17,21 +18,39 @@ export async function cmd({ dbConfig: DBConfig; flags: Arguments; }) { + const isJson = flags.json; const appToken = await getManagedAppTokenOrExit(flags.token); const productionSnapshot = await getProductionCurrentSnapshot({ appToken: appToken.token }); const currentSnapshot = createCurrentSnapshot(dbConfig); - const { queries: migrationQueries } = await getMigrationQueries({ + const { queries: migrationQueries, confirmations } = await getMigrationQueries({ oldSnapshot: JSON.stringify(productionSnapshot) !== '{}' ? productionSnapshot : createEmptySnapshot(), newSnapshot: currentSnapshot, }); + const result = { exitCode: 0, message: '', code: '', data: undefined as unknown }; if (migrationQueries.length === 0) { - console.log(`Database schema is up to date.`); + result.code = 'MATCH'; + result.message = `Database schema is up to date.`; } else { - console.log(`Database schema is out of date.`); - console.log(`Run 'astro db push' to push up your latest changes.`); + result.code = 'NO_MATCH'; + result.message = `Database schema is out of date.\nRun 'astro db push' to push up your latest changes.`; + } + + + if (confirmations.length > 0) { + result.code = 'DATA_LOSS'; + result.exitCode = 1; + result.data = confirmations; + result.message = formatDataLossMessage(confirmations, !isJson); + } + + if (isJson) { + console.log(JSON.stringify(result)); + } else { + console.log(result.message); } await appToken.destroy(); + process.exit(result.exitCode); } diff --git a/packages/db/src/core/cli/migration-queries.ts b/packages/db/src/core/cli/migration-queries.ts index 09984b8c6670..f1b1047cb74c 100644 --- a/packages/db/src/core/cli/migration-queries.ts +++ b/packages/db/src/core/cli/migration-queries.ts @@ -1,3 +1,4 @@ +import stripAnsi from 'strip-ansi'; import deepDiff from 'deep-diff'; import { SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core'; import * as color from 'kleur/colors'; @@ -147,21 +148,12 @@ export async function getCollectionChangeQueries({ if (dataLossCheck.dataLoss) { const { reason, columnName } = dataLossCheck; const reasonMsgs: Record = { - 'added-required': `New column ${color.bold( + 'added-required': `You added new required column '${color.bold( collectionName + '.' + columnName - )} is required with no default value.\nThis requires deleting existing data in the ${color.bold( - collectionName - )} collection.`, - 'added-unique': `New column ${color.bold( + )}' with no default value.\n This cannot be executed on an existing table.`, + 'updated-type': `Updating existing column ${color.bold( collectionName + '.' + columnName - )} is marked as unique.\nThis requires deleting existing data in the ${color.bold( - collectionName - )} collection.`, - 'updated-type': `Updated column ${color.bold( - collectionName + '.' + columnName - )} cannot convert data to new column data type.\nThis requires deleting existing data in the ${color.bold( - collectionName - )} collection.`, + )} to a new type that cannot be handled automatically.`, }; confirmations.push(reasonMsgs[reason]); } @@ -319,7 +311,7 @@ function canAlterTableDropColumn(column: DBColumn) { return true; } -type DataLossReason = 'added-required' | 'added-unique' | 'updated-type'; +type DataLossReason = 'added-required' | 'updated-type'; type DataLossResponse = | { dataLoss: false } | { dataLoss: true; columnName: string; reason: DataLossReason }; @@ -335,9 +327,6 @@ function canRecreateTableWithoutDataLoss( if (!a.schema.optional && !hasDefault(a)) { return { dataLoss: true, columnName, reason: 'added-required' }; } - if (!a.schema.optional && a.schema.unique) { - return { dataLoss: true, columnName, reason: 'added-unique' }; - } } for (const [columnName, u] of Object.entries(updated)) { if (u.old.type !== u.new.type && !canChangeTypeWithoutQuery(u.old, u.new)) { @@ -454,3 +443,18 @@ export function createCurrentSnapshot({ tables = {} }: DBConfig): DBSnapshot { export function createEmptySnapshot(): DBSnapshot { return { experimentalVersion: 1, schema: {} }; } + +export function formatDataLossMessage(confirmations: string[], isColor = true): string { + const messages = []; + messages.push(color.red('✖ We found some schema changes that cannot be handled automatically:')); + messages.push(``); + messages.push(...confirmations.map((m, i) => color.red(` (${i + 1}) `) + m)); + messages.push(``); + messages.push(`To resolve, revert these changes or update your schema, and re-run the command.`); + messages.push(`You may also run 'astro db push --force-reset' to ignore all warnings and force-push your local database schema to production instead. All data will be lost and the database will be reset.`); + let finalMessage = messages.join('\n'); + if (!isColor) { + finalMessage = stripAnsi(finalMessage); + } + return finalMessage; +} diff --git a/packages/db/test/fixtures/ticketing-example/db/config.ts b/packages/db/test/fixtures/ticketing-example/db/config.ts index f8148eaed305..09ed4d27375c 100644 --- a/packages/db/test/fixtures/ticketing-example/db/config.ts +++ b/packages/db/test/fixtures/ticketing-example/db/config.ts @@ -10,6 +10,8 @@ const Event = defineTable({ ticketPrice: column.number(), date: column.date(), location: column.text(), + author3: column.text(), + author4: column.text(), }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86f8068c8c08..9a2a8b6bede1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3841,6 +3841,9 @@ importers: prompts: specifier: ^2.4.2 version: 2.4.2 + strip-ansi: + specifier: ^7.1.0 + version: 7.1.0 yargs-parser: specifier: ^21.1.1 version: 21.1.1