diff --git a/packages/db/src/core/cli/commands/push/index.ts b/packages/db/src/core/cli/commands/push/index.ts index d9dc82e793..5809f0e7d1 100644 --- a/packages/db/src/core/cli/commands/push/index.ts +++ b/packages/db/src/core/cli/commands/push/index.ts @@ -1,6 +1,5 @@ import { createClient, type InStatement } from '@libsql/client'; import type { AstroConfig } from 'astro'; -import deepDiff from 'deep-diff'; import { drizzle } from 'drizzle-orm/sqlite-proxy'; import { red } from 'kleur/colors'; import prompts from 'prompts'; @@ -11,44 +10,56 @@ import type { AstroConfigWithDB, DBSnapshot } from '../../../types.js'; import { getRemoteDatabaseUrl } from '../../../utils.js'; import { getMigrationQueries } from '../../migration-queries.js'; import { - createCurrentSnapshot, createEmptySnapshot, getMigrations, - initializeFromMigrations, + getMigrationStatus, loadInitialSnapshot, loadMigration, + MIGRATION_NEEDED, + MIGRATIONS_NOT_INITIALIZED, + MIGRATIONS_UP_TO_DATE, } from '../../migrations.js'; - -const { diff } = deepDiff; +import { MISSING_SESSION_ID_ERROR } from '../../../errors.js'; export async function cmd({ config, flags }: { config: AstroConfig; flags: Arguments }) { const isSeedData = flags.seed; const isDryRun = flags.dryRun; const appToken = await getManagedAppTokenOrExit(flags.token); - const currentSnapshot = createCurrentSnapshot(config); - const allMigrationFiles = await getMigrations(); - if (allMigrationFiles.length === 0) { - console.log('Project not yet initialized!'); - process.exit(1); - } - const prevSnapshot = await initializeFromMigrations(allMigrationFiles); - const calculatedDiff = diff(prevSnapshot, currentSnapshot); - if (calculatedDiff) { - console.log('Changes detected!'); - console.log(calculatedDiff); + const migration = await getMigrationStatus(config); + if (migration.state === 'no-migrations-found') { + console.log(MIGRATIONS_NOT_INITIALIZED) + process.exit(1); + } else if (migration.state === 'ahead') { + console.log(MIGRATION_NEEDED); process.exit(1); } // get all migrations from the filesystem const allLocalMigrations = await getMigrations(); - const { data: missingMigrations } = await prepareMigrateQuery({ - migrations: allLocalMigrations, - appToken: appToken.token, - }); + let missingMigrations: string[] = []; + try { + const { data } = await prepareMigrateQuery({ + migrations: allLocalMigrations, + appToken: appToken.token, + }) + missingMigrations = data; + } catch (e) { + if (e instanceof Error) { + if (e.message.startsWith('{')) { + const { error: { code } = { code: "" } } = JSON.parse(e.message); + if (code === 'TOKEN_UNAUTHORIZED') { + console.error(MISSING_SESSION_ID_ERROR); + } + process.exit(1); + } + } + console.error(e); + process.exit(1); + } // exit early if there are no migrations to push if (missingMigrations.length === 0) { - console.info('No migrations to push! Your database is up to date!'); + console.log(MIGRATIONS_UP_TO_DATE); process.exit(0); } // push the database schema @@ -58,7 +69,7 @@ export async function cmd({ config, flags }: { config: AstroConfig; flags: Argum migrations: missingMigrations, appToken: appToken.token, isDryRun, - currentSnapshot, + currentSnapshot: migration.currentSnapshot, }); } // push the database seed data diff --git a/packages/db/src/core/cli/commands/sync/index.ts b/packages/db/src/core/cli/commands/sync/index.ts index 63afd9b79f..2f6fce0c7c 100644 --- a/packages/db/src/core/cli/commands/sync/index.ts +++ b/packages/db/src/core/cli/commands/sync/index.ts @@ -1,57 +1,43 @@ import type { AstroConfig } from 'astro'; -import deepDiff from 'deep-diff'; -import { writeFile } from 'fs/promises'; import type { Arguments } from 'yargs-parser'; +import { writeFile } from 'node:fs/promises'; import { - createCurrentSnapshot, - getMigrations, - initializeFromMigrations, + MIGRATIONS_CREATED, + MIGRATIONS_UP_TO_DATE, + getMigrationStatus, initializeMigrationsDirectory, } from '../../migrations.js'; import { getMigrationQueries } from '../../migration-queries.js'; -import prompts from 'prompts'; -import { bgRed, bold, red, reset } from 'kleur/colors'; -const { diff } = deepDiff; +import { bgRed, red, reset } from 'kleur/colors'; export async function cmd({ config }: { config: AstroConfig; flags: Arguments }) { - const currentSnapshot = createCurrentSnapshot(config); - const allMigrationFiles = await getMigrations(); - if (allMigrationFiles.length === 0) { - await initializeMigrationsDirectory(currentSnapshot); - console.log('Project initialized!'); - return; - } - - const prevSnapshot = await initializeFromMigrations(allMigrationFiles); - const calculatedDiff = diff(prevSnapshot, currentSnapshot); - if (!calculatedDiff) { - console.log('No changes detected!'); + const migration = await getMigrationStatus(config); + + if (migration.state === 'no-migrations-found') { + await initializeMigrationsDirectory(migration.currentSnapshot); + console.log(MIGRATIONS_CREATED); + return; + } else if (migration.state === 'up-to-date') { + console.log(MIGRATIONS_UP_TO_DATE); return; } + const { oldSnapshot, newSnapshot, newFilename, diff } = migration; const { queries: migrationQueries, confirmations } = await getMigrationQueries({ - oldSnapshot: prevSnapshot, - newSnapshot: currentSnapshot, + oldSnapshot, + newSnapshot }); // Warn the user about any changes that lead to data-loss. // When the user runs `db push`, they will be prompted to confirm these changes. confirmations.map((message) => console.log(bgRed(' !!! ') + ' ' + red(message))); - // Generate the new migration filename by calculating the largest number. - const largestNumber = allMigrationFiles.reduce((acc, curr) => { - const num = parseInt(curr.split('_')[0]); - return num > acc ? num : acc; - }, 0); const migrationFileContent = { - diff: calculatedDiff, + diff, db: migrationQueries, // TODO(fks): Encode the relevant data, instead of the raw message. // This will give `db push` more control over the formatting of the message. confirm: confirmations.map(c => reset(c)), }; - const migrationFileName = `./migrations/${String(largestNumber + 1).padStart( - 4, - '0' - )}_migration.json`; + const migrationFileName = `./migrations/${newFilename}`; await writeFile(migrationFileName, JSON.stringify(migrationFileContent, undefined, 2)); console.log(migrationFileName + ' created!'); } diff --git a/packages/db/src/core/cli/commands/verify/index.ts b/packages/db/src/core/cli/commands/verify/index.ts index 4e6434dc00..6e67ec1da8 100644 --- a/packages/db/src/core/cli/commands/verify/index.ts +++ b/packages/db/src/core/cli/commands/verify/index.ts @@ -1,23 +1,28 @@ import type { AstroConfig } from 'astro'; -import deepDiff from 'deep-diff'; import type { Arguments } from 'yargs-parser'; -import { getMigrations, initializeFromMigrations } from '../../migrations.js'; -const { diff } = deepDiff; +import { getMigrationStatus, MIGRATION_NEEDED, MIGRATIONS_NOT_INITIALIZED, MIGRATIONS_UP_TO_DATE } from '../../migrations.js'; -export async function cmd({ config }: { config: AstroConfig; flags: Arguments }) { - const currentSnapshot = JSON.parse(JSON.stringify(config.db?.collections ?? {})); - const allMigrationFiles = await getMigrations(); - if (allMigrationFiles.length === 0) { - console.log('Project not yet initialized!'); - process.exit(1); +export async function cmd({ config, flags }: { config: AstroConfig; flags: Arguments }) { + const status = await getMigrationStatus(config); + const { state } = status; + if (flags.json) { + console.log(JSON.stringify(status)); + process.exit(state === 'up-to-date' ? 0 : 1); } - - const prevSnapshot = await initializeFromMigrations(allMigrationFiles); - const calculatedDiff = diff(prevSnapshot, currentSnapshot); - if (calculatedDiff) { - console.log('Changes detected!'); - process.exit(1); + switch (state) { + case 'no-migrations-found': { + console.log(MIGRATIONS_NOT_INITIALIZED); + process.exit(1); + } + case 'ahead': { + console.log(MIGRATION_NEEDED); + process.exit(1); + } + case 'up-to-date': { + console.log(MIGRATIONS_UP_TO_DATE); + return; + } } - console.log('No changes detected.'); - return; } + + diff --git a/packages/db/src/core/cli/migrations.ts b/packages/db/src/core/cli/migrations.ts index 67dac6f91a..f6a87dd897 100644 --- a/packages/db/src/core/cli/migrations.ts +++ b/packages/db/src/core/cli/migrations.ts @@ -2,7 +2,75 @@ import deepDiff from 'deep-diff'; import { mkdir, readFile, readdir, writeFile } from 'fs/promises'; import type { DBSnapshot } from '../types.js'; import type { AstroConfig } from 'astro'; -const { applyChange } = deepDiff; +import { cyan, green, yellow } from 'kleur/colors'; +const { applyChange, diff: generateDiff } = deepDiff; + +export type MigrationStatus = { + state: 'no-migrations-found' + currentSnapshot: DBSnapshot +} | { + state: 'ahead', + oldSnapshot: DBSnapshot, + newSnapshot: DBSnapshot, + diff: deepDiff.Diff[], + newFilename: string, + summary: string, +} | { + state: 'up-to-date', + currentSnapshot: DBSnapshot +} + +export async function getMigrationStatus(config: AstroConfig): Promise { + const currentSnapshot = createCurrentSnapshot(config); + const allMigrationFiles = await getMigrations(); + + if (allMigrationFiles.length === 0) { + return { + state: 'no-migrations-found', + currentSnapshot + } + } + + const previousSnapshot = await initializeFromMigrations(allMigrationFiles); + const diff = generateDiff(previousSnapshot, currentSnapshot); + + if (diff) { + const n = getNewMigrationNumber(allMigrationFiles); + const newFilename = `${String(n + 1).padStart(4, '0')}_migration.json`; + return { + state: 'ahead', + oldSnapshot: previousSnapshot, + newSnapshot: currentSnapshot, + diff, + newFilename, + summary: generateDiffSummary(diff), + } + } + + return { + state: 'up-to-date', + currentSnapshot + } +} + +export const MIGRATIONS_CREATED = `${green('■ Migrations initialized!')}\n\n To execute your migrations, run\n ${cyan('astro db push')}` +export const MIGRATIONS_UP_TO_DATE = `${green('■ No migrations needed!')}\n\n Your data is all up to date.\n` +export const MIGRATIONS_NOT_INITIALIZED = `${yellow('▶ No migrations found!')}\n\n To scaffold your migrations folder, run\n ${cyan('astro db sync')}\n` +export const MIGRATION_NEEDED = `${yellow('▶ Changes detected!')}\n\n To create the necessary migration file, run\n ${cyan('astro db sync')}\n` + + +function generateDiffSummary(diff: deepDiff.Diff[]) { + // TODO: human readable summary + return JSON.stringify(diff, null, 2); +} + +function getNewMigrationNumber(allMigrationFiles: string[]): number { + const len = allMigrationFiles.length - 1; + return allMigrationFiles.reduce((acc, curr) => { + const num = Number.parseInt(curr.split('_')[0] ?? len, 10); + return num > acc ? num : acc; + }, 0); +} export async function getMigrations(): Promise { const migrationFiles = await readdir('./migrations').catch((err) => { diff --git a/packages/db/src/core/errors.ts b/packages/db/src/core/errors.ts index bd5756a795..ee0fdf1e85 100644 --- a/packages/db/src/core/errors.ts +++ b/packages/db/src/core/errors.ts @@ -1,19 +1,32 @@ -import { cyan, bold, red } from 'kleur/colors'; +import { cyan, bold, red, green, yellow } from 'kleur/colors'; export const MISSING_SESSION_ID_ERROR = `${red( - '⚠️ Login required.' -)} Run ${bold('astro db login')} to authenticate with Astro Studio.`; + '▶ Login required!' +)} + + To authenticate with Astro Studio, run + ${cyan('astro db login')}\n`; export const MISSING_PROJECT_ID_ERROR = `${red( - '⚠️ Directory not linked.' -)} Run ${bold('astro db link')} to link this directory to an Astro Studio project.`; + '▶ Directory not linked.' +)} -export const STUDIO_CONFIG_MISSING_WRITABLE_COLLECTIONS_ERROR = (collectionName: string) => - red(`⚠️ Writable collection ${bold(collectionName)} requires Astro Studio.`) + - ` Visit ${cyan('https://astro.build/studio')} to create your account` + - ` and then set ${bold('studio: true')} in your astro.config.js file to enable.`; + To link this directory to an Astro Studio project, run + ${cyan('astro db link')}\n`; -export const STUDIO_CONFIG_MISSING_CLI_ERROR = - red('⚠️ This command requires Astro Studio.') + - ` Visit ${cyan('https://astro.build/studio')} to create your account` + - ` and then set ${bold('studio: true')} in your astro.config.js file to enable.`; +export const STUDIO_CONFIG_MISSING_WRITABLE_COLLECTIONS_ERROR = (collectionName: string) => `${ + red(`▶ Writable collection ${bold(collectionName)} requires Astro Studio.`) +} + + Visit ${cyan('https://astro.build/studio')} to create your account + and set ${green('studio: true')} in your astro.config.mjs file to enable Studio.\n`; + +export const STUDIO_CONFIG_MISSING_CLI_ERROR = `${ + red('▶ This command requires Astro Studio.') +} + + Visit ${cyan('https://astro.build/studio')} to create your account + and set ${green('studio: true')} in your astro.config.mjs file to enable Studio.\n`; + + +export const MIGRATIONS_NOT_INITIALIZED = `${yellow('▶ No migrations found!')}\n\n To scaffold your migrations folder, run\n ${cyan('astro db sync')}\n`