| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206 |
- /**
- * 003_migrate_v1_format.js
- *
- * Migrates nodes from V1 format to V2 format.
- *
- * V1 format (old):
- * label: "example.com"
- * type: "domain"
- * created_at: "2026-01-23T18:28:46.048223+00:00"
- * domain: "example.com"
- * root: true
- * sketch_id: "..."
- * x, y: coordinates
- *
- * V2 format (new):
- * nodeLabel: "example.com"
- * nodeType: "domain"
- * nodeMetadata.created_at: "2026-01-23T18:28:46.048223+00:00"
- * nodeProperties.domain: "example.com"
- * nodeProperties.root: true
- * sketch_id: "..."
- * x, y: coordinates
- *
- * This migration is IDEMPOTENT:
- * - Only processes nodes that have V1 format (have `label` or `type` but NOT `nodeLabel`)
- * - Safe to run multiple times
- * - Processes in batches to handle large datasets
- */
- import neo4j from "neo4j-driver";
- // Reserved properties that should NOT be moved to nodeProperties
- const RESERVED_PROPERTIES = new Set([
- "id",
- "x",
- "y",
- "nodeLabel",
- "label",
- "nodeType",
- "type",
- "nodeImage",
- "nodeIcon",
- "nodeColor",
- "nodeSize",
- "nodeFlag",
- "nodeShape",
- "nodeMetadata",
- "nodeProperties",
- "created_at",
- "sketch_id",
- ]);
- // Properties that are part of nodeMetadata
- const METADATA_PROPERTIES = new Set(["created_at"]);
- const BATCH_SIZE = 500;
- /**
- * Main migration function
- * @param {import('neo4j-driver').Driver} driver
- * @param {import('neo4j-driver').Session} session
- * @param {boolean} dryRun
- * @returns {Promise<string>} Summary message
- */
- export async function migrate(driver, session, dryRun) {
- // Count nodes needing migration (V1 format: has `type` but no `nodeType`)
- const countResult = await session.run(`
- MATCH (n)
- WHERE n.type IS NOT NULL AND n.nodeType IS NULL
- RETURN count(n) AS count
- `);
- const totalCount = countResult.records[0].get("count").toNumber();
- if (totalCount === 0) {
- return "No V1 format nodes found - nothing to migrate";
- }
- console.log(`[INFO] Found ${totalCount} nodes in V1 format to migrate`);
- if (dryRun) {
- // In dry-run, show sample of what would be migrated
- const sampleResult = await session.run(`
- MATCH (n)
- WHERE n.type IS NOT NULL AND n.nodeType IS NULL
- RETURN n, labels(n) AS labels
- LIMIT 5
- `);
- console.log("[DRY-RUN] Sample nodes that would be migrated:");
- for (const record of sampleResult.records) {
- const node = record.get("n").properties;
- const labels = record.get("labels");
- console.log(` - [${labels.join(":")}] label="${node.label}", type="${node.type}"`);
- }
- return `Would migrate ${totalCount} nodes from V1 to V2 format`;
- }
- // Process in batches
- let migratedCount = 0;
- let batchNum = 0;
- while (migratedCount < totalCount) {
- batchNum++;
- console.log(
- `[INFO] Processing batch ${batchNum} (${migratedCount}/${totalCount} done)`
- );
- // Fetch a batch of V1 nodes
- const batchResult = await session.run(
- `
- MATCH (n)
- WHERE n.type IS NOT NULL AND n.nodeType IS NULL
- RETURN elementId(n) AS elementId, n, labels(n) AS labels
- LIMIT $limit
- `,
- { limit: neo4j.int(BATCH_SIZE) }
- );
- if (batchResult.records.length === 0) {
- break;
- }
- // Process each node in the batch
- for (const record of batchResult.records) {
- const elementId = record.get("elementId");
- const node = record.get("n").properties;
- // Build the new properties
- const updates = buildV2Properties(node);
- // Apply the update
- await session.run(
- `
- MATCH (n)
- WHERE elementId(n) = $elementId
- SET n += $updates
- REMOVE n.label, n.type, n.created_at
- `,
- { elementId, updates }
- );
- // Remove old dynamic properties that were moved to nodeProperties
- const propsToRemove = Object.keys(node).filter(
- (key) =>
- !RESERVED_PROPERTIES.has(key) &&
- !key.startsWith("nodeProperties.") &&
- !key.startsWith("nodeMetadata.")
- );
- if (propsToRemove.length > 0) {
- // Build dynamic REMOVE clause
- const removeClause = propsToRemove.map((p) => `n.\`${p}\``).join(", ");
- await session.run(
- `
- MATCH (n)
- WHERE elementId(n) = $elementId
- REMOVE ${removeClause}
- `,
- { elementId }
- );
- }
- migratedCount++;
- }
- }
- return `Migrated ${migratedCount} nodes from V1 to V2 format`;
- }
- /**
- * Builds V2 format properties from V1 node
- * @param {Record<string, any>} node - V1 node properties
- * @returns {Record<string, any>} - V2 format properties to SET
- */
- function buildV2Properties(node) {
- const updates = {};
- // Map core fields
- updates.nodeLabel = node.label || node.nodeLabel || "";
- updates.nodeType = node.type || node.nodeType || "";
- // Handle created_at -> nodeMetadata.created_at
- if (node.created_at) {
- updates["nodeMetadata.created_at"] = node.created_at;
- } else if (!node["nodeMetadata.created_at"]) {
- // Set current timestamp if no created_at exists
- updates["nodeMetadata.created_at"] = new Date().toISOString();
- }
- // Move non-reserved properties to nodeProperties.*
- for (const [key, value] of Object.entries(node)) {
- // Skip reserved properties
- if (RESERVED_PROPERTIES.has(key)) continue;
- // Skip properties already in nodeProperties/nodeMetadata namespace
- if (key.startsWith("nodeProperties.") || key.startsWith("nodeMetadata.")) {
- continue;
- }
- // Move to nodeProperties
- updates[`nodeProperties.${key}`] = value;
- }
- return updates;
- }
|