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.
446 lines
14 KiB
446 lines
14 KiB
package neo4j |
|
|
|
import ( |
|
"context" |
|
"fmt" |
|
"strconv" |
|
|
|
"git.mleku.dev/mleku/nostr/encoders/event" |
|
"git.mleku.dev/mleku/nostr/encoders/filter" |
|
"git.mleku.dev/mleku/nostr/encoders/hex" |
|
"next.orly.dev/pkg/database/indexes/types" |
|
) |
|
|
|
// parseInt64 parses a string to int64 |
|
func parseInt64(s string) (int64, error) { |
|
return strconv.ParseInt(s, 10, 64) |
|
} |
|
|
|
// tagBatchSize is the maximum number of tags to process in a single transaction |
|
// This prevents Neo4j stack overflow errors with events that have thousands of tags |
|
const tagBatchSize = 500 |
|
|
|
// SaveEvent stores a Nostr event in the Neo4j database. |
|
// It creates event nodes and relationships for authors, tags, and references. |
|
// This method leverages Neo4j's graph capabilities to model Nostr's social graph naturally. |
|
// |
|
// For social graph events (kinds 0, 3, 1984, 10000), it additionally processes them |
|
// to maintain NostrUser nodes and FOLLOWS/MUTES/REPORTS relationships with event traceability. |
|
// |
|
// To prevent Neo4j stack overflow errors with events containing thousands of tags, |
|
// tags are processed in batches using UNWIND instead of generating inline Cypher. |
|
func (n *N) SaveEvent(c context.Context, ev *event.E) (exists bool, err error) { |
|
eventID := hex.Enc(ev.ID[:]) |
|
|
|
// Check if event already exists |
|
checkCypher := "MATCH (e:Event {id: $id}) RETURN e.id AS id" |
|
checkParams := map[string]any{"id": eventID} |
|
|
|
result, err := n.ExecuteRead(c, checkCypher, checkParams) |
|
if err != nil { |
|
return false, fmt.Errorf("failed to check event existence: %w", err) |
|
} |
|
|
|
// Check if we got a result |
|
ctx := context.Background() |
|
if result.Next(ctx) { |
|
// Event exists - check if it's a social event that needs reprocessing |
|
// (in case relationships changed) |
|
if ev.Kind == 0 || ev.Kind == 3 || ev.Kind == 1984 || ev.Kind == 10000 { |
|
processor := NewSocialEventProcessor(n) |
|
if err := processor.ProcessSocialEvent(c, ev); err != nil { |
|
n.Logger.Warningf("failed to reprocess social event %s: %v", safePrefix(eventID, 16), err) |
|
// Don't fail the whole save, social processing is supplementary |
|
} |
|
} |
|
return true, nil // Event already exists |
|
} |
|
|
|
// Get next serial number |
|
serial, err := n.getNextSerial() |
|
if err != nil { |
|
return false, fmt.Errorf("failed to get serial number: %w", err) |
|
} |
|
|
|
// Step 1: Create base event with author (small, fixed-size query) |
|
cypher, params := n.buildBaseEventCypher(ev, serial) |
|
if _, err = n.ExecuteWrite(c, cypher, params); err != nil { |
|
return false, fmt.Errorf("failed to save event: %w", err) |
|
} |
|
|
|
// Step 2: Process tags in batches to avoid stack overflow |
|
if ev.Tags != nil { |
|
if err := n.addTagsInBatches(c, eventID, ev); err != nil { |
|
// Log but don't fail - base event is saved, tags are supplementary for queries |
|
n.Logger.Errorf("failed to add tags for event %s: %v", safePrefix(eventID, 16), err) |
|
} |
|
} |
|
|
|
// Process social graph events (kinds 0, 3, 1984, 10000) |
|
// This creates NostrUser nodes and social relationships (FOLLOWS, MUTES, REPORTS) |
|
// with event traceability for diff-based updates |
|
if ev.Kind == 0 || ev.Kind == 3 || ev.Kind == 1984 || ev.Kind == 10000 { |
|
processor := NewSocialEventProcessor(n) |
|
if err := processor.ProcessSocialEvent(c, ev); err != nil { |
|
// Log error but don't fail the whole save |
|
// NIP-01 queries will still work even if social processing fails |
|
n.Logger.Errorf("failed to process social event kind %d, event %s: %v", |
|
ev.Kind, safePrefix(eventID, 16), err) |
|
// Consider: should we fail here or continue? |
|
// For now, continue - social graph is supplementary to base relay |
|
} |
|
} |
|
|
|
return false, nil |
|
} |
|
|
|
// safePrefix returns up to n characters from a string, handling short strings gracefully |
|
func safePrefix(s string, n int) string { |
|
if len(s) <= n { |
|
return s |
|
} |
|
return s[:n] |
|
} |
|
|
|
// 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: |
|
// - Event node with all properties |
|
// - NostrUser node and AUTHORED_BY relationship (unified author + WoT node) |
|
func (n *N) buildBaseEventCypher(ev *event.E, serial uint64) (string, map[string]any) { |
|
params := make(map[string]any) |
|
|
|
// Event properties |
|
eventID := hex.Enc(ev.ID[:]) |
|
authorPubkey := hex.Enc(ev.Pubkey[:]) |
|
|
|
params["eventId"] = eventID |
|
params["serial"] = serial |
|
params["kind"] = int64(ev.Kind) |
|
params["createdAt"] = ev.CreatedAt |
|
params["content"] = string(ev.Content) |
|
params["sig"] = hex.Enc(ev.Sig[:]) |
|
params["pubkey"] = authorPubkey |
|
|
|
// Check for expiration tag (NIP-40) |
|
var expirationTs int64 = 0 |
|
if ev.Tags != nil { |
|
if expTag := ev.Tags.GetFirst([]byte("expiration")); expTag != nil && len(expTag.T) >= 2 { |
|
if ts, err := parseInt64(string(expTag.T[1])); err == nil { |
|
expirationTs = ts |
|
} |
|
} |
|
} |
|
params["expiration"] = expirationTs |
|
|
|
// Serialize tags as JSON string for storage |
|
// Handle nil tags gracefully - nil means empty tags "[]" |
|
var tagsJSON []byte |
|
if ev.Tags != nil { |
|
tagsJSON, _ = ev.Tags.MarshalJSON() |
|
} else { |
|
tagsJSON = []byte("[]") |
|
} |
|
params["tags"] = string(tagsJSON) |
|
|
|
// Build Cypher query - just event + author, no tags (tags added in batches) |
|
// Use MERGE to ensure idempotency for NostrUser nodes |
|
// NostrUser serves both NIP-01 author tracking and WoT social graph |
|
cypher := ` |
|
// Create or match NostrUser node (unified author + social graph) |
|
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 (e:Event { |
|
id: $eventId, |
|
serial: $serial, |
|
kind: $kind, |
|
created_at: $createdAt, |
|
content: $content, |
|
sig: $sig, |
|
pubkey: $pubkey, |
|
tags: $tags, |
|
expiration: $expiration |
|
}) |
|
|
|
// Link event to author |
|
CREATE (e)-[:AUTHORED_BY]->(a) |
|
|
|
RETURN e.id AS id` |
|
|
|
return cypher, params |
|
} |
|
|
|
// tagTypeValue represents a generic tag with type and value for batch processing |
|
type tagTypeValue struct { |
|
Type string |
|
Value string |
|
} |
|
|
|
// addTagsInBatches processes event tags in batches using UNWIND to prevent Neo4j stack overflow. |
|
// This handles e-tags (event references), p-tags (pubkey mentions), and other tags separately. |
|
func (n *N) addTagsInBatches(c context.Context, eventID string, ev *event.E) error { |
|
if ev.Tags == nil { |
|
return nil |
|
} |
|
|
|
// Collect tags by type |
|
var eTags, pTags []string |
|
var otherTags []tagTypeValue |
|
|
|
for _, tagItem := range *ev.Tags { |
|
if len(tagItem.T) < 2 { |
|
continue |
|
} |
|
|
|
tagType := string(tagItem.T[0]) |
|
|
|
switch tagType { |
|
case "e": // Event reference |
|
tagValue := ExtractETagValue(tagItem) |
|
if tagValue != "" { |
|
eTags = append(eTags, tagValue) |
|
} |
|
case "p": // Pubkey mention |
|
tagValue := ExtractPTagValue(tagItem) |
|
if tagValue != "" { |
|
pTags = append(pTags, tagValue) |
|
} |
|
default: // Other tags |
|
tagValue := string(tagItem.T[1]) |
|
otherTags = append(otherTags, tagTypeValue{Type: tagType, Value: tagValue}) |
|
} |
|
} |
|
|
|
// Add p-tags in batches (creates MENTIONS relationships) |
|
if len(pTags) > 0 { |
|
if err := n.addPTagsInBatches(c, eventID, pTags); err != nil { |
|
return fmt.Errorf("failed to add p-tags: %w", err) |
|
} |
|
} |
|
|
|
// Add e-tags in batches (creates REFERENCES relationships) |
|
if len(eTags) > 0 { |
|
if err := n.addETagsInBatches(c, eventID, eTags); err != nil { |
|
return fmt.Errorf("failed to add e-tags: %w", err) |
|
} |
|
} |
|
|
|
// Add other tags in batches (creates TAGGED_WITH relationships) |
|
if len(otherTags) > 0 { |
|
if err := n.addOtherTagsInBatches(c, eventID, otherTags); err != nil { |
|
return fmt.Errorf("failed to add other tags: %w", err) |
|
} |
|
} |
|
|
|
return nil |
|
} |
|
|
|
// addPTagsInBatches adds p-tag (pubkey mention) relationships using UNWIND for efficiency. |
|
// 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 { |
|
end := i + tagBatchSize |
|
if end > len(pTags) { |
|
end = len(pTags) |
|
} |
|
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() |
|
MERGE (t)-[:REFERENCES]->(u)` |
|
|
|
params := map[string]any{ |
|
"eventId": eventID, |
|
"pubkeys": batch, |
|
} |
|
|
|
if _, err := n.ExecuteWrite(c, cypher, params); err != nil { |
|
return fmt.Errorf("batch %d-%d: %w", i, end, err) |
|
} |
|
} |
|
|
|
return nil |
|
} |
|
|
|
// addETagsInBatches adds e-tag (event reference) relationships using UNWIND for efficiency. |
|
// 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 { |
|
end := i + tagBatchSize |
|
if end > len(eTags) { |
|
end = len(eTags) |
|
} |
|
batch := eTags[i:end] |
|
|
|
// Use UNWIND to process multiple e-tags in a single query |
|
// 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}) |
|
WHERE ref IS NOT NULL |
|
MERGE (t)-[:REFERENCES]->(ref)` |
|
|
|
params := map[string]any{ |
|
"eventId": eventID, |
|
"eventIds": batch, |
|
} |
|
|
|
if _, err := n.ExecuteWrite(c, cypher, params); err != nil { |
|
return fmt.Errorf("batch %d-%d: %w", i, end, err) |
|
} |
|
} |
|
|
|
return nil |
|
} |
|
|
|
// addOtherTagsInBatches adds generic tag relationships using UNWIND for efficiency. |
|
// Creates Tag nodes with type and value, and TAGGED_WITH relationships. |
|
func (n *N) addOtherTagsInBatches(c context.Context, eventID string, tags []tagTypeValue) error { |
|
// Process in batches to avoid memory issues |
|
for i := 0; i < len(tags); i += tagBatchSize { |
|
end := i + tagBatchSize |
|
if end > len(tags) { |
|
end = len(tags) |
|
} |
|
batch := tags[i:end] |
|
|
|
// Convert to map slice for Neo4j parameter passing |
|
tagMaps := make([]map[string]string, len(batch)) |
|
for j, t := range batch { |
|
tagMaps[j] = map[string]string{"type": t.Type, "value": t.Value} |
|
} |
|
|
|
// Use UNWIND to process multiple tags in a single query |
|
cypher := ` |
|
MATCH (e:Event {id: $eventId}) |
|
UNWIND $tags AS tag |
|
MERGE (t:Tag {type: tag.type, value: tag.value}) |
|
CREATE (e)-[:TAGGED_WITH]->(t)` |
|
|
|
params := map[string]any{ |
|
"eventId": eventID, |
|
"tags": tagMaps, |
|
} |
|
|
|
if _, err := n.ExecuteWrite(c, cypher, params); err != nil { |
|
return fmt.Errorf("batch %d-%d: %w", i, end, err) |
|
} |
|
} |
|
|
|
return nil |
|
} |
|
|
|
// GetSerialsFromFilter returns event serials matching a filter |
|
func (n *N) GetSerialsFromFilter(f *filter.F) (serials types.Uint40s, err error) { |
|
// Use QueryForSerials with background context |
|
return n.QueryForSerials(context.Background(), f) |
|
} |
|
|
|
// WouldReplaceEvent checks if an event would replace existing events |
|
// This handles replaceable events (kinds 0, 3, and 10000-19999) |
|
// and parameterized replaceable events (kinds 30000-39999) |
|
func (n *N) WouldReplaceEvent(ev *event.E) (bool, types.Uint40s, error) { |
|
// Check for replaceable events (kinds 0, 3, and 10000-19999) |
|
isReplaceable := ev.Kind == 0 || ev.Kind == 3 || (ev.Kind >= 10000 && ev.Kind < 20000) |
|
|
|
// Check for parameterized replaceable events (kinds 30000-39999) |
|
isParameterizedReplaceable := ev.Kind >= 30000 && ev.Kind < 40000 |
|
|
|
if !isReplaceable && !isParameterizedReplaceable { |
|
return false, nil, nil |
|
} |
|
|
|
authorPubkey := hex.Enc(ev.Pubkey[:]) |
|
ctx := context.Background() |
|
|
|
var cypher string |
|
params := map[string]any{ |
|
"pubkey": authorPubkey, |
|
"kind": int64(ev.Kind), |
|
"createdAt": ev.CreatedAt, |
|
} |
|
|
|
if isParameterizedReplaceable { |
|
// For parameterized replaceable events, we need to match on d-tag as well |
|
dTag := ev.Tags.GetFirst([]byte{'d'}) |
|
if dTag == nil { |
|
return false, nil, nil |
|
} |
|
|
|
dValue := "" |
|
if len(dTag.T) >= 2 { |
|
dValue = string(dTag.T[1]) |
|
} |
|
|
|
params["dValue"] = dValue |
|
|
|
// Query for existing parameterized replaceable events with same kind, pubkey, and d-tag |
|
cypher = ` |
|
MATCH (e:Event {kind: $kind, pubkey: $pubkey})-[:TAGGED_WITH]->(t:Tag {type: 'd', value: $dValue}) |
|
WHERE e.created_at < $createdAt |
|
RETURN e.serial AS serial, e.created_at AS created_at |
|
ORDER BY e.created_at DESC` |
|
|
|
} else { |
|
// Query for existing replaceable events with same kind and pubkey |
|
cypher = ` |
|
MATCH (e:Event {kind: $kind, pubkey: $pubkey}) |
|
WHERE e.created_at < $createdAt |
|
RETURN e.serial AS serial, e.created_at AS created_at |
|
ORDER BY e.created_at DESC` |
|
} |
|
|
|
result, err := n.ExecuteRead(ctx, cypher, params) |
|
if err != nil { |
|
return false, nil, fmt.Errorf("failed to query replaceable events: %w", err) |
|
} |
|
|
|
// Parse results |
|
var serials types.Uint40s |
|
wouldReplace := false |
|
|
|
for result.Next(ctx) { |
|
record := result.Record() |
|
if record == nil { |
|
continue |
|
} |
|
|
|
serialRaw, found := record.Get("serial") |
|
if !found { |
|
continue |
|
} |
|
|
|
serialVal, ok := serialRaw.(int64) |
|
if !ok { |
|
continue |
|
} |
|
|
|
wouldReplace = true |
|
serial := types.Uint40{} |
|
serial.Set(uint64(serialVal)) |
|
serials = append(serials, &serial) |
|
} |
|
|
|
return wouldReplace, serials, nil |
|
}
|
|
|