// Package store provides the Nostr event storage engine. // It uses an append-only WAL for event data and sorted flat files // for indexes. Single cooperative thread — no locks. package store import ( "bytes" "fmt" "math" "os" "path/filepath" "sort" "smesh.lol/pkg/nostr/event" "smesh.lol/pkg/nostr/filter" "smesh.lol/pkg/nostr/hex" "smesh.lol/pkg/store/index" "smesh.lol/pkg/store/serial" "smesh.lol/pkg/store/sorted" "smesh.lol/pkg/store/wal" ) // Engine is the main storage engine. type Engine struct { dir string w *wal.WAL nextPubSer uint64 eid *sorted.File fpc *sorted.File sei *sorted.File ca *sorted.File exp *sorted.File kc *sorted.File pc *sorted.File kpc *sorted.File tc *sorted.File tkc *sorted.File tpc *sorted.File tkp *sorted.File wrd *sorted.File pks *sorted.File spk *sorted.File // Graph indexes. epg *sorted.File peg *sorted.File eeg *sorted.File gee *sorted.File ppg *sorted.File gpp *sorted.File } // Open opens or creates a store at dir. func Open(dir string) (*Engine, error) { idxDir := filepath.Join(dir, "idx") if err := os.MkdirAll(idxDir, 0755); err != nil { return nil, err } w, err := wal.Open(filepath.Join(dir, "wal")) if err != nil { return nil, err } e := &Engine{dir: dir, w: w, nextPubSer: 1} open := func(name string, recLen, cmpLen int) (*sorted.File, error) { return sorted.Open(filepath.Join(idxDir, name+".dat"), recLen, cmpLen) } if e.eid, err = open("eid", index.EidKeyLen, index.EidKeyLen); err != nil { return nil, err } if e.fpc, err = open("fpc", index.FpcKeyLen, index.FpcCmpLen); err != nil { return nil, err } if e.sei, err = open("sei", index.SeiRecLen, index.SeiKeyLen); err != nil { return nil, err } if e.ca, err = open("ca", index.CAKeyLen, index.CAKeyLen); err != nil { return nil, err } if e.exp, err = open("exp", index.ExpKeyLen, index.ExpKeyLen); err != nil { return nil, err } if e.kc, err = open("kc", index.KCKeyLen, index.KCKeyLen); err != nil { return nil, err } if e.pc, err = open("pc", index.PCKeyLen, index.PCKeyLen); err != nil { return nil, err } if e.kpc, err = open("kpc", index.KPCKeyLen, index.KPCKeyLen); err != nil { return nil, err } if e.tc, err = open("tc", index.TCKeyLen, index.TCKeyLen); err != nil { return nil, err } if e.tkc, err = open("tkc", index.TKCKeyLen, index.TKCKeyLen); err != nil { return nil, err } if e.tpc, err = open("tpc", index.TPCKeyLen, index.TPCKeyLen); err != nil { return nil, err } if e.tkp, err = open("tkp", index.TKPKeyLen, index.TKPKeyLen); err != nil { return nil, err } if e.wrd, err = open("wrd", index.WrdKeyLen, index.WrdKeyLen); err != nil { return nil, err } if e.pks, err = open("pks", index.PksKeyLen, index.PksKeyLen); err != nil { return nil, err } if e.spk, err = open("spk", index.SpkRecLen, index.SpkKeyLen); err != nil { return nil, err } if e.epg, err = open("epg", index.EpgKeyLen, index.EpgKeyLen); err != nil { return nil, err } if e.peg, err = open("peg", index.PegKeyLen, index.PegKeyLen); err != nil { return nil, err } if e.eeg, err = open("eeg", index.EegKeyLen, index.EegKeyLen); err != nil { return nil, err } if e.gee, err = open("gee", index.GeeKeyLen, index.GeeKeyLen); err != nil { return nil, err } if e.ppg, err = open("ppg", index.PpgKeyLen, index.PpgKeyLen); err != nil { return nil, err } if e.gpp, err = open("gpp", index.GppKeyLen, index.GppKeyLen); err != nil { return nil, err } // Check for clean shutdown. If marker is missing, recover indexes from WAL. cleanPath := filepath.Join(dir, ".clean") if _, err := os.Stat(cleanPath); os.IsNotExist(err) { fmt.Println("store: clean marker missing, rebuilding indexes from WAL...") if err := e.rebuildIndexes(); err != nil { return nil, fmt.Errorf("store: recovery failed: %w", err) } if err := e.Flush(); err != nil { return nil, err } e.writeCleanMarker() } // Recover next pubkey serial from the spk index. if rec, ok := e.spk.Last(); ok { e.nextPubSer = serial.Get(rec[3:]) + 1 } return e, nil } // Close flushes all indexes and closes the WAL. func (e *Engine) Close() error { files := e.allFiles() for _, f := range files { if err := f.Close(); err != nil { return err } } return e.w.Close() } // Flush writes all buffered index data to disk and syncs the WAL. func (e *Engine) Flush() error { for _, f := range e.allFiles() { if err := f.Flush(); err != nil { return err } } if err := e.w.Sync(); err != nil { return err } e.writeCleanMarker() return nil } func (e *Engine) writeCleanMarker() { os.WriteFile(filepath.Join(e.dir, ".clean"), []byte("ok"), 0644) } func (e *Engine) removeCleanMarker() { os.Remove(filepath.Join(e.dir, ".clean")) } // rebuildIndexes clears all indexes and rebuilds from WAL. func (e *Engine) rebuildIndexes() error { for _, f := range e.allFiles() { if err := f.Clear(); err != nil { return err } } e.nextPubSer = 1 count := 0 err := e.w.ForEach(func(ser uint64, data []byte) bool { ev := event.New() if err := ev.UnmarshalBinary(bytes.NewReader(data)); err != nil { return true // skip corrupt entries } pubHash := serial.PubHash(ev.Pubkey) idHash := serial.IdHash(ev.ID) e.eid.Put(index.MakeEid(idHash, ser)) e.sei.Put(index.MakeSeiRec(ser, ev.ID)) e.fpc.Put(index.MakeFpc(ser, ev.ID, pubHash, ev.CreatedAt)) e.ca.Put(index.MakeCA(ev.CreatedAt, ser)) e.kc.Put(index.MakeKC(ev.Kind, ev.CreatedAt, ser)) e.pc.Put(index.MakePC(pubHash, ev.CreatedAt, ser)) e.kpc.Put(index.MakeKPC(ev.Kind, pubHash, ev.CreatedAt, ser)) if ev.Tags != nil { for _, tg := range *ev.Tags { if tg.Len() < 2 { continue } key := tg.Key() if len(key) != 1 { continue } tagKey := key[0] val := tg.ValueHex() if len(val) == 0 { continue } valHash := serial.Ident(val) e.tc.Put(index.MakeTC(tagKey, valHash, ev.CreatedAt, ser)) e.tkc.Put(index.MakeTKC(ev.Kind, tagKey, valHash, ev.CreatedAt, ser)) e.tpc.Put(index.MakeTPC(pubHash, tagKey, valHash, ev.CreatedAt, ser)) e.tkp.Put(index.MakeTKP(ev.Kind, pubHash, tagKey, valHash, ev.CreatedAt, ser)) } } if len(ev.Content) > 0 { words := splitWords(ev.Content) seen := map[string]bool{} for _, w := range words { if seen[string(w)] { continue } seen[string(w)] = true e.wrd.Put(index.MakeWrd(serial.Ident(w), ser)) } } e.populateGraphEdges(ev, ser) count++ return true }) if err != nil { return err } fmt.Println("store: rebuilt indexes for", count, "events") return nil } func (e *Engine) allFiles() []*sorted.File { return []*sorted.File{ e.eid, e.fpc, e.sei, e.ca, e.exp, e.kc, e.pc, e.kpc, e.tc, e.tkc, e.tpc, e.tkp, e.wrd, e.pks, e.spk, e.epg, e.peg, e.eeg, e.gee, e.ppg, e.gpp, } } // SaveEvent persists an event and all its indexes. func (e *Engine) SaveEvent(ev *event.E) error { e.removeCleanMarker() // Duplicate check via ID hash. idHash := serial.IdHash(ev.ID) if e.hasEvent(idHash, ev.ID) { return fmt.Errorf("duplicate event") } // Write event bytes to WAL → serial. data := ev.MarshalBinaryToBytes(nil) ser, err := e.w.Append(data) if err != nil { return err } pubHash := serial.PubHash(ev.Pubkey) // Core mappings. e.eid.Put(index.MakeEid(idHash, ser)) e.sei.Put(index.MakeSeiRec(ser, ev.ID)) e.fpc.Put(index.MakeFpc(ser, ev.ID, pubHash, ev.CreatedAt)) // Query indexes. e.ca.Put(index.MakeCA(ev.CreatedAt, ser)) e.kc.Put(index.MakeKC(ev.Kind, ev.CreatedAt, ser)) e.pc.Put(index.MakePC(pubHash, ev.CreatedAt, ser)) e.kpc.Put(index.MakeKPC(ev.Kind, pubHash, ev.CreatedAt, ser)) // Tag indexes. if ev.Tags != nil { for _, tg := range *ev.Tags { if tg.Len() < 2 { continue } key := tg.Key() if len(key) != 1 { continue } tagKey := key[0] val := tg.ValueHex() if len(val) == 0 { continue } valHash := serial.Ident(val) e.tc.Put(index.MakeTC(tagKey, valHash, ev.CreatedAt, ser)) e.tkc.Put(index.MakeTKC(ev.Kind, tagKey, valHash, ev.CreatedAt, ser)) e.tpc.Put(index.MakeTPC(pubHash, tagKey, valHash, ev.CreatedAt, ser)) e.tkp.Put(index.MakeTKP(ev.Kind, pubHash, tagKey, valHash, ev.CreatedAt, ser)) } } // Word index for search. if len(ev.Content) > 0 { words := splitWords(ev.Content) seen := map[string]bool{} for _, w := range words { if seen[string(w)] { continue } seen[string(w)] = true e.wrd.Put(index.MakeWrd(serial.Ident(w), ser)) } } // Pubkey serial mapping + graph edges. e.populateGraphEdges(ev, ser) return nil } // populateGraphEdges creates all graph index entries for an event. func (e *Engine) populateGraphEdges(ev *event.E, ser uint64) { authorSer := e.getOrCreatePubkeySerial(ev.Pubkey) kind := ev.Kind // epg/peg: author edge. e.epg.Put(index.MakeEpg(ser, authorSer, kind, index.DirAuthor)) e.peg.Put(index.MakePeg(authorSer, kind, index.DirAuthor, ser)) if ev.Tags == nil { return } for _, tg := range *ev.Tags { if tg.Len() < 2 { continue } key := tg.Key() if len(key) != 1 { continue } switch key[0] { case 'p': valHex := tg.ValueHex() if len(valHex) != 64 { continue } pkBytes, err := hex.Dec(string(valHex)) if err != nil || len(pkBytes) != 32 { continue } pkSer := e.getOrCreatePubkeySerial(pkBytes) // epg/peg: p-tag edges. e.epg.Put(index.MakeEpg(ser, pkSer, kind, index.DirPTagOut)) e.peg.Put(index.MakePeg(pkSer, kind, index.DirPTagIn, ser)) // ppg/gpp: direct pubkey-to-pubkey edge (skip self-reference). if pkSer != authorSer { e.ppg.Put(index.MakePpg(authorSer, pkSer, kind, index.DirPKOut, ser)) e.gpp.Put(index.MakeGpp(pkSer, kind, index.DirPKIn, authorSer, ser)) } case 'e': valHex := tg.ValueHex() if len(valHex) != 64 { continue } evBytes, err := hex.Dec(string(valHex)) if err != nil || len(evBytes) != 32 { continue } // Look up target event serial — only create edge if target exists. tgtSer, ok := e.getEventSerial(evBytes) if !ok { continue } // eeg/gee: e-tag edges. e.eeg.Put(index.MakeEeg(ser, tgtSer, kind, index.DirETagOut)) e.gee.Put(index.MakeGee(tgtSer, kind, index.DirETagIn, ser)) } } } // getEventSerial returns the WAL serial for a 32-byte event ID, if it exists. func (e *Engine) getEventSerial(id []byte) (uint64, bool) { idHash := serial.IdHash(id) start := index.MakeEid(idHash, 0) end := index.MakeEid(idHash, serial.Max) var found uint64 var ok bool e.eid.Scan(start, end, func(rec []byte) bool { s := serial.Get(rec[3+serial.HashLen:]) if eid, exists := e.getEventIDBySerial(s); exists { if bytes.Equal(eid, id) { found = s ok = true return false } } return true }) return found, ok } // SearchWord returns serials of events containing the given word. func (e *Engine) SearchWord(word []byte) []uint64 { wh := serial.Ident(word) return e.collectSerials(e.wrd, index.MakeWrd(wh, 0), index.MakeWrd(wh, serial.Max), index.WrdKeyLen) } func splitWords(content []byte) [][]byte { var words [][]byte var word []byte for _, b := range content { if b >= 'A' && b <= 'Z' { word = append(word, b+32) } else if (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') { word = append(word, b) } else { if len(word) >= 3 { w := []byte{:len(word)} copy(w, word) words = append(words, w) } word = word[:0] } } if len(word) >= 3 { w := []byte{:len(word)} copy(w, word) words = append(words, w) } return words } // GetBySerial reads an event from the WAL by its serial. func (e *Engine) GetBySerial(ser uint64) (*event.E, error) { data, err := e.w.Read(ser) if err != nil { return nil, err } ev := event.New() if err := ev.UnmarshalBinary(bytes.NewReader(data)); err != nil { return nil, err } return ev, nil } // QueryEvents returns events matching the filter, sorted newest-first. func (e *Engine) QueryEvents(f *filter.F) ([]*event.E, error) { if f.Ids != nil && f.Ids.Len() > 0 { return e.queryByIDs(f) } var since, until int64 if f.Since != nil && f.Since.I64() != 0 { since = f.Since.I64() } if f.Until != nil && f.Until.I64() != 0 { until = f.Until.I64() } else { until = math.MaxInt64 } hasKinds := f.Kinds != nil && f.Kinds.Len() > 0 hasAuthors := f.Authors != nil && f.Authors.Len() > 0 hasTags := f.Tags != nil && f.Tags.Len() > 0 var serials []uint64 switch { case hasTags && hasKinds && hasAuthors: serials = e.scanTKP(f, since, until) case hasTags && hasKinds: serials = e.scanTKC(f, since, until) case hasTags && hasAuthors: serials = e.scanTPC(f, since, until) case hasTags: serials = e.scanTC(f, since, until) case hasKinds && hasAuthors: serials = e.scanKPC(f, since, until) case hasKinds: serials = e.scanKC(f, since, until) case hasAuthors: serials = e.scanPC(f, since, until) default: serials = e.scanCA(since, until) } // Deduplicate. seen := map[uint64]bool{} deduped := serials[:0] for _, s := range serials { if !seen[s] { seen[s] = true deduped = append(deduped, s) } } // Fetch and apply full filter. var results event.S for _, ser := range deduped { ev, err := e.GetBySerial(ser) if err != nil { continue } if f.Matches(ev) { results = append(results, ev) } } sort.Sort(results) // reverse chronological if f.Limit != nil && int(*f.Limit) < len(results) { results = results[:*f.Limit] } return results, nil } // DeleteEvent removes an event and its primary index entries. func (e *Engine) DeleteEvent(id []byte) error { idHash := serial.IdHash(id) // Find the serial for this event. var ser uint64 var found bool start := index.MakeEid(idHash, 0) end := index.MakeEid(idHash, serial.Max) e.eid.Scan(start, end, func(rec []byte) bool { s := serial.Get(rec[3+serial.HashLen:]) if eid, ok := e.getEventIDBySerial(s); ok { if bytes.Equal(eid, id) { ser = s found = true return false } } return true }) if !found { return fmt.Errorf("event not found") } // Read the event to get kind/pubkey/timestamp for index cleanup. ev, err := e.GetBySerial(ser) if err != nil { return err } pubHash := serial.PubHash(ev.Pubkey) // Delete primary index entries. e.eid.Delete(index.MakeEid(idHash, ser)) e.ca.Delete(index.MakeCA(ev.CreatedAt, ser)) e.kc.Delete(index.MakeKC(ev.Kind, ev.CreatedAt, ser)) e.pc.Delete(index.MakePC(pubHash, ev.CreatedAt, ser)) e.kpc.Delete(index.MakeKPC(ev.Kind, pubHash, ev.CreatedAt, ser)) return nil } // GetByID retrieves an event by its 32-byte ID. func (e *Engine) GetByID(id []byte) (*event.E, error) { idHash := serial.IdHash(id) start := index.MakeEid(idHash, 0) end := index.MakeEid(idHash, serial.Max) var found *event.E e.eid.Scan(start, end, func(rec []byte) bool { ser := serial.Get(rec[3+serial.HashLen:]) if eid, ok := e.getEventIDBySerial(ser); ok { if bytes.Equal(eid, id) { if ev, err := e.GetBySerial(ser); err == nil { found = ev return false } } } return true }) if found == nil { return nil, fmt.Errorf("event not found") } return found, nil } // --- internal helpers --- func (e *Engine) hasEvent(idHash, fullID []byte) bool { start := index.MakeEid(idHash, 0) end := index.MakeEid(idHash, serial.Max) var found bool e.eid.Scan(start, end, func(rec []byte) bool { ser := serial.Get(rec[3+serial.HashLen:]) if eid, ok := e.getEventIDBySerial(ser); ok { if bytes.Equal(eid, fullID) { found = true return false } } return true }) return found } func (e *Engine) getOrCreatePubkeySerial(pubkey []byte) uint64 { pubHash := serial.PubHash(pubkey) start := index.MakePks(pubHash, 0) end := index.MakePks(pubHash, serial.Max) var found uint64 var exists bool e.pks.Scan(start, end, func(rec []byte) bool { found = serial.Get(rec[3+serial.HashLen:]) exists = true return false }) if exists { return found } ser := e.nextPubSer e.nextPubSer++ e.pks.Put(index.MakePks(pubHash, ser)) e.spk.Put(index.MakeSpkRec(ser, pubkey)) return ser } func (e *Engine) getEventIDBySerial(ser uint64) ([]byte, bool) { key := index.MakeSei(ser) rec, ok := e.sei.Get(key) if !ok { return nil, false } return rec[index.SeiKeyLen:], true } func (e *Engine) queryByIDs(f *filter.F) ([]*event.E, error) { var results []*event.E for _, id := range f.Ids.T { idHash := serial.IdHash(id) start := index.MakeEid(idHash, 0) end := index.MakeEid(idHash, serial.Max) e.eid.Scan(start, end, func(rec []byte) bool { ser := serial.Get(rec[3+serial.HashLen:]) if fullID, ok := e.getEventIDBySerial(ser); ok { if bytes.Equal(fullID, id) { if ev, err := e.GetBySerial(ser); err == nil { results = append(results, ev) } } } return true }) } return results, nil } func (e *Engine) collectSerials(idx *sorted.File, start, end []byte, keyLen int) []uint64 { var out []uint64 idx.Scan(start, end, func(rec []byte) bool { out = append(out, serial.Get(rec[keyLen-serial.Len:])) return true }) return out } func (e *Engine) scanCA(since, until int64) []uint64 { return e.collectSerials(e.ca, index.MakeCA(since, 0), index.MakeCA(until, serial.Max), index.CAKeyLen) } func (e *Engine) scanKC(f *filter.F, since, until int64) []uint64 { var out []uint64 for _, k := range f.Kinds.K { out = append(out, e.collectSerials(e.kc, index.MakeKC(k.K, since, 0), index.MakeKC(k.K, until, serial.Max), index.KCKeyLen)...) } return out } func (e *Engine) scanPC(f *filter.F, since, until int64) []uint64 { var out []uint64 for _, author := range f.Authors.T { ph := serial.PubHash(author) out = append(out, e.collectSerials(e.pc, index.MakePC(ph, since, 0), index.MakePC(ph, until, serial.Max), index.PCKeyLen)...) } return out } func (e *Engine) scanKPC(f *filter.F, since, until int64) []uint64 { var out []uint64 for _, k := range f.Kinds.K { for _, author := range f.Authors.T { ph := serial.PubHash(author) out = append(out, e.collectSerials(e.kpc, index.MakeKPC(k.K, ph, since, 0), index.MakeKPC(k.K, ph, until, serial.Max), index.KPCKeyLen)...) } } return out } func (e *Engine) scanTC(f *filter.F, since, until int64) []uint64 { var out []uint64 for _, tg := range *f.Tags { if tg.Len() < 2 || len(tg.Key()) != 1 { continue } tk := tg.Key()[0] for _, val := range tg.T[1:] { vh := serial.Ident(val) out = append(out, e.collectSerials(e.tc, index.MakeTC(tk, vh, since, 0), index.MakeTC(tk, vh, until, serial.Max), index.TCKeyLen)...) } } return out } func (e *Engine) scanTKC(f *filter.F, since, until int64) []uint64 { var out []uint64 for _, tg := range *f.Tags { if tg.Len() < 2 || len(tg.Key()) != 1 { continue } tk := tg.Key()[0] for _, val := range tg.T[1:] { vh := serial.Ident(val) for _, k := range f.Kinds.K { out = append(out, e.collectSerials(e.tkc, index.MakeTKC(k.K, tk, vh, since, 0), index.MakeTKC(k.K, tk, vh, until, serial.Max), index.TKCKeyLen)...) } } } return out } func (e *Engine) scanTPC(f *filter.F, since, until int64) []uint64 { var out []uint64 for _, tg := range *f.Tags { if tg.Len() < 2 || len(tg.Key()) != 1 { continue } tk := tg.Key()[0] for _, val := range tg.T[1:] { vh := serial.Ident(val) for _, author := range f.Authors.T { ph := serial.PubHash(author) out = append(out, e.collectSerials(e.tpc, index.MakeTPC(ph, tk, vh, since, 0), index.MakeTPC(ph, tk, vh, until, serial.Max), index.TPCKeyLen)...) } } } return out } func (e *Engine) scanTKP(f *filter.F, since, until int64) []uint64 { var out []uint64 for _, tg := range *f.Tags { if tg.Len() < 2 || len(tg.Key()) != 1 { continue } tk := tg.Key()[0] for _, val := range tg.T[1:] { vh := serial.Ident(val) for _, k := range f.Kinds.K { for _, author := range f.Authors.T { ph := serial.PubHash(author) out = append(out, e.collectSerials(e.tkp, index.MakeTKP(k.K, ph, tk, vh, since, 0), index.MakeTKP(k.K, ph, tk, vh, until, serial.Max), index.TKPKeyLen)...) } } } } return out } // --- Graph traversal --- // GetReferencingEvents returns serials of events that reference targetSer via e-tags. // Uses the gee (reverse event-event) index. func (e *Engine) GetReferencingEvents(targetSer uint64) []uint64 { start := index.MakeGee(targetSer, 0, 0, 0) end := index.MakeGee(targetSer, 0xFFFF, 0xFF, serial.Max) var out []uint64 e.gee.Scan(start, end, func(rec []byte) bool { out = append(out, serial.Get(rec[11:])) return true }) return out } // GetETagTargets returns serials of events that srcSer references via e-tags. // Uses the eeg (forward event-event) index. func (e *Engine) GetETagTargets(srcSer uint64) []uint64 { start := index.MakeEeg(srcSer, 0, 0, 0) end := index.MakeEeg(srcSer, serial.Max, 0xFFFF, 0xFF) var out []uint64 e.eeg.Scan(start, end, func(rec []byte) bool { out = append(out, serial.Get(rec[8:])) return true }) return out } // TraverseThread performs BFS traversal of thread structure via e-tags. // Returns all event IDs (32-byte binary) reachable from seedID within maxDepth. func (e *Engine) TraverseThread(seedID []byte, maxDepth int, direction string) [][]byte { seedSer, ok := e.getEventSerial(seedID) if !ok { return nil } if direction == "" { direction = "both" } visited := map[uint64]bool{} visited[seedSer] = true frontier := []uint64{seedSer} var allIDs [][]byte emptyStreak := 0 for depth := 1; depth <= maxDepth; depth++ { var next []uint64 found := 0 for _, evSer := range frontier { if direction == "both" || direction == "inbound" { for _, refSer := range e.GetReferencingEvents(evSer) { if visited[refSer] { continue } visited[refSer] = true if eid, ok := e.getEventIDBySerial(refSer); ok { allIDs = append(allIDs, eid) found++ } next = append(next, refSer) } } if direction == "both" || direction == "outbound" { for _, tgtSer := range e.GetETagTargets(evSer) { if visited[tgtSer] { continue } visited[tgtSer] = true if eid, ok := e.getEventIDBySerial(tgtSer); ok { allIDs = append(allIDs, eid) found++ } next = append(next, tgtSer) } } } if found == 0 { emptyStreak++ if emptyStreak >= 2 { break } } else { emptyStreak = 0 } frontier = next } return allIDs }