bring in khatru and eventstore.

This commit is contained in:
fiatjaf
2025-04-15 08:49:28 -03:00
parent 8466a9757b
commit 76032dc089
170 changed files with 15018 additions and 42 deletions
@@ -0,0 +1,64 @@
---
outline: deep
---
# Generating custom live events
Suppose you want to generate a new event every time a goal is scored on some soccer game and send that to all clients subscribed to a given game according to a tag `t`.
We'll assume you'll be polling some HTTP API that gives you the game's current score, and that in your `main` function you'll start the function that does the polling:
```go
func main () {
// other stuff here
relay := khatru.NewRelay()
go startPollingGame(relay)
// other stuff here
}
type GameStatus struct {
TeamA int `json:"team_a"`
TeamB int `json:"team_b"`
}
func startPollingGame(relay *khatru.Relay) {
current := GameStatus{0, 0}
for {
newStatus, err := fetchGameStatus()
if err != nil {
continue
}
if newStatus.TeamA > current.TeamA {
// team A has scored a goal, here we generate an event
evt := nostr.Event{
CreatedAt: nostr.Now(),
Kind: 1,
Content: "team A has scored!",
Tags: nostr.Tags{{"t", "this-game"}}
}
evt.Sign(global.RelayPrivateKey)
// calling BroadcastEvent will send the event to everybody who has been listening for tag "t=[this-game]"
// there is no need to do any code to keep track of these clients or who is listening to what, khatru
// does that already in the background automatically
relay.BroadcastEvent(evt)
// just calling BroadcastEvent won't cause this event to be be stored,
// if for any reason you want to store these events you must call the store functions manually
for _, store := range relay.StoreEvent {
store(context.TODO(), evt)
}
}
if newStatus.TeamB > current.TeamB {
// same here, if team B has scored a goal
// ...
}
}
}
func fetchGameStatus() (GameStatus, error) {
// implementation of calling some external API goes here
}
```
+88
View File
@@ -0,0 +1,88 @@
---
outline: deep
---
# Generating events on the fly from a non-Nostr data-source
Suppose you want to serve events with the weather data for periods in the past. All you have is a big CSV file with the data.
Then you get a query like `{"#g": ["d6nvp"], "since": 1664074800, "until": 1666666800, "kind": 10774}`, imagine for a while that kind `10774` means weather data.
First you do some geohashing calculation to discover that `d6nvp` corresponds to Willemstad, Curaçao, then you query your XML file for the Curaçao weather data for the given period -- from `2022-09-25` to `2022-10-25`, then you return the events corresponding to such query, signed on the fly:
```go
func main () {
// other stuff here
relay := khatru.NewRelay()
relay.QueryEvents = append(relay.QueryEvents,
handleWeatherQuery,
)
// other stuff here
}
func handleWeatherQuery(ctx context.Context, filter nostr.Filter) (ch chan *nostr.Event, err error) {
if filter.Kind != 10774 {
// this function only handles kind 10774, if the query is for something else we return
// a nil channel, which corresponds to no results
return nil, nil
}
file, err := os.Open("weatherdata.xml")
if err != nil {
return nil, fmt.Errorf("we have lost our file: %w", err)
}
// QueryEvents functions are expected to return a channel
ch := make(chan *nostr.Event)
// and they can do their query asynchronously, emitting events to the channel as they come
go func () {
defer file.Close()
// we're going to do this for each tag in the filter
gTags, _ := filter.Tags["g"]
for _, gTag := range gTags {
// translate geohash into city name
citName, err := geohashToCityName(gTag)
if err != nil {
continue
}
reader := csv.NewReader(file)
for {
record, err := reader.Read()
if err != nil {
return
}
// ensure we're only getting records for Willemstad
if cityName != record[0] {
continue
}
date, _ := time.Parse("2006-01-02", record[1])
ts := nostr.Timestamp(date.Unix())
if ts > filter.Since && ts < filter.Until {
// we found a record that matches the filter, so we make
// an event on the fly and return it
evt := nostr.Event{
CreatedAt: ts,
Kind: 10774,
Tags: nostr.Tags{
{"temperature", record[2]},
{"condition", record[3]},
}
}
evt.Sign(global.RelayPrivateKey)
ch <- evt
}
}
}
}()
return ch, nil
}
```
Beware, the code above is inefficient and the entire approach is not very smart, it's meant just as an example.
+58
View File
@@ -0,0 +1,58 @@
---
outline: deep
---
# Generating `khatru` relays dynamically and serving them from the same path
Suppose you want to expose a different relay interface depending on the subdomain that is accessed. I don't know, maybe you want to serve just events with pictures on `pictures.example.com` and just events with audio files on `audios.example.com`; maybe you want just events in English on `en.example.com` and just examples in Portuguese on `pt.example.com`, there are many possibilities.
You could achieve that with a scheme like the following
```go
var topLevelHost = "example.com"
var mainRelay = khatru.NewRelay() // we're omitting all the configuration steps for brevity
var subRelays = xsync.NewMapOf[string, *khatru.Relay]()
func main () {
handler := http.HandlerFunc(dynamicRelayHandler)
log.Printf("listening at http://0.0.0.0:8080")
http.ListenAndServe("0.0.0.0:8080", handler)
}
func dynamicRelayHandler(w http.ResponseWriter, r *http.Request) {
var relay *khatru.Relay
subdomain := r.Host[0 : len(topLevelHost)-len(topLevelHost)]
if subdomain == "" {
// no subdomain, use the main top-level relay
relay = mainRelay
} else {
// call on subdomain, so get a dynamic relay
subdomain = subdomain[0 : len(subdomain)-1] // remove dangling "."
// get a dynamic relay
relay, _ = subRelays.LoadOrCompute(subdomain, func () *khatru.Relay {
return makeNewRelay(subdomain)
})
}
relay.ServeHTTP(w, r)
}
func makeNewRelay (subdomain string) *khatru.Relay {
// somehow use the subdomain to generate a relay with specific configurations
relay := khatru.NewRelay()
switch subdomain {
case "pictures":
// relay configuration shenanigans go here
case "audios":
// relay configuration shenanigans go here
case "en":
// relay configuration shenanigans go here
case "pt":
// relay configuration shenanigans go here
}
return relay
}
```
In practice you could come up with a way that allows all these dynamic relays to share a common underlying datastore, but this is out of the scope of this example.
+67
View File
@@ -0,0 +1,67 @@
---
outline: deep
---
## Querying events from Google Drive
Suppose you have a bunch of events stored in text files on Google Drive and you want to serve them as a relay. You could just store each event as a separate file and use the native Google Drive search to match the queries when serving requests. It would probably not be as fast as using local database, but it would work.
```go
func main () {
// other stuff here
relay := khatru.NewRelay()
relay.StoreEvent = append(relay.StoreEvent, handleEvent)
relay.QueryEvents = append(relay.QueryEvents, handleQuery)
// other stuff here
}
func handleEvent(ctx context.Context, event *nostr.Event) error {
// store each event as a file on google drive
_, err := gdriveService.Files.Create(googledrive.CreateOptions{
Name: event.ID, // with the name set to their id
Body: event.String(), // the body as the full event JSON
})
return err
}
func handleQuery(ctx context.Context, filter nostr.Filter) (ch chan *nostr.Event, err error) {
// QueryEvents functions are expected to return a channel
ch := make(chan *nostr.Event)
// and they can do their query asynchronously, emitting events to the channel as they come
go func () {
if len(filter.IDs) > 0 {
// if the query is for ids we can do a simpler name match
for _, id := range filter.IDS {
results, _ := gdriveService.Files.List(googledrive.ListOptions{
Q: fmt.Sprintf("name = '%s'", id)
})
if len(results) > 0 {
var evt nostr.Event
json.Unmarshal(results[0].Body, &evt)
ch <- evt
}
}
} else {
// otherwise we use the google-provided search and hope it will catch tags that are in the event body
for tagName, tagValues := range filter.Tags {
results, _ := gdriveService.Files.List(googledrive.ListOptions{
Q: fmt.Sprintf("fullText contains '%s'", tagValues)
})
for _, result := range results {
var evt nostr.Event
json.Unmarshal(results[0].Body, &evt)
if filter.Match(evt) {
ch <- evt
}
}
}
}
}()
return ch, nil
}
```
(Disclaimer: since I have no idea of how to properly use the Google Drive API this interface is entirely made up.)
+51
View File
@@ -0,0 +1,51 @@
---
outline: deep
---
# Implementing NIP-50 `search` support
The [`nostr.Filter` type](https://pkg.go.dev/github.com/nbd-wtf/go-nostr#Filter) has a `Search` field, so you basically just has to handle that if it's present.
It can be tricky to implement fulltext search properly though, so some [eventstores](../core/eventstore) implement it natively, such as [Bluge](https://pkg.go.dev/github.com/fiatjaf/eventstore/bluge), [OpenSearch](https://pkg.go.dev/github.com/fiatjaf/eventstore/opensearch) and [ElasticSearch](https://pkg.go.dev/github.com/fiatjaf/eventstore/elasticsearch) (although for the last two you'll need an instance of these database servers running, while with Bluge it's embedded).
If you have any of these you can just use them just like any other eventstore:
```go
func main () {
// other stuff here
normal := &lmdb.LMDBBackend{Path: "data"}
os.MkdirAll(normal.Path, 0755)
if err := normal.Init(); err != nil {
panic(err)
}
search := bluge.BlugeBackend{Path: "search", RawEventStore: normal}
if err := search.Init(); err != nil {
panic(err)
}
relay.StoreEvent = append(relay.StoreEvent, normal.SaveEvent, search.SaveEvent)
relay.QueryEvents = append(relay.QueryEvents, normal.QueryEvents, search.QueryEvents)
relay.DeleteEvent = append(relay.DeleteEvent, normal.DeleteEvent, search.DeleteEvent)
// other stuff here
}
```
Note that in this case we're using the [LMDB](https://pkg.go.dev/github.com/fiatjaf/eventstore/lmdb) adapter for normal queries and it explicitly rejects any filter that contains a `Search` field, while [Bluge](https://pkg.go.dev/github.com/fiatjaf/eventstore/bluge) rejects any filter _without_ a `Search` value, which make them pair well together.
Other adapters, like [SQLite](https://pkg.go.dev/github.com/fiatjaf/eventstore/sqlite3), implement search functionality on their own, so if you don't want to use that you would have to have a middleware between, like:
```go
relay.StoreEvent = append(relay.StoreEvent, db.SaveEvent, search.SaveEvent)
relay.QueryEvents = append(relay.QueryEvents, func (ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) {
if len(filter.Search) > 0 {
return search.QueryEvents(ctx, filter)
} else {
filterNoSearch := filter
filterNoSearch.Search = ""
return normal.QueryEvents(ctx, filterNoSearch)
}
})
```