7 changed files with 932 additions and 2 deletions
@ -0,0 +1,185 @@ |
|||||||
|
# Pubkey Graph System |
||||||
|
|
||||||
|
## Overview |
||||||
|
|
||||||
|
The pubkey graph system provides efficient social graph queries by creating bidirectional, direction-aware edges between events and pubkeys in the ORLY relay. |
||||||
|
|
||||||
|
## Architecture |
||||||
|
|
||||||
|
### 1. Pubkey Serial Assignment |
||||||
|
|
||||||
|
**Purpose**: Compress 32-byte pubkeys to 5-byte serials for space efficiency. |
||||||
|
|
||||||
|
**Tables**: |
||||||
|
- `pks|pubkey_hash(8)|serial(5)` - Hash-to-serial lookup (16 bytes) |
||||||
|
- `spk|serial(5)` → 32-byte pubkey (value) - Serial-to-pubkey reverse lookup |
||||||
|
|
||||||
|
**Space Savings**: Each graph edge saves 27 bytes per pubkey reference (32 → 5 bytes). |
||||||
|
|
||||||
|
### 2. Graph Edge Storage |
||||||
|
|
||||||
|
**Bidirectional edges with metadata**: |
||||||
|
|
||||||
|
#### EventPubkeyGraph (Forward) |
||||||
|
``` |
||||||
|
epg|event_serial(5)|pubkey_serial(5)|kind(2)|direction(1) = 16 bytes |
||||||
|
``` |
||||||
|
|
||||||
|
#### PubkeyEventGraph (Reverse) |
||||||
|
``` |
||||||
|
peg|pubkey_serial(5)|kind(2)|direction(1)|event_serial(5) = 16 bytes |
||||||
|
``` |
||||||
|
|
||||||
|
### 3. Direction Byte |
||||||
|
|
||||||
|
The direction byte distinguishes relationship types: |
||||||
|
|
||||||
|
| Value | Direction | From Event Perspective | From Pubkey Perspective | |
||||||
|
|-------|-----------|------------------------|-------------------------| |
||||||
|
| `0` | Author | This pubkey is the event author | I am the author of this event | |
||||||
|
| `1` | P-Tag Out | Event references this pubkey | *(not used in reverse)* | |
||||||
|
| `2` | P-Tag In | *(not used in forward)* | I am referenced by this event | |
||||||
|
|
||||||
|
**Location in keys**: |
||||||
|
- **EventPubkeyGraph**: Byte 13 (after 3+5+5) |
||||||
|
- **PubkeyEventGraph**: Byte 10 (after 3+5+2) |
||||||
|
|
||||||
|
## Graph Edge Creation |
||||||
|
|
||||||
|
When an event is saved: |
||||||
|
|
||||||
|
1. **Extract pubkeys**: |
||||||
|
- Event author: `ev.Pubkey` |
||||||
|
- P-tags: All `["p", "<hex-pubkey>", ...]` tags |
||||||
|
|
||||||
|
2. **Get or create serials**: Each unique pubkey gets a monotonic 5-byte serial |
||||||
|
|
||||||
|
3. **Create bidirectional edges**: |
||||||
|
|
||||||
|
For **author** (pubkey = event author): |
||||||
|
``` |
||||||
|
epg|event_serial|author_serial|kind|0 (author edge) |
||||||
|
peg|author_serial|kind|0|event_serial (is-author edge) |
||||||
|
``` |
||||||
|
|
||||||
|
For each **p-tag** (referenced pubkey): |
||||||
|
``` |
||||||
|
epg|event_serial|ptag_serial|kind|1 (outbound reference) |
||||||
|
peg|ptag_serial|kind|2|event_serial (inbound reference) |
||||||
|
``` |
||||||
|
|
||||||
|
## Query Patterns |
||||||
|
|
||||||
|
### Find all events authored by a pubkey |
||||||
|
``` |
||||||
|
Prefix scan: peg|pubkey_serial|*|0|* |
||||||
|
Filter: direction == 0 (author) |
||||||
|
``` |
||||||
|
|
||||||
|
### Find all events mentioning a pubkey (inbound p-tags) |
||||||
|
``` |
||||||
|
Prefix scan: peg|pubkey_serial|*|2|* |
||||||
|
Filter: direction == 2 (p-tag inbound) |
||||||
|
``` |
||||||
|
|
||||||
|
### Find all kind-1 events mentioning a pubkey |
||||||
|
``` |
||||||
|
Prefix scan: peg|pubkey_serial|0x0001|2|* |
||||||
|
Exact match: kind == 1, direction == 2 |
||||||
|
``` |
||||||
|
|
||||||
|
### Find all pubkeys referenced by an event (outbound p-tags) |
||||||
|
``` |
||||||
|
Prefix scan: epg|event_serial|*|*|1 |
||||||
|
Filter: direction == 1 (p-tag outbound) |
||||||
|
``` |
||||||
|
|
||||||
|
### Find the author of an event |
||||||
|
``` |
||||||
|
Prefix scan: epg|event_serial|*|*|0 |
||||||
|
Filter: direction == 0 (author) |
||||||
|
``` |
||||||
|
|
||||||
|
## Implementation Details |
||||||
|
|
||||||
|
### Thread Safety |
||||||
|
|
||||||
|
The `GetOrCreatePubkeySerial` function uses: |
||||||
|
1. Read transaction to check for existing serial |
||||||
|
2. If not found, get next sequence number |
||||||
|
3. Write transaction with double-check to handle race conditions |
||||||
|
4. Returns existing serial if another goroutine created it concurrently |
||||||
|
|
||||||
|
### Deduplication |
||||||
|
|
||||||
|
The save-event function deduplicates pubkeys before creating serials: |
||||||
|
- Map keyed by hex-encoded pubkey |
||||||
|
- Prevents duplicate edges when author is also in p-tags |
||||||
|
|
||||||
|
### Edge Cases |
||||||
|
|
||||||
|
1. **Author in p-tags**: Only creates author edge (direction=0), skips duplicate p-tag edge |
||||||
|
2. **Invalid p-tags**: Silently skipped if hex decode fails or length != 32 bytes |
||||||
|
3. **No p-tags**: Only author edge is created |
||||||
|
|
||||||
|
## Performance Characteristics |
||||||
|
|
||||||
|
### Space Efficiency |
||||||
|
|
||||||
|
Per event with N unique pubkeys: |
||||||
|
- **Old approach** (storing full pubkeys): N × 32 bytes = 32N bytes |
||||||
|
- **New approach** (using serials): N × 5 bytes = 5N bytes |
||||||
|
- **Savings**: 27N bytes per event (84% reduction) |
||||||
|
|
||||||
|
Example: Event with author + 10 p-tags: |
||||||
|
- Old: 11 × 32 = 352 bytes |
||||||
|
- New: 11 × 5 = 55 bytes |
||||||
|
- **Saved: 297 bytes (84%)** |
||||||
|
|
||||||
|
### Query Performance |
||||||
|
|
||||||
|
1. **Pubkey lookup**: O(1) hash lookup via 8-byte truncated hash |
||||||
|
2. **Serial generation**: O(1) atomic increment |
||||||
|
3. **Graph queries**: Sequential scan with prefix optimization |
||||||
|
4. **Kind filtering**: Built into key ordering, no event decoding needed |
||||||
|
|
||||||
|
## Testing |
||||||
|
|
||||||
|
Comprehensive tests verify: |
||||||
|
- ✅ Serial assignment and deduplication |
||||||
|
- ✅ Bidirectional graph edge creation |
||||||
|
- ✅ Multiple events sharing pubkeys |
||||||
|
- ✅ Direction byte correctness |
||||||
|
- ✅ Edge cases (invalid pubkeys, non-existent keys) |
||||||
|
|
||||||
|
## Future Query APIs |
||||||
|
|
||||||
|
The graph structure supports efficient queries for: |
||||||
|
|
||||||
|
1. **Social Graph Queries**: |
||||||
|
- Who does Alice follow? (p-tags authored by Alice) |
||||||
|
- Who follows Bob? (p-tags referencing Bob) |
||||||
|
- Common connections between Alice and Bob |
||||||
|
|
||||||
|
2. **Event Discovery**: |
||||||
|
- All replies to Alice's events (kind-1 events with p-tag to Alice) |
||||||
|
- All events Alice has replied to (kind-1 events by Alice with p-tags) |
||||||
|
- Quote reposts, mentions, reactions by event kind |
||||||
|
|
||||||
|
3. **Analytics**: |
||||||
|
- Most-mentioned pubkeys (count p-tag-in edges) |
||||||
|
- Most active authors (count author edges) |
||||||
|
- Interaction patterns by kind |
||||||
|
|
||||||
|
## Migration Notes |
||||||
|
|
||||||
|
This is a **new index** that: |
||||||
|
- Runs alongside existing event indexes |
||||||
|
- Populated automatically for all new events |
||||||
|
- Does NOT require reindexing existing events (yet) |
||||||
|
- Can be backfilled via a migration if needed |
||||||
|
|
||||||
|
To backfill existing events, run a migration that: |
||||||
|
1. Iterates all events |
||||||
|
2. Extracts pubkeys and creates serials |
||||||
|
3. Creates graph edges for each event |
||||||
@ -0,0 +1,365 @@ |
|||||||
|
package database |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"testing" |
||||||
|
|
||||||
|
"github.com/dgraph-io/badger/v4" |
||||||
|
"next.orly.dev/pkg/database/indexes" |
||||||
|
"next.orly.dev/pkg/database/indexes/types" |
||||||
|
"next.orly.dev/pkg/encoders/event" |
||||||
|
"next.orly.dev/pkg/encoders/hex" |
||||||
|
"next.orly.dev/pkg/encoders/tag" |
||||||
|
) |
||||||
|
|
||||||
|
func TestPubkeySerialAssignment(t *testing.T) { |
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
db, err := New(ctx, cancel, t.TempDir(), "info") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
// Create a test pubkey
|
||||||
|
pubkey1 := make([]byte, 32) |
||||||
|
for i := range pubkey1 { |
||||||
|
pubkey1[i] = byte(i) |
||||||
|
} |
||||||
|
|
||||||
|
// Get or create serial for the first time
|
||||||
|
t.Logf("First call: GetOrCreatePubkeySerial for pubkey %s", hex.Enc(pubkey1)) |
||||||
|
ser1, err := db.GetOrCreatePubkeySerial(pubkey1) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get or create pubkey serial: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if ser1 == nil { |
||||||
|
t.Fatal("Serial should not be nil") |
||||||
|
} |
||||||
|
t.Logf("First call returned serial: %d", ser1.Get()) |
||||||
|
|
||||||
|
// Debug: List all keys in database
|
||||||
|
var keyCount int |
||||||
|
db.View(func(txn *badger.Txn) error { |
||||||
|
it := txn.NewIterator(badger.DefaultIteratorOptions) |
||||||
|
defer it.Close() |
||||||
|
for it.Rewind(); it.Valid(); it.Next() { |
||||||
|
key := it.Item().KeyCopy(nil) |
||||||
|
t.Logf("Found key: %s (len=%d)", hex.Enc(key), len(key)) |
||||||
|
keyCount++ |
||||||
|
if keyCount > 20 { |
||||||
|
break // Limit output
|
||||||
|
} |
||||||
|
} |
||||||
|
return nil |
||||||
|
}) |
||||||
|
t.Logf("Total keys found (first 20): %d", keyCount) |
||||||
|
|
||||||
|
// Debug: what prefix should we be looking for?
|
||||||
|
pubHash := new(types.PubHash) |
||||||
|
pubHash.FromPubkey(pubkey1) |
||||||
|
expectedPrefix := []byte(indexes.PubkeySerialPrefix) |
||||||
|
t.Logf("Expected PubkeySerial prefix: %s = %s", string(expectedPrefix), hex.Enc(expectedPrefix)) |
||||||
|
|
||||||
|
// Try direct lookup
|
||||||
|
t.Logf("Direct lookup: GetPubkeySerial for same pubkey") |
||||||
|
serDirect, err := db.GetPubkeySerial(pubkey1) |
||||||
|
if err != nil { |
||||||
|
t.Logf("Direct lookup failed: %v", err) |
||||||
|
} else { |
||||||
|
t.Logf("Direct lookup returned serial: %d", serDirect.Get()) |
||||||
|
} |
||||||
|
|
||||||
|
// Get the same pubkey again - should return the same serial
|
||||||
|
t.Logf("Second call: GetOrCreatePubkeySerial for same pubkey") |
||||||
|
ser2, err := db.GetOrCreatePubkeySerial(pubkey1) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get existing pubkey serial: %v", err) |
||||||
|
} |
||||||
|
t.Logf("Second call returned serial: %d", ser2.Get()) |
||||||
|
|
||||||
|
if ser1.Get() != ser2.Get() { |
||||||
|
t.Errorf("Expected same serial, got %d and %d", ser1.Get(), ser2.Get()) |
||||||
|
} |
||||||
|
|
||||||
|
// Create a different pubkey
|
||||||
|
pubkey2 := make([]byte, 32) |
||||||
|
for i := range pubkey2 { |
||||||
|
pubkey2[i] = byte(i + 100) |
||||||
|
} |
||||||
|
|
||||||
|
ser3, err := db.GetOrCreatePubkeySerial(pubkey2) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get or create second pubkey serial: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if ser3.Get() == ser1.Get() { |
||||||
|
t.Error("Different pubkeys should have different serials") |
||||||
|
} |
||||||
|
|
||||||
|
// Test reverse lookup: serial -> pubkey
|
||||||
|
retrievedPubkey1, err := db.GetPubkeyBySerial(ser1) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get pubkey by serial: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
if hex.Enc(retrievedPubkey1) != hex.Enc(pubkey1) { |
||||||
|
t.Errorf("Retrieved pubkey doesn't match. Expected %s, got %s", |
||||||
|
hex.Enc(pubkey1), hex.Enc(retrievedPubkey1)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func TestEventPubkeyGraph(t *testing.T) { |
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
db, err := New(ctx, cancel, t.TempDir(), "info") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
// Create test event with author and p-tags
|
||||||
|
authorPubkey, _ := hex.Dec("0000000000000000000000000000000000000000000000000000000000000001") |
||||||
|
pTagPubkey1, _ := hex.Dec("0000000000000000000000000000000000000000000000000000000000000002") |
||||||
|
pTagPubkey2, _ := hex.Dec("0000000000000000000000000000000000000000000000000000000000000003") |
||||||
|
|
||||||
|
eventID := make([]byte, 32) |
||||||
|
eventID[0] = 1 |
||||||
|
eventSig := make([]byte, 64) |
||||||
|
eventSig[0] = 1 |
||||||
|
|
||||||
|
ev := &event.E{ |
||||||
|
ID: eventID, |
||||||
|
Pubkey: authorPubkey, |
||||||
|
CreatedAt: 1234567890, |
||||||
|
Kind: 1, // text note
|
||||||
|
Content: []byte("Test event with p-tags"), |
||||||
|
Sig: eventSig, |
||||||
|
Tags: tag.NewS( |
||||||
|
tag.NewFromAny("p", hex.Enc(pTagPubkey1)), |
||||||
|
tag.NewFromAny("p", hex.Enc(pTagPubkey2)), |
||||||
|
tag.NewFromAny("e", "someeventid"), |
||||||
|
), |
||||||
|
} |
||||||
|
|
||||||
|
// Save the event - this should create pubkey serials and graph edges
|
||||||
|
_, err = db.SaveEvent(ctx, ev) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to save event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Verify that pubkey serials were created
|
||||||
|
authorSerial, err := db.GetPubkeySerial(authorPubkey) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get author pubkey serial: %v", err) |
||||||
|
} |
||||||
|
if authorSerial == nil { |
||||||
|
t.Fatal("Author serial should not be nil") |
||||||
|
} |
||||||
|
|
||||||
|
pTag1Serial, err := db.GetPubkeySerial(pTagPubkey1) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get p-tag1 pubkey serial: %v", err) |
||||||
|
} |
||||||
|
if pTag1Serial == nil { |
||||||
|
t.Fatal("P-tag1 serial should not be nil") |
||||||
|
} |
||||||
|
|
||||||
|
pTag2Serial, err := db.GetPubkeySerial(pTagPubkey2) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to get p-tag2 pubkey serial: %v", err) |
||||||
|
} |
||||||
|
if pTag2Serial == nil { |
||||||
|
t.Fatal("P-tag2 serial should not be nil") |
||||||
|
} |
||||||
|
|
||||||
|
// Verify all three pubkeys have different serials
|
||||||
|
if authorSerial.Get() == pTag1Serial.Get() || authorSerial.Get() == pTag2Serial.Get() || pTag1Serial.Get() == pTag2Serial.Get() { |
||||||
|
t.Error("All pubkey serials should be unique") |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("Event saved successfully with graph edges:") |
||||||
|
t.Logf(" Author serial: %d", authorSerial.Get()) |
||||||
|
t.Logf(" P-tag1 serial: %d", pTag1Serial.Get()) |
||||||
|
t.Logf(" P-tag2 serial: %d", pTag2Serial.Get()) |
||||||
|
} |
||||||
|
|
||||||
|
func TestMultipleEventsWithSamePubkeys(t *testing.T) { |
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
db, err := New(ctx, cancel, t.TempDir(), "info") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
// Create two events from the same author mentioning the same person
|
||||||
|
authorPubkey, _ := hex.Dec("0000000000000000000000000000000000000000000000000000000000000001") |
||||||
|
pTagPubkey, _ := hex.Dec("0000000000000000000000000000000000000000000000000000000000000002") |
||||||
|
|
||||||
|
eventID1 := make([]byte, 32) |
||||||
|
eventID1[0] = 1 |
||||||
|
eventSig1 := make([]byte, 64) |
||||||
|
eventSig1[0] = 1 |
||||||
|
|
||||||
|
ev1 := &event.E{ |
||||||
|
ID: eventID1, |
||||||
|
Pubkey: authorPubkey, |
||||||
|
CreatedAt: 1234567890, |
||||||
|
Kind: 1, |
||||||
|
Content: []byte("First event"), |
||||||
|
Sig: eventSig1, |
||||||
|
Tags: tag.NewS( |
||||||
|
tag.NewFromAny("p", hex.Enc(pTagPubkey)), |
||||||
|
), |
||||||
|
} |
||||||
|
|
||||||
|
eventID2 := make([]byte, 32) |
||||||
|
eventID2[0] = 2 |
||||||
|
eventSig2 := make([]byte, 64) |
||||||
|
eventSig2[0] = 2 |
||||||
|
|
||||||
|
ev2 := &event.E{ |
||||||
|
ID: eventID2, |
||||||
|
Pubkey: authorPubkey, |
||||||
|
CreatedAt: 1234567891, |
||||||
|
Kind: 1, |
||||||
|
Content: []byte("Second event"), |
||||||
|
Sig: eventSig2, |
||||||
|
Tags: tag.NewS( |
||||||
|
tag.NewFromAny("p", hex.Enc(pTagPubkey)), |
||||||
|
), |
||||||
|
} |
||||||
|
|
||||||
|
// Save both events
|
||||||
|
_, err = db.SaveEvent(ctx, ev1) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to save event 1: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
_, err = db.SaveEvent(ctx, ev2) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to save event 2: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Verify the same pubkeys got the same serials
|
||||||
|
authorSerial1, _ := db.GetPubkeySerial(authorPubkey) |
||||||
|
pTagSerial1, _ := db.GetPubkeySerial(pTagPubkey) |
||||||
|
|
||||||
|
if authorSerial1 == nil || pTagSerial1 == nil { |
||||||
|
t.Fatal("Pubkey serials should exist after saving events") |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("Both events share the same pubkey serials:") |
||||||
|
t.Logf(" Author serial: %d", authorSerial1.Get()) |
||||||
|
t.Logf(" P-tag serial: %d", pTagSerial1.Get()) |
||||||
|
} |
||||||
|
|
||||||
|
func TestPubkeySerialEdgeCases(t *testing.T) { |
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
db, err := New(ctx, cancel, t.TempDir(), "info") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
// Test with invalid pubkey length
|
||||||
|
invalidPubkey := make([]byte, 16) // Wrong length
|
||||||
|
_, err = db.GetOrCreatePubkeySerial(invalidPubkey) |
||||||
|
if err == nil { |
||||||
|
t.Error("Should reject pubkey with invalid length") |
||||||
|
} |
||||||
|
|
||||||
|
// Test GetPubkeySerial for non-existent pubkey
|
||||||
|
nonExistentPubkey := make([]byte, 32) |
||||||
|
for i := range nonExistentPubkey { |
||||||
|
nonExistentPubkey[i] = 0xFF |
||||||
|
} |
||||||
|
|
||||||
|
_, err = db.GetPubkeySerial(nonExistentPubkey) |
||||||
|
if err == nil { |
||||||
|
t.Error("Should return error for non-existent pubkey serial") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func TestGraphEdgeDirections(t *testing.T) { |
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
db, err := New(ctx, cancel, t.TempDir(), "info") |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to create database: %v", err) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
// Create test event with author and p-tags
|
||||||
|
authorPubkey, _ := hex.Dec("0000000000000000000000000000000000000000000000000000000000000001") |
||||||
|
pTagPubkey, _ := hex.Dec("0000000000000000000000000000000000000000000000000000000000000002") |
||||||
|
|
||||||
|
eventID := make([]byte, 32) |
||||||
|
eventID[0] = 1 |
||||||
|
eventSig := make([]byte, 64) |
||||||
|
eventSig[0] = 1 |
||||||
|
|
||||||
|
ev := &event.E{ |
||||||
|
ID: eventID, |
||||||
|
Pubkey: authorPubkey, |
||||||
|
CreatedAt: 1234567890, |
||||||
|
Kind: 1, // text note
|
||||||
|
Content: []byte("Test event"), |
||||||
|
Sig: eventSig, |
||||||
|
Tags: tag.NewS( |
||||||
|
tag.NewFromAny("p", hex.Enc(pTagPubkey)), |
||||||
|
), |
||||||
|
} |
||||||
|
|
||||||
|
// Save the event
|
||||||
|
_, err = db.SaveEvent(ctx, ev) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("Failed to save event: %v", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Verify graph edges with correct direction bytes
|
||||||
|
// Look for PubkeyEventGraph keys and check direction byte
|
||||||
|
var foundAuthorEdge, foundPTagEdge bool |
||||||
|
db.View(func(txn *badger.Txn) error { |
||||||
|
it := txn.NewIterator(badger.DefaultIteratorOptions) |
||||||
|
defer it.Close() |
||||||
|
|
||||||
|
prefix := []byte(indexes.PubkeyEventGraphPrefix) |
||||||
|
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() { |
||||||
|
key := it.Item().KeyCopy(nil) |
||||||
|
// Key format: peg(3)|pubkey_serial(5)|kind(2)|direction(1)|event_serial(5) = 16 bytes
|
||||||
|
if len(key) == 16 { |
||||||
|
direction := key[10] // Byte at position 10 is the direction
|
||||||
|
t.Logf("Found PubkeyEventGraph edge: key=%s, direction=%d", hex.Enc(key), direction) |
||||||
|
|
||||||
|
if direction == types.EdgeDirectionAuthor { |
||||||
|
foundAuthorEdge = true |
||||||
|
t.Logf(" ✓ Found author edge (direction=0)") |
||||||
|
} else if direction == types.EdgeDirectionPTagIn { |
||||||
|
foundPTagEdge = true |
||||||
|
t.Logf(" ✓ Found p-tag inbound edge (direction=2)") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return nil |
||||||
|
}) |
||||||
|
|
||||||
|
if !foundAuthorEdge { |
||||||
|
t.Error("Did not find author edge with direction=0") |
||||||
|
} |
||||||
|
if !foundPTagEdge { |
||||||
|
t.Error("Did not find p-tag inbound edge with direction=2") |
||||||
|
} |
||||||
|
|
||||||
|
t.Logf("Graph edges correctly stored with direction bytes:") |
||||||
|
t.Logf(" Author edge: %v (direction=0)", foundAuthorEdge) |
||||||
|
t.Logf(" P-tag inbound edge: %v (direction=2)", foundPTagEdge) |
||||||
|
} |
||||||
@ -0,0 +1,197 @@ |
|||||||
|
package database |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"errors" |
||||||
|
|
||||||
|
"github.com/dgraph-io/badger/v4" |
||||||
|
"lol.mleku.dev/chk" |
||||||
|
"next.orly.dev/pkg/database/indexes" |
||||||
|
"next.orly.dev/pkg/database/indexes/types" |
||||||
|
"next.orly.dev/pkg/encoders/hex" |
||||||
|
) |
||||||
|
|
||||||
|
// GetOrCreatePubkeySerial returns the serial for a pubkey, creating one if it doesn't exist.
|
||||||
|
// The pubkey parameter should be 32 bytes (schnorr public key).
|
||||||
|
// This function is thread-safe and uses transactions to ensure atomicity.
|
||||||
|
func (d *D) GetOrCreatePubkeySerial(pubkey []byte) (ser *types.Uint40, err error) { |
||||||
|
if len(pubkey) != 32 { |
||||||
|
err = errors.New("pubkey must be 32 bytes") |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Create pubkey hash
|
||||||
|
pubHash := new(types.PubHash) |
||||||
|
if err = pubHash.FromPubkey(pubkey); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// First, try to get existing serial (separate transaction for read)
|
||||||
|
var existingSer *types.Uint40 |
||||||
|
existingSer, err = d.GetPubkeySerial(pubkey) |
||||||
|
if err == nil && existingSer != nil { |
||||||
|
// Serial already exists
|
||||||
|
ser = existingSer |
||||||
|
return ser, nil |
||||||
|
} |
||||||
|
|
||||||
|
// Serial doesn't exist, create a new one
|
||||||
|
var serial uint64 |
||||||
|
if serial, err = d.pubkeySeq.Next(); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
ser = new(types.Uint40) |
||||||
|
if err = ser.Set(serial); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Store both mappings in a transaction
|
||||||
|
err = d.Update(func(txn *badger.Txn) error { |
||||||
|
// Double-check that the serial wasn't created by another goroutine
|
||||||
|
// while we were getting the sequence number
|
||||||
|
prefixBuf := new(bytes.Buffer) |
||||||
|
prefixBuf.Write([]byte(indexes.PubkeySerialPrefix)) |
||||||
|
if terr := pubHash.MarshalWrite(prefixBuf); chk.E(terr) { |
||||||
|
return terr |
||||||
|
} |
||||||
|
searchPrefix := prefixBuf.Bytes() |
||||||
|
|
||||||
|
opts := badger.DefaultIteratorOptions |
||||||
|
opts.PrefetchValues = false |
||||||
|
opts.Prefix = searchPrefix |
||||||
|
it := txn.NewIterator(opts) |
||||||
|
it.Seek(searchPrefix) |
||||||
|
if it.Valid() { |
||||||
|
// Another goroutine created it, extract and return that serial
|
||||||
|
key := it.Item().KeyCopy(nil) |
||||||
|
it.Close() |
||||||
|
if len(key) == 16 { |
||||||
|
serialBytes := key[11:16] |
||||||
|
serialBuf := bytes.NewReader(serialBytes) |
||||||
|
existSer := new(types.Uint40) |
||||||
|
if terr := existSer.UnmarshalRead(serialBuf); terr == nil { |
||||||
|
ser = existSer |
||||||
|
return nil // Don't write, just return the existing serial
|
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
it.Close() |
||||||
|
|
||||||
|
// Store pubkey hash -> serial mapping
|
||||||
|
keyBuf := new(bytes.Buffer) |
||||||
|
if terr := indexes.PubkeySerialEnc(pubHash, ser).MarshalWrite(keyBuf); chk.E(terr) { |
||||||
|
return terr |
||||||
|
} |
||||||
|
fullKey := make([]byte, len(keyBuf.Bytes())) |
||||||
|
copy(fullKey, keyBuf.Bytes()) |
||||||
|
// DEBUG: log the key being written
|
||||||
|
if len(fullKey) > 0 { |
||||||
|
// log.T.F("Writing PubkeySerial: key=%s (len=%d), prefix=%s", hex.Enc(fullKey), len(fullKey), string(fullKey[:3]))
|
||||||
|
} |
||||||
|
if terr := txn.Set(fullKey, nil); chk.E(terr) { |
||||||
|
return terr |
||||||
|
} |
||||||
|
|
||||||
|
// Store serial -> full pubkey mapping (pubkey stored as value)
|
||||||
|
keyBuf.Reset() |
||||||
|
if terr := indexes.SerialPubkeyEnc(ser).MarshalWrite(keyBuf); chk.E(terr) { |
||||||
|
return terr |
||||||
|
} |
||||||
|
if terr := txn.Set(keyBuf.Bytes(), pubkey); chk.E(terr) { |
||||||
|
return terr |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
}) |
||||||
|
|
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// GetPubkeySerial returns the serial for a pubkey if it exists.
|
||||||
|
// Returns an error if the pubkey doesn't have a serial yet.
|
||||||
|
func (d *D) GetPubkeySerial(pubkey []byte) (ser *types.Uint40, err error) { |
||||||
|
if len(pubkey) != 32 { |
||||||
|
err = errors.New("pubkey must be 32 bytes") |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Create pubkey hash
|
||||||
|
pubHash := new(types.PubHash) |
||||||
|
if err = pubHash.FromPubkey(pubkey); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Build search key with just prefix + pubkey hash (no serial)
|
||||||
|
prefixBuf := new(bytes.Buffer) |
||||||
|
prefixBuf.Write([]byte(indexes.PubkeySerialPrefix)) // 3 bytes
|
||||||
|
if err = pubHash.MarshalWrite(prefixBuf); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
searchPrefix := prefixBuf.Bytes() // Should be 11 bytes: 3 (prefix) + 8 (pubkey hash)
|
||||||
|
|
||||||
|
ser = new(types.Uint40) |
||||||
|
err = d.View(func(txn *badger.Txn) error { |
||||||
|
opts := badger.DefaultIteratorOptions |
||||||
|
opts.PrefetchValues = false // We only need the key
|
||||||
|
it := txn.NewIterator(opts) |
||||||
|
defer it.Close() |
||||||
|
|
||||||
|
// Seek to the prefix and check if we found a matching key
|
||||||
|
it.Seek(searchPrefix) |
||||||
|
if !it.ValidForPrefix(searchPrefix) { |
||||||
|
return errors.New("pubkey serial not found") |
||||||
|
} |
||||||
|
|
||||||
|
// Extract serial from key (last 5 bytes)
|
||||||
|
// Key format: prefix(3) + pubkey_hash(8) + serial(5) = 16 bytes
|
||||||
|
key := it.Item().KeyCopy(nil) |
||||||
|
if len(key) != 16 { |
||||||
|
return errors.New("invalid key length for pubkey serial") |
||||||
|
} |
||||||
|
|
||||||
|
// Verify the prefix matches
|
||||||
|
if !bytes.HasPrefix(key, searchPrefix) { |
||||||
|
return errors.New("key prefix mismatch") |
||||||
|
} |
||||||
|
|
||||||
|
serialBytes := key[11:16] // Extract last 5 bytes (the serial)
|
||||||
|
|
||||||
|
// Decode serial
|
||||||
|
serialBuf := bytes.NewReader(serialBytes) |
||||||
|
if err := ser.UnmarshalRead(serialBuf); chk.E(err) { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
}) |
||||||
|
|
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// GetPubkeyBySerial returns the full 32-byte pubkey for a given serial.
|
||||||
|
func (d *D) GetPubkeyBySerial(ser *types.Uint40) (pubkey []byte, err error) { |
||||||
|
keyBuf := new(bytes.Buffer) |
||||||
|
if err = indexes.SerialPubkeyEnc(ser).MarshalWrite(keyBuf); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
err = d.View(func(txn *badger.Txn) error { |
||||||
|
item, gerr := txn.Get(keyBuf.Bytes()) |
||||||
|
if chk.E(gerr) { |
||||||
|
return gerr |
||||||
|
} |
||||||
|
|
||||||
|
return item.Value(func(val []byte) error { |
||||||
|
pubkey = make([]byte, len(val)) |
||||||
|
copy(pubkey, val) |
||||||
|
return nil |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
if err != nil { |
||||||
|
err = errors.New("pubkey not found for serial: " + hex.Enc([]byte{byte(ser.Get())})) |
||||||
|
} |
||||||
|
|
||||||
|
return |
||||||
|
} |
||||||
Loading…
Reference in new issue