UNPKG

@launchql/core

Version:
1,087 lines (1,084 loc) 69.9 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.LaunchQLPackage = exports.PackageContext = void 0; const env_1 = require("@launchql/env"); const logger_1 = require("@launchql/logger"); const types_1 = require("@launchql/types"); const yanse_1 = __importDefault(require("yanse")); const child_process_1 = require("child_process"); const fs_1 = __importDefault(require("fs")); const glob = __importStar(require("glob")); const os_1 = __importDefault(require("os")); const parse_package_name_1 = require("parse-package-name"); const path_1 = __importStar(require("path")); const pg_cache_1 = require("pg-cache"); const template_scaffold_1 = require("../template-scaffold"); const extensions_1 = require("../../extensions/extensions"); const files_1 = require("../../files"); const parser_1 = require("../../files/plan/parser"); const validators_1 = require("../../files/plan/validators"); const generator_1 = require("../../files/plan/generator"); const files_2 = require("../../files"); const writer_1 = require("../../files/extension/writer"); const client_1 = require("../../migrate/client"); const modules_1 = require("../../modules/modules"); const package_1 = require("../../packaging/package"); const deps_1 = require("../../resolution/deps"); const target_utils_1 = require("../../utils/target-utils"); const logger = new logger_1.Logger('launchql'); function getUTCTimestamp(d = new Date()) { return (d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0') + 'T' + String(d.getUTCHours()).padStart(2, '0') + ':' + String(d.getUTCMinutes()).padStart(2, '0') + ':' + String(d.getUTCSeconds()).padStart(2, '0') + 'Z'); } function sortObjectByKey(obj) { return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b))); } const getNow = () => process.env.NODE_ENV === 'test' ? getUTCTimestamp(new Date('2017-08-11T08:11:51Z')) : getUTCTimestamp(new Date()); /** * Truncates workspace extensions to include only modules from the target onwards. * This prevents processing unnecessary modules that come before the target in dependency order. * * @param workspaceExtensions - The full workspace extension dependencies * @param targetName - The target module name to truncate from * @returns Truncated extensions starting from the target module */ const truncateExtensionsToTarget = (workspaceExtensions, targetName) => { const targetIndex = workspaceExtensions.resolved.indexOf(targetName); if (targetIndex === -1) { return workspaceExtensions; } return { resolved: workspaceExtensions.resolved.slice(targetIndex), external: workspaceExtensions.external }; }; var PackageContext; (function (PackageContext) { PackageContext["Outside"] = "outside"; PackageContext["Workspace"] = "workspace-root"; PackageContext["Module"] = "module"; PackageContext["ModuleInsideWorkspace"] = "module-in-workspace"; })(PackageContext || (exports.PackageContext = PackageContext = {})); class LaunchQLPackage { cwd; workspacePath; modulePath; config; allowedDirs = []; allowedParentDirs = []; _moduleMap; _moduleInfo; constructor(cwd = process.cwd()) { this.resetCwd(cwd); } resetCwd(cwd) { this.cwd = cwd; this.workspacePath = (0, env_1.resolveLaunchqlPath)(this.cwd); this.modulePath = this.resolveSqitchPath(); if (this.workspacePath) { this.config = this.loadConfigSync(); this.allowedDirs = this.loadAllowedDirs(); this.allowedParentDirs = this.loadAllowedParentDirs(); } } resolveSqitchPath() { try { return (0, env_1.walkUp)(this.cwd, 'pgpm.plan'); } catch { return undefined; } } loadConfigSync() { return (0, env_1.loadConfigSyncFromDir)(this.workspacePath); } loadAllowedDirs() { const globs = this.config?.packages ?? []; const dirs = globs.flatMap(pattern => glob.sync(path_1.default.join(this.workspacePath, pattern))); const resolvedDirs = dirs.map(dir => path_1.default.resolve(dir)); // Remove duplicates by converting to Set and back to array return [...new Set(resolvedDirs)]; } loadAllowedParentDirs() { const globs = this.config?.packages ?? []; const parentDirs = globs.map(pattern => { // Remove glob characters (*, **, ?, etc.) to get the base path const basePath = pattern.replace(/[*?[\]{}]/g, '').replace(/\/$/, ''); return path_1.default.resolve(this.workspacePath, basePath); }); // Remove duplicates by converting to Set and back to array return [...new Set(parentDirs)]; } isInsideAllowedDirs(cwd) { return this.allowedDirs.some(dir => cwd.startsWith(dir)); } isParentOfAllowedDirs(cwd) { const resolvedCwd = path_1.default.resolve(cwd); return this.allowedDirs.some(dir => dir.startsWith(resolvedCwd + path_1.default.sep)) || this.allowedParentDirs.some(dir => path_1.default.resolve(dir) === resolvedCwd); } createModuleDirectory(modName) { this.ensureWorkspace(); const isRoot = path_1.default.resolve(this.workspacePath) === path_1.default.resolve(this.cwd); const isParentDir = this.isParentOfAllowedDirs(this.cwd); const isInsideModule = this.isInsideAllowedDirs(this.cwd); let targetPath; if (isRoot) { const packagesDir = path_1.default.join(this.cwd, 'packages'); fs_1.default.mkdirSync(packagesDir, { recursive: true }); targetPath = path_1.default.join(packagesDir, modName); } else if (isParentDir) { targetPath = path_1.default.join(this.cwd, modName); } else if (isInsideModule) { console.error(yanse_1.default.red(`Error: Cannot create a module inside an existing module. Please run 'lql init' from the workspace root or from a parent directory like 'packages/'.`)); process.exit(1); } else { console.error(yanse_1.default.red(`Error: You must be inside the workspace root, a parent directory of modules (like 'packages/'), or inside one of the workspace packages: ${this.allowedDirs.join(', ')}`)); process.exit(1); } fs_1.default.mkdirSync(targetPath, { recursive: true }); return targetPath; } ensureModule() { if (!this.modulePath) throw new Error('Not inside a module'); } ensureWorkspace() { if (!this.workspacePath) throw new Error('Not inside a workspace'); } getContext() { if (this.modulePath && this.workspacePath) { const rel = path_1.default.relative(this.workspacePath, this.modulePath); const nested = !rel.startsWith('..') && !path_1.default.isAbsolute(rel); return nested ? PackageContext.ModuleInsideWorkspace : PackageContext.Module; } if (this.modulePath) return PackageContext.Module; if (this.workspacePath) return PackageContext.Workspace; return PackageContext.Outside; } isInWorkspace() { return this.getContext() === PackageContext.Workspace; } isInModule() { return (this.getContext() === PackageContext.Module || this.getContext() === PackageContext.ModuleInsideWorkspace); } getWorkspacePath() { return this.workspacePath; } getModulePath() { return this.modulePath; } clearCache() { delete this._moduleInfo; delete this._moduleMap; } // ──────────────── Workspace-wide ──────────────── async getModules() { if (!this.workspacePath || !this.config) return []; const dirs = this.loadAllowedDirs(); const results = []; for (const dir of dirs) { const proj = new LaunchQLPackage(dir); if (proj.isInModule()) { results.push(proj); } } return results; } /** * List all modules by parsing .control files in the workspace directory. * Handles naming collisions by preferring the shortest path. */ listModules() { if (!this.workspacePath) return {}; const moduleFiles = glob.sync(`${this.workspacePath}/**/*.control`).filter((file) => !/node_modules/.test(file)); // Group files by module name to handle collisions const filesByName = new Map(); moduleFiles.forEach((file) => { const moduleName = path_1.default.basename(file).split('.control')[0]; if (!filesByName.has(moduleName)) { filesByName.set(moduleName, []); } filesByName.get(moduleName).push(file); }); // For each module name, pick the shortest path in case of collisions const selectedFiles = new Map(); filesByName.forEach((files, moduleName) => { if (files.length === 1) { selectedFiles.set(moduleName, files[0]); } else { // Multiple files with same name - pick shortest path const shortestFile = files.reduce((shortest, current) => current.length < shortest.length ? current : shortest); selectedFiles.set(moduleName, shortestFile); } }); // Parse the selected control files return Array.from(selectedFiles.entries()).reduce((acc, [moduleName, file]) => { const module = (0, files_2.parseControlFile)(file, this.workspacePath); acc[moduleName] = module; return acc; }, {}); } getModuleMap() { if (!this.workspacePath) return {}; if (this._moduleMap) return this._moduleMap; this._moduleMap = this.listModules(); return this._moduleMap; } getAvailableModules() { const modules = this.getModuleMap(); return (0, extensions_1.getAvailableExtensions)(modules); } getModuleProject(name) { this.ensureWorkspace(); if (this.isInModule() && name === this.getModuleName()) { return this; } const modules = this.getModuleMap(); if (!modules[name]) { throw types_1.errors.MODULE_NOT_FOUND({ name }); } const modulePath = path_1.default.resolve(this.workspacePath, modules[name].path); return new LaunchQLPackage(modulePath); } // ──────────────── Module-scoped ──────────────── getModuleInfo() { this.ensureModule(); if (!this._moduleInfo) { this._moduleInfo = (0, files_2.getExtensionInfo)(this.cwd); } return this._moduleInfo; } getModuleName() { this.ensureModule(); return (0, files_2.getExtensionName)(this.cwd); } getRequiredModules() { this.ensureModule(); const info = this.getModuleInfo(); return (0, files_2.getInstalledExtensions)(info.controlFile); } setModuleDependencies(modules) { this.ensureModule(); // Validate for circular dependencies this.validateModuleDependencies(modules); (0, files_2.writeExtensions)(this.cwd, modules); } validateModuleDependencies(modules) { const currentModuleName = this.getModuleName(); if (modules.includes(currentModuleName)) { throw types_1.errors.CIRCULAR_DEPENDENCY({ module: currentModuleName, dependency: currentModuleName }); } // Check for circular dependencies by examining each module's dependencies const visited = new Set(); const visiting = new Set(); const checkCircular = (moduleName, path = []) => { if (visiting.has(moduleName)) { throw types_1.errors.CIRCULAR_DEPENDENCY({ module: path.join(' -> '), dependency: moduleName }); } if (visited.has(moduleName)) { return; } visiting.add(moduleName); // More complex dependency resolution would require loading other modules' dependencies visiting.delete(moduleName); visited.add(moduleName); }; modules.forEach(module => checkCircular(module, [currentModuleName])); } initModuleSqitch(modName, targetPath) { const plan = (0, files_1.generatePlan)({ moduleName: modName, uri: modName, entries: [] }); (0, files_1.writePlan)(path_1.default.join(targetPath, 'pgpm.plan'), plan); // Create deploy, revert, and verify directories const dirs = ['deploy', 'revert', 'verify']; dirs.forEach(dir => { const dirPath = path_1.default.join(targetPath, dir); if (!fs_1.default.existsSync(dirPath)) { fs_1.default.mkdirSync(dirPath, { recursive: true }); } }); } async initModule(options) { this.ensureWorkspace(); const targetPath = this.createModuleDirectory(options.name); const answers = { ...options.answers, name: options.name, moduleDesc: options.description, description: options.description, author: options.author, extensions: options.extensions }; await (0, template_scaffold_1.scaffoldTemplate)({ type: 'module', outputDir: targetPath, templateRepo: options.templateRepo ?? template_scaffold_1.DEFAULT_TEMPLATE_REPO, branch: options.branch, // Don't set default templatePath - let scaffoldTemplate use metadata-driven resolution templatePath: options.templatePath, answers, noTty: options.noTty ?? false, cacheTtlMs: options.cacheTtlMs ?? template_scaffold_1.DEFAULT_TEMPLATE_TTL_MS, toolName: options.toolName ?? template_scaffold_1.DEFAULT_TEMPLATE_TOOL_NAME, cwd: this.cwd }); this.initModuleSqitch(options.name, targetPath); (0, files_2.writeExtensions)(targetPath, options.extensions); } // ──────────────── Dependency Analysis ──────────────── getLatestChange(moduleName) { const modules = this.getModuleMap(); return (0, modules_1.latestChange)(moduleName, modules, this.workspacePath); } getLatestChangeAndVersion(moduleName) { const modules = this.getModuleMap(); return (0, modules_1.latestChangeAndVersion)(moduleName, modules, this.workspacePath); } getModuleExtensions() { this.ensureModule(); const moduleName = this.getModuleName(); const moduleMap = this.getModuleMap(); return (0, deps_1.resolveExtensionDependencies)(moduleName, moduleMap); } getModuleDependencies(moduleName) { const modules = this.getModuleMap(); const { native, sqitch } = (0, modules_1.getExtensionsAndModules)(moduleName, modules); return { native, modules: sqitch }; } getModuleDependencyChanges(moduleName) { const modules = this.getModuleMap(); const { native, sqitch } = (0, modules_1.getExtensionsAndModulesChanges)(moduleName, modules, this.workspacePath); return { native, modules: sqitch }; } // ──────────────── Plans ──────────────── getModulePlan() { this.ensureModule(); const planPath = path_1.default.join(this.getModulePath(), 'pgpm.plan'); return fs_1.default.readFileSync(planPath, 'utf8'); } getModuleControlFile() { this.ensureModule(); const info = this.getModuleInfo(); return fs_1.default.readFileSync(info.controlFile, 'utf8'); } getModuleMakefile() { this.ensureModule(); const info = this.getModuleInfo(); return fs_1.default.readFileSync(info.Makefile, 'utf8'); } getModuleSQL() { this.ensureModule(); const info = this.getModuleInfo(); return fs_1.default.readFileSync(info.sqlFile, 'utf8'); } generateModulePlan(options) { this.ensureModule(); const info = this.getModuleInfo(); const moduleName = info.extname; // Get raw dependencies and resolved list const tagResolution = options.includeTags === true ? 'preserve' : 'internal'; let { resolved, deps } = (0, deps_1.resolveDependencies)(this.cwd, moduleName, { tagResolution }); // Helper to extract module name from a change reference const getModuleName = (change) => { const colonIndex = change.indexOf(':'); return colonIndex > 0 ? change.substring(0, colonIndex) : null; }; // Helper to determine if a change is truly from an external package const isExternalChange = (change) => { const changeModule = getModuleName(change); return changeModule !== null && changeModule !== moduleName; }; // Helper to normalize change name (remove package prefix) const normalizeChangeName = (change) => { return change.includes(':') ? change.split(':').pop() : change; }; // Clean up the resolved list to handle both formats const uniqueChangeNames = new Set(); const normalizedResolved = []; // First, add local changes without prefixes resolved.forEach(change => { const normalized = normalizeChangeName(change); // Skip if we've already added this change if (uniqueChangeNames.has(normalized)) return; // Skip truly external changes - they should only be in dependencies if (isExternalChange(change)) return; uniqueChangeNames.add(normalized); normalizedResolved.push(normalized); }); // Clean up the deps object const normalizedDeps = {}; // Process each deps entry Object.keys(deps).forEach(key => { // Normalize the key - strip "/deploy/" and ".sql" if present let normalizedKey = key; if (normalizedKey.startsWith('/deploy/')) { normalizedKey = normalizedKey.substring(8); // Remove "/deploy/" } if (normalizedKey.endsWith('.sql')) { normalizedKey = normalizedKey.substring(0, normalizedKey.length - 4); // Remove ".sql" } // Skip keys for truly external changes - we only want local changes as keys if (isExternalChange(normalizedKey)) return; // Normalize the key for all changes, removing any same-package prefix const cleanKey = normalizeChangeName(normalizedKey); // Build the standard key format for our normalized deps const standardKey = `/deploy/${cleanKey}.sql`; // Initialize the dependencies array for this key if it doesn't exist normalizedDeps[standardKey] = normalizedDeps[standardKey] || []; // Add dependencies, handling both formats const dependencies = deps[key] || []; dependencies.forEach(dep => { // For truly external dependencies, keep the full reference if (isExternalChange(dep)) { if (!normalizedDeps[standardKey].includes(dep)) { normalizedDeps[standardKey].push(dep); } } else { // For same-package dependencies, normalize by removing prefix const normalizedDep = normalizeChangeName(dep); if (!normalizedDeps[standardKey].includes(normalizedDep)) { normalizedDeps[standardKey].push(normalizedDep); } } }); }); // Update with normalized versions resolved = normalizedResolved; deps = normalizedDeps; // Process external dependencies if needed const includePackages = options.includePackages === true; const preferTags = options.includeTags === true; if (includePackages && this.workspacePath) { const depData = this.getModuleDependencyChanges(moduleName); if (resolved.length > 0) { const firstKey = `/deploy/${resolved[0]}.sql`; deps[firstKey] = deps[firstKey] || []; depData.modules.forEach(m => { const extModuleName = m.name; const hasTagDependency = deps[firstKey].some(dep => dep.startsWith(`${extModuleName}:@`)); let depToken = `${extModuleName}:${m.latest}`; if (preferTags) { try { const moduleMap = this.getModuleMap(); const modInfo = moduleMap[extModuleName]; if (modInfo && this.workspacePath) { const planPath = path_1.default.join(this.workspacePath, modInfo.path, 'pgpm.plan'); const parsed = (0, parser_1.parsePlanFile)(planPath); const changes = parsed.data?.changes || []; const tags = parsed.data?.tags || []; if (changes.length > 0 && tags.length > 0) { const lastChangeName = changes[changes.length - 1]?.name; const lastTag = tags[tags.length - 1]; if (lastTag && lastTag.change === lastChangeName) { depToken = `${extModuleName}:@${lastTag.name}`; } } } } catch { } } if (!hasTagDependency && !deps[firstKey].includes(depToken)) { deps[firstKey].push(depToken); } }); } } // For debugging - log the cleaned structures // console.log("CLEAN DEPS GRAPH", JSON.stringify(deps, null, 2)); // console.log("CLEAN RES GRAPH", JSON.stringify(resolved, null, 2)); // Prepare entries for the plan file const entries = resolved.map(res => { const key = `/deploy/${res}.sql`; const dependencies = deps[key] || []; // Filter out dependencies that match the current change name // This prevents listing a change as dependent on itself const filteredDeps = dependencies.filter(dep => normalizeChangeName(dep) !== res); return { change: res, dependencies: filteredDeps, comment: `add ${res}` }; }); // Use the package-files package to generate the plan return (0, files_1.generatePlan)({ moduleName, uri: options.uri, entries }); } writeModulePlan(options) { this.ensureModule(); const name = this.getModuleName(); const plan = this.generateModulePlan(options); const moduleMap = this.getModuleMap(); const mod = moduleMap[name]; const planPath = path_1.default.join(this.workspacePath, mod.path, 'pgpm.plan'); // Use the package-files package to write the plan (0, files_1.writePlan)(planPath, plan); } /** * Add a tag to the current module's plan file */ addTag(tagName, changeName, comment) { this.ensureModule(); if (!this.modulePath) { throw types_1.errors.PATH_NOT_FOUND({ path: 'module path', type: 'module' }); } // Validate tag name if (!(0, validators_1.isValidTagName)(tagName)) { throw types_1.errors.INVALID_NAME({ name: tagName, type: 'tag', rules: "Tag names must follow Sqitch naming rules and cannot contain '/'" }); } const planPath = path_1.default.join(this.modulePath, 'pgpm.plan'); // Parse existing plan file const planResult = (0, parser_1.parsePlanFile)(planPath); if (!planResult.data) { throw types_1.errors.PLAN_PARSE_ERROR({ planPath, errors: planResult.errors.map(e => e.message).join(', ') }); } const plan = planResult.data; let targetChange = changeName; if (!targetChange) { if (plan.changes.length === 0) { throw new Error('No changes found in plan file. Cannot add tag without a target change.'); } targetChange = plan.changes[plan.changes.length - 1].name; } else { // Validate that the specified change exists const changeExists = plan.changes.some(c => c.name === targetChange); if (!changeExists) { throw types_1.errors.CHANGE_NOT_FOUND({ change: targetChange }); } } // Check if tag already exists const existingTag = plan.tags.find(t => t.name === tagName); if (existingTag) { throw new Error(`Tag '${tagName}' already exists and points to change '${existingTag.change}'.`); } // Create new tag const newTag = { name: tagName, change: targetChange, timestamp: (0, generator_1.getNow)(), planner: 'launchql', email: 'launchql@5b0c196eeb62', comment }; plan.tags.push(newTag); // Write updated plan file (0, files_1.writePlanFile)(planPath, plan); } /** * Add a change to the current module's plan file and create SQL files */ addChange(changeName, dependencies, comment) { // Validate change name first if (!changeName || !changeName.trim()) { throw new Error('Change name is required'); } if (!(0, validators_1.isValidChangeName)(changeName)) { throw types_1.errors.INVALID_NAME({ name: changeName, type: 'change', rules: "Change names must follow Sqitch naming rules" }); } if (!this.isInWorkspace() && !this.isInModule()) { throw new Error('This command must be run inside a LaunchQL workspace or module.'); } if (this.isInModule()) { this.ensureModule(); if (!this.modulePath) { throw types_1.errors.PATH_NOT_FOUND({ path: 'module path', type: 'module' }); } this.addChangeToModule(changeName, dependencies, comment); return; } throw new Error('When running from workspace root, please specify --package or run from within a module directory.'); } /** * Add change to the current module (internal helper) */ addChangeToModule(changeName, dependencies, comment) { const planPath = path_1.default.join(this.modulePath, 'pgpm.plan'); // Parse existing plan file const planResult = (0, parser_1.parsePlanFile)(planPath); if (!planResult.data) { throw types_1.errors.PLAN_PARSE_ERROR({ planPath, errors: planResult.errors.map(e => e.message).join(', ') }); } const plan = planResult.data; // Check if change already exists const existingChange = plan.changes.find(c => c.name === changeName); if (existingChange) { throw new Error(`Change '${changeName}' already exists in plan.`); } // Validate dependencies exist if provided if (dependencies && dependencies.length > 0) { const currentPackage = plan.package; for (const dep of dependencies) { // Parse the dependency to check if it's a cross-module reference const parsed = (0, validators_1.parseReference)(dep); if (parsed && parsed.package && parsed.package !== currentPackage) { continue; } const depExists = plan.changes.some(c => c.name === dep); if (!depExists) { throw new Error(`Dependency '${dep}' not found in plan. Add dependencies before referencing them.`); } } } // Create new change const newChange = { name: changeName, dependencies: dependencies || [], timestamp: (0, generator_1.getNow)(), planner: 'launchql', email: 'launchql@5b0c196eeb62', comment: comment || `add ${changeName}` }; plan.changes.push(newChange); // Write updated plan file (0, files_1.writePlanFile)(planPath, plan); // Create SQL files this.createSqlFiles(changeName, dependencies || [], comment || `add ${changeName}`); } /** * Create deploy/revert/verify SQL files for a change */ createSqlFiles(changeName, dependencies, comment) { if (!this.modulePath) { throw types_1.errors.PATH_NOT_FOUND({ path: 'module path', type: 'module' }); } const createdFiles = []; const createSqlFile = (type, content) => { const dir = path_1.default.dirname(changeName); const fileName = path_1.default.basename(changeName); const typeDir = path_1.default.join(this.modulePath, type); const targetDir = path_1.default.join(typeDir, dir); const filePath = path_1.default.join(targetDir, `${fileName}.sql`); fs_1.default.mkdirSync(targetDir, { recursive: true }); fs_1.default.writeFileSync(filePath, content); // Track the relative path from module root const relativePath = path_1.default.relative(this.modulePath, filePath); createdFiles.push(relativePath); }; // Create deploy file const deployContent = `-- Deploy: ${changeName} -- made with <3 @ constructive.io ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join('\n') + '\n' : ''} -- Add your deployment SQL here `; // Create revert file const revertContent = `-- Revert: ${changeName} -- Add your revert SQL here `; // Create verify file const verifyContent = `-- Verify: ${changeName} -- Add your verification SQL here `; createSqlFile('deploy', deployContent); createSqlFile('revert', revertContent); createSqlFile('verify', verifyContent); // Log created files to stdout process.stdout.write('\n✔ Files created\n\n'); createdFiles.forEach(file => { process.stdout.write(` create ${file}\n`); }); process.stdout.write('\n✨ All set!\n\n'); } // ──────────────── Packaging and npm ──────────────── publishToDist(distFolder = 'dist') { this.ensureModule(); const modPath = this.modulePath; // use modulePath, not cwd const name = this.getModuleName(); const controlFile = `${name}.control`; const fullDist = path_1.default.join(modPath, distFolder); if (fs_1.default.existsSync(fullDist)) { fs_1.default.rmSync(fullDist, { recursive: true, force: true }); } fs_1.default.mkdirSync(fullDist, { recursive: true }); const folders = ['deploy', 'revert', 'sql', 'verify']; const files = ['Makefile', 'package.json', 'pgpm.plan', controlFile]; // Add README file regardless of casing const readmeFile = fs_1.default.readdirSync(modPath).find(f => /^readme\.md$/i.test(f)); if (readmeFile) { files.push(readmeFile); // Include it in the list of files to copy } for (const folder of folders) { const src = path_1.default.join(modPath, folder); if (fs_1.default.existsSync(src)) { fs_1.default.cpSync(src, path_1.default.join(fullDist, folder), { recursive: true }); } } for (const file of files) { const src = path_1.default.join(modPath, file); if (!fs_1.default.existsSync(src)) { throw new Error(`Missing required file: ${file}`); } fs_1.default.cpSync(src, path_1.default.join(fullDist, file)); } } /** * Installs an extension npm package into the local skitch extensions directory, * and automatically adds it to the current module’s package.json dependencies. */ async installModules(...pkgstrs) { this.ensureWorkspace(); this.ensureModule(); const originalDir = process.cwd(); const skitchExtDir = path_1.default.join(this.workspacePath, 'extensions'); const pkgJsonPath = path_1.default.join(this.modulePath, 'package.json'); if (!fs_1.default.existsSync(pkgJsonPath)) { throw new Error(`No package.json found at module path: ${this.modulePath}`); } const pkgData = JSON.parse(fs_1.default.readFileSync(pkgJsonPath, 'utf-8')); pkgData.dependencies = pkgData.dependencies || {}; const newlyAdded = []; for (const pkgstr of pkgstrs) { const { name } = (0, parse_package_name_1.parse)(pkgstr); const tempDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'lql-install-')); try { process.chdir(tempDir); (0, child_process_1.execSync)(`npm install ${pkgstr} --production --prefix ./extensions`, { stdio: 'inherit' }); const matches = glob.sync('./extensions/**/pgpm.plan'); const installs = matches.map((conf) => { const fullConf = (0, path_1.resolve)(conf); const extDir = (0, path_1.dirname)(fullConf); const relativeDir = extDir.split('node_modules/')[1]; const dstDir = path_1.default.join(skitchExtDir, relativeDir); return { src: extDir, dst: dstDir, pkg: relativeDir }; }); for (const { src, dst, pkg } of installs) { if (fs_1.default.existsSync(dst)) { fs_1.default.rmSync(dst, { recursive: true, force: true }); } fs_1.default.mkdirSync(path_1.default.dirname(dst), { recursive: true }); (0, child_process_1.execSync)(`mv "${src}" "${dst}"`); logger.success(`✔ installed ${pkg}`); const pkgJsonFile = path_1.default.join(dst, 'package.json'); if (!fs_1.default.existsSync(pkgJsonFile)) { throw new Error(`Missing package.json in installed extension: ${dst}`); } const { version } = JSON.parse(fs_1.default.readFileSync(pkgJsonFile, 'utf-8')); pkgData.dependencies[name] = `${version}`; const extensionName = (0, files_2.getExtensionName)(dst); newlyAdded.push(extensionName); } } finally { fs_1.default.rmSync(tempDir, { recursive: true, force: true }); process.chdir(originalDir); } } const { dependencies, devDependencies, ...rest } = pkgData; const finalPkgData = { ...rest }; if (dependencies) { finalPkgData.dependencies = sortObjectByKey(dependencies); } if (devDependencies) { finalPkgData.devDependencies = sortObjectByKey(devDependencies); } fs_1.default.writeFileSync(pkgJsonPath, JSON.stringify(finalPkgData, null, 2)); logger.success(`📦 Updated package.json with: ${pkgstrs.join(', ')}`); // ─── Update .control file with actual extension names ────────────── const currentDeps = this.getRequiredModules(); const updatedDeps = Array.from(new Set([...currentDeps, ...newlyAdded])).sort(); (0, files_2.writeExtensions)(this.modulePath, updatedDeps); } // ──────────────── Package Operations ──────────────── /** * Get the set of modules that have been deployed to the database */ async getDeployedModules(pgConfig) { try { const client = new client_1.LaunchQLMigrate(pgConfig); await client.initialize(); const status = await client.status(); return new Set(status.map(s => s.package)); } catch (error) { if (error.code === '42P01' || error.code === '3F000') { return new Set(); } throw error; } } async resolveWorkspaceExtensionDependencies(opts) { const modules = this.getModuleMap(); const allModuleNames = Object.keys(modules); if (allModuleNames.length === 0) { return { resolved: [], external: [] }; } // Create a virtual module that depends on all workspace modules const virtualModuleName = '_virtual/workspace'; const virtualModuleMap = { ...modules, [virtualModuleName]: { requires: allModuleNames } }; const { resolved, external } = (0, deps_1.resolveExtensionDependencies)(virtualModuleName, virtualModuleMap); let filteredResolved = resolved.filter((moduleName) => moduleName !== virtualModuleName); // Filter by deployment status if requested if (opts?.filterDeployed && opts?.pgConfig) { const deployedModules = await this.getDeployedModules(opts.pgConfig); filteredResolved = filteredResolved.filter(module => deployedModules.has(module)); } return { resolved: filteredResolved, external: external }; } parsePackageTarget(target) { let name; let toChange; if (!target) { const context = this.getContext(); if (context === PackageContext.Module || context === PackageContext.ModuleInsideWorkspace) { name = this.getModuleName(); } else if (context === PackageContext.Workspace) { const modules = this.getModuleMap(); const moduleNames = Object.keys(modules); if (moduleNames.length === 0) { throw new Error('No modules found in workspace'); } name = null; // Indicates workspace-wide operation } else { throw new Error('Not in a LaunchQL workspace or module'); } } else { const parsed = (0, target_utils_1.parseTarget)(target); name = parsed.packageName; toChange = parsed.toChange; } return { name, toChange }; } async deploy(opts, target, recursive = true) { const log = new logger_1.Logger('deploy'); const { name, toChange } = this.parsePackageTarget(target); if (recursive) { // Cache for fast deployment const deployFastCache = {}; const getCacheKey = (pg, name, database) => { const { host, port, user } = pg ?? {}; return `${host}:${port}:${user}:${database}:${name}`; }; const modules = this.getModuleMap(); let extensions; if (name === null) { // When name is null, deploy ALL modules in the workspace extensions = await this.resolveWorkspaceExtensionDependencies(); } else { const moduleProject = this.getModuleProject(name); extensions = moduleProject.getModuleExtensions(); } const pgPool = (0, pg_cache_1.getPgPool)(opts.pg); const targetDescription = name === null ? 'all modules' : name; log.success(`🚀 Starting deployment to database ${opts.pg.database}...`); for (const extension of extensions.resolved) { try { if (extensions.external.includes(extension)) { const msg = `CREATE EXTENSION IF NOT EXISTS "${extension}" CASCADE;`; log.info(`📥 Installing external extension: ${extension}`); await pgPool.query(msg); } else { const modulePath = (0, path_1.resolve)(this.workspacePath, modules[extension].path); log.info(`📂 Deploying local module: ${extension}`); if (opts.deployment.fast) { const localProject = this.getModuleProject(extension); const cacheKey = getCacheKey(opts.pg, extension, opts.pg.database); if (opts.deployment.cache && deployFastCache[cacheKey]) { log.warn(`⚡ Using cached pkg for ${extension}.`); await pgPool.query(deployFastCache[cacheKey].sql); continue; } let pkg; try { pkg = await (0, package_1.packageModule)(localProject.modulePath, { usePlan: opts.deployment.usePlan, extension: false }); } catch (err) { const errorLines = []; errorLines.push(`❌ Failed to package module "${extension}" at path: ${modulePath}`); errorLines.push(` Module Path: ${modulePath}`); errorLines.push(` Workspace Path: ${this.workspacePath}`); errorLines.push(` Error Code: ${err.code || 'N/A'}`); errorLines.push(` Error Message: ${err.message || 'Unknown error'}`); if (err.code === 'ENOENT') { errorLines.push('💡 Hint: File or directory not found. Check if the module path is correct.'); } else if (err.code === 'EACCES') { errorLines.push('💡 Hint: Permission denied. Check file permissions.'); } else if (err.message && err.message.includes('pgpm.plan')) { errorLines.push('💡 Hint: pgpm.plan file issue. Check if the plan file exists and is valid.'); } log.error(errorLines.join('\n')); console.error(err); throw types_1.errors.DEPLOYMENT_FAILED({ type: 'Deployment', module: extension }); } await pgPool.query(pkg.sql); if (opts.deployment.cache) { deployFastCache[cacheKey] = pkg; } } else { try { const client = new client_1.LaunchQLMigrate(opts.pg); // Only apply toChange to the target module, not its dependencies const moduleToChange = extension === name ? toChange : undefined; const result = await client.deploy({ modulePath, toChange: moduleToChange, useTransaction: opts.deployment.useTx, logOnly: opts.deployment.logOnly, usePlan: opts.deployment.usePlan }); if (result.failed) { throw types_1.errors.OPERATION_FAILED({ operation: 'Deployment', target: result.failed }); } } catch (deployError) { log.error(`❌ Deployment failed for module ${extension}`); console.error(deployError); throw types_1.errors.DEPLOYMENT_FAILED({ type: 'Deployment', module: extension }); } } } } catch (err) { log.error(`🛑 Error during deployment: ${err instanceof Error ? err.message : err}`); console.error(err); throw types_1.errors.DEPLOYMENT_FAILED({ type: 'Deployment', module: extension }); } } log.success(`✅ Deployment complete for ${targetDescription}.`); } else { if (name === null) { throw types_1.errors.WORKSPACE_OPERATION_ERROR({ operation: 'deployment' }); } const moduleProject = this.getModuleProject(name); const modulePath = moduleProject.getModulePath(); if (!modulePath) { throw types_1.errors.PATH_NOT_FOUND({ path: name, type: 'module' }); } const client = new client_1.LaunchQLMigrate(opts.pg); const result = await client.deploy({ modulePath, toChange, useTransaction: opts.deployment?.useTx, logOnly: opts.deployment?.logOnly, usePlan: opts.deployment?.usePlan }); if (result.failed) { throw types_1.errors.OPERATION_FAILED({ operation: 'Deployment', target: result.failed }); } log.success(`✅ Single module deployment complete for ${name}.`); } } /** * Reverts database changes for modules. Unlike verify operations, revert operations * modify database state and must ensure dependent modules are reverted before their * dependencies to prevent database constraint violations. */ async revert(opts, target, recursive = true) { const log = new logger_1.Logger('revert'); const { name, toChange } = this.parsePackageTarget(target); if (recursive) { const modules = this.getModuleMap(); // Mirror deploy logic: find all modules that depend on the target module let extensionsToRevert; if (name === null) { // When name is null, revert ALL deployed modules in the workspace extensionsToRevert = await this.resolveWorkspaceExtensionDependencies({ filterDeployed: true, pgConfig: opts.pg }); } else { // Always use workspace-wide resolution in recursive mode, but filter to deployed modules const workspaceExtensions = await this.resolveWorkspaceExtensionDependencies({ filterDeployed: true, pgConfig: opts.pg }); extensionsToRevert = truncateExtensionsToTarget(workspaceExtensions, name); } const pgPool = (0, pg_cache_1.getPgPool)(opts.pg); const targetDescription = name === null ? 'all modules' : name; log.success(`🧹 Starting revert process on database ${opts.pg.database}...`); const reversedExtensions = [...extensionsToRevert.resolved].reverse(); for (const extension of reversedExtensions) { try { if (extensionsToRevert.external.includes(extension)) { const msg = `DROP EXTENSION IF EXISTS "${extension}"