diff --git a/CLAUDE.md b/CLAUDE.md index b7b7efe..553f163 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -235,11 +235,18 @@ export ORLY_AUTH_TO_WRITE=false # Require auth only for writes **`pkg/neo4j/`** - Neo4j graph database backend with social graph support - `neo4j.go` - Main database implementation - `schema.go` - Graph schema and index definitions (includes WoT extensions) +- `migrations.go` - Database schema migrations (v1: base, v2: WoT, v3: Tag-based e/p) - `query-events.go` - REQ filter to Cypher translation -- `save-event.go` - Event storage with relationship creation +- `save-event.go` - Event storage with Tag-based relationship creation +- `delete.go` - Event deletion (NIP-09) with Tag traversal for deletion detection - `social-event-processor.go` - Processes kinds 0, 3, 1984, 10000 for social graph +- `hex_utils.go` - Helpers for binary-to-hex tag value extraction - `WOT_SPEC.md` - Web of Trust data model specification (NostrUser nodes, trust metrics) - `MODIFYING_SCHEMA.md` - Guide for schema modifications +- **Tests:** + - `tag_model_test.go` - Tag-based e/p model and filter query tests + - `save-event_test.go` - Event storage and relationship tests + - `social-event-processor_test.go` - Social graph event processing tests **`pkg/protocol/`** - Nostr protocol implementation - `ws/` - WebSocket message framing and parsing @@ -349,6 +356,11 @@ export ORLY_AUTH_TO_WRITE=false # Require auth only for writes - Supports multiple backends via `ORLY_DB_TYPE` environment variable - **Badger** (default): Embedded key-value store with custom indexing, ideal for single-instance deployments - **Neo4j**: Graph database with social graph and Web of Trust (WoT) extensions + - **Tag-Based e/p Model**: All tags stored through intermediate Tag nodes + - `Event-[:TAGGED_WITH]->Tag{type:'e'}-[:REFERENCES]->Event` for e-tags + - `Event-[:TAGGED_WITH]->Tag{type:'p'}-[:REFERENCES]->NostrUser` for p-tags + - Enables unified querying: `#e` and `#p` filter queries work correctly + - Automatic migration from direct REFERENCES/MENTIONS (v3 migration) - Processes kinds 0 (profile), 3 (contacts), 1984 (reports), 10000 (mute list) for social graph - NostrUser nodes with trust metrics (influence, PageRank) - FOLLOWS, MUTES, REPORTS relationships for WoT analysis @@ -816,11 +828,18 @@ The directory spider (`pkg/spider/directory.go`) automatically discovers and syn ### Neo4j Social Graph Backend The Neo4j backend (`pkg/neo4j/`) includes Web of Trust (WoT) extensions: +- **Tag-Based e/p Model**: All tags (including e/p) stored through intermediate Tag nodes + - `Event-[:TAGGED_WITH]->Tag{type:'e'}-[:REFERENCES]->Event` + - `Event-[:TAGGED_WITH]->Tag{type:'p'}-[:REFERENCES]->NostrUser` + - Enables unified tag querying (`#e` and `#p` filter queries now work) + - v3 migration automatically converts existing direct REFERENCES/MENTIONS - **Social Event Processor**: Handles kinds 0, 3, 1984, 10000 for social graph management - **NostrUser nodes**: Store profile data and trust metrics (influence, PageRank) - **Relationships**: FOLLOWS, MUTES, REPORTS for social graph analysis +- **Deletion Detection**: `CheckForDeleted()` uses Tag traversal for kind 5 event checks - **WoT Schema**: See `pkg/neo4j/WOT_SPEC.md` for full specification - **Schema Modifications**: See `pkg/neo4j/MODIFYING_SCHEMA.md` for how to update +- **Comprehensive Tests**: `tag_model_test.go` covers Tag-based model, filter queries, migrations ### WasmDB IndexedDB Backend WebAssembly-compatible database backend (`pkg/wasmdb/`): diff --git a/pkg/neo4j/README.md b/pkg/neo4j/README.md index c4ffc7d..4e2a9c0 100644 --- a/pkg/neo4j/README.md +++ b/pkg/neo4j/README.md @@ -35,10 +35,12 @@ export ORLY_NEO4J_PASSWORD=password ## Features - **Graph-Native Storage**: Events, authors, and tags stored as nodes and relationships +- **Unified Tag Model**: All tags (including e/p tags) stored as Tag nodes with REFERENCES relationships - **Efficient Queries**: Leverages Neo4j's native graph traversal for tag and social graph queries - **Cypher Query Language**: Powerful, expressive query language for complex filters - **Automatic Indexing**: Unique constraints and indexes for optimal performance - **Relationship Queries**: Native support for event references, mentions, and tags +- **Automatic Migrations**: Schema migrations run automatically on startup - **Web of Trust (WoT) Extensions**: Optional support for trust metrics, social graph analysis, and content filtering (see [WOT_SPEC.md](./WOT_SPEC.md)) ## Architecture @@ -50,6 +52,23 @@ See [docs/NEO4J_BACKEND.md](../../docs/NEO4J_BACKEND.md) for comprehensive docum - Development guide - Comparison with other backends +### Tag-Based e/p Model + +All tags, including `e` (event references) and `p` (pubkey mentions), are stored through intermediate Tag nodes: + +``` +Event -[:TAGGED_WITH]-> Tag{type:'e',value:eventId} -[:REFERENCES]-> Event +Event -[:TAGGED_WITH]-> Tag{type:'p',value:pubkey} -[:REFERENCES]-> NostrUser +Event -[:TAGGED_WITH]-> Tag{type:'t',value:topic} (no REFERENCES for regular tags) +``` + +**Benefits:** +- Unified tag querying: `#e` and `#p` filter queries work correctly +- Consistent data model: All tags use the same TAGGED_WITH pattern +- Graph traversal: Can traverse from events through tags to referenced entities + +**Migration:** Existing databases with direct `REFERENCES`/`MENTIONS` relationships are automatically migrated at startup via v3 migration. + ### Web of Trust (WoT) Extensions This package includes schema support for Web of Trust trust metrics computation: @@ -96,6 +115,8 @@ This package includes schema support for Web of Trust trust metrics computation: ### Tests - `social-event-processor_test.go` - Comprehensive tests for kinds 0, 3, 1984, 10000 +- `tag_model_test.go` - Tag-based e/p model tests and filter query tests +- `save-event_test.go` - Event storage and relationship tests ## Testing @@ -166,11 +187,25 @@ MATCH (e:Event)-[:TAGGED_WITH]->(t:Tag {type: "t", value: "bitcoin"}) RETURN e ``` +### Event reference query (e-tags) +```cypher +MATCH (e:Event)-[:TAGGED_WITH]->(t:Tag {type: "e"})-[:REFERENCES]->(ref:Event) +WHERE e.id = "abc123..." +RETURN e, ref +``` + +### Mentions query (p-tags) +```cypher +MATCH (e:Event)-[:TAGGED_WITH]->(t:Tag {type: "p"})-[:REFERENCES]->(u:NostrUser) +WHERE e.id = "abc123..." +RETURN e, u +``` + ### Social graph query ```cypher MATCH (author:NostrUser {pubkey: "abc123..."}) <-[:AUTHORED_BY]-(e:Event) --[:MENTIONS]->(mentioned:NostrUser) +-[:TAGGED_WITH]->(:Tag {type: "p"})-[:REFERENCES]->(mentioned:NostrUser) RETURN author, e, mentioned ``` diff --git a/pkg/neo4j/WOT_SPEC.md b/pkg/neo4j/WOT_SPEC.md index c713894..13e5b5a 100644 --- a/pkg/neo4j/WOT_SPEC.md +++ b/pkg/neo4j/WOT_SPEC.md @@ -125,6 +125,40 @@ Legacy node label that is redundant with SetOfNostrUserWotMetricsCards. Should b ### Relationship Types +#### Tag-Based References (e and p tags) + +The Neo4j backend uses a unified Tag-based model for `e` and `p` tags, enabling consistent tag querying while maintaining graph traversal capabilities. + +**E-tags (Event References):** +``` +(Event)-[:TAGGED_WITH]->(Tag {type: 'e', value: })-[:REFERENCES]->(Event) +``` + +**P-tags (Pubkey Mentions):** +``` +(Event)-[:TAGGED_WITH]->(Tag {type: 'p', value: })-[:REFERENCES]->(NostrUser) +``` + +This model provides: +- Unified tag querying via `#e` and `#p` filters (same as other tags) +- Graph traversal from events to referenced events/users +- Consistent indexing through existing Tag node indexes + +**Query Examples:** +```cypher +-- Find all events that reference a specific event +MATCH (e:Event)-[:TAGGED_WITH]->(t:Tag {type: 'e', value: $eventId})-[:REFERENCES]->(ref:Event) +RETURN e + +-- Find all events that mention a specific pubkey +MATCH (e:Event)-[:TAGGED_WITH]->(t:Tag {type: 'p', value: $pubkey})-[:REFERENCES]->(u:NostrUser) +RETURN e + +-- Count references to an event (thread replies) +MATCH (t:Tag {type: 'e', value: $eventId})<-[:TAGGED_WITH]-(e:Event) +RETURN count(e) AS replyCount +``` + #### 1. FOLLOWS Represents a follow relationship between users (derived from kind 3 events). @@ -247,8 +281,9 @@ Comprehensive implementation with additional features: - `IS_A_REACTION_TO` (kind 7 reactions) - `IS_A_RESPONSE_TO` (kind 1 replies) - `IS_A_REPOST_OF` (kind 6, kind 16 reposts) - - `P_TAGGED` (p-tag mentions from events to users) - - `E_TAGGED` (e-tag references from events to events) + - Tag-based references (see "Tag-Based References" section above): + - `Event-[:TAGGED_WITH]->Tag{type:'p'}-[:REFERENCES]->NostrUser` (p-tag mentions) + - `Event-[:TAGGED_WITH]->Tag{type:'e'}-[:REFERENCES]->Event` (e-tag references) - NostrRelay, CashuMint nodes for ecosystem mapping - Enhanced GrapeRank incorporating zaps, replies, reactions diff --git a/pkg/neo4j/delete.go b/pkg/neo4j/delete.go index 0948791..756cd68 100644 --- a/pkg/neo4j/delete.go +++ b/pkg/neo4j/delete.go @@ -175,14 +175,15 @@ func (n *N) ProcessDelete(ev *event.E, admins [][]byte) error { // CheckForDeleted checks if an event has been deleted func (n *N) CheckForDeleted(ev *event.E, admins [][]byte) error { - // Query for kind 5 events that reference this event + // Query for kind 5 events that reference this event via Tag nodes ctx := context.Background() idStr := hex.Enc(ev.ID[:]) // Build cypher query to find deletion events + // Traverses through Tag nodes: Event-[:TAGGED_WITH]->Tag-[:REFERENCES]->Event cypher := ` MATCH (target:Event {id: $targetId}) -MATCH (delete:Event {kind: 5})-[:REFERENCES]->(target) +MATCH (delete:Event {kind: 5})-[:TAGGED_WITH]->(t:Tag {type: 'e'})-[:REFERENCES]->(target) WHERE delete.pubkey = $pubkey OR delete.pubkey IN $admins RETURN delete.id AS id LIMIT 1` diff --git a/pkg/neo4j/migrations.go b/pkg/neo4j/migrations.go index ef6b955..1ee9f8d 100644 --- a/pkg/neo4j/migrations.go +++ b/pkg/neo4j/migrations.go @@ -25,6 +25,11 @@ var migrations = []Migration{ 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, + }, } // RunMigrations executes all pending migrations @@ -343,3 +348,147 @@ func migrateBinaryToHex(ctx context.Context, n *N) error { 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 +} diff --git a/pkg/neo4j/save-event.go b/pkg/neo4j/save-event.go index 101e9ff..bf8356e 100644 --- a/pkg/neo4j/save-event.go +++ b/pkg/neo4j/save-event.go @@ -238,7 +238,8 @@ func (n *N) addTagsInBatches(c context.Context, eventID string, ev *event.E) err } // addPTagsInBatches adds p-tag (pubkey mention) relationships using UNWIND for efficiency. -// Creates NostrUser nodes for mentioned pubkeys and MENTIONS relationships. +// Creates Tag nodes with type='p' and REFERENCES relationships to NostrUser nodes. +// This enables unified tag querying via #p filters while maintaining the social graph. func (n *N) addPTagsInBatches(c context.Context, eventID string, pTags []string) error { // Process in batches to avoid memory issues for i := 0; i < len(pTags); i += tagBatchSize { @@ -249,12 +250,17 @@ func (n *N) addPTagsInBatches(c context.Context, eventID string, pTags []string) batch := pTags[i:end] // Use UNWIND to process multiple p-tags in a single query + // Creates Tag nodes as intermediaries, enabling unified #p filter queries + // Tag-[:REFERENCES]->NostrUser allows graph traversal from tag to user cypher := ` MATCH (e:Event {id: $eventId}) UNWIND $pubkeys AS pubkey +MERGE (t:Tag {type: 'p', value: pubkey}) +CREATE (e)-[:TAGGED_WITH]->(t) +WITH t, pubkey MERGE (u:NostrUser {pubkey: pubkey}) ON CREATE SET u.created_at = timestamp() -CREATE (e)-[:MENTIONS]->(u)` +MERGE (t)-[:REFERENCES]->(u)` params := map[string]any{ "eventId": eventID, @@ -270,7 +276,8 @@ CREATE (e)-[:MENTIONS]->(u)` } // addETagsInBatches adds e-tag (event reference) relationships using UNWIND for efficiency. -// Only creates REFERENCES relationships if the referenced event exists. +// Creates Tag nodes with type='e' and REFERENCES relationships to Event nodes (if they exist). +// This enables unified tag querying via #e filters while maintaining event graph structure. func (n *N) addETagsInBatches(c context.Context, eventID string, eTags []string) error { // Process in batches to avoid memory issues for i := 0; i < len(eTags); i += tagBatchSize { @@ -281,14 +288,18 @@ func (n *N) addETagsInBatches(c context.Context, eventID string, eTags []string) batch := eTags[i:end] // Use UNWIND to process multiple e-tags in a single query - // OPTIONAL MATCH ensures we only create relationships if referenced event exists + // Creates Tag nodes as intermediaries, enabling unified #e filter queries + // Tag-[:REFERENCES]->Event allows graph traversal from tag to referenced event + // OPTIONAL MATCH ensures we only create REFERENCES if referenced event exists cypher := ` MATCH (e:Event {id: $eventId}) UNWIND $eventIds AS refId +MERGE (t:Tag {type: 'e', value: refId}) +CREATE (e)-[:TAGGED_WITH]->(t) +WITH t, refId OPTIONAL MATCH (ref:Event {id: refId}) -WITH e, ref WHERE ref IS NOT NULL -CREATE (e)-[:REFERENCES]->(ref)` +MERGE (t)-[:REFERENCES]->(ref)` params := map[string]any{ "eventId": eventID, diff --git a/pkg/neo4j/save-event_test.go b/pkg/neo4j/save-event_test.go index ae1d28c..97148a6 100644 --- a/pkg/neo4j/save-event_test.go +++ b/pkg/neo4j/save-event_test.go @@ -151,7 +151,7 @@ func TestSafePrefix(t *testing.T) { } // TestSaveEvent_ETagReference tests that events with e-tags are saved correctly -// and the REFERENCES relationships are created when the referenced event exists. +// using the Tag-based model: Event-[:TAGGED_WITH]->Tag-[:REFERENCES]->Event. // Uses shared testDB from testmain_test.go to avoid auth rate limiting. func TestSaveEvent_ETagReference(t *testing.T) { if testDB == nil { @@ -226,10 +226,10 @@ func TestSaveEvent_ETagReference(t *testing.T) { t.Fatal("Reply event should not exist yet") } - // Verify REFERENCES relationship was created + // Verify Tag-based e-tag model: Event-[:TAGGED_WITH]->Tag{type:'e'}-[:REFERENCES]->Event cypher := ` - MATCH (reply:Event {id: $replyId})-[:REFERENCES]->(root:Event {id: $rootId}) - RETURN reply.id AS replyId, root.id AS rootId + MATCH (reply:Event {id: $replyId})-[:TAGGED_WITH]->(t:Tag {type: 'e', value: $rootId})-[:REFERENCES]->(root:Event {id: $rootId}) + RETURN reply.id AS replyId, t.value AS tagValue, root.id AS rootId ` params := map[string]any{ "replyId": hex.Enc(replyEvent.ID[:]), @@ -238,42 +238,43 @@ func TestSaveEvent_ETagReference(t *testing.T) { result, err := testDB.ExecuteRead(ctx, cypher, params) if err != nil { - t.Fatalf("Failed to query REFERENCES relationship: %v", err) + t.Fatalf("Failed to query Tag-based REFERENCES: %v", err) } if !result.Next(ctx) { - t.Error("Expected REFERENCES relationship between reply and root events") + t.Error("Expected Tag-based REFERENCES relationship between reply and root events") } else { record := result.Record() returnedReplyId := record.Values[0].(string) - returnedRootId := record.Values[1].(string) - t.Logf("✓ REFERENCES relationship verified: %s -> %s", returnedReplyId[:8], returnedRootId[:8]) + tagValue := record.Values[1].(string) + returnedRootId := record.Values[2].(string) + t.Logf("✓ Tag-based REFERENCES verified: Event(%s) -> Tag{e:%s} -> Event(%s)", returnedReplyId[:8], tagValue[:8], returnedRootId[:8]) } - // Verify MENTIONS relationship was also created for the p-tag - mentionsCypher := ` - MATCH (reply:Event {id: $replyId})-[:MENTIONS]->(author:NostrUser {pubkey: $authorPubkey}) - RETURN author.pubkey AS pubkey + // Verify Tag-based p-tag model: Event-[:TAGGED_WITH]->Tag{type:'p'}-[:REFERENCES]->NostrUser + pTagCypher := ` + MATCH (reply:Event {id: $replyId})-[:TAGGED_WITH]->(t:Tag {type: 'p', value: $authorPubkey})-[:REFERENCES]->(author:NostrUser {pubkey: $authorPubkey}) + RETURN author.pubkey AS pubkey, t.value AS tagValue ` - mentionsParams := map[string]any{ + pTagParams := map[string]any{ "replyId": hex.Enc(replyEvent.ID[:]), "authorPubkey": hex.Enc(alice.Pub()), } - mentionsResult, err := testDB.ExecuteRead(ctx, mentionsCypher, mentionsParams) + pTagResult, err := testDB.ExecuteRead(ctx, pTagCypher, pTagParams) if err != nil { - t.Fatalf("Failed to query MENTIONS relationship: %v", err) + t.Fatalf("Failed to query Tag-based p-tag: %v", err) } - if !mentionsResult.Next(ctx) { - t.Error("Expected MENTIONS relationship for p-tag") + if !pTagResult.Next(ctx) { + t.Error("Expected Tag-based p-tag relationship") } else { - t.Logf("✓ MENTIONS relationship verified") + t.Logf("✓ Tag-based p-tag relationship verified") } } // TestSaveEvent_ETagMissingReference tests that e-tags to non-existent events -// don't create broken relationships (batched processing handles this gracefully). +// create Tag nodes but don't create REFERENCES relationships to missing events. // Uses shared testDB from testmain_test.go to avoid auth rate limiting. func TestSaveEvent_ETagMissingReference(t *testing.T) { if testDB == nil { @@ -331,29 +332,50 @@ func TestSaveEvent_ETagMissingReference(t *testing.T) { t.Error("Event should have been saved despite missing reference") } - // Verify no REFERENCES relationship was created (as the target doesn't exist) + // Verify Tag node was created with TAGGED_WITH relationship + tagCypher := ` + MATCH (e:Event {id: $eventId})-[:TAGGED_WITH]->(t:Tag {type: 'e', value: $refId}) + RETURN t.value AS tagValue + ` + tagParams := map[string]any{ + "eventId": hex.Enc(ev.ID[:]), + "refId": nonExistentEventID, + } + + tagResult, err := testDB.ExecuteRead(ctx, tagCypher, tagParams) + if err != nil { + t.Fatalf("Failed to check Tag node: %v", err) + } + + if !tagResult.Next(ctx) { + t.Error("Expected Tag node to be created for e-tag even when target doesn't exist") + } else { + t.Logf("✓ Tag node created for missing reference") + } + + // Verify no REFERENCES relationship was created from Tag (as the target Event doesn't exist) refCypher := ` - MATCH (e:Event {id: $eventId})-[:REFERENCES]->(ref:Event) + MATCH (t:Tag {type: 'e', value: $refId})-[:REFERENCES]->(ref:Event) RETURN count(ref) AS refCount ` - refParams := map[string]any{"eventId": hex.Enc(ev.ID[:])} + refParams := map[string]any{"refId": nonExistentEventID} refResult, err := testDB.ExecuteRead(ctx, refCypher, refParams) if err != nil { - t.Fatalf("Failed to check references: %v", err) + t.Fatalf("Failed to check REFERENCES from Tag: %v", err) } if refResult.Next(ctx) { count := refResult.Record().Values[0].(int64) if count > 0 { - t.Errorf("Expected no REFERENCES relationship for non-existent event, got %d", count) + t.Errorf("Expected no REFERENCES from Tag for non-existent event, got %d", count) } else { - t.Logf("✓ Correctly handled missing reference (no relationship created)") + t.Logf("✓ Correctly handled missing reference (no REFERENCES from Tag)") } } } -// TestSaveEvent_MultipleETags tests events with multiple e-tags. +// TestSaveEvent_MultipleETags tests events with multiple e-tags using Tag-based model. // Uses shared testDB from testmain_test.go to avoid auth rate limiting. func TestSaveEvent_MultipleETags(t *testing.T) { if testDB == nil { @@ -409,7 +431,7 @@ func TestSaveEvent_MultipleETags(t *testing.T) { t.Fatalf("Failed to sign reply event: %v", err) } - // Save reply event - tests batched e-tag creation + // Save reply event - tests batched e-tag creation with Tag nodes exists, err := testDB.SaveEvent(ctx, replyEvent) if err != nil { t.Fatalf("Failed to save multi-reference event: %v", err) @@ -418,16 +440,17 @@ func TestSaveEvent_MultipleETags(t *testing.T) { t.Fatal("Reply event should not exist yet") } - // Verify all REFERENCES relationships were created + // Verify all Tag-based REFERENCES relationships were created + // Event-[:TAGGED_WITH]->Tag{type:'e'}-[:REFERENCES]->Event cypher := ` - MATCH (reply:Event {id: $replyId})-[:REFERENCES]->(ref:Event) + MATCH (reply:Event {id: $replyId})-[:TAGGED_WITH]->(t:Tag {type: 'e'})-[:REFERENCES]->(ref:Event) RETURN ref.id AS refId ` params := map[string]any{"replyId": hex.Enc(replyEvent.ID[:])} result, err := testDB.ExecuteRead(ctx, cypher, params) if err != nil { - t.Fatalf("Failed to query REFERENCES relationships: %v", err) + t.Fatalf("Failed to query Tag-based REFERENCES: %v", err) } referencedIDs := make(map[string]bool) @@ -437,20 +460,20 @@ func TestSaveEvent_MultipleETags(t *testing.T) { } if len(referencedIDs) != 3 { - t.Errorf("Expected 3 REFERENCES relationships, got %d", len(referencedIDs)) + t.Errorf("Expected 3 Tag-based REFERENCES, got %d", len(referencedIDs)) } for i, id := range eventIDs { if !referencedIDs[id] { - t.Errorf("Missing REFERENCES relationship to event %d (%s)", i, id[:8]) + t.Errorf("Missing Tag-based REFERENCES to event %d (%s)", i, id[:8]) } } - t.Logf("✓ All %d REFERENCES relationships created successfully", len(referencedIDs)) + t.Logf("✓ All %d Tag-based REFERENCES created successfully", len(referencedIDs)) } // TestSaveEvent_LargePTagBatch tests that events with many p-tags are saved correctly -// using batched processing to avoid Neo4j stack overflow. +// using batched Tag-based processing to avoid Neo4j stack overflow. // Uses shared testDB from testmain_test.go to avoid auth rate limiting. func TestSaveEvent_LargePTagBatch(t *testing.T) { if testDB == nil { @@ -498,24 +521,45 @@ func TestSaveEvent_LargePTagBatch(t *testing.T) { t.Fatal("Event should not exist yet") } - // Verify all MENTIONS relationships were created - countCypher := ` - MATCH (e:Event {id: $eventId})-[:MENTIONS]->(u:NostrUser) - RETURN count(u) AS mentionCount + // Verify all Tag nodes were created with TAGGED_WITH relationships + tagCountCypher := ` + MATCH (e:Event {id: $eventId})-[:TAGGED_WITH]->(t:Tag {type: 'p'}) + RETURN count(t) AS tagCount ` - countParams := map[string]any{"eventId": hex.Enc(ev.ID[:])} + tagCountParams := map[string]any{"eventId": hex.Enc(ev.ID[:])} - result, err := testDB.ExecuteRead(ctx, countCypher, countParams) + tagResult, err := testDB.ExecuteRead(ctx, tagCountCypher, tagCountParams) if err != nil { - t.Fatalf("Failed to count MENTIONS: %v", err) + t.Fatalf("Failed to count p-tag Tag nodes: %v", err) } - if result.Next(ctx) { - count := result.Record().Values[0].(int64) + if tagResult.Next(ctx) { + count := tagResult.Record().Values[0].(int64) if count != int64(numTags) { - t.Errorf("Expected %d MENTIONS relationships, got %d", numTags, count) + t.Errorf("Expected %d Tag nodes, got %d", numTags, count) } else { - t.Logf("✓ All %d MENTIONS relationships created via batched processing", count) + t.Logf("✓ All %d p-tag Tag nodes created via batched processing", count) + } + } + + // Verify all REFERENCES relationships to NostrUser were created + refCountCypher := ` + MATCH (e:Event {id: $eventId})-[:TAGGED_WITH]->(t:Tag {type: 'p'})-[:REFERENCES]->(u:NostrUser) + RETURN count(u) AS refCount + ` + refCountParams := map[string]any{"eventId": hex.Enc(ev.ID[:])} + + refResult, err := testDB.ExecuteRead(ctx, refCountCypher, refCountParams) + if err != nil { + t.Fatalf("Failed to count Tag-based REFERENCES to NostrUser: %v", err) + } + + if refResult.Next(ctx) { + count := refResult.Record().Values[0].(int64) + if count != int64(numTags) { + t.Errorf("Expected %d REFERENCES to NostrUser, got %d", numTags, count) + } else { + t.Logf("✓ All %d Tag-based REFERENCES to NostrUser created via batched processing", count) } } } diff --git a/pkg/neo4j/tag_model_test.go b/pkg/neo4j/tag_model_test.go new file mode 100644 index 0000000..d9991e4 --- /dev/null +++ b/pkg/neo4j/tag_model_test.go @@ -0,0 +1,1105 @@ +package neo4j + +import ( + "context" + "fmt" + "testing" + + "git.mleku.dev/mleku/nostr/encoders/event" + "git.mleku.dev/mleku/nostr/encoders/filter" + "git.mleku.dev/mleku/nostr/encoders/hex" + "git.mleku.dev/mleku/nostr/encoders/tag" + "git.mleku.dev/mleku/nostr/encoders/timestamp" + "git.mleku.dev/mleku/nostr/interfaces/signer/p8k" +) + +// ============================================================================= +// Tag-Based E/P Model Tests +// ============================================================================= + +// TestTagBasedModel_ETagCreatesTagNode verifies that e-tags create Tag nodes +// with type='e' and TAGGED_WITH relationships from the event. +func TestTagBasedModel_ETagCreatesTagNode(t *testing.T) { + if testDB == nil { + t.Skip("Neo4j not available") + } + + ctx := context.Background() + cleanTestDatabase() + + signer, err := p8k.New() + if err != nil { + t.Fatalf("Failed to create signer: %v", err) + } + if err := signer.Generate(); err != nil { + t.Fatalf("Failed to generate keypair: %v", err) + } + + // Create a target event first + targetEvent := event.New() + targetEvent.Pubkey = signer.Pub() + targetEvent.CreatedAt = timestamp.Now().V + targetEvent.Kind = 1 + targetEvent.Content = []byte("Target event") + if err := targetEvent.Sign(signer); err != nil { + t.Fatalf("Failed to sign target event: %v", err) + } + + if _, err := testDB.SaveEvent(ctx, targetEvent); err != nil { + t.Fatalf("Failed to save target event: %v", err) + } + + targetID := hex.Enc(targetEvent.ID[:]) + + // Create event with e-tag referencing the target + ev := event.New() + ev.Pubkey = signer.Pub() + ev.CreatedAt = timestamp.Now().V + 1 + ev.Kind = 1 + ev.Content = []byte("Event with e-tag") + ev.Tags = tag.NewS( + tag.NewFromAny("e", targetID, "", "reply"), + ) + if err := ev.Sign(signer); err != nil { + t.Fatalf("Failed to sign event: %v", err) + } + + if _, err := testDB.SaveEvent(ctx, ev); err != nil { + t.Fatalf("Failed to save event: %v", err) + } + + eventID := hex.Enc(ev.ID[:]) + + // Verify Tag node was created + tagCypher := ` + MATCH (t:Tag {type: 'e', value: $targetId}) + RETURN t.type AS type, t.value AS value + ` + tagResult, err := testDB.ExecuteRead(ctx, tagCypher, map[string]any{"targetId": targetID}) + if err != nil { + t.Fatalf("Failed to query Tag node: %v", err) + } + + if !tagResult.Next(ctx) { + t.Fatal("Expected Tag node with type='e' to be created") + } + + record := tagResult.Record() + tagType := record.Values[0].(string) + tagValue := record.Values[1].(string) + + if tagType != "e" { + t.Errorf("Expected tag type 'e', got %q", tagType) + } + if tagValue != targetID { + t.Errorf("Expected tag value %q, got %q", targetID, tagValue) + } + + // Verify TAGGED_WITH relationship exists + taggedWithCypher := ` + MATCH (e:Event {id: $eventId})-[:TAGGED_WITH]->(t:Tag {type: 'e', value: $targetId}) + RETURN count(t) AS count + ` + twResult, err := testDB.ExecuteRead(ctx, taggedWithCypher, map[string]any{ + "eventId": eventID, + "targetId": targetID, + }) + if err != nil { + t.Fatalf("Failed to query TAGGED_WITH: %v", err) + } + + if twResult.Next(ctx) { + count := twResult.Record().Values[0].(int64) + if count != 1 { + t.Errorf("Expected 1 TAGGED_WITH relationship, got %d", count) + } + } else { + t.Fatal("Expected TAGGED_WITH relationship to exist") + } + + // Verify REFERENCES relationship from Tag to Event + refCypher := ` + MATCH (t:Tag {type: 'e', value: $targetId})-[:REFERENCES]->(target:Event {id: $targetId}) + RETURN count(target) AS count + ` + refResult, err := testDB.ExecuteRead(ctx, refCypher, map[string]any{"targetId": targetID}) + if err != nil { + t.Fatalf("Failed to query REFERENCES: %v", err) + } + + if refResult.Next(ctx) { + count := refResult.Record().Values[0].(int64) + if count != 1 { + t.Errorf("Expected 1 REFERENCES relationship from Tag to Event, got %d", count) + } + } else { + t.Fatal("Expected REFERENCES relationship from Tag to Event") + } + + t.Logf("Tag-based e-tag model verified: Event -> Tag{e} -> Event") +} + +// TestTagBasedModel_PTagCreatesTagNode verifies that p-tags create Tag nodes +// with type='p' and REFERENCES relationships to NostrUser nodes. +func TestTagBasedModel_PTagCreatesTagNode(t *testing.T) { + if testDB == nil { + t.Skip("Neo4j not available") + } + + ctx := context.Background() + cleanTestDatabase() + + // Create two signers: author and mentioned user + author, err := p8k.New() + if err != nil { + t.Fatalf("Failed to create author signer: %v", err) + } + if err := author.Generate(); err != nil { + t.Fatalf("Failed to generate author keypair: %v", err) + } + + mentioned, err := p8k.New() + if err != nil { + t.Fatalf("Failed to create mentioned signer: %v", err) + } + if err := mentioned.Generate(); err != nil { + t.Fatalf("Failed to generate mentioned keypair: %v", err) + } + + mentionedPubkey := hex.Enc(mentioned.Pub()) + + // Create event with p-tag + ev := event.New() + ev.Pubkey = author.Pub() + ev.CreatedAt = timestamp.Now().V + ev.Kind = 1 + ev.Content = []byte("Event mentioning someone") + ev.Tags = tag.NewS( + tag.NewFromAny("p", mentionedPubkey), + ) + if err := ev.Sign(author); err != nil { + t.Fatalf("Failed to sign event: %v", err) + } + + if _, err := testDB.SaveEvent(ctx, ev); err != nil { + t.Fatalf("Failed to save event: %v", err) + } + + eventID := hex.Enc(ev.ID[:]) + + // Verify Tag node was created + tagCypher := ` + MATCH (t:Tag {type: 'p', value: $pubkey}) + RETURN t.type AS type, t.value AS value + ` + tagResult, err := testDB.ExecuteRead(ctx, tagCypher, map[string]any{"pubkey": mentionedPubkey}) + if err != nil { + t.Fatalf("Failed to query Tag node: %v", err) + } + + if !tagResult.Next(ctx) { + t.Fatal("Expected Tag node with type='p' to be created") + } + + // Verify TAGGED_WITH relationship exists + taggedWithCypher := ` + MATCH (e:Event {id: $eventId})-[:TAGGED_WITH]->(t:Tag {type: 'p', value: $pubkey}) + RETURN count(t) AS count + ` + twResult, err := testDB.ExecuteRead(ctx, taggedWithCypher, map[string]any{ + "eventId": eventID, + "pubkey": mentionedPubkey, + }) + if err != nil { + t.Fatalf("Failed to query TAGGED_WITH: %v", err) + } + + if twResult.Next(ctx) { + count := twResult.Record().Values[0].(int64) + if count != 1 { + t.Errorf("Expected 1 TAGGED_WITH relationship, got %d", count) + } + } + + // Verify REFERENCES relationship from Tag to NostrUser + refCypher := ` + MATCH (t:Tag {type: 'p', value: $pubkey})-[:REFERENCES]->(u:NostrUser {pubkey: $pubkey}) + RETURN count(u) AS count + ` + refResult, err := testDB.ExecuteRead(ctx, refCypher, map[string]any{"pubkey": mentionedPubkey}) + if err != nil { + t.Fatalf("Failed to query REFERENCES: %v", err) + } + + if refResult.Next(ctx) { + count := refResult.Record().Values[0].(int64) + if count != 1 { + t.Errorf("Expected 1 REFERENCES relationship from Tag to NostrUser, got %d", count) + } + } else { + t.Fatal("Expected REFERENCES relationship from Tag to NostrUser") + } + + // Verify NostrUser was created for the mentioned pubkey + userCypher := ` + MATCH (u:NostrUser {pubkey: $pubkey}) + RETURN u.pubkey AS pubkey + ` + userResult, err := testDB.ExecuteRead(ctx, userCypher, map[string]any{"pubkey": mentionedPubkey}) + if err != nil { + t.Fatalf("Failed to query NostrUser: %v", err) + } + + if !userResult.Next(ctx) { + t.Fatal("Expected NostrUser to be created for mentioned pubkey") + } + + t.Logf("Tag-based p-tag model verified: Event -> Tag{p} -> NostrUser") +} + +// TestTagBasedModel_ETagWithoutTargetEvent verifies that e-tags create Tag nodes +// even when the referenced event doesn't exist, but don't create REFERENCES. +func TestTagBasedModel_ETagWithoutTargetEvent(t *testing.T) { + if testDB == nil { + t.Skip("Neo4j not available") + } + + ctx := context.Background() + cleanTestDatabase() + + signer, err := p8k.New() + if err != nil { + t.Fatalf("Failed to create signer: %v", err) + } + if err := signer.Generate(); err != nil { + t.Fatalf("Failed to generate keypair: %v", err) + } + + // Non-existent event ID + nonExistentID := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + // Create event with e-tag referencing non-existent event + ev := event.New() + ev.Pubkey = signer.Pub() + ev.CreatedAt = timestamp.Now().V + ev.Kind = 1 + ev.Content = []byte("Reply to ghost event") + ev.Tags = tag.NewS( + tag.NewFromAny("e", nonExistentID, "", "reply"), + ) + if err := ev.Sign(signer); err != nil { + t.Fatalf("Failed to sign event: %v", err) + } + + if _, err := testDB.SaveEvent(ctx, ev); err != nil { + t.Fatalf("Failed to save event: %v", err) + } + + eventID := hex.Enc(ev.ID[:]) + + // Verify Tag node WAS created (for query purposes) + tagCypher := ` + MATCH (e:Event {id: $eventId})-[:TAGGED_WITH]->(t:Tag {type: 'e', value: $targetId}) + RETURN t.value AS value + ` + tagResult, err := testDB.ExecuteRead(ctx, tagCypher, map[string]any{ + "eventId": eventID, + "targetId": nonExistentID, + }) + if err != nil { + t.Fatalf("Failed to query Tag node: %v", err) + } + + if !tagResult.Next(ctx) { + t.Fatal("Expected Tag node to be created even for non-existent target") + } + + // Verify REFERENCES was NOT created (target doesn't exist) + refCypher := ` + MATCH (t:Tag {type: 'e', value: $targetId})-[:REFERENCES]->(target:Event) + RETURN count(target) AS count + ` + refResult, err := testDB.ExecuteRead(ctx, refCypher, map[string]any{"targetId": nonExistentID}) + if err != nil { + t.Fatalf("Failed to query REFERENCES: %v", err) + } + + if refResult.Next(ctx) { + count := refResult.Record().Values[0].(int64) + if count != 0 { + t.Errorf("Expected 0 REFERENCES for non-existent event, got %d", count) + } + } + + t.Logf("Correctly handled e-tag to non-existent event: Tag created, no REFERENCES") +} + +// ============================================================================= +// Tag Filter Query Tests (#e and #p filters) +// ============================================================================= + +// TestTagFilter_ETagQuery tests that #e filters work with the Tag-based model. +func TestTagFilter_ETagQuery(t *testing.T) { + if testDB == nil { + t.Skip("Neo4j not available") + } + + ctx := context.Background() + cleanTestDatabase() + + signer, err := p8k.New() + if err != nil { + t.Fatalf("Failed to create signer: %v", err) + } + if err := signer.Generate(); err != nil { + t.Fatalf("Failed to generate keypair: %v", err) + } + + // Create root event + rootEvent := event.New() + rootEvent.Pubkey = signer.Pub() + rootEvent.CreatedAt = timestamp.Now().V + rootEvent.Kind = 1 + rootEvent.Content = []byte("Root event") + if err := rootEvent.Sign(signer); err != nil { + t.Fatalf("Failed to sign root event: %v", err) + } + + if _, err := testDB.SaveEvent(ctx, rootEvent); err != nil { + t.Fatalf("Failed to save root event: %v", err) + } + + rootID := hex.Enc(rootEvent.ID[:]) + + // Create reply event with e-tag + replyEvent := event.New() + replyEvent.Pubkey = signer.Pub() + replyEvent.CreatedAt = timestamp.Now().V + 1 + replyEvent.Kind = 1 + replyEvent.Content = []byte("Reply to root") + replyEvent.Tags = tag.NewS( + tag.NewFromAny("e", rootID, "", "root"), + ) + if err := replyEvent.Sign(signer); err != nil { + t.Fatalf("Failed to sign reply event: %v", err) + } + + if _, err := testDB.SaveEvent(ctx, replyEvent); err != nil { + t.Fatalf("Failed to save reply event: %v", err) + } + + // Create unrelated event (no e-tag) + unrelatedEvent := event.New() + unrelatedEvent.Pubkey = signer.Pub() + unrelatedEvent.CreatedAt = timestamp.Now().V + 2 + unrelatedEvent.Kind = 1 + unrelatedEvent.Content = []byte("Unrelated event") + if err := unrelatedEvent.Sign(signer); err != nil { + t.Fatalf("Failed to sign unrelated event: %v", err) + } + + if _, err := testDB.SaveEvent(ctx, unrelatedEvent); err != nil { + t.Fatalf("Failed to save unrelated event: %v", err) + } + + // Query events with #e filter + f := &filter.F{ + Tags: tag.NewS(tag.NewFromAny("e", rootID)), + } + + events, err := testDB.QueryEvents(ctx, f) + if err != nil { + t.Fatalf("Failed to query with #e filter: %v", err) + } + + if len(events) != 1 { + t.Errorf("Expected 1 event with #e filter, got %d", len(events)) + } + + if len(events) > 0 { + foundID := hex.Enc(events[0].ID[:]) + expectedID := hex.Enc(replyEvent.ID[:]) + if foundID != expectedID { + t.Errorf("Expected to find reply event, got event %s", foundID[:8]) + } + } + + t.Logf("#e filter query working correctly with Tag-based model") +} + +// TestTagFilter_PTagQuery tests that #p filters work with the Tag-based model. +func TestTagFilter_PTagQuery(t *testing.T) { + if testDB == nil { + t.Skip("Neo4j not available") + } + + ctx := context.Background() + cleanTestDatabase() + + // Create two signers + author, err := p8k.New() + if err != nil { + t.Fatalf("Failed to create author signer: %v", err) + } + if err := author.Generate(); err != nil { + t.Fatalf("Failed to generate author keypair: %v", err) + } + + mentioned, err := p8k.New() + if err != nil { + t.Fatalf("Failed to create mentioned signer: %v", err) + } + if err := mentioned.Generate(); err != nil { + t.Fatalf("Failed to generate mentioned keypair: %v", err) + } + + mentionedPubkey := hex.Enc(mentioned.Pub()) + + // Create event that mentions someone + mentionEvent := event.New() + mentionEvent.Pubkey = author.Pub() + mentionEvent.CreatedAt = timestamp.Now().V + mentionEvent.Kind = 1 + mentionEvent.Content = []byte("Hey @someone") + mentionEvent.Tags = tag.NewS( + tag.NewFromAny("p", mentionedPubkey), + ) + if err := mentionEvent.Sign(author); err != nil { + t.Fatalf("Failed to sign mention event: %v", err) + } + + if _, err := testDB.SaveEvent(ctx, mentionEvent); err != nil { + t.Fatalf("Failed to save mention event: %v", err) + } + + // Create event without p-tag + regularEvent := event.New() + regularEvent.Pubkey = author.Pub() + regularEvent.CreatedAt = timestamp.Now().V + 1 + regularEvent.Kind = 1 + regularEvent.Content = []byte("Regular post") + if err := regularEvent.Sign(author); err != nil { + t.Fatalf("Failed to sign regular event: %v", err) + } + + if _, err := testDB.SaveEvent(ctx, regularEvent); err != nil { + t.Fatalf("Failed to save regular event: %v", err) + } + + // Query events with #p filter + f := &filter.F{ + Tags: tag.NewS(tag.NewFromAny("p", mentionedPubkey)), + } + + events, err := testDB.QueryEvents(ctx, f) + if err != nil { + t.Fatalf("Failed to query with #p filter: %v", err) + } + + if len(events) != 1 { + t.Errorf("Expected 1 event with #p filter, got %d", len(events)) + } + + if len(events) > 0 { + foundID := hex.Enc(events[0].ID[:]) + expectedID := hex.Enc(mentionEvent.ID[:]) + if foundID != expectedID { + t.Errorf("Expected to find mention event, got event %s", foundID[:8]) + } + } + + t.Logf("#p filter query working correctly with Tag-based model") +} + +// TestTagFilter_MultiplePTags tests events with multiple p-tags. +func TestTagFilter_MultiplePTags(t *testing.T) { + if testDB == nil { + t.Skip("Neo4j not available") + } + + ctx := context.Background() + cleanTestDatabase() + + author, err := p8k.New() + if err != nil { + t.Fatalf("Failed to create author signer: %v", err) + } + if err := author.Generate(); err != nil { + t.Fatalf("Failed to generate author keypair: %v", err) + } + + // Generate 5 pubkeys to mention + var mentionedPubkeys []string + for i := 0; i < 5; i++ { + mentionedPubkeys = append(mentionedPubkeys, fmt.Sprintf("%064x", i+1)) + } + + // Create event mentioning all 5 + ev := event.New() + ev.Pubkey = author.Pub() + ev.CreatedAt = timestamp.Now().V + ev.Kind = 1 + ev.Content = []byte("Group mention") + tags := tag.NewS() + for _, pk := range mentionedPubkeys { + tags.Append(tag.NewFromAny("p", pk)) + } + ev.Tags = tags + if err := ev.Sign(author); err != nil { + t.Fatalf("Failed to sign event: %v", err) + } + + if _, err := testDB.SaveEvent(ctx, ev); err != nil { + t.Fatalf("Failed to save event: %v", err) + } + + // Verify all Tag nodes were created + countCypher := ` + MATCH (e:Event {id: $eventId})-[:TAGGED_WITH]->(t:Tag {type: 'p'}) + RETURN count(t) AS count + ` + result, err := testDB.ExecuteRead(ctx, countCypher, map[string]any{"eventId": hex.Enc(ev.ID[:])}) + if err != nil { + t.Fatalf("Failed to count p-tag Tags: %v", err) + } + + if result.Next(ctx) { + count := result.Record().Values[0].(int64) + if count != int64(len(mentionedPubkeys)) { + t.Errorf("Expected %d p-tag Tag nodes, got %d", len(mentionedPubkeys), count) + } + } + + // Query for events mentioning any of the pubkeys + f := &filter.F{ + Tags: tag.NewS(tag.NewFromAny("p", mentionedPubkeys[2])), // Query for the 3rd pubkey + } + + events, err := testDB.QueryEvents(ctx, f) + if err != nil { + t.Fatalf("Failed to query with #p filter: %v", err) + } + + if len(events) != 1 { + t.Errorf("Expected 1 event mentioning pubkey, got %d", len(events)) + } + + t.Logf("Multiple p-tags correctly stored and queryable") +} + +// ============================================================================= +// CheckForDeleted with Tag Traversal Tests +// ============================================================================= + +// TestCheckForDeleted_WithTagModel tests that CheckForDeleted works with +// the new Tag-based model for e-tag references. +func TestCheckForDeleted_WithTagModel(t *testing.T) { + if testDB == nil { + t.Skip("Neo4j not available") + } + + ctx := context.Background() + cleanTestDatabase() + + signer, err := p8k.New() + if err != nil { + t.Fatalf("Failed to create signer: %v", err) + } + if err := signer.Generate(); err != nil { + t.Fatalf("Failed to generate keypair: %v", err) + } + + // Create target event + targetEvent := event.New() + targetEvent.Pubkey = signer.Pub() + targetEvent.CreatedAt = timestamp.Now().V + targetEvent.Kind = 1 + targetEvent.Content = []byte("Event to be deleted") + if err := targetEvent.Sign(signer); err != nil { + t.Fatalf("Failed to sign target event: %v", err) + } + + if _, err := testDB.SaveEvent(ctx, targetEvent); err != nil { + t.Fatalf("Failed to save target event: %v", err) + } + + targetID := hex.Enc(targetEvent.ID[:]) + + // Create kind 5 deletion event with e-tag + deleteEvent := event.New() + deleteEvent.Pubkey = signer.Pub() + deleteEvent.CreatedAt = timestamp.Now().V + 1 + deleteEvent.Kind = 5 + deleteEvent.Content = []byte("Deleting my event") + deleteEvent.Tags = tag.NewS( + tag.NewFromAny("e", targetID), + ) + if err := deleteEvent.Sign(signer); err != nil { + t.Fatalf("Failed to sign delete event: %v", err) + } + + if _, err := testDB.SaveEvent(ctx, deleteEvent); err != nil { + t.Fatalf("Failed to save delete event: %v", err) + } + + // Verify the Tag-based traversal exists: + // DeleteEvent-[:TAGGED_WITH]->Tag{type:'e'}-[:REFERENCES]->TargetEvent + traversalCypher := ` + MATCH (delete:Event {kind: 5})-[:TAGGED_WITH]->(t:Tag {type: 'e'})-[:REFERENCES]->(target:Event {id: $targetId}) + RETURN delete.id AS deleteId + ` + result, err := testDB.ExecuteRead(ctx, traversalCypher, map[string]any{"targetId": targetID}) + if err != nil { + t.Fatalf("Failed to query traversal: %v", err) + } + + if !result.Next(ctx) { + t.Fatal("Expected Tag-based traversal from delete event to target") + } + + // Test CheckForDeleted + admins := [][]byte{} // No admins, author can delete own events + err = testDB.CheckForDeleted(targetEvent, admins) + + if err == nil { + t.Error("Expected CheckForDeleted to return error for deleted event") + } else if err.Error() != "event has been deleted" { + t.Errorf("Unexpected error message: %v", err) + } + + t.Logf("CheckForDeleted correctly detects deletion via Tag-based traversal") +} + +// TestCheckForDeleted_NotDeleted verifies CheckForDeleted returns nil for +// events that haven't been deleted. +func TestCheckForDeleted_NotDeleted(t *testing.T) { + if testDB == nil { + t.Skip("Neo4j not available") + } + + ctx := context.Background() + cleanTestDatabase() + + signer, err := p8k.New() + if err != nil { + t.Fatalf("Failed to create signer: %v", err) + } + if err := signer.Generate(); err != nil { + t.Fatalf("Failed to generate keypair: %v", err) + } + + // Create event that won't be deleted + ev := event.New() + ev.Pubkey = signer.Pub() + ev.CreatedAt = timestamp.Now().V + ev.Kind = 1 + ev.Content = []byte("Regular event") + if err := ev.Sign(signer); err != nil { + t.Fatalf("Failed to sign event: %v", err) + } + + if _, err := testDB.SaveEvent(ctx, ev); err != nil { + t.Fatalf("Failed to save event: %v", err) + } + + // CheckForDeleted should return nil + admins := [][]byte{} + err = testDB.CheckForDeleted(ev, admins) + + if err != nil { + t.Errorf("Expected nil for non-deleted event, got: %v", err) + } + + t.Logf("CheckForDeleted correctly returns nil for non-deleted event") +} + +// ============================================================================= +// Migration v3 Tests +// ============================================================================= + +// TestMigrationV3_ConvertDirectReferences tests that the v3 migration +// correctly converts direct REFERENCES relationships to Tag-based model. +func TestMigrationV3_ConvertDirectReferences(t *testing.T) { + if testDB == nil { + t.Skip("Neo4j not available") + } + + ctx := context.Background() + cleanTestDatabase() + + // Manually create old-style direct REFERENCES relationship + // (simulating pre-migration data) + setupCypher := ` + // Create two events + CREATE (source:Event { + id: '1111111111111111111111111111111111111111111111111111111111111111', + pubkey: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + kind: 1, + created_at: 1700000000, + content: 'Source event', + sig: '0000000000000000000000000000000000000000000000000000000000000000' + + '0000000000000000000000000000000000000000000000000000000000000000', + tags: '[]', + serial: 1, + expiration: 0 + }) + CREATE (target:Event { + id: '2222222222222222222222222222222222222222222222222222222222222222', + pubkey: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + kind: 1, + created_at: 1699999999, + content: 'Target event', + sig: '0000000000000000000000000000000000000000000000000000000000000000' + + '0000000000000000000000000000000000000000000000000000000000000000', + tags: '[]', + serial: 2, + expiration: 0 + }) + // Create old-style direct REFERENCES (pre-migration) + CREATE (source)-[:REFERENCES]->(target) + ` + + if _, err := testDB.ExecuteWrite(ctx, setupCypher, nil); err != nil { + t.Fatalf("Failed to setup pre-migration data: %v", err) + } + + // Verify old-style relationship exists + checkOldCypher := ` + MATCH (s:Event)-[r:REFERENCES]->(t:Event) + WHERE NOT (s)-[:TAGGED_WITH]->(:Tag) + RETURN count(r) AS count + ` + result, err := testDB.ExecuteRead(ctx, checkOldCypher, nil) + if err != nil { + t.Fatalf("Failed to check old relationship: %v", err) + } + + var oldCount int64 + if result.Next(ctx) { + oldCount = result.Record().Values[0].(int64) + } + + if oldCount == 0 { + t.Skip("No old-style REFERENCES to migrate") + } + + t.Logf("Found %d old-style REFERENCES to migrate", oldCount) + + // Run migration + err = migrateToTagBasedReferences(ctx, testDB) + if err != nil { + t.Fatalf("Migration failed: %v", err) + } + + // Verify old-style relationship was removed + result, err = testDB.ExecuteRead(ctx, checkOldCypher, nil) + if err != nil { + t.Fatalf("Failed to check post-migration: %v", err) + } + + if result.Next(ctx) { + count := result.Record().Values[0].(int64) + if count != 0 { + t.Errorf("Expected 0 old-style REFERENCES after migration, got %d", count) + } + } + + // Verify new Tag-based structure exists + checkNewCypher := ` + MATCH (s:Event {id: '1111111111111111111111111111111111111111111111111111111111111111'}) + -[:TAGGED_WITH]->(t:Tag {type: 'e'}) + -[:REFERENCES]->(target:Event {id: '2222222222222222222222222222222222222222222222222222222222222222'}) + RETURN t.value AS tagValue + ` + result, err = testDB.ExecuteRead(ctx, checkNewCypher, nil) + if err != nil { + t.Fatalf("Failed to check new structure: %v", err) + } + + if !result.Next(ctx) { + t.Error("Expected Tag-based structure after migration") + } else { + tagValue := result.Record().Values[0].(string) + expectedValue := "2222222222222222222222222222222222222222222222222222222222222222" + if tagValue != expectedValue { + t.Errorf("Expected tag value %s, got %s", expectedValue, tagValue) + } + } + + t.Logf("Migration v3 correctly converted REFERENCES to Tag-based model") +} + +// TestMigrationV3_ConvertDirectMentions tests that the v3 migration +// correctly converts direct MENTIONS relationships to Tag-based model. +func TestMigrationV3_ConvertDirectMentions(t *testing.T) { + if testDB == nil { + t.Skip("Neo4j not available") + } + + ctx := context.Background() + cleanTestDatabase() + + // Manually create old-style direct MENTIONS relationship + setupCypher := ` + // Create event and user + CREATE (source:Event { + id: '3333333333333333333333333333333333333333333333333333333333333333', + pubkey: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + kind: 1, + created_at: 1700000000, + content: 'Event mentioning user', + sig: '0000000000000000000000000000000000000000000000000000000000000000' + + '0000000000000000000000000000000000000000000000000000000000000000', + tags: '[]', + serial: 3, + expiration: 0 + }) + MERGE (user:NostrUser {pubkey: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'}) + // Create old-style direct MENTIONS (pre-migration) + CREATE (source)-[:MENTIONS]->(user) + ` + + if _, err := testDB.ExecuteWrite(ctx, setupCypher, nil); err != nil { + t.Fatalf("Failed to setup pre-migration data: %v", err) + } + + // Verify old-style relationship exists + checkOldCypher := ` + MATCH (e:Event)-[r:MENTIONS]->(u:NostrUser) + RETURN count(r) AS count + ` + result, err := testDB.ExecuteRead(ctx, checkOldCypher, nil) + if err != nil { + t.Fatalf("Failed to check old relationship: %v", err) + } + + var oldCount int64 + if result.Next(ctx) { + oldCount = result.Record().Values[0].(int64) + } + + if oldCount == 0 { + t.Skip("No old-style MENTIONS to migrate") + } + + t.Logf("Found %d old-style MENTIONS to migrate", oldCount) + + // Run migration + err = migrateToTagBasedReferences(ctx, testDB) + if err != nil { + t.Fatalf("Migration failed: %v", err) + } + + // Verify old-style relationship was removed + result, err = testDB.ExecuteRead(ctx, checkOldCypher, nil) + if err != nil { + t.Fatalf("Failed to check post-migration: %v", err) + } + + if result.Next(ctx) { + count := result.Record().Values[0].(int64) + if count != 0 { + t.Errorf("Expected 0 old-style MENTIONS after migration, got %d", count) + } + } + + // Verify new Tag-based structure exists + checkNewCypher := ` + MATCH (e:Event {id: '3333333333333333333333333333333333333333333333333333333333333333'}) + -[:TAGGED_WITH]->(t:Tag {type: 'p'}) + -[:REFERENCES]->(u:NostrUser {pubkey: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'}) + RETURN t.value AS tagValue + ` + result, err = testDB.ExecuteRead(ctx, checkNewCypher, nil) + if err != nil { + t.Fatalf("Failed to check new structure: %v", err) + } + + if !result.Next(ctx) { + t.Error("Expected Tag-based structure after migration") + } else { + tagValue := result.Record().Values[0].(string) + expectedValue := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + if tagValue != expectedValue { + t.Errorf("Expected tag value %s, got %s", expectedValue, tagValue) + } + } + + t.Logf("Migration v3 correctly converted MENTIONS to Tag-based model") +} + +// TestMigrationV3_Idempotent tests that the v3 migration is idempotent +// (safe to run multiple times). +func TestMigrationV3_Idempotent(t *testing.T) { + if testDB == nil { + t.Skip("Neo4j not available") + } + + ctx := context.Background() + cleanTestDatabase() + + // Create proper Tag-based structure (as if migration already ran) + signer, err := p8k.New() + if err != nil { + t.Fatalf("Failed to create signer: %v", err) + } + if err := signer.Generate(); err != nil { + t.Fatalf("Failed to generate keypair: %v", err) + } + + // Create event with e-tag using new model + targetEvent := event.New() + targetEvent.Pubkey = signer.Pub() + targetEvent.CreatedAt = timestamp.Now().V + targetEvent.Kind = 1 + targetEvent.Content = []byte("Target") + if err := targetEvent.Sign(signer); err != nil { + t.Fatalf("Failed to sign target event: %v", err) + } + + if _, err := testDB.SaveEvent(ctx, targetEvent); err != nil { + t.Fatalf("Failed to save target event: %v", err) + } + + replyEvent := event.New() + replyEvent.Pubkey = signer.Pub() + replyEvent.CreatedAt = timestamp.Now().V + 1 + replyEvent.Kind = 1 + replyEvent.Content = []byte("Reply") + replyEvent.Tags = tag.NewS( + tag.NewFromAny("e", hex.Enc(targetEvent.ID[:])), + ) + if err := replyEvent.Sign(signer); err != nil { + t.Fatalf("Failed to sign reply event: %v", err) + } + + if _, err := testDB.SaveEvent(ctx, replyEvent); err != nil { + t.Fatalf("Failed to save reply event: %v", err) + } + + // Count Tag nodes before running migration + countBefore := countNodes(t, "Tag") + + // Run migration (should be no-op since data is already correct) + err = migrateToTagBasedReferences(ctx, testDB) + if err != nil { + t.Fatalf("Migration failed: %v", err) + } + + // Count Tag nodes after - should be unchanged + countAfter := countNodes(t, "Tag") + + if countBefore != countAfter { + t.Errorf("Migration changed Tag count: before=%d, after=%d", countBefore, countAfter) + } + + // Run migration again - should still be idempotent + err = migrateToTagBasedReferences(ctx, testDB) + if err != nil { + t.Fatalf("Second migration run failed: %v", err) + } + + countAfterSecond := countNodes(t, "Tag") + if countAfter != countAfterSecond { + t.Errorf("Second migration run changed Tag count: %d -> %d", countAfter, countAfterSecond) + } + + t.Logf("Migration v3 is idempotent (safe to run multiple times)") +} + +// ============================================================================= +// Large Dataset Tests +// ============================================================================= + +// TestLargeETagBatch tests events with many e-tags are handled correctly. +func TestLargeETagBatch(t *testing.T) { + if testDB == nil { + t.Skip("Neo4j not available") + } + + ctx := context.Background() + cleanTestDatabase() + + signer, err := p8k.New() + if err != nil { + t.Fatalf("Failed to create signer: %v", err) + } + if err := signer.Generate(); err != nil { + t.Fatalf("Failed to generate keypair: %v", err) + } + + // Create 100 target events + numTargets := 100 + var targetIDs []string + for i := 0; i < numTargets; i++ { + targetEvent := event.New() + targetEvent.Pubkey = signer.Pub() + targetEvent.CreatedAt = timestamp.Now().V + int64(i) + targetEvent.Kind = 1 + targetEvent.Content = []byte(fmt.Sprintf("Target %d", i)) + if err := targetEvent.Sign(signer); err != nil { + t.Fatalf("Failed to sign target event %d: %v", i, err) + } + + if _, err := testDB.SaveEvent(ctx, targetEvent); err != nil { + t.Fatalf("Failed to save target event %d: %v", i, err) + } + + targetIDs = append(targetIDs, hex.Enc(targetEvent.ID[:])) + } + + // Create event referencing all 100 targets + ev := event.New() + ev.Pubkey = signer.Pub() + ev.CreatedAt = timestamp.Now().V + int64(numTargets+1) + ev.Kind = 1 + ev.Content = []byte("Event with many e-tags") + tags := tag.NewS() + for _, id := range targetIDs { + tags.Append(tag.NewFromAny("e", id)) + } + ev.Tags = tags + if err := ev.Sign(signer); err != nil { + t.Fatalf("Failed to sign event: %v", err) + } + + if _, err := testDB.SaveEvent(ctx, ev); err != nil { + t.Fatalf("Failed to save event with %d e-tags: %v", numTargets, err) + } + + // Verify all Tag nodes were created + countCypher := ` + MATCH (e:Event {id: $eventId})-[:TAGGED_WITH]->(t:Tag {type: 'e'}) + RETURN count(t) AS count + ` + result, err := testDB.ExecuteRead(ctx, countCypher, map[string]any{"eventId": hex.Enc(ev.ID[:])}) + if err != nil { + t.Fatalf("Failed to count e-tag Tags: %v", err) + } + + if result.Next(ctx) { + count := result.Record().Values[0].(int64) + if count != int64(numTargets) { + t.Errorf("Expected %d e-tag Tag nodes, got %d", numTargets, count) + } + } + + // Verify all REFERENCES were created (since all targets exist) + refCountCypher := ` + MATCH (e:Event {id: $eventId})-[:TAGGED_WITH]->(t:Tag {type: 'e'})-[:REFERENCES]->(target:Event) + RETURN count(target) AS count + ` + refResult, err := testDB.ExecuteRead(ctx, refCountCypher, map[string]any{"eventId": hex.Enc(ev.ID[:])}) + if err != nil { + t.Fatalf("Failed to count REFERENCES: %v", err) + } + + if refResult.Next(ctx) { + count := refResult.Record().Values[0].(int64) + if count != int64(numTargets) { + t.Errorf("Expected %d REFERENCES, got %d", numTargets, count) + } + } + + t.Logf("Large e-tag batch (%d tags) handled correctly", numTargets) +} diff --git a/pkg/version/version b/pkg/version/version index 56facb1..e1d6235 100644 --- a/pkg/version/version +++ b/pkg/version/version @@ -1 +1 @@ -v0.35.5 +v0.36.0