From 2c285d1a3806888aae9cb1bbbe83f0515aadc5ef Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 29 Jun 2025 15:52:30 -0700 Subject: [PATCH] split class and test-utils --- packages/core/package.json | 1 + .../core/src/class/launchql-refactored.ts | 257 ++++++++++++++++ packages/core/src/managers/config-manager.ts | 124 ++++++++ .../core/src/managers/extension-manager.ts | 162 +++++++++++ packages/core/src/managers/index.ts | 5 + packages/core/src/managers/module-manager.ts | 248 ++++++++++++++++ packages/core/src/managers/package-manager.ts | 218 ++++++++++++++ packages/core/src/managers/plan-generator.ts | 275 ++++++++++++++++++ packages/core/test-utils/test-fixtures.ts | 217 ++++++++++++++ packages/migrate/src/client.ts | 18 +- packages/migrate/src/index.ts | 6 +- packages/migrate/src/parser/plan.ts | 22 +- packages/migrate/src/types.ts | 16 +- packages/types/src/index.ts | 3 +- packages/types/src/sqitch.ts | 25 ++ 15 files changed, 1560 insertions(+), 37 deletions(-) create mode 100644 packages/core/src/class/launchql-refactored.ts create mode 100644 packages/core/src/managers/config-manager.ts create mode 100644 packages/core/src/managers/extension-manager.ts create mode 100644 packages/core/src/managers/index.ts create mode 100644 packages/core/src/managers/module-manager.ts create mode 100644 packages/core/src/managers/package-manager.ts create mode 100644 packages/core/src/managers/plan-generator.ts create mode 100644 packages/core/test-utils/test-fixtures.ts create mode 100644 packages/types/src/sqitch.ts diff --git a/packages/core/package.json b/packages/core/package.json index 2a2918cdb..e49e06c8e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -42,6 +42,7 @@ "@types/rimraf": "^4.0.5" }, "dependencies": { + "@launchql/logger": "^1.0.1", "@launchql/migrate": "^2.2.1", "@launchql/server-utils": "^2.1.15", "@launchql/templatizer": "^2.1.6", diff --git a/packages/core/src/class/launchql-refactored.ts b/packages/core/src/class/launchql-refactored.ts new file mode 100644 index 000000000..611990ab8 --- /dev/null +++ b/packages/core/src/class/launchql-refactored.ts @@ -0,0 +1,257 @@ +import { Logger } from '@launchql/logger'; +import { + ConfigManager, + ModuleManager, + ExtensionManager, + PackageManager, + PlanGenerator +} from '../managers'; + +export enum ProjectContext { + Workspace = 'workspace', + Module = 'module', + Unknown = 'unknown' +} + +/** + * Refactored LaunchQL class using manager pattern + * This class serves as a facade to the various managers + */ +export class LaunchQL { + private logger = new Logger('launchql'); + private configManager: ConfigManager; + private moduleManager: ModuleManager; + private extensionManager: ExtensionManager; + private packageManager: PackageManager; + private planGenerator: PlanGenerator; + + constructor(cwd: string = process.cwd()) { + // Initialize managers + this.configManager = new ConfigManager(cwd); + this.moduleManager = new ModuleManager(this.configManager); + this.extensionManager = new ExtensionManager(this.configManager, this.moduleManager); + this.packageManager = new PackageManager( + this.configManager, + this.moduleManager, + this.extensionManager + ); + this.planGenerator = new PlanGenerator( + this.configManager, + this.moduleManager, + this.extensionManager + ); + } + + // Delegate to ConfigManager + resetCwd(cwd: string): void { + this.configManager.resetCwd(cwd); + } + + isInsideAllowedDirs(cwd: string): boolean { + return this.configManager.isInsideAllowedDirs(cwd); + } + + ensureModule(): void { + this.configManager.ensureModule(); + } + + ensureWorkspace(): void { + this.configManager.ensureWorkspace(); + } + + getContext(): ProjectContext { + if (this.configManager.isInModule() && this.configManager.isInWorkspace()) { + return ProjectContext.Module; + } + if (this.configManager.isInWorkspace()) return ProjectContext.Workspace; + return ProjectContext.Unknown; + } + + isInWorkspace(): boolean { + return this.configManager.isInWorkspace(); + } + + isInModule(): boolean { + return this.configManager.isInModule(); + } + + getWorkspacePath(): string | undefined { + return this.configManager.getWorkspacePath(); + } + + getModulePath(): string | undefined { + return this.configManager.getModulePath(); + } + + get cwd(): string { + return this.configManager.getCwd(); + } + + get workspacePath(): string | undefined { + return this.configManager.getWorkspacePath(); + } + + get modulePath(): string | undefined { + return this.configManager.getModulePath(); + } + + get config(): any { + return this.configManager.getConfig(); + } + + get allowedDirs(): string[] { + return this.configManager.getAllowedDirs(); + } + + // Delegate to ModuleManager + clearCache(): void { + this.moduleManager.clearCache(); + this.extensionManager.clearCache(); + } + + getAvailableModules(): LaunchQL[] { + return this.moduleManager.getAvailableModules(); + } + + getModuleMap(): any { + return this.moduleManager.getModuleMap(); + } + + getModuleName(): string { + return this.moduleManager.getModuleName(); + } + + getModuleDependencies(): string[] { + return this.moduleManager.getModuleDependencies(); + } + + getModuleDependencyChanges(): string[] { + return this.moduleManager.getModuleDependencyChanges(); + } + + getLatestChange(): string | undefined { + return this.moduleManager.getLatestChange(); + } + + getLatestChangeAndVersion(): any { + return this.moduleManager.getLatestChangeAndVersion(); + } + + getModulePlan(): string { + return this.moduleManager.getModulePlan(); + } + + getModuleSQL(): string[] { + return this.moduleManager.getModuleSQL(); + } + + getModuleControlFile(): any { + return this.moduleManager.getModuleControlFile(); + } + + getModuleMakefile(): string | null { + return this.moduleManager.getModuleMakefile(); + } + + normalizeChangeName(name: string): string { + return this.moduleManager.normalizeChangeName(name); + } + + async initModule(name: string): Promise { + return this.moduleManager.initModule(name); + } + + // Delegate to ExtensionManager + getModuleInfo(): any { + return this.extensionManager.getModuleInfo(); + } + + getModuleExtensions(): string[] { + return this.extensionManager.getModuleExtensions(); + } + + getAvailableExtensions(): string[] { + return this.extensionManager.getAvailableExtensions(); + } + + getInstalledExtensions(): string[] { + return this.extensionManager.getInstalledExtensions(); + } + + async installExtension(packageSpec: string): Promise { + return this.extensionManager.installExtension(packageSpec); + } + + async writeExtensions(): Promise { + return this.extensionManager.writeExtensions(); + } + + // Delegate to PackageManager + async publishToDist(): Promise { + return this.packageManager.publishToDist(); + } + + setModuleDependencies(dependencies: string[]): void { + this.packageManager.setModuleDependencies(dependencies); + } + + getRequiredModules(): string[] { + return this.packageManager.getRequiredModules(); + } + + // Delegate to PlanGenerator + generatePlanFromFiles(): string { + return this.planGenerator.generatePlanFromFiles(); + } + + writeModulePlan(): void { + this.planGenerator.writeModulePlan(); + } + + addChangeToProject(name: string, dependencies?: string[]): void { + this.planGenerator.addChangeToProject(name, dependencies); + } + + validatePlanConsistency(): { valid: boolean; errors: string[] } { + return this.planGenerator.validatePlanConsistency(); + } + + // Convenience methods that combine multiple managers + async install(packages: string[]): Promise { + for (const pkg of packages) { + await this.installExtension(pkg); + } + } + + async deploy(): Promise { + // Validate plan first + const validation = this.validatePlanConsistency(); + if (!validation.valid) { + throw new Error(`Invalid plan: ${validation.errors.join(', ')}`); + } + + // Deploy logic would go here + this.logger.info('Deploy functionality to be implemented'); + } + + async revert(target?: string): Promise { + // Revert logic would go here + this.logger.info('Revert functionality to be implemented'); + } + + async verify(): Promise { + // Verify logic would go here + this.logger.info('Verify functionality to be implemented'); + } + + async status(): Promise { + // Status logic would go here + return { + workspace: this.getWorkspacePath(), + module: this.getModulePath(), + context: this.getContext(), + modules: this.getAvailableModules().map(m => m.getModuleName()), + extensions: this.getInstalledExtensions() + }; + } +} \ No newline at end of file diff --git a/packages/core/src/managers/config-manager.ts b/packages/core/src/managers/config-manager.ts new file mode 100644 index 000000000..1bbf96ae2 --- /dev/null +++ b/packages/core/src/managers/config-manager.ts @@ -0,0 +1,124 @@ +import fs from 'fs'; +import path from 'path'; +import { walkUp } from '../utils'; +import { Logger } from '@launchql/logger'; + +export interface LaunchQLConfig { + version?: string; + directories?: string[]; + [key: string]: any; +} + +export class ConfigManager { + private logger = new Logger('launchql:config'); + private config?: LaunchQLConfig; + private workspacePath?: string; + private modulePath?: string; + private allowedDirs: string[] = []; + private cwd: string; + + constructor(cwd: string = process.cwd()) { + this.cwd = cwd; + this.initialize(); + } + + private initialize(): void { + this.workspacePath = this.resolveLaunchqlPath(); + this.modulePath = this.resolveSqitchPath(); + + if (this.workspacePath) { + this.config = this.loadConfig(); + this.allowedDirs = this.loadAllowedDirs(); + } + } + + resetCwd(cwd: string): void { + this.cwd = cwd; + this.initialize(); + } + + private resolveLaunchqlPath(): string | undefined { + try { + return walkUp(this.cwd, 'launchql.json'); + } catch { + return undefined; + } + } + + private resolveSqitchPath(): string | undefined { + try { + return walkUp(this.cwd, 'sqitch.conf'); + } catch { + return undefined; + } + } + + private loadConfig(): LaunchQLConfig { + if (!this.workspacePath) { + throw new Error('Workspace path not found'); + } + const configPath = path.join(this.workspacePath, 'launchql.json'); + const content = fs.readFileSync(configPath, 'utf8'); + return JSON.parse(content); + } + + private loadAllowedDirs(): string[] { + if (!this.config?.directories) return []; + + return this.config.directories.map(dir => + path.resolve(this.workspacePath!, dir) + ); + } + + isInsideAllowedDirs(cwd: string): boolean { + return this.allowedDirs.some(dir => cwd.startsWith(dir)); + } + + getWorkspacePath(): string | undefined { + return this.workspacePath; + } + + getModulePath(): string | undefined { + return this.modulePath; + } + + getConfig(): LaunchQLConfig | undefined { + return this.config; + } + + get(key: string): T | undefined { + return this.config?.[key] as T; + } + + getAllowedDirs(): string[] { + return [...this.allowedDirs]; + } + + getCwd(): string { + return this.cwd; + } + + isInWorkspace(): boolean { + return !!this.workspacePath; + } + + isInModule(): boolean { + return ( + !!this.modulePath && + !!this.workspacePath && + this.modulePath.startsWith(this.workspacePath) + ); + } + + ensureWorkspace(): void { + if (!this.workspacePath) { + throw new Error('Not inside a workspace'); + } + } + + ensureModule(): void { + if (!this.modulePath) { + throw new Error('Not inside a module'); + } + } +} \ No newline at end of file diff --git a/packages/core/src/managers/extension-manager.ts b/packages/core/src/managers/extension-manager.ts new file mode 100644 index 000000000..b2a558d23 --- /dev/null +++ b/packages/core/src/managers/extension-manager.ts @@ -0,0 +1,162 @@ +import fs from 'fs'; +import path from 'path'; +import { spawn } from 'child_process'; +import { promisify } from 'util'; +import { Logger } from '@launchql/logger'; +import { ConfigManager } from './config-manager'; +import { ModuleManager } from './module-manager'; +import { + getExtensionInfo, + writeExtensions as writeExtensionsUtil, + getExtensionName, + getAvailableExtensions, + getInstalledExtensions, + ExtensionInfo, +} from '../extensions'; +import { extDeps } from '../deps'; +import { parse } from 'parse-package-name'; + +const rename = promisify(fs.rename); + +export class ExtensionManager { + private logger = new Logger('launchql:extensions'); + private _moduleInfo?: ExtensionInfo; + + constructor( + private configManager: ConfigManager, + private moduleManager: ModuleManager + ) {} + + clearCache(): void { + this._moduleInfo = undefined; + } + + getModuleInfo(): ExtensionInfo { + const workspacePath = this.configManager.getWorkspacePath(); + if (!workspacePath) return {} as ExtensionInfo; + + if (this._moduleInfo) return this._moduleInfo; + + this._moduleInfo = getExtensionInfo(workspacePath); + return this._moduleInfo; + } + + getModuleExtensions(): string[] { + this.configManager.ensureModule(); + const moduleName = this.moduleManager.getModuleName(); + const moduleMap = this.moduleManager.getModuleMap(); + + const extensions = extDeps(moduleName, moduleMap); + return [...extensions.resolved, ...extensions.external]; + } + + getAvailableExtensions(): string[] { + const moduleMap = this.moduleManager.getModuleMap(); + return getAvailableExtensions(moduleMap); + } + + getInstalledExtensions(): string[] { + const workspacePath = this.configManager.getWorkspacePath(); + if (!workspacePath) return []; + + return getInstalledExtensions(workspacePath); + } + + async installExtension(packageSpec: string): Promise { + const workspacePath = this.configManager.getWorkspacePath(); + if (!workspacePath) { + throw new Error('Not in a workspace'); + } + + // Validate package name + const parsed = parse(packageSpec); + if (!parsed.name) { + throw new Error(`Invalid package specification: ${packageSpec}`); + } + + // Install package safely using spawn instead of execSync + await this.installPackage(packageSpec, path.join(workspacePath, 'extensions')); + + // Move to correct location if needed + const installedPath = path.join(workspacePath, 'extensions', 'node_modules', parsed.name); + const targetPath = path.join(workspacePath, 'extensions', getExtensionName(parsed.name)); + + if (fs.existsSync(installedPath) && !fs.existsSync(targetPath)) { + await rename(installedPath, targetPath); + } + } + + private async installPackage(packageSpec: string, prefix: string): Promise { + return new Promise((resolve, reject) => { + const args = ['install', packageSpec, '--production', '--prefix', prefix]; + + this.logger.info(`Installing package: ${packageSpec}`); + + const proc = spawn('npm', args, { + cwd: this.configManager.getWorkspacePath(), + stdio: 'inherit' + }); + + proc.on('close', (code) => { + if (code === 0) { + this.logger.success(`Successfully installed: ${packageSpec}`); + resolve(); + } else { + reject(new Error(`npm install failed with code ${code}`)); + } + }); + + proc.on('error', (err) => { + reject(new Error(`Failed to spawn npm: ${err.message}`)); + }); + }); + } + + async writeExtensions(): Promise { + this.configManager.ensureModule(); + const workspacePath = this.configManager.getWorkspacePath()!; + const extensions = this.getModuleExtensions(); + + await writeExtensionsUtil(workspacePath, extensions); + } + + getExtensionDependencies(extensionName: string): string[] { + const workspacePath = this.configManager.getWorkspacePath(); + if (!workspacePath) return []; + + const extensionPath = path.join(workspacePath, 'extensions', extensionName); + if (!fs.existsSync(extensionPath)) return []; + + const controlFile = path.join(extensionPath, `${extensionName}.control`); + if (!fs.existsSync(controlFile)) return []; + + const content = fs.readFileSync(controlFile, 'utf8'); + const requiresMatch = content.match(/requires\s*=\s*'([^']+)'/); + + if (requiresMatch) { + return requiresMatch[1].split(',').map(dep => dep.trim()); + } + + return []; + } + + isExtensionInstalled(name: string): boolean { + const installed = this.getInstalledExtensions(); + return installed.includes(name); + } + + getExtensionVersion(name: string): string | null { + const workspacePath = this.configManager.getWorkspacePath(); + if (!workspacePath) return null; + + const extensionPath = path.join(workspacePath, 'extensions', name); + const controlFile = path.join(extensionPath, `${name}.control`); + + if (!fs.existsSync(controlFile)) return null; + + const content = fs.readFileSync(controlFile, 'utf8'); + const versionMatch = content.match(/default_version\s*=\s*'([^']+)'/); + + return versionMatch ? versionMatch[1] : null; + } +} \ No newline at end of file diff --git a/packages/core/src/managers/index.ts b/packages/core/src/managers/index.ts new file mode 100644 index 000000000..1af5754a3 --- /dev/null +++ b/packages/core/src/managers/index.ts @@ -0,0 +1,5 @@ +export { ConfigManager, LaunchQLConfig } from './config-manager'; +export { ModuleManager, ModuleInfo } from './module-manager'; +export { ExtensionManager } from './extension-manager'; +export { PackageManager, PackageInfo } from './package-manager'; +export { PlanGenerator } from './plan-generator'; \ No newline at end of file diff --git a/packages/core/src/managers/module-manager.ts b/packages/core/src/managers/module-manager.ts new file mode 100644 index 000000000..28943418a --- /dev/null +++ b/packages/core/src/managers/module-manager.ts @@ -0,0 +1,248 @@ +import fs from 'fs'; +import path from 'path'; +import * as glob from 'glob'; +import { Logger } from '@launchql/logger'; +import { ConfigManager } from './config-manager'; +import { + listModules, + latestChange, + latestChangeAndVersion, + getExtensionsAndModules, + getExtensionsAndModulesChanges, + ModuleMap +} from '../modules'; +import { getDeps } from '../deps'; +import { LaunchQL } from '../class/launchql-refactored'; + +export interface ModuleInfo { + name: string; + path: string; + dependencies?: string[]; + extensions?: string[]; +} + +export class ModuleManager { + private logger = new Logger('launchql:modules'); + private _moduleMap?: ModuleMap; + + constructor( + private configManager: ConfigManager + ) {} + + clearCache(): void { + this._moduleMap = undefined; + } + + getModuleMap(): ModuleMap { + const workspacePath = this.configManager.getWorkspacePath(); + if (!workspacePath) return {}; + + if (this._moduleMap) return this._moduleMap; + + this._moduleMap = listModules(workspacePath); + return this._moduleMap; + } + + getAvailableModules(): LaunchQL[] { + const workspacePath = this.configManager.getWorkspacePath(); + const config = this.configManager.getConfig(); + if (!workspacePath || !config) return []; + + const modules: LaunchQL[] = []; + const dirs = config.directories || []; + + for (const dir of dirs) { + const proj = new LaunchQL(path.join(workspacePath, dir)); + if (proj.isInModule()) { + modules.push(proj); + } + } + + return modules; + } + + getModuleName(): string { + this.configManager.ensureModule(); + const modulePath = this.configManager.getModulePath()!; + return path.basename(modulePath); + } + + getModuleDependencies(): string[] { + this.configManager.ensureModule(); + const modulePath = this.configManager.getModulePath()!; + const moduleName = this.getModuleName(); + + const deps = getDeps(modulePath, moduleName); + return [...deps.resolved, ...deps.external]; + } + + getModuleDependencyChanges(): string[] { + this.configManager.ensureModule(); + const modulePath = this.configManager.getModulePath()!; + const workspacePath = this.configManager.getWorkspacePath()!; + const moduleName = this.getModuleName(); + + const deps = getDeps(modulePath, moduleName); + const changes: string[] = []; + + // Get changes from resolved dependencies + deps.resolved.forEach(depName => { + const moduleMap = this.getModuleMap(); + if (moduleMap[depName]) { + const depChanges = getExtensionsAndModulesChanges( + depName, + moduleMap, + workspacePath + ); + // Collect native extensions and sqitch modules + changes.push(...depChanges.native); + depChanges.sqitch.forEach(mod => { + changes.push(`${mod.name}:${mod.latest}`); + }); + } + }); + + return [...new Set(changes)]; + } + + getLatestChange(): string | undefined { + this.configManager.ensureModule(); + const workspacePath = this.configManager.getWorkspacePath()!; + const moduleName = this.getModuleName(); + const moduleMap = this.getModuleMap(); + + try { + return latestChange(moduleName, moduleMap, workspacePath); + } catch { + return undefined; + } + } + + getLatestChangeAndVersion(): { change: string; version: string } | undefined { + this.configManager.ensureModule(); + const workspacePath = this.configManager.getWorkspacePath()!; + const moduleName = this.getModuleName(); + const moduleMap = this.getModuleMap(); + + try { + return latestChangeAndVersion(moduleName, moduleMap, workspacePath); + } catch { + return undefined; + } + } + + getModulePlan(): string { + this.configManager.ensureModule(); + const modulePath = this.configManager.getModulePath()!; + const planPath = path.join(modulePath, 'sqitch.plan'); + + if (!fs.existsSync(planPath)) { + return ''; + } + + return fs.readFileSync(planPath, 'utf8'); + } + + getModuleSQL(): string[] { + this.configManager.ensureModule(); + const modulePath = this.configManager.getModulePath()!; + const deployPath = path.join(modulePath, 'deploy'); + + if (!fs.existsSync(deployPath)) { + return []; + } + + const files = glob.sync('*.sql', { cwd: deployPath }); + return files.map(f => path.basename(f, '.sql')); + } + + getModuleControlFile(): any { + this.configManager.ensureModule(); + const modulePath = this.configManager.getModulePath()!; + const controlPath = path.join(modulePath, `${this.getModuleName()}.control`); + + if (!fs.existsSync(controlPath)) { + return null; + } + + const content = fs.readFileSync(controlPath, 'utf8'); + const control: any = {}; + + content.split('\n').forEach(line => { + const match = line.match(/^(\w+)\s*=\s*'?([^']+)'?$/); + if (match) { + control[match[1]] = match[2]; + } + }); + + return control; + } + + getModuleMakefile(): string | null { + this.configManager.ensureModule(); + const modulePath = this.configManager.getModulePath()!; + const makefilePath = path.join(modulePath, 'Makefile'); + + if (!fs.existsSync(makefilePath)) { + return null; + } + + return fs.readFileSync(makefilePath, 'utf8'); + } + + normalizeChangeName(name: string): string { + return name.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase(); + } + + private createModuleDirectory(modName: string): string { + const workspacePath = this.configManager.getWorkspacePath(); + const cwd = this.configManager.getCwd(); + const isRoot = workspacePath === cwd; + + if (isRoot) { + const modPath = path.join(workspacePath!, 'packages', 'deploy', modName); + fs.mkdirSync(modPath, { recursive: true }); + return modPath; + } else { + if (!this.configManager.isInsideAllowedDirs(cwd)) { + throw new Error( + `Current directory is not inside allowed directories: ${cwd}` + ); + } + const modPath = path.join(cwd, modName); + fs.mkdirSync(modPath, { recursive: true }); + return modPath; + } + } + + async initModule(name: string): Promise { + this.configManager.ensureWorkspace(); + const modulePath = this.createModuleDirectory(name); + + // Initialize sqitch in the module + await this.initModuleSqitch(modulePath); + + return modulePath; + } + + private async initModuleSqitch(modulePath: string): Promise { + // This will be replaced with native implementation + // For now, keeping the structure + const dirs = ['deploy', 'revert', 'verify']; + for (const dir of dirs) { + fs.mkdirSync(path.join(modulePath, dir), { recursive: true }); + } + + // Create sqitch.conf + const sqitchConf = `[core] + engine = pg + plan_file = sqitch.plan +[engine "pg"] + target = db:pg: +`; + fs.writeFileSync(path.join(modulePath, 'sqitch.conf'), sqitchConf); + + // Create empty sqitch.plan + fs.writeFileSync(path.join(modulePath, 'sqitch.plan'), '%syntax-version=1.0.0\n%project=\n\n'); + } +} \ No newline at end of file diff --git a/packages/core/src/managers/package-manager.ts b/packages/core/src/managers/package-manager.ts new file mode 100644 index 000000000..597a0fe6e --- /dev/null +++ b/packages/core/src/managers/package-manager.ts @@ -0,0 +1,218 @@ +import fs from 'fs'; +import path from 'path'; +import { Logger } from '@launchql/logger'; +import { ConfigManager } from './config-manager'; +import { ModuleManager } from './module-manager'; +import { ExtensionManager } from './extension-manager'; + +export interface PackageInfo { + name: string; + version: string; + dependencies?: Record; + devDependencies?: Record; +} + +export class PackageManager { + private logger = new Logger('launchql:packages'); + + constructor( + private configManager: ConfigManager, + private moduleManager: ModuleManager, + private extensionManager: ExtensionManager + ) {} + + async publishToDist(distFolder: string = 'dist'): Promise { + this.configManager.ensureModule(); + const modulePath = this.configManager.getModulePath()!; + const moduleName = this.moduleManager.getModuleName(); + + const controlFile = `${moduleName}.control`; + const fullDist = path.join(modulePath, distFolder); + + // Clean existing dist folder + if (fs.existsSync(fullDist)) { + fs.rmSync(fullDist, { recursive: true, force: true }); + } + + // Create dist folder + fs.mkdirSync(fullDist, { recursive: true }); + + // Define what to copy + const folders = ['deploy', 'revert', 'sql', 'verify']; + const files = ['Makefile', 'package.json', 'sqitch.conf', 'sqitch.plan', controlFile]; + + // Add README file regardless of casing + const readmeFile = fs.readdirSync(modulePath).find(f => /^readme\.md$/i.test(f)); + if (readmeFile) { + files.push(readmeFile); + } + + // Copy folders + folders.forEach(folder => { + const src = path.join(modulePath, folder); + const dest = path.join(fullDist, folder); + if (fs.existsSync(src)) { + fs.cpSync(src, dest, { recursive: true }); + } + }); + + // Copy files + files.forEach(file => { + const src = path.join(modulePath, file); + const dest = path.join(fullDist, file); + if (fs.existsSync(src)) { + fs.copyFileSync(src, dest); + } + }); + + this.logger.info(`Published module ${moduleName} to ${distFolder}`); + } + + getPackageJson(): PackageInfo | null { + const modulePath = this.configManager.getModulePath(); + if (!modulePath) return null; + + const packageJsonPath = path.join(modulePath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) return null; + + const content = fs.readFileSync(packageJsonPath, 'utf8'); + return JSON.parse(content); + } + + updatePackageJson(updates: Partial): void { + this.configManager.ensureModule(); + const modulePath = this.configManager.getModulePath()!; + const packageJsonPath = path.join(modulePath, 'package.json'); + + let packageJson: PackageInfo; + if (fs.existsSync(packageJsonPath)) { + const content = fs.readFileSync(packageJsonPath, 'utf8'); + packageJson = JSON.parse(content); + } else { + packageJson = { + name: this.moduleManager.getModuleName(), + version: '1.0.0' + }; + } + + // Merge updates + Object.assign(packageJson, updates); + + // Write back + fs.writeFileSync( + packageJsonPath, + JSON.stringify(packageJson, null, 2) + '\n' + ); + } + + setModuleDependencies(dependencies: string[]): void { + const packageJson = this.getPackageJson() || { + name: this.moduleManager.getModuleName(), + version: '1.0.0' + }; + + // Convert dependency array to object with versions + const deps: Record = {}; + dependencies.forEach(dep => { + // Check if it's an extension + if (this.extensionManager.isExtensionInstalled(dep)) { + const version = this.extensionManager.getExtensionVersion(dep); + deps[dep] = version || '*'; + } else { + // It's a module dependency + deps[dep] = '*'; + } + }); + + this.updatePackageJson({ + dependencies: deps + }); + } + + getRequiredModules(): string[] { + const packageJson = this.getPackageJson(); + if (!packageJson?.dependencies) return []; + + return Object.keys(packageJson.dependencies).filter(dep => { + // Filter out extensions, keep only modules + return !this.extensionManager.isExtensionInstalled(dep); + }); + } + + getRequiredExtensions(): string[] { + const packageJson = this.getPackageJson(); + if (!packageJson?.dependencies) return []; + + return Object.keys(packageJson.dependencies).filter(dep => { + // Keep only extensions + return this.extensionManager.isExtensionInstalled(dep); + }); + } + + validateDependencies(): { valid: boolean; missing: string[] } { + const required = this.getRequiredModules(); + const available = this.moduleManager.getAvailableModules().map(m => m.getModuleName()); + + const missing = required.filter(req => !available.includes(req)); + + return { + valid: missing.length === 0, + missing + }; + } + + async createDistribution(): Promise { + this.configManager.ensureModule(); + const modulePath = this.configManager.getModulePath()!; + const distPath = path.join(modulePath, 'dist'); + + // Create dist directory + fs.mkdirSync(distPath, { recursive: true }); + + // Copy necessary files + const filesToCopy = [ + 'sqitch.plan', + 'sqitch.conf', + 'package.json', + 'README.md', + 'LICENSE' + ]; + + for (const file of filesToCopy) { + const srcPath = path.join(modulePath, file); + if (fs.existsSync(srcPath)) { + const destPath = path.join(distPath, file); + fs.copyFileSync(srcPath, destPath); + } + } + + // Copy SQL directories + const sqlDirs = ['deploy', 'revert', 'verify']; + for (const dir of sqlDirs) { + const srcDir = path.join(modulePath, dir); + const destDir = path.join(distPath, dir); + if (fs.existsSync(srcDir)) { + this.copyDirectory(srcDir, destDir); + } + } + + return distPath; + } + + private copyDirectory(src: string, dest: string): void { + fs.mkdirSync(dest, { recursive: true }); + + const entries = fs.readdirSync(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + this.copyDirectory(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } + } +} \ No newline at end of file diff --git a/packages/core/src/managers/plan-generator.ts b/packages/core/src/managers/plan-generator.ts new file mode 100644 index 000000000..f908d1279 --- /dev/null +++ b/packages/core/src/managers/plan-generator.ts @@ -0,0 +1,275 @@ +import fs from 'fs'; +import path from 'path'; +import { Logger } from '@launchql/logger'; +import { SqitchPlanEntry } from '@launchql/types'; +import { ConfigManager } from './config-manager'; +import { ModuleManager } from './module-manager'; +import { ExtensionManager } from './extension-manager'; +import { getExtensionsAndModulesChanges } from '../modules'; + +export class PlanGenerator { + private logger = new Logger('launchql:plans'); + + constructor( + private configManager: ConfigManager, + private moduleManager: ModuleManager, + private extensionManager: ExtensionManager + ) {} + + generatePlanFromFiles(): string { + this.configManager.ensureModule(); + const modulePath = this.configManager.getModulePath()!; + const workspacePath = this.configManager.getWorkspacePath()!; + const moduleName = this.moduleManager.getModuleName(); + + // Get all SQL files + const sqlFiles = this.moduleManager.getModuleSQL(); + + // Get dependencies + const moduleDeps = this.moduleManager.getModuleDependencies(); + const extensionDeps = this.extensionManager.getModuleExtensions(); + + // Get dependency changes + const depChanges = this.moduleManager.getModuleDependencyChanges(); + + // Build plan + const lines: string[] = [ + '%syntax-version=1.0.0', + `%project=${moduleName}`, + '' + ]; + + // Add extension dependencies as comments + if (extensionDeps.length > 0) { + lines.push('# Extensions required:'); + extensionDeps.forEach(ext => { + lines.push(`# - ${ext}`); + }); + lines.push(''); + } + + // Add module dependencies as comments + if (moduleDeps.length > 0) { + lines.push('# Module dependencies:'); + moduleDeps.forEach(mod => { + lines.push(`# - ${mod}`); + }); + lines.push(''); + } + + // Add changes + sqlFiles.forEach((sqlFile, index) => { + const changeName = this.moduleManager.normalizeChangeName(sqlFile); + const deps: string[] = []; + + // First change depends on all dependency changes + if (index === 0 && depChanges.length > 0) { + deps.push(...depChanges); + } + + // Subsequent changes depend on previous change + if (index > 0) { + const prevChange = this.moduleManager.normalizeChangeName(sqlFiles[index - 1]); + deps.push(prevChange); + } + + // Build change line + let line = changeName; + if (deps.length > 0) { + line += ` [${deps.join(' ')}]`; + } + line += ` ${this.getTimestamp()} ${this.getPlanner()}`; + + lines.push(line); + }); + + return lines.join('\n') + '\n'; + } + + writeModulePlan(): void { + this.configManager.ensureModule(); + const modulePath = this.configManager.getModulePath()!; + const plan = this.generatePlanFromFiles(); + + const planPath = path.join(modulePath, 'sqitch.plan'); + fs.writeFileSync(planPath, plan); + + this.logger.success(`Written plan to ${planPath}`); + } + + parsePlanContent(planContent: string): SqitchPlanEntry[] { + const entries: SqitchPlanEntry[] = []; + const lines = planContent.split('\n'); + + for (const line of lines) { + // Skip empty lines and comments + if (!line.trim() || line.startsWith('#') || line.startsWith('%')) { + continue; + } + + // Parse change line + const match = line.match(/^(\S+)(?:\s+\[([^\]]+)\])?(?:\s+(\S+))?(?:\s+(\S+))?(?:\s+#\s*(.*))?$/); + if (match) { + const [, name, deps, timestamp, planner, note] = match; + entries.push({ + name, + dependencies: deps ? deps.split(/\s+/) : undefined, + timestamp, + planner, + note + }); + } + } + + return entries; + } + + validatePlanConsistency(): { valid: boolean; errors: string[] } { + this.configManager.ensureModule(); + const errors: string[] = []; + + try { + const plan = this.moduleManager.getModulePlan(); + if (!plan) { + errors.push('No sqitch.plan file found'); + return { valid: false, errors }; + } + + const entries = this.parsePlanContent(plan); + const sqlFiles = this.moduleManager.getModuleSQL(); + + // Check all SQL files have entries + for (const sqlFile of sqlFiles) { + const changeName = this.moduleManager.normalizeChangeName(sqlFile); + const hasEntry = entries.some(e => e.name === changeName); + if (!hasEntry) { + errors.push(`Missing plan entry for ${sqlFile}`); + } + } + + // Check all entries have SQL files + for (const entry of entries) { + const hasSql = sqlFiles.some(f => + this.moduleManager.normalizeChangeName(f) === entry.name + ); + if (!hasSql) { + errors.push(`Missing SQL file for plan entry ${entry.name}`); + } + } + + // Validate dependencies + const availableChanges = new Set(entries.map(e => e.name)); + const depChanges = new Set(this.moduleManager.getModuleDependencyChanges()); + + for (const entry of entries) { + if (entry.dependencies) { + for (const dep of entry.dependencies) { + if (!availableChanges.has(dep) && !depChanges.has(dep)) { + errors.push(`Unknown dependency ${dep} in change ${entry.name}`); + } + } + } + } + + } catch (error) { + errors.push(`Error validating plan: ${error instanceof Error ? error.message : String(error)}`); + } + + return { + valid: errors.length === 0, + errors + }; + } + + private getTimestamp(): string { + const now = new Date(); + return now.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, 'Z'); + } + + private getPlanner(): string { + // Try to get from git config + try { + const gitUser = process.env.GIT_AUTHOR_NAME || process.env.USER || 'Unknown'; + const gitEmail = process.env.GIT_AUTHOR_EMAIL || 'unknown@example.com'; + return `${gitUser} <${gitEmail}>`; + } catch { + return 'Unknown '; + } + } + + addChangeToProject(name: string, dependencies?: string[]): void { + this.configManager.ensureModule(); + const modulePath = this.configManager.getModulePath()!; + + // Normalize change name + const changeName = this.moduleManager.normalizeChangeName(name); + + // Read current plan + const planPath = path.join(modulePath, 'sqitch.plan'); + let plan = ''; + if (fs.existsSync(planPath)) { + plan = fs.readFileSync(planPath, 'utf8'); + } else { + // Create new plan + plan = `%syntax-version=1.0.0\n%project=${this.moduleManager.getModuleName()}\n\n`; + } + + // Parse existing entries + const entries = this.parsePlanContent(plan); + + // Check if change already exists + if (entries.some(e => e.name === changeName)) { + throw new Error(`Change ${changeName} already exists in plan`); + } + + // Build new entry + let entry = changeName; + if (dependencies && dependencies.length > 0) { + entry += ` [${dependencies.join(' ')}]`; + } else if (entries.length > 0) { + // Depend on last change if no explicit dependencies + entry += ` [${entries[entries.length - 1].name}]`; + } + entry += ` ${this.getTimestamp()} ${this.getPlanner()}`; + + // Append to plan + plan = plan.trimEnd() + '\n' + entry + '\n'; + + // Write plan + fs.writeFileSync(planPath, plan); + + // Create SQL file templates + this.createChangeTemplates(changeName); + + this.logger.success(`Added change ${changeName} to plan`); + } + + private createChangeTemplates(changeName: string): void { + const modulePath = this.configManager.getModulePath()!; + const dirs = ['deploy', 'revert', 'verify']; + + for (const dir of dirs) { + const dirPath = path.join(modulePath, dir); + fs.mkdirSync(dirPath, { recursive: true }); + + const filePath = path.join(dirPath, `${changeName}.sql`); + if (!fs.existsSync(filePath)) { + let content = `-- ${dir}/${changeName}.sql\n`; + + switch (dir) { + case 'deploy': + content += `-- Deploy ${changeName}\n\nBEGIN;\n\n-- XXX Add DDLs here.\n\nCOMMIT;\n`; + break; + case 'revert': + content += `-- Revert ${changeName}\n\nBEGIN;\n\n-- XXX Add DDLs here.\n\nCOMMIT;\n`; + break; + case 'verify': + content += `-- Verify ${changeName}\n\nBEGIN;\n\n-- XXX Add verifications here.\n\nROLLBACK;\n`; + break; + } + + fs.writeFileSync(filePath, content); + } + } + } +} \ No newline at end of file diff --git a/packages/core/test-utils/test-fixtures.ts b/packages/core/test-utils/test-fixtures.ts new file mode 100644 index 000000000..98f998283 --- /dev/null +++ b/packages/core/test-utils/test-fixtures.ts @@ -0,0 +1,217 @@ +import { LaunchQL } from '../src/class/launchql-refactored'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const { mkdtempSync, rmSync, cpSync } = fs; + +export const FIXTURES_PATH = path.resolve(__dirname, '../../../__fixtures__'); + +export const getFixturePath = (...paths: string[]) => + path.join(FIXTURES_PATH, ...paths); + +export const cleanText = (text: string): string => + text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .join('\n'); + +/** + * Test fixture for the refactored LaunchQL class + * Provides utilities for creating temporary test environments + */ +export class LaunchQLTestFixture { + readonly tempDir: string; + readonly tempFixtureDir: string; + readonly getFixturePath: (...paths: string[]) => string; + readonly getLaunchQL: (workspacePath?: string[]) => LaunchQL; + readonly getModuleLaunchQL: (workspacePath: string[], moduleName: string) => LaunchQL; + + constructor(...fixturePath: string[]) { + const originalFixtureDir = getFixturePath(...fixturePath); + this.tempDir = mkdtempSync(path.join(os.tmpdir(), 'launchql-test-')); + this.tempFixtureDir = path.join(this.tempDir, ...fixturePath); + + cpSync(originalFixtureDir, this.tempFixtureDir, { recursive: true }); + + this.getFixturePath = (...paths: string[]) => + path.join(this.tempFixtureDir, ...paths); + + // Create LaunchQL instance for workspace or specific path + this.getLaunchQL = (workspacePath?: string[]): LaunchQL => { + const cwd = workspacePath + ? this.getFixturePath(...workspacePath) + : this.tempFixtureDir; + return new LaunchQL(cwd); + }; + + // Create LaunchQL instance for a specific module + this.getModuleLaunchQL = (workspacePath: string[], moduleName: string): LaunchQL => { + const workspace = new LaunchQL(this.getFixturePath(...workspacePath)); + const moduleMap = workspace.getModuleMap(); + const meta = moduleMap[moduleName]; + if (!meta) throw new Error(`Module ${moduleName} not found in workspace`); + return new LaunchQL(this.getFixturePath(...workspacePath, meta.path)); + }; + } + + /** + * Get the full path to a file/directory within the fixture + */ + fixturePath(...paths: string[]) { + return path.join(this.tempFixtureDir, ...paths); + } + + /** + * Create a new file in the fixture + */ + createFile(relativePath: string, content: string) { + const fullPath = this.fixturePath(relativePath); + const dir = path.dirname(fullPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(fullPath, content); + } + + /** + * Read a file from the fixture + */ + readFile(relativePath: string): string { + return fs.readFileSync(this.fixturePath(relativePath), 'utf-8'); + } + + /** + * Check if a file exists in the fixture + */ + fileExists(relativePath: string): boolean { + return fs.existsSync(this.fixturePath(relativePath)); + } + + /** + * List files in a directory + */ + listFiles(relativePath: string = ''): string[] { + const dir = this.fixturePath(relativePath); + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) { + return []; + } + return fs.readdirSync(dir); + } + + /** + * Clean up the temporary directory + */ + cleanup() { + rmSync(this.tempDir, { recursive: true, force: true }); + } +} + +/** + * Helper to create a minimal workspace fixture + */ +export function createMinimalWorkspace(): LaunchQLTestFixture { + const fixture = new LaunchQLTestFixture(); + + // Create minimal launchql.json + fixture.createFile('launchql.json', JSON.stringify({ + modules: {}, + extensions: {} + }, null, 2)); + + return fixture; +} + +/** + * Helper to create a workspace with a module + */ +export function createWorkspaceWithModule(moduleName: string, modulePath: string = 'packages/' + moduleName): LaunchQLTestFixture { + const fixture = new LaunchQLTestFixture(); + + // Create workspace config + fixture.createFile('launchql.json', JSON.stringify({ + modules: { + [moduleName]: { + path: modulePath + } + }, + extensions: {} + }, null, 2)); + + // Create module directory + fixture.createFile(path.join(modulePath, '.gitkeep'), ''); + + // Create module sqitch.conf + fixture.createFile(path.join(modulePath, 'sqitch.conf'), `[core] + engine = pg + plan_file = sqitch.plan +[engine "pg"] + target = db:pg: +[deploy] + verify = true +[rebase] + verify = true +`); + + // Create empty plan + fixture.createFile(path.join(modulePath, 'sqitch.plan'), `%syntax-version=1.0.0 +%project=${moduleName} + +`); + + // Create deploy/revert/verify directories + fixture.createFile(path.join(modulePath, 'deploy', '.gitkeep'), ''); + fixture.createFile(path.join(modulePath, 'revert', '.gitkeep'), ''); + fixture.createFile(path.join(modulePath, 'verify', '.gitkeep'), ''); + + return fixture; +} + +/** + * Helper to add a change to a module + */ +export function addChangeToModule( + fixture: LaunchQLTestFixture, + modulePath: string, + changeName: string, + dependencies: string[] = [] +): void { + const deployPath = path.join(modulePath, 'deploy', `${changeName}.sql`); + const revertPath = path.join(modulePath, 'revert', `${changeName}.sql`); + const verifyPath = path.join(modulePath, 'verify', `${changeName}.sql`); + + // Create SQL files + fixture.createFile(deployPath, `-- Deploy ${changeName}\nBEGIN;\n\n-- Your deploy SQL here\n\nCOMMIT;\n`); + fixture.createFile(revertPath, `-- Revert ${changeName}\nBEGIN;\n\n-- Your revert SQL here\n\nCOMMIT;\n`); + fixture.createFile(verifyPath, `-- Verify ${changeName}\nBEGIN;\n\n-- Your verify SQL here\n\nROLLBACK;\n`); + + // Update plan file + const planPath = path.join(modulePath, 'sqitch.plan'); + const currentPlan = fixture.readFile(planPath); + const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); + const deps = dependencies.length > 0 ? `[${dependencies.join(' ')}] ` : ''; + const newLine = `${changeName} ${deps}${timestamp} Test User # Add ${changeName}\n`; + + fixture.createFile(planPath, currentPlan + newLine); +} + +/** + * Helper to create a test database configuration + */ +export function createTestDatabaseConfig(fixture: LaunchQLTestFixture, modulePath: string): void { + const sqitchConf = `[core] + engine = pg + plan_file = sqitch.plan +[engine "pg"] + target = db:pg://postgres@localhost/testdb +[target "test"] + uri = db:pg://postgres@localhost/testdb +[deploy] + verify = true +[rebase] + verify = true +`; + + fixture.createFile(path.join(modulePath, 'sqitch.conf'), sqitchConf); +} \ No newline at end of file diff --git a/packages/migrate/src/client.ts b/packages/migrate/src/client.ts index 33e3a945b..0215adc92 100644 --- a/packages/migrate/src/client.ts +++ b/packages/migrate/src/client.ts @@ -14,7 +14,7 @@ import { VerifyResult, StatusResult } from './types'; -import { parsePlanFile, getChangesInOrder } from './parser/plan'; +import { parsePlanForExecution, getChangesForDeployment } from './parser/plan'; import { hashFile } from './utils/hash'; import { readScript, scriptExists } from './utils/fs'; import { cleanSql } from './clean'; @@ -87,8 +87,8 @@ export class LaunchQLMigrate { await this.initialize(); const { project, targetDatabase, planPath, deployPath, verifyPath, toChange, useTransaction = true } = options; - const plan = parsePlanFile(planPath); - const changes = getChangesInOrder(planPath); + const plan = parsePlanForExecution(planPath); + const changes = getChangesForDeployment(planPath); const deployed: string[] = []; const skipped: string[] = []; @@ -182,8 +182,8 @@ export class LaunchQLMigrate { await this.initialize(); const { project, targetDatabase, planPath, revertPath, toChange, useTransaction = true } = options; - const plan = parsePlanFile(planPath); - const changes = getChangesInOrder(planPath, true); // Reverse order for revert + const plan = parsePlanForExecution(planPath); + const changes = getChangesForDeployment(planPath, true); // Reverse order for revert const reverted: string[] = []; const skipped: string[] = []; @@ -254,8 +254,8 @@ export class LaunchQLMigrate { await this.initialize(); const { project, targetDatabase, planPath, verifyPath } = options; - const plan = parsePlanFile(planPath); - const changes = getChangesInOrder(planPath); + const plan = parsePlanForExecution(planPath); + const changes = getChangesForDeployment(planPath); const verified: string[] = []; const failed: string[] = []; @@ -456,8 +456,8 @@ export class LaunchQLMigrate { * Get pending changes (in plan but not deployed) */ async getPendingChanges(planPath: string, targetDatabase: string): Promise { - const plan = parsePlanFile(planPath); - const allChanges = getChangesInOrder(planPath); + const plan = parsePlanForExecution(planPath); + const allChanges = getChangesForDeployment(planPath); const targetPool = getPgPool({ ...this.pgConfig, diff --git a/packages/migrate/src/index.ts b/packages/migrate/src/index.ts index 46f091367..da00e7ca8 100644 --- a/packages/migrate/src/index.ts +++ b/packages/migrate/src/index.ts @@ -1,6 +1,10 @@ export { LaunchQLMigrate } from './client'; export * from './types'; -export { parsePlanFile, getChangeNamesFromPlan, getChangesInOrder } from './parser/plan'; +export { + parsePlanForExecution, + getChangeNamesFromPlan, + getChangesForDeployment +} from './parser/plan'; export { hashFile, hashString } from './utils/hash'; export { readScript, scriptExists } from './utils/fs'; export { withTransaction, TransactionContext, TransactionOptions } from './utils/transaction'; diff --git a/packages/migrate/src/parser/plan.ts b/packages/migrate/src/parser/plan.ts index 405a180a2..57e12d084 100644 --- a/packages/migrate/src/parser/plan.ts +++ b/packages/migrate/src/parser/plan.ts @@ -1,8 +1,8 @@ import { readFileSync } from 'fs'; -import { Change, PlanFile } from '../types'; +import { SqitchChange, SqitchPlan } from '@launchql/types'; /** - * Parse a Sqitch plan file into a structured format + * Parse a Sqitch plan file into a structured format for execution * * Plan line format: * change_name [dep1 dep2] timestamp planner # comment @@ -10,13 +10,13 @@ import { Change, PlanFile } from '../types'; * Example: * procedures/verify_constraint [pg-utilities:procedures/tg_update_timestamps] 2017-08-11T08:11:51Z skitch # add procedures/verify_constraint */ -export function parsePlanFile(planPath: string): PlanFile { +export function parsePlanForExecution(planPath: string): SqitchPlan { const content = readFileSync(planPath, 'utf-8'); const lines = content.split('\n'); let project = ''; let uri = ''; - const changes: Change[] = []; + const changes: SqitchChange[] = []; for (const line of lines) { const trimmed = line.trim(); @@ -53,7 +53,7 @@ export function parsePlanFile(planPath: string): PlanFile { /** * Parse a single change line from a plan file */ -function parseChangeLine(line: string): Change | null { +function parseChangeLine(line: string): SqitchChange | null { // Regular expression to parse change lines // Matches: change_name [deps] timestamp planner # comment const regex = /^(\S+)(?:\s+\[([^\]]*)\])?(?:\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z))?(?:\s+(\S+))?(?:\s+<([^>]+)>)?(?:\s+#\s+(.*))?$/; @@ -63,7 +63,7 @@ function parseChangeLine(line: string): Change | null { return null; } - const [, name, depsStr, timestamp, planner, email, comment] = match; + const [, name, depsStr, timestamp, planner, email, note] = match; // Parse dependencies const dependencies = depsStr @@ -76,7 +76,7 @@ function parseChangeLine(line: string): Change | null { timestamp, planner, email, - comment + note }; } @@ -84,14 +84,14 @@ function parseChangeLine(line: string): Change | null { * Extract just the change names from a plan file (for compatibility with existing code) */ export function getChangeNamesFromPlan(planPath: string): string[] { - const plan = parsePlanFile(planPath); + const plan = parsePlanForExecution(planPath); return plan.changes.map(change => change.name); } /** - * Get changes in deployment order (forward) or revert order (reverse) + * Get changes for deployment operations in forward or reverse order */ -export function getChangesInOrder(planPath: string, reverse: boolean = false): Change[] { - const plan = parsePlanFile(planPath); +export function getChangesForDeployment(planPath: string, reverse: boolean = false): SqitchChange[] { + const plan = parsePlanForExecution(planPath); return reverse ? [...plan.changes].reverse() : plan.changes; } \ No newline at end of file diff --git a/packages/migrate/src/types.ts b/packages/migrate/src/types.ts index 16caad6c2..56ec23505 100644 --- a/packages/migrate/src/types.ts +++ b/packages/migrate/src/types.ts @@ -1,19 +1,5 @@ import { PgConfig } from 'pg-env'; - -export interface Change { - name: string; - dependencies: string[]; - timestamp?: string; - planner?: string; - email?: string; - comment?: string; -} - -export interface PlanFile { - project: string; - uri?: string; - changes: Change[]; -} +export { SqitchChange, SqitchPlan } from '@launchql/types'; export interface DeployOptions { project: string; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index eb90d0616..36fc1aca1 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,4 +1,5 @@ export * from './launchql'; export * from './env'; export * from './error'; -export * from './error-factory'; \ No newline at end of file +export * from './error-factory'; +export * from './sqitch'; \ No newline at end of file diff --git a/packages/types/src/sqitch.ts b/packages/types/src/sqitch.ts new file mode 100644 index 000000000..178a3c6e5 --- /dev/null +++ b/packages/types/src/sqitch.ts @@ -0,0 +1,25 @@ +// Shared Sqitch types for both migrate and core modules + +export interface SqitchChange { + name: string; + dependencies: string[]; + timestamp?: string; + planner?: string; + email?: string; + note?: string; +} + +export interface SqitchPlan { + project: string; + uri?: string; + changes: SqitchChange[]; +} + +export interface SqitchPlanEntry { + name: string; + dependencies?: string[]; + timestamp?: string; + planner?: string; + email?: string; + note?: string; +} \ No newline at end of file