initial draft of neo4j database driver
This commit is contained in:
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
// NewDatabase creates a database instance based on the specified type.
|
||||
// Supported types: "badger", "dgraph"
|
||||
// Supported types: "badger", "dgraph", "neo4j"
|
||||
func NewDatabase(
|
||||
ctx context.Context,
|
||||
cancel context.CancelFunc,
|
||||
@@ -23,8 +23,12 @@ func NewDatabase(
|
||||
// Use the new dgraph implementation
|
||||
// Import dynamically to avoid import cycles
|
||||
return newDgraphDatabase(ctx, cancel, dataDir, logLevel)
|
||||
case "neo4j":
|
||||
// Use the new neo4j implementation
|
||||
// Import dynamically to avoid import cycles
|
||||
return newNeo4jDatabase(ctx, cancel, dataDir, logLevel)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database type: %s (supported: badger, dgraph)", dbType)
|
||||
return nil, fmt.Errorf("unsupported database type: %s (supported: badger, dgraph, neo4j)", dbType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,3 +41,13 @@ var newDgraphDatabase func(context.Context, context.CancelFunc, string, string)
|
||||
func RegisterDgraphFactory(factory func(context.Context, context.CancelFunc, string, string) (Database, error)) {
|
||||
newDgraphDatabase = factory
|
||||
}
|
||||
|
||||
// newNeo4jDatabase creates a neo4j database instance
|
||||
// This is defined here to avoid import cycles
|
||||
var newNeo4jDatabase func(context.Context, context.CancelFunc, string, string) (Database, error)
|
||||
|
||||
// RegisterNeo4jFactory registers the neo4j database factory
|
||||
// This is called from the neo4j package's init() function
|
||||
func RegisterNeo4jFactory(factory func(context.Context, context.CancelFunc, string, string) (Database, error)) {
|
||||
newNeo4jDatabase = factory
|
||||
}
|
||||
|
||||
132
pkg/neo4j/README.md
Normal file
132
pkg/neo4j/README.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Neo4j Database Backend
|
||||
|
||||
A graph database backend implementation for the ORLY Nostr relay using Neo4j.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Start Neo4j
|
||||
|
||||
```bash
|
||||
docker run -d --name neo4j \
|
||||
-p 7474:7474 -p 7687:7687 \
|
||||
-e NEO4J_AUTH=neo4j/password \
|
||||
neo4j:5.15
|
||||
```
|
||||
|
||||
### 2. Configure Environment
|
||||
|
||||
```bash
|
||||
export ORLY_DB_TYPE=neo4j
|
||||
export ORLY_NEO4J_URI=bolt://localhost:7687
|
||||
export ORLY_NEO4J_USER=neo4j
|
||||
export ORLY_NEO4J_PASSWORD=password
|
||||
```
|
||||
|
||||
### 3. Run ORLY
|
||||
|
||||
```bash
|
||||
./orly
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Graph-Native Storage**: Events, authors, and tags stored as nodes and 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
|
||||
|
||||
## Architecture
|
||||
|
||||
See [docs/NEO4J_BACKEND.md](../../docs/NEO4J_BACKEND.md) for comprehensive documentation on:
|
||||
- Graph schema design
|
||||
- How Nostr REQ messages are implemented in Cypher
|
||||
- Performance tuning
|
||||
- Development guide
|
||||
- Comparison with other backends
|
||||
|
||||
## File Structure
|
||||
|
||||
- `neo4j.go` - Main database implementation
|
||||
- `schema.go` - Graph schema and index definitions
|
||||
- `query-events.go` - REQ filter to Cypher translation
|
||||
- `save-event.go` - Event storage with relationship creation
|
||||
- `fetch-event.go` - Event retrieval by serial/ID
|
||||
- `serial.go` - Serial number management
|
||||
- `markers.go` - Metadata key-value storage
|
||||
- `identity.go` - Relay identity management
|
||||
- `delete.go` - Event deletion (NIP-09)
|
||||
- `subscriptions.go` - Subscription management
|
||||
- `nip43.go` - Invite-based ACL (NIP-43)
|
||||
- `import-export.go` - Event import/export
|
||||
- `logger.go` - Logging infrastructure
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Start Neo4j test instance
|
||||
docker run -d --name neo4j-test \
|
||||
-p 7687:7687 \
|
||||
-e NEO4J_AUTH=neo4j/test \
|
||||
neo4j:5.15
|
||||
|
||||
# Run tests
|
||||
ORLY_NEO4J_URI="bolt://localhost:7687" \
|
||||
ORLY_NEO4J_USER="neo4j" \
|
||||
ORLY_NEO4J_PASSWORD="test" \
|
||||
go test ./pkg/neo4j/...
|
||||
|
||||
# Cleanup
|
||||
docker rm -f neo4j-test
|
||||
```
|
||||
|
||||
## Example Cypher Queries
|
||||
|
||||
### Find all events by an author
|
||||
```cypher
|
||||
MATCH (e:Event {pubkey: "abc123..."})
|
||||
RETURN e
|
||||
ORDER BY e.created_at DESC
|
||||
```
|
||||
|
||||
### Find events with specific tags
|
||||
```cypher
|
||||
MATCH (e:Event)-[:TAGGED_WITH]->(t:Tag {type: "t", value: "bitcoin"})
|
||||
RETURN e
|
||||
```
|
||||
|
||||
### Social graph query
|
||||
```cypher
|
||||
MATCH (author:Author {pubkey: "abc123..."})
|
||||
<-[:AUTHORED_BY]-(e:Event)
|
||||
-[:MENTIONS]->(mentioned:Author)
|
||||
RETURN author, e, mentioned
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Use Limits**: Always include LIMIT in queries
|
||||
2. **Index Usage**: Ensure queries use indexed properties (id, kind, created_at)
|
||||
3. **Parameterize**: Use parameterized queries to enable query plan caching
|
||||
4. **Monitor**: Use `EXPLAIN` and `PROFILE` to analyze query performance
|
||||
|
||||
## Limitations
|
||||
|
||||
- Requires external Neo4j database (not embedded)
|
||||
- Higher memory usage compared to Badger
|
||||
- Metadata still uses Badger (markers, subscriptions)
|
||||
- More complex deployment than single-binary solutions
|
||||
|
||||
## Why Neo4j for Nostr?
|
||||
|
||||
Nostr is inherently a social graph with heavy relationship queries:
|
||||
- Event references (e-tags) → Graph edges
|
||||
- Author mentions (p-tags) → Graph edges
|
||||
- Follow relationships → Graph structure
|
||||
- Thread traversal → Path queries
|
||||
|
||||
Neo4j excels at these patterns, making it a natural fit for relationship-heavy Nostr queries.
|
||||
|
||||
## License
|
||||
|
||||
Same as ORLY relay project.
|
||||
173
pkg/neo4j/delete.go
Normal file
173
pkg/neo4j/delete.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package neo4j
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"next.orly.dev/pkg/database/indexes/types"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
)
|
||||
|
||||
// DeleteEvent deletes an event by its ID
|
||||
func (n *N) DeleteEvent(c context.Context, eid []byte) error {
|
||||
idStr := hex.Enc(eid)
|
||||
|
||||
cypher := "MATCH (e:Event {id: $id}) DETACH DELETE e"
|
||||
params := map[string]any{"id": idStr}
|
||||
|
||||
_, err := n.ExecuteWrite(c, cypher, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete event: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEventBySerial deletes an event by its serial number
|
||||
func (n *N) DeleteEventBySerial(c context.Context, ser *types.Uint40, ev *event.E) error {
|
||||
serial := ser.Get()
|
||||
|
||||
cypher := "MATCH (e:Event {serial: $serial}) DETACH DELETE e"
|
||||
params := map[string]any{"serial": int64(serial)}
|
||||
|
||||
_, err := n.ExecuteWrite(c, cypher, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete event: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteExpired deletes expired events (stub implementation)
|
||||
func (n *N) DeleteExpired() {
|
||||
// This would need to implement expiration logic based on event.expiration tag (NIP-40)
|
||||
// For now, this is a no-op
|
||||
}
|
||||
|
||||
// ProcessDelete processes a kind 5 deletion event
|
||||
func (n *N) ProcessDelete(ev *event.E, admins [][]byte) error {
|
||||
// Deletion events (kind 5) can delete events by the same author
|
||||
// or by relay admins
|
||||
|
||||
// Check if this is a kind 5 event
|
||||
if ev.Kind != 5 {
|
||||
return fmt.Errorf("not a deletion event")
|
||||
}
|
||||
|
||||
// Get all 'e' tags (event IDs to delete)
|
||||
eTags := ev.Tags.GetAll([]byte{'e'})
|
||||
if len(eTags) == 0 {
|
||||
return nil // Nothing to delete
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
isAdmin := false
|
||||
|
||||
// Check if author is an admin
|
||||
for _, adminPk := range admins {
|
||||
if string(ev.Pubkey[:]) == string(adminPk) {
|
||||
isAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// For each event ID in e-tags, delete it if allowed
|
||||
for _, eTag := range eTags {
|
||||
if len(eTag.T) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
eventIDStr := string(eTag.T[1])
|
||||
eventID, err := hex.Dec(eventIDStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Fetch the event to check authorship
|
||||
cypher := "MATCH (e:Event {id: $id}) RETURN e.pubkey AS pubkey"
|
||||
params := map[string]any{"id": eventIDStr}
|
||||
|
||||
result, err := n.ExecuteRead(ctx, cypher, params)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
neo4jResult, ok := result.(interface {
|
||||
Next(context.Context) bool
|
||||
Record() *interface{}
|
||||
Err() error
|
||||
})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if neo4jResult.Next(ctx) {
|
||||
record := neo4jResult.Record()
|
||||
if record != nil {
|
||||
recordMap, ok := (*record).(map[string]any)
|
||||
if ok {
|
||||
if pubkeyStr, ok := recordMap["pubkey"].(string); ok {
|
||||
pubkey, err := hex.Dec(pubkeyStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if deletion is allowed (same author or admin)
|
||||
canDelete := isAdmin || string(ev.Pubkey[:]) == string(pubkey)
|
||||
if canDelete {
|
||||
// Delete the event
|
||||
if err := n.DeleteEvent(ctx, eventID); err != nil {
|
||||
n.Logger.Warningf("failed to delete event %s: %v", eventIDStr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
ctx := context.Background()
|
||||
idStr := hex.Enc(ev.ID[:])
|
||||
|
||||
// Build cypher query to find deletion events
|
||||
cypher := `
|
||||
MATCH (target:Event {id: $targetId})
|
||||
MATCH (delete:Event {kind: 5})-[:REFERENCES]->(target)
|
||||
WHERE delete.pubkey = $pubkey OR delete.pubkey IN $admins
|
||||
RETURN delete.id AS id
|
||||
LIMIT 1`
|
||||
|
||||
adminPubkeys := make([]string, len(admins))
|
||||
for i, admin := range admins {
|
||||
adminPubkeys[i] = hex.Enc(admin)
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
"targetId": idStr,
|
||||
"pubkey": hex.Enc(ev.Pubkey[:]),
|
||||
"admins": adminPubkeys,
|
||||
}
|
||||
|
||||
result, err := n.ExecuteRead(ctx, cypher, params)
|
||||
if err != nil {
|
||||
return nil // Not deleted
|
||||
}
|
||||
|
||||
neo4jResult, ok := result.(interface {
|
||||
Next(context.Context) bool
|
||||
Record() *interface{}
|
||||
Err() error
|
||||
})
|
||||
if ok && neo4jResult.Next(ctx) {
|
||||
return fmt.Errorf("event has been deleted")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
444
pkg/neo4j/fetch-event.go
Normal file
444
pkg/neo4j/fetch-event.go
Normal file
@@ -0,0 +1,444 @@
|
||||
package neo4j
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"next.orly.dev/pkg/database"
|
||||
"next.orly.dev/pkg/database/indexes/types"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/encoders/tag"
|
||||
"next.orly.dev/pkg/interfaces/store"
|
||||
)
|
||||
|
||||
// FetchEventBySerial retrieves an event by its serial number
|
||||
func (n *N) FetchEventBySerial(ser *types.Uint40) (ev *event.E, err error) {
|
||||
serial := ser.Get()
|
||||
|
||||
cypher := `
|
||||
MATCH (e:Event {serial: $serial})
|
||||
RETURN e.id AS id,
|
||||
e.kind AS kind,
|
||||
e.created_at AS created_at,
|
||||
e.content AS content,
|
||||
e.sig AS sig,
|
||||
e.pubkey AS pubkey,
|
||||
e.tags AS tags`
|
||||
|
||||
params := map[string]any{"serial": int64(serial)}
|
||||
|
||||
result, err := n.ExecuteRead(context.Background(), cypher, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch event by serial: %w", err)
|
||||
}
|
||||
|
||||
evs, err := n.parseEventsFromResult(result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(evs) == 0 {
|
||||
return nil, fmt.Errorf("event not found")
|
||||
}
|
||||
|
||||
return evs[0], nil
|
||||
}
|
||||
|
||||
// FetchEventsBySerials retrieves multiple events by their serial numbers
|
||||
func (n *N) FetchEventsBySerials(serials []*types.Uint40) (
|
||||
events map[uint64]*event.E, err error,
|
||||
) {
|
||||
if len(serials) == 0 {
|
||||
return make(map[uint64]*event.E), nil
|
||||
}
|
||||
|
||||
// Build list of serial numbers
|
||||
serialNums := make([]int64, len(serials))
|
||||
for i, ser := range serials {
|
||||
serialNums[i] = int64(ser.Get())
|
||||
}
|
||||
|
||||
cypher := `
|
||||
MATCH (e:Event)
|
||||
WHERE e.serial IN $serials
|
||||
RETURN e.id AS id,
|
||||
e.kind AS kind,
|
||||
e.created_at AS created_at,
|
||||
e.content AS content,
|
||||
e.sig AS sig,
|
||||
e.pubkey AS pubkey,
|
||||
e.tags AS tags,
|
||||
e.serial AS serial`
|
||||
|
||||
params := map[string]any{"serials": serialNums}
|
||||
|
||||
result, err := n.ExecuteRead(context.Background(), cypher, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch events by serials: %w", err)
|
||||
}
|
||||
|
||||
// Parse events and map by serial
|
||||
events = make(map[uint64]*event.E)
|
||||
ctx := context.Background()
|
||||
|
||||
neo4jResult, ok := result.(interface {
|
||||
Next(context.Context) bool
|
||||
Record() *interface{}
|
||||
Err() error
|
||||
})
|
||||
if !ok {
|
||||
return events, nil
|
||||
}
|
||||
|
||||
for neo4jResult.Next(ctx) {
|
||||
record := neo4jResult.Record()
|
||||
if record == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
recordMap, ok := (*record).(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse event
|
||||
idStr, _ := recordMap["id"].(string)
|
||||
kind, _ := recordMap["kind"].(int64)
|
||||
createdAt, _ := recordMap["created_at"].(int64)
|
||||
content, _ := recordMap["content"].(string)
|
||||
sigStr, _ := recordMap["sig"].(string)
|
||||
pubkeyStr, _ := recordMap["pubkey"].(string)
|
||||
tagsStr, _ := recordMap["tags"].(string)
|
||||
serialVal, _ := recordMap["serial"].(int64)
|
||||
|
||||
id, err := hex.Dec(idStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sig, err := hex.Dec(sigStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
pubkey, err := hex.Dec(pubkeyStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
tags := tag.NewS()
|
||||
if tagsStr != "" {
|
||||
_ = tags.UnmarshalJSON([]byte(tagsStr))
|
||||
}
|
||||
|
||||
e := &event.E{
|
||||
Kind: uint16(kind),
|
||||
CreatedAt: createdAt,
|
||||
Content: []byte(content),
|
||||
Tags: tags,
|
||||
}
|
||||
|
||||
copy(e.ID[:], id)
|
||||
copy(e.Sig[:], sig)
|
||||
copy(e.Pubkey[:], pubkey)
|
||||
|
||||
events[uint64(serialVal)] = e
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// GetSerialById retrieves the serial number for an event ID
|
||||
func (n *N) GetSerialById(id []byte) (ser *types.Uint40, err error) {
|
||||
idStr := hex.Enc(id)
|
||||
|
||||
cypher := "MATCH (e:Event {id: $id}) RETURN e.serial AS serial"
|
||||
params := map[string]any{"id": idStr}
|
||||
|
||||
result, err := n.ExecuteRead(context.Background(), cypher, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get serial by ID: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
neo4jResult, ok := result.(interface {
|
||||
Next(context.Context) bool
|
||||
Record() *interface{}
|
||||
Err() error
|
||||
})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid result type")
|
||||
}
|
||||
|
||||
if neo4jResult.Next(ctx) {
|
||||
record := neo4jResult.Record()
|
||||
if record != nil {
|
||||
recordMap, ok := (*record).(map[string]any)
|
||||
if ok {
|
||||
if serialVal, ok := recordMap["serial"].(int64); ok {
|
||||
ser = &types.Uint40{}
|
||||
ser.Set(uint64(serialVal))
|
||||
return ser, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("event not found")
|
||||
}
|
||||
|
||||
// GetSerialsByIds retrieves serial numbers for multiple event IDs
|
||||
func (n *N) GetSerialsByIds(ids *tag.T) (
|
||||
serials map[string]*types.Uint40, err error,
|
||||
) {
|
||||
serials = make(map[string]*types.Uint40)
|
||||
|
||||
if len(ids.T) == 0 {
|
||||
return serials, nil
|
||||
}
|
||||
|
||||
// Extract ID strings
|
||||
idStrs := make([]string, 0, len(ids.T))
|
||||
for _, idTag := range ids.T {
|
||||
if len(idTag) >= 2 {
|
||||
idStrs = append(idStrs, string(idTag[1]))
|
||||
}
|
||||
}
|
||||
|
||||
if len(idStrs) == 0 {
|
||||
return serials, nil
|
||||
}
|
||||
|
||||
cypher := `
|
||||
MATCH (e:Event)
|
||||
WHERE e.id IN $ids
|
||||
RETURN e.id AS id, e.serial AS serial`
|
||||
|
||||
params := map[string]any{"ids": idStrs}
|
||||
|
||||
result, err := n.ExecuteRead(context.Background(), cypher, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get serials by IDs: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
neo4jResult, ok := result.(interface {
|
||||
Next(context.Context) bool
|
||||
Record() *interface{}
|
||||
Err() error
|
||||
})
|
||||
if !ok {
|
||||
return serials, nil
|
||||
}
|
||||
|
||||
for neo4jResult.Next(ctx) {
|
||||
record := neo4jResult.Record()
|
||||
if record == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
recordMap, ok := (*record).(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
idStr, _ := recordMap["id"].(string)
|
||||
serialVal, _ := recordMap["serial"].(int64)
|
||||
|
||||
serial := &types.Uint40{}
|
||||
serial.Set(uint64(serialVal))
|
||||
serials[idStr] = serial
|
||||
}
|
||||
|
||||
return serials, nil
|
||||
}
|
||||
|
||||
// GetSerialsByIdsWithFilter retrieves serials with a filter function
|
||||
func (n *N) GetSerialsByIdsWithFilter(
|
||||
ids *tag.T, fn func(ev *event.E, ser *types.Uint40) bool,
|
||||
) (serials map[string]*types.Uint40, err error) {
|
||||
serials = make(map[string]*types.Uint40)
|
||||
|
||||
if fn == nil {
|
||||
// No filter, just return all
|
||||
return n.GetSerialsByIds(ids)
|
||||
}
|
||||
|
||||
// With filter, need to fetch events
|
||||
for _, idTag := range ids.T {
|
||||
if len(idTag) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
idBytes, err := hex.Dec(string(idTag[1]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
serial, err := n.GetSerialById(idBytes)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
ev, err := n.FetchEventBySerial(serial)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if fn(ev, serial) {
|
||||
serials[string(idTag[1])] = serial
|
||||
}
|
||||
}
|
||||
|
||||
return serials, nil
|
||||
}
|
||||
|
||||
// GetSerialsByRange retrieves serials within a range
|
||||
func (n *N) GetSerialsByRange(idx database.Range) (
|
||||
serials types.Uint40s, err error,
|
||||
) {
|
||||
// This would need to be implemented based on how ranges are defined
|
||||
// For now, returning not implemented
|
||||
err = fmt.Errorf("not implemented")
|
||||
return
|
||||
}
|
||||
|
||||
// GetFullIdPubkeyBySerial retrieves ID and pubkey for a serial number
|
||||
func (n *N) GetFullIdPubkeyBySerial(ser *types.Uint40) (
|
||||
fidpk *store.IdPkTs, err error,
|
||||
) {
|
||||
serial := ser.Get()
|
||||
|
||||
cypher := `
|
||||
MATCH (e:Event {serial: $serial})
|
||||
RETURN e.id AS id,
|
||||
e.pubkey AS pubkey,
|
||||
e.created_at AS created_at`
|
||||
|
||||
params := map[string]any{"serial": int64(serial)}
|
||||
|
||||
result, err := n.ExecuteRead(context.Background(), cypher, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get ID and pubkey by serial: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
neo4jResult, ok := result.(interface {
|
||||
Next(context.Context) bool
|
||||
Record() *interface{}
|
||||
Err() error
|
||||
})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid result type")
|
||||
}
|
||||
|
||||
if neo4jResult.Next(ctx) {
|
||||
record := neo4jResult.Record()
|
||||
if record != nil {
|
||||
recordMap, ok := (*record).(map[string]any)
|
||||
if ok {
|
||||
idStr, _ := recordMap["id"].(string)
|
||||
pubkeyStr, _ := recordMap["pubkey"].(string)
|
||||
createdAt, _ := recordMap["created_at"].(int64)
|
||||
|
||||
id, err := hex.Dec(idStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pubkey, err := hex.Dec(pubkeyStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fidpk = &store.IdPkTs{
|
||||
Id: id,
|
||||
Pub: pubkey,
|
||||
Ts: createdAt,
|
||||
Ser: serial,
|
||||
}
|
||||
|
||||
return fidpk, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("event not found")
|
||||
}
|
||||
|
||||
// GetFullIdPubkeyBySerials retrieves IDs and pubkeys for multiple serials
|
||||
func (n *N) GetFullIdPubkeyBySerials(sers []*types.Uint40) (
|
||||
fidpks []*store.IdPkTs, err error,
|
||||
) {
|
||||
fidpks = make([]*store.IdPkTs, 0, len(sers))
|
||||
|
||||
if len(sers) == 0 {
|
||||
return fidpks, nil
|
||||
}
|
||||
|
||||
// Build list of serial numbers
|
||||
serialNums := make([]int64, len(sers))
|
||||
for i, ser := range sers {
|
||||
serialNums[i] = int64(ser.Get())
|
||||
}
|
||||
|
||||
cypher := `
|
||||
MATCH (e:Event)
|
||||
WHERE e.serial IN $serials
|
||||
RETURN e.id AS id,
|
||||
e.pubkey AS pubkey,
|
||||
e.created_at AS created_at,
|
||||
e.serial AS serial`
|
||||
|
||||
params := map[string]any{"serials": serialNums}
|
||||
|
||||
result, err := n.ExecuteRead(context.Background(), cypher, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get IDs and pubkeys by serials: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
neo4jResult, ok := result.(interface {
|
||||
Next(context.Context) bool
|
||||
Record() *interface{}
|
||||
Err() error
|
||||
})
|
||||
if !ok {
|
||||
return fidpks, nil
|
||||
}
|
||||
|
||||
for neo4jResult.Next(ctx) {
|
||||
record := neo4jResult.Record()
|
||||
if record == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
recordMap, ok := (*record).(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
idStr, _ := recordMap["id"].(string)
|
||||
pubkeyStr, _ := recordMap["pubkey"].(string)
|
||||
createdAt, _ := recordMap["created_at"].(int64)
|
||||
serialVal, _ := recordMap["serial"].(int64)
|
||||
|
||||
id, err := hex.Dec(idStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
pubkey, err := hex.Dec(pubkeyStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
fidpks = append(fidpks, &store.IdPkTs{
|
||||
Id: id,
|
||||
Pub: pubkey,
|
||||
Ts: createdAt,
|
||||
Ser: uint64(serialVal),
|
||||
})
|
||||
}
|
||||
|
||||
return fidpks, nil
|
||||
}
|
||||
44
pkg/neo4j/identity.go
Normal file
44
pkg/neo4j/identity.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package neo4j
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"next.orly.dev/pkg/crypto/keys"
|
||||
)
|
||||
|
||||
// Relay identity methods
|
||||
// We use the marker system to store the relay's private key
|
||||
|
||||
const relayIdentityMarkerKey = "relay_identity_secret"
|
||||
|
||||
// GetRelayIdentitySecret retrieves the relay's identity secret key
|
||||
func (n *N) GetRelayIdentitySecret() (skb []byte, err error) {
|
||||
return n.GetMarker(relayIdentityMarkerKey)
|
||||
}
|
||||
|
||||
// SetRelayIdentitySecret sets the relay's identity secret key
|
||||
func (n *N) SetRelayIdentitySecret(skb []byte) error {
|
||||
return n.SetMarker(relayIdentityMarkerKey, skb)
|
||||
}
|
||||
|
||||
// GetOrCreateRelayIdentitySecret retrieves or creates the relay identity
|
||||
func (n *N) GetOrCreateRelayIdentitySecret() (skb []byte, err error) {
|
||||
skb, err = n.GetRelayIdentitySecret()
|
||||
if err == nil {
|
||||
return skb, nil
|
||||
}
|
||||
|
||||
// Generate new identity
|
||||
skb, err = keys.GenerateSecretKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate identity: %w", err)
|
||||
}
|
||||
|
||||
// Store it
|
||||
if err = n.SetRelayIdentitySecret(skb); err != nil {
|
||||
return nil, fmt.Errorf("failed to store identity: %w", err)
|
||||
}
|
||||
|
||||
n.Logger.Infof("generated new relay identity")
|
||||
return skb, nil
|
||||
}
|
||||
97
pkg/neo4j/import-export.go
Normal file
97
pkg/neo4j/import-export.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package neo4j
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
)
|
||||
|
||||
// Import imports events from a reader (JSONL format)
|
||||
func (n *N) Import(rr io.Reader) {
|
||||
n.ImportEventsFromReader(context.Background(), rr)
|
||||
}
|
||||
|
||||
// Export exports events to a writer (JSONL format)
|
||||
func (n *N) Export(c context.Context, w io.Writer, pubkeys ...[]byte) {
|
||||
// Query all events or events for specific pubkeys
|
||||
// Write as JSONL
|
||||
|
||||
// Stub implementation
|
||||
fmt.Fprintf(w, "# Export not yet implemented for neo4j\n")
|
||||
}
|
||||
|
||||
// ImportEventsFromReader imports events from a reader
|
||||
func (n *N) ImportEventsFromReader(ctx context.Context, rr io.Reader) error {
|
||||
scanner := bufio.NewScanner(rr)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 10*1024*1024) // 10MB max line size
|
||||
|
||||
count := 0
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip comments
|
||||
if line[0] == '#' {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse event
|
||||
ev := &event.E{}
|
||||
if err := json.Unmarshal(line, ev); err != nil {
|
||||
n.Logger.Warningf("failed to parse event: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Save event
|
||||
if _, err := n.SaveEvent(ctx, ev); err != nil {
|
||||
n.Logger.Warningf("failed to import event: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
count++
|
||||
if count%1000 == 0 {
|
||||
n.Logger.Infof("imported %d events", count)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("scanner error: %w", err)
|
||||
}
|
||||
|
||||
n.Logger.Infof("import complete: %d events", count)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImportEventsFromStrings imports events from JSON strings
|
||||
func (n *N) ImportEventsFromStrings(
|
||||
ctx context.Context,
|
||||
eventJSONs []string,
|
||||
policyManager interface{ CheckPolicy(action string, ev *event.E, pubkey []byte, remote string) (bool, error) },
|
||||
) error {
|
||||
for _, eventJSON := range eventJSONs {
|
||||
ev := &event.E{}
|
||||
if err := json.Unmarshal([]byte(eventJSON), ev); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check policy if manager is provided
|
||||
if policyManager != nil {
|
||||
if allowed, err := policyManager.CheckPolicy("write", ev, ev.Pubkey[:], "import"); err != nil || !allowed {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Save event
|
||||
if _, err := n.SaveEvent(ctx, ev); err != nil {
|
||||
n.Logger.Warningf("failed to import event: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
68
pkg/neo4j/logger.go
Normal file
68
pkg/neo4j/logger.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package neo4j
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/atomic"
|
||||
"lol.mleku.dev"
|
||||
"lol.mleku.dev/log"
|
||||
)
|
||||
|
||||
// NewLogger creates a new dgraph logger.
|
||||
func NewLogger(logLevel int, label string) (l *logger) {
|
||||
l = &logger{Label: label}
|
||||
l.Level.Store(int32(logLevel))
|
||||
return
|
||||
}
|
||||
|
||||
type logger struct {
|
||||
Level atomic.Int32
|
||||
Label string
|
||||
}
|
||||
|
||||
// SetLogLevel atomically adjusts the log level to the given log level code.
|
||||
func (l *logger) SetLogLevel(level int) {
|
||||
l.Level.Store(int32(level))
|
||||
}
|
||||
|
||||
// Errorf is a log printer for this level of message.
|
||||
func (l *logger) Errorf(s string, i ...interface{}) {
|
||||
if l.Level.Load() >= lol.Error {
|
||||
s = l.Label + ": " + s
|
||||
txt := fmt.Sprintf(s, i...)
|
||||
_, file, line, _ := runtime.Caller(2)
|
||||
log.E.F("%s\n%s:%d", strings.TrimSpace(txt), file, line)
|
||||
}
|
||||
}
|
||||
|
||||
// Warningf is a log printer for this level of message.
|
||||
func (l *logger) Warningf(s string, i ...interface{}) {
|
||||
if l.Level.Load() >= lol.Warn {
|
||||
s = l.Label + ": " + s
|
||||
txt := fmt.Sprintf(s, i...)
|
||||
_, file, line, _ := runtime.Caller(2)
|
||||
log.W.F("%s\n%s:%d", strings.TrimSpace(txt), file, line)
|
||||
}
|
||||
}
|
||||
|
||||
// Infof is a log printer for this level of message.
|
||||
func (l *logger) Infof(s string, i ...interface{}) {
|
||||
if l.Level.Load() >= lol.Info {
|
||||
s = l.Label + ": " + s
|
||||
txt := fmt.Sprintf(s, i...)
|
||||
_, file, line, _ := runtime.Caller(2)
|
||||
log.I.F("%s\n%s:%d", strings.TrimSpace(txt), file, line)
|
||||
}
|
||||
}
|
||||
|
||||
// Debugf is a log printer for this level of message.
|
||||
func (l *logger) Debugf(s string, i ...interface{}) {
|
||||
if l.Level.Load() >= lol.Debug {
|
||||
s = l.Label + ": " + s
|
||||
txt := fmt.Sprintf(s, i...)
|
||||
_, file, line, _ := runtime.Caller(2)
|
||||
log.D.F("%s\n%s:%d", strings.TrimSpace(txt), file, line)
|
||||
}
|
||||
}
|
||||
91
pkg/neo4j/markers.go
Normal file
91
pkg/neo4j/markers.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package neo4j
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
)
|
||||
|
||||
// Markers provide metadata key-value storage using Neo4j Marker nodes
|
||||
// We store markers as special nodes with label "Marker"
|
||||
|
||||
// SetMarker sets a metadata marker
|
||||
func (n *N) SetMarker(key string, value []byte) error {
|
||||
valueHex := hex.Enc(value)
|
||||
|
||||
cypher := `
|
||||
MERGE (m:Marker {key: $key})
|
||||
SET m.value = $value`
|
||||
|
||||
params := map[string]any{
|
||||
"key": key,
|
||||
"value": valueHex,
|
||||
}
|
||||
|
||||
_, err := n.ExecuteWrite(context.Background(), cypher, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set marker: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMarker retrieves a metadata marker
|
||||
func (n *N) GetMarker(key string) (value []byte, err error) {
|
||||
cypher := "MATCH (m:Marker {key: $key}) RETURN m.value AS value"
|
||||
params := map[string]any{"key": key}
|
||||
|
||||
result, err := n.ExecuteRead(context.Background(), cypher, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get marker: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
neo4jResult, ok := result.(interface {
|
||||
Next(context.Context) bool
|
||||
Record() *interface{}
|
||||
Err() error
|
||||
})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid result type")
|
||||
}
|
||||
|
||||
if neo4jResult.Next(ctx) {
|
||||
record := neo4jResult.Record()
|
||||
if record != nil {
|
||||
recordMap, ok := (*record).(map[string]any)
|
||||
if ok {
|
||||
if valueStr, ok := recordMap["value"].(string); ok {
|
||||
// Decode hex value
|
||||
value, err = hex.Dec(valueStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode marker value: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("marker not found: %s", key)
|
||||
}
|
||||
|
||||
// HasMarker checks if a marker exists
|
||||
func (n *N) HasMarker(key string) bool {
|
||||
_, err := n.GetMarker(key)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// DeleteMarker removes a metadata marker
|
||||
func (n *N) DeleteMarker(key string) error {
|
||||
cypher := "MATCH (m:Marker {key: $key}) DELETE m"
|
||||
params := map[string]any{"key": key}
|
||||
|
||||
_, err := n.ExecuteWrite(context.Background(), cypher, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete marker: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
321
pkg/neo4j/neo4j.go
Normal file
321
pkg/neo4j/neo4j.go
Normal file
@@ -0,0 +1,321 @@
|
||||
// Package neo4j provides a Neo4j-based implementation of the database interface.
|
||||
// Neo4j is a native graph database optimized for relationship-heavy queries,
|
||||
// making it ideal for Nostr's social graph and event reference patterns.
|
||||
package neo4j
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/dgraph-io/badger/v4"
|
||||
"github.com/neo4j/neo4j-go-driver/v5/neo4j"
|
||||
"lol.mleku.dev"
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/database"
|
||||
"next.orly.dev/pkg/encoders/filter"
|
||||
"next.orly.dev/pkg/utils/apputil"
|
||||
)
|
||||
|
||||
// N implements the database.Database interface using Neo4j as the storage backend
|
||||
type N struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
dataDir string
|
||||
Logger *logger
|
||||
|
||||
// Neo4j client connection
|
||||
driver neo4j.DriverWithContext
|
||||
|
||||
// Fallback badger storage for metadata (markers, identity, etc.)
|
||||
pstore *badger.DB
|
||||
|
||||
// Configuration
|
||||
neo4jURI string
|
||||
neo4jUser string
|
||||
neo4jPassword string
|
||||
|
||||
ready chan struct{} // Closed when database is ready to serve requests
|
||||
}
|
||||
|
||||
// Ensure N implements database.Database interface at compile time
|
||||
var _ database.Database = (*N)(nil)
|
||||
|
||||
// init registers the neo4j database factory
|
||||
func init() {
|
||||
database.RegisterNeo4jFactory(func(
|
||||
ctx context.Context,
|
||||
cancel context.CancelFunc,
|
||||
dataDir string,
|
||||
logLevel string,
|
||||
) (database.Database, error) {
|
||||
return New(ctx, cancel, dataDir, logLevel)
|
||||
})
|
||||
}
|
||||
|
||||
// Config holds configuration options for the Neo4j database
|
||||
type Config struct {
|
||||
DataDir string
|
||||
LogLevel string
|
||||
Neo4jURI string // Neo4j bolt URI (e.g., "bolt://localhost:7687")
|
||||
Neo4jUser string // Authentication username
|
||||
Neo4jPassword string // Authentication password
|
||||
}
|
||||
|
||||
// New creates a new Neo4j-based database instance
|
||||
func New(
|
||||
ctx context.Context, cancel context.CancelFunc, dataDir, logLevel string,
|
||||
) (
|
||||
n *N, err error,
|
||||
) {
|
||||
// Get Neo4j connection details from environment
|
||||
neo4jURI := os.Getenv("ORLY_NEO4J_URI")
|
||||
if neo4jURI == "" {
|
||||
neo4jURI = "bolt://localhost:7687"
|
||||
}
|
||||
neo4jUser := os.Getenv("ORLY_NEO4J_USER")
|
||||
if neo4jUser == "" {
|
||||
neo4jUser = "neo4j"
|
||||
}
|
||||
neo4jPassword := os.Getenv("ORLY_NEO4J_PASSWORD")
|
||||
if neo4jPassword == "" {
|
||||
neo4jPassword = "password"
|
||||
}
|
||||
|
||||
n = &N{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
dataDir: dataDir,
|
||||
Logger: NewLogger(lol.GetLogLevel(logLevel), dataDir),
|
||||
neo4jURI: neo4jURI,
|
||||
neo4jUser: neo4jUser,
|
||||
neo4jPassword: neo4jPassword,
|
||||
ready: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Ensure the data directory exists
|
||||
if err = os.MkdirAll(dataDir, 0755); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure directory structure
|
||||
dummyFile := filepath.Join(dataDir, "dummy.sst")
|
||||
if err = apputil.EnsureDir(dummyFile); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize neo4j client connection
|
||||
if err = n.initNeo4jClient(); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize badger for metadata storage
|
||||
if err = n.initStorage(); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Apply Nostr schema to neo4j (create constraints and indexes)
|
||||
if err = n.applySchema(ctx); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize serial counter
|
||||
if err = n.initSerialCounter(); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Start warmup goroutine to signal when database is ready
|
||||
go n.warmup()
|
||||
|
||||
// Setup shutdown handler
|
||||
go func() {
|
||||
<-n.ctx.Done()
|
||||
n.cancel()
|
||||
if n.driver != nil {
|
||||
n.driver.Close(context.Background())
|
||||
}
|
||||
if n.pstore != nil {
|
||||
n.pstore.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// initNeo4jClient establishes connection to Neo4j server
|
||||
func (n *N) initNeo4jClient() error {
|
||||
n.Logger.Infof("connecting to neo4j at %s", n.neo4jURI)
|
||||
|
||||
// Create Neo4j driver
|
||||
driver, err := neo4j.NewDriverWithContext(
|
||||
n.neo4jURI,
|
||||
neo4j.BasicAuth(n.neo4jUser, n.neo4jPassword, ""),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create neo4j driver: %w", err)
|
||||
}
|
||||
|
||||
n.driver = driver
|
||||
|
||||
// Verify connectivity
|
||||
ctx := context.Background()
|
||||
if err := driver.VerifyConnectivity(ctx); err != nil {
|
||||
return fmt.Errorf("failed to verify neo4j connectivity: %w", err)
|
||||
}
|
||||
|
||||
n.Logger.Infof("successfully connected to neo4j")
|
||||
return nil
|
||||
}
|
||||
|
||||
// initStorage opens Badger database for metadata storage
|
||||
func (n *N) initStorage() error {
|
||||
metadataDir := filepath.Join(n.dataDir, "metadata")
|
||||
|
||||
if err := os.MkdirAll(metadataDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create metadata directory: %w", err)
|
||||
}
|
||||
|
||||
opts := badger.DefaultOptions(metadataDir)
|
||||
|
||||
var err error
|
||||
n.pstore, err = badger.Open(opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open badger metadata store: %w", err)
|
||||
}
|
||||
|
||||
n.Logger.Infof("metadata storage initialized")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteRead executes a read query against Neo4j
|
||||
func (n *N) ExecuteRead(ctx context.Context, cypher string, params map[string]any) (neo4j.ResultWithContext, error) {
|
||||
session := n.driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeRead})
|
||||
defer session.Close(ctx)
|
||||
|
||||
result, err := session.Run(ctx, cypher, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("neo4j read query failed: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ExecuteWrite executes a write query against Neo4j
|
||||
func (n *N) ExecuteWrite(ctx context.Context, cypher string, params map[string]any) (neo4j.ResultWithContext, error) {
|
||||
session := n.driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeWrite})
|
||||
defer session.Close(ctx)
|
||||
|
||||
result, err := session.Run(ctx, cypher, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("neo4j write query failed: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ExecuteWriteTransaction executes a transactional write operation
|
||||
func (n *N) ExecuteWriteTransaction(ctx context.Context, work func(tx neo4j.ManagedTransaction) (any, error)) (any, error) {
|
||||
session := n.driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeWrite})
|
||||
defer session.Close(ctx)
|
||||
|
||||
return session.ExecuteWrite(ctx, work)
|
||||
}
|
||||
|
||||
// Path returns the data directory path
|
||||
func (n *N) Path() string { return n.dataDir }
|
||||
|
||||
// Init initializes the database with a given path (no-op, path set in New)
|
||||
func (n *N) Init(path string) (err error) {
|
||||
// Path already set in New()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sync flushes pending writes
|
||||
func (n *N) Sync() (err error) {
|
||||
if n.pstore != nil {
|
||||
return n.pstore.Sync()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the database
|
||||
func (n *N) Close() (err error) {
|
||||
n.cancel()
|
||||
if n.driver != nil {
|
||||
if e := n.driver.Close(context.Background()); e != nil {
|
||||
err = e
|
||||
}
|
||||
}
|
||||
if n.pstore != nil {
|
||||
if e := n.pstore.Close(); e != nil && err == nil {
|
||||
err = e
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Wipe removes all data
|
||||
func (n *N) Wipe() (err error) {
|
||||
// Close and remove badger metadata
|
||||
if n.pstore != nil {
|
||||
if err = n.pstore.Close(); chk.E(err) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = os.RemoveAll(n.dataDir); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Delete all nodes and relationships in Neo4j
|
||||
ctx := context.Background()
|
||||
_, err = n.ExecuteWrite(ctx, "MATCH (n) DETACH DELETE n", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to wipe neo4j database: %w", err)
|
||||
}
|
||||
|
||||
return n.initStorage()
|
||||
}
|
||||
|
||||
// SetLogLevel sets the logging level
|
||||
func (n *N) SetLogLevel(level string) {
|
||||
// n.Logger.SetLevel(lol.GetLogLevel(level))
|
||||
}
|
||||
|
||||
// EventIdsBySerial retrieves event IDs by serial range (stub)
|
||||
func (n *N) EventIdsBySerial(start uint64, count int) (
|
||||
evs []uint64, err error,
|
||||
) {
|
||||
err = fmt.Errorf("not implemented")
|
||||
return
|
||||
}
|
||||
|
||||
// RunMigrations runs database migrations (no-op for neo4j)
|
||||
func (n *N) RunMigrations() {
|
||||
// No-op for neo4j
|
||||
}
|
||||
|
||||
// Ready returns a channel that closes when the database is ready to serve requests.
|
||||
// This allows callers to wait for database warmup to complete.
|
||||
func (n *N) Ready() <-chan struct{} {
|
||||
return n.ready
|
||||
}
|
||||
|
||||
// warmup performs database warmup operations and closes the ready channel when complete.
|
||||
// For Neo4j, warmup ensures the connection is healthy and constraints are applied.
|
||||
func (n *N) warmup() {
|
||||
defer close(n.ready)
|
||||
|
||||
// Neo4j connection and schema are already verified during initialization
|
||||
// Just give a brief moment for any background processes to settle
|
||||
n.Logger.Infof("neo4j database warmup complete, ready to serve requests")
|
||||
}
|
||||
|
||||
// GetCachedJSON returns cached query results (not implemented for Neo4j)
|
||||
func (n *N) GetCachedJSON(f *filter.F) ([][]byte, bool) { return nil, false }
|
||||
|
||||
// CacheMarshaledJSON caches marshaled JSON results (not implemented for Neo4j)
|
||||
func (n *N) CacheMarshaledJSON(f *filter.F, marshaledJSON [][]byte) {}
|
||||
|
||||
// InvalidateQueryCache invalidates the query cache (not implemented for Neo4j)
|
||||
func (n *N) InvalidateQueryCache() {}
|
||||
212
pkg/neo4j/nip43.go
Normal file
212
pkg/neo4j/nip43.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package neo4j
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"next.orly.dev/pkg/database"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
)
|
||||
|
||||
// NIP-43 Invite-based ACL methods
|
||||
// Simplified implementation using marker-based storage via Badger
|
||||
// For production, these could use Neo4j nodes with relationships
|
||||
|
||||
// AddNIP43Member adds a member using an invite code
|
||||
func (n *N) AddNIP43Member(pubkey []byte, inviteCode string) error {
|
||||
key := "nip43_" + hex.Enc(pubkey)
|
||||
|
||||
member := database.NIP43Membership{
|
||||
InviteCode: inviteCode,
|
||||
AddedAt: time.Now(),
|
||||
}
|
||||
copy(member.Pubkey[:], pubkey)
|
||||
|
||||
data, err := json.Marshal(member)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal membership: %w", err)
|
||||
}
|
||||
|
||||
// Also add to members list
|
||||
if err := n.addToMembersList(pubkey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return n.SetMarker(key, data)
|
||||
}
|
||||
|
||||
// RemoveNIP43Member removes a member
|
||||
func (n *N) RemoveNIP43Member(pubkey []byte) error {
|
||||
key := "nip43_" + hex.Enc(pubkey)
|
||||
|
||||
// Remove from members list
|
||||
if err := n.removeFromMembersList(pubkey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return n.DeleteMarker(key)
|
||||
}
|
||||
|
||||
// IsNIP43Member checks if a pubkey is a member
|
||||
func (n *N) IsNIP43Member(pubkey []byte) (isMember bool, err error) {
|
||||
_, err = n.GetNIP43Membership(pubkey)
|
||||
return err == nil, nil
|
||||
}
|
||||
|
||||
// GetNIP43Membership retrieves membership information
|
||||
func (n *N) GetNIP43Membership(pubkey []byte) (*database.NIP43Membership, error) {
|
||||
key := "nip43_" + hex.Enc(pubkey)
|
||||
|
||||
data, err := n.GetMarker(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var member database.NIP43Membership
|
||||
if err := json.Unmarshal(data, &member); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal membership: %w", err)
|
||||
}
|
||||
|
||||
return &member, nil
|
||||
}
|
||||
|
||||
// GetAllNIP43Members retrieves all member pubkeys
|
||||
func (n *N) GetAllNIP43Members() ([][]byte, error) {
|
||||
data, err := n.GetMarker("nip43_members_list")
|
||||
if err != nil {
|
||||
return nil, nil // No members = empty list
|
||||
}
|
||||
|
||||
var members []string
|
||||
if err := json.Unmarshal(data, &members); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal members list: %w", err)
|
||||
}
|
||||
|
||||
result := make([][]byte, 0, len(members))
|
||||
for _, hexPubkey := range members {
|
||||
pubkey, err := hex.Dec(hexPubkey)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, pubkey)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// StoreInviteCode stores an invite code with expiration
|
||||
func (n *N) StoreInviteCode(code string, expiresAt time.Time) error {
|
||||
key := "invite_" + code
|
||||
|
||||
inviteData := map[string]interface{}{
|
||||
"code": code,
|
||||
"expiresAt": expiresAt,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(inviteData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal invite: %w", err)
|
||||
}
|
||||
|
||||
return n.SetMarker(key, data)
|
||||
}
|
||||
|
||||
// ValidateInviteCode checks if an invite code is valid
|
||||
func (n *N) ValidateInviteCode(code string) (valid bool, err error) {
|
||||
key := "invite_" + code
|
||||
|
||||
data, err := n.GetMarker(key)
|
||||
if err != nil {
|
||||
return false, nil // Code doesn't exist
|
||||
}
|
||||
|
||||
var inviteData map[string]interface{}
|
||||
if err := json.Unmarshal(data, &inviteData); err != nil {
|
||||
return false, fmt.Errorf("failed to unmarshal invite: %w", err)
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if expiresStr, ok := inviteData["expiresAt"].(string); ok {
|
||||
expiresAt, err := time.Parse(time.RFC3339, expiresStr)
|
||||
if err == nil && time.Now().After(expiresAt) {
|
||||
return false, nil // Expired
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// DeleteInviteCode removes an invite code
|
||||
func (n *N) DeleteInviteCode(code string) error {
|
||||
key := "invite_" + code
|
||||
return n.DeleteMarker(key)
|
||||
}
|
||||
|
||||
// PublishNIP43MembershipEvent publishes a membership event
|
||||
func (n *N) PublishNIP43MembershipEvent(kind int, pubkey []byte) error {
|
||||
// This would require publishing an actual Nostr event
|
||||
// For now, just log it
|
||||
n.Logger.Infof("would publish NIP-43 event kind %d for %s", kind, hex.Enc(pubkey))
|
||||
return nil
|
||||
}
|
||||
|
||||
// addToMembersList adds a pubkey to the members list
|
||||
func (n *N) addToMembersList(pubkey []byte) error {
|
||||
data, err := n.GetMarker("nip43_members_list")
|
||||
|
||||
var members []string
|
||||
if err == nil {
|
||||
if err := json.Unmarshal(data, &members); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal members list: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
hexPubkey := hex.Enc(pubkey)
|
||||
|
||||
// Check if already in list
|
||||
for _, member := range members {
|
||||
if member == hexPubkey {
|
||||
return nil // Already in list
|
||||
}
|
||||
}
|
||||
|
||||
members = append(members, hexPubkey)
|
||||
|
||||
data, err = json.Marshal(members)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal members list: %w", err)
|
||||
}
|
||||
|
||||
return n.SetMarker("nip43_members_list", data)
|
||||
}
|
||||
|
||||
// removeFromMembersList removes a pubkey from the members list
|
||||
func (n *N) removeFromMembersList(pubkey []byte) error {
|
||||
data, err := n.GetMarker("nip43_members_list")
|
||||
if err != nil {
|
||||
return nil // No list = nothing to remove
|
||||
}
|
||||
|
||||
var members []string
|
||||
if err := json.Unmarshal(data, &members); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal members list: %w", err)
|
||||
}
|
||||
|
||||
hexPubkey := hex.Enc(pubkey)
|
||||
|
||||
// Filter out the pubkey
|
||||
filtered := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
if member != hexPubkey {
|
||||
filtered = append(filtered, member)
|
||||
}
|
||||
}
|
||||
|
||||
data, err = json.Marshal(filtered)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal members list: %w", err)
|
||||
}
|
||||
|
||||
return n.SetMarker("nip43_members_list", data)
|
||||
}
|
||||
480
pkg/neo4j/query-events.go
Normal file
480
pkg/neo4j/query-events.go
Normal file
@@ -0,0 +1,480 @@
|
||||
package neo4j
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"next.orly.dev/pkg/database/indexes/types"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/filter"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/encoders/tag"
|
||||
"next.orly.dev/pkg/interfaces/store"
|
||||
)
|
||||
|
||||
// QueryEvents retrieves events matching the given filter
|
||||
func (n *N) QueryEvents(c context.Context, f *filter.F) (evs event.S, err error) {
|
||||
return n.QueryEventsWithOptions(c, f, false, false)
|
||||
}
|
||||
|
||||
// QueryAllVersions retrieves all versions of events matching the filter
|
||||
func (n *N) QueryAllVersions(c context.Context, f *filter.F) (evs event.S, err error) {
|
||||
return n.QueryEventsWithOptions(c, f, false, true)
|
||||
}
|
||||
|
||||
// QueryEventsWithOptions retrieves events with specific options
|
||||
func (n *N) QueryEventsWithOptions(
|
||||
c context.Context, f *filter.F, includeDeleteEvents bool, showAllVersions bool,
|
||||
) (evs event.S, err error) {
|
||||
// Build Cypher query from Nostr filter
|
||||
cypher, params := n.buildCypherQuery(f, includeDeleteEvents)
|
||||
|
||||
// Execute query
|
||||
result, err := n.ExecuteRead(c, cypher, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute query: %w", err)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
evs, err = n.parseEventsFromResult(result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse events: %w", err)
|
||||
}
|
||||
|
||||
return evs, nil
|
||||
}
|
||||
|
||||
// buildCypherQuery constructs a Cypher query from a Nostr filter
|
||||
// This is the core translation layer between Nostr's REQ filter format and Neo4j's Cypher
|
||||
func (n *N) buildCypherQuery(f *filter.F, includeDeleteEvents bool) (string, map[string]any) {
|
||||
params := make(map[string]any)
|
||||
var whereClauses []string
|
||||
|
||||
// Start with basic MATCH clause
|
||||
matchClause := "MATCH (e:Event)"
|
||||
|
||||
// IDs filter - uses exact match or prefix matching
|
||||
if len(f.Ids.T) > 0 {
|
||||
idConditions := make([]string, len(f.Ids.T))
|
||||
for i, id := range f.Ids.T {
|
||||
paramName := fmt.Sprintf("id_%d", i)
|
||||
hexID := hex.Enc(id)
|
||||
|
||||
// Handle prefix matching for partial IDs
|
||||
if len(id) < 32 { // Full event ID is 32 bytes (64 hex chars)
|
||||
idConditions[i] = fmt.Sprintf("e.id STARTS WITH $%s", paramName)
|
||||
} else {
|
||||
idConditions[i] = fmt.Sprintf("e.id = $%s", paramName)
|
||||
}
|
||||
params[paramName] = hexID
|
||||
}
|
||||
whereClauses = append(whereClauses, "("+strings.Join(idConditions, " OR ")+")")
|
||||
}
|
||||
|
||||
// Authors filter - supports prefix matching for partial pubkeys
|
||||
if len(f.Authors.T) > 0 {
|
||||
authorConditions := make([]string, len(f.Authors.T))
|
||||
for i, author := range f.Authors.T {
|
||||
paramName := fmt.Sprintf("author_%d", i)
|
||||
hexAuthor := hex.Enc(author)
|
||||
|
||||
// Handle prefix matching for partial pubkeys
|
||||
if len(author) < 32 { // Full pubkey is 32 bytes (64 hex chars)
|
||||
authorConditions[i] = fmt.Sprintf("e.pubkey STARTS WITH $%s", paramName)
|
||||
} else {
|
||||
authorConditions[i] = fmt.Sprintf("e.pubkey = $%s", paramName)
|
||||
}
|
||||
params[paramName] = hexAuthor
|
||||
}
|
||||
whereClauses = append(whereClauses, "("+strings.Join(authorConditions, " OR ")+")")
|
||||
}
|
||||
|
||||
// Kinds filter - matches event types
|
||||
if len(f.Kinds.K) > 0 {
|
||||
kinds := make([]int64, len(f.Kinds.K))
|
||||
for i, k := range f.Kinds.K {
|
||||
kinds[i] = int64(k.K)
|
||||
}
|
||||
params["kinds"] = kinds
|
||||
whereClauses = append(whereClauses, "e.kind IN $kinds")
|
||||
}
|
||||
|
||||
// Time range filters - for temporal queries
|
||||
if f.Since != nil {
|
||||
params["since"] = f.Since.V
|
||||
whereClauses = append(whereClauses, "e.created_at >= $since")
|
||||
}
|
||||
if f.Until != nil {
|
||||
params["until"] = f.Until.V
|
||||
whereClauses = append(whereClauses, "e.created_at <= $until")
|
||||
}
|
||||
|
||||
// Tag filters - this is where Neo4j's graph capabilities shine
|
||||
// We can efficiently traverse tag relationships
|
||||
tagIndex := 0
|
||||
for tagType, tagValues := range *f.Tags {
|
||||
if len(tagValues.T) > 0 {
|
||||
tagVarName := fmt.Sprintf("t%d", tagIndex)
|
||||
tagTypeParam := fmt.Sprintf("tagType_%d", tagIndex)
|
||||
tagValuesParam := fmt.Sprintf("tagValues_%d", tagIndex)
|
||||
|
||||
// Add tag relationship to MATCH clause
|
||||
matchClause += fmt.Sprintf(" OPTIONAL MATCH (e)-[:TAGGED_WITH]->(%s:Tag)", tagVarName)
|
||||
|
||||
// Convert tag values to strings
|
||||
tagValueStrings := make([]string, len(tagValues.T))
|
||||
for i, tv := range tagValues.T {
|
||||
tagValueStrings[i] = string(tv)
|
||||
}
|
||||
|
||||
// Add WHERE conditions for this tag
|
||||
params[tagTypeParam] = string(tagType)
|
||||
params[tagValuesParam] = tagValueStrings
|
||||
whereClauses = append(whereClauses,
|
||||
fmt.Sprintf("(%s.type = $%s AND %s.value IN $%s)",
|
||||
tagVarName, tagTypeParam, tagVarName, tagValuesParam))
|
||||
|
||||
tagIndex++
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude delete events unless requested
|
||||
if !includeDeleteEvents {
|
||||
whereClauses = append(whereClauses, "e.kind <> 5")
|
||||
}
|
||||
|
||||
// Build WHERE clause
|
||||
whereClause := ""
|
||||
if len(whereClauses) > 0 {
|
||||
whereClause = " WHERE " + strings.Join(whereClauses, " AND ")
|
||||
}
|
||||
|
||||
// Build RETURN clause with all event properties
|
||||
returnClause := `
|
||||
RETURN e.id AS id,
|
||||
e.kind AS kind,
|
||||
e.created_at AS created_at,
|
||||
e.content AS content,
|
||||
e.sig AS sig,
|
||||
e.pubkey AS pubkey,
|
||||
e.tags AS tags,
|
||||
e.serial AS serial`
|
||||
|
||||
// Add ordering (most recent first)
|
||||
orderClause := " ORDER BY e.created_at DESC"
|
||||
|
||||
// Add limit if specified
|
||||
limitClause := ""
|
||||
if *f.Limit > 0 {
|
||||
params["limit"] = *f.Limit
|
||||
limitClause = " LIMIT $limit"
|
||||
}
|
||||
|
||||
// Combine all parts
|
||||
cypher := matchClause + whereClause + returnClause + orderClause + limitClause
|
||||
|
||||
return cypher, params
|
||||
}
|
||||
|
||||
// parseEventsFromResult converts Neo4j query results to Nostr events
|
||||
func (n *N) parseEventsFromResult(result any) ([]*event.E, error) {
|
||||
// Type assert to Neo4j result
|
||||
neo4jResult, ok := result.(interface {
|
||||
Next(context.Context) bool
|
||||
Record() *interface{}
|
||||
Err() error
|
||||
})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid result type")
|
||||
}
|
||||
|
||||
events := make([]*event.E, 0)
|
||||
ctx := context.Background()
|
||||
|
||||
// Iterate through result records
|
||||
for neo4jResult.Next(ctx) {
|
||||
record := neo4jResult.Record()
|
||||
if record == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract fields from record
|
||||
recordMap, ok := (*record).(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse event fields
|
||||
idStr, _ := recordMap["id"].(string)
|
||||
kind, _ := recordMap["kind"].(int64)
|
||||
createdAt, _ := recordMap["created_at"].(int64)
|
||||
content, _ := recordMap["content"].(string)
|
||||
sigStr, _ := recordMap["sig"].(string)
|
||||
pubkeyStr, _ := recordMap["pubkey"].(string)
|
||||
tagsStr, _ := recordMap["tags"].(string)
|
||||
|
||||
// Decode hex strings
|
||||
id, err := hex.Dec(idStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sig, err := hex.Dec(sigStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
pubkey, err := hex.Dec(pubkeyStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse tags from JSON
|
||||
tags := tag.NewS()
|
||||
if tagsStr != "" {
|
||||
_ = tags.UnmarshalJSON([]byte(tagsStr))
|
||||
}
|
||||
|
||||
// Create event
|
||||
e := &event.E{
|
||||
Kind: uint16(kind),
|
||||
CreatedAt: createdAt,
|
||||
Content: []byte(content),
|
||||
Tags: tags,
|
||||
}
|
||||
|
||||
// Copy fixed-size arrays
|
||||
copy(e.ID[:], id)
|
||||
copy(e.Sig[:], sig)
|
||||
copy(e.Pubkey[:], pubkey)
|
||||
|
||||
events = append(events, e)
|
||||
}
|
||||
|
||||
if err := neo4jResult.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating results: %w", err)
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// QueryDeleteEventsByTargetId retrieves delete events targeting a specific event ID
|
||||
func (n *N) QueryDeleteEventsByTargetId(c context.Context, targetEventId []byte) (
|
||||
evs event.S, err error,
|
||||
) {
|
||||
targetIDStr := hex.Enc(targetEventId)
|
||||
|
||||
// Query for kind 5 events that reference this event
|
||||
// This uses Neo4j's graph traversal to find delete events
|
||||
cypher := `
|
||||
MATCH (target:Event {id: $targetId})
|
||||
MATCH (e:Event {kind: 5})-[:REFERENCES]->(target)
|
||||
RETURN e.id AS id,
|
||||
e.kind AS kind,
|
||||
e.created_at AS created_at,
|
||||
e.content AS content,
|
||||
e.sig AS sig,
|
||||
e.pubkey AS pubkey,
|
||||
e.tags AS tags,
|
||||
e.serial AS serial
|
||||
ORDER BY e.created_at DESC`
|
||||
|
||||
params := map[string]any{"targetId": targetIDStr}
|
||||
|
||||
result, err := n.ExecuteRead(c, cypher, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query delete events: %w", err)
|
||||
}
|
||||
|
||||
evs, err = n.parseEventsFromResult(result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse delete events: %w", err)
|
||||
}
|
||||
|
||||
return evs, nil
|
||||
}
|
||||
|
||||
// QueryForSerials retrieves event serials matching a filter
|
||||
func (n *N) QueryForSerials(c context.Context, f *filter.F) (
|
||||
serials types.Uint40s, err error,
|
||||
) {
|
||||
// Build query but only return serial numbers
|
||||
cypher, params := n.buildCypherQuery(f, false)
|
||||
|
||||
// Replace RETURN clause to only fetch serials
|
||||
returnClause := " RETURN e.serial AS serial"
|
||||
cypherParts := strings.Split(cypher, "RETURN")
|
||||
if len(cypherParts) < 2 {
|
||||
return nil, fmt.Errorf("invalid query structure")
|
||||
}
|
||||
|
||||
// Rebuild query with serial-only return
|
||||
cypher = cypherParts[0] + returnClause
|
||||
if strings.Contains(cypherParts[1], "ORDER BY") {
|
||||
orderPart := " ORDER BY" + strings.Split(cypherParts[1], "ORDER BY")[1]
|
||||
cypher += orderPart
|
||||
}
|
||||
|
||||
result, err := n.ExecuteRead(c, cypher, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query serials: %w", err)
|
||||
}
|
||||
|
||||
// Parse serials from result
|
||||
serials = make([]*types.Uint40, 0)
|
||||
ctx := context.Background()
|
||||
|
||||
neo4jResult, ok := result.(interface {
|
||||
Next(context.Context) bool
|
||||
Record() *interface{}
|
||||
Err() error
|
||||
})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid result type")
|
||||
}
|
||||
|
||||
for neo4jResult.Next(ctx) {
|
||||
record := neo4jResult.Record()
|
||||
if record == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
recordMap, ok := (*record).(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
serialVal, _ := recordMap["serial"].(int64)
|
||||
serial := types.Uint40{}
|
||||
serial.Set(uint64(serialVal))
|
||||
serials = append(serials, &serial)
|
||||
}
|
||||
|
||||
return serials, nil
|
||||
}
|
||||
|
||||
// QueryForIds retrieves event IDs matching a filter
|
||||
func (n *N) QueryForIds(c context.Context, f *filter.F) (
|
||||
idPkTs []*store.IdPkTs, err error,
|
||||
) {
|
||||
// Build query but only return ID, pubkey, created_at, serial
|
||||
cypher, params := n.buildCypherQuery(f, false)
|
||||
|
||||
// Replace RETURN clause
|
||||
returnClause := `
|
||||
RETURN e.id AS id,
|
||||
e.pubkey AS pubkey,
|
||||
e.created_at AS created_at,
|
||||
e.serial AS serial`
|
||||
|
||||
cypherParts := strings.Split(cypher, "RETURN")
|
||||
if len(cypherParts) < 2 {
|
||||
return nil, fmt.Errorf("invalid query structure")
|
||||
}
|
||||
|
||||
cypher = cypherParts[0] + returnClause
|
||||
if strings.Contains(cypherParts[1], "ORDER BY") {
|
||||
orderPart := " ORDER BY" + strings.Split(cypherParts[1], "ORDER BY")[1]
|
||||
cypher += orderPart
|
||||
}
|
||||
|
||||
result, err := n.ExecuteRead(c, cypher, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query IDs: %w", err)
|
||||
}
|
||||
|
||||
// Parse IDs from result
|
||||
idPkTs = make([]*store.IdPkTs, 0)
|
||||
ctx := context.Background()
|
||||
|
||||
neo4jResult, ok := result.(interface {
|
||||
Next(context.Context) bool
|
||||
Record() *interface{}
|
||||
Err() error
|
||||
})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid result type")
|
||||
}
|
||||
|
||||
for neo4jResult.Next(ctx) {
|
||||
record := neo4jResult.Record()
|
||||
if record == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
recordMap, ok := (*record).(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
idStr, _ := recordMap["id"].(string)
|
||||
pubkeyStr, _ := recordMap["pubkey"].(string)
|
||||
createdAt, _ := recordMap["created_at"].(int64)
|
||||
serialVal, _ := recordMap["serial"].(int64)
|
||||
|
||||
id, err := hex.Dec(idStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
pubkey, err := hex.Dec(pubkeyStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
idPkTs = append(idPkTs, &store.IdPkTs{
|
||||
Id: id,
|
||||
Pub: pubkey,
|
||||
Ts: createdAt,
|
||||
Ser: uint64(serialVal),
|
||||
})
|
||||
}
|
||||
|
||||
return idPkTs, nil
|
||||
}
|
||||
|
||||
// CountEvents counts events matching a filter
|
||||
func (n *N) CountEvents(c context.Context, f *filter.F) (
|
||||
count int, approximate bool, err error,
|
||||
) {
|
||||
// Build query but only count results
|
||||
cypher, params := n.buildCypherQuery(f, false)
|
||||
|
||||
// Replace RETURN clause with COUNT
|
||||
returnClause := " RETURN count(e) AS count"
|
||||
cypherParts := strings.Split(cypher, "RETURN")
|
||||
if len(cypherParts) < 2 {
|
||||
return 0, false, fmt.Errorf("invalid query structure")
|
||||
}
|
||||
|
||||
// Remove ORDER BY and LIMIT for count query
|
||||
cypher = cypherParts[0] + returnClause
|
||||
delete(params, "limit") // Remove limit parameter if it exists
|
||||
|
||||
result, err := n.ExecuteRead(c, cypher, params)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("failed to count events: %w", err)
|
||||
}
|
||||
|
||||
// Parse count from result
|
||||
ctx := context.Background()
|
||||
neo4jResult, ok := result.(interface {
|
||||
Next(context.Context) bool
|
||||
Record() *interface{}
|
||||
Err() error
|
||||
})
|
||||
if !ok {
|
||||
return 0, false, fmt.Errorf("invalid result type")
|
||||
}
|
||||
|
||||
if neo4jResult.Next(ctx) {
|
||||
record := neo4jResult.Record()
|
||||
if record != nil {
|
||||
recordMap, ok := (*record).(map[string]any)
|
||||
if ok {
|
||||
countVal, _ := recordMap["count"].(int64)
|
||||
count = int(countVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count, false, nil
|
||||
}
|
||||
266
pkg/neo4j/save-event.go
Normal file
266
pkg/neo4j/save-event.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package neo4j
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"next.orly.dev/pkg/database/indexes/types"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/filter"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
)
|
||||
|
||||
// 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.
|
||||
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()
|
||||
neo4jResult, ok := result.(interface {
|
||||
Next(context.Context) bool
|
||||
Record() *interface{}
|
||||
Err() error
|
||||
})
|
||||
if ok && neo4jResult.Next(ctx) {
|
||||
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)
|
||||
}
|
||||
|
||||
// Build and execute Cypher query to create event with all relationships
|
||||
cypher, params := n.buildEventCreationCypher(ev, serial)
|
||||
|
||||
if _, err = n.ExecuteWrite(c, cypher, params); err != nil {
|
||||
return false, fmt.Errorf("failed to save event: %w", err)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// buildEventCreationCypher constructs a Cypher query to create an event node with all relationships
|
||||
// This is a single atomic operation that creates:
|
||||
// - Event node with all properties
|
||||
// - Author node and AUTHORED_BY relationship
|
||||
// - Tag nodes and TAGGED_WITH relationships
|
||||
// - Reference relationships (REFERENCES for 'e' tags, MENTIONS for 'p' tags)
|
||||
func (n *N) buildEventCreationCypher(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
|
||||
|
||||
// Serialize tags as JSON string for storage
|
||||
tagsJSON, _ := ev.Tags.MarshalJSON()
|
||||
params["tags"] = string(tagsJSON)
|
||||
|
||||
// Start building the Cypher query
|
||||
// Use MERGE to ensure idempotency for author nodes
|
||||
cypher := `
|
||||
// Create or match author node
|
||||
MERGE (a:Author {pubkey: $pubkey})
|
||||
|
||||
// Create event node
|
||||
CREATE (e:Event {
|
||||
id: $eventId,
|
||||
serial: $serial,
|
||||
kind: $kind,
|
||||
created_at: $createdAt,
|
||||
content: $content,
|
||||
sig: $sig,
|
||||
pubkey: $pubkey,
|
||||
tags: $tags
|
||||
})
|
||||
|
||||
// Link event to author
|
||||
CREATE (e)-[:AUTHORED_BY]->(a)
|
||||
`
|
||||
|
||||
// Process tags to create relationships
|
||||
// Different tag types create different relationship patterns
|
||||
tagNodeIndex := 0
|
||||
eTagIndex := 0
|
||||
pTagIndex := 0
|
||||
|
||||
for _, tagItem := range *ev.Tags {
|
||||
if len(tagItem.T) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
tagType := string(tagItem.T[0])
|
||||
tagValue := string(tagItem.T[1])
|
||||
|
||||
switch tagType {
|
||||
case "e": // Event reference - creates REFERENCES relationship
|
||||
// Create reference to another event (if it exists)
|
||||
paramName := fmt.Sprintf("eTag_%d", eTagIndex)
|
||||
params[paramName] = tagValue
|
||||
|
||||
cypher += fmt.Sprintf(`
|
||||
// Reference to event (e-tag)
|
||||
OPTIONAL MATCH (ref%d:Event {id: $%s})
|
||||
FOREACH (ignoreMe IN CASE WHEN ref%d IS NOT NULL THEN [1] ELSE [] END |
|
||||
CREATE (e)-[:REFERENCES]->(ref%d)
|
||||
)
|
||||
`, eTagIndex, paramName, eTagIndex, eTagIndex)
|
||||
|
||||
eTagIndex++
|
||||
|
||||
case "p": // Pubkey mention - creates MENTIONS relationship
|
||||
// Create mention to another author
|
||||
paramName := fmt.Sprintf("pTag_%d", pTagIndex)
|
||||
params[paramName] = tagValue
|
||||
|
||||
cypher += fmt.Sprintf(`
|
||||
// Mention of author (p-tag)
|
||||
MERGE (mentioned%d:Author {pubkey: $%s})
|
||||
CREATE (e)-[:MENTIONS]->(mentioned%d)
|
||||
`, pTagIndex, paramName, pTagIndex)
|
||||
|
||||
pTagIndex++
|
||||
|
||||
default: // Other tags - creates Tag nodes and TAGGED_WITH relationships
|
||||
// Create tag node and relationship
|
||||
typeParam := fmt.Sprintf("tagType_%d", tagNodeIndex)
|
||||
valueParam := fmt.Sprintf("tagValue_%d", tagNodeIndex)
|
||||
params[typeParam] = tagType
|
||||
params[valueParam] = tagValue
|
||||
|
||||
cypher += fmt.Sprintf(`
|
||||
// Generic tag relationship
|
||||
MERGE (tag%d:Tag {type: $%s, value: $%s})
|
||||
CREATE (e)-[:TAGGED_WITH]->(tag%d)
|
||||
`, tagNodeIndex, typeParam, valueParam, tagNodeIndex)
|
||||
|
||||
tagNodeIndex++
|
||||
}
|
||||
}
|
||||
|
||||
// Return the created event
|
||||
cypher += `
|
||||
RETURN e.id AS id`
|
||||
|
||||
return cypher, params
|
||||
}
|
||||
|
||||
// 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
|
||||
neo4jResult, ok := result.(interface {
|
||||
Next(context.Context) bool
|
||||
Record() *interface{}
|
||||
Err() error
|
||||
})
|
||||
if !ok {
|
||||
return false, nil, fmt.Errorf("invalid result type")
|
||||
}
|
||||
|
||||
var serials types.Uint40s
|
||||
wouldReplace := false
|
||||
|
||||
for neo4jResult.Next(ctx) {
|
||||
record := neo4jResult.Record()
|
||||
if record == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
recordMap, ok := (*record).(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
serialVal, _ := recordMap["serial"].(int64)
|
||||
wouldReplace = true
|
||||
serial := types.Uint40{}
|
||||
serial.Set(uint64(serialVal))
|
||||
serials = append(serials, &serial)
|
||||
}
|
||||
|
||||
return wouldReplace, serials, nil
|
||||
}
|
||||
108
pkg/neo4j/schema.go
Normal file
108
pkg/neo4j/schema.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package neo4j
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// applySchema creates Neo4j constraints and indexes for Nostr events
|
||||
// Neo4j uses Cypher queries to define schema constraints and indexes
|
||||
func (n *N) applySchema(ctx context.Context) error {
|
||||
n.Logger.Infof("applying Nostr schema to neo4j")
|
||||
|
||||
// Create constraints and indexes using Cypher queries
|
||||
// Constraints ensure uniqueness and are automatically indexed
|
||||
constraints := []string{
|
||||
// Unique constraint on Event.id (event ID must be unique)
|
||||
"CREATE CONSTRAINT event_id_unique IF NOT EXISTS FOR (e:Event) REQUIRE e.id IS UNIQUE",
|
||||
|
||||
// Unique constraint on Author.pubkey (author public key must be unique)
|
||||
"CREATE CONSTRAINT author_pubkey_unique IF NOT EXISTS FOR (a:Author) REQUIRE a.pubkey IS UNIQUE",
|
||||
|
||||
// Unique constraint on Marker.key (marker key must be unique)
|
||||
"CREATE CONSTRAINT marker_key_unique IF NOT EXISTS FOR (m:Marker) REQUIRE m.key IS UNIQUE",
|
||||
}
|
||||
|
||||
// Additional indexes for query optimization
|
||||
indexes := []string{
|
||||
// Index on Event.kind for kind-based queries
|
||||
"CREATE INDEX event_kind IF NOT EXISTS FOR (e:Event) ON (e.kind)",
|
||||
|
||||
// Index on Event.created_at for time-range queries
|
||||
"CREATE INDEX event_created_at IF NOT EXISTS FOR (e:Event) ON (e.created_at)",
|
||||
|
||||
// Index on Event.serial for serial-based lookups
|
||||
"CREATE INDEX event_serial IF NOT EXISTS FOR (e:Event) ON (e.serial)",
|
||||
|
||||
// Composite index for common query patterns (kind + created_at)
|
||||
"CREATE INDEX event_kind_created_at IF NOT EXISTS FOR (e:Event) ON (e.kind, e.created_at)",
|
||||
|
||||
// Index on Tag.type for tag-type queries
|
||||
"CREATE INDEX tag_type IF NOT EXISTS FOR (t:Tag) ON (t.type)",
|
||||
|
||||
// Index on Tag.value for tag-value queries
|
||||
"CREATE INDEX tag_value IF NOT EXISTS FOR (t:Tag) ON (t.value)",
|
||||
|
||||
// Composite index for tag queries (type + value)
|
||||
"CREATE INDEX tag_type_value IF NOT EXISTS FOR (t:Tag) ON (t.type, t.value)",
|
||||
}
|
||||
|
||||
// Execute all constraint creation queries
|
||||
for _, constraint := range constraints {
|
||||
if _, err := n.ExecuteWrite(ctx, constraint, nil); err != nil {
|
||||
return fmt.Errorf("failed to create constraint: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Execute all index creation queries
|
||||
for _, index := range indexes {
|
||||
if _, err := n.ExecuteWrite(ctx, index, nil); err != nil {
|
||||
return fmt.Errorf("failed to create index: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
n.Logger.Infof("schema applied successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// dropAll drops all data from neo4j (useful for testing)
|
||||
func (n *N) dropAll(ctx context.Context) error {
|
||||
n.Logger.Warningf("dropping all data from neo4j")
|
||||
|
||||
// Delete all nodes and relationships
|
||||
_, err := n.ExecuteWrite(ctx, "MATCH (n) DETACH DELETE n", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to drop all data: %w", err)
|
||||
}
|
||||
|
||||
// Drop all constraints
|
||||
constraints := []string{
|
||||
"DROP CONSTRAINT event_id_unique IF EXISTS",
|
||||
"DROP CONSTRAINT author_pubkey_unique IF EXISTS",
|
||||
"DROP CONSTRAINT marker_key_unique IF EXISTS",
|
||||
}
|
||||
|
||||
for _, constraint := range constraints {
|
||||
_, _ = n.ExecuteWrite(ctx, constraint, nil)
|
||||
// Ignore errors as constraints may not exist
|
||||
}
|
||||
|
||||
// Drop all indexes
|
||||
indexes := []string{
|
||||
"DROP INDEX event_kind IF EXISTS",
|
||||
"DROP INDEX event_created_at IF EXISTS",
|
||||
"DROP INDEX event_serial IF EXISTS",
|
||||
"DROP INDEX event_kind_created_at IF EXISTS",
|
||||
"DROP INDEX tag_type IF EXISTS",
|
||||
"DROP INDEX tag_value IF EXISTS",
|
||||
"DROP INDEX tag_type_value IF EXISTS",
|
||||
}
|
||||
|
||||
for _, index := range indexes {
|
||||
_, _ = n.ExecuteWrite(ctx, index, nil)
|
||||
// Ignore errors as indexes may not exist
|
||||
}
|
||||
|
||||
// Reapply schema after dropping
|
||||
return n.applySchema(ctx)
|
||||
}
|
||||
113
pkg/neo4j/serial.go
Normal file
113
pkg/neo4j/serial.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package neo4j
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Serial number management
|
||||
// We use a special Marker node in Neo4j to track the next available serial number
|
||||
|
||||
const serialCounterKey = "serial_counter"
|
||||
|
||||
var (
|
||||
serialMutex sync.Mutex
|
||||
)
|
||||
|
||||
// getNextSerial atomically increments and returns the next serial number
|
||||
func (n *N) getNextSerial() (uint64, error) {
|
||||
serialMutex.Lock()
|
||||
defer serialMutex.Unlock()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Query current serial value
|
||||
cypher := "MATCH (m:Marker {key: $key}) RETURN m.value AS value"
|
||||
params := map[string]any{"key": serialCounterKey}
|
||||
|
||||
result, err := n.ExecuteRead(ctx, cypher, params)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to query serial counter: %w", err)
|
||||
}
|
||||
|
||||
neo4jResult, ok := result.(interface {
|
||||
Next(context.Context) bool
|
||||
Record() *interface{}
|
||||
Err() error
|
||||
})
|
||||
if !ok {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
var currentSerial uint64 = 1
|
||||
if neo4jResult.Next(ctx) {
|
||||
record := neo4jResult.Record()
|
||||
if record != nil {
|
||||
recordMap, ok := (*record).(map[string]any)
|
||||
if ok {
|
||||
if value, ok := recordMap["value"].(int64); ok {
|
||||
currentSerial = uint64(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Increment serial
|
||||
nextSerial := currentSerial + 1
|
||||
|
||||
// Update counter
|
||||
updateCypher := `
|
||||
MERGE (m:Marker {key: $key})
|
||||
SET m.value = $value`
|
||||
updateParams := map[string]any{
|
||||
"key": serialCounterKey,
|
||||
"value": int64(nextSerial),
|
||||
}
|
||||
|
||||
_, err = n.ExecuteWrite(ctx, updateCypher, updateParams)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to update serial counter: %w", err)
|
||||
}
|
||||
|
||||
return currentSerial, nil
|
||||
}
|
||||
|
||||
// initSerialCounter initializes the serial counter if it doesn't exist
|
||||
func (n *N) initSerialCounter() error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Check if counter exists
|
||||
cypher := "MATCH (m:Marker {key: $key}) RETURN m.value AS value"
|
||||
params := map[string]any{"key": serialCounterKey}
|
||||
|
||||
result, err := n.ExecuteRead(ctx, cypher, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check serial counter: %w", err)
|
||||
}
|
||||
|
||||
neo4jResult, ok := result.(interface {
|
||||
Next(context.Context) bool
|
||||
Record() *interface{}
|
||||
Err() error
|
||||
})
|
||||
if ok && neo4jResult.Next(ctx) {
|
||||
// Counter already exists
|
||||
return nil
|
||||
}
|
||||
|
||||
// Initialize counter at 1
|
||||
initCypher := "CREATE (m:Marker {key: $key, value: $value})"
|
||||
initParams := map[string]any{
|
||||
"key": serialCounterKey,
|
||||
"value": int64(1),
|
||||
}
|
||||
|
||||
_, err = n.ExecuteWrite(ctx, initCypher, initParams)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize serial counter: %w", err)
|
||||
}
|
||||
|
||||
n.Logger.Infof("initialized serial counter")
|
||||
return nil
|
||||
}
|
||||
181
pkg/neo4j/subscriptions.go
Normal file
181
pkg/neo4j/subscriptions.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package neo4j
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"next.orly.dev/pkg/database"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
)
|
||||
|
||||
// Subscription and payment methods
|
||||
// Simplified implementation using marker-based storage via Badger
|
||||
// For production graph-based storage, these could use Neo4j nodes with relationships
|
||||
|
||||
// GetSubscription retrieves subscription information for a pubkey
|
||||
func (n *N) GetSubscription(pubkey []byte) (*database.Subscription, error) {
|
||||
key := "sub_" + hex.Enc(pubkey)
|
||||
data, err := n.GetMarker(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var sub database.Subscription
|
||||
if err := json.Unmarshal(data, &sub); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal subscription: %w", err)
|
||||
}
|
||||
|
||||
return &sub, nil
|
||||
}
|
||||
|
||||
// IsSubscriptionActive checks if a pubkey has an active subscription
|
||||
func (n *N) IsSubscriptionActive(pubkey []byte) (bool, error) {
|
||||
sub, err := n.GetSubscription(pubkey)
|
||||
if err != nil {
|
||||
return false, nil // No subscription = not active
|
||||
}
|
||||
|
||||
return sub.PaidUntil.After(time.Now()), nil
|
||||
}
|
||||
|
||||
// ExtendSubscription extends a subscription by the specified number of days
|
||||
func (n *N) ExtendSubscription(pubkey []byte, days int) error {
|
||||
key := "sub_" + hex.Enc(pubkey)
|
||||
|
||||
// Get existing subscription or create new
|
||||
var sub database.Subscription
|
||||
data, err := n.GetMarker(key)
|
||||
if err == nil {
|
||||
if err := json.Unmarshal(data, &sub); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal subscription: %w", err)
|
||||
}
|
||||
} else {
|
||||
// New subscription - set trial period
|
||||
sub.TrialEnd = time.Now()
|
||||
sub.PaidUntil = time.Now()
|
||||
}
|
||||
|
||||
// Extend expiration
|
||||
if sub.PaidUntil.Before(time.Now()) {
|
||||
sub.PaidUntil = time.Now()
|
||||
}
|
||||
sub.PaidUntil = sub.PaidUntil.Add(time.Duration(days) * 24 * time.Hour)
|
||||
|
||||
// Save
|
||||
data, err = json.Marshal(sub)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal subscription: %w", err)
|
||||
}
|
||||
|
||||
return n.SetMarker(key, data)
|
||||
}
|
||||
|
||||
// RecordPayment records a payment for subscription extension
|
||||
func (n *N) RecordPayment(
|
||||
pubkey []byte, amount int64, invoice, preimage string,
|
||||
) error {
|
||||
// Store payment in payments list
|
||||
key := "payments_" + hex.Enc(pubkey)
|
||||
|
||||
var payments []database.Payment
|
||||
data, err := n.GetMarker(key)
|
||||
if err == nil {
|
||||
if err := json.Unmarshal(data, &payments); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal payments: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
payment := database.Payment{
|
||||
Amount: amount,
|
||||
Timestamp: time.Now(),
|
||||
Invoice: invoice,
|
||||
Preimage: preimage,
|
||||
}
|
||||
|
||||
payments = append(payments, payment)
|
||||
|
||||
data, err = json.Marshal(payments)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal payments: %w", err)
|
||||
}
|
||||
|
||||
return n.SetMarker(key, data)
|
||||
}
|
||||
|
||||
// GetPaymentHistory retrieves payment history for a pubkey
|
||||
func (n *N) GetPaymentHistory(pubkey []byte) ([]database.Payment, error) {
|
||||
key := "payments_" + hex.Enc(pubkey)
|
||||
|
||||
data, err := n.GetMarker(key)
|
||||
if err != nil {
|
||||
return nil, nil // No payments = empty list
|
||||
}
|
||||
|
||||
var payments []database.Payment
|
||||
if err := json.Unmarshal(data, &payments); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal payments: %w", err)
|
||||
}
|
||||
|
||||
return payments, nil
|
||||
}
|
||||
|
||||
// ExtendBlossomSubscription extends a Blossom storage subscription
|
||||
func (n *N) ExtendBlossomSubscription(
|
||||
pubkey []byte, tier string, storageMB int64, daysExtended int,
|
||||
) error {
|
||||
key := "blossom_" + hex.Enc(pubkey)
|
||||
|
||||
// Simple implementation - just store tier and expiry
|
||||
data := map[string]interface{}{
|
||||
"tier": tier,
|
||||
"storageMB": storageMB,
|
||||
"extended": daysExtended,
|
||||
"updated": time.Now(),
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal blossom subscription: %w", err)
|
||||
}
|
||||
|
||||
return n.SetMarker(key, jsonData)
|
||||
}
|
||||
|
||||
// GetBlossomStorageQuota retrieves the storage quota for a pubkey
|
||||
func (n *N) GetBlossomStorageQuota(pubkey []byte) (quotaMB int64, err error) {
|
||||
key := "blossom_" + hex.Enc(pubkey)
|
||||
|
||||
data, err := n.GetMarker(key)
|
||||
if err != nil {
|
||||
return 0, nil // No subscription = 0 quota
|
||||
}
|
||||
|
||||
var subData map[string]interface{}
|
||||
if err := json.Unmarshal(data, &subData); err != nil {
|
||||
return 0, fmt.Errorf("failed to unmarshal blossom data: %w", err)
|
||||
}
|
||||
|
||||
if storageMB, ok := subData["storageMB"].(float64); ok {
|
||||
return int64(storageMB), nil
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// IsFirstTimeUser checks if this is the first time a user is accessing the relay
|
||||
func (n *N) IsFirstTimeUser(pubkey []byte) (bool, error) {
|
||||
key := "first_seen_" + hex.Enc(pubkey)
|
||||
|
||||
// If marker exists, not first time
|
||||
if n.HasMarker(key) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Mark as seen
|
||||
if err := n.SetMarker(key, []byte{1}); err != nil {
|
||||
return true, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
15
pkg/neo4j/testmain_test.go
Normal file
15
pkg/neo4j/testmain_test.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package neo4j
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// skipIfNeo4jNotAvailable skips the test if Neo4j is not available
|
||||
func skipIfNeo4jNotAvailable(t *testing.T) {
|
||||
// Check if Neo4j connection details are provided
|
||||
uri := os.Getenv("ORLY_NEO4J_URI")
|
||||
if uri == "" {
|
||||
t.Skip("Neo4j not available (set ORLY_NEO4J_URI to enable tests)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user