From c0e92a2dd2687acf7afcabb4ebfa36aa688395f3 Mon Sep 17 00:00:00 2001 From: woikos Date: Tue, 27 Jan 2026 17:48:55 +0100 Subject: [PATCH] Add naddr property to Neo4j addressable Event nodes (v0.58.0) - Add naddr_unique constraint and event_naddr index to schema.go - Add buildNaddr() helper to compute pubkey:kind:dtag coordinate - Store naddr on Event nodes for kinds 30000-39999, NULL for others - Add v5 migration to populate naddr for existing addressable events Implements: https://git.nostrdev.com/mleku/next.orly.dev/issues/27 Files modified: - pkg/neo4j/schema.go: Added naddr constraint/index and DROP statements - pkg/neo4j/save-event.go: Added buildNaddr() and naddr in Event CREATE - pkg/neo4j/migrations.go: Added v5 migration migrateAddNaddr() - pkg/version/version: Bumped to v0.58.0 Co-Authored-By: Claude Opus 4.5 --- pkg/neo4j/migrations.go | 80 +++++++++++++++++++++++++++++++++++++++++ pkg/neo4j/save-event.go | 39 ++++++++++++++++++-- pkg/neo4j/schema.go | 25 +++++++++++++ pkg/version/version | 2 +- 4 files changed, 143 insertions(+), 3 deletions(-) diff --git a/pkg/neo4j/migrations.go b/pkg/neo4j/migrations.go index f8e7c30..cac6f43 100644 --- a/pkg/neo4j/migrations.go +++ b/pkg/neo4j/migrations.go @@ -35,6 +35,11 @@ var migrations = []Migration{ Description: "Deduplicate REPORTS relationships by (reporter, reported, report_type)", Migrate: migrateDeduplicateReports, }, + { + Version: "v5", + Description: "Add naddr property to addressable Event nodes (kinds 30000-39999)", + Migrate: migrateAddNaddr, + }, } // RunMigrations executes all pending migrations @@ -595,3 +600,78 @@ func migrateDeduplicateReports(ctx context.Context, n *N) error { n.Logger.Infof("REPORTS deduplication migration completed successfully") return nil } + +// migrateAddNaddr adds the naddr property to existing addressable Event nodes (kinds 30000-39999). +// The naddr format is: pubkey:kind:dtag (colon-delimited coordinate) +// This enables direct lookups and uniqueness constraints for parameterized replaceable events. +func migrateAddNaddr(ctx context.Context, n *N) error { + // Step 1: Count addressable events without naddr + countCypher := ` + MATCH (e:Event) + WHERE e.kind >= 30000 AND e.kind < 40000 + AND e.naddr IS NULL + RETURN count(e) AS count + ` + result, err := n.ExecuteRead(ctx, countCypher, nil) + if err != nil { + return fmt.Errorf("failed to count addressable events: %w", err) + } + + var eventCount int64 + if result.Next(ctx) { + if count, ok := result.Record().Values[0].(int64); ok { + eventCount = count + } + } + + if eventCount == 0 { + n.Logger.Infof("no addressable events without naddr found, migration complete") + return nil + } + + n.Logger.Infof("found %d addressable events to update with naddr", eventCount) + + // Step 2: Update events in batches + // For each event, compute naddr from pubkey + kind + d-tag + // The d-tag value is obtained from the Tag node via TAGGED_WITH relationship + updateCypher := ` + MATCH (e:Event) + WHERE e.kind >= 30000 AND e.kind < 40000 + AND e.naddr IS NULL + WITH e LIMIT 1000 + + // Get d-tag value via TAGGED_WITH relationship + OPTIONAL MATCH (e)-[:TAGGED_WITH]->(t:Tag {type: 'd'}) + WITH e, COALESCE(t.value, '') AS dValue + + // Build naddr: pubkey:kind:dValue + SET e.naddr = e.pubkey + ':' + toString(e.kind) + ':' + dValue + + RETURN count(e) AS updated + ` + + // Run migration in batches until no more events to update + totalUpdated := int64(0) + for { + writeResult, err := n.ExecuteWrite(ctx, updateCypher, nil) + if err != nil { + return fmt.Errorf("failed to update addressable events batch: %w", err) + } + + var batchUpdated int64 + if writeResult.Next(ctx) { + if count, ok := writeResult.Record().Values[0].(int64); ok { + batchUpdated = count + } + } + + if batchUpdated == 0 { + break + } + totalUpdated += batchUpdated + n.Logger.Infof("updated %d addressable events with naddr (total: %d)", batchUpdated, totalUpdated) + } + + n.Logger.Infof("naddr migration completed: updated %d addressable events", totalUpdated) + return nil +} diff --git a/pkg/neo4j/save-event.go b/pkg/neo4j/save-event.go index c019a07..71b4f99 100644 --- a/pkg/neo4j/save-event.go +++ b/pkg/neo4j/save-event.go @@ -111,6 +111,30 @@ func safePrefix(s string, n int) string { return s[:n] } +// buildNaddr creates the naddr coordinate string for addressable events. +// Format: pubkey:kind:dtag (colon-delimited) +// Returns empty string for non-addressable events. +// This is used for the naddr_unique constraint in Neo4j. +func buildNaddr(ev *event.E) string { + // Only for addressable events (kinds 30000-39999) + if ev.Kind < 30000 || ev.Kind >= 40000 { + return "" + } + + pubkey := hex.Enc(ev.Pubkey[:]) + kind := strconv.FormatInt(int64(ev.Kind), 10) + + // Get d-tag value (empty string if not present) + dValue := "" + if ev.Tags != nil { + if dTag := ev.Tags.GetFirst([]byte{'d'}); dTag != nil && len(dTag.T) >= 2 { + dValue = string(dTag.T[1]) + } + } + + return pubkey + ":" + kind + ":" + dValue +} + // buildBaseEventCypher constructs a Cypher query to create just the base event node and author. // Tags are added separately in batches to prevent stack overflow with large tag sets. // This creates: @@ -142,6 +166,16 @@ func (n *N) buildBaseEventCypher(ev *event.E, serial uint64) (string, map[string } params["expiration"] = expirationTs + // Compute naddr for addressable events (kinds 30000-39999) + // Format: pubkey:kind:dtag - NULL for non-addressable events + // NULL allows multiple non-addressable events while maintaining uniqueness for naddr values + naddr := buildNaddr(ev) + if naddr != "" { + params["naddr"] = naddr + } else { + params["naddr"] = nil + } + // Serialize tags as JSON string for storage // Handle nil tags gracefully - nil means empty tags "[]" var tagsJSON []byte @@ -160,7 +194,7 @@ func (n *N) buildBaseEventCypher(ev *event.E, serial uint64) (string, map[string MERGE (a:NostrUser {pubkey: $pubkey}) ON CREATE SET a.created_at = timestamp(), a.first_seen_event = $eventId -// Create event node with expiration for NIP-40 support +// Create event node with expiration for NIP-40 support and naddr for NIP-33 addressable events CREATE (e:Event { id: $eventId, serial: $serial, @@ -170,7 +204,8 @@ CREATE (e:Event { sig: $sig, pubkey: $pubkey, tags: $tags, - expiration: $expiration + expiration: $expiration, + naddr: $naddr }) // Link event to author diff --git a/pkg/neo4j/schema.go b/pkg/neo4j/schema.go index b07bf3c..a38c445 100644 --- a/pkg/neo4j/schema.go +++ b/pkg/neo4j/schema.go @@ -43,6 +43,16 @@ func (n *N) applySchema(ctx context.Context) error { // NOTE: NostrUser unifies both NIP-01 author tracking and WoT social graph "CREATE CONSTRAINT nostrUser_pubkey IF NOT EXISTS FOR (n:NostrUser) REQUIRE n.pubkey IS UNIQUE", + // ============================================================ + // === OPTIONAL: Addressable Event Support (NIP-33) === + // These support parameterized replaceable events (kinds 30000-39999) + // ============================================================ + + // OPTIONAL (NIP-33): naddr uniqueness for addressable events + // Format: pubkey:kind:dtag (colon-delimited coordinate) + // Ensures only one event per author+kind+d-tag combination + "CREATE CONSTRAINT naddr_unique IF NOT EXISTS FOR (e:Event) REQUIRE e.naddr IS UNIQUE", + // ============================================================ // === OPTIONAL: Internal Relay Operations === // These are used for relay state management, not NIP-01 queries @@ -116,6 +126,15 @@ func (n *N) applySchema(ctx context.Context) error { // Optimizes queries like: {"kinds": [1], "since": , "until": } "CREATE INDEX event_kind_created_at IF NOT EXISTS FOR (e:Event) ON (e.kind, e.created_at)", + // ============================================================ + // === OPTIONAL: Addressable Event Indexes (NIP-33) === + // Support parameterized replaceable events (kinds 30000-39999) + // ============================================================ + + // OPTIONAL (NIP-33): Event.naddr index for addressable event lookups + // Enables fast queries by naddr coordinate (pubkey:kind:dtag) + "CREATE INDEX event_naddr IF NOT EXISTS FOR (e:Event) ON (e.naddr)", + // ============================================================ // === OPTIONAL: Internal Relay Operation Indexes === // Used for relay-internal operations, not NIP-01 queries @@ -205,6 +224,9 @@ func (n *N) dropAll(ctx context.Context) error { // Legacy constraint (removed in migration) "DROP CONSTRAINT author_pubkey_unique IF EXISTS", + // OPTIONAL (NIP-33) constraints + "DROP CONSTRAINT naddr_unique IF EXISTS", + // OPTIONAL (Internal) constraints "DROP CONSTRAINT marker_key_unique IF EXISTS", @@ -232,6 +254,9 @@ func (n *N) dropAll(ctx context.Context) error { // RECOMMENDED (Performance) indexes "DROP INDEX event_kind_created_at IF EXISTS", + // OPTIONAL (NIP-33) indexes + "DROP INDEX event_naddr IF EXISTS", + // OPTIONAL (Internal) indexes "DROP INDEX event_serial IF EXISTS", "DROP INDEX event_expiration IF EXISTS", diff --git a/pkg/version/version b/pkg/version/version index f21f2a1..0bf6617 100644 --- a/pkg/version/version +++ b/pkg/version/version @@ -1 +1 @@ -v0.57.2 +v0.58.0