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.
 
 
 
 
 
 

197 lines
5.7 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,
},
}
// 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
}