initial draft of neo4j database driver

This commit is contained in:
2025-11-17 08:19:44 +00:00
parent beed174e83
commit 86481a42e8
22 changed files with 3259 additions and 14 deletions

View File

@@ -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
View 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
View 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
View 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
View 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
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View 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)")
}
}