| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242 |
- #!/usr/bin/env node
- /**
- * Neo4j Migration Runner
- *
- * Runs migrations from neo4j-migrations/ directory in order.
- * Tracks applied migrations in (:_Migration) nodes to prevent re-running.
- * All migrations must be idempotent for safety.
- *
- * Usage:
- * node scripts/migrate.js [--dry-run]
- *
- * Environment variables (from .env or shell):
- * NEO4J_URI_BOLT - Bolt URI (default: bolt://localhost:7687)
- * NEO4J_USERNAME - Username (default: neo4j)
- * NEO4J_PASSWORD - Password (required)
- */
- import "dotenv/config";
- import neo4j from "neo4j-driver";
- import fs from "fs/promises";
- import path from "path";
- import { fileURLToPath } from "url";
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
- const MIGRATIONS_DIR = path.join(__dirname, "..", "neo4j-migrations");
- const config = {
- uri: "bolt://localhost:7687",
- user: process.env.NEO4J_USERNAME || "neo4j",
- password: process.env.NEO4J_PASSWORD,
- };
- const isDryRun = process.argv.includes("--dry-run");
- /**
- * Logger with consistent formatting
- */
- const log = {
- info: (msg) => console.log(`[INFO] ${msg}`),
- warn: (msg) => console.log(`[WARN] ${msg}`),
- error: (msg) => console.error(`[ERROR] ${msg}`),
- success: (msg) => console.log(`[OK] ${msg}`),
- dry: (msg) => console.log(`[DRY-RUN] ${msg}`),
- };
- /**
- * Ensures the _Migration tracking infrastructure exists
- */
- async function ensureMigrationInfrastructure(session) {
- await session.run(`
- CREATE CONSTRAINT migration_name_unique IF NOT EXISTS
- FOR (m:_Migration) REQUIRE m.name IS UNIQUE
- `);
- }
- /**
- * Gets list of already applied migrations
- */
- async function getAppliedMigrations(session) {
- const result = await session.run(`
- MATCH (m:_Migration)
- RETURN m.name AS name
- ORDER BY m.name
- `);
- return new Set(result.records.map((r) => r.get("name")));
- }
- /**
- * Records a migration as applied
- */
- async function recordMigration(session, name) {
- await session.run(
- `
- MERGE (m:_Migration {name: $name})
- ON CREATE SET m.applied_at = datetime()
- ON MATCH SET m.last_run_at = datetime()
- `,
- { name },
- );
- }
- /**
- * Gets all migration files sorted by name
- */
- async function getMigrationFiles() {
- const files = await fs.readdir(MIGRATIONS_DIR);
- return files
- .filter((f) => f.endsWith(".cypher") || f.endsWith(".js"))
- .filter((f) => !f.startsWith("_")) // Skip files starting with _
- .sort();
- }
- /**
- * Runs a .cypher migration file
- * Splits on semicolons and runs each statement
- */
- async function runCypherMigration(session, filePath, dryRun) {
- const content = await fs.readFile(filePath, "utf-8");
- // Split by semicolons, filter empty statements and comments-only blocks
- const statements = content
- .split(";")
- .map((s) => s.trim())
- .filter((s) => {
- // Remove comment-only statements
- const withoutComments = s
- .split("\n")
- .filter((line) => !line.trim().startsWith("//"))
- .join("\n")
- .trim();
- return withoutComments.length > 0;
- });
- for (const statement of statements) {
- if (dryRun) {
- log.dry(`Would execute: ${statement.substring(0, 80)}...`);
- } else {
- await session.run(statement);
- }
- }
- return statements.length;
- }
- /**
- * Runs a .js migration file
- * The file must export a `migrate(driver, session, dryRun)` function
- */
- async function runJsMigration(driver, session, filePath, dryRun) {
- const module = await import(
- fileURLToPath(new URL(filePath, import.meta.url))
- );
- if (typeof module.migrate !== "function") {
- throw new Error(`Migration ${filePath} must export a 'migrate' function`);
- }
- return await module.migrate(driver, session, dryRun);
- }
- /**
- * Main migration runner
- */
- async function main() {
- if (!config.password) {
- log.error("NEO4J_PASSWORD environment variable is required");
- process.exit(1);
- }
- if (isDryRun) {
- log.info("Running in dry-run mode - no changes will be made");
- }
- log.info(`Connecting to Neo4j at ${config.uri}`);
- const driver = neo4j.driver(
- config.uri,
- neo4j.auth.basic(config.user, config.password),
- );
- try {
- // Verify connectivity
- await driver.verifyConnectivity();
- log.success("Connected to Neo4j");
- const session = driver.session();
- try {
- // Setup migration tracking
- if (!isDryRun) {
- await ensureMigrationInfrastructure(session);
- }
- // Get applied migrations
- const applied = isDryRun
- ? new Set()
- : await getAppliedMigrations(session);
- if (applied.size > 0) {
- log.info(`Found ${applied.size} previously applied migrations`);
- }
- // Get all migration files
- const files = await getMigrationFiles();
- log.info(`Found ${files.length} migration files`);
- let appliedCount = 0;
- let skippedCount = 0;
- for (const file of files) {
- const migrationName = file.replace(/\.(cypher|js)$/, "");
- const filePath = path.join(MIGRATIONS_DIR, file);
- if (applied.has(migrationName)) {
- log.info(`Skipping ${file} (already applied)`);
- skippedCount++;
- continue;
- }
- log.info(`Running migration: ${file}`);
- try {
- if (file.endsWith(".cypher")) {
- const count = await runCypherMigration(session, filePath, isDryRun);
- log.success(`${file}: executed ${count} statements`);
- } else if (file.endsWith(".js")) {
- const result = await runJsMigration(
- driver,
- session,
- filePath,
- isDryRun,
- );
- log.success(`${file}: ${result || "completed"}`);
- }
- // Record migration as applied
- if (!isDryRun) {
- await recordMigration(session, migrationName);
- }
- appliedCount++;
- } catch (err) {
- log.error(`Migration ${file} failed: ${err.message}`);
- throw err;
- }
- }
- log.info("---");
- log.success(
- `Migration complete: ${appliedCount} applied, ${skippedCount} skipped`,
- );
- } finally {
- await session.close();
- }
- } catch (err) {
- log.error(`Migration failed: ${err.message}`);
- process.exit(1);
- } finally {
- await driver.close();
- }
- }
- main();
|