migrate.js 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. #!/usr/bin/env node
  2. /**
  3. * Neo4j Migration Runner
  4. *
  5. * Runs migrations from neo4j-migrations/ directory in order.
  6. * Tracks applied migrations in (:_Migration) nodes to prevent re-running.
  7. * All migrations must be idempotent for safety.
  8. *
  9. * Usage:
  10. * node scripts/migrate.js [--dry-run]
  11. *
  12. * Environment variables (from .env or shell):
  13. * NEO4J_URI_BOLT - Bolt URI (default: bolt://localhost:7687)
  14. * NEO4J_USERNAME - Username (default: neo4j)
  15. * NEO4J_PASSWORD - Password (required)
  16. */
  17. import "dotenv/config";
  18. import neo4j from "neo4j-driver";
  19. import fs from "fs/promises";
  20. import path from "path";
  21. import { fileURLToPath } from "url";
  22. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  23. const MIGRATIONS_DIR = path.join(__dirname, "..", "neo4j-migrations");
  24. const config = {
  25. uri: "bolt://localhost:7687",
  26. user: process.env.NEO4J_USERNAME || "neo4j",
  27. password: process.env.NEO4J_PASSWORD,
  28. };
  29. const isDryRun = process.argv.includes("--dry-run");
  30. /**
  31. * Logger with consistent formatting
  32. */
  33. const log = {
  34. info: (msg) => console.log(`[INFO] ${msg}`),
  35. warn: (msg) => console.log(`[WARN] ${msg}`),
  36. error: (msg) => console.error(`[ERROR] ${msg}`),
  37. success: (msg) => console.log(`[OK] ${msg}`),
  38. dry: (msg) => console.log(`[DRY-RUN] ${msg}`),
  39. };
  40. /**
  41. * Ensures the _Migration tracking infrastructure exists
  42. */
  43. async function ensureMigrationInfrastructure(session) {
  44. await session.run(`
  45. CREATE CONSTRAINT migration_name_unique IF NOT EXISTS
  46. FOR (m:_Migration) REQUIRE m.name IS UNIQUE
  47. `);
  48. }
  49. /**
  50. * Gets list of already applied migrations
  51. */
  52. async function getAppliedMigrations(session) {
  53. const result = await session.run(`
  54. MATCH (m:_Migration)
  55. RETURN m.name AS name
  56. ORDER BY m.name
  57. `);
  58. return new Set(result.records.map((r) => r.get("name")));
  59. }
  60. /**
  61. * Records a migration as applied
  62. */
  63. async function recordMigration(session, name) {
  64. await session.run(
  65. `
  66. MERGE (m:_Migration {name: $name})
  67. ON CREATE SET m.applied_at = datetime()
  68. ON MATCH SET m.last_run_at = datetime()
  69. `,
  70. { name },
  71. );
  72. }
  73. /**
  74. * Gets all migration files sorted by name
  75. */
  76. async function getMigrationFiles() {
  77. const files = await fs.readdir(MIGRATIONS_DIR);
  78. return files
  79. .filter((f) => f.endsWith(".cypher") || f.endsWith(".js"))
  80. .filter((f) => !f.startsWith("_")) // Skip files starting with _
  81. .sort();
  82. }
  83. /**
  84. * Runs a .cypher migration file
  85. * Splits on semicolons and runs each statement
  86. */
  87. async function runCypherMigration(session, filePath, dryRun) {
  88. const content = await fs.readFile(filePath, "utf-8");
  89. // Split by semicolons, filter empty statements and comments-only blocks
  90. const statements = content
  91. .split(";")
  92. .map((s) => s.trim())
  93. .filter((s) => {
  94. // Remove comment-only statements
  95. const withoutComments = s
  96. .split("\n")
  97. .filter((line) => !line.trim().startsWith("//"))
  98. .join("\n")
  99. .trim();
  100. return withoutComments.length > 0;
  101. });
  102. for (const statement of statements) {
  103. if (dryRun) {
  104. log.dry(`Would execute: ${statement.substring(0, 80)}...`);
  105. } else {
  106. await session.run(statement);
  107. }
  108. }
  109. return statements.length;
  110. }
  111. /**
  112. * Runs a .js migration file
  113. * The file must export a `migrate(driver, session, dryRun)` function
  114. */
  115. async function runJsMigration(driver, session, filePath, dryRun) {
  116. const module = await import(
  117. fileURLToPath(new URL(filePath, import.meta.url))
  118. );
  119. if (typeof module.migrate !== "function") {
  120. throw new Error(`Migration ${filePath} must export a 'migrate' function`);
  121. }
  122. return await module.migrate(driver, session, dryRun);
  123. }
  124. /**
  125. * Main migration runner
  126. */
  127. async function main() {
  128. if (!config.password) {
  129. log.error("NEO4J_PASSWORD environment variable is required");
  130. process.exit(1);
  131. }
  132. if (isDryRun) {
  133. log.info("Running in dry-run mode - no changes will be made");
  134. }
  135. log.info(`Connecting to Neo4j at ${config.uri}`);
  136. const driver = neo4j.driver(
  137. config.uri,
  138. neo4j.auth.basic(config.user, config.password),
  139. );
  140. try {
  141. // Verify connectivity
  142. await driver.verifyConnectivity();
  143. log.success("Connected to Neo4j");
  144. const session = driver.session();
  145. try {
  146. // Setup migration tracking
  147. if (!isDryRun) {
  148. await ensureMigrationInfrastructure(session);
  149. }
  150. // Get applied migrations
  151. const applied = isDryRun
  152. ? new Set()
  153. : await getAppliedMigrations(session);
  154. if (applied.size > 0) {
  155. log.info(`Found ${applied.size} previously applied migrations`);
  156. }
  157. // Get all migration files
  158. const files = await getMigrationFiles();
  159. log.info(`Found ${files.length} migration files`);
  160. let appliedCount = 0;
  161. let skippedCount = 0;
  162. for (const file of files) {
  163. const migrationName = file.replace(/\.(cypher|js)$/, "");
  164. const filePath = path.join(MIGRATIONS_DIR, file);
  165. if (applied.has(migrationName)) {
  166. log.info(`Skipping ${file} (already applied)`);
  167. skippedCount++;
  168. continue;
  169. }
  170. log.info(`Running migration: ${file}`);
  171. try {
  172. if (file.endsWith(".cypher")) {
  173. const count = await runCypherMigration(session, filePath, isDryRun);
  174. log.success(`${file}: executed ${count} statements`);
  175. } else if (file.endsWith(".js")) {
  176. const result = await runJsMigration(
  177. driver,
  178. session,
  179. filePath,
  180. isDryRun,
  181. );
  182. log.success(`${file}: ${result || "completed"}`);
  183. }
  184. // Record migration as applied
  185. if (!isDryRun) {
  186. await recordMigration(session, migrationName);
  187. }
  188. appliedCount++;
  189. } catch (err) {
  190. log.error(`Migration ${file} failed: ${err.message}`);
  191. throw err;
  192. }
  193. }
  194. log.info("---");
  195. log.success(
  196. `Migration complete: ${appliedCount} applied, ${skippedCount} skipped`,
  197. );
  198. } finally {
  199. await session.close();
  200. }
  201. } catch (err) {
  202. log.error(`Migration failed: ${err.message}`);
  203. process.exit(1);
  204. } finally {
  205. await driver.close();
  206. }
  207. }
  208. main();