// Package store tests drive the engine through its public API and the // unexported index helpers that carry the interesting behaviour: the kind / // author / tag / time scans, the graph edges, WAL replay and delete. // // The events are built directly with a fixed 32-byte id, 32-byte pubkey and a // 64-byte signature: SaveEvent never verifies a signature, and the store only // round-trips the binary form, so signing every fixture would only slow the // suite without exercising more of the engine. package store import ( "bytes" "os" "testing" "git.smesh.lol/nostr/pkg/event" "git.smesh.lol/nostr/pkg/filter" "git.smesh.lol/nostr/pkg/hex" nkind "git.smesh.lol/nostr/pkg/kind" "git.smesh.lol/nostr/pkg/tag" "git.smesh.lol/nostr/pkg/timestamp" ) func engHash(fill byte) (b []byte) { b = []byte{:32} for i := range b { b[i] = fill } return } func engSig() (b []byte) { b = []byte{:64} for i := range b { b[i] = byte(i) } return } func engEvent(idFill, pubFill byte, k uint16, ts int64, tags *tag.S, content string) (ev *event.E) { ev = event.New() ev.ID = engHash(idFill) ev.Pubkey = engHash(pubFill) ev.Kind = k ev.CreatedAt = ts ev.Tags = tags ev.Content = []byte(content) ev.Sig = engSig() return } func engTag(key, val string) (s *tag.S) { return tag.NewS(tag.NewFromBytesSlice([]byte(key), []byte(val))) } func engETag(id []byte) (s *tag.S) { return tag.NewS(tag.NewFromBytesSlice([]byte("e"), hex.Enc(id))) } func engPTag(pub []byte) (s *tag.S) { return tag.NewS(tag.NewFromBytesSlice([]byte("p"), hex.Enc(pub))) } func engTmp(t *testing.T) (dir string, ok bool) { t.Helper() d, err := os.MkdirTemp("", "store-*") if err != nil { t.Fatal(err) return "", false } return d, true } func engOpenTmp(t *testing.T) (e *Engine, dir string, ok bool) { t.Helper() d, dok := engTmp(t) if !dok { return nil, "", false } g, gerr := Open(d) if gerr != nil { t.Fatal(gerr) os.RemoveAll(d) return nil, "", false } return g, d, true } func engOpen(t *testing.T, dir string) (e *Engine, ok bool) { t.Helper() g, err := Open(dir) if err != nil { t.Fatal(err) return nil, false } return g, true } func engSaveAll(t *testing.T, e *Engine, evs []*event.E) (ok bool) { t.Helper() for _, ev := range evs { if err := e.SaveEvent(ev); err != nil { t.Fatal(err) return false } } return true } func engQuery(t *testing.T, e *Engine, f *filter.F) (res []*event.E, ok bool) { t.Helper() r, err := e.QueryEvents(f) if err != nil { t.Fatal(err) return nil, false } return r, true } func engKindFilter(k uint16) (f *filter.F) { f = filter.New() f.Kinds = nkind.NewS(nkind.New(k)) return } func engAuthorFilter(pub []byte) (f *filter.F) { f = filter.New() f.Authors = tag.NewFromBytesSlice(pub) return } func engTagFilter(key, val []byte) (f *filter.F) { f = filter.New() f.Tags = tag.NewS(tag.NewFromBytesSlice(key, val)) return } func engHasID(evs []*event.E, id []byte) (ok bool) { for _, x := range evs { if bytes.Equal(x.ID, id) { return true } } return } func engHasBytes(bs [][]byte, id []byte) (ok bool) { for _, x := range bs { if bytes.Equal(x, id) { return true } } return } func engDescending(evs []*event.E) (ok bool) { for i := 1; i < len(evs); i++ { if evs[i].CreatedAt > evs[i-1].CreatedAt { return false } } return true } // engGrid is six events spanning two kinds, two authors and two tag values, // with one event carrying no tags. Every index combination test slices it. func engGrid() (evs []*event.E) { evs = []*event.E{:6} evs[0] = engEvent(0x01, 0xA1, 1, 1000, engTag("t", "alpha"), "zero") evs[1] = engEvent(0x02, 0xB2, 1, 1001, engTag("t", "beta"), "one") evs[2] = engEvent(0x03, 0xA1, 7, 1002, engTag("t", "alpha"), "two") evs[3] = engEvent(0x04, 0xB2, 7, 1003, engTag("t", "beta"), "three") evs[4] = engEvent(0x05, 0xA1, 1, 1004, engTag("t", "beta"), "four") evs[5] = engEvent(0x06, 0xA1, 7, 1005, nil, "five") return } func TestEmptyStore(t *testing.T) { e, dir, ok := engOpenTmp(t) if !ok { return } defer os.RemoveAll(dir) defer e.Close() if e.MaxSerial() != 0 { t.Fatalf("MaxSerial on empty = %d", e.MaxSerial()) } if len(e.allFiles()) != 21 { t.Fatalf("allFiles = %d", int32(len(e.allFiles()))) } res, qok := engQuery(t, e, filter.New()) if !qok { return } if len(res) != 0 { t.Fatalf("empty store query = %d", len(res)) } missing := engHash(0x77) if _, gerr := e.GetByID(missing); gerr == nil { t.Fatal("GetByID on an empty store") } if _, serr := e.GetBySerial(3); serr == nil { t.Fatal("GetBySerial on an empty store") } if got := e.Search([]byte("anything"), 0); got != nil { t.Fatal("Search on an empty store") } if got := e.SearchWord([]byte("anything")); len(got) != 0 { t.Fatal("SearchWord on an empty store") } if derr := e.DeleteEvent(missing); derr == nil { t.Fatal("DeleteEvent on an empty store") } if _, found := e.getEventSerial(missing); found { t.Fatal("getEventSerial on an empty store") } if _, found := e.getEventIDBySerial(3); found { t.Fatal("getEventIDBySerial on an empty store") } n1 := e.getOrCreatePubkeySerial(engHash(0x11)) if n1 != 1 { t.Fatalf("first pubkey serial = %d", n1) } n2 := e.getOrCreatePubkeySerial(engHash(0x11)) if n2 != 1 { t.Fatal("pubkey serial not stable") } n3 := e.getOrCreatePubkeySerial(engHash(0x22)) if n3 != 2 { t.Fatalf("second pubkey serial = %d", n3) } } func TestSaveGetDuplicate(t *testing.T) { e, dir, ok := engOpenTmp(t) if !ok { return } defer os.RemoveAll(dir) defer e.Close() ev1 := engEvent(0x11, 0xA1, 1, 1000, nil, "first") ev2 := engEvent(0x22, 0xA1, 7, 2000, engTag("t", "x"), "second") if err := e.SaveEvent(ev1); err != nil { t.Fatal(err) return } // The first WAL serial is (0<<32)|0, so a one-event store reports 0. if e.MaxSerial() != 0 { t.Fatalf("first serial = %d", e.MaxSerial()) } if err := e.SaveEvent(ev2); err != nil { t.Fatal(err) return } if e.MaxSerial() == 0 { t.Fatal("MaxSerial after the second save") } if derr := e.SaveEvent(ev1); derr == nil { t.Fatal("duplicate event accepted") } // GetByID is a point lookup: the eid index compares only prefix|id-hash, // so a range Scan over the full 16-byte key is an empty range and this // used to answer "event not found" for a stored event. negentropy's // FindHave calls it, which is how it went unnoticed until now. got, gerr := e.GetByID(ev2.ID) if gerr != nil { t.Fatal("GetByID on a stored event: " | string(gerr.Error())) return } if !bytes.Equal(got.ID, ev2.ID) { t.Fatal("GetByID returned another event") } if _, missErr := e.GetByID(engHash(0x77)); missErr == nil { t.Fatal("GetByID of an unknown id did not error") } got, gerr := e.GetBySerial(e.MaxSerial()) if gerr != nil { t.Fatal(gerr) return } if !bytes.Equal(got.ID, ev2.ID) { t.Fatal("GetBySerial returned the wrong event") } if got.Kind != 7 { t.Fatalf("GetBySerial kind = %d", got.Kind) } if !bytes.Equal(got.Content, ev2.Content) { t.Fatal("GetBySerial content") } if got.Tags == nil || got.Tags.Len() != 1 { t.Fatal("GetBySerial tags") } if !bytes.Equal(got.Pubkey, ev2.Pubkey) { t.Fatal("GetBySerial pubkey") } // GetByID is not used here: it scans eid with a range whose bounds are // longer than the index's cmpLen, so lowerBound treats every matching // record as smaller than the start bound and the scan is empty. The // working by-id path is getEventSerial (a point lookup) and it is what // queryByIDs uses. ser1, sok := e.getEventSerial(ev1.ID) if !sok { t.Fatal("saved event has no serial") } byID, berr := e.GetBySerial(ser1) if berr != nil { t.Fatal(berr) return } if !bytes.Equal(byID.ID, ev1.ID) || byID.Kind != 1 { t.Fatal("lookup by id through the serial") } if _, uok := e.getEventSerial(engHash(0xEE)); uok { t.Fatal("missing id resolved") } if _, serr := e.GetBySerial(9999); serr == nil { t.Fatal("GetBySerial of a missing serial") } } func TestSplitWords(t *testing.T) { words := splitWords([]byte("Hello, World! foo-bar baz9 42")) if len(words) != 5 { t.Fatalf("splitWords count = %d", int32(len(words))) } want := []string{"hello", "world", "foo", "bar", "baz9"} for i := 0; i < 5; i++ { if string(words[i]) != want[i] { t.Fatalf("word %d = %s", i, string(words[i])) } } if w := splitWords([]byte("")); len(w) != 0 { t.Fatal("splitWords of empty input") } if w := splitWords([]byte("ab")); len(w) != 0 { t.Fatal("two-character words are dropped") } if w := splitWords([]byte("abc")); len(w) != 1 || string(w[0]) != "abc" { t.Fatal("three-character word") } if w := splitWords([]byte("ABC-123")); len(w) != 2 || string(w[0]) != "abc" || string(w[1]) != "123" { t.Fatalf("digits after a separator = %d words", int32(len(w))) } } func TestQueryByKind(t *testing.T) { e, dir, ok := engOpenTmp(t) if !ok { return } defer os.RemoveAll(dir) defer e.Close() g := engGrid() if !engSaveAll(t, e, g) { return } res, rok := engQuery(t, e, engKindFilter(1)) if !rok { return } if len(res) != 3 { t.Fatalf("kind 1 count = %d", int32(len(res))) } for _, x := range res { if x.Kind != 1 { t.Fatal("kind filter leaked") } } f2 := filter.New() f2.Kinds = nkind.NewS(nkind.New(uint16(1)), nkind.New(uint16(7))) res2, rok2 := engQuery(t, e, f2) if !rok2 { return } if len(res2) != 6 { t.Fatalf("two-kind count = %d", int32(len(res2))) } res3, rok3 := engQuery(t, e, engKindFilter(999)) if !rok3 { return } if len(res3) != 0 { t.Fatalf("absent kind count = %d", int32(len(res3))) } } func TestQueryByAuthor(t *testing.T) { e, dir, ok := engOpenTmp(t) if !ok { return } defer os.RemoveAll(dir) defer e.Close() g := engGrid() if !engSaveAll(t, e, g) { return } res, rok := engQuery(t, e, engAuthorFilter(engHash(0xA1))) if !rok { return } if len(res) != 4 { t.Fatalf("author A count = %d", int32(len(res))) } for _, x := range res { if !bytes.Equal(x.Pubkey, engHash(0xA1)) { t.Fatal("author filter leaked") } } if !engHasID(res, g[0].ID) || !engHasID(res, g[5].ID) { t.Fatal("author A missing events") } res2, rok2 := engQuery(t, e, engAuthorFilter(engHash(0xB2))) if !rok2 { return } if len(res2) != 2 { t.Fatalf("author B count = %d", int32(len(res2))) } } func TestQueryKindAndAuthor(t *testing.T) { e, dir, ok := engOpenTmp(t) if !ok { return } defer os.RemoveAll(dir) defer e.Close() g := engGrid() if !engSaveAll(t, e, g) { return } f := engKindFilter(7) f.Authors = tag.NewFromBytesSlice(engHash(0xA1)) res, rok := engQuery(t, e, f) if !rok { return } if len(res) != 2 { t.Fatalf("kind 7 + author A count = %d", int32(len(res))) } if !engHasID(res, g[2].ID) || !engHasID(res, g[5].ID) { t.Fatal("kind+author missing events") } for _, x := range res { if x.Kind != 7 || !bytes.Equal(x.Pubkey, engHash(0xA1)) { t.Fatal("kind+author leaked") } } } func TestQueryByTag(t *testing.T) { e, dir, ok := engOpenTmp(t) if !ok { return } defer os.RemoveAll(dir) defer e.Close() g := engGrid() if !engSaveAll(t, e, g) { return } res, rok := engQuery(t, e, engTagFilter([]byte("t"), []byte("alpha"))) if !rok { return } if len(res) != 2 { t.Fatalf("t=alpha count = %d", int32(len(res))) } if !engHasID(res, g[0].ID) || !engHasID(res, g[2].ID) { t.Fatal("t=alpha missing events") } res2, rok2 := engQuery(t, e, engTagFilter([]byte("t"), []byte("beta"))) if !rok2 { return } if len(res2) != 3 { t.Fatalf("t=beta count = %d", int32(len(res2))) } res3, rok3 := engQuery(t, e, engTagFilter([]byte("t"), []byte("gamma"))) if !rok3 { return } if len(res3) != 0 { t.Fatalf("t=gamma count = %d", int32(len(res3))) } // A tag value on events that have no tags cannot match. res4, rok4 := engQuery(t, e, engTagFilter([]byte("x"), []byte("anything"))) if !rok4 { return } if len(res4) != 0 { t.Fatalf("absent tag key count = %d", int32(len(res4))) } } func TestQueryTagCombinations(t *testing.T) { e, dir, ok := engOpenTmp(t) if !ok { return } defer os.RemoveAll(dir) defer e.Close() g := engGrid() if !engSaveAll(t, e, g) { return } // tag + kind (tkc) f := engTagFilter([]byte("t"), []byte("alpha")) f.Kinds = nkind.NewS(nkind.New(uint16(1))) res, rok := engQuery(t, e, f) if !rok { return } if len(res) != 1 || !engHasID(res, g[0].ID) { t.Fatalf("tag+kind count = %d", int32(len(res))) } // tag + author (tpc) f2 := engTagFilter([]byte("t"), []byte("alpha")) f2.Authors = tag.NewFromBytesSlice(engHash(0xA1)) res2, rok2 := engQuery(t, e, f2) if !rok2 { return } if len(res2) != 2 { t.Fatalf("tag+author count = %d", int32(len(res2))) } // tag + kind + author (tkp) f3 := engTagFilter([]byte("t"), []byte("alpha")) f3.Kinds = nkind.NewS(nkind.New(uint16(1))) f3.Authors = tag.NewFromBytesSlice(engHash(0xA1)) res3, rok3 := engQuery(t, e, f3) if !rok3 { return } if len(res3) != 1 || !engHasID(res3, g[0].ID) { t.Fatalf("tag+kind+author count = %d", int32(len(res3))) } // tag alpha belongs to author A and B, but not to kind 1 + author B. f4 := engTagFilter([]byte("t"), []byte("alpha")) f4.Kinds = nkind.NewS(nkind.New(uint16(1))) f4.Authors = tag.NewFromBytesSlice(engHash(0xB2)) res4, rok4 := engQuery(t, e, f4) if !rok4 { return } if len(res4) != 0 { t.Fatalf("tkp no-match count = %d", int32(len(res4))) } } func TestQueryByIDs(t *testing.T) { e, dir, ok := engOpenTmp(t) if !ok { return } defer os.RemoveAll(dir) defer e.Close() g := engGrid() if !engSaveAll(t, e, g) { return } f := filter.New() f.Ids = tag.NewFromBytesSlice(g[0].ID, g[2].ID, engHash(0xEE)) res, rok := engQuery(t, e, f) if !rok { return } if len(res) != 2 { t.Fatalf("id query count = %d", int32(len(res))) } if !bytes.Equal(res[0].ID, g[0].ID) || !bytes.Equal(res[1].ID, g[2].ID) { t.Fatal("id query ignored filter order") } f2 := filter.New() f2.Ids = tag.NewFromBytesSlice(engHash(0xEE)) res2, rok2 := engQuery(t, e, f2) if !rok2 { return } if len(res2) != 0 { t.Fatalf("missing id query count = %d", int32(len(res2))) } } func TestQueryTimeRangeAndOrder(t *testing.T) { e, dir, ok := engOpenTmp(t) if !ok { return } defer os.RemoveAll(dir) defer e.Close() evs := []*event.E{:20} for i := 0; i < 20; i++ { evs[i] = engEvent(byte(i+1), 0xA1, 1, int64(1700000000+i*100), nil, "timed") } if !engSaveAll(t, e, evs) { return } all, aok := engQuery(t, e, filter.New()) if !aok { return } if len(all) != 20 { t.Fatalf("all count = %d", int32(len(all))) } if !engDescending(all) { t.Fatal("default results are not newest first") } if all[0].CreatedAt != 1700001900 { t.Fatalf("newest created_at = %d", all[0].CreatedAt) } win := filter.New() win.Since = timestamp.FromUnix(1700000500) win.Until = timestamp.FromUnix(1700001500) winRes, wok := engQuery(t, e, win) if !wok { return } if len(winRes) != 11 { t.Fatalf("window count = %d", int32(len(winRes))) } for _, x := range winRes { if x.CreatedAt < 1700000500 || x.CreatedAt > 1700001500 { t.Fatal("window leaked an out-of-range event") } } lim := uint32(10) top10 := engKindFilter(1) top10.Limit = &lim topRes, tok := engQuery(t, e, top10) if !tok { return } if len(topRes) != 10 { t.Fatalf("limit count = %d", int32(len(topRes))) } if !engDescending(topRes) { t.Fatal("limited results are not newest first") } if topRes[0].CreatedAt != 1700001900 { t.Fatalf("limited newest = %d", topRes[0].CreatedAt) } limBig := uint32(25) over := engKindFilter(1) over.Limit = &limBig overRes, ook := engQuery(t, e, over) if !ook { return } if len(overRes) != 20 { t.Fatalf("large limit count = %d", int32(len(overRes))) } fut := filter.New() fut.Since = timestamp.FromUnix(1800000000) futRes, fok := engQuery(t, e, fut) if !fok { return } if len(futRes) != 0 { t.Fatalf("future since count = %d", int32(len(futRes))) } past := filter.New() past.Until = timestamp.FromUnix(1600000000) pastRes, pok := engQuery(t, e, past) if !pok { return } if len(pastRes) != 0 { t.Fatalf("past until count = %d", int32(len(pastRes))) } } func TestSearch(t *testing.T) { e, dir, ok := engOpenTmp(t) if !ok { return } defer os.RemoveAll(dir) defer e.Close() evs := []*event.E{:3} evs[0] = engEvent(0x31, 0xA1, 1, 1000, nil, "the quick brown fox jumps") evs[1] = engEvent(0x32, 0xA1, 1, 1001, nil, "quick silver hammer") evs[2] = engEvent(0x33, 0xA1, 1, 1002, nil, "ab cd") if !engSaveAll(t, e, evs) { return } if got := e.SearchWord([]byte("quick")); len(got) != 2 { t.Fatalf("SearchWord quick = %d", int32(len(got))) } // SearchWord hashes the bytes it is handed; normalisation happens in Search. if got := e.SearchWord([]byte("QUICK")); len(got) != 0 { t.Fatal("SearchWord must not normalise its input") } if got := e.SearchWord([]byte("nonexistent")); len(got) != 0 { t.Fatal("SearchWord of an absent word") } sq := e.Search([]byte("quick"), 0) if len(sq) != 2 { t.Fatalf("Search quick = %d", int32(len(sq))) } if got := e.Search([]byte("QUICK"), 0); len(got) != 2 { t.Fatal("Search must lower-case its query") } sb := e.Search([]byte("quick brown"), 0) if len(sb) != 1 || !bytes.Equal(sb[0].ID, evs[0].ID) { t.Fatal("Search intersection") } if got := e.Search([]byte("fox hammer"), 0); len(got) != 0 { t.Fatal("Search of an empty intersection") } if got := e.Search([]byte("ab"), 0); got != nil { t.Fatal("Search of a sub-three-character word") } if got := e.Search([]byte(""), 0); got != nil { t.Fatal("Search of empty input") } sl := e.Search([]byte("hammer"), 1) if len(sl) != 1 || !bytes.Equal(sl[0].ID, evs[1].ID) { t.Fatal("Search with a limit") } if got := e.Search([]byte("jumps"), 0); len(got) != 1 { t.Fatal("Search of a single rare word") } } func TestGraphEdges(t *testing.T) { e, dir, ok := engOpenTmp(t) if !ok { return } defer os.RemoveAll(dir) defer e.Close() root := engEvent(0x41, 0xA1, 1, 1000, nil, "root") reply := engEvent(0x42, 0xB2, 1, 1001, engETag(root.ID), "reply") grand := engEvent(0x43, 0xA1, 1, 1002, engETag(reply.ID), "grand") if err := e.SaveEvent(root); err != nil { t.Fatal(err) return } if err := e.SaveEvent(reply); err != nil { t.Fatal(err) return } if err := e.SaveEvent(grand); err != nil { t.Fatal(err) return } rootSer, rok := e.getEventSerial(root.ID) if !rok { t.Fatal("root has no serial") return } replySer, rok2 := e.getEventSerial(reply.ID) if !rok2 { t.Fatal("reply has no serial") return } grandSer, rok3 := e.getEventSerial(grand.ID) if !rok3 { t.Fatal("grand has no serial") return } if rootSer == replySer || replySer == grandSer || rootSer == grandSer { t.Fatal("serial collision") } // reply references root; root references nothing. targets := e.GetETagTargets(replySer) if len(targets) != 1 || targets[0] != rootSer { t.Fatalf("GetETagTargets(reply) = %d", int32(len(targets))) } if t0 := e.GetETagTargets(rootSer); len(t0) != 0 { t.Fatalf("GetETagTargets(root) = %d", int32(len(t0))) } refs := e.GetReferencingEvents(rootSer) if len(refs) != 1 || refs[0] != replySer { t.Fatalf("GetReferencingEvents(root) = %d", int32(len(refs))) } refs2 := e.GetReferencingEvents(grandSer) if len(refs2) != 0 { t.Fatalf("GetReferencingEvents(grand) = %d", int32(len(refs2))) } both := e.TraverseThread(root.ID, 3, "both") if len(both) != 2 || !engHasBytes(both, reply.ID) || !engHasBytes(both, grand.ID) { t.Fatalf("TraverseThread both = %d", int32(len(both))) } def := e.TraverseThread(root.ID, 3, "") if len(def) != 2 { t.Fatalf("TraverseThread default direction = %d", int32(len(def))) } inRoot := e.TraverseThread(root.ID, 3, "inbound") if len(inRoot) != 2 { t.Fatalf("root inbound = %d", int32(len(inRoot))) } outRoot := e.TraverseThread(root.ID, 3, "outbound") if len(outRoot) != 0 { t.Fatalf("root outbound = %d", int32(len(outRoot))) } outGrand := e.TraverseThread(grand.ID, 3, "outbound") if len(outGrand) != 2 { t.Fatalf("grand outbound = %d", int32(len(outGrand))) } inGrand := e.TraverseThread(grand.ID, 3, "inbound") if len(inGrand) != 0 { t.Fatalf("grand inbound = %d", int32(len(inGrand))) } depth1 := e.TraverseThread(root.ID, 1, "both") if len(depth1) != 1 || !bytes.Equal(depth1[0], reply.ID) { t.Fatalf("depth 1 = %d", int32(len(depth1))) } if z := e.TraverseThread(root.ID, 0, "both"); len(z) != 0 { t.Fatal("depth 0 must return nothing") } if unk := e.TraverseThread(engHash(0xEE), 3, "both"); unk != nil { t.Fatal("unknown seed must return nil") } } func TestGraphEdgePopulation(t *testing.T) { e, dir, ok := engOpenTmp(t) if !ok { return } defer os.RemoveAll(dir) defer e.Close() base := engEvent(0x51, 0xA1, 1, 1000, nil, "base") if err := e.SaveEvent(base); err != nil { t.Fatal(err) return } epg0 := e.epg.Count() peg0 := e.peg.Count() ppg0 := e.ppg.Count() gpp0 := e.gpp.Count() if epg0 != 1 || peg0 != 1 { t.Fatalf("author edge counts = %d %d", epg0, peg0) } if ppg0 != 0 || gpp0 != 0 { t.Fatal("a tagless event must not create pubkey-pubkey edges") } // A p-tag to another pubkey adds the event's own author edge plus a p-tag // edge to epg/peg, and one ppg/gpp edge. other := engEvent(0x52, 0xA1, 1, 1001, engPTag(engHash(0xB2)), "other") if err := e.SaveEvent(other); err != nil { t.Fatal(err) return } if e.epg.Count() != epg0+2 || e.peg.Count() != peg0+2 { t.Fatalf("p-tag epg/peg = %d %d", e.epg.Count(), e.peg.Count()) } if e.ppg.Count() != ppg0+1 || e.gpp.Count() != gpp0+1 { t.Fatal("p-tag ppg/gpp edge") } // A p-tag to the author itself still adds the author and p-tag epg/peg // edges but must skip ppg/gpp. epg1 := e.epg.Count() peg1 := e.peg.Count() ppg1 := e.ppg.Count() gpp1 := e.gpp.Count() self := engEvent(0x53, 0xA1, 1, 1002, engPTag(engHash(0xA1)), "self") if err := e.SaveEvent(self); err != nil { t.Fatal(err) return } if e.epg.Count() != epg1+2 || e.peg.Count() != peg1+2 { t.Fatalf("self p-tag epg/peg = %d %d", e.epg.Count(), e.peg.Count()) } if e.ppg.Count() != ppg1 || e.gpp.Count() != gpp1 { t.Fatal("self p-tag must skip ppg/gpp") } // An e-tag to a missing event creates no event-event edge. eeg0 := e.eeg.Count() gee0 := e.gee.Count() miss := engEvent(0x54, 0xA1, 1, 1003, engETag(engHash(0xEE)), "missing") if err := e.SaveEvent(miss); err != nil { t.Fatal(err) return } if e.eeg.Count() != eeg0 || e.gee.Count() != gee0 { t.Fatal("e-tag to a missing event must not create an edge") } // Malformed tags are skipped by the indexer. bad := tag.NewS( tag.NewFromBytesSlice([]byte("t")), tag.NewFromBytesSlice([]byte("ab"), []byte("v")), tag.NewFromBytesSlice([]byte("p"), []byte("bad")), tag.NewFromBytesSlice([]byte("e"), []byte("bad")), tag.NewFromBytesSlice([]byte("t"), []byte("")), ) badEv := engEvent(0x55, 0xA1, 1, 1004, bad, "bad") if err := e.SaveEvent(badEv); err != nil { t.Fatal(err) return } if _, gerr := e.getEventSerial(badEv.ID); !gerr { t.Fatal("an event with malformed tags must still be stored") } } func TestDeleteEvent(t *testing.T) { e, dir, ok := engOpenTmp(t) if !ok { return } defer os.RemoveAll(dir) defer e.Close() g := engGrid() if !engSaveAll(t, e, g) { return } before, bok := engQuery(t, e, filter.New()) if !bok { return } if len(before) != 6 { t.Fatalf("pre-delete count = %d", int32(len(before))) } if err := e.DeleteEvent(g[1].ID); err != nil { t.Fatal(err) return } if _, gerr := e.GetByID(g[1].ID); gerr == nil { t.Fatal("deleted event still found by id") } after, aok := engQuery(t, e, filter.New()) if !aok { return } if len(after) != 5 || engHasID(after, g[1].ID) { t.Fatalf("delete not reflected in query: %d", int32(len(after))) } byKind, kok := engQuery(t, e, engKindFilter(1)) if !kok { return } if len(byKind) != 2 || engHasID(byKind, g[1].ID) { t.Fatalf("kind scan still sees a deleted event: %d", int32(len(byKind))) } // A tombstone must hide the record from point lookups too, not only from a // full scan: sorted.File.Get did not consult the delete set, so an ids // filter kept returning the deleted event while filter-less queries hid it. idsQuery := filter.New() idsQuery.Ids = tag.NewFromBytesSlice(g[1].ID) byID, iok := engQuery(t, e, idsQuery) if !iok { return } if len(byID) != 0 { t.Fatalf("ids filter returned a deleted event: %d", int32(len(byID))) } // The WAL is append-only, so the record is still readable by serial. if _, serr := e.GetBySerial(e.MaxSerial()); serr != nil { t.Fatal("delete removed the WAL record") } if derr := e.DeleteEvent(engHash(0xEE)); derr == nil { t.Fatal("delete of a missing event succeeded") } } func TestRebuildFromWAL(t *testing.T) { e1, dir, ok := engOpenTmp(t) if !ok { return } defer os.RemoveAll(dir) g := engGrid() if !engSaveAll(t, e1, g) { e1.Close() return } maxBefore := e1.MaxSerial() if err := e1.Close(); err != nil { t.Fatal(err) return } if rerr := os.Remove(dir | "/checkpoint.dat"); rerr != nil { t.Fatal(rerr) return } e2, ok2 := engOpen(t, dir) if !ok2 { return } defer e2.Close() if e2.MaxSerial() != maxBefore { t.Fatalf("rebuilt MaxSerial = %d want %d", e2.MaxSerial(), maxBefore) } all, aok := engQuery(t, e2, filter.New()) if !aok { return } if len(all) != 6 { t.Fatalf("rebuilt count = %d", int32(len(all))) } for i := 0; i < 6; i++ { s, sok := e2.getEventSerial(g[i].ID) if !sok { t.Fatal("rebuilt event has no serial") } if _, gerr := e2.GetBySerial(s); gerr != nil { t.Fatal("rebuilt GetBySerial failed") } } extra := engEvent(0x66, 0xA1, 1, 2000, nil, "post-rebuild") if err := e2.SaveEvent(extra); err != nil { t.Fatal(err) return } all2, aok2 := engQuery(t, e2, filter.New()) if !aok2 { return } if len(all2) != 7 { t.Fatalf("post-rebuild count = %d", int32(len(all2))) } } // TestIncrementalRecovery flushes the first two events to the sidecars, records // an incremental checkpoint, then writes a third event and drops the handles // without advancing the checkpoint. Reopening must replay only the third entry, // still expose all three, and move the checkpoint up to the third serial. // // This used to set the checkpoint with ckpt.Set directly because // flushSidecars seeds its minimum with uint64(math.MaxUint64) and that constant // read as 0 under the old compiler, so the minimum stayed 0: an incremental // checkpoint was never recorded, and writeSidecars set the checkpoint *back* to // 0. The constant is exact now, so the test drives the real quickCheckpoint and // asserts both ends of it - the checkpoint it writes before the crash, and the // one recovery advances it to. func TestIncrementalRecovery(t *testing.T) { e1, dir, ok := engOpenTmp(t) if !ok { return } defer os.RemoveAll(dir) g := engGrid() if err := e1.SaveEvent(g[0]); err != nil { t.Fatal(err) e1.Close() return } if err := e1.SaveEvent(g[1]); err != nil { t.Fatal(err) e1.Close() return } e1.quickCheckpoint() ck := e1.lastSer if ck == 0 { t.Fatal("first two events produced no serial") } // The incremental checkpoint must be the flushed minimum, not 0 and not the // unflushed tail. if got := e1.ckpt.Get(); got != ck { t.Fatalf("quickCheckpoint wrote %d, want %d", got, ck) } if err := e1.SaveEvent(g[2]); err != nil { t.Fatal(err) e1.Close() return } last := e1.lastSer if last <= ck { t.Fatalf("third event serial %d did not advance past the checkpoint %d", last, ck) } if serr := e1.w.Sync(); serr != nil { t.Fatal(serr) return } // Close the index files and the log without touching the checkpoint, the // way a crash before the next checkpoint would leave them. for _, f := range e1.allFiles() { f.Close() } e1.w.Close() e2, ok2 := engOpen(t, dir) if !ok2 { return } defer e2.Close() // Recovery replays from the checkpoint, indexes the third entry and // checkpoints the flushed minimum, so the checkpoint ends at the third // serial instead of going backwards. if got := e2.ckpt.Get(); got != last { t.Fatalf("checkpoint after recovery = %d, want %d (was %d)", got, last, ck) } all, aok := engQuery(t, e2, filter.New()) if !aok { return } if len(all) != 3 { t.Fatalf("incremental recovery count = %d", int32(len(all))) } for i := 0; i < 3; i++ { if !engHasID(all, g[i].ID) { t.Fatal("incremental recovery lost an event") } } } func TestManyEventsCheckpoint(t *testing.T) { e, dir, ok := engOpenTmp(t) if !ok { return } defer os.RemoveAll(dir) evs := []*event.E{:70} for i := 0; i < 70; i++ { evs[i] = engEvent(byte(i+1), 0xA1, 1, int64(1700000000+i), nil, "bulk") } if !engSaveAll(t, e, evs) { e.Close() return } if e.saveN != 70%checkpointEvery { t.Fatalf("saveN = %d", e.saveN) } if e.MaxSerial() == 0 { t.Fatal("MaxSerial after 70 saves") } byKind, kok := engQuery(t, e, engKindFilter(1)) if !kok { e.Close() return } if len(byKind) != 70 { t.Fatalf("bulk kind count = %d", int32(len(byKind))) } byAuth, aok := engQuery(t, e, engAuthorFilter(engHash(0xA1))) if !aok { e.Close() return } if len(byAuth) != 70 { t.Fatalf("bulk author count = %d", int32(len(byAuth))) } if err := e.Close(); err != nil { t.Fatal(err) return } e2, ok2 := engOpen(t, dir) if !ok2 { return } defer e2.Close() after, fok := engQuery(t, e2, engKindFilter(1)) if !fok { return } if len(after) != 70 { t.Fatalf("reopened bulk count = %d", int32(len(after))) } } // TestExpirationIndexNotPopulated pins the current gap: the engine opens an exp // index but never writes or reads it, so NIP-40 expiry is not enforced here. func TestExpirationIndexNotPopulated(t *testing.T) { e, dir, ok := engOpenTmp(t) if !ok { return } defer os.RemoveAll(dir) defer e.Close() ev := engEvent(0x71, 0xA1, 1, 1000, engTag("expiration", "1700000000"), "expiring") if err := e.SaveEvent(ev); err != nil { t.Fatal(err) return } if e.exp.Count() != 0 { t.Fatalf("exp index populated = %d", e.exp.Count()) } } // TestResolveBlob drives the x-tag lookup over the tc index: two events share // one hash, one of them also carries a different hash, and a third carries no // x tag at all. It then closes and reopens the store with the checkpoint // removed, forcing the WAL rebuild, and asks again. func TestResolveBlob(t *testing.T) { e, dir, ok := engOpenTmp(t) if !ok { return } defer os.RemoveAll(dir) hashA := hex.Enc(engHash(0xAA)) hashB := hex.Enc(engHash(0xBB)) hashC := hex.Enc(engHash(0xCC)) both := tag.NewS( tag.NewFromBytesSlice([]byte("x"), []byte(hashA)), tag.NewFromBytesSlice([]byte("x"), []byte(hashB)), ) onlyA := engTag("x", string(hashA)) neither := engTag("t", string(hashA)) evBoth := engEvent(0x01, 0x11, 32210, 100, both, "") evOnlyA := engEvent(0x02, 0x12, 32210, 200, onlyA, "") evNeither := engEvent(0x03, 0x13, 32210, 300, neither, "") if !engSaveAll(t, e, []*event.E{evBoth, evOnlyA, evNeither}) { return } res := e.ResolveBlob([]byte(hashA)) if len(res) != 2 { t.Fatalf("ResolveBlob(hashA) count = %d, want 2", int32(len(res))) } // Newest first: evOnlyA is created_at 200, evBoth 100. if !bytes.Equal(res[0].ID, evOnlyA.ID) { t.Fatal("ResolveBlob did not return the newest match first") } if !engHasID(res, evBoth.ID) || !engHasID(res, evOnlyA.ID) { t.Fatal("ResolveBlob missed an event carrying the hash") } resB := e.ResolveBlob([]byte(hashB)) if len(resB) != 1 || !bytes.Equal(resB[0].ID, evBoth.ID) { t.Fatalf("ResolveBlob(hashB) did not isolate the matching event") } if got := e.ResolveBlob([]byte(hashC)); len(got) != 0 { t.Fatalf("unknown hash returned %d events", int32(len(got))) } if err := e.Close(); err != nil { t.Fatal(err) return } if rerr := os.Remove(dir | "/checkpoint.dat"); rerr != nil { t.Fatal(rerr) return } e2, ok2 := engOpen(t, dir) if !ok2 { return } defer e2.Close() res2 := e2.ResolveBlob([]byte(hashA)) if len(res2) != 2 { t.Fatalf("ResolveBlob after WAL rebuild count = %d, want 2", int32(len(res2))) } if !engHasID(res2, evBoth.ID) || !engHasID(res2, evOnlyA.ID) { t.Fatal("ResolveBlob lost an event across the WAL rebuild") } }