Skip to content

Commit

Permalink
Added migration sub command (#1)
Browse files Browse the repository at this point in the history
  • Loading branch information
colodenn authored Dec 19, 2024
1 parent 46159ee commit c83dc76
Show file tree
Hide file tree
Showing 27 changed files with 3,920 additions and 22 deletions.
Binary file modified bun.lockb
Binary file not shown.
4 changes: 4 additions & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
[test]
coverage = true
coverageThreshold = 1.0

[install]
exact = true
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
"license": "Apache-2.0",
"dependencies": {
"@iarna/toml": "2.2.5",
"@ronin/engine": "0.0.16",
"@ronin/compiler": "0.12.4",
"chalk-template": "1.1.0",
"get-port": "7.1.0",
"ini": "5.0.0",
Expand All @@ -38,6 +40,8 @@
"ora": "8.1.0"
},
"devDependencies": {
"bun-bagel": "1.1.0",
"@ronin/schema": "0.1.2",
"@biomejs/biome": "1.9.4",
"@types/bun": "1.1.12",
"@types/ini": "4.1.1",
Expand Down
275 changes: 275 additions & 0 deletions src/commands/migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
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 { db } from '@/src/utils/database';
import { diffModels } from '@/src/utils/migration';
import { type BaseFlags, 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 { Model } from '@ronin/compiler';

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 },
prod: { type: 'boolean', short: 'p', default: false },
} satisfies NonNullable<Parameters<typeof parseArgs>[0]>['options'];

type Flags = BaseFlags & Partial<Record<keyof typeof MIGRATION_FLAGS, boolean>>;

/** Current status of the migration creation process */
type Status = 'readingConfig' | 'readingModels' | 'comparing' | 'syncing';

/**
* Handles migration commands for creating and applying database migrations.
*/
export default async function main(
appToken: string | undefined,
sessionToken: string | undefined,
flags: BaseFlags,
positionals: Array<string>,
): Promise<void> {
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: {
console.error('Please specify a valid sub command.');
process.exit(1);
}
}
} catch (error) {
console.error(
'An unexpected error occurred:',
error instanceof Error ? error.message : error,
);
process.exit(1);
}
}

/**
* Applies migration statements to the database.
*/
const applyMigrationStatements = async (
appToken: string | undefined,
flags: Flags,
statements: Array<{ statement: string }>,
slug: string,
): Promise<void> => {
if (flags.prod) {
console.log('Applying migration to production database');

await fetch(`https://data.ronin.co/?data-selector=${slug}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${appToken}`,
},
body: JSON.stringify({
nativeQueries: statements.map((query) => ({
query: query.statement,
mode: 'write',
})),
}),
});

return;
}

console.log('Applying migration to local database');

await db.query(statements.map(({ statement }) => statement));
fs.writeFileSync('.ronin/db.sqlite', await db.getContents());
};

/**
* Creates a new migration based on model differences.
*/
const create = async (
appToken: string | undefined,
sessionToken: string | undefined,
flags: Flags,
): Promise<void> => {
let status: Status = 'readingConfig';
const spinner = ora('Reading configuration').start();

try {
const { slug } = await getOrSelectSpaceId(sessionToken, spinner);
status = 'comparing';
spinner.text = 'Comparing models';

const [existingModels, definedModels] = await Promise.all([
getModels(db, appToken!, slug, flags.prod),
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/.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();
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/migrations');
fs.mkdirSync(migrationsPath, { recursive: true });

fs.copyFileSync(
path.join(process.cwd(), 'models/.protocols', `migration-${paddedNum}.ts`),
path.join(migrationsPath, `migration-${paddedNum}.ts`),
);

await applyMigrationStatements(appToken, flags, statements, slug);
}

process.exit(0);
} catch (err) {
spinner.fail(`Failed during ${status}:\n`);
console.error(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<string>,
): Promise<void> => {
const spinner = ora('Applying migration').start();
const migrationFilePath = positionals[positionals.indexOf('migration') + 2];

try {
const { slug } = await getOrSelectSpaceId(sessionToken, spinner);
const existingModels = await getModels(db, appToken, slug, flags.prod);
const protocol = await new Protocol().load(migrationFilePath);
const statements = protocol.getSQLStatements(existingModels);

const files = fs.readdirSync(path.join(process.cwd(), 'models/.protocols'));
const latestProtocolFile = files.sort().pop() || 'migration';

const migrationsPath = path.join(process.cwd(), 'models/migrations');
fs.copyFileSync(
migrationFilePath ||
path.join(process.cwd(), 'models/.protocols', path.basename(latestProtocolFile)),
path.join(migrationsPath, path.basename(latestProtocolFile)),
);

await applyMigrationStatements(appToken, flags, 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<Model>,
existingModels: Array<Model>,
): void => {
for (const existingModel of existingModels) {
const definedModel = definedModels.find((local) => local.slug === existingModel.slug);
if (definedModel && definedModel !== existingModel) {
logTableDiff(definedModel, existingModel, definedModel.slug);
}
}
};
33 changes: 12 additions & 21 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,18 @@ import { parseArgs } from 'node:util';

import initializeProject from '@/src/commands/init';
import logIn from '@/src/commands/login';
import migrate from '@/src/commands/migration';
import { printHelp, printVersion } from '@/src/utils/info';
import { BASE_FLAGS, type BaseFlags } from '@/src/utils/misc';
import { getSession } from '@/src/utils/session';

let values: Record<string, unknown>;
let flags: BaseFlags;
let positionals: Array<string>;

try {
({ values, positionals } = parseArgs({
({ values: flags, positionals } = parseArgs({
args: process.argv,
options: {
help: {
type: 'boolean',
short: 'h',
default: false,
},
version: {
type: 'boolean',
short: 'v',
default: false,
},
debug: {
type: 'boolean',
short: 'd',
default: false,
},
},
options: BASE_FLAGS,
strict: true,
allowPositionals: true,
}));
Expand All @@ -45,8 +31,8 @@ try {

const run = async (): Promise<void> => {
// Flags for printing useful information about the CLI.
if (values.help) return printHelp();
if (values.version) return printVersion();
if (flags.help) return printHelp();
if (flags.version) return printVersion();

// This ensures that people can accidentally type uppercase letters and still get the
// command they are looking for.
Expand Down Expand Up @@ -79,6 +65,11 @@ const run = async (): Promise<void> => {
// `init` sub command
if (normalizedPositionals.includes('init')) return initializeProject(positionals);

// Handle 'migration' command
if (normalizedPositionals.includes('migration')) {
return migrate(appToken, session?.token, flags, positionals);
}

// If no matching flags or commands were found, render the help, since we don't want to
// use the main `ronin` command for anything yet.
return printHelp();
Expand Down
Loading

0 comments on commit c83dc76

Please sign in to comment.