From e79798f31a9694aa0ba6f9c5f92d1a764064c04c Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Fri, 10 Jan 2025 18:19:05 +0100 Subject: [PATCH 1/4] Improved help --- src/utils/info.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/info.ts b/src/utils/info.ts index 320bc66..d91190f 100644 --- a/src/utils/info.ts +++ b/src/utils/info.ts @@ -22,7 +22,8 @@ export const printHelp = (): Promise => { login Authenticate with RONIN (run by default for every command) init [space] Initialize the TypeScript types for a given space - migration create|apply Create or apply a migration for a locally defined database schema + diff Compare the database schema with the local schema and create a patch + apply Apply the most recent patch to the database {bold OPTIONS} From df0c011160bcae8352522128e1939262116dd5db Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Fri, 10 Jan 2025 18:34:25 +0100 Subject: [PATCH 2/4] Avoid duplicating code --- src/commands/apply.ts | 111 +++++++++++++ src/commands/diff.ts | 98 ++++++++++++ src/commands/migration.ts | 318 ------------------------------------- src/index.ts | 15 +- src/utils/migration.ts | 11 +- src/utils/space.ts | 52 ++++++ tests/fixtures/protocol.ts | 1 + 7 files changed, 283 insertions(+), 323 deletions(-) create mode 100644 src/commands/apply.ts create mode 100644 src/commands/diff.ts delete mode 100644 src/commands/migration.ts diff --git a/src/commands/apply.ts b/src/commands/apply.ts new file mode 100644 index 0000000..dd1cf06 --- /dev/null +++ b/src/commands/apply.ts @@ -0,0 +1,111 @@ +import ora from 'ora'; + +import fs from 'node:fs'; +import path from 'node:path'; +import { initializeDatabase } from '@/src/utils/database'; +import type { MigrationFlags } from '@/src/utils/migration'; +import { MODELS_IN_CODE_DIR } from '@/src/utils/misc'; +import { getModels } from '@/src/utils/model'; +import { Protocol } from '@/src/utils/protocol'; +import { getOrSelectSpaceId } from '@/src/utils/space'; +import { spinner } from '@/src/utils/spinner'; +import { RoninError } from '@ronin/compiler'; +import type { Database } from '@ronin/engine'; + +/** + * Applies a migration file to the database. + */ +export default async ( + appToken: string | undefined, + sessionToken: string | undefined, + flags: MigrationFlags, + positionals: Array, +): Promise => { + const spinner = ora('Applying migration').start(); + const migrationFilePath = positionals[positionals.indexOf('apply') + 1]; + + const db = await initializeDatabase(); + + try { + const { slug } = await getOrSelectSpaceId(sessionToken, spinner); + const existingModels = await getModels( + db, + appToken ?? sessionToken, + slug, + flags.local, + ); + const protocol = await new Protocol().load(migrationFilePath); + const statements = protocol.getSQLStatements(existingModels); + + const files = fs.readdirSync( + path.join(process.cwd(), MODELS_IN_CODE_DIR, '.protocols'), + ); + const latestProtocolFile = files.sort().pop() || 'migration'; + + const migrationsPath = path.join(process.cwd(), MODELS_IN_CODE_DIR, 'migrations'); + + if (!fs.existsSync(migrationsPath)) { + fs.mkdirSync(migrationsPath, { recursive: true }); + } + + fs.copyFileSync( + migrationFilePath || + path.join( + process.cwd(), + MODELS_IN_CODE_DIR, + '.protocols', + path.basename(latestProtocolFile), + ), + path.join(migrationsPath, path.basename(latestProtocolFile)), + ); + + await applyMigrationStatements(appToken ?? sessionToken, flags, db, statements, slug); + + spinner.succeed('Successfully applied migration'); + process.exit(0); + } catch (err) { + const message = + err instanceof RoninError + ? err.message + : `Failed to apply migration: ${err instanceof Error ? err.message : err}`; + spinner.fail(message); + + process.exit(1); + } +}; + +/** + * Applies migration statements to the database. + */ +const applyMigrationStatements = async ( + appTokenOrSessionToken: string | undefined, + flags: MigrationFlags, + db: Database, + statements: Array<{ statement: string }>, + slug: string, +): Promise => { + if (flags.local) { + spinner.info('Applying migration to local database'); + + await db.query(statements.map(({ statement }) => statement)); + fs.writeFileSync('.ronin/db.sqlite', await db.getContents()); + + return; + } + + spinner.info('Applying migration to production database'); + + await fetch(`https://data.ronin.co/?data-selector=${slug}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${appTokenOrSessionToken}`, + }, + body: JSON.stringify({ + nativeQueries: statements.map((query) => ({ + query: query.statement, + mode: 'write', + })), + }), + }); +}; diff --git a/src/commands/diff.ts b/src/commands/diff.ts new file mode 100644 index 0000000..62542f9 --- /dev/null +++ b/src/commands/diff.ts @@ -0,0 +1,98 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { initializeDatabase } from '@/src/utils/database'; +import { type MigrationFlags, diffModels } from '@/src/utils/migration'; +import { MODELS_IN_CODE_DIR, getModelDefinitions, logTableDiff } from '@/src/utils/misc'; +import { getModels } from '@/src/utils/model'; +import { Protocol } from '@/src/utils/protocol'; +import { getOrSelectSpaceId } from '@/src/utils/space'; +import { type Status, spinner } from '@/src/utils/spinner'; +import { type Model, RoninError } from '@ronin/compiler'; + +/** + * Creates a new migration based on model differences. + */ +export default async ( + appToken: string | undefined, + sessionToken: string | undefined, + flags: MigrationFlags, +): Promise => { + let status: Status = 'readingConfig'; + spinner.start('Reading configuration'); + + const db = await initializeDatabase(); + + try { + const { slug } = await getOrSelectSpaceId(sessionToken, spinner); + status = 'comparing'; + spinner.text = 'Comparing models'; + + const [existingModels, definedModels] = await Promise.all([ + getModels(db, appToken ?? sessionToken, slug, flags.local), + getModelDefinitions(), + ]); + + if (flags.debug) { + logModelDiffs(definedModels, existingModels); + } + + spinner.stopAndPersist(); + const modelDiff = await diffModels(definedModels, existingModels); + spinner.start(); + + if (modelDiff.length === 0) { + spinner.succeed('No changes detected'); + return process.exit(0); + } + + status = 'syncing'; + spinner.text = 'Writing migration protocol file'; + + const migrationsDir = path.join(process.cwd(), MODELS_IN_CODE_DIR, '.protocols'); + const nextNum = (() => { + if (!fs.existsSync(migrationsDir)) return 1; + const files = fs.readdirSync(migrationsDir); + const migrationFiles = files.filter((f) => f.startsWith('migration-')); + if (migrationFiles.length === 0) return 1; + const numbers = migrationFiles.map((f) => Number.parseInt(f.split('-')[1])); + return Math.max(...numbers) + 1; + })(); + + const paddedNum = String(nextNum).padStart(4, '0'); + const protocol = new Protocol(modelDiff); + await protocol.convertToQueryObjects(); + await protocol.save(`migration-${paddedNum}`); + + if (flags.sql) { + const allModels = [...existingModels, ...definedModels]; + await protocol.saveSQL(`migration-${paddedNum}`, allModels); + } + + spinner.succeed('Successfully generated migration protocol file'); + + process.exit(0); + } catch (err) { + const message = + err instanceof RoninError + ? err.message + : `Failed during ${status}: ${err instanceof Error ? err.message : err}`; + spinner.fail(message); + + process.exit(1); + } +}; + +/** + * Helper to log model differences in debug mode. + */ +const logModelDiffs = ( + definedModels: Array, + existingModels: Array, +): void => { + for (const existingModel of existingModels) { + const definedModel = definedModels.find((local) => local.slug === existingModel.slug); + if (definedModel && definedModel !== existingModel) { + logTableDiff(definedModel, existingModel, definedModel.slug); + } + } +}; diff --git a/src/commands/migration.ts b/src/commands/migration.ts deleted file mode 100644 index be7c30e..0000000 --- a/src/commands/migration.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { select } from '@inquirer/prompts'; -import ora, { type Ora } from 'ora'; - -import fs from 'node:fs'; -import path from 'node:path'; -import type { parseArgs } from 'node:util'; -import { readConfig, saveConfig } from '@/src/utils/config'; -import { initializeDatabase } from '@/src/utils/database'; -import { diffModels } from '@/src/utils/migration'; -import { - type BaseFlags, - MODELS_IN_CODE_DIR, - getModelDefinitions, - logTableDiff, -} from '@/src/utils/misc'; -import { getModels } from '@/src/utils/model'; -import { Protocol } from '@/src/utils/protocol'; -import { getSpaces } from '@/src/utils/space'; -import { type Status, spinner } from '@/src/utils/spinner'; -import { type Model, RoninError } from '@ronin/compiler'; -import type { Database } from '@ronin/engine'; - -export const MIGRATION_FLAGS = { - reset: { type: 'boolean', short: 'r', default: false }, - sql: { type: 'boolean', short: 's', default: false }, - apply: { type: 'boolean', short: 'a', default: false }, - local: { type: 'boolean', short: 'l', default: false }, -} satisfies NonNullable[0]>['options']; - -type Flags = BaseFlags & Partial>; - -/** - * Handles migration commands for creating and applying database migrations. - */ -export default async function main( - appToken: string | undefined, - sessionToken: string | undefined, - flags: BaseFlags, - positionals: Array, -): Promise { - const subCommand = positionals[positionals.indexOf('migration') + 1]; - - try { - switch (subCommand) { - case 'apply': - await apply(appToken, sessionToken, flags, positionals); - break; - case 'create': - await create(appToken, sessionToken, flags); - break; - default: { - spinner.fail('Please specify a valid sub command.'); - process.exit(1); - } - } - } catch (error) { - const message = - error instanceof RoninError - ? error.message - : `An unexpected error occurred: ${error instanceof Error ? error.message : error}`; - spinner.fail(message); - - process.exit(1); - } -} - -/** - * Applies migration statements to the database. - */ -const applyMigrationStatements = async ( - appTokenOrSessionToken: string | undefined, - flags: Flags, - db: Database, - statements: Array<{ statement: string }>, - slug: string, -): Promise => { - if (flags.local) { - spinner.info('Applying migration to local database'); - - await db.query(statements.map(({ statement }) => statement)); - fs.writeFileSync('.ronin/db.sqlite', await db.getContents()); - - return; - } - - spinner.info('Applying migration to production database'); - - await fetch(`https://data.ronin.co/?data-selector=${slug}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${appTokenOrSessionToken}`, - }, - body: JSON.stringify({ - nativeQueries: statements.map((query) => ({ - query: query.statement, - mode: 'write', - })), - }), - }); -}; - -/** - * Creates a new migration based on model differences. - */ -const create = async ( - appToken: string | undefined, - sessionToken: string | undefined, - flags: Flags, -): Promise => { - let status: Status = 'readingConfig'; - spinner.start('Reading configuration'); - - const db = await initializeDatabase(); - - try { - const { slug } = await getOrSelectSpaceId(sessionToken, spinner); - status = 'comparing'; - spinner.text = 'Comparing models'; - - const [existingModels, definedModels] = await Promise.all([ - getModels(db, appToken ?? sessionToken, slug, flags.local), - getModelDefinitions(), - ]); - - if (flags.debug) { - logModelDiffs(definedModels, existingModels); - } - - spinner.stopAndPersist(); - const modelDiff = await diffModels(definedModels, existingModels); - spinner.start(); - - if (modelDiff.length === 0) { - spinner.succeed('No changes detected'); - return process.exit(0); - } - - status = 'syncing'; - spinner.text = 'Writing migration protocol file'; - - const migrationsDir = path.join(process.cwd(), MODELS_IN_CODE_DIR, '.protocols'); - const nextNum = (() => { - if (!fs.existsSync(migrationsDir)) return 1; - const files = fs.readdirSync(migrationsDir); - const migrationFiles = files.filter((f) => f.startsWith('migration-')); - if (migrationFiles.length === 0) return 1; - const numbers = migrationFiles.map((f) => Number.parseInt(f.split('-')[1])); - return Math.max(...numbers) + 1; - })(); - - const paddedNum = String(nextNum).padStart(4, '0'); - const protocol = new Protocol(modelDiff); - await protocol.convertToQueryObjects(); - await protocol.save(`migration-${paddedNum}`); - - if (flags.sql) { - const allModels = [...existingModels, ...definedModels]; - await protocol.saveSQL(`migration-${paddedNum}`, allModels); - } - - spinner.succeed('Successfully generated migration protocol file'); - - if (flags.apply) { - const statements = protocol.getSQLStatements(existingModels); - const migrationsPath = path.join(process.cwd(), MODELS_IN_CODE_DIR, 'migrations'); - - if (!fs.existsSync(migrationsPath)) { - fs.mkdirSync(migrationsPath, { recursive: true }); - } - - fs.copyFileSync( - path.join( - process.cwd(), - MODELS_IN_CODE_DIR, - '.protocols', - `migration-${paddedNum}.ts`, - ), - path.join(migrationsPath, `migration-${paddedNum}.ts`), - ); - - await applyMigrationStatements( - appToken ?? sessionToken, - flags, - db, - statements, - slug, - ); - } - - process.exit(0); - } catch (err) { - spinner.fail( - `Failed during ${status}:\n ${err instanceof Error ? err.message : err}`, - ); - throw err; - } -}; - -/** - * Applies a migration file to the database. - */ -const apply = async ( - appToken: string | undefined, - sessionToken: string | undefined, - flags: Flags, - positionals: Array, -): Promise => { - const spinner = ora('Applying migration').start(); - const migrationFilePath = positionals[positionals.indexOf('migration') + 2]; - - const db = await initializeDatabase(); - - try { - const { slug } = await getOrSelectSpaceId(sessionToken, spinner); - const existingModels = await getModels( - db, - appToken ?? sessionToken, - slug, - flags.local, - ); - const protocol = await new Protocol().load(migrationFilePath); - const statements = protocol.getSQLStatements(existingModels); - - const files = fs.readdirSync( - path.join(process.cwd(), MODELS_IN_CODE_DIR, '.protocols'), - ); - const latestProtocolFile = files.sort().pop() || 'migration'; - - const migrationsPath = path.join(process.cwd(), MODELS_IN_CODE_DIR, 'migrations'); - - if (!fs.existsSync(migrationsPath)) { - fs.mkdirSync(migrationsPath, { recursive: true }); - } - - fs.copyFileSync( - migrationFilePath || - path.join( - process.cwd(), - MODELS_IN_CODE_DIR, - '.protocols', - path.basename(latestProtocolFile), - ), - path.join(migrationsPath, path.basename(latestProtocolFile)), - ); - - await applyMigrationStatements(appToken ?? sessionToken, flags, db, statements, slug); - - spinner.succeed('Successfully applied migration'); - process.exit(0); - } catch (error) { - spinner.fail('Failed to apply migration'); - throw error; - } -}; - -/** - * Helper to get or interactively select a space ID. - */ -const getOrSelectSpaceId = async ( - sessionToken?: string, - spinner?: Ora, -): Promise<{ id: string; slug: string }> => { - const config = readConfig(); - let space = { id: config.spaceId, slug: config.spaceSlug }; - - if (!space.id && sessionToken) { - const spaces = await getSpaces(sessionToken); - - if (spaces?.length === 0) { - throw new Error( - "You don't have access to any space or your CLI session is invalid.\n\n" + - 'Please login again (by running `npx ronin login`) or ' + - 'create a new space on the dashboard (`https://ronin.co/new`) and try again.', - ); - } - - if (spaces.length === 1) { - space = { id: spaces[0].id, slug: spaces[0].handle }; - } else { - spinner?.stop(); - space = await select({ - message: 'Which space do you want to apply models to?', - choices: spaces.map((space) => ({ - name: space.handle, - value: { id: space.id, slug: space.handle }, - description: space.name, - })), - }); - } - - saveConfig({ spaceId: space.id, spaceSlug: space.slug }); - } - - if (!space) { - throw new Error('Space ID is not specified.'); - } - - return { - id: space.id!, - slug: space.slug!, - }; -}; - -/** - * Helper to log model differences in debug mode. - */ -const logModelDiffs = ( - definedModels: Array, - existingModels: Array, -): void => { - for (const existingModel of existingModels) { - const definedModel = definedModels.find((local) => local.slug === existingModel.slug); - if (definedModel && definedModel !== existingModel) { - logTableDiff(definedModel, existingModel, definedModel.slug); - } - } -}; diff --git a/src/index.ts b/src/index.ts index 81228a4..bfbdae4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,11 @@ import { parseArgs } from 'node:util'; +import apply from '@/src/commands/apply'; +import diff from '@/src/commands/diff'; import initializeProject from '@/src/commands/init'; import logIn from '@/src/commands/login'; -import migrate, { MIGRATION_FLAGS } from '@/src/commands/migration'; import { printHelp, printVersion } from '@/src/utils/info'; +import { MIGRATION_FLAGS } from '@/src/utils/migration'; import { BASE_FLAGS, type BaseFlags } from '@/src/utils/misc'; import { getSession } from '@/src/utils/session'; import { spinner } from '@/src/utils/spinner'; @@ -64,9 +66,14 @@ const run = async (): Promise => { // `init` sub command if (normalizedPositionals.includes('init')) return initializeProject(positionals); - // Handle 'migration' command - if (normalizedPositionals.includes('migration')) { - return migrate(appToken, session?.token, flags, positionals); + // `diff` sub command + if (normalizedPositionals.includes('diff')) { + return diff(appToken, session?.token, flags); + } + + // `diff` sub command + if (normalizedPositionals.includes('apply')) { + return apply(appToken, session?.token, flags, positionals); } // If no matching flags or commands were found, render the help, since we don't want to diff --git a/src/utils/migration.ts b/src/utils/migration.ts index 1361b5f..4caaa97 100644 --- a/src/utils/migration.ts +++ b/src/utils/migration.ts @@ -1,5 +1,6 @@ +import type { parseArgs } from 'node:util'; import { diffFields, fieldsToAdjust } from '@/src/utils/field'; -import { areArraysEqual } from '@/src/utils/misc'; +import { type BaseFlags, areArraysEqual } from '@/src/utils/misc'; import { createIndexQuery, createModelQuery, @@ -392,3 +393,11 @@ export const createIndexes = ( return diff; }; + +export const MIGRATION_FLAGS = { + sql: { type: 'boolean', short: 's', default: false }, + local: { type: 'boolean', short: 'l', default: false }, +} satisfies NonNullable[0]>['options']; + +export type MigrationFlags = BaseFlags & + Partial>; diff --git a/src/utils/space.ts b/src/utils/space.ts index b735693..7854ea0 100644 --- a/src/utils/space.ts +++ b/src/utils/space.ts @@ -1,3 +1,7 @@ +import { readConfig, saveConfig } from '@/src/utils/config'; +import { select } from '@inquirer/prompts'; +import type { Ora } from 'ora'; + /** * Fetches all available spaces for the authenticated user session. * @@ -52,3 +56,51 @@ export const getSpaces = async ( throw new Error(`Failed to fetch available spaces: ${(error as Error).message}`); } }; + +/** + * Helper to get or interactively select a space ID. + */ +export const getOrSelectSpaceId = async ( + sessionToken?: string, + spinner?: Ora, +): Promise<{ id: string; slug: string }> => { + const config = readConfig(); + let space = { id: config.spaceId, slug: config.spaceSlug }; + + if (!space.id && sessionToken) { + const spaces = await getSpaces(sessionToken); + + if (spaces?.length === 0) { + throw new Error( + "You don't have access to any space or your CLI session is invalid.\n\n" + + 'Please login again (by running `npx ronin login`) or ' + + 'create a new space on the dashboard (`https://ronin.co/new`) and try again.', + ); + } + + if (spaces.length === 1) { + space = { id: spaces[0].id, slug: spaces[0].handle }; + } else { + spinner?.stop(); + space = await select({ + message: 'Which space do you want to apply models to?', + choices: spaces.map((space) => ({ + name: space.handle, + value: { id: space.id, slug: space.handle }, + description: space.name, + })), + }); + } + + saveConfig({ spaceId: space.id, spaceSlug: space.slug }); + } + + if (!space) { + throw new Error('Space ID is not specified.'); + } + + return { + id: space.id!, + slug: space.slug!, + }; +}; diff --git a/tests/fixtures/protocol.ts b/tests/fixtures/protocol.ts index 2501c7a..5c83ef6 100644 --- a/tests/fixtures/protocol.ts +++ b/tests/fixtures/protocol.ts @@ -1,3 +1,4 @@ import { get } from 'ronin'; +// biome-ignore lint/nursery/useExplicitType: In a real scenario, there is no type. export default () => [get.account.with({ handle: 'elaine' })]; From 37983c147b997e07c0158be2a0725b1d3d855136 Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Fri, 10 Jan 2025 18:41:19 +0100 Subject: [PATCH 3/4] Ensure missing changes --- src/commands/diff.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/commands/diff.ts b/src/commands/diff.ts index 62542f9..e4fa8b8 100644 --- a/src/commands/diff.ts +++ b/src/commands/diff.ts @@ -18,7 +18,7 @@ export default async ( flags: MigrationFlags, ): Promise => { let status: Status = 'readingConfig'; - spinner.start('Reading configuration'); + spinner.text = 'Reading configuration'; const db = await initializeDatabase(); @@ -36,9 +36,7 @@ export default async ( logModelDiffs(definedModels, existingModels); } - spinner.stopAndPersist(); const modelDiff = await diffModels(definedModels, existingModels); - spinner.start(); if (modelDiff.length === 0) { spinner.succeed('No changes detected'); From fde0ae956fbd6b7873aceeed1b27799611d1c93f Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Fri, 10 Jan 2025 18:48:38 +0100 Subject: [PATCH 4/4] Added flag for auto-applying --- src/commands/diff.ts | 6 ++++++ src/utils/migration.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/src/commands/diff.ts b/src/commands/diff.ts index e4fa8b8..730c86e 100644 --- a/src/commands/diff.ts +++ b/src/commands/diff.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import apply from '@/src/commands/apply'; import { initializeDatabase } from '@/src/utils/database'; import { type MigrationFlags, diffModels } from '@/src/utils/migration'; import { MODELS_IN_CODE_DIR, getModelDefinitions, logTableDiff } from '@/src/utils/misc'; @@ -68,6 +69,11 @@ export default async ( spinner.succeed('Successfully generated migration protocol file'); + // If desired, immediately apply the migration + if (flags.apply) { + await apply(appToken, sessionToken, flags, ['apply']); + } + process.exit(0); } catch (err) { const message = diff --git a/src/utils/migration.ts b/src/utils/migration.ts index 4caaa97..47cbd13 100644 --- a/src/utils/migration.ts +++ b/src/utils/migration.ts @@ -397,6 +397,7 @@ export const createIndexes = ( export const MIGRATION_FLAGS = { sql: { type: 'boolean', short: 's', default: false }, local: { type: 'boolean', short: 'l', default: false }, + apply: { type: 'boolean', short: 'a', default: false }, } satisfies NonNullable[0]>['options']; export type MigrationFlags = BaseFlags &