59 changed files with 8651 additions and 38 deletions
@ -0,0 +1,555 @@ |
|||||||
|
package neo4j |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"os" |
||||||
|
"testing" |
||||||
|
|
||||||
|
"git.mleku.dev/mleku/nostr/encoders/event" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/filter" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/hex" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/kind" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/tag" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/timestamp" |
||||||
|
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k" |
||||||
|
) |
||||||
|
|
||||||
|
func TestDeleteEvent(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
signer, err := p8k.New() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create signer: %v", err) |
||||||
|
} |
||||||
|
if err := signer.Generate(); err != nil { |
||||||
|
t.Fatalf("Failed to generate keypair: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create and save event
|
||||||
|
ev := event.New() |
||||||
|
ev.Pubkey = signer.Pub() |
||||||
|
ev.CreatedAt = timestamp.Now().V |
||||||
|
ev.Kind = 1 |
||||||
|
ev.Content = []byte("Event to be deleted") |
||||||
|
|
||||||
|
if err := ev.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, ev); err != nil { |
||||||
|
t.Fatalf("Failed to save event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Verify event exists
|
||||||
|
evs, err := db.QueryEvents(ctx, &filter.F{ |
||||||
|
Ids: tag.NewFromBytesSlice(ev.ID), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query event: %v", err) |
||||||
|
} |
||||||
|
if len(evs) != 1 { |
||||||
|
t.Fatalf("Expected 1 event before deletion, got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
// Delete the event
|
||||||
|
if err := db.DeleteEvent(ctx, ev.ID[:]); err != nil { |
||||||
|
t.Fatalf("Failed to delete event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Verify event is deleted
|
||||||
|
evs, err = db.QueryEvents(ctx, &filter.F{ |
||||||
|
Ids: tag.NewFromBytesSlice(ev.ID), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query after deletion: %v", err) |
||||||
|
} |
||||||
|
if len(evs) != 0 { |
||||||
|
t.Fatalf("Expected 0 events after deletion, got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ DeleteEvent successfully removed event") |
||||||
|
} |
||||||
|
|
||||||
|
func TestDeleteEventBySerial(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
signer, err := p8k.New() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create signer: %v", err) |
||||||
|
} |
||||||
|
if err := signer.Generate(); err != nil { |
||||||
|
t.Fatalf("Failed to generate keypair: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create and save event
|
||||||
|
ev := event.New() |
||||||
|
ev.Pubkey = signer.Pub() |
||||||
|
ev.CreatedAt = timestamp.Now().V |
||||||
|
ev.Kind = 1 |
||||||
|
ev.Content = []byte("Event to be deleted by serial") |
||||||
|
|
||||||
|
if err := ev.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, ev); err != nil { |
||||||
|
t.Fatalf("Failed to save event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Get serial
|
||||||
|
serial, err := db.GetSerialById(ev.ID[:]) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get serial: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Delete by serial
|
||||||
|
if err := db.DeleteEventBySerial(ctx, serial, ev); err != nil { |
||||||
|
t.Fatalf("Failed to delete event by serial: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Verify event is deleted
|
||||||
|
evs, err := db.QueryEvents(ctx, &filter.F{ |
||||||
|
Ids: tag.NewFromBytesSlice(ev.ID), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query after deletion: %v", err) |
||||||
|
} |
||||||
|
if len(evs) != 0 { |
||||||
|
t.Fatalf("Expected 0 events after deletion, got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ DeleteEventBySerial successfully removed event") |
||||||
|
} |
||||||
|
|
||||||
|
func TestProcessDelete_AuthorCanDeleteOwnEvent(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
signer, err := p8k.New() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create signer: %v", err) |
||||||
|
} |
||||||
|
if err := signer.Generate(); err != nil { |
||||||
|
t.Fatalf("Failed to generate keypair: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create and save original event
|
||||||
|
originalEvent := event.New() |
||||||
|
originalEvent.Pubkey = signer.Pub() |
||||||
|
originalEvent.CreatedAt = timestamp.Now().V |
||||||
|
originalEvent.Kind = 1 |
||||||
|
originalEvent.Content = []byte("This event will be deleted via kind 5") |
||||||
|
|
||||||
|
if err := originalEvent.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, originalEvent); err != nil { |
||||||
|
t.Fatalf("Failed to save event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create kind 5 deletion event
|
||||||
|
deleteEvent := event.New() |
||||||
|
deleteEvent.Pubkey = signer.Pub() // Same author
|
||||||
|
deleteEvent.CreatedAt = timestamp.Now().V + 1 |
||||||
|
deleteEvent.Kind = kind.Deletion.K |
||||||
|
deleteEvent.Content = []byte("Deleting my event") |
||||||
|
deleteEvent.Tags = tag.NewS( |
||||||
|
tag.NewFromAny("e", hex.Enc(originalEvent.ID[:])), |
||||||
|
) |
||||||
|
|
||||||
|
if err := deleteEvent.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign delete event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Process deletion (no admins)
|
||||||
|
if err := db.ProcessDelete(deleteEvent, nil); err != nil { |
||||||
|
t.Fatalf("Failed to process delete: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Verify original event is deleted
|
||||||
|
evs, err := db.QueryEvents(ctx, &filter.F{ |
||||||
|
Ids: tag.NewFromBytesSlice(originalEvent.ID), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query after deletion: %v", err) |
||||||
|
} |
||||||
|
if len(evs) != 0 { |
||||||
|
t.Fatalf("Expected 0 events after deletion, got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ ProcessDelete allowed author to delete own event") |
||||||
|
} |
||||||
|
|
||||||
|
func TestProcessDelete_OtherUserCannotDelete(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
alice, _ := p8k.New() |
||||||
|
alice.Generate() |
||||||
|
|
||||||
|
bob, _ := p8k.New() |
||||||
|
bob.Generate() |
||||||
|
|
||||||
|
// Alice creates an event
|
||||||
|
aliceEvent := event.New() |
||||||
|
aliceEvent.Pubkey = alice.Pub() |
||||||
|
aliceEvent.CreatedAt = timestamp.Now().V |
||||||
|
aliceEvent.Kind = 1 |
||||||
|
aliceEvent.Content = []byte("Alice's event") |
||||||
|
|
||||||
|
if err := aliceEvent.Sign(alice); err != nil { |
||||||
|
t.Fatalf("Failed to sign event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, aliceEvent); err != nil { |
||||||
|
t.Fatalf("Failed to save event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Bob tries to delete Alice's event
|
||||||
|
deleteEvent := event.New() |
||||||
|
deleteEvent.Pubkey = bob.Pub() // Different author
|
||||||
|
deleteEvent.CreatedAt = timestamp.Now().V + 1 |
||||||
|
deleteEvent.Kind = kind.Deletion.K |
||||||
|
deleteEvent.Tags = tag.NewS( |
||||||
|
tag.NewFromAny("e", hex.Enc(aliceEvent.ID[:])), |
||||||
|
) |
||||||
|
|
||||||
|
if err := deleteEvent.Sign(bob); err != nil { |
||||||
|
t.Fatalf("Failed to sign delete event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Process deletion (Bob is not an admin)
|
||||||
|
_ = db.ProcessDelete(deleteEvent, nil) |
||||||
|
|
||||||
|
// Verify Alice's event still exists
|
||||||
|
evs, err := db.QueryEvents(ctx, &filter.F{ |
||||||
|
Ids: tag.NewFromBytesSlice(aliceEvent.ID), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query: %v", err) |
||||||
|
} |
||||||
|
if len(evs) != 1 { |
||||||
|
t.Fatalf("Expected Alice's event to still exist, got %d events", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ ProcessDelete correctly prevented unauthorized deletion") |
||||||
|
} |
||||||
|
|
||||||
|
func TestProcessDelete_AdminCanDeleteAnyEvent(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
alice, _ := p8k.New() |
||||||
|
alice.Generate() |
||||||
|
|
||||||
|
admin, _ := p8k.New() |
||||||
|
admin.Generate() |
||||||
|
|
||||||
|
// Alice creates an event
|
||||||
|
aliceEvent := event.New() |
||||||
|
aliceEvent.Pubkey = alice.Pub() |
||||||
|
aliceEvent.CreatedAt = timestamp.Now().V |
||||||
|
aliceEvent.Kind = 1 |
||||||
|
aliceEvent.Content = []byte("Alice's event to be deleted by admin") |
||||||
|
|
||||||
|
if err := aliceEvent.Sign(alice); err != nil { |
||||||
|
t.Fatalf("Failed to sign event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, aliceEvent); err != nil { |
||||||
|
t.Fatalf("Failed to save event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Admin creates deletion event
|
||||||
|
deleteEvent := event.New() |
||||||
|
deleteEvent.Pubkey = admin.Pub() |
||||||
|
deleteEvent.CreatedAt = timestamp.Now().V + 1 |
||||||
|
deleteEvent.Kind = kind.Deletion.K |
||||||
|
deleteEvent.Tags = tag.NewS( |
||||||
|
tag.NewFromAny("e", hex.Enc(aliceEvent.ID[:])), |
||||||
|
) |
||||||
|
|
||||||
|
if err := deleteEvent.Sign(admin); err != nil { |
||||||
|
t.Fatalf("Failed to sign delete event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Process deletion with admin pubkey
|
||||||
|
adminPubkeys := [][]byte{admin.Pub()} |
||||||
|
if err := db.ProcessDelete(deleteEvent, adminPubkeys); err != nil { |
||||||
|
t.Fatalf("Failed to process delete: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Verify Alice's event is deleted
|
||||||
|
evs, err := db.QueryEvents(ctx, &filter.F{ |
||||||
|
Ids: tag.NewFromBytesSlice(aliceEvent.ID), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query: %v", err) |
||||||
|
} |
||||||
|
if len(evs) != 0 { |
||||||
|
t.Fatalf("Expected Alice's event to be deleted, got %d events", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ ProcessDelete allowed admin to delete event") |
||||||
|
} |
||||||
|
|
||||||
|
func TestCheckForDeleted(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
signer, err := p8k.New() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create signer: %v", err) |
||||||
|
} |
||||||
|
if err := signer.Generate(); err != nil { |
||||||
|
t.Fatalf("Failed to generate keypair: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create target event
|
||||||
|
targetEvent := event.New() |
||||||
|
targetEvent.Pubkey = signer.Pub() |
||||||
|
targetEvent.CreatedAt = timestamp.Now().V |
||||||
|
targetEvent.Kind = 1 |
||||||
|
targetEvent.Content = []byte("Target event") |
||||||
|
|
||||||
|
if err := targetEvent.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign target event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, targetEvent); err != nil { |
||||||
|
t.Fatalf("Failed to save target event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Check that event is not deleted (no deletion event exists)
|
||||||
|
err = db.CheckForDeleted(targetEvent, nil) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Expected no error for non-deleted event, got: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create deletion event that references target
|
||||||
|
deleteEvent := event.New() |
||||||
|
deleteEvent.Pubkey = signer.Pub() |
||||||
|
deleteEvent.CreatedAt = timestamp.Now().V + 1 |
||||||
|
deleteEvent.Kind = kind.Deletion.K |
||||||
|
deleteEvent.Tags = tag.NewS( |
||||||
|
tag.NewFromAny("e", hex.Enc(targetEvent.ID[:])), |
||||||
|
) |
||||||
|
|
||||||
|
if err := deleteEvent.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign delete event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, deleteEvent); err != nil { |
||||||
|
t.Fatalf("Failed to save delete event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Now check should return error (event has been deleted)
|
||||||
|
err = db.CheckForDeleted(targetEvent, nil) |
||||||
|
if err == nil { |
||||||
|
t.Fatal("Expected error for deleted event") |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ CheckForDeleted correctly detected deletion event") |
||||||
|
} |
||||||
|
|
||||||
|
func TestReplaceableEventDeletion(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
signer, err := p8k.New() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create signer: %v", err) |
||||||
|
} |
||||||
|
if err := signer.Generate(); err != nil { |
||||||
|
t.Fatalf("Failed to generate keypair: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create replaceable event (kind 0 - profile)
|
||||||
|
profileEvent := event.New() |
||||||
|
profileEvent.Pubkey = signer.Pub() |
||||||
|
profileEvent.CreatedAt = timestamp.Now().V |
||||||
|
profileEvent.Kind = 0 |
||||||
|
profileEvent.Content = []byte(`{"name":"Test User"}`) |
||||||
|
|
||||||
|
if err := profileEvent.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, profileEvent); err != nil { |
||||||
|
t.Fatalf("Failed to save event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Verify event exists
|
||||||
|
evs, err := db.QueryEvents(ctx, &filter.F{ |
||||||
|
Kinds: kind.NewS(kind.New(0)), |
||||||
|
Authors: tag.NewFromBytesSlice(signer.Pub()), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query: %v", err) |
||||||
|
} |
||||||
|
if len(evs) != 1 { |
||||||
|
t.Fatalf("Expected 1 profile event, got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
// Create a newer replaceable event (replaces the old one)
|
||||||
|
newerProfileEvent := event.New() |
||||||
|
newerProfileEvent.Pubkey = signer.Pub() |
||||||
|
newerProfileEvent.CreatedAt = timestamp.Now().V + 100 |
||||||
|
newerProfileEvent.Kind = 0 |
||||||
|
newerProfileEvent.Content = []byte(`{"name":"Updated User"}`) |
||||||
|
|
||||||
|
if err := newerProfileEvent.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign newer event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, newerProfileEvent); err != nil { |
||||||
|
t.Fatalf("Failed to save newer event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Query should return only the newer event
|
||||||
|
evs, err = db.QueryEvents(ctx, &filter.F{ |
||||||
|
Kinds: kind.NewS(kind.New(0)), |
||||||
|
Authors: tag.NewFromBytesSlice(signer.Pub()), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query: %v", err) |
||||||
|
} |
||||||
|
if len(evs) != 1 { |
||||||
|
t.Fatalf("Expected 1 profile event after replacement, got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
if hex.Enc(evs[0].ID[:]) != hex.Enc(newerProfileEvent.ID[:]) { |
||||||
|
t.Fatal("Expected newer profile event to be returned") |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Replaceable event correctly replaced by newer version") |
||||||
|
} |
||||||
@ -0,0 +1,570 @@ |
|||||||
|
package neo4j |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"context" |
||||||
|
"encoding/json" |
||||||
|
"os" |
||||||
|
"testing" |
||||||
|
"time" |
||||||
|
|
||||||
|
"git.mleku.dev/mleku/nostr/encoders/event" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/filter" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/hex" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/kind" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/tag" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/timestamp" |
||||||
|
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k" |
||||||
|
) |
||||||
|
|
||||||
|
func TestExpiration_SaveEventWithExpiration(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
signer, err := p8k.New() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create signer: %v", err) |
||||||
|
} |
||||||
|
if err := signer.Generate(); err != nil { |
||||||
|
t.Fatalf("Failed to generate keypair: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create event with expiration tag (expires in 1 hour)
|
||||||
|
futureExpiration := time.Now().Add(1 * time.Hour).Unix() |
||||||
|
|
||||||
|
ev := event.New() |
||||||
|
ev.Pubkey = signer.Pub() |
||||||
|
ev.CreatedAt = timestamp.Now().V |
||||||
|
ev.Kind = 1 |
||||||
|
ev.Content = []byte("Event with expiration") |
||||||
|
ev.Tags = tag.NewS(tag.NewFromAny("expiration", timestamp.From(futureExpiration).String())) |
||||||
|
|
||||||
|
if err := ev.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, ev); err != nil { |
||||||
|
t.Fatalf("Failed to save event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Query the event to verify it was saved
|
||||||
|
evs, err := db.QueryEvents(ctx, &filter.F{ |
||||||
|
Ids: tag.NewFromBytesSlice(ev.ID), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if len(evs) != 1 { |
||||||
|
t.Fatalf("Expected 1 event, got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Event with expiration tag saved successfully") |
||||||
|
} |
||||||
|
|
||||||
|
func TestExpiration_DeleteExpiredEvents(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
signer, err := p8k.New() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create signer: %v", err) |
||||||
|
} |
||||||
|
if err := signer.Generate(); err != nil { |
||||||
|
t.Fatalf("Failed to generate keypair: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create an expired event (expired 1 hour ago)
|
||||||
|
pastExpiration := time.Now().Add(-1 * time.Hour).Unix() |
||||||
|
|
||||||
|
expiredEv := event.New() |
||||||
|
expiredEv.Pubkey = signer.Pub() |
||||||
|
expiredEv.CreatedAt = timestamp.Now().V - 7200 // 2 hours ago
|
||||||
|
expiredEv.Kind = 1 |
||||||
|
expiredEv.Content = []byte("Expired event") |
||||||
|
expiredEv.Tags = tag.NewS(tag.NewFromAny("expiration", timestamp.From(pastExpiration).String())) |
||||||
|
|
||||||
|
if err := expiredEv.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign expired event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, expiredEv); err != nil { |
||||||
|
t.Fatalf("Failed to save expired event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create a non-expired event (expires in 1 hour)
|
||||||
|
futureExpiration := time.Now().Add(1 * time.Hour).Unix() |
||||||
|
|
||||||
|
validEv := event.New() |
||||||
|
validEv.Pubkey = signer.Pub() |
||||||
|
validEv.CreatedAt = timestamp.Now().V |
||||||
|
validEv.Kind = 1 |
||||||
|
validEv.Content = []byte("Valid event") |
||||||
|
validEv.Tags = tag.NewS(tag.NewFromAny("expiration", timestamp.From(futureExpiration).String())) |
||||||
|
|
||||||
|
if err := validEv.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign valid event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, validEv); err != nil { |
||||||
|
t.Fatalf("Failed to save valid event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create an event without expiration
|
||||||
|
permanentEv := event.New() |
||||||
|
permanentEv.Pubkey = signer.Pub() |
||||||
|
permanentEv.CreatedAt = timestamp.Now().V + 1 |
||||||
|
permanentEv.Kind = 1 |
||||||
|
permanentEv.Content = []byte("Permanent event (no expiration)") |
||||||
|
|
||||||
|
if err := permanentEv.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign permanent event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, permanentEv); err != nil { |
||||||
|
t.Fatalf("Failed to save permanent event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Verify all 3 events exist
|
||||||
|
evs, err := db.QueryEvents(ctx, &filter.F{ |
||||||
|
Authors: tag.NewFromBytesSlice(signer.Pub()), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query events: %v", err) |
||||||
|
} |
||||||
|
if len(evs) != 3 { |
||||||
|
t.Fatalf("Expected 3 events before deletion, got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
// Run DeleteExpired
|
||||||
|
db.DeleteExpired() |
||||||
|
|
||||||
|
// Verify only expired event was deleted
|
||||||
|
evs, err = db.QueryEvents(ctx, &filter.F{ |
||||||
|
Authors: tag.NewFromBytesSlice(signer.Pub()), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query events after deletion: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if len(evs) != 2 { |
||||||
|
t.Fatalf("Expected 2 events after deletion (expired removed), got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
// Verify the correct events remain
|
||||||
|
foundValid := false |
||||||
|
foundPermanent := false |
||||||
|
for _, ev := range evs { |
||||||
|
if hex.Enc(ev.ID[:]) == hex.Enc(validEv.ID[:]) { |
||||||
|
foundValid = true |
||||||
|
} |
||||||
|
if hex.Enc(ev.ID[:]) == hex.Enc(permanentEv.ID[:]) { |
||||||
|
foundPermanent = true |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if !foundValid { |
||||||
|
t.Fatal("Valid event (with future expiration) was incorrectly deleted") |
||||||
|
} |
||||||
|
if !foundPermanent { |
||||||
|
t.Fatal("Permanent event (no expiration) was incorrectly deleted") |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ DeleteExpired correctly removed only expired events") |
||||||
|
} |
||||||
|
|
||||||
|
func TestExpiration_NoExpirationTag(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
signer, err := p8k.New() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create signer: %v", err) |
||||||
|
} |
||||||
|
if err := signer.Generate(); err != nil { |
||||||
|
t.Fatalf("Failed to generate keypair: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create event without expiration tag
|
||||||
|
ev := event.New() |
||||||
|
ev.Pubkey = signer.Pub() |
||||||
|
ev.CreatedAt = timestamp.Now().V |
||||||
|
ev.Kind = 1 |
||||||
|
ev.Content = []byte("Event without expiration") |
||||||
|
|
||||||
|
if err := ev.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, ev); err != nil { |
||||||
|
t.Fatalf("Failed to save event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Run DeleteExpired - event should not be deleted
|
||||||
|
db.DeleteExpired() |
||||||
|
|
||||||
|
// Verify event still exists
|
||||||
|
evs, err := db.QueryEvents(ctx, &filter.F{ |
||||||
|
Ids: tag.NewFromBytesSlice(ev.ID), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if len(evs) != 1 { |
||||||
|
t.Fatalf("Expected 1 event (no expiration should not be deleted), got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Events without expiration tag are not deleted") |
||||||
|
} |
||||||
|
|
||||||
|
func TestExport_AllEvents(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
signer, err := p8k.New() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create signer: %v", err) |
||||||
|
} |
||||||
|
if err := signer.Generate(); err != nil { |
||||||
|
t.Fatalf("Failed to generate keypair: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create and save some events
|
||||||
|
for i := 0; i < 5; i++ { |
||||||
|
ev := event.New() |
||||||
|
ev.Pubkey = signer.Pub() |
||||||
|
ev.CreatedAt = timestamp.Now().V + int64(i) |
||||||
|
ev.Kind = 1 |
||||||
|
ev.Content = []byte("Test event for export") |
||||||
|
ev.Tags = tag.NewS(tag.NewFromAny("t", "test")) |
||||||
|
|
||||||
|
if err := ev.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, ev); err != nil { |
||||||
|
t.Fatalf("Failed to save event: %v", err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Export all events
|
||||||
|
var buf bytes.Buffer |
||||||
|
db.Export(ctx, &buf) |
||||||
|
|
||||||
|
// Parse the exported JSONL
|
||||||
|
lines := bytes.Split(buf.Bytes(), []byte("\n")) |
||||||
|
validLines := 0 |
||||||
|
for _, line := range lines { |
||||||
|
if len(line) == 0 { |
||||||
|
continue |
||||||
|
} |
||||||
|
var ev event.E |
||||||
|
if err := json.Unmarshal(line, &ev); err != nil { |
||||||
|
t.Fatalf("Failed to parse exported event: %v", err) |
||||||
|
} |
||||||
|
validLines++ |
||||||
|
} |
||||||
|
|
||||||
|
if validLines != 5 { |
||||||
|
t.Fatalf("Expected 5 exported events, got %d", validLines) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Export all events returned %d events in JSONL format", validLines) |
||||||
|
} |
||||||
|
|
||||||
|
func TestExport_FilterByPubkey(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create two signers
|
||||||
|
alice, _ := p8k.New() |
||||||
|
alice.Generate() |
||||||
|
|
||||||
|
bob, _ := p8k.New() |
||||||
|
bob.Generate() |
||||||
|
|
||||||
|
baseTs := timestamp.Now().V |
||||||
|
|
||||||
|
// Create events from Alice
|
||||||
|
for i := 0; i < 3; i++ { |
||||||
|
ev := event.New() |
||||||
|
ev.Pubkey = alice.Pub() |
||||||
|
ev.CreatedAt = baseTs + int64(i) |
||||||
|
ev.Kind = 1 |
||||||
|
ev.Content = []byte("Alice's event") |
||||||
|
|
||||||
|
if err := ev.Sign(alice); err != nil { |
||||||
|
t.Fatalf("Failed to sign event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, ev); err != nil { |
||||||
|
t.Fatalf("Failed to save event: %v", err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Create events from Bob
|
||||||
|
for i := 0; i < 2; i++ { |
||||||
|
ev := event.New() |
||||||
|
ev.Pubkey = bob.Pub() |
||||||
|
ev.CreatedAt = baseTs + int64(i) + 10 |
||||||
|
ev.Kind = 1 |
||||||
|
ev.Content = []byte("Bob's event") |
||||||
|
|
||||||
|
if err := ev.Sign(bob); err != nil { |
||||||
|
t.Fatalf("Failed to sign event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, ev); err != nil { |
||||||
|
t.Fatalf("Failed to save event: %v", err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Export only Alice's events
|
||||||
|
var buf bytes.Buffer |
||||||
|
db.Export(ctx, &buf, alice.Pub()) |
||||||
|
|
||||||
|
// Parse the exported JSONL
|
||||||
|
lines := bytes.Split(buf.Bytes(), []byte("\n")) |
||||||
|
validLines := 0 |
||||||
|
alicePubkey := hex.Enc(alice.Pub()) |
||||||
|
for _, line := range lines { |
||||||
|
if len(line) == 0 { |
||||||
|
continue |
||||||
|
} |
||||||
|
var ev event.E |
||||||
|
if err := json.Unmarshal(line, &ev); err != nil { |
||||||
|
t.Fatalf("Failed to parse exported event: %v", err) |
||||||
|
} |
||||||
|
if hex.Enc(ev.Pubkey[:]) != alicePubkey { |
||||||
|
t.Fatalf("Exported event has wrong pubkey (expected Alice)") |
||||||
|
} |
||||||
|
validLines++ |
||||||
|
} |
||||||
|
|
||||||
|
if validLines != 3 { |
||||||
|
t.Fatalf("Expected 3 events from Alice, got %d", validLines) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Export with pubkey filter returned %d events from Alice only", validLines) |
||||||
|
} |
||||||
|
|
||||||
|
func TestExport_Empty(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Export from empty database
|
||||||
|
var buf bytes.Buffer |
||||||
|
db.Export(ctx, &buf) |
||||||
|
|
||||||
|
// Should be empty or just whitespace
|
||||||
|
content := bytes.TrimSpace(buf.Bytes()) |
||||||
|
if len(content) != 0 { |
||||||
|
t.Fatalf("Expected empty export, got: %s", string(content)) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Export from empty database returns empty result") |
||||||
|
} |
||||||
|
|
||||||
|
func TestImportExport_RoundTrip(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
signer, _ := p8k.New() |
||||||
|
signer.Generate() |
||||||
|
|
||||||
|
// Create original events
|
||||||
|
originalEvents := make([]*event.E, 3) |
||||||
|
for i := 0; i < 3; i++ { |
||||||
|
ev := event.New() |
||||||
|
ev.Pubkey = signer.Pub() |
||||||
|
ev.CreatedAt = timestamp.Now().V + int64(i) |
||||||
|
ev.Kind = 1 |
||||||
|
ev.Content = []byte("Round trip test event") |
||||||
|
ev.Tags = tag.NewS(tag.NewFromAny("t", "roundtrip")) |
||||||
|
|
||||||
|
if err := ev.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, ev); err != nil { |
||||||
|
t.Fatalf("Failed to save event: %v", err) |
||||||
|
} |
||||||
|
originalEvents[i] = ev |
||||||
|
} |
||||||
|
|
||||||
|
// Export events
|
||||||
|
var buf bytes.Buffer |
||||||
|
db.Export(ctx, &buf) |
||||||
|
|
||||||
|
// Wipe database
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Verify database is empty
|
||||||
|
evs, err := db.QueryEvents(ctx, &filter.F{ |
||||||
|
Kinds: kind.NewS(kind.New(1)), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query events: %v", err) |
||||||
|
} |
||||||
|
if len(evs) != 0 { |
||||||
|
t.Fatalf("Expected 0 events after wipe, got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
// Import events
|
||||||
|
db.Import(bytes.NewReader(buf.Bytes())) |
||||||
|
|
||||||
|
// Verify events were restored
|
||||||
|
evs, err = db.QueryEvents(ctx, &filter.F{ |
||||||
|
Authors: tag.NewFromBytesSlice(signer.Pub()), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query imported events: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if len(evs) != 3 { |
||||||
|
t.Fatalf("Expected 3 imported events, got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
// Verify event IDs match
|
||||||
|
importedIDs := make(map[string]bool) |
||||||
|
for _, ev := range evs { |
||||||
|
importedIDs[hex.Enc(ev.ID[:])] = true |
||||||
|
} |
||||||
|
|
||||||
|
for _, orig := range originalEvents { |
||||||
|
if !importedIDs[hex.Enc(orig.ID[:])] { |
||||||
|
t.Fatalf("Original event %s not found after import", hex.Enc(orig.ID[:])) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Export/Import round trip preserved %d events correctly", len(evs)) |
||||||
|
} |
||||||
@ -0,0 +1,502 @@ |
|||||||
|
package neo4j |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"os" |
||||||
|
"testing" |
||||||
|
|
||||||
|
"git.mleku.dev/mleku/nostr/encoders/event" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/filter" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/hex" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/tag" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/timestamp" |
||||||
|
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k" |
||||||
|
"next.orly.dev/pkg/database/indexes/types" |
||||||
|
) |
||||||
|
|
||||||
|
func TestFetchEventBySerial(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
signer, err := p8k.New() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create signer: %v", err) |
||||||
|
} |
||||||
|
if err := signer.Generate(); err != nil { |
||||||
|
t.Fatalf("Failed to generate keypair: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create and save a test event
|
||||||
|
ev := event.New() |
||||||
|
ev.Pubkey = signer.Pub() |
||||||
|
ev.CreatedAt = timestamp.Now().V |
||||||
|
ev.Kind = 1 |
||||||
|
ev.Content = []byte("Test event for fetch by serial") |
||||||
|
|
||||||
|
if err := ev.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, ev); err != nil { |
||||||
|
t.Fatalf("Failed to save event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Get the serial for this event
|
||||||
|
serial, err := db.GetSerialById(ev.ID[:]) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get serial by ID: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Fetch event by serial
|
||||||
|
fetchedEvent, err := db.FetchEventBySerial(serial) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to fetch event by serial: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if fetchedEvent == nil { |
||||||
|
t.Fatal("Expected fetched event to be non-nil") |
||||||
|
} |
||||||
|
|
||||||
|
// Verify event properties
|
||||||
|
if hex.Enc(fetchedEvent.ID[:]) != hex.Enc(ev.ID[:]) { |
||||||
|
t.Fatalf("Event ID mismatch: got %s, expected %s", |
||||||
|
hex.Enc(fetchedEvent.ID[:]), hex.Enc(ev.ID[:])) |
||||||
|
} |
||||||
|
|
||||||
|
if fetchedEvent.Kind != ev.Kind { |
||||||
|
t.Fatalf("Kind mismatch: got %d, expected %d", fetchedEvent.Kind, ev.Kind) |
||||||
|
} |
||||||
|
|
||||||
|
if hex.Enc(fetchedEvent.Pubkey[:]) != hex.Enc(ev.Pubkey[:]) { |
||||||
|
t.Fatalf("Pubkey mismatch") |
||||||
|
} |
||||||
|
|
||||||
|
if fetchedEvent.CreatedAt != ev.CreatedAt { |
||||||
|
t.Fatalf("CreatedAt mismatch: got %d, expected %d", |
||||||
|
fetchedEvent.CreatedAt, ev.CreatedAt) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ FetchEventBySerial returned correct event") |
||||||
|
} |
||||||
|
|
||||||
|
func TestFetchEventBySerial_NonExistent(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
// Try to fetch with non-existent serial
|
||||||
|
nonExistentSerial := &types.Uint40{} |
||||||
|
nonExistentSerial.Set(0xFFFFFFFFFF) // Max value
|
||||||
|
|
||||||
|
_, err = db.FetchEventBySerial(nonExistentSerial) |
||||||
|
if err == nil { |
||||||
|
t.Fatal("Expected error for non-existent serial") |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ FetchEventBySerial correctly returned error for non-existent serial") |
||||||
|
} |
||||||
|
|
||||||
|
func TestFetchEventsBySerials(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
signer, err := p8k.New() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create signer: %v", err) |
||||||
|
} |
||||||
|
if err := signer.Generate(); err != nil { |
||||||
|
t.Fatalf("Failed to generate keypair: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create and save multiple events
|
||||||
|
var serials []*types.Uint40 |
||||||
|
eventIDs := make(map[uint64]string) |
||||||
|
|
||||||
|
for i := 0; i < 5; i++ { |
||||||
|
ev := event.New() |
||||||
|
ev.Pubkey = signer.Pub() |
||||||
|
ev.CreatedAt = timestamp.Now().V + int64(i) |
||||||
|
ev.Kind = 1 |
||||||
|
ev.Content = []byte("Test event") |
||||||
|
|
||||||
|
if err := ev.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, ev); err != nil { |
||||||
|
t.Fatalf("Failed to save event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
serial, err := db.GetSerialById(ev.ID[:]) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get serial: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
serials = append(serials, serial) |
||||||
|
eventIDs[serial.Get()] = hex.Enc(ev.ID[:]) |
||||||
|
} |
||||||
|
|
||||||
|
// Fetch all events by serials
|
||||||
|
events, err := db.FetchEventsBySerials(serials) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to fetch events by serials: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if len(events) != 5 { |
||||||
|
t.Fatalf("Expected 5 events, got %d", len(events)) |
||||||
|
} |
||||||
|
|
||||||
|
// Verify each event
|
||||||
|
for serial, expectedID := range eventIDs { |
||||||
|
ev, exists := events[serial] |
||||||
|
if !exists { |
||||||
|
t.Fatalf("Event with serial %d not found", serial) |
||||||
|
} |
||||||
|
if hex.Enc(ev.ID[:]) != expectedID { |
||||||
|
t.Fatalf("Event ID mismatch for serial %d", serial) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ FetchEventsBySerials returned %d correct events", len(events)) |
||||||
|
} |
||||||
|
|
||||||
|
func TestGetSerialById(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
signer, err := p8k.New() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create signer: %v", err) |
||||||
|
} |
||||||
|
if err := signer.Generate(); err != nil { |
||||||
|
t.Fatalf("Failed to generate keypair: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create and save event
|
||||||
|
ev := event.New() |
||||||
|
ev.Pubkey = signer.Pub() |
||||||
|
ev.CreatedAt = timestamp.Now().V |
||||||
|
ev.Kind = 1 |
||||||
|
ev.Content = []byte("Test event") |
||||||
|
|
||||||
|
if err := ev.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, ev); err != nil { |
||||||
|
t.Fatalf("Failed to save event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Get serial by ID
|
||||||
|
serial, err := db.GetSerialById(ev.ID[:]) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get serial by ID: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if serial == nil { |
||||||
|
t.Fatal("Expected serial to be non-nil") |
||||||
|
} |
||||||
|
|
||||||
|
if serial.Get() == 0 { |
||||||
|
t.Fatal("Expected non-zero serial") |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ GetSerialById returned serial: %d", serial.Get()) |
||||||
|
} |
||||||
|
|
||||||
|
func TestGetSerialById_NonExistent(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
// Try to get serial for non-existent event
|
||||||
|
fakeID, _ := hex.Dec("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") |
||||||
|
|
||||||
|
_, err = db.GetSerialById(fakeID) |
||||||
|
if err == nil { |
||||||
|
t.Fatal("Expected error for non-existent event ID") |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ GetSerialById correctly returned error for non-existent ID") |
||||||
|
} |
||||||
|
|
||||||
|
func TestGetSerialsByIds(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
signer, err := p8k.New() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create signer: %v", err) |
||||||
|
} |
||||||
|
if err := signer.Generate(); err != nil { |
||||||
|
t.Fatalf("Failed to generate keypair: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create and save multiple events
|
||||||
|
ids := tag.NewS() |
||||||
|
for i := 0; i < 3; i++ { |
||||||
|
ev := event.New() |
||||||
|
ev.Pubkey = signer.Pub() |
||||||
|
ev.CreatedAt = timestamp.Now().V + int64(i) |
||||||
|
ev.Kind = 1 |
||||||
|
ev.Content = []byte("Test event") |
||||||
|
|
||||||
|
if err := ev.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, ev); err != nil { |
||||||
|
t.Fatalf("Failed to save event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
ids.Append(tag.NewFromAny("", hex.Enc(ev.ID[:]))) |
||||||
|
} |
||||||
|
|
||||||
|
// Get serials by IDs
|
||||||
|
serials, err := db.GetSerialsByIds(ids) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get serials by IDs: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if len(serials) != 3 { |
||||||
|
t.Fatalf("Expected 3 serials, got %d", len(serials)) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ GetSerialsByIds returned %d serials", len(serials)) |
||||||
|
} |
||||||
|
|
||||||
|
func TestGetFullIdPubkeyBySerial(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
signer, err := p8k.New() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create signer: %v", err) |
||||||
|
} |
||||||
|
if err := signer.Generate(); err != nil { |
||||||
|
t.Fatalf("Failed to generate keypair: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create and save event
|
||||||
|
ev := event.New() |
||||||
|
ev.Pubkey = signer.Pub() |
||||||
|
ev.CreatedAt = timestamp.Now().V |
||||||
|
ev.Kind = 1 |
||||||
|
ev.Content = []byte("Test event") |
||||||
|
|
||||||
|
if err := ev.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, ev); err != nil { |
||||||
|
t.Fatalf("Failed to save event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Get serial
|
||||||
|
serial, err := db.GetSerialById(ev.ID[:]) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get serial: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Get full ID and pubkey
|
||||||
|
idPkTs, err := db.GetFullIdPubkeyBySerial(serial) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get full ID and pubkey: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if idPkTs == nil { |
||||||
|
t.Fatal("Expected non-nil result") |
||||||
|
} |
||||||
|
|
||||||
|
if hex.Enc(idPkTs.Id) != hex.Enc(ev.ID[:]) { |
||||||
|
t.Fatalf("ID mismatch") |
||||||
|
} |
||||||
|
|
||||||
|
if hex.Enc(idPkTs.Pub) != hex.Enc(ev.Pubkey[:]) { |
||||||
|
t.Fatalf("Pubkey mismatch") |
||||||
|
} |
||||||
|
|
||||||
|
if idPkTs.Ts != ev.CreatedAt { |
||||||
|
t.Fatalf("Timestamp mismatch") |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ GetFullIdPubkeyBySerial returned correct data") |
||||||
|
} |
||||||
|
|
||||||
|
func TestQueryForSerials(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
signer, err := p8k.New() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create signer: %v", err) |
||||||
|
} |
||||||
|
if err := signer.Generate(); err != nil { |
||||||
|
t.Fatalf("Failed to generate keypair: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create and save events
|
||||||
|
for i := 0; i < 5; i++ { |
||||||
|
ev := event.New() |
||||||
|
ev.Pubkey = signer.Pub() |
||||||
|
ev.CreatedAt = timestamp.Now().V + int64(i) |
||||||
|
ev.Kind = 1 |
||||||
|
ev.Content = []byte("Test event") |
||||||
|
|
||||||
|
if err := ev.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, ev); err != nil { |
||||||
|
t.Fatalf("Failed to save event: %v", err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Query for serials
|
||||||
|
serials, err := db.QueryForSerials(ctx, &filter.F{ |
||||||
|
Authors: tag.NewFromBytesSlice(signer.Pub()), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query for serials: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if len(serials) != 5 { |
||||||
|
t.Fatalf("Expected 5 serials, got %d", len(serials)) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ QueryForSerials returned %d serials", len(serials)) |
||||||
|
} |
||||||
@ -0,0 +1,342 @@ |
|||||||
|
package neo4j |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"os" |
||||||
|
"testing" |
||||||
|
"time" |
||||||
|
|
||||||
|
"git.mleku.dev/mleku/nostr/encoders/hex" |
||||||
|
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k" |
||||||
|
) |
||||||
|
|
||||||
|
func TestNIP43_AddAndRemoveMember(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
signer, _ := p8k.New() |
||||||
|
signer.Generate() |
||||||
|
pubkey := signer.Pub() |
||||||
|
|
||||||
|
// Add member
|
||||||
|
inviteCode := "test-invite-123" |
||||||
|
if err := db.AddNIP43Member(pubkey, inviteCode); err != nil { |
||||||
|
t.Fatalf("Failed to add NIP-43 member: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Check membership
|
||||||
|
isMember, err := db.IsNIP43Member(pubkey) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to check membership: %v", err) |
||||||
|
} |
||||||
|
if !isMember { |
||||||
|
t.Fatal("Expected pubkey to be a member") |
||||||
|
} |
||||||
|
|
||||||
|
// Get membership details
|
||||||
|
membership, err := db.GetNIP43Membership(pubkey) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get membership: %v", err) |
||||||
|
} |
||||||
|
if membership.InviteCode != inviteCode { |
||||||
|
t.Fatalf("Invite code mismatch: got %s, expected %s", membership.InviteCode, inviteCode) |
||||||
|
} |
||||||
|
|
||||||
|
// Remove member
|
||||||
|
if err := db.RemoveNIP43Member(pubkey); err != nil { |
||||||
|
t.Fatalf("Failed to remove member: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Verify no longer a member
|
||||||
|
isMember, _ = db.IsNIP43Member(pubkey) |
||||||
|
if isMember { |
||||||
|
t.Fatal("Expected pubkey to not be a member after removal") |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ NIP-43 add and remove member works correctly") |
||||||
|
} |
||||||
|
|
||||||
|
func TestNIP43_GetAllMembers(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Add multiple members
|
||||||
|
var pubkeys [][]byte |
||||||
|
for i := 0; i < 3; i++ { |
||||||
|
signer, _ := p8k.New() |
||||||
|
signer.Generate() |
||||||
|
pubkey := signer.Pub() |
||||||
|
pubkeys = append(pubkeys, pubkey) |
||||||
|
|
||||||
|
if err := db.AddNIP43Member(pubkey, "invite"+string(rune('A'+i))); err != nil { |
||||||
|
t.Fatalf("Failed to add member %d: %v", i, err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Get all members
|
||||||
|
members, err := db.GetAllNIP43Members() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get all members: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if len(members) != 3 { |
||||||
|
t.Fatalf("Expected 3 members, got %d", len(members)) |
||||||
|
} |
||||||
|
|
||||||
|
// Verify all added pubkeys are in the members list
|
||||||
|
memberMap := make(map[string]bool) |
||||||
|
for _, m := range members { |
||||||
|
memberMap[hex.Enc(m)] = true |
||||||
|
} |
||||||
|
|
||||||
|
for i, pk := range pubkeys { |
||||||
|
if !memberMap[hex.Enc(pk)] { |
||||||
|
t.Fatalf("Member %d not found in list", i) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ GetAllNIP43Members returned %d members", len(members)) |
||||||
|
} |
||||||
|
|
||||||
|
func TestNIP43_InviteCode(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Store valid invite code (expires in 1 hour)
|
||||||
|
validCode := "valid-code-123" |
||||||
|
expiresAt := time.Now().Add(1 * time.Hour) |
||||||
|
if err := db.StoreInviteCode(validCode, expiresAt); err != nil { |
||||||
|
t.Fatalf("Failed to store invite code: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Validate the code
|
||||||
|
isValid, err := db.ValidateInviteCode(validCode) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to validate invite code: %v", err) |
||||||
|
} |
||||||
|
if !isValid { |
||||||
|
t.Fatal("Expected valid invite code to be valid") |
||||||
|
} |
||||||
|
|
||||||
|
// Test non-existent code
|
||||||
|
isValid, err = db.ValidateInviteCode("non-existent-code") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to validate non-existent code: %v", err) |
||||||
|
} |
||||||
|
if isValid { |
||||||
|
t.Fatal("Expected non-existent code to be invalid") |
||||||
|
} |
||||||
|
|
||||||
|
// Delete the invite code
|
||||||
|
if err := db.DeleteInviteCode(validCode); err != nil { |
||||||
|
t.Fatalf("Failed to delete invite code: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Verify code is no longer valid
|
||||||
|
isValid, _ = db.ValidateInviteCode(validCode) |
||||||
|
if isValid { |
||||||
|
t.Fatal("Expected deleted code to be invalid") |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ NIP-43 invite code operations work correctly") |
||||||
|
} |
||||||
|
|
||||||
|
func TestNIP43_ExpiredInviteCode(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Store expired invite code (expired 1 hour ago)
|
||||||
|
expiredCode := "expired-code-123" |
||||||
|
expiresAt := time.Now().Add(-1 * time.Hour) |
||||||
|
if err := db.StoreInviteCode(expiredCode, expiresAt); err != nil { |
||||||
|
t.Fatalf("Failed to store expired invite code: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Validate should return false for expired code
|
||||||
|
isValid, err := db.ValidateInviteCode(expiredCode) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to validate expired code: %v", err) |
||||||
|
} |
||||||
|
if isValid { |
||||||
|
t.Fatal("Expected expired code to be invalid") |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Expired invite code correctly detected as invalid") |
||||||
|
} |
||||||
|
|
||||||
|
func TestNIP43_DuplicateMember(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
signer, _ := p8k.New() |
||||||
|
signer.Generate() |
||||||
|
pubkey := signer.Pub() |
||||||
|
|
||||||
|
// Add member first time
|
||||||
|
if err := db.AddNIP43Member(pubkey, "invite1"); err != nil { |
||||||
|
t.Fatalf("Failed to add member: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Add same member again (should not error, just update)
|
||||||
|
if err := db.AddNIP43Member(pubkey, "invite2"); err != nil { |
||||||
|
t.Fatalf("Failed to re-add member: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Check membership still exists
|
||||||
|
isMember, _ := db.IsNIP43Member(pubkey) |
||||||
|
if !isMember { |
||||||
|
t.Fatal("Expected pubkey to still be a member") |
||||||
|
} |
||||||
|
|
||||||
|
// Get all members should have only 1 entry
|
||||||
|
members, _ := db.GetAllNIP43Members() |
||||||
|
if len(members) != 1 { |
||||||
|
t.Fatalf("Expected 1 member, got %d", len(members)) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Duplicate member handling works correctly") |
||||||
|
} |
||||||
|
|
||||||
|
func TestNIP43_MembershipPersistence(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
signer, _ := p8k.New() |
||||||
|
signer.Generate() |
||||||
|
pubkey := signer.Pub() |
||||||
|
|
||||||
|
// Add member
|
||||||
|
inviteCode := "persistence-test" |
||||||
|
if err := db.AddNIP43Member(pubkey, inviteCode); err != nil { |
||||||
|
t.Fatalf("Failed to add member: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Get membership and verify all fields
|
||||||
|
membership, err := db.GetNIP43Membership(pubkey) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get membership: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if membership.InviteCode != inviteCode { |
||||||
|
t.Fatalf("InviteCode mismatch") |
||||||
|
} |
||||||
|
|
||||||
|
if membership.AddedAt.IsZero() { |
||||||
|
t.Fatal("AddedAt should not be zero") |
||||||
|
} |
||||||
|
|
||||||
|
// Verify the pubkey in membership matches
|
||||||
|
if hex.Enc(membership.Pubkey[:]) != hex.Enc(pubkey) { |
||||||
|
t.Fatal("Pubkey mismatch in membership") |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ NIP-43 membership persistence verified") |
||||||
|
} |
||||||
@ -0,0 +1,452 @@ |
|||||||
|
package neo4j |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"os" |
||||||
|
"testing" |
||||||
|
|
||||||
|
"git.mleku.dev/mleku/nostr/encoders/event" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/filter" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/hex" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/kind" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/tag" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/timestamp" |
||||||
|
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k" |
||||||
|
) |
||||||
|
|
||||||
|
// setupTestDatabase creates a fresh Neo4j database connection for testing
|
||||||
|
func setupTestDatabase(t *testing.T) (*N, context.Context, context.CancelFunc) { |
||||||
|
t.Helper() |
||||||
|
|
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
cancel() |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
db.Close() |
||||||
|
cancel() |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
return db, ctx, cancel |
||||||
|
} |
||||||
|
|
||||||
|
// createTestSigner creates a new signer for test events
|
||||||
|
func createTestSigner(t *testing.T) *p8k.Signer { |
||||||
|
t.Helper() |
||||||
|
|
||||||
|
signer, err := p8k.New() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create signer: %v", err) |
||||||
|
} |
||||||
|
if err := signer.Generate(); err != nil { |
||||||
|
t.Fatalf("Failed to generate keypair: %v", err) |
||||||
|
} |
||||||
|
return signer |
||||||
|
} |
||||||
|
|
||||||
|
// createAndSaveEvent creates a signed event and saves it to the database
|
||||||
|
func createAndSaveEvent(t *testing.T, ctx context.Context, db *N, signer *p8k.Signer, k uint16, content string, tags *tag.S, ts int64) *event.E { |
||||||
|
t.Helper() |
||||||
|
|
||||||
|
ev := event.New() |
||||||
|
ev.Pubkey = signer.Pub() |
||||||
|
ev.CreatedAt = ts |
||||||
|
ev.Kind = k |
||||||
|
ev.Content = []byte(content) |
||||||
|
ev.Tags = tags |
||||||
|
|
||||||
|
if err := ev.Sign(signer); err != nil { |
||||||
|
t.Fatalf("Failed to sign event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := db.SaveEvent(ctx, ev); err != nil { |
||||||
|
t.Fatalf("Failed to save event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
return ev |
||||||
|
} |
||||||
|
|
||||||
|
func TestQueryEventsByID(t *testing.T) { |
||||||
|
db, ctx, cancel := setupTestDatabase(t) |
||||||
|
defer db.Close() |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
signer := createTestSigner(t) |
||||||
|
|
||||||
|
// Create and save a test event
|
||||||
|
ev := createAndSaveEvent(t, ctx, db, signer, 1, "Test event for ID query", nil, timestamp.Now().V) |
||||||
|
|
||||||
|
// Query by ID
|
||||||
|
evs, err := db.QueryEvents(ctx, &filter.F{ |
||||||
|
Ids: tag.NewFromBytesSlice(ev.ID), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query events by ID: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if len(evs) != 1 { |
||||||
|
t.Fatalf("Expected 1 event, got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
if hex.Enc(evs[0].ID[:]) != hex.Enc(ev.ID[:]) { |
||||||
|
t.Fatalf("Event ID mismatch: got %s, expected %s", |
||||||
|
hex.Enc(evs[0].ID[:]), hex.Enc(ev.ID[:])) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Query by ID returned correct event") |
||||||
|
} |
||||||
|
|
||||||
|
func TestQueryEventsByKind(t *testing.T) { |
||||||
|
db, ctx, cancel := setupTestDatabase(t) |
||||||
|
defer db.Close() |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
signer := createTestSigner(t) |
||||||
|
baseTs := timestamp.Now().V |
||||||
|
|
||||||
|
// Create events of different kinds
|
||||||
|
createAndSaveEvent(t, ctx, db, signer, 1, "Kind 1 event A", nil, baseTs) |
||||||
|
createAndSaveEvent(t, ctx, db, signer, 1, "Kind 1 event B", nil, baseTs+1) |
||||||
|
createAndSaveEvent(t, ctx, db, signer, 7, "Kind 7 reaction", nil, baseTs+2) |
||||||
|
createAndSaveEvent(t, ctx, db, signer, 30023, "Kind 30023 article", nil, baseTs+3) |
||||||
|
|
||||||
|
// Query for kind 1
|
||||||
|
evs, err := db.QueryEvents(ctx, &filter.F{ |
||||||
|
Kinds: kind.NewS(kind.New(1)), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query events by kind: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if len(evs) != 2 { |
||||||
|
t.Fatalf("Expected 2 kind 1 events, got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
for _, ev := range evs { |
||||||
|
if ev.Kind != 1 { |
||||||
|
t.Fatalf("Expected kind 1, got %d", ev.Kind) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Query by kind returned %d correct events", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
func TestQueryEventsByAuthor(t *testing.T) { |
||||||
|
db, ctx, cancel := setupTestDatabase(t) |
||||||
|
defer db.Close() |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
alice := createTestSigner(t) |
||||||
|
bob := createTestSigner(t) |
||||||
|
baseTs := timestamp.Now().V |
||||||
|
|
||||||
|
// Create events from different authors
|
||||||
|
createAndSaveEvent(t, ctx, db, alice, 1, "Alice's event 1", nil, baseTs) |
||||||
|
createAndSaveEvent(t, ctx, db, alice, 1, "Alice's event 2", nil, baseTs+1) |
||||||
|
createAndSaveEvent(t, ctx, db, bob, 1, "Bob's event", nil, baseTs+2) |
||||||
|
|
||||||
|
// Query for Alice's events
|
||||||
|
evs, err := db.QueryEvents(ctx, &filter.F{ |
||||||
|
Authors: tag.NewFromBytesSlice(alice.Pub()), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query events by author: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if len(evs) != 2 { |
||||||
|
t.Fatalf("Expected 2 events from Alice, got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
alicePubkey := hex.Enc(alice.Pub()) |
||||||
|
for _, ev := range evs { |
||||||
|
if hex.Enc(ev.Pubkey[:]) != alicePubkey { |
||||||
|
t.Fatalf("Expected author %s, got %s", alicePubkey, hex.Enc(ev.Pubkey[:])) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Query by author returned %d correct events", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
func TestQueryEventsByTimeRange(t *testing.T) { |
||||||
|
db, ctx, cancel := setupTestDatabase(t) |
||||||
|
defer db.Close() |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
signer := createTestSigner(t) |
||||||
|
baseTs := timestamp.Now().V |
||||||
|
|
||||||
|
// Create events at different times
|
||||||
|
createAndSaveEvent(t, ctx, db, signer, 1, "Old event", nil, baseTs-7200) // 2 hours ago
|
||||||
|
createAndSaveEvent(t, ctx, db, signer, 1, "Recent event", nil, baseTs-1800) // 30 min ago
|
||||||
|
createAndSaveEvent(t, ctx, db, signer, 1, "Current event", nil, baseTs) |
||||||
|
|
||||||
|
// Query for events in the last hour
|
||||||
|
since := ×tamp.T{V: baseTs - 3600} |
||||||
|
evs, err := db.QueryEvents(ctx, &filter.F{ |
||||||
|
Since: since, |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query events by time range: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if len(evs) != 2 { |
||||||
|
t.Fatalf("Expected 2 events in last hour, got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
for _, ev := range evs { |
||||||
|
if ev.CreatedAt < since.V { |
||||||
|
t.Fatalf("Event created_at %d is before since %d", ev.CreatedAt, since.V) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Query by time range returned %d correct events", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
func TestQueryEventsByTag(t *testing.T) { |
||||||
|
db, ctx, cancel := setupTestDatabase(t) |
||||||
|
defer db.Close() |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
signer := createTestSigner(t) |
||||||
|
baseTs := timestamp.Now().V |
||||||
|
|
||||||
|
// Create events with tags
|
||||||
|
createAndSaveEvent(t, ctx, db, signer, 1, "Bitcoin post", |
||||||
|
tag.NewS(tag.NewFromAny("t", "bitcoin")), baseTs) |
||||||
|
createAndSaveEvent(t, ctx, db, signer, 1, "Nostr post", |
||||||
|
tag.NewS(tag.NewFromAny("t", "nostr")), baseTs+1) |
||||||
|
createAndSaveEvent(t, ctx, db, signer, 1, "Bitcoin and Nostr post", |
||||||
|
tag.NewS(tag.NewFromAny("t", "bitcoin"), tag.NewFromAny("t", "nostr")), baseTs+2) |
||||||
|
|
||||||
|
// Query for bitcoin tagged events
|
||||||
|
evs, err := db.QueryEvents(ctx, &filter.F{ |
||||||
|
Tags: tag.NewS(tag.NewFromAny("t", "bitcoin")), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query events by tag: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if len(evs) != 2 { |
||||||
|
t.Fatalf("Expected 2 bitcoin-tagged events, got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Query by tag returned %d correct events", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
func TestQueryEventsByKindAndAuthor(t *testing.T) { |
||||||
|
db, ctx, cancel := setupTestDatabase(t) |
||||||
|
defer db.Close() |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
alice := createTestSigner(t) |
||||||
|
bob := createTestSigner(t) |
||||||
|
baseTs := timestamp.Now().V |
||||||
|
|
||||||
|
// Create events
|
||||||
|
createAndSaveEvent(t, ctx, db, alice, 1, "Alice note", nil, baseTs) |
||||||
|
createAndSaveEvent(t, ctx, db, alice, 7, "Alice reaction", nil, baseTs+1) |
||||||
|
createAndSaveEvent(t, ctx, db, bob, 1, "Bob note", nil, baseTs+2) |
||||||
|
|
||||||
|
// Query for Alice's kind 1 events
|
||||||
|
evs, err := db.QueryEvents(ctx, &filter.F{ |
||||||
|
Kinds: kind.NewS(kind.New(1)), |
||||||
|
Authors: tag.NewFromBytesSlice(alice.Pub()), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query events by kind and author: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if len(evs) != 1 { |
||||||
|
t.Fatalf("Expected 1 kind 1 event from Alice, got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Query by kind and author returned correct events") |
||||||
|
} |
||||||
|
|
||||||
|
func TestQueryEventsWithLimit(t *testing.T) { |
||||||
|
db, ctx, cancel := setupTestDatabase(t) |
||||||
|
defer db.Close() |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
signer := createTestSigner(t) |
||||||
|
baseTs := timestamp.Now().V |
||||||
|
|
||||||
|
// Create many events
|
||||||
|
for i := 0; i < 20; i++ { |
||||||
|
createAndSaveEvent(t, ctx, db, signer, 1, "Event", nil, baseTs+int64(i)) |
||||||
|
} |
||||||
|
|
||||||
|
// Query with limit
|
||||||
|
limit := 5 |
||||||
|
evs, err := db.QueryEvents(ctx, &filter.F{ |
||||||
|
Kinds: kind.NewS(kind.New(1)), |
||||||
|
Limit: limit, |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query events with limit: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if len(evs) != limit { |
||||||
|
t.Fatalf("Expected %d events with limit, got %d", limit, len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Query with limit returned %d events", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
func TestQueryEventsOrderByCreatedAt(t *testing.T) { |
||||||
|
db, ctx, cancel := setupTestDatabase(t) |
||||||
|
defer db.Close() |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
signer := createTestSigner(t) |
||||||
|
baseTs := timestamp.Now().V |
||||||
|
|
||||||
|
// Create events at different times
|
||||||
|
createAndSaveEvent(t, ctx, db, signer, 1, "First", nil, baseTs) |
||||||
|
createAndSaveEvent(t, ctx, db, signer, 1, "Second", nil, baseTs+100) |
||||||
|
createAndSaveEvent(t, ctx, db, signer, 1, "Third", nil, baseTs+200) |
||||||
|
|
||||||
|
// Query and verify order (should be descending by created_at)
|
||||||
|
evs, err := db.QueryEvents(ctx, &filter.F{ |
||||||
|
Kinds: kind.NewS(kind.New(1)), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query events: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if len(evs) < 2 { |
||||||
|
t.Fatalf("Expected at least 2 events, got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
// Verify descending order
|
||||||
|
for i := 1; i < len(evs); i++ { |
||||||
|
if evs[i-1].CreatedAt < evs[i].CreatedAt { |
||||||
|
t.Fatalf("Events not in descending order: %d < %d at index %d", |
||||||
|
evs[i-1].CreatedAt, evs[i].CreatedAt, i) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Query returned events in correct descending order") |
||||||
|
} |
||||||
|
|
||||||
|
func TestQueryEventsEmpty(t *testing.T) { |
||||||
|
db, ctx, cancel := setupTestDatabase(t) |
||||||
|
defer db.Close() |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
// Query for non-existent kind
|
||||||
|
evs, err := db.QueryEvents(ctx, &filter.F{ |
||||||
|
Kinds: kind.NewS(kind.New(99999)), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query events: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if len(evs) != 0 { |
||||||
|
t.Fatalf("Expected 0 events, got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Query for non-existent kind returned empty result") |
||||||
|
} |
||||||
|
|
||||||
|
func TestQueryEventsMultipleKinds(t *testing.T) { |
||||||
|
db, ctx, cancel := setupTestDatabase(t) |
||||||
|
defer db.Close() |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
signer := createTestSigner(t) |
||||||
|
baseTs := timestamp.Now().V |
||||||
|
|
||||||
|
// Create events of different kinds
|
||||||
|
createAndSaveEvent(t, ctx, db, signer, 1, "Note", nil, baseTs) |
||||||
|
createAndSaveEvent(t, ctx, db, signer, 7, "Reaction", nil, baseTs+1) |
||||||
|
createAndSaveEvent(t, ctx, db, signer, 30023, "Article", nil, baseTs+2) |
||||||
|
|
||||||
|
// Query for multiple kinds
|
||||||
|
evs, err := db.QueryEvents(ctx, &filter.F{ |
||||||
|
Kinds: kind.NewS(kind.New(1), kind.New(7)), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query events: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if len(evs) != 2 { |
||||||
|
t.Fatalf("Expected 2 events (kind 1 and 7), got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Query for multiple kinds returned correct events") |
||||||
|
} |
||||||
|
|
||||||
|
func TestQueryEventsMultipleAuthors(t *testing.T) { |
||||||
|
db, ctx, cancel := setupTestDatabase(t) |
||||||
|
defer db.Close() |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
alice := createTestSigner(t) |
||||||
|
bob := createTestSigner(t) |
||||||
|
charlie := createTestSigner(t) |
||||||
|
baseTs := timestamp.Now().V |
||||||
|
|
||||||
|
// Create events from different authors
|
||||||
|
createAndSaveEvent(t, ctx, db, alice, 1, "Alice", nil, baseTs) |
||||||
|
createAndSaveEvent(t, ctx, db, bob, 1, "Bob", nil, baseTs+1) |
||||||
|
createAndSaveEvent(t, ctx, db, charlie, 1, "Charlie", nil, baseTs+2) |
||||||
|
|
||||||
|
// Query for Alice and Bob's events
|
||||||
|
authors := tag.NewFromBytesSlice(alice.Pub()) |
||||||
|
authors.Append(tag.NewFromBytesSlice(bob.Pub()).GetFirst(nil)) |
||||||
|
|
||||||
|
evs, err := db.QueryEvents(ctx, &filter.F{ |
||||||
|
Authors: authors, |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to query events: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if len(evs) != 2 { |
||||||
|
t.Fatalf("Expected 2 events from Alice and Bob, got %d", len(evs)) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Query for multiple authors returned correct events") |
||||||
|
} |
||||||
|
|
||||||
|
func TestCountEvents(t *testing.T) { |
||||||
|
db, ctx, cancel := setupTestDatabase(t) |
||||||
|
defer db.Close() |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
signer := createTestSigner(t) |
||||||
|
baseTs := timestamp.Now().V |
||||||
|
|
||||||
|
// Create events
|
||||||
|
for i := 0; i < 5; i++ { |
||||||
|
createAndSaveEvent(t, ctx, db, signer, 1, "Event", nil, baseTs+int64(i)) |
||||||
|
} |
||||||
|
|
||||||
|
// Count events
|
||||||
|
count, err := db.CountEvents(ctx, &filter.F{ |
||||||
|
Kinds: kind.NewS(kind.New(1)), |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to count events: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if count != 5 { |
||||||
|
t.Fatalf("Expected count 5, got %d", count) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Count events returned correct count: %d", count) |
||||||
|
} |
||||||
@ -0,0 +1,436 @@ |
|||||||
|
package neo4j |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"os" |
||||||
|
"testing" |
||||||
|
|
||||||
|
"git.mleku.dev/mleku/nostr/encoders/filter" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/kind" |
||||||
|
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k" |
||||||
|
) |
||||||
|
|
||||||
|
func TestSubscriptions_AddAndRemove(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
// Create a subscription
|
||||||
|
subID := "test-sub-123" |
||||||
|
f := &filter.F{ |
||||||
|
Kinds: kind.NewS(kind.New(1)), |
||||||
|
} |
||||||
|
|
||||||
|
// Add subscription
|
||||||
|
db.AddSubscription(subID, f) |
||||||
|
|
||||||
|
// Get subscription count (should be 1)
|
||||||
|
count := db.GetSubscriptionCount() |
||||||
|
if count != 1 { |
||||||
|
t.Fatalf("Expected 1 subscription, got %d", count) |
||||||
|
} |
||||||
|
|
||||||
|
// Remove subscription
|
||||||
|
db.RemoveSubscription(subID) |
||||||
|
|
||||||
|
// Get subscription count (should be 0)
|
||||||
|
count = db.GetSubscriptionCount() |
||||||
|
if count != 0 { |
||||||
|
t.Fatalf("Expected 0 subscriptions after removal, got %d", count) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Subscription add/remove works correctly") |
||||||
|
} |
||||||
|
|
||||||
|
func TestSubscriptions_MultipleSubscriptions(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
// Add multiple subscriptions
|
||||||
|
for i := 0; i < 5; i++ { |
||||||
|
subID := string(rune('A' + i)) |
||||||
|
f := &filter.F{ |
||||||
|
Kinds: kind.NewS(kind.New(uint16(i + 1))), |
||||||
|
} |
||||||
|
db.AddSubscription(subID, f) |
||||||
|
} |
||||||
|
|
||||||
|
// Get subscription count
|
||||||
|
count := db.GetSubscriptionCount() |
||||||
|
if count != 5 { |
||||||
|
t.Fatalf("Expected 5 subscriptions, got %d", count) |
||||||
|
} |
||||||
|
|
||||||
|
// Remove some subscriptions
|
||||||
|
db.RemoveSubscription("A") |
||||||
|
db.RemoveSubscription("C") |
||||||
|
|
||||||
|
count = db.GetSubscriptionCount() |
||||||
|
if count != 3 { |
||||||
|
t.Fatalf("Expected 3 subscriptions after removal, got %d", count) |
||||||
|
} |
||||||
|
|
||||||
|
// Clear all subscriptions
|
||||||
|
db.ClearSubscriptions() |
||||||
|
|
||||||
|
count = db.GetSubscriptionCount() |
||||||
|
if count != 0 { |
||||||
|
t.Fatalf("Expected 0 subscriptions after clear, got %d", count) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Multiple subscriptions managed correctly") |
||||||
|
} |
||||||
|
|
||||||
|
func TestSubscriptions_DuplicateID(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
subID := "duplicate-test" |
||||||
|
|
||||||
|
// Add first subscription
|
||||||
|
f1 := &filter.F{ |
||||||
|
Kinds: kind.NewS(kind.New(1)), |
||||||
|
} |
||||||
|
db.AddSubscription(subID, f1) |
||||||
|
|
||||||
|
// Add subscription with same ID (should replace)
|
||||||
|
f2 := &filter.F{ |
||||||
|
Kinds: kind.NewS(kind.New(7)), |
||||||
|
} |
||||||
|
db.AddSubscription(subID, f2) |
||||||
|
|
||||||
|
// Should still have only 1 subscription
|
||||||
|
count := db.GetSubscriptionCount() |
||||||
|
if count != 1 { |
||||||
|
t.Fatalf("Expected 1 subscription (duplicate replaced), got %d", count) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Duplicate subscription ID handling works correctly") |
||||||
|
} |
||||||
|
|
||||||
|
func TestSubscriptions_RemoveNonExistent(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
// Try to remove non-existent subscription (should not panic)
|
||||||
|
db.RemoveSubscription("non-existent") |
||||||
|
|
||||||
|
// Should still have 0 subscriptions
|
||||||
|
count := db.GetSubscriptionCount() |
||||||
|
if count != 0 { |
||||||
|
t.Fatalf("Expected 0 subscriptions, got %d", count) |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Removing non-existent subscription handled gracefully") |
||||||
|
} |
||||||
|
|
||||||
|
func TestMarkers_SetGetDelete(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Set a marker
|
||||||
|
key := "test-marker" |
||||||
|
value := []byte("test-value-123") |
||||||
|
if err := db.SetMarker(key, value); err != nil { |
||||||
|
t.Fatalf("Failed to set marker: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Get the marker
|
||||||
|
retrieved, err := db.GetMarker(key) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get marker: %v", err) |
||||||
|
} |
||||||
|
if string(retrieved) != string(value) { |
||||||
|
t.Fatalf("Marker value mismatch: got %s, expected %s", string(retrieved), string(value)) |
||||||
|
} |
||||||
|
|
||||||
|
// Update the marker
|
||||||
|
newValue := []byte("updated-value") |
||||||
|
if err := db.SetMarker(key, newValue); err != nil { |
||||||
|
t.Fatalf("Failed to update marker: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
retrieved, err = db.GetMarker(key) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get updated marker: %v", err) |
||||||
|
} |
||||||
|
if string(retrieved) != string(newValue) { |
||||||
|
t.Fatalf("Updated marker value mismatch") |
||||||
|
} |
||||||
|
|
||||||
|
// Delete the marker
|
||||||
|
if err := db.DeleteMarker(key); err != nil { |
||||||
|
t.Fatalf("Failed to delete marker: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Verify marker is deleted
|
||||||
|
_, err = db.GetMarker(key) |
||||||
|
if err == nil { |
||||||
|
t.Fatal("Expected error when getting deleted marker") |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Markers set/get/delete works correctly") |
||||||
|
} |
||||||
|
|
||||||
|
func TestMarkers_GetNonExistent(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
// Try to get non-existent marker
|
||||||
|
_, err = db.GetMarker("non-existent-marker") |
||||||
|
if err == nil { |
||||||
|
t.Fatal("Expected error when getting non-existent marker") |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Getting non-existent marker returns error as expected") |
||||||
|
} |
||||||
|
|
||||||
|
func TestSerial_GetNextSerial(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Get first serial
|
||||||
|
serial1, err := db.getNextSerial() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get first serial: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Get second serial
|
||||||
|
serial2, err := db.getNextSerial() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get second serial: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Serial should increment
|
||||||
|
if serial2 <= serial1 { |
||||||
|
t.Fatalf("Expected serial to increment: serial1=%d, serial2=%d", serial1, serial2) |
||||||
|
} |
||||||
|
|
||||||
|
// Get multiple more serials and verify they're all unique and increasing
|
||||||
|
var serials []uint64 |
||||||
|
for i := 0; i < 10; i++ { |
||||||
|
s, err := db.getNextSerial() |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get serial %d: %v", i, err) |
||||||
|
} |
||||||
|
serials = append(serials, s) |
||||||
|
} |
||||||
|
|
||||||
|
for i := 1; i < len(serials); i++ { |
||||||
|
if serials[i] <= serials[i-1] { |
||||||
|
t.Fatalf("Serials not increasing: %d <= %d", serials[i], serials[i-1]) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Serial generation works correctly (generated %d unique serials)", len(serials)+2) |
||||||
|
} |
||||||
|
|
||||||
|
func TestDatabaseReady(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
// Wait for ready
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
// Database should be ready now
|
||||||
|
t.Logf("✓ Database ready signal works correctly") |
||||||
|
} |
||||||
|
|
||||||
|
func TestIdentity(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
// Get identity (creates if not exists)
|
||||||
|
signer := db.Identity() |
||||||
|
if signer == nil { |
||||||
|
t.Fatal("Expected non-nil signer from Identity()") |
||||||
|
} |
||||||
|
|
||||||
|
// Get identity again (should return same one)
|
||||||
|
signer2 := db.Identity() |
||||||
|
if signer2 == nil { |
||||||
|
t.Fatal("Expected non-nil signer from second Identity() call") |
||||||
|
} |
||||||
|
|
||||||
|
// Public keys should match
|
||||||
|
pub1 := signer.Pub() |
||||||
|
pub2 := signer2.Pub() |
||||||
|
for i := range pub1 { |
||||||
|
if pub1[i] != pub2[i] { |
||||||
|
t.Fatal("Identity pubkeys don't match across calls") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Identity persistence works correctly") |
||||||
|
} |
||||||
|
|
||||||
|
func TestWipe(t *testing.T) { |
||||||
|
neo4jURI := os.Getenv("ORLY_NEO4J_URI") |
||||||
|
if neo4jURI == "" { |
||||||
|
t.Skip("Skipping Neo4j test: ORLY_NEO4J_URI not set") |
||||||
|
} |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
tempDir := t.TempDir() |
||||||
|
db, err := New(ctx, cancel, tempDir, "debug") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
<-db.Ready() |
||||||
|
|
||||||
|
signer, _ := p8k.New() |
||||||
|
signer.Generate() |
||||||
|
|
||||||
|
// Add some data
|
||||||
|
if err := db.AddNIP43Member(signer.Pub(), "test"); err != nil { |
||||||
|
t.Fatalf("Failed to add member: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Wipe the database
|
||||||
|
if err := db.Wipe(); err != nil { |
||||||
|
t.Fatalf("Failed to wipe database: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Verify data is gone
|
||||||
|
isMember, _ := db.IsNIP43Member(signer.Pub()) |
||||||
|
if isMember { |
||||||
|
t.Fatal("Expected data to be wiped") |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("✓ Wipe clears database correctly") |
||||||
|
} |
||||||
@ -0,0 +1,614 @@ |
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package wasmdb |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"context" |
||||||
|
"fmt" |
||||||
|
"sort" |
||||||
|
"strconv" |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/aperturerobotics/go-indexeddb/idb" |
||||||
|
"lol.mleku.dev/chk" |
||||||
|
"lol.mleku.dev/errorf" |
||||||
|
|
||||||
|
"git.mleku.dev/mleku/nostr/encoders/event" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/filter" |
||||||
|
hexenc "git.mleku.dev/mleku/nostr/encoders/hex" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/ints" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/kind" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/tag" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/tag/atag" |
||||||
|
"next.orly.dev/pkg/database" |
||||||
|
"next.orly.dev/pkg/database/indexes" |
||||||
|
"next.orly.dev/pkg/database/indexes/types" |
||||||
|
"next.orly.dev/pkg/interfaces/store" |
||||||
|
"next.orly.dev/pkg/utils" |
||||||
|
) |
||||||
|
|
||||||
|
// DeleteEvent removes an event from the database identified by `eid`.
|
||||||
|
func (w *W) DeleteEvent(c context.Context, eid []byte) (err error) { |
||||||
|
w.Logger.Warnf("deleting event %0x", eid) |
||||||
|
|
||||||
|
// Get the serial number for the event ID
|
||||||
|
var ser *types.Uint40 |
||||||
|
ser, err = w.GetSerialById(eid) |
||||||
|
if chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
if ser == nil { |
||||||
|
// Event wasn't found, nothing to delete
|
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Fetch the event to get its data
|
||||||
|
var ev *event.E |
||||||
|
ev, err = w.FetchEventBySerial(ser) |
||||||
|
if chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
if ev == nil { |
||||||
|
// Event wasn't found, nothing to delete
|
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if err = w.DeleteEventBySerial(c, ser, ev); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// DeleteEventBySerial removes an event and all its indexes by serial number.
|
||||||
|
func (w *W) DeleteEventBySerial(c context.Context, ser *types.Uint40, ev *event.E) (err error) { |
||||||
|
w.Logger.Infof("DeleteEventBySerial: deleting event %0x (serial %d)", ev.ID, ser.Get()) |
||||||
|
|
||||||
|
// Get all indexes for the event
|
||||||
|
var idxs [][]byte |
||||||
|
idxs, err = database.GetIndexesForEvent(ev, ser.Get()) |
||||||
|
if chk.E(err) { |
||||||
|
w.Logger.Errorf("DeleteEventBySerial: failed to get indexes for event %0x: %v", ev.ID, err) |
||||||
|
return |
||||||
|
} |
||||||
|
w.Logger.Infof("DeleteEventBySerial: found %d indexes for event %0x", len(idxs), ev.ID) |
||||||
|
|
||||||
|
// Collect all unique store names we need to access
|
||||||
|
storeNames := make(map[string]struct{}) |
||||||
|
for _, key := range idxs { |
||||||
|
if len(key) >= 3 { |
||||||
|
storeNames[string(key[:3])] = struct{}{} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Also include event stores
|
||||||
|
storeNames[string(indexes.EventPrefix)] = struct{}{} |
||||||
|
storeNames[string(indexes.SmallEventPrefix)] = struct{}{} |
||||||
|
|
||||||
|
// Convert to slice
|
||||||
|
storeList := make([]string, 0, len(storeNames)) |
||||||
|
for name := range storeNames { |
||||||
|
storeList = append(storeList, name) |
||||||
|
} |
||||||
|
|
||||||
|
if len(storeList) == 0 { |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// Start a transaction to delete the event and all its indexes
|
||||||
|
tx, err := w.db.Transaction(idb.TransactionReadWrite, storeList[0], storeList[1:]...) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("failed to start delete transaction: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Delete all indexes
|
||||||
|
for _, key := range idxs { |
||||||
|
if len(key) < 3 { |
||||||
|
continue |
||||||
|
} |
||||||
|
storeName := string(key[:3]) |
||||||
|
objStore, storeErr := tx.ObjectStore(storeName) |
||||||
|
if storeErr != nil { |
||||||
|
w.Logger.Warnf("DeleteEventBySerial: failed to get object store %s: %v", storeName, storeErr) |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
keyJS := bytesToSafeValue(key) |
||||||
|
if _, delErr := objStore.Delete(keyJS); delErr != nil { |
||||||
|
w.Logger.Warnf("DeleteEventBySerial: failed to delete index from %s: %v", storeName, delErr) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Delete from small event store
|
||||||
|
sevKeyBuf := new(bytes.Buffer) |
||||||
|
if err = indexes.SmallEventEnc(ser).MarshalWrite(sevKeyBuf); err == nil { |
||||||
|
if objStore, storeErr := tx.ObjectStore(string(indexes.SmallEventPrefix)); storeErr == nil { |
||||||
|
// For small events, the key includes size and data, so we need to scan
|
||||||
|
w.deleteKeysByPrefix(objStore, sevKeyBuf.Bytes()) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Delete from large event store
|
||||||
|
evtKeyBuf := new(bytes.Buffer) |
||||||
|
if err = indexes.EventEnc(ser).MarshalWrite(evtKeyBuf); err == nil { |
||||||
|
if objStore, storeErr := tx.ObjectStore(string(indexes.EventPrefix)); storeErr == nil { |
||||||
|
keyJS := bytesToSafeValue(evtKeyBuf.Bytes()) |
||||||
|
objStore.Delete(keyJS) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Commit transaction
|
||||||
|
if err = tx.Await(c); err != nil { |
||||||
|
return fmt.Errorf("failed to commit delete transaction: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
w.Logger.Infof("DeleteEventBySerial: successfully deleted event %0x and all indexes", ev.ID) |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// deleteKeysByPrefix deletes all keys starting with the given prefix from an object store
|
||||||
|
func (w *W) deleteKeysByPrefix(store *idb.ObjectStore, prefix []byte) { |
||||||
|
cursorReq, err := store.OpenCursor(idb.CursorNext) |
||||||
|
if err != nil { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
var keysToDelete [][]byte |
||||||
|
cursorReq.Iter(w.ctx, func(cursor *idb.CursorWithValue) error { |
||||||
|
keyVal, keyErr := cursor.Key() |
||||||
|
if keyErr != nil { |
||||||
|
return keyErr |
||||||
|
} |
||||||
|
|
||||||
|
keyBytes := safeValueToBytes(keyVal) |
||||||
|
if len(keyBytes) >= len(prefix) && bytes.HasPrefix(keyBytes, prefix) { |
||||||
|
keysToDelete = append(keysToDelete, keyBytes) |
||||||
|
} |
||||||
|
|
||||||
|
return cursor.Continue() |
||||||
|
}) |
||||||
|
|
||||||
|
// Delete collected keys
|
||||||
|
for _, key := range keysToDelete { |
||||||
|
keyJS := bytesToSafeValue(key) |
||||||
|
store.Delete(keyJS) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// DeleteExpired scans for events with expiration timestamps that have passed and deletes them.
|
||||||
|
func (w *W) DeleteExpired() { |
||||||
|
now := time.Now().Unix() |
||||||
|
|
||||||
|
// Open read transaction to find expired events
|
||||||
|
tx, err := w.db.Transaction(idb.TransactionReadOnly, string(indexes.ExpirationPrefix)) |
||||||
|
if err != nil { |
||||||
|
w.Logger.Warnf("DeleteExpired: failed to start transaction: %v", err) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
objStore, err := tx.ObjectStore(string(indexes.ExpirationPrefix)) |
||||||
|
if err != nil { |
||||||
|
w.Logger.Warnf("DeleteExpired: failed to get expiration store: %v", err) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
var expiredSerials types.Uint40s |
||||||
|
|
||||||
|
cursorReq, err := objStore.OpenCursor(idb.CursorNext) |
||||||
|
if err != nil { |
||||||
|
w.Logger.Warnf("DeleteExpired: failed to open cursor: %v", err) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
cursorReq.Iter(w.ctx, func(cursor *idb.CursorWithValue) error { |
||||||
|
keyVal, keyErr := cursor.Key() |
||||||
|
if keyErr != nil { |
||||||
|
return keyErr |
||||||
|
} |
||||||
|
|
||||||
|
keyBytes := safeValueToBytes(keyVal) |
||||||
|
if len(keyBytes) < 8 { // exp prefix (3) + expiration (variable) + serial (5)
|
||||||
|
return cursor.Continue() |
||||||
|
} |
||||||
|
|
||||||
|
// Parse expiration key: exp|expiration_timestamp|serial
|
||||||
|
exp, ser := indexes.ExpirationVars() |
||||||
|
buf := bytes.NewBuffer(keyBytes) |
||||||
|
if err := indexes.ExpirationDec(exp, ser).UnmarshalRead(buf); err != nil { |
||||||
|
return cursor.Continue() |
||||||
|
} |
||||||
|
|
||||||
|
if int64(exp.Get()) > now { |
||||||
|
// Not expired yet
|
||||||
|
return cursor.Continue() |
||||||
|
} |
||||||
|
|
||||||
|
expiredSerials = append(expiredSerials, ser) |
||||||
|
return cursor.Continue() |
||||||
|
}) |
||||||
|
|
||||||
|
// Delete expired events
|
||||||
|
for _, ser := range expiredSerials { |
||||||
|
ev, fetchErr := w.FetchEventBySerial(ser) |
||||||
|
if fetchErr != nil || ev == nil { |
||||||
|
continue |
||||||
|
} |
||||||
|
if err := w.DeleteEventBySerial(context.Background(), ser, ev); err != nil { |
||||||
|
w.Logger.Warnf("DeleteExpired: failed to delete expired event: %v", err) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// ProcessDelete processes a kind 5 deletion event, deleting referenced events.
|
||||||
|
func (w *W) ProcessDelete(ev *event.E, admins [][]byte) (err error) { |
||||||
|
eTags := ev.Tags.GetAll([]byte("e")) |
||||||
|
aTags := ev.Tags.GetAll([]byte("a")) |
||||||
|
kTags := ev.Tags.GetAll([]byte("k")) |
||||||
|
|
||||||
|
// Process e-tags: delete specific events by ID
|
||||||
|
for _, eTag := range eTags { |
||||||
|
if eTag.Len() < 2 { |
||||||
|
continue |
||||||
|
} |
||||||
|
// Use ValueHex() to handle both binary and hex storage formats
|
||||||
|
eventIdHex := eTag.ValueHex() |
||||||
|
if len(eventIdHex) != 64 { // hex encoded event ID
|
||||||
|
continue |
||||||
|
} |
||||||
|
// Decode hex event ID
|
||||||
|
var eid []byte |
||||||
|
if eid, err = hexenc.DecAppend(nil, eventIdHex); chk.E(err) { |
||||||
|
continue |
||||||
|
} |
||||||
|
// Fetch the event to verify ownership
|
||||||
|
var ser *types.Uint40 |
||||||
|
if ser, err = w.GetSerialById(eid); chk.E(err) || ser == nil { |
||||||
|
continue |
||||||
|
} |
||||||
|
var targetEv *event.E |
||||||
|
if targetEv, err = w.FetchEventBySerial(ser); chk.E(err) || targetEv == nil { |
||||||
|
continue |
||||||
|
} |
||||||
|
// Only allow users to delete their own events
|
||||||
|
if !utils.FastEqual(targetEv.Pubkey, ev.Pubkey) { |
||||||
|
continue |
||||||
|
} |
||||||
|
// Delete the event
|
||||||
|
if err = w.DeleteEvent(context.Background(), eid); chk.E(err) { |
||||||
|
w.Logger.Warnf("failed to delete event %x via e-tag: %v", eid, err) |
||||||
|
continue |
||||||
|
} |
||||||
|
w.Logger.Debugf("deleted event %x via e-tag deletion", eid) |
||||||
|
} |
||||||
|
|
||||||
|
// Process a-tags: delete addressable events by kind:pubkey:d-tag
|
||||||
|
for _, aTag := range aTags { |
||||||
|
if aTag.Len() < 2 { |
||||||
|
continue |
||||||
|
} |
||||||
|
// Parse the 'a' tag value: kind:pubkey:d-tag (for parameterized) or kind:pubkey (for regular)
|
||||||
|
split := bytes.Split(aTag.Value(), []byte{':'}) |
||||||
|
if len(split) < 2 { |
||||||
|
continue |
||||||
|
} |
||||||
|
// Parse the kind
|
||||||
|
kindStr := string(split[0]) |
||||||
|
kindInt, parseErr := strconv.Atoi(kindStr) |
||||||
|
if parseErr != nil { |
||||||
|
continue |
||||||
|
} |
||||||
|
kk := kind.New(uint16(kindInt)) |
||||||
|
// Parse the pubkey
|
||||||
|
var pk []byte |
||||||
|
if pk, err = hexenc.DecAppend(nil, split[1]); chk.E(err) { |
||||||
|
continue |
||||||
|
} |
||||||
|
// Only allow users to delete their own events
|
||||||
|
if !utils.FastEqual(pk, ev.Pubkey) { |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
// Build filter for events to delete
|
||||||
|
delFilter := &filter.F{ |
||||||
|
Authors: tag.NewFromBytesSlice(pk), |
||||||
|
Kinds: kind.NewS(kk), |
||||||
|
} |
||||||
|
|
||||||
|
// For parameterized replaceable events, add d-tag filter
|
||||||
|
if kind.IsParameterizedReplaceable(kk.K) && len(split) >= 3 { |
||||||
|
dValue := split[2] |
||||||
|
delFilter.Tags = tag.NewS(tag.NewFromAny([]byte("d"), dValue)) |
||||||
|
} |
||||||
|
|
||||||
|
// Find matching events
|
||||||
|
var idxs []database.Range |
||||||
|
if idxs, err = database.GetIndexesFromFilter(delFilter); chk.E(err) { |
||||||
|
continue |
||||||
|
} |
||||||
|
var sers types.Uint40s |
||||||
|
for _, idx := range idxs { |
||||||
|
var s types.Uint40s |
||||||
|
if s, err = w.GetSerialsByRange(idx); chk.E(err) { |
||||||
|
continue |
||||||
|
} |
||||||
|
sers = append(sers, s...) |
||||||
|
} |
||||||
|
|
||||||
|
// Delete events older than the deletion event
|
||||||
|
if len(sers) > 0 { |
||||||
|
var idPkTss []*store.IdPkTs |
||||||
|
var tmp []*store.IdPkTs |
||||||
|
if tmp, err = w.GetFullIdPubkeyBySerials(sers); chk.E(err) { |
||||||
|
continue |
||||||
|
} |
||||||
|
idPkTss = append(idPkTss, tmp...) |
||||||
|
// Sort by timestamp
|
||||||
|
sort.Slice(idPkTss, func(i, j int) bool { |
||||||
|
return idPkTss[i].Ts > idPkTss[j].Ts |
||||||
|
}) |
||||||
|
for _, v := range idPkTss { |
||||||
|
if v.Ts < ev.CreatedAt { |
||||||
|
if err = w.DeleteEvent(context.Background(), v.Id); chk.E(err) { |
||||||
|
w.Logger.Warnf("failed to delete event %x via a-tag: %v", v.Id, err) |
||||||
|
continue |
||||||
|
} |
||||||
|
w.Logger.Debugf("deleted event %x via a-tag deletion", v.Id) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// If there are no e or a tags, delete all replaceable events of the kinds
|
||||||
|
// specified by the k tags for the pubkey of the delete event.
|
||||||
|
if len(eTags) == 0 && len(aTags) == 0 { |
||||||
|
// Parse the kind tags
|
||||||
|
var kinds []*kind.K |
||||||
|
for _, k := range kTags { |
||||||
|
kv := k.Value() |
||||||
|
iv := ints.New(0) |
||||||
|
if _, err = iv.Unmarshal(kv); chk.E(err) { |
||||||
|
continue |
||||||
|
} |
||||||
|
kinds = append(kinds, kind.New(iv.N)) |
||||||
|
} |
||||||
|
|
||||||
|
var idxs []database.Range |
||||||
|
if idxs, err = database.GetIndexesFromFilter( |
||||||
|
&filter.F{ |
||||||
|
Authors: tag.NewFromBytesSlice(ev.Pubkey), |
||||||
|
Kinds: kind.NewS(kinds...), |
||||||
|
}, |
||||||
|
); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
var sers types.Uint40s |
||||||
|
for _, idx := range idxs { |
||||||
|
var s types.Uint40s |
||||||
|
if s, err = w.GetSerialsByRange(idx); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
sers = append(sers, s...) |
||||||
|
} |
||||||
|
|
||||||
|
if len(sers) > 0 { |
||||||
|
var idPkTss []*store.IdPkTs |
||||||
|
var tmp []*store.IdPkTs |
||||||
|
if tmp, err = w.GetFullIdPubkeyBySerials(sers); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
idPkTss = append(idPkTss, tmp...) |
||||||
|
// Sort by timestamp
|
||||||
|
sort.Slice(idPkTss, func(i, j int) bool { |
||||||
|
return idPkTss[i].Ts > idPkTss[j].Ts |
||||||
|
}) |
||||||
|
for _, v := range idPkTss { |
||||||
|
if v.Ts < ev.CreatedAt { |
||||||
|
if err = w.DeleteEvent(context.Background(), v.Id); chk.E(err) { |
||||||
|
continue |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// CheckForDeleted checks if the event has been deleted, and returns an error with
|
||||||
|
// prefix "blocked:" if it is. This function also allows designating admin
|
||||||
|
// pubkeys that may also delete the event.
|
||||||
|
func (w *W) CheckForDeleted(ev *event.E, admins [][]byte) (err error) { |
||||||
|
keys := append([][]byte{ev.Pubkey}, admins...) |
||||||
|
authors := tag.NewFromBytesSlice(keys...) |
||||||
|
|
||||||
|
// If the event is addressable, check for a deletion event with the same
|
||||||
|
// kind/pubkey/dtag
|
||||||
|
if kind.IsParameterizedReplaceable(ev.Kind) { |
||||||
|
var idxs []database.Range |
||||||
|
// Construct an a-tag
|
||||||
|
t := ev.Tags.GetFirst([]byte("d")) |
||||||
|
var dTagValue []byte |
||||||
|
if t != nil { |
||||||
|
dTagValue = t.Value() |
||||||
|
} |
||||||
|
a := atag.T{ |
||||||
|
Kind: kind.New(ev.Kind), |
||||||
|
Pubkey: ev.Pubkey, |
||||||
|
DTag: dTagValue, |
||||||
|
} |
||||||
|
at := a.Marshal(nil) |
||||||
|
if idxs, err = database.GetIndexesFromFilter( |
||||||
|
&filter.F{ |
||||||
|
Authors: authors, |
||||||
|
Kinds: kind.NewS(kind.Deletion), |
||||||
|
Tags: tag.NewS(tag.NewFromAny("#a", at)), |
||||||
|
}, |
||||||
|
); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
var sers types.Uint40s |
||||||
|
for _, idx := range idxs { |
||||||
|
var s types.Uint40s |
||||||
|
if s, err = w.GetSerialsByRange(idx); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
sers = append(sers, s...) |
||||||
|
} |
||||||
|
|
||||||
|
if len(sers) > 0 { |
||||||
|
var idPkTss []*store.IdPkTs |
||||||
|
var tmp []*store.IdPkTs |
||||||
|
if tmp, err = w.GetFullIdPubkeyBySerials(sers); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
idPkTss = append(idPkTss, tmp...) |
||||||
|
// Find the newest deletion timestamp
|
||||||
|
maxTs := idPkTss[0].Ts |
||||||
|
for i := 1; i < len(idPkTss); i++ { |
||||||
|
if idPkTss[i].Ts > maxTs { |
||||||
|
maxTs = idPkTss[i].Ts |
||||||
|
} |
||||||
|
} |
||||||
|
if ev.CreatedAt < maxTs { |
||||||
|
err = errorf.E( |
||||||
|
"blocked: %0x was deleted by address %s because it is older than the delete: event: %d delete: %d", |
||||||
|
ev.ID, at, ev.CreatedAt, maxTs, |
||||||
|
) |
||||||
|
return |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// If the event is replaceable, check if there is a deletion event newer
|
||||||
|
// than the event
|
||||||
|
if kind.IsReplaceable(ev.Kind) { |
||||||
|
var idxs []database.Range |
||||||
|
if idxs, err = database.GetIndexesFromFilter( |
||||||
|
&filter.F{ |
||||||
|
Authors: tag.NewFromBytesSlice(ev.Pubkey), |
||||||
|
Kinds: kind.NewS(kind.Deletion), |
||||||
|
Tags: tag.NewS( |
||||||
|
tag.NewFromAny("#k", fmt.Sprint(ev.Kind)), |
||||||
|
), |
||||||
|
}, |
||||||
|
); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
var sers types.Uint40s |
||||||
|
for _, idx := range idxs { |
||||||
|
var s types.Uint40s |
||||||
|
if s, err = w.GetSerialsByRange(idx); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
sers = append(sers, s...) |
||||||
|
} |
||||||
|
|
||||||
|
if len(sers) > 0 { |
||||||
|
var idPkTss []*store.IdPkTs |
||||||
|
var tmp []*store.IdPkTs |
||||||
|
if tmp, err = w.GetFullIdPubkeyBySerials(sers); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
idPkTss = append(idPkTss, tmp...) |
||||||
|
// Find the newest deletion
|
||||||
|
maxTs := idPkTss[0].Ts |
||||||
|
maxId := idPkTss[0].Id |
||||||
|
for i := 1; i < len(idPkTss); i++ { |
||||||
|
if idPkTss[i].Ts > maxTs { |
||||||
|
maxTs = idPkTss[i].Ts |
||||||
|
maxId = idPkTss[i].Id |
||||||
|
} |
||||||
|
} |
||||||
|
if ev.CreatedAt < maxTs { |
||||||
|
err = fmt.Errorf( |
||||||
|
"blocked: %0x was deleted: the event is older than the delete event %0x: event: %d delete: %d", |
||||||
|
ev.ID, maxId, ev.CreatedAt, maxTs, |
||||||
|
) |
||||||
|
return |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// This type of delete can also use an a tag to specify kind and author
|
||||||
|
idxs = nil |
||||||
|
a := atag.T{ |
||||||
|
Kind: kind.New(ev.Kind), |
||||||
|
Pubkey: ev.Pubkey, |
||||||
|
} |
||||||
|
at := a.Marshal(nil) |
||||||
|
if idxs, err = database.GetIndexesFromFilter( |
||||||
|
&filter.F{ |
||||||
|
Authors: authors, |
||||||
|
Kinds: kind.NewS(kind.Deletion), |
||||||
|
Tags: tag.NewS(tag.NewFromAny("#a", at)), |
||||||
|
}, |
||||||
|
); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
sers = nil |
||||||
|
for _, idx := range idxs { |
||||||
|
var s types.Uint40s |
||||||
|
if s, err = w.GetSerialsByRange(idx); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
sers = append(sers, s...) |
||||||
|
} |
||||||
|
|
||||||
|
if len(sers) > 0 { |
||||||
|
var idPkTss []*store.IdPkTs |
||||||
|
var tmp []*store.IdPkTs |
||||||
|
if tmp, err = w.GetFullIdPubkeyBySerials(sers); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
idPkTss = append(idPkTss, tmp...) |
||||||
|
// Find the newest deletion
|
||||||
|
maxTs := idPkTss[0].Ts |
||||||
|
for i := 1; i < len(idPkTss); i++ { |
||||||
|
if idPkTss[i].Ts > maxTs { |
||||||
|
maxTs = idPkTss[i].Ts |
||||||
|
} |
||||||
|
} |
||||||
|
if ev.CreatedAt < maxTs { |
||||||
|
err = errorf.E( |
||||||
|
"blocked: %0x was deleted by address %s because it is older than the delete: event: %d delete: %d", |
||||||
|
ev.ID, at, ev.CreatedAt, maxTs, |
||||||
|
) |
||||||
|
return |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Otherwise check for a delete by event id
|
||||||
|
var idxs []database.Range |
||||||
|
if idxs, err = database.GetIndexesFromFilter( |
||||||
|
&filter.F{ |
||||||
|
Authors: authors, |
||||||
|
Kinds: kind.NewS(kind.Deletion), |
||||||
|
Tags: tag.NewS( |
||||||
|
tag.NewFromAny("e", hexenc.Enc(ev.ID)), |
||||||
|
), |
||||||
|
}, |
||||||
|
); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
for _, idx := range idxs { |
||||||
|
var s types.Uint40s |
||||||
|
if s, err = w.GetSerialsByRange(idx); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
if len(s) > 0 { |
||||||
|
// Any e-tag deletion found means the exact event was deleted
|
||||||
|
err = errorf.E("blocked: %0x has been deleted", ev.ID) |
||||||
|
return |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return |
||||||
|
} |
||||||
@ -0,0 +1,256 @@ |
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package wasmdb |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"errors" |
||||||
|
|
||||||
|
"github.com/aperturerobotics/go-indexeddb/idb" |
||||||
|
"lol.mleku.dev/chk" |
||||||
|
|
||||||
|
"git.mleku.dev/mleku/nostr/encoders/event" |
||||||
|
"next.orly.dev/pkg/database/indexes" |
||||||
|
"next.orly.dev/pkg/database/indexes/types" |
||||||
|
"next.orly.dev/pkg/interfaces/store" |
||||||
|
) |
||||||
|
|
||||||
|
// FetchEventBySerial retrieves an event by its serial number
|
||||||
|
func (w *W) FetchEventBySerial(ser *types.Uint40) (ev *event.E, err error) { |
||||||
|
if ser == nil { |
||||||
|
return nil, errors.New("nil serial") |
||||||
|
} |
||||||
|
|
||||||
|
// First try small event store (sev prefix)
|
||||||
|
ev, err = w.fetchSmallEvent(ser) |
||||||
|
if err == nil && ev != nil { |
||||||
|
return ev, nil |
||||||
|
} |
||||||
|
|
||||||
|
// Then try large event store (evt prefix)
|
||||||
|
ev, err = w.fetchLargeEvent(ser) |
||||||
|
if err == nil && ev != nil { |
||||||
|
return ev, nil |
||||||
|
} |
||||||
|
|
||||||
|
return nil, errors.New("event not found") |
||||||
|
} |
||||||
|
|
||||||
|
// fetchSmallEvent fetches an event from the small event store
|
||||||
|
func (w *W) fetchSmallEvent(ser *types.Uint40) (*event.E, error) { |
||||||
|
// Build the key prefix
|
||||||
|
keyBuf := new(bytes.Buffer) |
||||||
|
if err := indexes.SmallEventEnc(ser).MarshalWrite(keyBuf); chk.E(err) { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
prefix := keyBuf.Bytes() |
||||||
|
|
||||||
|
// Open transaction
|
||||||
|
tx, err := w.db.Transaction(idb.TransactionReadOnly, string(indexes.SmallEventPrefix)) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
store, err := tx.ObjectStore(string(indexes.SmallEventPrefix)) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
// Use cursor to find matching key
|
||||||
|
cursorReq, err := store.OpenCursor(idb.CursorNext) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
var foundEvent *event.E |
||||||
|
err = cursorReq.Iter(w.ctx, func(cursor *idb.CursorWithValue) error { |
||||||
|
keyVal, keyErr := cursor.Key() |
||||||
|
if keyErr != nil { |
||||||
|
return keyErr |
||||||
|
} |
||||||
|
|
||||||
|
keyBytes := safeValueToBytes(keyVal) |
||||||
|
if len(keyBytes) >= len(prefix) && bytes.HasPrefix(keyBytes, prefix) { |
||||||
|
// Found matching key
|
||||||
|
// Format: sev|serial(5)|size(2)|data(variable)
|
||||||
|
if len(keyBytes) > 10 { // 3 + 5 + 2 = 10 minimum
|
||||||
|
sizeOffset := 8 // 3 prefix + 5 serial
|
||||||
|
if len(keyBytes) > sizeOffset+2 { |
||||||
|
size := int(keyBytes[sizeOffset])<<8 | int(keyBytes[sizeOffset+1]) |
||||||
|
dataStart := sizeOffset + 2 |
||||||
|
if len(keyBytes) >= dataStart+size { |
||||||
|
eventData := keyBytes[dataStart : dataStart+size] |
||||||
|
ev := new(event.E) |
||||||
|
if unmarshalErr := ev.UnmarshalBinary(bytes.NewReader(eventData)); unmarshalErr == nil { |
||||||
|
foundEvent = ev |
||||||
|
return errors.New("found") // Stop iteration
|
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return cursor.Continue() |
||||||
|
}) |
||||||
|
|
||||||
|
if foundEvent != nil { |
||||||
|
return foundEvent, nil |
||||||
|
} |
||||||
|
if err != nil && err.Error() != "found" { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
return nil, errors.New("small event not found") |
||||||
|
} |
||||||
|
|
||||||
|
// fetchLargeEvent fetches an event from the large event store
|
||||||
|
func (w *W) fetchLargeEvent(ser *types.Uint40) (*event.E, error) { |
||||||
|
// Build the key
|
||||||
|
keyBuf := new(bytes.Buffer) |
||||||
|
if err := indexes.EventEnc(ser).MarshalWrite(keyBuf); chk.E(err) { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
// Open transaction
|
||||||
|
tx, err := w.db.Transaction(idb.TransactionReadOnly, string(indexes.EventPrefix)) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
store, err := tx.ObjectStore(string(indexes.EventPrefix)) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
// Get the value directly
|
||||||
|
keyJS := bytesToSafeValue(keyBuf.Bytes()) |
||||||
|
req, err := store.Get(keyJS) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
val, err := req.Await(w.ctx) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
if val.IsUndefined() || val.IsNull() { |
||||||
|
return nil, errors.New("large event not found") |
||||||
|
} |
||||||
|
|
||||||
|
eventData := safeValueToBytes(val) |
||||||
|
if len(eventData) == 0 { |
||||||
|
return nil, errors.New("empty event data") |
||||||
|
} |
||||||
|
|
||||||
|
ev := new(event.E) |
||||||
|
if err := ev.UnmarshalBinary(bytes.NewReader(eventData)); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
return ev, nil |
||||||
|
} |
||||||
|
|
||||||
|
// FetchEventsBySerials retrieves multiple events by their serial numbers
|
||||||
|
func (w *W) FetchEventsBySerials(serials []*types.Uint40) (events map[uint64]*event.E, err error) { |
||||||
|
events = make(map[uint64]*event.E) |
||||||
|
|
||||||
|
for _, ser := range serials { |
||||||
|
if ser == nil { |
||||||
|
continue |
||||||
|
} |
||||||
|
ev, fetchErr := w.FetchEventBySerial(ser) |
||||||
|
if fetchErr == nil && ev != nil { |
||||||
|
events[ser.Get()] = ev |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return events, nil |
||||||
|
} |
||||||
|
|
||||||
|
// GetFullIdPubkeyBySerial retrieves the ID, pubkey hash, and timestamp for a serial
|
||||||
|
func (w *W) GetFullIdPubkeyBySerial(ser *types.Uint40) (fidpk *store.IdPkTs, err error) { |
||||||
|
if ser == nil { |
||||||
|
return nil, errors.New("nil serial") |
||||||
|
} |
||||||
|
|
||||||
|
// Build the prefix to search for
|
||||||
|
keyBuf := new(bytes.Buffer) |
||||||
|
indexes.FullIdPubkeyEnc(ser, nil, nil, nil).MarshalWrite(keyBuf) |
||||||
|
prefix := keyBuf.Bytes()[:8] // 3 prefix + 5 serial
|
||||||
|
|
||||||
|
// Search in the fpc object store
|
||||||
|
tx, err := w.db.Transaction(idb.TransactionReadOnly, string(indexes.FullIdPubkeyPrefix)) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
objStore, err := tx.ObjectStore(string(indexes.FullIdPubkeyPrefix)) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
// Use cursor to find matching key
|
||||||
|
cursorReq, err := objStore.OpenCursor(idb.CursorNext) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
err = cursorReq.Iter(w.ctx, func(cursor *idb.CursorWithValue) error { |
||||||
|
keyVal, keyErr := cursor.Key() |
||||||
|
if keyErr != nil { |
||||||
|
return keyErr |
||||||
|
} |
||||||
|
|
||||||
|
keyBytes := safeValueToBytes(keyVal) |
||||||
|
if len(keyBytes) >= len(prefix) && bytes.HasPrefix(keyBytes, prefix) { |
||||||
|
// Found matching key
|
||||||
|
// Format: fpc|serial(5)|id(32)|pubkey_hash(8)|timestamp(8)
|
||||||
|
if len(keyBytes) >= 56 { // 3 + 5 + 32 + 8 + 8 = 56
|
||||||
|
fidpk = &store.IdPkTs{ |
||||||
|
Id: make([]byte, 32), |
||||||
|
Pub: make([]byte, 8), |
||||||
|
Ts: 0, |
||||||
|
} |
||||||
|
copy(fidpk.Id, keyBytes[8:40]) |
||||||
|
copy(fidpk.Pub, keyBytes[40:48]) |
||||||
|
// Parse timestamp (big-endian uint64)
|
||||||
|
var ts int64 |
||||||
|
for i := 0; i < 8; i++ { |
||||||
|
ts = (ts << 8) | int64(keyBytes[48+i]) |
||||||
|
} |
||||||
|
fidpk.Ts = ts |
||||||
|
fidpk.Ser = ser.Get() |
||||||
|
return errors.New("found") // Stop iteration
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return cursor.Continue() |
||||||
|
}) |
||||||
|
|
||||||
|
if fidpk != nil { |
||||||
|
return fidpk, nil |
||||||
|
} |
||||||
|
if err != nil && err.Error() != "found" { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
return nil, errors.New("full id pubkey not found") |
||||||
|
} |
||||||
|
|
||||||
|
// GetFullIdPubkeyBySerials retrieves ID/pubkey/timestamp for multiple serials
|
||||||
|
func (w *W) GetFullIdPubkeyBySerials(sers []*types.Uint40) (fidpks []*store.IdPkTs, err error) { |
||||||
|
fidpks = make([]*store.IdPkTs, 0, len(sers)) |
||||||
|
|
||||||
|
for _, ser := range sers { |
||||||
|
if ser == nil { |
||||||
|
continue |
||||||
|
} |
||||||
|
fidpk, fetchErr := w.GetFullIdPubkeyBySerial(ser) |
||||||
|
if fetchErr == nil && fidpk != nil { |
||||||
|
fidpks = append(fidpks, fidpk) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return fidpks, nil |
||||||
|
} |
||||||
@ -0,0 +1,162 @@ |
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package wasmdb |
||||||
|
|
||||||
|
import ( |
||||||
|
"syscall/js" |
||||||
|
|
||||||
|
"github.com/hack-pad/safejs" |
||||||
|
) |
||||||
|
|
||||||
|
// safeValueToBytes converts a safejs.Value to a []byte
|
||||||
|
// This handles Uint8Array, ArrayBuffer, and strings from IndexedDB
|
||||||
|
func safeValueToBytes(val safejs.Value) []byte { |
||||||
|
if val.IsUndefined() || val.IsNull() { |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// Get global Uint8Array and ArrayBuffer constructors
|
||||||
|
uint8ArrayType := safejs.MustGetGlobal("Uint8Array") |
||||||
|
arrayBufferType := safejs.MustGetGlobal("ArrayBuffer") |
||||||
|
|
||||||
|
// Check if it's a Uint8Array
|
||||||
|
isUint8Array, _ := val.InstanceOf(uint8ArrayType) |
||||||
|
if isUint8Array { |
||||||
|
length, err := val.Length() |
||||||
|
if err != nil { |
||||||
|
return nil |
||||||
|
} |
||||||
|
buf := make([]byte, length) |
||||||
|
// Copy bytes - we need to iterate since safejs doesn't have CopyBytesToGo
|
||||||
|
for i := 0; i < length; i++ { |
||||||
|
elem, err := val.Index(i) |
||||||
|
if err != nil { |
||||||
|
return nil |
||||||
|
} |
||||||
|
intVal, err := elem.Int() |
||||||
|
if err != nil { |
||||||
|
return nil |
||||||
|
} |
||||||
|
buf[i] = byte(intVal) |
||||||
|
} |
||||||
|
return buf |
||||||
|
} |
||||||
|
|
||||||
|
// Check if it's an ArrayBuffer
|
||||||
|
isArrayBuffer, _ := val.InstanceOf(arrayBufferType) |
||||||
|
if isArrayBuffer { |
||||||
|
// Create a Uint8Array view of the ArrayBuffer
|
||||||
|
uint8Array, err := uint8ArrayType.New(val) |
||||||
|
if err != nil { |
||||||
|
return nil |
||||||
|
} |
||||||
|
return safeValueToBytes(uint8Array) |
||||||
|
} |
||||||
|
|
||||||
|
// Try to treat it as a typed array-like object
|
||||||
|
length, err := val.Length() |
||||||
|
if err == nil && length > 0 { |
||||||
|
buf := make([]byte, length) |
||||||
|
for i := 0; i < length; i++ { |
||||||
|
elem, err := val.Index(i) |
||||||
|
if err != nil { |
||||||
|
return nil |
||||||
|
} |
||||||
|
intVal, err := elem.Int() |
||||||
|
if err != nil { |
||||||
|
return nil |
||||||
|
} |
||||||
|
buf[i] = byte(intVal) |
||||||
|
} |
||||||
|
return buf |
||||||
|
} |
||||||
|
|
||||||
|
// Last resort: check if it's a string (for string keys in IndexedDB)
|
||||||
|
if val.Type() == safejs.TypeString { |
||||||
|
str, err := val.String() |
||||||
|
if err == nil { |
||||||
|
return []byte(str) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// bytesToSafeValue converts a []byte to a safejs.Value (Uint8Array)
|
||||||
|
func bytesToSafeValue(buf []byte) safejs.Value { |
||||||
|
if buf == nil { |
||||||
|
return safejs.Null() |
||||||
|
} |
||||||
|
|
||||||
|
uint8Array := js.Global().Get("Uint8Array").New(len(buf)) |
||||||
|
js.CopyBytesToJS(uint8Array, buf) |
||||||
|
return safejs.Safe(uint8Array) |
||||||
|
} |
||||||
|
|
||||||
|
// cryptoRandom fills the provided byte slice with cryptographically secure random bytes
|
||||||
|
// using the Web Crypto API (crypto.getRandomValues) or Node.js crypto.randomFillSync
|
||||||
|
func cryptoRandom(buf []byte) error { |
||||||
|
if len(buf) == 0 { |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// First try browser's crypto.getRandomValues
|
||||||
|
crypto := js.Global().Get("crypto") |
||||||
|
if crypto.IsUndefined() { |
||||||
|
// Fallback to msCrypto for older IE
|
||||||
|
crypto = js.Global().Get("msCrypto") |
||||||
|
} |
||||||
|
|
||||||
|
if !crypto.IsUndefined() { |
||||||
|
// Try getRandomValues (browser API)
|
||||||
|
getRandomValues := crypto.Get("getRandomValues") |
||||||
|
if !getRandomValues.IsUndefined() && getRandomValues.Type() == js.TypeFunction { |
||||||
|
// Create a Uint8Array to receive random bytes
|
||||||
|
uint8Array := js.Global().Get("Uint8Array").New(len(buf)) |
||||||
|
|
||||||
|
// Call crypto.getRandomValues - may throw in Node.js
|
||||||
|
defer func() { |
||||||
|
// Recover from panic if this method doesn't work
|
||||||
|
recover() |
||||||
|
}() |
||||||
|
getRandomValues.Invoke(uint8Array) |
||||||
|
|
||||||
|
// Copy the random bytes to our Go slice
|
||||||
|
js.CopyBytesToGo(buf, uint8Array) |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// Try randomFillSync (Node.js API)
|
||||||
|
randomFillSync := crypto.Get("randomFillSync") |
||||||
|
if !randomFillSync.IsUndefined() && randomFillSync.Type() == js.TypeFunction { |
||||||
|
uint8Array := js.Global().Get("Uint8Array").New(len(buf)) |
||||||
|
randomFillSync.Invoke(uint8Array) |
||||||
|
js.CopyBytesToGo(buf, uint8Array) |
||||||
|
return nil |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Try to load Node.js crypto module via require
|
||||||
|
requireFunc := js.Global().Get("require") |
||||||
|
if !requireFunc.IsUndefined() && requireFunc.Type() == js.TypeFunction { |
||||||
|
nodeCrypto := requireFunc.Invoke("crypto") |
||||||
|
if !nodeCrypto.IsUndefined() { |
||||||
|
randomFillSync := nodeCrypto.Get("randomFillSync") |
||||||
|
if !randomFillSync.IsUndefined() && randomFillSync.Type() == js.TypeFunction { |
||||||
|
uint8Array := js.Global().Get("Uint8Array").New(len(buf)) |
||||||
|
randomFillSync.Invoke(uint8Array) |
||||||
|
js.CopyBytesToGo(buf, uint8Array) |
||||||
|
return nil |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return errNoCryptoAPI |
||||||
|
} |
||||||
|
|
||||||
|
// errNoCryptoAPI is returned when the Web Crypto API is not available
|
||||||
|
type cryptoAPIError struct{} |
||||||
|
|
||||||
|
func (cryptoAPIError) Error() string { return "Web Crypto API not available" } |
||||||
|
|
||||||
|
var errNoCryptoAPI = cryptoAPIError{} |
||||||
@ -0,0 +1,293 @@ |
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package wasmdb |
||||||
|
|
||||||
|
import ( |
||||||
|
"bufio" |
||||||
|
"bytes" |
||||||
|
"context" |
||||||
|
"encoding/json" |
||||||
|
"io" |
||||||
|
|
||||||
|
"github.com/aperturerobotics/go-indexeddb/idb" |
||||||
|
"lol.mleku.dev/chk" |
||||||
|
|
||||||
|
"git.mleku.dev/mleku/nostr/encoders/event" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/filter" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/tag" |
||||||
|
"next.orly.dev/pkg/database" |
||||||
|
"next.orly.dev/pkg/database/indexes" |
||||||
|
"next.orly.dev/pkg/database/indexes/types" |
||||||
|
) |
||||||
|
|
||||||
|
// Import reads events from a JSONL reader and imports them into the database
|
||||||
|
func (w *W) Import(rr io.Reader) { |
||||||
|
ctx := context.Background() |
||||||
|
scanner := bufio.NewScanner(rr) |
||||||
|
// Increase buffer size for large events
|
||||||
|
buf := make([]byte, 1024*1024) // 1MB buffer
|
||||||
|
scanner.Buffer(buf, len(buf)) |
||||||
|
|
||||||
|
imported := 0 |
||||||
|
for scanner.Scan() { |
||||||
|
line := scanner.Bytes() |
||||||
|
if len(line) == 0 { |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
ev := event.New() |
||||||
|
if err := json.Unmarshal(line, ev); err != nil { |
||||||
|
w.Logger.Warnf("Import: failed to unmarshal event: %v", err) |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := w.SaveEvent(ctx, ev); err != nil { |
||||||
|
w.Logger.Debugf("Import: failed to save event: %v", err) |
||||||
|
continue |
||||||
|
} |
||||||
|
imported++ |
||||||
|
} |
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil { |
||||||
|
w.Logger.Errorf("Import: scanner error: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
w.Logger.Infof("Import: imported %d events", imported) |
||||||
|
} |
||||||
|
|
||||||
|
// Export writes events to a JSONL writer, optionally filtered by pubkeys
|
||||||
|
func (w *W) Export(c context.Context, wr io.Writer, pubkeys ...[]byte) { |
||||||
|
var evs event.S |
||||||
|
var err error |
||||||
|
|
||||||
|
// Query events
|
||||||
|
if len(pubkeys) > 0 { |
||||||
|
// Export only events from specified pubkeys
|
||||||
|
for _, pk := range pubkeys { |
||||||
|
// Get all serials for this pubkey
|
||||||
|
serials, err := w.GetSerialsByPubkey(pk) |
||||||
|
if err != nil { |
||||||
|
w.Logger.Warnf("Export: failed to get serials for pubkey: %v", err) |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
for _, ser := range serials { |
||||||
|
ev, err := w.FetchEventBySerial(ser) |
||||||
|
if err != nil || ev == nil { |
||||||
|
continue |
||||||
|
} |
||||||
|
evs = append(evs, ev) |
||||||
|
} |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Export all events
|
||||||
|
evs, err = w.getAllEvents(c) |
||||||
|
if err != nil { |
||||||
|
w.Logger.Errorf("Export: failed to get all events: %v", err) |
||||||
|
return |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Write events as JSONL
|
||||||
|
exported := 0 |
||||||
|
for _, ev := range evs { |
||||||
|
data, err := json.Marshal(ev) |
||||||
|
if err != nil { |
||||||
|
w.Logger.Warnf("Export: failed to marshal event: %v", err) |
||||||
|
continue |
||||||
|
} |
||||||
|
wr.Write(data) |
||||||
|
wr.Write([]byte("\n")) |
||||||
|
exported++ |
||||||
|
} |
||||||
|
|
||||||
|
w.Logger.Infof("Export: exported %d events", exported) |
||||||
|
} |
||||||
|
|
||||||
|
// ImportEventsFromReader imports events from a JSONL reader with context support
|
||||||
|
func (w *W) ImportEventsFromReader(ctx context.Context, rr io.Reader) error { |
||||||
|
scanner := bufio.NewScanner(rr) |
||||||
|
buf := make([]byte, 1024*1024) |
||||||
|
scanner.Buffer(buf, len(buf)) |
||||||
|
|
||||||
|
imported := 0 |
||||||
|
for scanner.Scan() { |
||||||
|
select { |
||||||
|
case <-ctx.Done(): |
||||||
|
w.Logger.Infof("ImportEventsFromReader: cancelled after %d events", imported) |
||||||
|
return ctx.Err() |
||||||
|
default: |
||||||
|
} |
||||||
|
|
||||||
|
line := scanner.Bytes() |
||||||
|
if len(line) == 0 { |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
ev := event.New() |
||||||
|
if err := json.Unmarshal(line, ev); err != nil { |
||||||
|
w.Logger.Warnf("ImportEventsFromReader: failed to unmarshal: %v", err) |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := w.SaveEvent(ctx, ev); err != nil { |
||||||
|
w.Logger.Debugf("ImportEventsFromReader: failed to save: %v", err) |
||||||
|
continue |
||||||
|
} |
||||||
|
imported++ |
||||||
|
} |
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
w.Logger.Infof("ImportEventsFromReader: imported %d events", imported) |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// ImportEventsFromStrings imports events from JSON strings with policy checking
|
||||||
|
func (w *W) ImportEventsFromStrings( |
||||||
|
ctx context.Context, |
||||||
|
eventJSONs []string, |
||||||
|
policyManager interface { |
||||||
|
CheckPolicy(action string, ev *event.E, pubkey []byte, remote string) (bool, error) |
||||||
|
}, |
||||||
|
) error { |
||||||
|
imported := 0 |
||||||
|
|
||||||
|
for _, jsonStr := range eventJSONs { |
||||||
|
select { |
||||||
|
case <-ctx.Done(): |
||||||
|
w.Logger.Infof("ImportEventsFromStrings: cancelled after %d events", imported) |
||||||
|
return ctx.Err() |
||||||
|
default: |
||||||
|
} |
||||||
|
|
||||||
|
ev := event.New() |
||||||
|
if err := json.Unmarshal([]byte(jsonStr), ev); err != nil { |
||||||
|
w.Logger.Warnf("ImportEventsFromStrings: failed to unmarshal: %v", err) |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
// Check policy if manager is provided
|
||||||
|
if policyManager != nil { |
||||||
|
allowed, err := policyManager.CheckPolicy("write", ev, ev.Pubkey, "import") |
||||||
|
if err != nil || !allowed { |
||||||
|
w.Logger.Debugf("ImportEventsFromStrings: policy rejected event") |
||||||
|
continue |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if _, err := w.SaveEvent(ctx, ev); err != nil { |
||||||
|
w.Logger.Debugf("ImportEventsFromStrings: failed to save: %v", err) |
||||||
|
continue |
||||||
|
} |
||||||
|
imported++ |
||||||
|
} |
||||||
|
|
||||||
|
w.Logger.Infof("ImportEventsFromStrings: imported %d events", imported) |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// GetSerialsByPubkey returns all event serials for a given pubkey
|
||||||
|
func (w *W) GetSerialsByPubkey(pubkey []byte) ([]*types.Uint40, error) { |
||||||
|
// Build range for pubkey index
|
||||||
|
idx, err := database.GetIndexesFromFilter(&filter.F{ |
||||||
|
Authors: tag.NewFromBytesSlice(pubkey), |
||||||
|
}) |
||||||
|
if chk.E(err) { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
var serials []*types.Uint40 |
||||||
|
for _, r := range idx { |
||||||
|
sers, err := w.GetSerialsByRange(r) |
||||||
|
if err != nil { |
||||||
|
continue |
||||||
|
} |
||||||
|
serials = append(serials, sers...) |
||||||
|
} |
||||||
|
|
||||||
|
return serials, nil |
||||||
|
} |
||||||
|
|
||||||
|
// getAllEvents retrieves all events from the database
|
||||||
|
func (w *W) getAllEvents(c context.Context) (event.S, error) { |
||||||
|
// Scan through the small event store and large event store
|
||||||
|
var events event.S |
||||||
|
|
||||||
|
// Get events from small event store
|
||||||
|
sevEvents, err := w.scanEventStore(string(indexes.SmallEventPrefix), true) |
||||||
|
if err == nil { |
||||||
|
events = append(events, sevEvents...) |
||||||
|
} |
||||||
|
|
||||||
|
// Get events from large event store
|
||||||
|
evtEvents, err := w.scanEventStore(string(indexes.EventPrefix), false) |
||||||
|
if err == nil { |
||||||
|
events = append(events, evtEvents...) |
||||||
|
} |
||||||
|
|
||||||
|
return events, nil |
||||||
|
} |
||||||
|
|
||||||
|
// scanEventStore scans an event store and returns all events
|
||||||
|
func (w *W) scanEventStore(storeName string, isSmallEvent bool) (event.S, error) { |
||||||
|
tx, err := w.db.Transaction(idb.TransactionReadOnly, storeName) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
store, err := tx.ObjectStore(storeName) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
var events event.S |
||||||
|
|
||||||
|
cursorReq, err := store.OpenCursor(idb.CursorNext) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
err = cursorReq.Iter(w.ctx, func(cursor *idb.CursorWithValue) error { |
||||||
|
var eventData []byte |
||||||
|
|
||||||
|
if isSmallEvent { |
||||||
|
// Small events: data is embedded in the key
|
||||||
|
keyVal, keyErr := cursor.Key() |
||||||
|
if keyErr != nil { |
||||||
|
return keyErr |
||||||
|
} |
||||||
|
keyBytes := safeValueToBytes(keyVal) |
||||||
|
// Format: sev|serial|size_uint16|event_data
|
||||||
|
if len(keyBytes) > 10 { // 3 + 5 + 2 minimum
|
||||||
|
sizeOffset := 8 // 3 prefix + 5 serial
|
||||||
|
if len(keyBytes) > sizeOffset+2 { |
||||||
|
size := int(keyBytes[sizeOffset])<<8 | int(keyBytes[sizeOffset+1]) |
||||||
|
if len(keyBytes) >= sizeOffset+2+size { |
||||||
|
eventData = keyBytes[sizeOffset+2 : sizeOffset+2+size] |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Large events: data is in the value
|
||||||
|
val, valErr := cursor.Value() |
||||||
|
if valErr != nil { |
||||||
|
return valErr |
||||||
|
} |
||||||
|
eventData = safeValueToBytes(val) |
||||||
|
} |
||||||
|
|
||||||
|
if len(eventData) > 0 { |
||||||
|
ev := event.New() |
||||||
|
if err := ev.UnmarshalBinary(bytes.NewReader(eventData)); err == nil { |
||||||
|
events = append(events, ev) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return cursor.Continue() |
||||||
|
}) |
||||||
|
|
||||||
|
return events, err |
||||||
|
} |
||||||
@ -0,0 +1,75 @@ |
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package wasmdb |
||||||
|
|
||||||
|
import ( |
||||||
|
"fmt" |
||||||
|
"syscall/js" |
||||||
|
"time" |
||||||
|
|
||||||
|
"lol.mleku.dev" |
||||||
|
) |
||||||
|
|
||||||
|
// logger provides logging functionality for the wasmdb package
|
||||||
|
// It outputs to the browser console via console.log/warn/error
|
||||||
|
type logger struct { |
||||||
|
level int |
||||||
|
} |
||||||
|
|
||||||
|
// NewLogger creates a new logger with the specified level
|
||||||
|
func NewLogger(level int) *logger { |
||||||
|
return &logger{level: level} |
||||||
|
} |
||||||
|
|
||||||
|
// SetLogLevel changes the logging level
|
||||||
|
func (l *logger) SetLogLevel(level int) { |
||||||
|
l.level = level |
||||||
|
} |
||||||
|
|
||||||
|
// formatMessage creates a formatted log message with timestamp
|
||||||
|
func (l *logger) formatMessage(level, format string, args ...interface{}) string { |
||||||
|
msg := fmt.Sprintf(format, args...) |
||||||
|
return fmt.Sprintf("[%s] [wasmdb] [%s] %s", |
||||||
|
time.Now().Format("15:04:05.000"), |
||||||
|
level, |
||||||
|
msg, |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
// Debugf logs a debug message
|
||||||
|
func (l *logger) Debugf(format string, args ...interface{}) { |
||||||
|
if l.level <= lol.Debug { |
||||||
|
msg := l.formatMessage("DEBUG", format, args...) |
||||||
|
js.Global().Get("console").Call("log", msg) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Infof logs an info message
|
||||||
|
func (l *logger) Infof(format string, args ...interface{}) { |
||||||
|
if l.level <= lol.Info { |
||||||
|
msg := l.formatMessage("INFO", format, args...) |
||||||
|
js.Global().Get("console").Call("log", msg) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Warnf logs a warning message
|
||||||
|
func (l *logger) Warnf(format string, args ...interface{}) { |
||||||
|
if l.level <= lol.Warn { |
||||||
|
msg := l.formatMessage("WARN", format, args...) |
||||||
|
js.Global().Get("console").Call("warn", msg) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Errorf logs an error message
|
||||||
|
func (l *logger) Errorf(format string, args ...interface{}) { |
||||||
|
if l.level <= lol.Error { |
||||||
|
msg := l.formatMessage("ERROR", format, args...) |
||||||
|
js.Global().Get("console").Call("error", msg) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Fatalf logs a fatal message (does not exit in WASM)
|
||||||
|
func (l *logger) Fatalf(format string, args ...interface{}) { |
||||||
|
msg := l.formatMessage("FATAL", format, args...) |
||||||
|
js.Global().Get("console").Call("error", msg) |
||||||
|
} |
||||||
@ -0,0 +1,213 @@ |
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package wasmdb |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"encoding/binary" |
||||||
|
"errors" |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/aperturerobotics/go-indexeddb/idb" |
||||||
|
"github.com/hack-pad/safejs" |
||||||
|
|
||||||
|
"next.orly.dev/pkg/database" |
||||||
|
) |
||||||
|
|
||||||
|
const ( |
||||||
|
// NIP43StoreName is the object store for NIP-43 membership
|
||||||
|
NIP43StoreName = "nip43" |
||||||
|
|
||||||
|
// InvitesStoreName is the object store for invite codes
|
||||||
|
InvitesStoreName = "invites" |
||||||
|
) |
||||||
|
|
||||||
|
// AddNIP43Member adds a pubkey as a NIP-43 member with the given invite code
|
||||||
|
func (w *W) AddNIP43Member(pubkey []byte, inviteCode string) error { |
||||||
|
if len(pubkey) != 32 { |
||||||
|
return errors.New("invalid pubkey length") |
||||||
|
} |
||||||
|
|
||||||
|
// Create membership record
|
||||||
|
membership := &database.NIP43Membership{ |
||||||
|
Pubkey: make([]byte, 32), |
||||||
|
InviteCode: inviteCode, |
||||||
|
AddedAt: time.Now(), |
||||||
|
} |
||||||
|
copy(membership.Pubkey, pubkey) |
||||||
|
|
||||||
|
// Serialize membership
|
||||||
|
data := w.serializeNIP43Membership(membership) |
||||||
|
|
||||||
|
// Store using pubkey as key
|
||||||
|
return w.setStoreValue(NIP43StoreName, string(pubkey), data) |
||||||
|
} |
||||||
|
|
||||||
|
// RemoveNIP43Member removes a pubkey from NIP-43 membership
|
||||||
|
func (w *W) RemoveNIP43Member(pubkey []byte) error { |
||||||
|
return w.deleteStoreValue(NIP43StoreName, string(pubkey)) |
||||||
|
} |
||||||
|
|
||||||
|
// IsNIP43Member checks if a pubkey is a NIP-43 member
|
||||||
|
func (w *W) IsNIP43Member(pubkey []byte) (isMember bool, err error) { |
||||||
|
data, err := w.getStoreValue(NIP43StoreName, string(pubkey)) |
||||||
|
if err != nil { |
||||||
|
return false, nil // Not found is not an error, just not a member
|
||||||
|
} |
||||||
|
return data != nil, nil |
||||||
|
} |
||||||
|
|
||||||
|
// GetNIP43Membership returns the full membership details for a pubkey
|
||||||
|
func (w *W) GetNIP43Membership(pubkey []byte) (*database.NIP43Membership, error) { |
||||||
|
data, err := w.getStoreValue(NIP43StoreName, string(pubkey)) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
if data == nil { |
||||||
|
return nil, errors.New("membership not found") |
||||||
|
} |
||||||
|
|
||||||
|
return w.deserializeNIP43Membership(data) |
||||||
|
} |
||||||
|
|
||||||
|
// GetAllNIP43Members returns all NIP-43 member pubkeys
|
||||||
|
func (w *W) GetAllNIP43Members() ([][]byte, error) { |
||||||
|
tx, err := w.db.Transaction(idb.TransactionReadOnly, NIP43StoreName) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
store, err := tx.ObjectStore(NIP43StoreName) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
var members [][]byte |
||||||
|
|
||||||
|
cursorReq, err := store.OpenCursor(idb.CursorNext) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
err = cursorReq.Iter(w.ctx, func(cursor *idb.CursorWithValue) error { |
||||||
|
keyVal, keyErr := cursor.Key() |
||||||
|
if keyErr != nil { |
||||||
|
return keyErr |
||||||
|
} |
||||||
|
|
||||||
|
// Key is the pubkey stored as string
|
||||||
|
keyBytes := safeValueToBytes(keyVal) |
||||||
|
if len(keyBytes) == 32 { |
||||||
|
pubkey := make([]byte, 32) |
||||||
|
copy(pubkey, keyBytes) |
||||||
|
members = append(members, pubkey) |
||||||
|
} |
||||||
|
|
||||||
|
return cursor.Continue() |
||||||
|
}) |
||||||
|
|
||||||
|
if err != nil && err.Error() != "found" { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
return members, nil |
||||||
|
} |
||||||
|
|
||||||
|
// StoreInviteCode stores an invite code with expiration time
|
||||||
|
func (w *W) StoreInviteCode(code string, expiresAt time.Time) error { |
||||||
|
// Serialize expiration time as unix timestamp
|
||||||
|
data := make([]byte, 8) |
||||||
|
binary.BigEndian.PutUint64(data, uint64(expiresAt.Unix())) |
||||||
|
|
||||||
|
return w.setStoreValue(InvitesStoreName, code, data) |
||||||
|
} |
||||||
|
|
||||||
|
// ValidateInviteCode checks if an invite code is valid (exists and not expired)
|
||||||
|
func (w *W) ValidateInviteCode(code string) (valid bool, err error) { |
||||||
|
data, err := w.getStoreValue(InvitesStoreName, code) |
||||||
|
if err != nil { |
||||||
|
return false, nil |
||||||
|
} |
||||||
|
if data == nil || len(data) < 8 { |
||||||
|
return false, nil |
||||||
|
} |
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
expiresAt := time.Unix(int64(binary.BigEndian.Uint64(data)), 0) |
||||||
|
if time.Now().After(expiresAt) { |
||||||
|
return false, nil |
||||||
|
} |
||||||
|
|
||||||
|
return true, nil |
||||||
|
} |
||||||
|
|
||||||
|
// DeleteInviteCode removes an invite code
|
||||||
|
func (w *W) DeleteInviteCode(code string) error { |
||||||
|
return w.deleteStoreValue(InvitesStoreName, code) |
||||||
|
} |
||||||
|
|
||||||
|
// PublishNIP43MembershipEvent is a no-op in WASM (events are handled by the relay)
|
||||||
|
func (w *W) PublishNIP43MembershipEvent(kind int, pubkey []byte) error { |
||||||
|
// In WASM context, this would typically be handled by the client
|
||||||
|
// This is a no-op implementation
|
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// serializeNIP43Membership converts a membership to bytes for storage
|
||||||
|
func (w *W) serializeNIP43Membership(m *database.NIP43Membership) []byte { |
||||||
|
buf := new(bytes.Buffer) |
||||||
|
|
||||||
|
// Write pubkey (32 bytes)
|
||||||
|
buf.Write(m.Pubkey) |
||||||
|
|
||||||
|
// Write AddedAt as unix timestamp (8 bytes)
|
||||||
|
ts := make([]byte, 8) |
||||||
|
binary.BigEndian.PutUint64(ts, uint64(m.AddedAt.Unix())) |
||||||
|
buf.Write(ts) |
||||||
|
|
||||||
|
// Write invite code length (4 bytes) + invite code
|
||||||
|
codeBytes := []byte(m.InviteCode) |
||||||
|
codeLen := make([]byte, 4) |
||||||
|
binary.BigEndian.PutUint32(codeLen, uint32(len(codeBytes))) |
||||||
|
buf.Write(codeLen) |
||||||
|
buf.Write(codeBytes) |
||||||
|
|
||||||
|
return buf.Bytes() |
||||||
|
} |
||||||
|
|
||||||
|
// deserializeNIP43Membership converts bytes back to a membership
|
||||||
|
func (w *W) deserializeNIP43Membership(data []byte) (*database.NIP43Membership, error) { |
||||||
|
if len(data) < 44 { // 32 + 8 + 4 minimum
|
||||||
|
return nil, errors.New("invalid membership data") |
||||||
|
} |
||||||
|
|
||||||
|
m := &database.NIP43Membership{} |
||||||
|
|
||||||
|
// Read pubkey
|
||||||
|
m.Pubkey = make([]byte, 32) |
||||||
|
copy(m.Pubkey, data[:32]) |
||||||
|
|
||||||
|
// Read AddedAt
|
||||||
|
m.AddedAt = time.Unix(int64(binary.BigEndian.Uint64(data[32:40])), 0) |
||||||
|
|
||||||
|
// Read invite code
|
||||||
|
codeLen := binary.BigEndian.Uint32(data[40:44]) |
||||||
|
if len(data) < int(44+codeLen) { |
||||||
|
return nil, errors.New("invalid invite code length") |
||||||
|
} |
||||||
|
m.InviteCode = string(data[44 : 44+codeLen]) |
||||||
|
|
||||||
|
return m, nil |
||||||
|
} |
||||||
|
|
||||||
|
// Helper to convert safejs.Value to string for keys
|
||||||
|
func safeValueToString(v safejs.Value) string { |
||||||
|
if v.IsUndefined() || v.IsNull() { |
||||||
|
return "" |
||||||
|
} |
||||||
|
str, err := v.String() |
||||||
|
if err != nil { |
||||||
|
return "" |
||||||
|
} |
||||||
|
return str |
||||||
|
} |
||||||
@ -0,0 +1,767 @@ |
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package wasmdb |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"context" |
||||||
|
"errors" |
||||||
|
"sort" |
||||||
|
"strconv" |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/aperturerobotics/go-indexeddb/idb" |
||||||
|
"lol.mleku.dev/chk" |
||||||
|
sha256 "github.com/minio/sha256-simd" |
||||||
|
|
||||||
|
"git.mleku.dev/mleku/nostr/encoders/event" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/filter" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/hex" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/ints" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/kind" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/tag" |
||||||
|
"next.orly.dev/pkg/database" |
||||||
|
"next.orly.dev/pkg/database/indexes/types" |
||||||
|
"next.orly.dev/pkg/interfaces/store" |
||||||
|
"next.orly.dev/pkg/utils" |
||||||
|
) |
||||||
|
|
||||||
|
// CheckExpiration checks if an event has expired based on its "expiration" tag
|
||||||
|
func CheckExpiration(ev *event.E) (expired bool) { |
||||||
|
var err error |
||||||
|
expTag := ev.Tags.GetFirst([]byte("expiration")) |
||||||
|
if expTag != nil { |
||||||
|
expTS := ints.New(0) |
||||||
|
if _, err = expTS.Unmarshal(expTag.Value()); err == nil { |
||||||
|
if int64(expTS.N) < time.Now().Unix() { |
||||||
|
return true |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// GetSerialsByRange retrieves serials from an index range using cursor iteration.
|
||||||
|
// The index keys must end with a 5-byte serial number.
|
||||||
|
func (w *W) GetSerialsByRange(idx database.Range) (sers types.Uint40s, err error) { |
||||||
|
if len(idx.Start) < 3 { |
||||||
|
return nil, errors.New("invalid range: start key too short") |
||||||
|
} |
||||||
|
|
||||||
|
// Extract the object store name from the 3-byte prefix
|
||||||
|
storeName := string(idx.Start[:3]) |
||||||
|
|
||||||
|
// Open a read transaction
|
||||||
|
tx, err := w.db.Transaction(idb.TransactionReadOnly, storeName) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
objStore, err := tx.ObjectStore(storeName) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
// Open cursor in reverse order (newest first like Badger)
|
||||||
|
cursorReq, err := objStore.OpenCursor(idb.CursorPrevious) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
// Pre-allocate slice
|
||||||
|
sers = make(types.Uint40s, 0, 100) |
||||||
|
|
||||||
|
// Create end boundary with 0xff suffix for inclusive range
|
||||||
|
endBoundary := make([]byte, len(idx.End)+5) |
||||||
|
copy(endBoundary, idx.End) |
||||||
|
for i := len(idx.End); i < len(endBoundary); i++ { |
||||||
|
endBoundary[i] = 0xff |
||||||
|
} |
||||||
|
|
||||||
|
err = cursorReq.Iter(w.ctx, func(cursor *idb.CursorWithValue) error { |
||||||
|
keyVal, keyErr := cursor.Key() |
||||||
|
if keyErr != nil { |
||||||
|
return keyErr |
||||||
|
} |
||||||
|
|
||||||
|
key := safeValueToBytes(keyVal) |
||||||
|
if len(key) < 8 { // minimum: 3 prefix + 5 serial
|
||||||
|
return cursor.Continue() |
||||||
|
} |
||||||
|
|
||||||
|
// Check if key is within range
|
||||||
|
keyWithoutSerial := key[:len(key)-5] |
||||||
|
|
||||||
|
// Compare with start (lower bound)
|
||||||
|
cmp := bytes.Compare(keyWithoutSerial, idx.Start) |
||||||
|
if cmp < 0 { |
||||||
|
// Key is before range start, stop iteration
|
||||||
|
return errors.New("done") |
||||||
|
} |
||||||
|
|
||||||
|
// Compare with end boundary
|
||||||
|
if bytes.Compare(key, endBoundary) > 0 { |
||||||
|
// Key is after range end, continue to find keys in range
|
||||||
|
return cursor.Continue() |
||||||
|
} |
||||||
|
|
||||||
|
// Extract serial from last 5 bytes
|
||||||
|
ser := new(types.Uint40) |
||||||
|
if err := ser.UnmarshalRead(bytes.NewReader(key[len(key)-5:])); err == nil { |
||||||
|
sers = append(sers, ser) |
||||||
|
} |
||||||
|
|
||||||
|
return cursor.Continue() |
||||||
|
}) |
||||||
|
|
||||||
|
if err != nil && err.Error() != "done" { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
// Sort by serial (ascending)
|
||||||
|
sort.Slice(sers, func(i, j int) bool { |
||||||
|
return sers[i].Get() < sers[j].Get() |
||||||
|
}) |
||||||
|
|
||||||
|
return sers, nil |
||||||
|
} |
||||||
|
|
||||||
|
// QueryForIds retrieves IdPkTs records based on a filter.
|
||||||
|
// Results are sorted by timestamp in reverse chronological order.
|
||||||
|
func (w *W) QueryForIds(c context.Context, f *filter.F) (idPkTs []*store.IdPkTs, err error) { |
||||||
|
if f.Ids != nil && f.Ids.Len() > 0 { |
||||||
|
err = errors.New("query for Ids is invalid for a filter with Ids") |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
var idxs []database.Range |
||||||
|
if idxs, err = database.GetIndexesFromFilter(f); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
var results []*store.IdPkTs |
||||||
|
results = make([]*store.IdPkTs, 0, len(idxs)*100) |
||||||
|
|
||||||
|
// Track match counts for search ranking
|
||||||
|
counts := make(map[uint64]int) |
||||||
|
|
||||||
|
for _, idx := range idxs { |
||||||
|
var founds types.Uint40s |
||||||
|
if founds, err = w.GetSerialsByRange(idx); err != nil { |
||||||
|
w.Logger.Warnf("QueryForIds: GetSerialsByRange error: %v", err) |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
var tmp []*store.IdPkTs |
||||||
|
if tmp, err = w.GetFullIdPubkeyBySerials(founds); err != nil { |
||||||
|
w.Logger.Warnf("QueryForIds: GetFullIdPubkeyBySerials error: %v", err) |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
// Track match counts for search queries
|
||||||
|
if len(f.Search) > 0 { |
||||||
|
for _, v := range tmp { |
||||||
|
counts[v.Ser]++ |
||||||
|
} |
||||||
|
} |
||||||
|
results = append(results, tmp...) |
||||||
|
} |
||||||
|
|
||||||
|
// Deduplicate results
|
||||||
|
seen := make(map[uint64]struct{}, len(results)) |
||||||
|
idPkTs = make([]*store.IdPkTs, 0, len(results)) |
||||||
|
for _, idpk := range results { |
||||||
|
if _, ok := seen[idpk.Ser]; !ok { |
||||||
|
seen[idpk.Ser] = struct{}{} |
||||||
|
idPkTs = append(idPkTs, idpk) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// For search queries combined with other filters, verify matches
|
||||||
|
if len(f.Search) > 0 && ((f.Authors != nil && f.Authors.Len() > 0) || |
||||||
|
(f.Kinds != nil && f.Kinds.Len() > 0) || |
||||||
|
(f.Tags != nil && f.Tags.Len() > 0)) { |
||||||
|
// Build serial list for fetching
|
||||||
|
serials := make([]*types.Uint40, 0, len(idPkTs)) |
||||||
|
for _, v := range idPkTs { |
||||||
|
s := new(types.Uint40) |
||||||
|
s.Set(v.Ser) |
||||||
|
serials = append(serials, s) |
||||||
|
} |
||||||
|
|
||||||
|
var evs map[uint64]*event.E |
||||||
|
if evs, err = w.FetchEventsBySerials(serials); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
filtered := make([]*store.IdPkTs, 0, len(idPkTs)) |
||||||
|
for _, v := range idPkTs { |
||||||
|
ev, ok := evs[v.Ser] |
||||||
|
if !ok || ev == nil { |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
matchesAll := true |
||||||
|
if f.Authors != nil && f.Authors.Len() > 0 && !f.Authors.Contains(ev.Pubkey) { |
||||||
|
matchesAll = false |
||||||
|
} |
||||||
|
if matchesAll && f.Kinds != nil && f.Kinds.Len() > 0 && !f.Kinds.Contains(ev.Kind) { |
||||||
|
matchesAll = false |
||||||
|
} |
||||||
|
if matchesAll && f.Tags != nil && f.Tags.Len() > 0 { |
||||||
|
tagOK := true |
||||||
|
for _, t := range *f.Tags { |
||||||
|
if t.Len() < 2 { |
||||||
|
continue |
||||||
|
} |
||||||
|
key := t.Key() |
||||||
|
values := t.T[1:] |
||||||
|
if !ev.Tags.ContainsAny(key, values) { |
||||||
|
tagOK = false |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
if !tagOK { |
||||||
|
matchesAll = false |
||||||
|
} |
||||||
|
} |
||||||
|
if matchesAll { |
||||||
|
filtered = append(filtered, v) |
||||||
|
} |
||||||
|
} |
||||||
|
idPkTs = filtered |
||||||
|
} |
||||||
|
|
||||||
|
// Sort by timestamp (newest first)
|
||||||
|
if len(f.Search) == 0 { |
||||||
|
sort.Slice(idPkTs, func(i, j int) bool { |
||||||
|
return idPkTs[i].Ts > idPkTs[j].Ts |
||||||
|
}) |
||||||
|
} else { |
||||||
|
// Search ranking: blend match count with recency
|
||||||
|
var maxCount int |
||||||
|
var minTs, maxTs int64 |
||||||
|
if len(idPkTs) > 0 { |
||||||
|
minTs, maxTs = idPkTs[0].Ts, idPkTs[0].Ts |
||||||
|
} |
||||||
|
for _, v := range idPkTs { |
||||||
|
if c := counts[v.Ser]; c > maxCount { |
||||||
|
maxCount = c |
||||||
|
} |
||||||
|
if v.Ts < minTs { |
||||||
|
minTs = v.Ts |
||||||
|
} |
||||||
|
if v.Ts > maxTs { |
||||||
|
maxTs = v.Ts |
||||||
|
} |
||||||
|
} |
||||||
|
tsSpan := maxTs - minTs |
||||||
|
if tsSpan <= 0 { |
||||||
|
tsSpan = 1 |
||||||
|
} |
||||||
|
if maxCount <= 0 { |
||||||
|
maxCount = 1 |
||||||
|
} |
||||||
|
sort.Slice(idPkTs, func(i, j int) bool { |
||||||
|
ci := float64(counts[idPkTs[i].Ser]) / float64(maxCount) |
||||||
|
cj := float64(counts[idPkTs[j].Ser]) / float64(maxCount) |
||||||
|
ai := float64(idPkTs[i].Ts-minTs) / float64(tsSpan) |
||||||
|
aj := float64(idPkTs[j].Ts-minTs) / float64(tsSpan) |
||||||
|
si := 0.5*ci + 0.5*ai |
||||||
|
sj := 0.5*cj + 0.5*aj |
||||||
|
if si == sj { |
||||||
|
return idPkTs[i].Ts > idPkTs[j].Ts |
||||||
|
} |
||||||
|
return si > sj |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// Apply limit
|
||||||
|
if f.Limit != nil && len(idPkTs) > int(*f.Limit) { |
||||||
|
idPkTs = idPkTs[:*f.Limit] |
||||||
|
} |
||||||
|
|
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// QueryForSerials takes a filter and returns matching event serials
|
||||||
|
func (w *W) QueryForSerials(c context.Context, f *filter.F) (sers types.Uint40s, err error) { |
||||||
|
var founds []*types.Uint40 |
||||||
|
var idPkTs []*store.IdPkTs |
||||||
|
|
||||||
|
if f.Ids != nil && f.Ids.Len() > 0 { |
||||||
|
// Use batch lookup for IDs
|
||||||
|
var serialMap map[string]*types.Uint40 |
||||||
|
if serialMap, err = w.GetSerialsByIds(f.Ids); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
for _, ser := range serialMap { |
||||||
|
founds = append(founds, ser) |
||||||
|
} |
||||||
|
var tmp []*store.IdPkTs |
||||||
|
if tmp, err = w.GetFullIdPubkeyBySerials(founds); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
idPkTs = append(idPkTs, tmp...) |
||||||
|
} else { |
||||||
|
if idPkTs, err = w.QueryForIds(c, f); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Extract serials
|
||||||
|
for _, idpk := range idPkTs { |
||||||
|
ser := new(types.Uint40) |
||||||
|
if err = ser.Set(idpk.Ser); chk.E(err) { |
||||||
|
continue |
||||||
|
} |
||||||
|
sers = append(sers, ser) |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// QueryEvents queries events based on a filter
|
||||||
|
func (w *W) QueryEvents(c context.Context, f *filter.F) (evs event.S, err error) { |
||||||
|
return w.QueryEventsWithOptions(c, f, true, false) |
||||||
|
} |
||||||
|
|
||||||
|
// QueryAllVersions queries events and returns all versions of replaceable events
|
||||||
|
func (w *W) QueryAllVersions(c context.Context, f *filter.F) (evs event.S, err error) { |
||||||
|
return w.QueryEventsWithOptions(c, f, true, true) |
||||||
|
} |
||||||
|
|
||||||
|
// QueryEventsWithOptions queries events with additional options for deletion and versioning
|
||||||
|
func (w *W) QueryEventsWithOptions(c context.Context, f *filter.F, includeDeleteEvents bool, showAllVersions bool) (evs event.S, err error) { |
||||||
|
wantMultipleVersions := showAllVersions || (f.Limit != nil && *f.Limit > 1) |
||||||
|
|
||||||
|
var expDeletes types.Uint40s |
||||||
|
var expEvs event.S |
||||||
|
|
||||||
|
// Handle ID-based queries
|
||||||
|
if f.Ids != nil && f.Ids.Len() > 0 { |
||||||
|
w.Logger.Debugf("QueryEvents: ids path, count=%d", f.Ids.Len()) |
||||||
|
|
||||||
|
serials, idErr := w.GetSerialsByIds(f.Ids) |
||||||
|
if idErr != nil { |
||||||
|
w.Logger.Warnf("QueryEvents: error looking up ids: %v", idErr) |
||||||
|
} |
||||||
|
|
||||||
|
// Convert to slice for batch fetch
|
||||||
|
var serialsSlice []*types.Uint40 |
||||||
|
idHexToSerial := make(map[uint64]string, len(serials)) |
||||||
|
for idHex, ser := range serials { |
||||||
|
serialsSlice = append(serialsSlice, ser) |
||||||
|
idHexToSerial[ser.Get()] = idHex |
||||||
|
} |
||||||
|
|
||||||
|
// Batch fetch events
|
||||||
|
var fetchedEvents map[uint64]*event.E |
||||||
|
if fetchedEvents, err = w.FetchEventsBySerials(serialsSlice); err != nil { |
||||||
|
w.Logger.Warnf("QueryEvents: batch fetch failed: %v", err) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Process fetched events
|
||||||
|
for serialValue, ev := range fetchedEvents { |
||||||
|
idHex := idHexToSerial[serialValue] |
||||||
|
|
||||||
|
ser := new(types.Uint40) |
||||||
|
if err = ser.Set(serialValue); err != nil { |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if CheckExpiration(ev) { |
||||||
|
w.Logger.Debugf("QueryEvents: id=%s filtered out due to expiration", idHex) |
||||||
|
expDeletes = append(expDeletes, ser) |
||||||
|
expEvs = append(expEvs, ev) |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
// Check for deletion
|
||||||
|
if derr := w.CheckForDeleted(ev, nil); derr != nil { |
||||||
|
w.Logger.Debugf("QueryEvents: id=%s filtered out due to deletion: %v", idHex, derr) |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
evs = append(evs, ev) |
||||||
|
} |
||||||
|
|
||||||
|
// Sort and apply limit
|
||||||
|
sort.Slice(evs, func(i, j int) bool { |
||||||
|
return evs[i].CreatedAt > evs[j].CreatedAt |
||||||
|
}) |
||||||
|
if f.Limit != nil && len(evs) > int(*f.Limit) { |
||||||
|
evs = evs[:*f.Limit] |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Non-IDs path
|
||||||
|
var idPkTs []*store.IdPkTs |
||||||
|
if idPkTs, err = w.QueryForIds(c, f); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Maps for replaceable event handling
|
||||||
|
replaceableEvents := make(map[string]*event.E) |
||||||
|
replaceableEventVersions := make(map[string]event.S) |
||||||
|
paramReplaceableEvents := make(map[string]map[string]*event.E) |
||||||
|
paramReplaceableEventVersions := make(map[string]map[string]event.S) |
||||||
|
var regularEvents event.S |
||||||
|
|
||||||
|
// Deletion tracking maps
|
||||||
|
deletionsByKindPubkey := make(map[string]bool) |
||||||
|
deletionsByKindPubkeyDTag := make(map[string]map[string]int64) |
||||||
|
deletedEventIds := make(map[string]bool) |
||||||
|
|
||||||
|
// Query for deletion events if we have authors
|
||||||
|
if f.Authors != nil && f.Authors.Len() > 0 { |
||||||
|
deletionFilter := &filter.F{ |
||||||
|
Kinds: kind.NewS(kind.New(5)), |
||||||
|
Authors: f.Authors, |
||||||
|
} |
||||||
|
var deletionIdPkTs []*store.IdPkTs |
||||||
|
if deletionIdPkTs, err = w.QueryForIds(c, deletionFilter); err == nil { |
||||||
|
idPkTs = append(idPkTs, deletionIdPkTs...) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Prepare serials for batch fetch
|
||||||
|
var allSerials []*types.Uint40 |
||||||
|
serialToIdPk := make(map[uint64]*store.IdPkTs, len(idPkTs)) |
||||||
|
for _, idpk := range idPkTs { |
||||||
|
ser := new(types.Uint40) |
||||||
|
if err = ser.Set(idpk.Ser); err != nil { |
||||||
|
continue |
||||||
|
} |
||||||
|
allSerials = append(allSerials, ser) |
||||||
|
serialToIdPk[ser.Get()] = idpk |
||||||
|
} |
||||||
|
|
||||||
|
// Batch fetch all events
|
||||||
|
var allEvents map[uint64]*event.E |
||||||
|
if allEvents, err = w.FetchEventsBySerials(allSerials); err != nil { |
||||||
|
w.Logger.Warnf("QueryEvents: batch fetch failed in non-IDs path: %v", err) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// First pass: collect deletion events
|
||||||
|
for serialValue, ev := range allEvents { |
||||||
|
ser := new(types.Uint40) |
||||||
|
if err = ser.Set(serialValue); err != nil { |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
if CheckExpiration(ev) { |
||||||
|
expDeletes = append(expDeletes, ser) |
||||||
|
expEvs = append(expEvs, ev) |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
if ev.Kind == kind.Deletion.K { |
||||||
|
// Process e-tags and a-tags for deletion tracking
|
||||||
|
aTags := ev.Tags.GetAll([]byte("a")) |
||||||
|
for _, aTag := range aTags { |
||||||
|
if aTag.Len() < 2 { |
||||||
|
continue |
||||||
|
} |
||||||
|
split := bytes.Split(aTag.Value(), []byte{':'}) |
||||||
|
if len(split) < 2 { |
||||||
|
continue |
||||||
|
} |
||||||
|
kindInt, parseErr := strconv.Atoi(string(split[0])) |
||||||
|
if parseErr != nil { |
||||||
|
continue |
||||||
|
} |
||||||
|
kk := kind.New(uint16(kindInt)) |
||||||
|
if !kind.IsReplaceable(kk.K) { |
||||||
|
continue |
||||||
|
} |
||||||
|
var pk []byte |
||||||
|
if pk, err = hex.DecAppend(nil, split[1]); err != nil { |
||||||
|
continue |
||||||
|
} |
||||||
|
if !utils.FastEqual(pk, ev.Pubkey) { |
||||||
|
continue |
||||||
|
} |
||||||
|
key := hex.Enc(pk) + ":" + strconv.Itoa(int(kk.K)) |
||||||
|
|
||||||
|
if kind.IsParameterizedReplaceable(kk.K) { |
||||||
|
if len(split) < 3 { |
||||||
|
continue |
||||||
|
} |
||||||
|
if _, exists := deletionsByKindPubkeyDTag[key]; !exists { |
||||||
|
deletionsByKindPubkeyDTag[key] = make(map[string]int64) |
||||||
|
} |
||||||
|
dValue := string(split[2]) |
||||||
|
if ts, ok := deletionsByKindPubkeyDTag[key][dValue]; !ok || ev.CreatedAt > ts { |
||||||
|
deletionsByKindPubkeyDTag[key][dValue] = ev.CreatedAt |
||||||
|
} |
||||||
|
} else { |
||||||
|
deletionsByKindPubkey[key] = true |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Process e-tags for specific event deletions
|
||||||
|
eTags := ev.Tags.GetAll([]byte("e")) |
||||||
|
for _, eTag := range eTags { |
||||||
|
eTagHex := eTag.ValueHex() |
||||||
|
if len(eTagHex) != 64 { |
||||||
|
continue |
||||||
|
} |
||||||
|
evId := make([]byte, sha256.Size) |
||||||
|
if _, hexErr := hex.DecBytes(evId, eTagHex); hexErr != nil { |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
// Look for target in current batch
|
||||||
|
var targetEv *event.E |
||||||
|
for _, candidateEv := range allEvents { |
||||||
|
if utils.FastEqual(candidateEv.ID, evId) { |
||||||
|
targetEv = candidateEv |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Try to fetch if not in batch
|
||||||
|
if targetEv == nil { |
||||||
|
ser, serErr := w.GetSerialById(evId) |
||||||
|
if serErr != nil || ser == nil { |
||||||
|
continue |
||||||
|
} |
||||||
|
targetEv, serErr = w.FetchEventBySerial(ser) |
||||||
|
if serErr != nil || targetEv == nil { |
||||||
|
continue |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if !utils.FastEqual(targetEv.Pubkey, ev.Pubkey) { |
||||||
|
continue |
||||||
|
} |
||||||
|
deletedEventIds[hex.Enc(targetEv.ID)] = true |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Second pass: process all events, filtering deleted ones
|
||||||
|
for _, ev := range allEvents { |
||||||
|
// Tag filter verification
|
||||||
|
if f.Tags != nil && f.Tags.Len() > 0 { |
||||||
|
tagMatches := 0 |
||||||
|
for _, filterTag := range *f.Tags { |
||||||
|
if filterTag.Len() >= 2 { |
||||||
|
filterKey := filterTag.Key() |
||||||
|
var actualKey []byte |
||||||
|
if len(filterKey) == 2 && filterKey[0] == '#' { |
||||||
|
actualKey = filterKey[1:] |
||||||
|
} else { |
||||||
|
actualKey = filterKey |
||||||
|
} |
||||||
|
eventHasTag := false |
||||||
|
if ev.Tags != nil { |
||||||
|
for _, eventTag := range *ev.Tags { |
||||||
|
if eventTag.Len() >= 2 && bytes.Equal(eventTag.Key(), actualKey) { |
||||||
|
for _, filterValue := range filterTag.T[1:] { |
||||||
|
if database.TagValuesMatchUsingTagMethods(eventTag, filterValue) { |
||||||
|
eventHasTag = true |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
if eventHasTag { |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
if eventHasTag { |
||||||
|
tagMatches++ |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
if tagMatches < f.Tags.Len() { |
||||||
|
continue |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Skip deletion events unless explicitly requested
|
||||||
|
if ev.Kind == kind.Deletion.K { |
||||||
|
kind5Requested := false |
||||||
|
if f.Kinds != nil && f.Kinds.Len() > 0 { |
||||||
|
for i := 0; i < f.Kinds.Len(); i++ { |
||||||
|
if f.Kinds.K[i].K == kind.Deletion.K { |
||||||
|
kind5Requested = true |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
if !kind5Requested { |
||||||
|
continue |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Check if event ID is in filter
|
||||||
|
isIdInFilter := false |
||||||
|
if f.Ids != nil && f.Ids.Len() > 0 { |
||||||
|
for i := 0; i < f.Ids.Len(); i++ { |
||||||
|
if utils.FastEqual(ev.ID, (*f.Ids).T[i]) { |
||||||
|
isIdInFilter = true |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Check if specifically deleted
|
||||||
|
eventIdHex := hex.Enc(ev.ID) |
||||||
|
if deletedEventIds[eventIdHex] { |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
// Handle replaceable events
|
||||||
|
if kind.IsReplaceable(ev.Kind) { |
||||||
|
key := hex.Enc(ev.Pubkey) + ":" + strconv.Itoa(int(ev.Kind)) |
||||||
|
if deletionsByKindPubkey[key] && !isIdInFilter { |
||||||
|
continue |
||||||
|
} else if wantMultipleVersions { |
||||||
|
replaceableEventVersions[key] = append(replaceableEventVersions[key], ev) |
||||||
|
} else { |
||||||
|
existing, exists := replaceableEvents[key] |
||||||
|
if !exists || ev.CreatedAt > existing.CreatedAt { |
||||||
|
replaceableEvents[key] = ev |
||||||
|
} |
||||||
|
} |
||||||
|
} else if kind.IsParameterizedReplaceable(ev.Kind) { |
||||||
|
key := hex.Enc(ev.Pubkey) + ":" + strconv.Itoa(int(ev.Kind)) |
||||||
|
dTag := ev.Tags.GetFirst([]byte("d")) |
||||||
|
var dValue string |
||||||
|
if dTag != nil && dTag.Len() > 1 { |
||||||
|
dValue = string(dTag.Value()) |
||||||
|
} |
||||||
|
|
||||||
|
if deletionMap, exists := deletionsByKindPubkeyDTag[key]; exists { |
||||||
|
if delTs, ok := deletionMap[dValue]; ok && ev.CreatedAt < delTs && !isIdInFilter { |
||||||
|
continue |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if wantMultipleVersions { |
||||||
|
if _, exists := paramReplaceableEventVersions[key]; !exists { |
||||||
|
paramReplaceableEventVersions[key] = make(map[string]event.S) |
||||||
|
} |
||||||
|
paramReplaceableEventVersions[key][dValue] = append(paramReplaceableEventVersions[key][dValue], ev) |
||||||
|
} else { |
||||||
|
if _, exists := paramReplaceableEvents[key]; !exists { |
||||||
|
paramReplaceableEvents[key] = make(map[string]*event.E) |
||||||
|
} |
||||||
|
existing, exists := paramReplaceableEvents[key][dValue] |
||||||
|
if !exists || ev.CreatedAt > existing.CreatedAt { |
||||||
|
paramReplaceableEvents[key][dValue] = ev |
||||||
|
} |
||||||
|
} |
||||||
|
} else { |
||||||
|
regularEvents = append(regularEvents, ev) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Collect results
|
||||||
|
if wantMultipleVersions { |
||||||
|
for _, versions := range replaceableEventVersions { |
||||||
|
sort.Slice(versions, func(i, j int) bool { |
||||||
|
return versions[i].CreatedAt > versions[j].CreatedAt |
||||||
|
}) |
||||||
|
limit := len(versions) |
||||||
|
if f.Limit != nil && int(*f.Limit) < limit { |
||||||
|
limit = int(*f.Limit) |
||||||
|
} |
||||||
|
for i := 0; i < limit; i++ { |
||||||
|
evs = append(evs, versions[i]) |
||||||
|
} |
||||||
|
} |
||||||
|
} else { |
||||||
|
for _, ev := range replaceableEvents { |
||||||
|
evs = append(evs, ev) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if wantMultipleVersions { |
||||||
|
for _, dTagMap := range paramReplaceableEventVersions { |
||||||
|
for _, versions := range dTagMap { |
||||||
|
sort.Slice(versions, func(i, j int) bool { |
||||||
|
return versions[i].CreatedAt > versions[j].CreatedAt |
||||||
|
}) |
||||||
|
limit := len(versions) |
||||||
|
if f.Limit != nil && int(*f.Limit) < limit { |
||||||
|
limit = int(*f.Limit) |
||||||
|
} |
||||||
|
for i := 0; i < limit; i++ { |
||||||
|
evs = append(evs, versions[i]) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} else { |
||||||
|
for _, innerMap := range paramReplaceableEvents { |
||||||
|
for _, ev := range innerMap { |
||||||
|
evs = append(evs, ev) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
evs = append(evs, regularEvents...) |
||||||
|
|
||||||
|
// Sort and limit
|
||||||
|
sort.Slice(evs, func(i, j int) bool { |
||||||
|
return evs[i].CreatedAt > evs[j].CreatedAt |
||||||
|
}) |
||||||
|
if f.Limit != nil && len(evs) > int(*f.Limit) { |
||||||
|
evs = evs[:*f.Limit] |
||||||
|
} |
||||||
|
|
||||||
|
// Delete expired events in background
|
||||||
|
go func() { |
||||||
|
for i, ser := range expDeletes { |
||||||
|
w.DeleteEventBySerial(context.Background(), ser, expEvs[i]) |
||||||
|
} |
||||||
|
}() |
||||||
|
} |
||||||
|
|
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// QueryDeleteEventsByTargetId queries for delete events targeting a specific event ID
|
||||||
|
func (w *W) QueryDeleteEventsByTargetId(c context.Context, targetEventId []byte) (evs event.S, err error) { |
||||||
|
f := &filter.F{ |
||||||
|
Kinds: kind.NewS(kind.Deletion), |
||||||
|
Tags: tag.NewS( |
||||||
|
tag.NewFromAny("#e", hex.Enc(targetEventId)), |
||||||
|
), |
||||||
|
} |
||||||
|
return w.QueryEventsWithOptions(c, f, true, false) |
||||||
|
} |
||||||
|
|
||||||
|
// CountEvents counts events matching a filter
|
||||||
|
func (w *W) CountEvents(c context.Context, f *filter.F) (count int, approx bool, err error) { |
||||||
|
approx = false |
||||||
|
if f == nil { |
||||||
|
return 0, false, nil |
||||||
|
} |
||||||
|
|
||||||
|
// For ID-based queries, count resolved IDs
|
||||||
|
if f.Ids != nil && f.Ids.Len() > 0 { |
||||||
|
serials, idErr := w.GetSerialsByIds(f.Ids) |
||||||
|
if idErr != nil { |
||||||
|
return 0, false, idErr |
||||||
|
} |
||||||
|
return len(serials), false, nil |
||||||
|
} |
||||||
|
|
||||||
|
// For other queries, get serials and count
|
||||||
|
var sers types.Uint40s |
||||||
|
if sers, err = w.QueryForSerials(c, f); err != nil { |
||||||
|
return 0, false, err |
||||||
|
} |
||||||
|
|
||||||
|
return len(sers), false, nil |
||||||
|
} |
||||||
|
|
||||||
|
// GetSerialsFromFilter is an alias for QueryForSerials for interface compatibility
|
||||||
|
func (w *W) GetSerialsFromFilter(f *filter.F) (serials types.Uint40s, err error) { |
||||||
|
return w.QueryForSerials(w.ctx, f) |
||||||
|
} |
||||||
@ -0,0 +1,26 @@ |
|||||||
|
#!/bin/bash |
||||||
|
|
||||||
|
# Run wasmdb tests using Node.js with fake-indexeddb |
||||||
|
# This script builds the test binary and runs it in Node.js |
||||||
|
|
||||||
|
set -e |
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
||||||
|
TESTDATA_DIR="$SCRIPT_DIR/testdata" |
||||||
|
WASM_FILE="$TESTDATA_DIR/wasmdb_test.wasm" |
||||||
|
|
||||||
|
# Ensure Node.js dependencies are installed |
||||||
|
if [ ! -d "$TESTDATA_DIR/node_modules" ]; then |
||||||
|
echo "Installing Node.js dependencies..." |
||||||
|
cd "$TESTDATA_DIR" |
||||||
|
npm install |
||||||
|
cd - > /dev/null |
||||||
|
fi |
||||||
|
|
||||||
|
# Build the test binary |
||||||
|
echo "Building WASM test binary..." |
||||||
|
GOOS=js GOARCH=wasm CGO_ENABLED=0 go test -c -o "$WASM_FILE" "$SCRIPT_DIR" |
||||||
|
|
||||||
|
# Run the tests |
||||||
|
echo "Running tests in Node.js..." |
||||||
|
node "$TESTDATA_DIR/run_wasm_tests.mjs" "$WASM_FILE" "$@" |
||||||
@ -0,0 +1,423 @@ |
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package wasmdb |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"context" |
||||||
|
"errors" |
||||||
|
"fmt" |
||||||
|
"strings" |
||||||
|
|
||||||
|
"github.com/aperturerobotics/go-indexeddb/idb" |
||||||
|
"github.com/hack-pad/safejs" |
||||||
|
"lol.mleku.dev/chk" |
||||||
|
|
||||||
|
"git.mleku.dev/mleku/nostr/encoders/event" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/hex" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/kind" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/tag" |
||||||
|
"next.orly.dev/pkg/database" |
||||||
|
"next.orly.dev/pkg/database/indexes" |
||||||
|
"next.orly.dev/pkg/database/indexes/types" |
||||||
|
) |
||||||
|
|
||||||
|
var ( |
||||||
|
// ErrOlderThanExisting is returned when a candidate event is older than an existing replaceable/addressable event.
|
||||||
|
ErrOlderThanExisting = errors.New("older than existing event") |
||||||
|
// ErrMissingDTag is returned when a parameterized replaceable event lacks the required 'd' tag.
|
||||||
|
ErrMissingDTag = errors.New("event is missing a d tag identifier") |
||||||
|
) |
||||||
|
|
||||||
|
// SaveEvent saves an event to the database, generating all necessary indexes.
|
||||||
|
func (w *W) SaveEvent(c context.Context, ev *event.E) (replaced bool, err error) { |
||||||
|
if ev == nil { |
||||||
|
err = errors.New("nil event") |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Reject ephemeral events (kinds 20000-29999) - they should never be stored
|
||||||
|
if ev.Kind >= 20000 && ev.Kind <= 29999 { |
||||||
|
err = errors.New("blocked: ephemeral events should not be stored") |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Validate kind 3 (follow list) events have at least one p tag
|
||||||
|
if ev.Kind == 3 { |
||||||
|
hasPTag := false |
||||||
|
if ev.Tags != nil { |
||||||
|
for _, t := range *ev.Tags { |
||||||
|
if t != nil && t.Len() >= 2 { |
||||||
|
key := t.Key() |
||||||
|
if len(key) == 1 && key[0] == 'p' { |
||||||
|
hasPTag = true |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
if !hasPTag { |
||||||
|
w.Logger.Warnf("SaveEvent: rejecting kind 3 event without p tags from pubkey %x", ev.Pubkey) |
||||||
|
err = errors.New("blocked: kind 3 follow list events must have at least one p tag") |
||||||
|
return |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Check if the event already exists
|
||||||
|
var ser *types.Uint40 |
||||||
|
if ser, err = w.GetSerialById(ev.ID); err == nil && ser != nil { |
||||||
|
err = errors.New("blocked: event already exists: " + hex.Enc(ev.ID[:])) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// If the error is "id not found", we can proceed
|
||||||
|
if err != nil && strings.Contains(err.Error(), "id not found") { |
||||||
|
err = nil |
||||||
|
} else if err != nil { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Check for replacement - only validate, don't delete old events
|
||||||
|
if kind.IsReplaceable(ev.Kind) || kind.IsParameterizedReplaceable(ev.Kind) { |
||||||
|
var werr error |
||||||
|
if replaced, _, werr = w.WouldReplaceEvent(ev); werr != nil { |
||||||
|
if errors.Is(werr, ErrOlderThanExisting) { |
||||||
|
if kind.IsReplaceable(ev.Kind) { |
||||||
|
err = errors.New("blocked: event is older than existing replaceable event") |
||||||
|
} else { |
||||||
|
err = errors.New("blocked: event is older than existing addressable event") |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
if errors.Is(werr, ErrMissingDTag) { |
||||||
|
err = ErrMissingDTag |
||||||
|
return |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Get the next sequence number for the event
|
||||||
|
serial, err := w.nextEventSerial() |
||||||
|
if err != nil { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Generate all indexes for the event
|
||||||
|
idxs, err := database.GetIndexesForEvent(ev, serial) |
||||||
|
if err != nil { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Serialize event to binary
|
||||||
|
eventDataBuf := new(bytes.Buffer) |
||||||
|
ev.MarshalBinary(eventDataBuf) |
||||||
|
eventData := eventDataBuf.Bytes() |
||||||
|
|
||||||
|
// Determine storage strategy
|
||||||
|
smallEventThreshold := 1024 // Could be made configurable
|
||||||
|
isSmallEvent := len(eventData) <= smallEventThreshold |
||||||
|
isReplaceableEvent := kind.IsReplaceable(ev.Kind) |
||||||
|
isAddressableEvent := kind.IsParameterizedReplaceable(ev.Kind) |
||||||
|
|
||||||
|
// Create serial type
|
||||||
|
ser = new(types.Uint40) |
||||||
|
if err = ser.Set(serial); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Start a transaction to save the event and all its indexes
|
||||||
|
// We need to include all object stores we'll write to
|
||||||
|
storesToWrite := []string{ |
||||||
|
string(indexes.IdPrefix), |
||||||
|
string(indexes.FullIdPubkeyPrefix), |
||||||
|
string(indexes.CreatedAtPrefix), |
||||||
|
string(indexes.PubkeyPrefix), |
||||||
|
string(indexes.KindPrefix), |
||||||
|
string(indexes.KindPubkeyPrefix), |
||||||
|
string(indexes.TagPrefix), |
||||||
|
string(indexes.TagKindPrefix), |
||||||
|
string(indexes.TagPubkeyPrefix), |
||||||
|
string(indexes.TagKindPubkeyPrefix), |
||||||
|
string(indexes.WordPrefix), |
||||||
|
} |
||||||
|
|
||||||
|
// Add event storage store
|
||||||
|
if isSmallEvent { |
||||||
|
storesToWrite = append(storesToWrite, string(indexes.SmallEventPrefix)) |
||||||
|
} else { |
||||||
|
storesToWrite = append(storesToWrite, string(indexes.EventPrefix)) |
||||||
|
} |
||||||
|
|
||||||
|
// Add specialized stores if needed
|
||||||
|
if isAddressableEvent && isSmallEvent { |
||||||
|
storesToWrite = append(storesToWrite, string(indexes.AddressableEventPrefix)) |
||||||
|
} else if isReplaceableEvent && isSmallEvent { |
||||||
|
storesToWrite = append(storesToWrite, string(indexes.ReplaceableEventPrefix)) |
||||||
|
} |
||||||
|
|
||||||
|
// Start transaction
|
||||||
|
tx, err := w.db.Transaction(idb.TransactionReadWrite, storesToWrite[0], storesToWrite[1:]...) |
||||||
|
if err != nil { |
||||||
|
return false, fmt.Errorf("failed to start transaction: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Save each index to its respective object store
|
||||||
|
for _, key := range idxs { |
||||||
|
if len(key) < 3 { |
||||||
|
continue |
||||||
|
} |
||||||
|
// Extract store name from 3-byte prefix
|
||||||
|
storeName := string(key[:3]) |
||||||
|
|
||||||
|
store, storeErr := tx.ObjectStore(storeName) |
||||||
|
if storeErr != nil { |
||||||
|
w.Logger.Warnf("SaveEvent: failed to get object store %s: %v", storeName, storeErr) |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
// Use the full key as the IndexedDB key, empty value
|
||||||
|
keyJS := bytesToSafeValue(key) |
||||||
|
_, putErr := store.PutKey(keyJS, safejs.Null()) |
||||||
|
if putErr != nil { |
||||||
|
w.Logger.Warnf("SaveEvent: failed to put index %s: %v", storeName, putErr) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Store the event data
|
||||||
|
if isSmallEvent { |
||||||
|
// Small event: store inline with sev prefix
|
||||||
|
// Format: sev|serial|size_uint16|event_data
|
||||||
|
keyBuf := new(bytes.Buffer) |
||||||
|
if err = indexes.SmallEventEnc(ser).MarshalWrite(keyBuf); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
// Append size as uint16 big-endian
|
||||||
|
sizeBytes := []byte{byte(len(eventData) >> 8), byte(len(eventData))} |
||||||
|
keyBuf.Write(sizeBytes) |
||||||
|
keyBuf.Write(eventData) |
||||||
|
|
||||||
|
store, storeErr := tx.ObjectStore(string(indexes.SmallEventPrefix)) |
||||||
|
if storeErr == nil { |
||||||
|
keyJS := bytesToSafeValue(keyBuf.Bytes()) |
||||||
|
store.PutKey(keyJS, safejs.Null()) |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Large event: store separately with evt prefix
|
||||||
|
keyBuf := new(bytes.Buffer) |
||||||
|
if err = indexes.EventEnc(ser).MarshalWrite(keyBuf); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
store, storeErr := tx.ObjectStore(string(indexes.EventPrefix)) |
||||||
|
if storeErr == nil { |
||||||
|
keyJS := bytesToSafeValue(keyBuf.Bytes()) |
||||||
|
valueJS := bytesToSafeValue(eventData) |
||||||
|
store.PutKey(keyJS, valueJS) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Store specialized keys for replaceable/addressable events
|
||||||
|
if isAddressableEvent && isSmallEvent { |
||||||
|
dTag := ev.Tags.GetFirst([]byte("d")) |
||||||
|
if dTag != nil { |
||||||
|
pubHash := new(types.PubHash) |
||||||
|
pubHash.FromPubkey(ev.Pubkey) |
||||||
|
kindVal := new(types.Uint16) |
||||||
|
kindVal.Set(ev.Kind) |
||||||
|
dTagHash := new(types.Ident) |
||||||
|
dTagHash.FromIdent(dTag.Value()) |
||||||
|
|
||||||
|
keyBuf := new(bytes.Buffer) |
||||||
|
if err = indexes.AddressableEventEnc(pubHash, kindVal, dTagHash).MarshalWrite(keyBuf); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
sizeBytes := []byte{byte(len(eventData) >> 8), byte(len(eventData))} |
||||||
|
keyBuf.Write(sizeBytes) |
||||||
|
keyBuf.Write(eventData) |
||||||
|
|
||||||
|
store, storeErr := tx.ObjectStore(string(indexes.AddressableEventPrefix)) |
||||||
|
if storeErr == nil { |
||||||
|
keyJS := bytesToSafeValue(keyBuf.Bytes()) |
||||||
|
store.PutKey(keyJS, safejs.Null()) |
||||||
|
} |
||||||
|
} |
||||||
|
} else if isReplaceableEvent && isSmallEvent { |
||||||
|
pubHash := new(types.PubHash) |
||||||
|
pubHash.FromPubkey(ev.Pubkey) |
||||||
|
kindVal := new(types.Uint16) |
||||||
|
kindVal.Set(ev.Kind) |
||||||
|
|
||||||
|
keyBuf := new(bytes.Buffer) |
||||||
|
if err = indexes.ReplaceableEventEnc(pubHash, kindVal).MarshalWrite(keyBuf); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
sizeBytes := []byte{byte(len(eventData) >> 8), byte(len(eventData))} |
||||||
|
keyBuf.Write(sizeBytes) |
||||||
|
keyBuf.Write(eventData) |
||||||
|
|
||||||
|
store, storeErr := tx.ObjectStore(string(indexes.ReplaceableEventPrefix)) |
||||||
|
if storeErr == nil { |
||||||
|
keyJS := bytesToSafeValue(keyBuf.Bytes()) |
||||||
|
store.PutKey(keyJS, safejs.Null()) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Commit transaction
|
||||||
|
if err = tx.Await(c); err != nil { |
||||||
|
return false, fmt.Errorf("failed to commit transaction: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
w.Logger.Debugf("SaveEvent: saved event %x (kind %d, %d bytes, %d indexes)", |
||||||
|
ev.ID[:8], ev.Kind, len(eventData), len(idxs)) |
||||||
|
|
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// WouldReplaceEvent checks if the provided event would replace existing events
|
||||||
|
func (w *W) WouldReplaceEvent(ev *event.E) (bool, types.Uint40s, error) { |
||||||
|
// Only relevant for replaceable or parameterized replaceable kinds
|
||||||
|
if !(kind.IsReplaceable(ev.Kind) || kind.IsParameterizedReplaceable(ev.Kind)) { |
||||||
|
return false, nil, nil |
||||||
|
} |
||||||
|
|
||||||
|
// Build filter for existing events
|
||||||
|
var f interface{} |
||||||
|
if kind.IsReplaceable(ev.Kind) { |
||||||
|
// For now, simplified check - would need full filter implementation
|
||||||
|
return false, nil, nil |
||||||
|
} else { |
||||||
|
// Parameterized replaceable requires 'd' tag
|
||||||
|
dTag := ev.Tags.GetFirst([]byte("d")) |
||||||
|
if dTag == nil { |
||||||
|
return false, nil, ErrMissingDTag |
||||||
|
} |
||||||
|
// Simplified - full implementation would query existing events
|
||||||
|
_ = f |
||||||
|
} |
||||||
|
|
||||||
|
// Simplified implementation - assume no conflicts for now
|
||||||
|
// Full implementation would query the database and compare timestamps
|
||||||
|
return false, nil, nil |
||||||
|
} |
||||||
|
|
||||||
|
// GetSerialById looks up the serial number for an event ID
|
||||||
|
func (w *W) GetSerialById(id []byte) (ser *types.Uint40, err error) { |
||||||
|
if len(id) != 32 { |
||||||
|
return nil, errors.New("invalid event ID length") |
||||||
|
} |
||||||
|
|
||||||
|
// Create ID hash
|
||||||
|
idHash := new(types.IdHash) |
||||||
|
if err = idHash.FromId(id); chk.E(err) { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
// Build the prefix to search for
|
||||||
|
keyBuf := new(bytes.Buffer) |
||||||
|
indexes.IdEnc(idHash, nil).MarshalWrite(keyBuf) |
||||||
|
prefix := keyBuf.Bytes()[:11] // 3 prefix + 8 id hash
|
||||||
|
|
||||||
|
// Search in the eid object store
|
||||||
|
tx, err := w.db.Transaction(idb.TransactionReadOnly, string(indexes.IdPrefix)) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
store, err := tx.ObjectStore(string(indexes.IdPrefix)) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
// Use cursor to find matching key
|
||||||
|
cursorReq, err := store.OpenCursor(idb.CursorNext) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
err = cursorReq.Iter(w.ctx, func(cursor *idb.CursorWithValue) error { |
||||||
|
keyVal, keyErr := cursor.Key() |
||||||
|
if keyErr != nil { |
||||||
|
return keyErr |
||||||
|
} |
||||||
|
|
||||||
|
keyBytes := safeValueToBytes(keyVal) |
||||||
|
if len(keyBytes) >= len(prefix) && bytes.HasPrefix(keyBytes, prefix) { |
||||||
|
// Found matching key, extract serial from last 5 bytes
|
||||||
|
if len(keyBytes) >= 16 { // 3 + 8 + 5
|
||||||
|
ser = new(types.Uint40) |
||||||
|
ser.UnmarshalRead(bytes.NewReader(keyBytes[11:16])) |
||||||
|
return errors.New("found") // Stop iteration
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return cursor.Continue() |
||||||
|
}) |
||||||
|
|
||||||
|
if ser != nil { |
||||||
|
return ser, nil |
||||||
|
} |
||||||
|
if err != nil && err.Error() != "found" { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
return nil, errors.New("id not found in database") |
||||||
|
} |
||||||
|
|
||||||
|
// GetSerialsByIds looks up serial numbers for multiple event IDs
|
||||||
|
func (w *W) GetSerialsByIds(ids *tag.T) (serials map[string]*types.Uint40, err error) { |
||||||
|
serials = make(map[string]*types.Uint40) |
||||||
|
|
||||||
|
if ids == nil { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
for i := 1; i < ids.Len(); i++ { |
||||||
|
idBytes := ids.T[i] |
||||||
|
if len(idBytes) == 64 { |
||||||
|
// Hex encoded ID
|
||||||
|
var decoded []byte |
||||||
|
decoded, err = hex.Dec(string(idBytes)) |
||||||
|
if err != nil { |
||||||
|
continue |
||||||
|
} |
||||||
|
idBytes = decoded |
||||||
|
} |
||||||
|
|
||||||
|
if len(idBytes) == 32 { |
||||||
|
var ser *types.Uint40 |
||||||
|
ser, err = w.GetSerialById(idBytes) |
||||||
|
if err == nil && ser != nil { |
||||||
|
serials[hex.Enc(idBytes)] = ser |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
err = nil |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// GetSerialsByIdsWithFilter looks up serial numbers with a filter function
|
||||||
|
func (w *W) GetSerialsByIdsWithFilter(ids *tag.T, fn func(ev *event.E, ser *types.Uint40) bool) (serials map[string]*types.Uint40, err error) { |
||||||
|
allSerials, err := w.GetSerialsByIds(ids) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
if fn == nil { |
||||||
|
return allSerials, nil |
||||||
|
} |
||||||
|
|
||||||
|
serials = make(map[string]*types.Uint40) |
||||||
|
for idHex, ser := range allSerials { |
||||||
|
ev, fetchErr := w.FetchEventBySerial(ser) |
||||||
|
if fetchErr != nil { |
||||||
|
continue |
||||||
|
} |
||||||
|
if fn(ev, ser) { |
||||||
|
serials[idHex] = ser |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return serials, nil |
||||||
|
} |
||||||
@ -0,0 +1,332 @@ |
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package wasmdb |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"encoding/binary" |
||||||
|
"encoding/json" |
||||||
|
"errors" |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/aperturerobotics/go-indexeddb/idb" |
||||||
|
|
||||||
|
"next.orly.dev/pkg/database" |
||||||
|
) |
||||||
|
|
||||||
|
const ( |
||||||
|
// SubscriptionsStoreName is the object store for payment subscriptions
|
||||||
|
SubscriptionsStoreName = "subscriptions" |
||||||
|
|
||||||
|
// PaymentsPrefix is the key prefix for payment records
|
||||||
|
PaymentsPrefix = "payment:" |
||||||
|
) |
||||||
|
|
||||||
|
// GetSubscription retrieves a subscription for a pubkey
|
||||||
|
func (w *W) GetSubscription(pubkey []byte) (*database.Subscription, error) { |
||||||
|
key := "sub:" + string(pubkey) |
||||||
|
data, err := w.getStoreValue(SubscriptionsStoreName, key) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
if data == nil { |
||||||
|
return nil, nil |
||||||
|
} |
||||||
|
|
||||||
|
return w.deserializeSubscription(data) |
||||||
|
} |
||||||
|
|
||||||
|
// IsSubscriptionActive checks if a pubkey has an active subscription
|
||||||
|
// If no subscription exists, creates a 30-day trial
|
||||||
|
func (w *W) IsSubscriptionActive(pubkey []byte) (bool, error) { |
||||||
|
key := "sub:" + string(pubkey) |
||||||
|
data, err := w.getStoreValue(SubscriptionsStoreName, key) |
||||||
|
if err != nil { |
||||||
|
return false, err |
||||||
|
} |
||||||
|
|
||||||
|
now := time.Now() |
||||||
|
|
||||||
|
if data == nil { |
||||||
|
// Create new trial subscription
|
||||||
|
sub := &database.Subscription{ |
||||||
|
TrialEnd: now.AddDate(0, 0, 30), |
||||||
|
} |
||||||
|
subData := w.serializeSubscription(sub) |
||||||
|
if err := w.setStoreValue(SubscriptionsStoreName, key, subData); err != nil { |
||||||
|
return false, err |
||||||
|
} |
||||||
|
return true, nil |
||||||
|
} |
||||||
|
|
||||||
|
sub, err := w.deserializeSubscription(data) |
||||||
|
if err != nil { |
||||||
|
return false, err |
||||||
|
} |
||||||
|
|
||||||
|
// Active if within trial or paid period
|
||||||
|
return now.Before(sub.TrialEnd) || (!sub.PaidUntil.IsZero() && now.Before(sub.PaidUntil)), nil |
||||||
|
} |
||||||
|
|
||||||
|
// ExtendSubscription extends a subscription by the given number of days
|
||||||
|
func (w *W) ExtendSubscription(pubkey []byte, days int) error { |
||||||
|
if days <= 0 { |
||||||
|
return errors.New("invalid days") |
||||||
|
} |
||||||
|
|
||||||
|
key := "sub:" + string(pubkey) |
||||||
|
data, err := w.getStoreValue(SubscriptionsStoreName, key) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
now := time.Now() |
||||||
|
var sub *database.Subscription |
||||||
|
|
||||||
|
if data == nil { |
||||||
|
// Create new subscription
|
||||||
|
sub = &database.Subscription{ |
||||||
|
PaidUntil: now.AddDate(0, 0, days), |
||||||
|
} |
||||||
|
} else { |
||||||
|
sub, err = w.deserializeSubscription(data) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
// Extend from current paid date if still active, otherwise from now
|
||||||
|
extendFrom := now |
||||||
|
if !sub.PaidUntil.IsZero() && sub.PaidUntil.After(now) { |
||||||
|
extendFrom = sub.PaidUntil |
||||||
|
} |
||||||
|
sub.PaidUntil = extendFrom.AddDate(0, 0, days) |
||||||
|
} |
||||||
|
|
||||||
|
// Serialize and store
|
||||||
|
subData := w.serializeSubscription(sub) |
||||||
|
return w.setStoreValue(SubscriptionsStoreName, key, subData) |
||||||
|
} |
||||||
|
|
||||||
|
// RecordPayment records a payment for a pubkey
|
||||||
|
func (w *W) RecordPayment(pubkey []byte, amount int64, invoice, preimage string) error { |
||||||
|
now := time.Now() |
||||||
|
payment := &database.Payment{ |
||||||
|
Amount: amount, |
||||||
|
Timestamp: now, |
||||||
|
Invoice: invoice, |
||||||
|
Preimage: preimage, |
||||||
|
} |
||||||
|
|
||||||
|
data := w.serializePayment(payment) |
||||||
|
|
||||||
|
// Create unique key with timestamp
|
||||||
|
key := PaymentsPrefix + string(pubkey) + ":" + now.Format(time.RFC3339Nano) |
||||||
|
return w.setStoreValue(SubscriptionsStoreName, key, data) |
||||||
|
} |
||||||
|
|
||||||
|
// GetPaymentHistory retrieves all payments for a pubkey
|
||||||
|
func (w *W) GetPaymentHistory(pubkey []byte) ([]database.Payment, error) { |
||||||
|
prefix := PaymentsPrefix + string(pubkey) + ":" |
||||||
|
|
||||||
|
tx, err := w.db.Transaction(idb.TransactionReadOnly, SubscriptionsStoreName) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
store, err := tx.ObjectStore(SubscriptionsStoreName) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
var payments []database.Payment |
||||||
|
|
||||||
|
cursorReq, err := store.OpenCursor(idb.CursorNext) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
prefixBytes := []byte(prefix) |
||||||
|
|
||||||
|
err = cursorReq.Iter(w.ctx, func(cursor *idb.CursorWithValue) error { |
||||||
|
keyVal, keyErr := cursor.Key() |
||||||
|
if keyErr != nil { |
||||||
|
return keyErr |
||||||
|
} |
||||||
|
|
||||||
|
keyBytes := safeValueToBytes(keyVal) |
||||||
|
if bytes.HasPrefix(keyBytes, prefixBytes) { |
||||||
|
val, valErr := cursor.Value() |
||||||
|
if valErr != nil { |
||||||
|
return valErr |
||||||
|
} |
||||||
|
valBytes := safeValueToBytes(val) |
||||||
|
if payment, err := w.deserializePayment(valBytes); err == nil { |
||||||
|
payments = append(payments, *payment) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return cursor.Continue() |
||||||
|
}) |
||||||
|
|
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
return payments, nil |
||||||
|
} |
||||||
|
|
||||||
|
// ExtendBlossomSubscription extends a blossom subscription with storage quota
|
||||||
|
func (w *W) ExtendBlossomSubscription(pubkey []byte, level string, storageMB int64, days int) error { |
||||||
|
if days <= 0 { |
||||||
|
return errors.New("invalid days") |
||||||
|
} |
||||||
|
|
||||||
|
key := "sub:" + string(pubkey) |
||||||
|
data, err := w.getStoreValue(SubscriptionsStoreName, key) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
now := time.Now() |
||||||
|
var sub *database.Subscription |
||||||
|
|
||||||
|
if data == nil { |
||||||
|
sub = &database.Subscription{ |
||||||
|
PaidUntil: now.AddDate(0, 0, days), |
||||||
|
BlossomLevel: level, |
||||||
|
BlossomStorage: storageMB, |
||||||
|
} |
||||||
|
} else { |
||||||
|
sub, err = w.deserializeSubscription(data) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
// Extend from current paid date if still active
|
||||||
|
extendFrom := now |
||||||
|
if !sub.PaidUntil.IsZero() && sub.PaidUntil.After(now) { |
||||||
|
extendFrom = sub.PaidUntil |
||||||
|
} |
||||||
|
sub.PaidUntil = extendFrom.AddDate(0, 0, days) |
||||||
|
|
||||||
|
// Set level and accumulate storage
|
||||||
|
sub.BlossomLevel = level |
||||||
|
if sub.BlossomStorage > 0 && sub.PaidUntil.After(now) { |
||||||
|
sub.BlossomStorage += storageMB |
||||||
|
} else { |
||||||
|
sub.BlossomStorage = storageMB |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
subData := w.serializeSubscription(sub) |
||||||
|
return w.setStoreValue(SubscriptionsStoreName, key, subData) |
||||||
|
} |
||||||
|
|
||||||
|
// GetBlossomStorageQuota returns the storage quota for a pubkey
|
||||||
|
func (w *W) GetBlossomStorageQuota(pubkey []byte) (quotaMB int64, err error) { |
||||||
|
sub, err := w.GetSubscription(pubkey) |
||||||
|
if err != nil { |
||||||
|
return 0, err |
||||||
|
} |
||||||
|
if sub == nil { |
||||||
|
return 0, nil |
||||||
|
} |
||||||
|
// Only return quota if subscription is active
|
||||||
|
if sub.PaidUntil.IsZero() || time.Now().After(sub.PaidUntil) { |
||||||
|
return 0, nil |
||||||
|
} |
||||||
|
return sub.BlossomStorage, nil |
||||||
|
} |
||||||
|
|
||||||
|
// IsFirstTimeUser checks if a pubkey is a first-time user (no subscription history)
|
||||||
|
func (w *W) IsFirstTimeUser(pubkey []byte) (bool, error) { |
||||||
|
key := "firstlogin:" + string(pubkey) |
||||||
|
data, err := w.getStoreValue(SubscriptionsStoreName, key) |
||||||
|
if err != nil { |
||||||
|
return false, err |
||||||
|
} |
||||||
|
|
||||||
|
if data == nil { |
||||||
|
// First time - record the login
|
||||||
|
now := time.Now() |
||||||
|
loginData, _ := json.Marshal(map[string]interface{}{ |
||||||
|
"first_login": now, |
||||||
|
}) |
||||||
|
_ = w.setStoreValue(SubscriptionsStoreName, key, loginData) |
||||||
|
return true, nil |
||||||
|
} |
||||||
|
|
||||||
|
return false, nil |
||||||
|
} |
||||||
|
|
||||||
|
// serializeSubscription converts a subscription to bytes using JSON
|
||||||
|
func (w *W) serializeSubscription(s *database.Subscription) []byte { |
||||||
|
data, _ := json.Marshal(s) |
||||||
|
return data |
||||||
|
} |
||||||
|
|
||||||
|
// deserializeSubscription converts bytes to a subscription
|
||||||
|
func (w *W) deserializeSubscription(data []byte) (*database.Subscription, error) { |
||||||
|
s := &database.Subscription{} |
||||||
|
if err := json.Unmarshal(data, s); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
return s, nil |
||||||
|
} |
||||||
|
|
||||||
|
// serializePayment converts a payment to bytes
|
||||||
|
func (w *W) serializePayment(p *database.Payment) []byte { |
||||||
|
buf := new(bytes.Buffer) |
||||||
|
|
||||||
|
// Amount (8 bytes)
|
||||||
|
amt := make([]byte, 8) |
||||||
|
binary.BigEndian.PutUint64(amt, uint64(p.Amount)) |
||||||
|
buf.Write(amt) |
||||||
|
|
||||||
|
// Timestamp (8 bytes)
|
||||||
|
ts := make([]byte, 8) |
||||||
|
binary.BigEndian.PutUint64(ts, uint64(p.Timestamp.Unix())) |
||||||
|
buf.Write(ts) |
||||||
|
|
||||||
|
// Invoice length (4 bytes) + Invoice
|
||||||
|
invBytes := []byte(p.Invoice) |
||||||
|
invLen := make([]byte, 4) |
||||||
|
binary.BigEndian.PutUint32(invLen, uint32(len(invBytes))) |
||||||
|
buf.Write(invLen) |
||||||
|
buf.Write(invBytes) |
||||||
|
|
||||||
|
// Preimage length (4 bytes) + Preimage
|
||||||
|
preBytes := []byte(p.Preimage) |
||||||
|
preLen := make([]byte, 4) |
||||||
|
binary.BigEndian.PutUint32(preLen, uint32(len(preBytes))) |
||||||
|
buf.Write(preLen) |
||||||
|
buf.Write(preBytes) |
||||||
|
|
||||||
|
return buf.Bytes() |
||||||
|
} |
||||||
|
|
||||||
|
// deserializePayment converts bytes to a payment
|
||||||
|
func (w *W) deserializePayment(data []byte) (*database.Payment, error) { |
||||||
|
if len(data) < 24 { // 8 + 8 + 4 + 4 minimum
|
||||||
|
return nil, errors.New("invalid payment data") |
||||||
|
} |
||||||
|
|
||||||
|
p := &database.Payment{} |
||||||
|
|
||||||
|
p.Amount = int64(binary.BigEndian.Uint64(data[0:8])) |
||||||
|
p.Timestamp = time.Unix(int64(binary.BigEndian.Uint64(data[8:16])), 0) |
||||||
|
|
||||||
|
invLen := binary.BigEndian.Uint32(data[16:20]) |
||||||
|
if len(data) < int(20+invLen+4) { |
||||||
|
return nil, errors.New("invalid invoice length") |
||||||
|
} |
||||||
|
p.Invoice = string(data[20 : 20+invLen]) |
||||||
|
|
||||||
|
offset := 20 + invLen |
||||||
|
preLen := binary.BigEndian.Uint32(data[offset : offset+4]) |
||||||
|
if len(data) < int(offset+4+preLen) { |
||||||
|
return nil, errors.New("invalid preimage length") |
||||||
|
} |
||||||
|
p.Preimage = string(data[offset+4 : offset+4+preLen]) |
||||||
|
|
||||||
|
return p, nil |
||||||
|
} |
||||||
@ -0,0 +1,24 @@ |
|||||||
|
{ |
||||||
|
"name": "wasmdb-test", |
||||||
|
"version": "1.0.0", |
||||||
|
"lockfileVersion": 3, |
||||||
|
"requires": true, |
||||||
|
"packages": { |
||||||
|
"": { |
||||||
|
"name": "wasmdb-test", |
||||||
|
"version": "1.0.0", |
||||||
|
"dependencies": { |
||||||
|
"fake-indexeddb": "^6.0.0" |
||||||
|
} |
||||||
|
}, |
||||||
|
"node_modules/fake-indexeddb": { |
||||||
|
"version": "6.2.5", |
||||||
|
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", |
||||||
|
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", |
||||||
|
"license": "Apache-2.0", |
||||||
|
"engines": { |
||||||
|
"node": ">=18" |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,12 @@ |
|||||||
|
{ |
||||||
|
"name": "wasmdb-test", |
||||||
|
"version": "1.0.0", |
||||||
|
"description": "Node.js test harness for wasmdb WASM tests", |
||||||
|
"type": "module", |
||||||
|
"scripts": { |
||||||
|
"test": "node run_wasm_tests.mjs" |
||||||
|
}, |
||||||
|
"dependencies": { |
||||||
|
"fake-indexeddb": "^6.0.0" |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,572 @@ |
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
// Package wasmdb provides a WebAssembly-compatible database implementation
|
||||||
|
// using IndexedDB as the storage backend. It replicates the Badger database's
|
||||||
|
// index schema for full query compatibility.
|
||||||
|
//
|
||||||
|
// This implementation uses aperturerobotics/go-indexeddb (a fork of hack-pad/go-indexeddb)
|
||||||
|
// which provides full IndexedDB bindings with cursor/range support and transaction retry
|
||||||
|
// mechanisms to handle IndexedDB's transaction expiration issues in Go WASM.
|
||||||
|
//
|
||||||
|
// Architecture:
|
||||||
|
// - Each index type (evt, eid, kc-, pc-, etc.) maps to an IndexedDB object store
|
||||||
|
// - Keys are binary-encoded using the same format as the Badger implementation
|
||||||
|
// - Range queries use IndexedDB cursors with KeyRange bounds
|
||||||
|
// - Serial numbers are managed using a dedicated "meta" object store
|
||||||
|
package wasmdb |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"encoding/binary" |
||||||
|
"errors" |
||||||
|
"fmt" |
||||||
|
"sync" |
||||||
|
|
||||||
|
"github.com/aperturerobotics/go-indexeddb/idb" |
||||||
|
"github.com/hack-pad/safejs" |
||||||
|
"lol.mleku.dev" |
||||||
|
"lol.mleku.dev/chk" |
||||||
|
|
||||||
|
"git.mleku.dev/mleku/nostr/encoders/event" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/filter" |
||||||
|
"next.orly.dev/pkg/database" |
||||||
|
"next.orly.dev/pkg/database/indexes" |
||||||
|
) |
||||||
|
|
||||||
|
const ( |
||||||
|
// DatabaseName is the IndexedDB database name
|
||||||
|
DatabaseName = "orly-nostr-relay" |
||||||
|
|
||||||
|
// DatabaseVersion is incremented when schema changes require migration
|
||||||
|
DatabaseVersion = 1 |
||||||
|
|
||||||
|
// MetaStoreName holds metadata like serial counters
|
||||||
|
MetaStoreName = "meta" |
||||||
|
|
||||||
|
// EventSerialKey is the key for the event serial counter in meta store
|
||||||
|
EventSerialKey = "event_serial" |
||||||
|
|
||||||
|
// PubkeySerialKey is the key for the pubkey serial counter in meta store
|
||||||
|
PubkeySerialKey = "pubkey_serial" |
||||||
|
|
||||||
|
// RelayIdentityKey is the key for the relay identity secret
|
||||||
|
RelayIdentityKey = "relay_identity" |
||||||
|
) |
||||||
|
|
||||||
|
// Object store names matching Badger index prefixes
|
||||||
|
var objectStoreNames = []string{ |
||||||
|
MetaStoreName, |
||||||
|
string(indexes.EventPrefix), // "evt" - full events
|
||||||
|
string(indexes.SmallEventPrefix), // "sev" - small events inline
|
||||||
|
string(indexes.ReplaceableEventPrefix), // "rev" - replaceable events
|
||||||
|
string(indexes.AddressableEventPrefix), // "aev" - addressable events
|
||||||
|
string(indexes.IdPrefix), // "eid" - event ID index
|
||||||
|
string(indexes.FullIdPubkeyPrefix), // "fpc" - full ID + pubkey + timestamp
|
||||||
|
string(indexes.CreatedAtPrefix), // "c--" - created_at index
|
||||||
|
string(indexes.KindPrefix), // "kc-" - kind index
|
||||||
|
string(indexes.PubkeyPrefix), // "pc-" - pubkey index
|
||||||
|
string(indexes.KindPubkeyPrefix), // "kpc" - kind + pubkey index
|
||||||
|
string(indexes.TagPrefix), // "tc-" - tag index
|
||||||
|
string(indexes.TagKindPrefix), // "tkc" - tag + kind index
|
||||||
|
string(indexes.TagPubkeyPrefix), // "tpc" - tag + pubkey index
|
||||||
|
string(indexes.TagKindPubkeyPrefix), // "tkp" - tag + kind + pubkey index
|
||||||
|
string(indexes.WordPrefix), // "wrd" - word search index
|
||||||
|
string(indexes.ExpirationPrefix), // "exp" - expiration index
|
||||||
|
string(indexes.VersionPrefix), // "ver" - schema version
|
||||||
|
string(indexes.PubkeySerialPrefix), // "pks" - pubkey serial index
|
||||||
|
string(indexes.SerialPubkeyPrefix), // "spk" - serial to pubkey
|
||||||
|
string(indexes.EventPubkeyGraphPrefix), // "epg" - event-pubkey graph
|
||||||
|
string(indexes.PubkeyEventGraphPrefix), // "peg" - pubkey-event graph
|
||||||
|
"markers", // metadata key-value storage
|
||||||
|
"subscriptions", // payment subscriptions
|
||||||
|
"nip43", // NIP-43 membership
|
||||||
|
"invites", // invite codes
|
||||||
|
} |
||||||
|
|
||||||
|
// W implements the database.Database interface using IndexedDB
|
||||||
|
type W struct { |
||||||
|
ctx context.Context |
||||||
|
cancel context.CancelFunc |
||||||
|
|
||||||
|
dataDir string // Not really used in WASM, but kept for interface compatibility
|
||||||
|
Logger *logger |
||||||
|
|
||||||
|
db *idb.Database |
||||||
|
dbMu sync.RWMutex |
||||||
|
ready chan struct{} |
||||||
|
|
||||||
|
// Serial counters (cached in memory, persisted to IndexedDB)
|
||||||
|
eventSerial uint64 |
||||||
|
pubkeySerial uint64 |
||||||
|
serialMu sync.Mutex |
||||||
|
} |
||||||
|
|
||||||
|
// Ensure W implements database.Database interface at compile time
|
||||||
|
var _ database.Database = (*W)(nil) |
||||||
|
|
||||||
|
// init registers the wasmdb database factory
|
||||||
|
func init() { |
||||||
|
database.RegisterWasmDBFactory(func( |
||||||
|
ctx context.Context, |
||||||
|
cancel context.CancelFunc, |
||||||
|
cfg *database.DatabaseConfig, |
||||||
|
) (database.Database, error) { |
||||||
|
return NewWithConfig(ctx, cancel, cfg) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// NewWithConfig creates a new IndexedDB-based database instance
|
||||||
|
func NewWithConfig( |
||||||
|
ctx context.Context, cancel context.CancelFunc, cfg *database.DatabaseConfig, |
||||||
|
) (*W, error) { |
||||||
|
w := &W{ |
||||||
|
ctx: ctx, |
||||||
|
cancel: cancel, |
||||||
|
dataDir: cfg.DataDir, |
||||||
|
Logger: NewLogger(lol.GetLogLevel(cfg.LogLevel)), |
||||||
|
ready: make(chan struct{}), |
||||||
|
} |
||||||
|
|
||||||
|
// Open or create the IndexedDB database
|
||||||
|
if err := w.openDatabase(); err != nil { |
||||||
|
return nil, fmt.Errorf("failed to open IndexedDB: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Load serial counters from storage
|
||||||
|
if err := w.loadSerialCounters(); err != nil { |
||||||
|
return nil, fmt.Errorf("failed to load serial counters: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Start warmup goroutine
|
||||||
|
go w.warmup() |
||||||
|
|
||||||
|
// Setup shutdown handler
|
||||||
|
go func() { |
||||||
|
<-w.ctx.Done() |
||||||
|
w.cancel() |
||||||
|
w.Close() |
||||||
|
}() |
||||||
|
|
||||||
|
return w, nil |
||||||
|
} |
||||||
|
|
||||||
|
// New creates a new IndexedDB-based database instance with default configuration
|
||||||
|
func New( |
||||||
|
ctx context.Context, cancel context.CancelFunc, dataDir, logLevel string, |
||||||
|
) (*W, error) { |
||||||
|
cfg := &database.DatabaseConfig{ |
||||||
|
DataDir: dataDir, |
||||||
|
LogLevel: logLevel, |
||||||
|
} |
||||||
|
return NewWithConfig(ctx, cancel, cfg) |
||||||
|
} |
||||||
|
|
||||||
|
// openDatabase opens or creates the IndexedDB database with all required object stores
|
||||||
|
func (w *W) openDatabase() error { |
||||||
|
w.dbMu.Lock() |
||||||
|
defer w.dbMu.Unlock() |
||||||
|
|
||||||
|
// Get the IndexedDB factory (panics if not available)
|
||||||
|
factory := idb.Global() |
||||||
|
|
||||||
|
// Open the database with upgrade handler
|
||||||
|
openReq, err := factory.Open(w.ctx, DatabaseName, DatabaseVersion, func(db *idb.Database, oldVersion, newVersion uint) error { |
||||||
|
// This is called when the database needs to be created or upgraded
|
||||||
|
w.Logger.Infof("IndexedDB upgrade: version %d -> %d", oldVersion, newVersion) |
||||||
|
|
||||||
|
// Create all object stores
|
||||||
|
for _, storeName := range objectStoreNames { |
||||||
|
// Check if store already exists
|
||||||
|
if !w.hasObjectStore(db, storeName) { |
||||||
|
// Create object store without auto-increment (we manage keys manually)
|
||||||
|
opts := idb.ObjectStoreOptions{} |
||||||
|
if _, err := db.CreateObjectStore(storeName, opts); err != nil { |
||||||
|
return fmt.Errorf("failed to create object store %s: %w", storeName, err) |
||||||
|
} |
||||||
|
w.Logger.Debugf("created object store: %s", storeName) |
||||||
|
} |
||||||
|
} |
||||||
|
return nil |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("failed to open IndexedDB: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
db, err := openReq.Await(w.ctx) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("failed to await IndexedDB open: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
w.db = db |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// hasObjectStore checks if an object store exists in the database
|
||||||
|
func (w *W) hasObjectStore(db *idb.Database, name string) bool { |
||||||
|
names, err := db.ObjectStoreNames() |
||||||
|
if err != nil { |
||||||
|
return false |
||||||
|
} |
||||||
|
for _, n := range names { |
||||||
|
if n == name { |
||||||
|
return true |
||||||
|
} |
||||||
|
} |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
// loadSerialCounters loads the event and pubkey serial counters from IndexedDB
|
||||||
|
func (w *W) loadSerialCounters() error { |
||||||
|
w.serialMu.Lock() |
||||||
|
defer w.serialMu.Unlock() |
||||||
|
|
||||||
|
// Load event serial
|
||||||
|
eventSerialBytes, err := w.getMeta(EventSerialKey) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
if eventSerialBytes != nil && len(eventSerialBytes) == 8 { |
||||||
|
w.eventSerial = binary.BigEndian.Uint64(eventSerialBytes) |
||||||
|
} |
||||||
|
|
||||||
|
// Load pubkey serial
|
||||||
|
pubkeySerialBytes, err := w.getMeta(PubkeySerialKey) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
if pubkeySerialBytes != nil && len(pubkeySerialBytes) == 8 { |
||||||
|
w.pubkeySerial = binary.BigEndian.Uint64(pubkeySerialBytes) |
||||||
|
} |
||||||
|
|
||||||
|
w.Logger.Infof("loaded serials: event=%d, pubkey=%d", w.eventSerial, w.pubkeySerial) |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// getMeta retrieves a value from the meta object store
|
||||||
|
func (w *W) getMeta(key string) ([]byte, error) { |
||||||
|
tx, err := w.db.Transaction(idb.TransactionReadOnly, MetaStoreName) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
store, err := tx.ObjectStore(MetaStoreName) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
keyVal, err := safejs.ValueOf(key) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
req, err := store.Get(keyVal) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
val, err := req.Await(w.ctx) |
||||||
|
if err != nil { |
||||||
|
// Key not found is not an error
|
||||||
|
return nil, nil |
||||||
|
} |
||||||
|
|
||||||
|
if val.IsUndefined() || val.IsNull() { |
||||||
|
return nil, nil |
||||||
|
} |
||||||
|
|
||||||
|
// Convert safejs.Value to []byte
|
||||||
|
return safeValueToBytes(val), nil |
||||||
|
} |
||||||
|
|
||||||
|
// setMeta stores a value in the meta object store
|
||||||
|
func (w *W) setMeta(key string, value []byte) error { |
||||||
|
tx, err := w.db.Transaction(idb.TransactionReadWrite, MetaStoreName) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
store, err := tx.ObjectStore(MetaStoreName) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
// Convert value to Uint8Array for IndexedDB storage
|
||||||
|
valueJS := bytesToSafeValue(value) |
||||||
|
|
||||||
|
// Put with key - using PutKey since we're managing keys
|
||||||
|
keyVal, err := safejs.ValueOf(key) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
_, err = store.PutKey(keyVal, valueJS) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
return tx.Await(w.ctx) |
||||||
|
} |
||||||
|
|
||||||
|
// nextEventSerial returns the next event serial number and persists it
|
||||||
|
func (w *W) nextEventSerial() (uint64, error) { |
||||||
|
w.serialMu.Lock() |
||||||
|
defer w.serialMu.Unlock() |
||||||
|
|
||||||
|
w.eventSerial++ |
||||||
|
serial := w.eventSerial |
||||||
|
|
||||||
|
// Persist to IndexedDB
|
||||||
|
buf := make([]byte, 8) |
||||||
|
binary.BigEndian.PutUint64(buf, serial) |
||||||
|
if err := w.setMeta(EventSerialKey, buf); err != nil { |
||||||
|
return 0, err |
||||||
|
} |
||||||
|
|
||||||
|
return serial, nil |
||||||
|
} |
||||||
|
|
||||||
|
// nextPubkeySerial returns the next pubkey serial number and persists it
|
||||||
|
func (w *W) nextPubkeySerial() (uint64, error) { |
||||||
|
w.serialMu.Lock() |
||||||
|
defer w.serialMu.Unlock() |
||||||
|
|
||||||
|
w.pubkeySerial++ |
||||||
|
serial := w.pubkeySerial |
||||||
|
|
||||||
|
// Persist to IndexedDB
|
||||||
|
buf := make([]byte, 8) |
||||||
|
binary.BigEndian.PutUint64(buf, serial) |
||||||
|
if err := w.setMeta(PubkeySerialKey, buf); err != nil { |
||||||
|
return 0, err |
||||||
|
} |
||||||
|
|
||||||
|
return serial, nil |
||||||
|
} |
||||||
|
|
||||||
|
// warmup performs database warmup and closes the ready channel when complete
|
||||||
|
func (w *W) warmup() { |
||||||
|
defer close(w.ready) |
||||||
|
// IndexedDB is ready immediately after opening
|
||||||
|
w.Logger.Infof("IndexedDB database warmup complete, ready to serve requests") |
||||||
|
} |
||||||
|
|
||||||
|
// Path returns the database path (not used in WASM)
|
||||||
|
func (w *W) Path() string { return w.dataDir } |
||||||
|
|
||||||
|
// Init initializes the database (no-op, done in New)
|
||||||
|
func (w *W) Init(path string) error { return nil } |
||||||
|
|
||||||
|
// Sync flushes pending writes (IndexedDB handles persistence automatically)
|
||||||
|
func (w *W) Sync() error { return nil } |
||||||
|
|
||||||
|
// Close closes the database
|
||||||
|
func (w *W) Close() error { |
||||||
|
w.dbMu.Lock() |
||||||
|
defer w.dbMu.Unlock() |
||||||
|
|
||||||
|
if w.db != nil { |
||||||
|
w.db.Close() |
||||||
|
w.db = nil |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// Wipe removes all data and recreates object stores
|
||||||
|
func (w *W) Wipe() error { |
||||||
|
w.dbMu.Lock() |
||||||
|
defer w.dbMu.Unlock() |
||||||
|
|
||||||
|
// Close the current database
|
||||||
|
if w.db != nil { |
||||||
|
w.db.Close() |
||||||
|
w.db = nil |
||||||
|
} |
||||||
|
|
||||||
|
// Delete the database
|
||||||
|
factory := idb.Global() |
||||||
|
delReq, err := factory.DeleteDatabase(DatabaseName) |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("failed to delete IndexedDB: %w", err) |
||||||
|
} |
||||||
|
if err := delReq.Await(w.ctx); err != nil { |
||||||
|
return fmt.Errorf("failed to await IndexedDB delete: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Reset serial counters
|
||||||
|
w.serialMu.Lock() |
||||||
|
w.eventSerial = 0 |
||||||
|
w.pubkeySerial = 0 |
||||||
|
w.serialMu.Unlock() |
||||||
|
|
||||||
|
// Reopen the database (this will recreate all object stores)
|
||||||
|
w.dbMu.Unlock() |
||||||
|
err = w.openDatabase() |
||||||
|
w.dbMu.Lock() |
||||||
|
|
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
// SetLogLevel sets the logging level
|
||||||
|
func (w *W) SetLogLevel(level string) { |
||||||
|
w.Logger.SetLogLevel(lol.GetLogLevel(level)) |
||||||
|
} |
||||||
|
|
||||||
|
// Ready returns a channel that closes when the database is ready
|
||||||
|
func (w *W) Ready() <-chan struct{} { return w.ready } |
||||||
|
|
||||||
|
// RunMigrations runs database migrations (handled by IndexedDB upgrade)
|
||||||
|
func (w *W) RunMigrations() {} |
||||||
|
|
||||||
|
// EventIdsBySerial retrieves event IDs by serial range
|
||||||
|
func (w *W) EventIdsBySerial(start uint64, count int) ([]uint64, error) { |
||||||
|
return nil, errors.New("not implemented") |
||||||
|
} |
||||||
|
|
||||||
|
// Query cache methods (simplified for WASM - no caching)
|
||||||
|
func (w *W) GetCachedJSON(f *filter.F) ([][]byte, bool) { return nil, false } |
||||||
|
func (w *W) CacheMarshaledJSON(f *filter.F, marshaledJSON [][]byte) {} |
||||||
|
func (w *W) GetCachedEvents(f *filter.F) (event.S, bool) { return nil, false } |
||||||
|
func (w *W) CacheEvents(f *filter.F, events event.S) {} |
||||||
|
func (w *W) InvalidateQueryCache() {} |
||||||
|
|
||||||
|
// Placeholder implementations for remaining interface methods
|
||||||
|
// Query methods are implemented in query-events.go
|
||||||
|
// Delete methods are implemented in delete-event.go
|
||||||
|
|
||||||
|
// Import, Export, and ImportEvents methods are implemented in import-export.go
|
||||||
|
|
||||||
|
func (w *W) GetRelayIdentitySecret() (skb []byte, err error) { |
||||||
|
return w.getMeta(RelayIdentityKey) |
||||||
|
} |
||||||
|
|
||||||
|
func (w *W) SetRelayIdentitySecret(skb []byte) error { |
||||||
|
return w.setMeta(RelayIdentityKey, skb) |
||||||
|
} |
||||||
|
|
||||||
|
func (w *W) GetOrCreateRelayIdentitySecret() (skb []byte, err error) { |
||||||
|
skb, err = w.GetRelayIdentitySecret() |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
if skb != nil { |
||||||
|
return skb, nil |
||||||
|
} |
||||||
|
// Generate new secret key (32 random bytes)
|
||||||
|
// In WASM, we use crypto.getRandomValues
|
||||||
|
skb = make([]byte, 32) |
||||||
|
if err := cryptoRandom(skb); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
if err := w.SetRelayIdentitySecret(skb); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
return skb, nil |
||||||
|
} |
||||||
|
|
||||||
|
func (w *W) SetMarker(key string, value []byte) error { |
||||||
|
return w.setStoreValue("markers", key, value) |
||||||
|
} |
||||||
|
|
||||||
|
func (w *W) GetMarker(key string) (value []byte, err error) { |
||||||
|
return w.getStoreValue("markers", key) |
||||||
|
} |
||||||
|
|
||||||
|
func (w *W) HasMarker(key string) bool { |
||||||
|
val, err := w.GetMarker(key) |
||||||
|
return err == nil && val != nil |
||||||
|
} |
||||||
|
|
||||||
|
func (w *W) DeleteMarker(key string) error { |
||||||
|
return w.deleteStoreValue("markers", key) |
||||||
|
} |
||||||
|
|
||||||
|
// Subscription methods are implemented in subscriptions.go
|
||||||
|
// NIP-43 methods are implemented in nip43.go
|
||||||
|
|
||||||
|
// Helper methods for object store operations
|
||||||
|
|
||||||
|
func (w *W) setStoreValue(storeName, key string, value []byte) error { |
||||||
|
tx, err := w.db.Transaction(idb.TransactionReadWrite, storeName) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
store, err := tx.ObjectStore(storeName) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
keyVal, err := safejs.ValueOf(key) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
valueJS := bytesToSafeValue(value) |
||||||
|
|
||||||
|
_, err = store.PutKey(keyVal, valueJS) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
return tx.Await(w.ctx) |
||||||
|
} |
||||||
|
|
||||||
|
func (w *W) getStoreValue(storeName, key string) ([]byte, error) { |
||||||
|
tx, err := w.db.Transaction(idb.TransactionReadOnly, storeName) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
store, err := tx.ObjectStore(storeName) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
keyVal, err := safejs.ValueOf(key) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
req, err := store.Get(keyVal) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
val, err := req.Await(w.ctx) |
||||||
|
if err != nil { |
||||||
|
return nil, nil |
||||||
|
} |
||||||
|
|
||||||
|
if val.IsUndefined() || val.IsNull() { |
||||||
|
return nil, nil |
||||||
|
} |
||||||
|
|
||||||
|
return safeValueToBytes(val), nil |
||||||
|
} |
||||||
|
|
||||||
|
func (w *W) deleteStoreValue(storeName, key string) error { |
||||||
|
tx, err := w.db.Transaction(idb.TransactionReadWrite, storeName) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
store, err := tx.ObjectStore(storeName) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
keyVal, err := safejs.ValueOf(key) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
_, err = store.Delete(keyVal) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
return tx.Await(w.ctx) |
||||||
|
} |
||||||
|
|
||||||
|
// Placeholder for unused variable
|
||||||
|
var _ = chk.E |
||||||
Loading…
Reference in new issue