diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..08624e7 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,39 @@ +# eventstore command-line tool + +``` +go install github.com/fiatjaf/eventstore/cli@latest +``` + +## Usage + +This should be pretty straightforward. You pipe events or filters, as JSON, to the `eventstore` command, and they yield something. You can use [nak](https://github.com/fiatjaf/nak) to generate these events or filters easily. + +### Querying the last 100 events of kind 1 + +```fish +~> nak req -k 1 -l 100 --bare | eventstore -d /path/to/store query +~> # or +~> echo '{"kinds":[1],"limit":100}' | eventstore -d /path/to/store query +``` + +This will automatically determine the storage type being used at `/path/to/store`, but you can also specify it manually using the `-t` option (`-t lmdb`, `-t sqlite` etc). + +### Saving an event to the store + +```fish +~> nak event -k 1 -c hello | eventstore -d /path/to/store put +~> # or +~> echo '{"id":"35369e6bae5f77c4e1745c2eb5db84c4493e87f6e449aee62a261bbc1fea2788","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1701193836,"kind":1,"tags":[],"content":"hello","sig":"ef08d559e042d9af4cdc3328a064f737603d86ec4f929f193d5a3ce9ea22a3fb8afc1923ee3c3742fd01856065352c5632e91f633528c80e9c5711fa1266824c"}' | eventstore -d /path/to/store put +``` + +You can also create a database from scratch if it's a disk database, but then you have to specify `-t` to `sqlite`, `badger` or `lmdb`. + +### Connecting to Postgres, MySQL and other remote databases + +You should be able to connect by just passing the database connection URI to `-d`: + +```bash +~> eventstore -d 'postgres://myrelay:38yg4o83yf48a3s7g@localhost:5432/myrelay?sslmode=disable' +``` + +That should be prefixed with `postgres://` for Postgres, `mysql://` for MySQL and `https://` for ElasticSearch. diff --git a/cli/del.go b/cli/del.go new file mode 100644 index 0000000..0af823b --- /dev/null +++ b/cli/del.go @@ -0,0 +1,21 @@ +package main + +import ( + "fmt" + + "github.com/urfave/cli/v2" +) + +var del = &cli.Command{ + Name: "del", + Usage: "deletes an event", + Description: ``, + Action: func(c *cli.Context) error { + for line := range getStdinLinesOrBlank() { + fmt.Println(line) + } + + exitIfLineProcessingError(c) + return nil + }, +} diff --git a/cli/helpers.go b/cli/helpers.go new file mode 100644 index 0000000..9576fdf --- /dev/null +++ b/cli/helpers.go @@ -0,0 +1,109 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/urfave/cli/v2" +) + +const ( + LINE_PROCESSING_ERROR = iota +) + +func detect(dir string) (string, error) { + f, err := os.Stat(dir) + if err != nil { + return "", err + } + if !f.IsDir() { + f, err := os.Open(dir) + if err != nil { + return "", err + } + buf := make([]byte, 15) + f.Read(buf) + if string(buf) == "SQLite format 3" { + return "sqlite", nil + } + return "", fmt.Errorf("unknown format") + } + + entries, err := os.ReadDir(dir) + if err != nil { + return "", err + } + + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".mdb") { + return "lmdb", nil + } + if strings.HasSuffix(entry.Name(), ".vlog") { + return "badger", nil + } + } + + return "", fmt.Errorf("undetected") +} + +func getStdin() string { + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) == 0 { + read := bytes.NewBuffer(make([]byte, 0, 1000)) + _, err := io.Copy(read, os.Stdin) + if err == nil { + return read.String() + } + } + return "" +} + +func isPiped() bool { + stat, _ := os.Stdin.Stat() + return stat.Mode()&os.ModeCharDevice == 0 +} + +func getStdinLinesOrBlank() chan string { + multi := make(chan string) + if hasStdinLines := writeStdinLinesOrNothing(multi); !hasStdinLines { + single := make(chan string, 1) + single <- "" + close(single) + return single + } else { + return multi + } +} + +func writeStdinLinesOrNothing(ch chan string) (hasStdinLines bool) { + if isPiped() { + // piped + go func() { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + ch <- strings.TrimSpace(scanner.Text()) + } + close(ch) + }() + return true + } else { + // not piped + return false + } +} + +func lineProcessingError(c *cli.Context, msg string, args ...any) { + c.Context = context.WithValue(c.Context, LINE_PROCESSING_ERROR, true) + fmt.Fprintf(os.Stderr, msg, args...) +} + +func exitIfLineProcessingError(c *cli.Context) { + if val := c.Context.Value(LINE_PROCESSING_ERROR); val != nil && val.(bool) { + os.Exit(123) + } +} diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 0000000..5342494 --- /dev/null +++ b/cli/main.go @@ -0,0 +1,99 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/fiatjaf/eventstore" + "github.com/fiatjaf/eventstore/badger" + "github.com/fiatjaf/eventstore/elasticsearch" + "github.com/fiatjaf/eventstore/lmdb" + "github.com/fiatjaf/eventstore/mysql" + "github.com/fiatjaf/eventstore/postgresql" + "github.com/fiatjaf/eventstore/sqlite3" + "github.com/urfave/cli/v2" +) + +var db eventstore.Store + +var app = &cli.App{ + Name: "eventstore", + Usage: "a CLI for all the eventstore backends", + UsageText: "eventstore -d ./data/sqlite ...", + Flags: []cli.Flag{ + &cli.PathFlag{ + Name: "store", + Aliases: []string{"d"}, + Usage: "path to the database file or directory or database connection uri", + }, + &cli.StringFlag{ + Name: "type", + Aliases: []string{"t"}, + Usage: "store type ('sqlite', 'lmdb', 'badger', 'postgres', 'mysql', 'elasticsearch')", + }, + }, + Before: func(c *cli.Context) error { + path := c.Path("store") + typ := c.String("type") + if typ != "" { + // bypass automatic detection + // this also works for creating disk databases from scratch + } else { + // try to detect based on url scheme + switch { + case strings.HasPrefix(path, "postgres://"), strings.HasPrefix(path, "postgresql://"): + typ = "postgres" + case strings.HasPrefix(path, "mysql://"): + typ = "mysql" + case strings.HasPrefix(path, "https://"): + // if we ever add something else that uses URLs we'll have to modify this + typ = "elasticsearch" + default: + // try to detect based on the form and names of disk files + dbname, err := detect(path) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf( + "'%s' does not exist, to create a store there specify the --type argument", path) + } + return fmt.Errorf("failed to detect store type: %w", err) + } + typ = dbname + } + } + + switch typ { + case "sqlite": + db = &sqlite3.SQLite3Backend{DatabaseURL: path} + case "lmdb": + db = &lmdb.LMDBBackend{Path: path, MaxLimit: 5000} + case "badger": + db = &badger.BadgerBackend{Path: path, MaxLimit: 5000} + case "postgres", "postgresql": + db = &postgresql.PostgresBackend{DatabaseURL: path} + case "mysql": + db = &mysql.MySQLBackend{DatabaseURL: path} + case "elasticsearch": + db = &elasticsearch.ElasticsearchStorage{URL: path} + case "": + return fmt.Errorf("couldn't determine store type, you can use --type to specify it manually") + default: + return fmt.Errorf("'%s' store type is not supported by this CLI", typ) + } + + return db.Init() + }, + Commands: []*cli.Command{ + query, + put, + del, + }, +} + +func main() { + if err := app.Run(os.Args); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/cli/put.go b/cli/put.go new file mode 100644 index 0000000..db78566 --- /dev/null +++ b/cli/put.go @@ -0,0 +1,30 @@ +package main + +import ( + "github.com/mailru/easyjson" + "github.com/nbd-wtf/go-nostr" + "github.com/urfave/cli/v2" +) + +var put = &cli.Command{ + Name: "put", + Usage: "saves an event to an eventstore", + Description: ``, + Action: func(c *cli.Context) error { + for line := range getStdinLinesOrBlank() { + var event nostr.Event + if err := easyjson.Unmarshal([]byte(line), &event); err != nil { + lineProcessingError(c, "invalid event '%s' received from stdin: %s", line, err) + continue + } + + if err := db.SaveEvent(c.Context, &event); err != nil { + lineProcessingError(c, "failed to save event '%s': %s", line, err) + continue + } + } + + exitIfLineProcessingError(c) + return nil + }, +} diff --git a/cli/query.go b/cli/query.go new file mode 100644 index 0000000..deadcca --- /dev/null +++ b/cli/query.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + + "github.com/mailru/easyjson" + "github.com/nbd-wtf/go-nostr" + "github.com/urfave/cli/v2" +) + +var query = &cli.Command{ + Name: "query", + Usage: "queries an eventstore for events", + Description: ``, + Action: func(c *cli.Context) error { + for line := range getStdinLinesOrBlank() { + filter := nostr.Filter{} + if err := easyjson.Unmarshal([]byte(line), &filter); err != nil { + lineProcessingError(c, "invalid filter '%s' received from stdin: %s", line, err) + continue + } + + ch, err := db.QueryEvents(c.Context, filter) + if err != nil { + lineProcessingError(c, "error querying: %w", err) + continue + } + + for evt := range ch { + fmt.Println(evt) + } + } + + exitIfLineProcessingError(c) + return nil + }, +} diff --git a/go.mod b/go.mod index 8c7ddd1..4e0bcc5 100644 --- a/go.mod +++ b/go.mod @@ -13,12 +13,14 @@ require ( github.com/mattn/go-sqlite3 v1.14.18 github.com/nbd-wtf/go-nostr v0.25.3 github.com/stretchr/testify v1.8.4 + github.com/urfave/cli/v2 v2.25.7 ) require ( github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect @@ -43,9 +45,11 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/tidwall/gjson v1.17.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect go.opencensus.io v0.24.0 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/net v0.17.0 // indirect diff --git a/go.sum b/go.sum index 57292fc..3bde1e5 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -123,6 +125,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= github.com/puzpuzpuz/xsync/v2 v2.5.1/go.mod h1:gD2H2krq/w52MfPLE+Uy64TzJDVY7lP2znR9qmR35kU= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -139,6 +143,10 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= +github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=