You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
597 lines
19 KiB
597 lines
19 KiB
package neo4j |
|
|
|
import ( |
|
"context" |
|
"fmt" |
|
) |
|
|
|
// Migration represents a database migration with a version identifier |
|
type Migration struct { |
|
Version string |
|
Description string |
|
Migrate func(ctx context.Context, n *N) error |
|
} |
|
|
|
// migrations is the ordered list of database migrations |
|
// Migrations are applied in order and tracked via Marker nodes |
|
var migrations = []Migration{ |
|
{ |
|
Version: "v1", |
|
Description: "Merge Author nodes into NostrUser nodes", |
|
Migrate: migrateAuthorToNostrUser, |
|
}, |
|
{ |
|
Version: "v2", |
|
Description: "Clean up binary-encoded pubkeys and event IDs to lowercase hex", |
|
Migrate: migrateBinaryToHex, |
|
}, |
|
{ |
|
Version: "v3", |
|
Description: "Convert direct REFERENCES/MENTIONS relationships to Tag-based model", |
|
Migrate: migrateToTagBasedReferences, |
|
}, |
|
{ |
|
Version: "v4", |
|
Description: "Deduplicate REPORTS relationships by (reporter, reported, report_type)", |
|
Migrate: migrateDeduplicateReports, |
|
}, |
|
} |
|
|
|
// RunMigrations executes all pending migrations |
|
func (n *N) RunMigrations() { |
|
ctx := context.Background() |
|
|
|
for _, migration := range migrations { |
|
// Check if migration has already been applied |
|
if n.migrationApplied(ctx, migration.Version) { |
|
n.Logger.Infof("migration %s already applied, skipping", migration.Version) |
|
continue |
|
} |
|
|
|
n.Logger.Infof("applying migration %s: %s", migration.Version, migration.Description) |
|
|
|
if err := migration.Migrate(ctx, n); err != nil { |
|
n.Logger.Errorf("migration %s failed: %v", migration.Version, err) |
|
// Continue to next migration - don't fail startup |
|
continue |
|
} |
|
|
|
// Mark migration as complete |
|
if err := n.markMigrationComplete(ctx, migration.Version, migration.Description); err != nil { |
|
n.Logger.Warningf("failed to mark migration %s as complete: %v", migration.Version, err) |
|
} |
|
|
|
n.Logger.Infof("migration %s completed successfully", migration.Version) |
|
} |
|
} |
|
|
|
// migrationApplied checks if a migration has already been applied |
|
func (n *N) migrationApplied(ctx context.Context, version string) bool { |
|
cypher := ` |
|
MATCH (m:Migration {version: $version}) |
|
RETURN m.version |
|
` |
|
result, err := n.ExecuteRead(ctx, cypher, map[string]any{"version": version}) |
|
if err != nil { |
|
return false |
|
} |
|
return result.Next(ctx) |
|
} |
|
|
|
// markMigrationComplete marks a migration as applied |
|
func (n *N) markMigrationComplete(ctx context.Context, version, description string) error { |
|
cypher := ` |
|
CREATE (m:Migration { |
|
version: $version, |
|
description: $description, |
|
applied_at: timestamp() |
|
}) |
|
` |
|
_, err := n.ExecuteWrite(ctx, cypher, map[string]any{ |
|
"version": version, |
|
"description": description, |
|
}) |
|
return err |
|
} |
|
|
|
// migrateAuthorToNostrUser migrates Author nodes to NostrUser nodes |
|
// This consolidates the separate Author (NIP-01) and NostrUser (WoT) labels |
|
// into a unified NostrUser label for the social graph |
|
func migrateAuthorToNostrUser(ctx context.Context, n *N) error { |
|
// Step 1: Check if there are any Author nodes to migrate |
|
countCypher := `MATCH (a:Author) RETURN count(a) AS count` |
|
countResult, err := n.ExecuteRead(ctx, countCypher, nil) |
|
if err != nil { |
|
return fmt.Errorf("failed to count Author nodes: %w", err) |
|
} |
|
|
|
var authorCount int64 |
|
if countResult.Next(ctx) { |
|
record := countResult.Record() |
|
if count, ok := record.Values[0].(int64); ok { |
|
authorCount = count |
|
} |
|
} |
|
|
|
if authorCount == 0 { |
|
n.Logger.Infof("no Author nodes to migrate") |
|
return nil |
|
} |
|
|
|
n.Logger.Infof("migrating %d Author nodes to NostrUser", authorCount) |
|
|
|
// Step 2: For each Author node, merge into NostrUser with same pubkey |
|
// This uses MERGE to either match existing NostrUser or create new one |
|
// Then copies any relationships from Author to NostrUser |
|
mergeCypher := ` |
|
// Match all Author nodes |
|
MATCH (a:Author) |
|
|
|
// For each Author, merge into NostrUser (creates if doesn't exist) |
|
MERGE (u:NostrUser {pubkey: a.pubkey}) |
|
ON CREATE SET u.created_at = timestamp(), u.migrated_from_author = true |
|
|
|
// Return count for logging |
|
RETURN count(DISTINCT a) AS migrated |
|
` |
|
|
|
result, err := n.ExecuteWrite(ctx, mergeCypher, nil) |
|
if err != nil { |
|
return fmt.Errorf("failed to merge Author nodes to NostrUser: %w", err) |
|
} |
|
|
|
// Log result (result consumption happens within the session) |
|
_ = result |
|
|
|
// Step 3: Migrate AUTHORED_BY relationships from Author to NostrUser |
|
// Events should now point to NostrUser instead of Author |
|
relationshipCypher := ` |
|
// Find events linked to Author via AUTHORED_BY |
|
MATCH (e:Event)-[r:AUTHORED_BY]->(a:Author) |
|
|
|
// Get or create the corresponding NostrUser |
|
MATCH (u:NostrUser {pubkey: a.pubkey}) |
|
|
|
// Create new relationship to NostrUser if it doesn't exist |
|
MERGE (e)-[:AUTHORED_BY]->(u) |
|
|
|
// Delete old relationship to Author |
|
DELETE r |
|
|
|
RETURN count(r) AS migrated_relationships |
|
` |
|
|
|
_, err = n.ExecuteWrite(ctx, relationshipCypher, nil) |
|
if err != nil { |
|
return fmt.Errorf("failed to migrate AUTHORED_BY relationships: %w", err) |
|
} |
|
|
|
// Step 4: Migrate MENTIONS relationships from Author to NostrUser |
|
mentionsCypher := ` |
|
// Find events with MENTIONS to Author |
|
MATCH (e:Event)-[r:MENTIONS]->(a:Author) |
|
|
|
// Get or create the corresponding NostrUser |
|
MATCH (u:NostrUser {pubkey: a.pubkey}) |
|
|
|
// Create new relationship to NostrUser if it doesn't exist |
|
MERGE (e)-[:MENTIONS]->(u) |
|
|
|
// Delete old relationship to Author |
|
DELETE r |
|
|
|
RETURN count(r) AS migrated_mentions |
|
` |
|
|
|
_, err = n.ExecuteWrite(ctx, mentionsCypher, nil) |
|
if err != nil { |
|
return fmt.Errorf("failed to migrate MENTIONS relationships: %w", err) |
|
} |
|
|
|
// Step 5: Delete orphaned Author nodes (no longer needed) |
|
deleteCypher := ` |
|
// Find Author nodes with no remaining relationships |
|
MATCH (a:Author) |
|
WHERE NOT (a)<-[:AUTHORED_BY]-() AND NOT (a)<-[:MENTIONS]-() |
|
DETACH DELETE a |
|
RETURN count(a) AS deleted |
|
` |
|
|
|
_, err = n.ExecuteWrite(ctx, deleteCypher, nil) |
|
if err != nil { |
|
return fmt.Errorf("failed to delete orphaned Author nodes: %w", err) |
|
} |
|
|
|
// Step 6: Drop the old Author constraint if it exists |
|
dropConstraintCypher := `DROP CONSTRAINT author_pubkey_unique IF EXISTS` |
|
_, _ = n.ExecuteWrite(ctx, dropConstraintCypher, nil) |
|
// Ignore error as constraint may not exist |
|
|
|
n.Logger.Infof("completed Author to NostrUser migration") |
|
return nil |
|
} |
|
|
|
// migrateBinaryToHex cleans up any binary-encoded pubkeys and event IDs |
|
// The nostr library stores e/p tag values in binary format (33 bytes with null terminator), |
|
// but Neo4j should store them as lowercase hex strings for consistent querying. |
|
// This migration: |
|
// 1. Finds NostrUser nodes with invalid (non-hex) pubkeys and deletes them |
|
// 2. Finds Event nodes with invalid pubkeys/IDs and deletes them |
|
// 3. Finds Tag nodes (type 'e' or 'p') with invalid values and deletes them |
|
// 4. Cleans up MENTIONS relationships pointing to invalid NostrUser nodes |
|
func migrateBinaryToHex(ctx context.Context, n *N) error { |
|
// Step 1: Count problematic nodes before cleanup |
|
n.Logger.Infof("scanning for binary-encoded values in Neo4j...") |
|
|
|
// Check for NostrUser nodes with invalid pubkeys (not 64 char hex) |
|
// A valid hex pubkey is exactly 64 lowercase hex characters |
|
countInvalidUsersCypher := ` |
|
MATCH (u:NostrUser) |
|
WHERE size(u.pubkey) <> 64 |
|
OR NOT u.pubkey =~ '^[0-9a-f]{64}$' |
|
RETURN count(u) AS count |
|
` |
|
result, err := n.ExecuteRead(ctx, countInvalidUsersCypher, nil) |
|
if err != nil { |
|
return fmt.Errorf("failed to count invalid NostrUser nodes: %w", err) |
|
} |
|
|
|
var invalidUserCount int64 |
|
if result.Next(ctx) { |
|
if count, ok := result.Record().Values[0].(int64); ok { |
|
invalidUserCount = count |
|
} |
|
} |
|
n.Logger.Infof("found %d NostrUser nodes with invalid pubkeys", invalidUserCount) |
|
|
|
// Check for Event nodes with invalid pubkeys or IDs |
|
countInvalidEventsCypher := ` |
|
MATCH (e:Event) |
|
WHERE (size(e.pubkey) <> 64 OR NOT e.pubkey =~ '^[0-9a-f]{64}$') |
|
OR (size(e.id) <> 64 OR NOT e.id =~ '^[0-9a-f]{64}$') |
|
RETURN count(e) AS count |
|
` |
|
result, err = n.ExecuteRead(ctx, countInvalidEventsCypher, nil) |
|
if err != nil { |
|
return fmt.Errorf("failed to count invalid Event nodes: %w", err) |
|
} |
|
|
|
var invalidEventCount int64 |
|
if result.Next(ctx) { |
|
if count, ok := result.Record().Values[0].(int64); ok { |
|
invalidEventCount = count |
|
} |
|
} |
|
n.Logger.Infof("found %d Event nodes with invalid pubkeys or IDs", invalidEventCount) |
|
|
|
// Check for Tag nodes (e/p type) with invalid values |
|
countInvalidTagsCypher := ` |
|
MATCH (t:Tag) |
|
WHERE t.type IN ['e', 'p'] |
|
AND (size(t.value) <> 64 OR NOT t.value =~ '^[0-9a-f]{64}$') |
|
RETURN count(t) AS count |
|
` |
|
result, err = n.ExecuteRead(ctx, countInvalidTagsCypher, nil) |
|
if err != nil { |
|
return fmt.Errorf("failed to count invalid Tag nodes: %w", err) |
|
} |
|
|
|
var invalidTagCount int64 |
|
if result.Next(ctx) { |
|
if count, ok := result.Record().Values[0].(int64); ok { |
|
invalidTagCount = count |
|
} |
|
} |
|
n.Logger.Infof("found %d Tag nodes (e/p type) with invalid values", invalidTagCount) |
|
|
|
// If nothing to clean up, we're done |
|
if invalidUserCount == 0 && invalidEventCount == 0 && invalidTagCount == 0 { |
|
n.Logger.Infof("no binary-encoded values found, migration complete") |
|
return nil |
|
} |
|
|
|
// Step 2: Delete invalid NostrUser nodes and their relationships |
|
if invalidUserCount > 0 { |
|
n.Logger.Infof("deleting %d invalid NostrUser nodes...", invalidUserCount) |
|
deleteInvalidUsersCypher := ` |
|
MATCH (u:NostrUser) |
|
WHERE size(u.pubkey) <> 64 |
|
OR NOT u.pubkey =~ '^[0-9a-f]{64}$' |
|
DETACH DELETE u |
|
` |
|
_, err = n.ExecuteWrite(ctx, deleteInvalidUsersCypher, nil) |
|
if err != nil { |
|
return fmt.Errorf("failed to delete invalid NostrUser nodes: %w", err) |
|
} |
|
n.Logger.Infof("deleted %d invalid NostrUser nodes", invalidUserCount) |
|
} |
|
|
|
// Step 3: Delete invalid Event nodes and their relationships |
|
if invalidEventCount > 0 { |
|
n.Logger.Infof("deleting %d invalid Event nodes...", invalidEventCount) |
|
deleteInvalidEventsCypher := ` |
|
MATCH (e:Event) |
|
WHERE (size(e.pubkey) <> 64 OR NOT e.pubkey =~ '^[0-9a-f]{64}$') |
|
OR (size(e.id) <> 64 OR NOT e.id =~ '^[0-9a-f]{64}$') |
|
DETACH DELETE e |
|
` |
|
_, err = n.ExecuteWrite(ctx, deleteInvalidEventsCypher, nil) |
|
if err != nil { |
|
return fmt.Errorf("failed to delete invalid Event nodes: %w", err) |
|
} |
|
n.Logger.Infof("deleted %d invalid Event nodes", invalidEventCount) |
|
} |
|
|
|
// Step 4: Delete invalid Tag nodes (e/p type) and their relationships |
|
if invalidTagCount > 0 { |
|
n.Logger.Infof("deleting %d invalid Tag nodes...", invalidTagCount) |
|
deleteInvalidTagsCypher := ` |
|
MATCH (t:Tag) |
|
WHERE t.type IN ['e', 'p'] |
|
AND (size(t.value) <> 64 OR NOT t.value =~ '^[0-9a-f]{64}$') |
|
DETACH DELETE t |
|
` |
|
_, err = n.ExecuteWrite(ctx, deleteInvalidTagsCypher, nil) |
|
if err != nil { |
|
return fmt.Errorf("failed to delete invalid Tag nodes: %w", err) |
|
} |
|
n.Logger.Infof("deleted %d invalid Tag nodes", invalidTagCount) |
|
} |
|
|
|
// Step 5: Clean up any orphaned MENTIONS/REFERENCES relationships |
|
// These would be relationships pointing to nodes we just deleted |
|
cleanupOrphanedCypher := ` |
|
// Clean up any ProcessedSocialEvent nodes with invalid pubkeys |
|
MATCH (p:ProcessedSocialEvent) |
|
WHERE size(p.pubkey) <> 64 |
|
OR NOT p.pubkey =~ '^[0-9a-f]{64}$' |
|
DETACH DELETE p |
|
` |
|
_, _ = n.ExecuteWrite(ctx, cleanupOrphanedCypher, nil) |
|
// Ignore errors - best effort cleanup |
|
|
|
n.Logger.Infof("binary-to-hex migration completed successfully") |
|
return nil |
|
} |
|
|
|
// migrateToTagBasedReferences converts direct REFERENCES and MENTIONS relationships |
|
// to the new Tag-based model where: |
|
// - Event-[:REFERENCES]->Event becomes Event-[:TAGGED_WITH]->Tag-[:REFERENCES]->Event |
|
// - Event-[:MENTIONS]->NostrUser becomes Event-[:TAGGED_WITH]->Tag-[:REFERENCES]->NostrUser |
|
// |
|
// This enables unified tag querying via #e and #p filters while maintaining graph traversal. |
|
func migrateToTagBasedReferences(ctx context.Context, n *N) error { |
|
// Step 1: Count existing direct REFERENCES relationships (Event->Event) |
|
countRefCypher := ` |
|
MATCH (source:Event)-[r:REFERENCES]->(target:Event) |
|
RETURN count(r) AS count |
|
` |
|
result, err := n.ExecuteRead(ctx, countRefCypher, nil) |
|
if err != nil { |
|
return fmt.Errorf("failed to count REFERENCES relationships: %w", err) |
|
} |
|
|
|
var refCount int64 |
|
if result.Next(ctx) { |
|
if count, ok := result.Record().Values[0].(int64); ok { |
|
refCount = count |
|
} |
|
} |
|
n.Logger.Infof("found %d direct Event-[:REFERENCES]->Event relationships to migrate", refCount) |
|
|
|
// Step 2: Count existing direct MENTIONS relationships (Event->NostrUser) |
|
countMentionsCypher := ` |
|
MATCH (source:Event)-[r:MENTIONS]->(target:NostrUser) |
|
RETURN count(r) AS count |
|
` |
|
result, err = n.ExecuteRead(ctx, countMentionsCypher, nil) |
|
if err != nil { |
|
return fmt.Errorf("failed to count MENTIONS relationships: %w", err) |
|
} |
|
|
|
var mentionsCount int64 |
|
if result.Next(ctx) { |
|
if count, ok := result.Record().Values[0].(int64); ok { |
|
mentionsCount = count |
|
} |
|
} |
|
n.Logger.Infof("found %d direct Event-[:MENTIONS]->NostrUser relationships to migrate", mentionsCount) |
|
|
|
// If nothing to migrate, we're done |
|
if refCount == 0 && mentionsCount == 0 { |
|
n.Logger.Infof("no direct relationships to migrate, migration complete") |
|
return nil |
|
} |
|
|
|
// Step 3: Migrate REFERENCES relationships to Tag-based model |
|
// Process in batches to avoid memory issues with large datasets |
|
if refCount > 0 { |
|
n.Logger.Infof("migrating %d REFERENCES relationships to Tag-based model...", refCount) |
|
|
|
// This query: |
|
// 1. Finds Event->Event REFERENCES relationships |
|
// 2. Creates/merges Tag node with type='e' and value=target event ID |
|
// 3. Creates TAGGED_WITH from source Event to Tag |
|
// 4. Creates REFERENCES from Tag to target Event |
|
// 5. Deletes the old direct REFERENCES relationship |
|
migrateRefCypher := ` |
|
MATCH (source:Event)-[r:REFERENCES]->(target:Event) |
|
WITH source, r, target LIMIT 1000 |
|
MERGE (t:Tag {type: 'e', value: target.id}) |
|
CREATE (source)-[:TAGGED_WITH]->(t) |
|
MERGE (t)-[:REFERENCES]->(target) |
|
DELETE r |
|
RETURN count(r) AS migrated |
|
` |
|
|
|
// Run migration in batches until no more relationships exist |
|
totalMigrated := int64(0) |
|
for { |
|
result, err := n.ExecuteWrite(ctx, migrateRefCypher, nil) |
|
if err != nil { |
|
return fmt.Errorf("failed to migrate REFERENCES batch: %w", err) |
|
} |
|
|
|
var batchMigrated int64 |
|
if result.Next(ctx) { |
|
if count, ok := result.Record().Values[0].(int64); ok { |
|
batchMigrated = count |
|
} |
|
} |
|
|
|
if batchMigrated == 0 { |
|
break |
|
} |
|
totalMigrated += batchMigrated |
|
n.Logger.Infof("migrated %d REFERENCES relationships (total: %d)", batchMigrated, totalMigrated) |
|
} |
|
|
|
n.Logger.Infof("completed migrating %d REFERENCES relationships", totalMigrated) |
|
} |
|
|
|
// Step 4: Migrate MENTIONS relationships to Tag-based model |
|
if mentionsCount > 0 { |
|
n.Logger.Infof("migrating %d MENTIONS relationships to Tag-based model...", mentionsCount) |
|
|
|
// This query: |
|
// 1. Finds Event->NostrUser MENTIONS relationships |
|
// 2. Creates/merges Tag node with type='p' and value=target pubkey |
|
// 3. Creates TAGGED_WITH from source Event to Tag |
|
// 4. Creates REFERENCES from Tag to target NostrUser |
|
// 5. Deletes the old direct MENTIONS relationship |
|
migrateMentionsCypher := ` |
|
MATCH (source:Event)-[r:MENTIONS]->(target:NostrUser) |
|
WITH source, r, target LIMIT 1000 |
|
MERGE (t:Tag {type: 'p', value: target.pubkey}) |
|
CREATE (source)-[:TAGGED_WITH]->(t) |
|
MERGE (t)-[:REFERENCES]->(target) |
|
DELETE r |
|
RETURN count(r) AS migrated |
|
` |
|
|
|
// Run migration in batches until no more relationships exist |
|
totalMigrated := int64(0) |
|
for { |
|
result, err := n.ExecuteWrite(ctx, migrateMentionsCypher, nil) |
|
if err != nil { |
|
return fmt.Errorf("failed to migrate MENTIONS batch: %w", err) |
|
} |
|
|
|
var batchMigrated int64 |
|
if result.Next(ctx) { |
|
if count, ok := result.Record().Values[0].(int64); ok { |
|
batchMigrated = count |
|
} |
|
} |
|
|
|
if batchMigrated == 0 { |
|
break |
|
} |
|
totalMigrated += batchMigrated |
|
n.Logger.Infof("migrated %d MENTIONS relationships (total: %d)", batchMigrated, totalMigrated) |
|
} |
|
|
|
n.Logger.Infof("completed migrating %d MENTIONS relationships", totalMigrated) |
|
} |
|
|
|
n.Logger.Infof("Tag-based references migration completed successfully") |
|
return nil |
|
} |
|
|
|
// migrateDeduplicateReports removes duplicate REPORTS relationships |
|
// Prior to this migration, processReport() used CREATE which allowed multiple |
|
// REPORTS relationships with the same report_type between the same two users. |
|
// This migration keeps only the most recent report (by created_at) for each |
|
// (reporter, reported, report_type) combination. |
|
func migrateDeduplicateReports(ctx context.Context, n *N) error { |
|
// Step 1: Count duplicate REPORTS relationships |
|
// Duplicates are defined as multiple REPORTS with the same (reporter, reported, report_type) |
|
countDuplicatesCypher := ` |
|
MATCH (reporter:NostrUser)-[r:REPORTS]->(reported:NostrUser) |
|
WITH reporter, reported, r.report_type AS type, collect(r) AS rels |
|
WHERE size(rels) > 1 |
|
RETURN sum(size(rels) - 1) AS duplicate_count |
|
` |
|
result, err := n.ExecuteRead(ctx, countDuplicatesCypher, nil) |
|
if err != nil { |
|
return fmt.Errorf("failed to count duplicate REPORTS: %w", err) |
|
} |
|
|
|
var duplicateCount int64 |
|
if result.Next(ctx) { |
|
if count, ok := result.Record().Values[0].(int64); ok { |
|
duplicateCount = count |
|
} |
|
} |
|
|
|
if duplicateCount == 0 { |
|
n.Logger.Infof("no duplicate REPORTS relationships found, migration complete") |
|
return nil |
|
} |
|
|
|
n.Logger.Infof("found %d duplicate REPORTS relationships to remove", duplicateCount) |
|
|
|
// Step 2: Delete duplicate REPORTS, keeping the one with the highest created_at |
|
// This query: |
|
// 1. Groups REPORTS by (reporter, reported, report_type) |
|
// 2. Finds the maximum created_at for each group |
|
// 3. Deletes all relationships in the group except the newest one |
|
deleteDuplicatesCypher := ` |
|
MATCH (reporter:NostrUser)-[r:REPORTS]->(reported:NostrUser) |
|
WITH reporter, reported, r.report_type AS type, |
|
collect(r) AS rels, max(r.created_at) AS maxCreatedAt |
|
WHERE size(rels) > 1 |
|
UNWIND rels AS rel |
|
WITH rel, maxCreatedAt |
|
WHERE rel.created_at < maxCreatedAt |
|
DELETE rel |
|
RETURN count(*) AS deleted |
|
` |
|
|
|
writeResult, err := n.ExecuteWrite(ctx, deleteDuplicatesCypher, nil) |
|
if err != nil { |
|
return fmt.Errorf("failed to delete duplicate REPORTS: %w", err) |
|
} |
|
|
|
var deletedCount int64 |
|
if writeResult.Next(ctx) { |
|
if count, ok := writeResult.Record().Values[0].(int64); ok { |
|
deletedCount = count |
|
} |
|
} |
|
|
|
n.Logger.Infof("deleted %d duplicate REPORTS relationships", deletedCount) |
|
|
|
// Step 3: Mark superseded ProcessedSocialEvent nodes for deleted reports |
|
// Find ProcessedSocialEvent nodes (kind 1984) whose event IDs are no longer |
|
// referenced by any REPORTS relationship's created_by_event |
|
markSupersededCypher := ` |
|
MATCH (evt:ProcessedSocialEvent {event_kind: 1984}) |
|
WHERE evt.superseded_by IS NULL |
|
AND NOT EXISTS { |
|
MATCH ()-[r:REPORTS]->() |
|
WHERE r.created_by_event = evt.event_id |
|
} |
|
SET evt.superseded_by = 'migration_v4_dedupe' |
|
RETURN count(evt) AS superseded |
|
` |
|
|
|
markResult, err := n.ExecuteWrite(ctx, markSupersededCypher, nil) |
|
if err != nil { |
|
// Non-fatal - just log warning |
|
n.Logger.Warningf("failed to mark superseded ProcessedSocialEvent nodes: %v", err) |
|
} else { |
|
var supersededCount int64 |
|
if markResult.Next(ctx) { |
|
if count, ok := markResult.Record().Values[0].(int64); ok { |
|
supersededCount = count |
|
} |
|
} |
|
if supersededCount > 0 { |
|
n.Logger.Infof("marked %d ProcessedSocialEvent nodes as superseded", supersededCount) |
|
} |
|
} |
|
|
|
n.Logger.Infof("REPORTS deduplication migration completed successfully") |
|
return nil |
|
}
|
|
|