@launchql/core
Version:
LaunchQL Package and Migration Tools
527 lines (526 loc) • 23.1 kB
JavaScript
import { readFileSync } from 'fs';
import { sync as glob } from 'glob';
import { join, relative } from 'path';
import { LaunchQLPackage } from '../core/class/launchql';
import { parsePlanFile } from '../files/plan/parser';
import { errors } from '@launchql/types';
/**
* Core dependency resolution algorithm that handles circular dependency detection
* and topological sorting of dependencies. This unified implementation eliminates
* code duplication between getDeps, resolveExtensionDependencies, and resolveDependencies.
*
* @param deps - The dependency graph mapping modules to their dependencies
* @param external - Array to collect external dependencies
* @param options - Configuration options for customizing resolver behavior
* @returns A function that performs dependency resolution with the given configuration
*/
function createDependencyResolver(deps, external, options) {
const { handleExternalDep, transformModule, makeKey = (module) => module, extname } = options;
return function dep_resolve(sqlmodule, resolved, unresolved) {
unresolved.push(sqlmodule);
let moduleToResolve = sqlmodule;
let edges;
let returnEarly = false;
if (transformModule) {
const result = transformModule(sqlmodule, extname);
moduleToResolve = result.module;
edges = result.edges;
returnEarly = result.returnEarly || false;
}
else {
edges = deps[makeKey(sqlmodule)];
}
// Handle external dependencies if no edges found
if (!edges) {
if (handleExternalDep) {
handleExternalDep(sqlmodule, deps, external);
edges = deps[sqlmodule] || [];
}
else {
throw errors.MODULE_NOT_FOUND({ name: sqlmodule });
}
}
if (returnEarly) {
const index = unresolved.indexOf(sqlmodule);
unresolved.splice(index, 1);
return;
}
// Process each dependency
for (const dep of edges) {
if (!resolved.includes(dep)) {
if (unresolved.includes(dep)) {
throw errors.CIRCULAR_DEPENDENCY({ module: moduleToResolve, dependency: dep });
}
dep_resolve(dep, resolved, unresolved);
}
}
resolved.push(moduleToResolve);
const index = unresolved.indexOf(sqlmodule);
unresolved.splice(index, 1);
};
}
/**
* Generates a standardized key for SQL deployment files
* @param sqlmodule - The module name (e.g., 'users/create')
* @returns The standardized file path key (e.g., '/deploy/users/create.sql')
*/
const makeKey = (sqlmodule) => `/deploy/${sqlmodule}.sql`;
/**
* Resolves dependencies for extension modules using a pre-built module map.
* This is a simpler version that works with module metadata rather than parsing SQL files.
*
* @param name - The name of the module to resolve dependencies for
* @param modules - Record mapping module names to their dependency requirements
* @returns Object containing external dependencies and resolved dependency order
*/
export const resolveExtensionDependencies = (name, modules) => {
if (!modules[name]) {
throw errors.MODULE_NOT_FOUND({ name });
}
const external = [];
const deps = Object.keys(modules).reduce((memo, key) => {
memo[key] = modules[key].requires;
return memo;
}, {});
// Handle external dependencies for resolveExtensionDependencies - simpler than getDeps
const handleExternalDep = (dep, deps, external) => {
external.push(dep);
deps[dep] = [];
};
// Create the dependency resolver with resolveExtensionDependencies-specific configuration
const dep_resolve = createDependencyResolver(deps, external, {
handleExternalDep,
extname: name // For resolveExtensionDependencies, we use the module name as extname
});
const resolved = [];
const unresolved = [];
dep_resolve(name, resolved, unresolved);
return { external, resolved };
};
//
//
//
//
//
// - for each change in the plan, create a node in the dependency graph and add edges for any declared dependencies.
//
//
// - Cross-package references:
//
//
//
//
// resolveDependencies overview
// - Purpose: compute dependency graph and apply order for a package/module.
// - Sources: 'sql' (parse headers + topo + extensions-first) vs 'plan' (use plan.changes order directly).
// - Tags: 'preserve' (keep), 'internal' (map for traversal), 'resolve' (replace with change names).
// - Output: { external, resolved, deps, resolvedTags? }.
// Detailed notes are placed inline near the relevant code paths below.
export const resolveDependencies = (packageDir, extname, options = {}) => {
const { tagResolution = 'preserve', loadPlanFiles = true, planFileLoader, source = 'sql' } = options;
// For 'resolve' and 'internal' modes, we need plan file loading
const planCache = {};
// Helper function to load a plan file for a package
const loadPlanFile = (packageName) => {
if (!loadPlanFiles) {
return null;
}
if (planFileLoader) {
return planFileLoader(packageName, extname, packageDir);
}
if (planCache[packageName]) {
return planCache[packageName];
}
try {
let planPath;
if (packageName === extname) {
// For the current package
planPath = join(packageDir, 'launchql.plan');
}
else {
// For external packages, use LaunchQLPackage to find module path
const project = new LaunchQLPackage(packageDir);
const moduleMap = project.getModuleMap();
const module = moduleMap[packageName];
if (!module) {
throw errors.MODULE_NOT_FOUND({ name: packageName });
}
const workspacePath = project.getWorkspacePath();
if (!workspacePath) {
throw new Error(`No workspace found for module ${packageName}`);
}
planPath = join(workspacePath, module.path, 'launchql.plan');
}
const result = parsePlanFile(planPath);
if (result.data) {
planCache[packageName] = result.data;
return result.data;
}
}
catch (error) {
// Plan file not found or parse error
console.warn(`Could not load plan file for package ${packageName}: ${error}`);
}
return null;
};
// Plan-mode branch: use plan.changes order directly; build graph from plan deps (no topo or resort).
// - Loads the current package plan and throws if missing.
// - For each change in plan, adds a node; edges come from change.dependencies.
// - Tag handling per tagResolution: 'preserve' keeps tokens, 'internal' maps for traversal, 'resolve' replaces with change names.
// - Cross-package refs "pkg:change" are recorded in external and kept as graph nodes for coordination by callers.
// - Internal refs like "extname:change" are normalized to "change".
const resolveTagToChange = (projectName, tagName) => {
const plan = loadPlanFile(projectName);
if (!plan)
return null;
const tag = plan.tags.find(t => t.name === tagName);
if (!tag)
return null;
return tag.change;
};
if (source === 'plan') {
const plan = loadPlanFile(extname);
if (!plan) {
throw errors.PLAN_PARSE_ERROR({ planPath: `${extname}/launchql.plan`, errors: 'Plan file not found or failed to parse while using plan-only resolution' });
}
const external = [];
const deps = {};
const tagMappings = {};
const normalizeInternal = (dep) => {
if (/:/.test(dep)) {
const [project, localKey] = dep.split(':', 2);
if (project === extname)
return localKey;
}
return dep;
};
const resolveTagDep = (projectName, tagName) => {
const change = resolveTagToChange(projectName, tagName);
if (!change)
return null;
return `${projectName}:${change}`;
};
for (const ch of plan.changes) {
const key = makeKey(ch.name);
deps[key] = [];
const changeDeps = ch.dependencies || [];
for (const rawDep of changeDeps) {
let dep = rawDep.trim();
if (dep.includes('@')) {
const m = dep.match(/^([^:]+):@(.+)$/);
if (m) {
const projectName = m[1];
const tagName = m[2];
const resolved = resolveTagDep(projectName, tagName);
if (resolved) {
if (tagResolution === 'resolve')
dep = resolved;
else if (tagResolution === 'internal')
tagMappings[dep] = resolved;
}
}
else {
const m2 = dep.match(/^@(.+)$/);
if (m2) {
const tagName = m2[1];
const resolved = resolveTagDep(extname, tagName);
if (resolved) {
if (tagResolution === 'resolve')
dep = resolved;
else if (tagResolution === 'internal')
tagMappings[dep] = resolved;
}
}
}
}
if (/:/.test(dep)) {
const [project] = dep.split(':', 2);
if (project !== extname) {
external.push(dep);
if (!deps[dep])
deps[dep] = [];
deps[key].push(dep);
continue;
}
deps[key].push(normalizeInternal(dep));
continue;
}
deps[key].push(dep);
}
}
const transformModule = (sqlmodule, extnameLocal) => {
const originalModule = sqlmodule;
if (tagResolution === 'preserve') {
let moduleToResolve = sqlmodule;
let edges = deps[makeKey(sqlmodule)];
if (/:/.test(sqlmodule)) {
const [project, localKey] = sqlmodule.split(':', 2);
if (project === extnameLocal) {
moduleToResolve = localKey;
edges = deps[makeKey(localKey)];
if (!edges)
throw errors.MODULE_NOT_FOUND({ name: `${localKey} (from ${project}:${localKey})` });
}
else {
external.push(sqlmodule);
deps[sqlmodule] = deps[sqlmodule] || [];
return { module: sqlmodule, edges: [], returnEarly: true };
}
}
else {
if (!edges)
throw errors.MODULE_NOT_FOUND({ name: sqlmodule });
}
return { module: moduleToResolve, edges };
}
if (/:/.test(originalModule)) {
const [project] = originalModule.split(':', 2);
if (project !== extnameLocal) {
external.push(originalModule);
deps[originalModule] = deps[originalModule] || [];
return { module: originalModule, edges: [], returnEarly: true };
}
}
let moduleToResolve = sqlmodule;
if (tagResolution === 'internal' && tagMappings[sqlmodule]) {
moduleToResolve = tagMappings[sqlmodule];
}
let edges = deps[makeKey(moduleToResolve)];
if (/:/.test(moduleToResolve)) {
const [project, localKey] = moduleToResolve.split(':', 2);
if (project === extnameLocal) {
moduleToResolve = localKey;
edges = deps[makeKey(localKey)];
if (!edges)
throw errors.MODULE_NOT_FOUND({ name: `${localKey} (from ${project}:${localKey})` });
}
}
else {
if (!edges) {
edges = deps[makeKey(sqlmodule)];
if (!edges)
throw errors.MODULE_NOT_FOUND({ name: sqlmodule });
}
}
if (tagResolution === 'internal' && edges) {
const processedEdges = edges.map(dep => {
if (/:/.test(dep)) {
const [project] = dep.split(':', 2);
if (project !== extnameLocal)
return dep;
}
if (tagMappings[dep])
return tagMappings[dep];
return dep;
});
return { module: moduleToResolve, edges: processedEdges };
}
return { module: moduleToResolve, edges };
};
// or extension-first resorting. Externals are still tracked in the deps graph and external array.
const resolved = plan.changes.map(ch => ch.name);
return { external, resolved, deps, resolvedTags: tagMappings };
}
const external = [];
const deps = {};
const tagMappings = {};
// Process SQL files and build dependency graph
const files = glob(`${packageDir}/deploy/**/*.sql`);
for (const file of files) {
const data = readFileSync(file, 'utf-8');
const lines = data.split('\n');
const key = '/' + relative(packageDir, file);
deps[key] = [];
for (const line of lines) {
// Handle requires statements
const requiresMatch = line.match(/^-- requires: (.*)/);
if (requiresMatch) {
const dep = requiresMatch[1].trim();
// For 'preserve' mode, just add the dependency as-is (like original getDeps)
if (tagResolution === 'preserve') {
deps[key].push(dep);
continue;
}
// For other modes, handle tag resolution
if (dep.includes('@')) {
const match = dep.match(/^([^:]+):@(.+)$/);
if (match) {
const [, projectName, tagName] = match;
const taggedChange = resolveTagToChange(projectName, tagName);
if (taggedChange) {
if (tagResolution === 'resolve') {
// Full resolution: replace tag with actual change
const resolvedDep = `${projectName}:${taggedChange}`;
deps[key].push(resolvedDep);
}
else if (tagResolution === 'internal') {
// Internal resolution: keep tag in deps but track mapping
tagMappings[dep] = `${projectName}:${taggedChange}`;
deps[key].push(dep);
}
}
else {
// Could not resolve tag, keep it as is
deps[key].push(dep);
}
}
else {
// Invalid tag format, keep as is
deps[key].push(dep);
}
}
else {
// Not a tag, keep as is
deps[key].push(dep);
}
continue;
}
// Handle deploy statements - exactly as in original
let m2;
let keyToTest;
if (/:/.test(line)) {
m2 = line.match(/^-- Deploy ([^:]*):([\w\/]+) to pg/);
if (m2) {
const actualProject = m2[1];
keyToTest = m2[2];
if (extname !== actualProject) {
throw new Error(`Mismatched project name in deploy file:
Expected project: ${extname}
Found in line : ${actualProject}
Line : ${line}`);
}
const expectedKey = makeKey(keyToTest);
if (key !== expectedKey) {
throw new Error(`Deployment script path or internal name mismatch:
Expected key : ${key}
Found in line : ${expectedKey}
Line : ${line}`);
}
}
}
else {
m2 = line.match(/^-- Deploy (.*) to pg/);
if (m2) {
keyToTest = m2[1];
if (key !== makeKey(keyToTest)) {
throw new Error('deployment script in wrong place or is named wrong internally\n' + line);
}
}
}
}
}
const transformModule = (sqlmodule, extname) => {
const originalModule = sqlmodule;
// For 'preserve' mode, use simpler logic (like original getDeps)
if (tagResolution === 'preserve') {
let moduleToResolve = sqlmodule;
let edges = deps[makeKey(sqlmodule)];
if (/:/.test(sqlmodule)) {
// Has a prefix — could be internal or external
const [project, localKey] = sqlmodule.split(':', 2);
if (project === extname) {
// Internal reference to current package
moduleToResolve = localKey;
edges = deps[makeKey(localKey)];
if (!edges) {
throw errors.MODULE_NOT_FOUND({ name: `${localKey} (from ${project}:${localKey})` });
}
}
else {
// External reference — always OK, even if not in deps yet
external.push(sqlmodule);
deps[sqlmodule] = [];
return { module: sqlmodule, edges: [], returnEarly: true };
}
}
else {
// No prefix — must be internal
if (!edges) {
throw errors.MODULE_NOT_FOUND({ name: sqlmodule });
}
}
return { module: moduleToResolve, edges };
}
// Check if the ORIGINAL module (before tag resolution) is external
if (/:/.test(originalModule)) {
const [project, localKey] = originalModule.split(':', 2);
if (project !== extname) {
// External reference — always OK, even if not in deps yet
external.push(originalModule);
deps[originalModule] = deps[originalModule] || [];
return { module: originalModule, edges: [], returnEarly: true };
}
}
// For internal resolution mode, check if this module is a tag and resolve it
let moduleToResolve = sqlmodule;
if (tagResolution === 'internal' && tagMappings[sqlmodule]) {
moduleToResolve = tagMappings[sqlmodule];
}
let edges = deps[makeKey(moduleToResolve)];
if (/:/.test(moduleToResolve)) {
// Has a prefix — must be internal since we already handled external above
const [project, localKey] = moduleToResolve.split(':', 2);
if (project === extname) {
// Internal reference to current package
moduleToResolve = localKey;
edges = deps[makeKey(localKey)];
if (!edges) {
throw errors.MODULE_NOT_FOUND({ name: `${localKey} (from ${project}:${localKey})` });
}
}
}
else {
// No prefix — must be internal
if (!edges) {
// Check if we have edges for the original module
edges = deps[makeKey(sqlmodule)];
if (!edges) {
throw errors.MODULE_NOT_FOUND({ name: sqlmodule });
}
}
}
// For internal resolution, process dependencies through tag mappings
if (tagResolution === 'internal' && edges) {
const processedEdges = edges.map(dep => {
// Check if this dependency is external - if so, don't resolve tags
if (/:/.test(dep)) {
const [project, localKey] = dep.split(':', 2);
if (project !== extname) {
// External dependency - keep original tag name
return dep;
}
}
// Internal dependency - apply tag mapping if available
if (tagMappings[dep]) {
return tagMappings[dep];
}
return dep;
});
return { module: moduleToResolve, edges: processedEdges };
}
return { module: moduleToResolve, edges };
};
// Create the dependency resolver with resolveDependencies-specific configuration
const dep_resolve = createDependencyResolver(deps, external, {
transformModule,
makeKey,
extname
});
let resolved = [];
const unresolved = [];
// Synthetic root '_virtual/app' seeds local deploy/* modules into resolver for topo ordering.
// Removed after resolution; not present in returned output.
// Followed by extension-first reordering for deterministic application in SQL mode only.
// Add synthetic root node - exactly as in original
deps[makeKey('_virtual/app')] = Object.keys(deps)
.filter((dep) => dep.startsWith('/deploy/'))
.map((dep) => dep.replace(/^\/deploy\//, '').replace(/\.sql$/, ''));
dep_resolve('_virtual/app', resolved, unresolved);
const index = resolved.indexOf('_virtual/app');
resolved.splice(index, 1);
delete deps[makeKey('_virtual/app')];
const extensions = resolved.filter((module) => module.startsWith('extensions/'));
const normalSql = resolved.filter((module) => !module.startsWith('extensions/'));
resolved = [...extensions, ...normalSql];
return { external, resolved, deps, resolvedTags: tagMappings };
};