Add schema abstraction, move event store into zooid
This commit is contained in:
@@ -8,10 +8,10 @@ A single zooid instance can run any number of "virtual" relays. The `config` dir
|
|||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
Zooid supports a few environment variables, which configure shared resources, like the web server or sqlite database file.
|
Zooid supports a few environment variables, which configure shared resources like the web server or sqlite database.
|
||||||
|
|
||||||
- `PORT` - the port the server will listen on for all requests. Defaults to `3334`.
|
- `PORT` - the port the server will listen on for all requests. Defaults to `3334`.
|
||||||
- `DATABASE_PATH` - the location of the database path. Defaults to `./data.db`
|
- `DATA` - the location of the directory for storing database files and media. Defaults to `./data`.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -52,7 +52,6 @@ Configures NIP 86 support.
|
|||||||
Configures blossom support.
|
Configures blossom support.
|
||||||
|
|
||||||
- `enabled` - whether blossom is enabled.
|
- `enabled` - whether blossom is enabled.
|
||||||
- `directory` - where to store files. Defaults to `./data/{my-relay}/media`.
|
|
||||||
|
|
||||||
### `[roles]`
|
### `[roles]`
|
||||||
|
|
||||||
@@ -88,11 +87,16 @@ can_invite = true
|
|||||||
|
|
||||||
[roles.admin]
|
[roles.admin]
|
||||||
pubkeys = ["d9254d9898fd4728f7e2b32b87520221a50f6b8b97d935d7da2de8923988aa6d"]
|
pubkeys = ["d9254d9898fd4728f7e2b32b87520221a50f6b8b97d935d7da2de8923988aa6d"]
|
||||||
|
|
||||||
[data]
|
|
||||||
events = "./data/my-relay/events"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
See `justfile` for defined commands.
|
See `justfile` for defined commands.
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- [ ] Create a "schema" abstraction to namespace tables
|
||||||
|
- This resource should be passed to event stores as well as claims, redemptions, etc
|
||||||
|
- We might need to create a custom blossom backend since the prefixes for the two stores will collide
|
||||||
|
- [ ] Watch configuration files and hot reload
|
||||||
|
- [ ] Free up resources after instance inactivity
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ func main() {
|
|||||||
shutdown := make(chan os.Signal, 1)
|
shutdown := make(chan os.Signal, 1)
|
||||||
signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
port := zooid.Env("PORT", "3334")
|
port := zooid.Env("PORT")
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: fmt.Sprintf(":%s", port),
|
Addr: fmt.Sprintf(":%s", port),
|
||||||
Handler: http.HandlerFunc(zooid.ServeHTTP),
|
Handler: http.HandlerFunc(zooid.ServeHTTP),
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fiatjaf.com/nostr"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *SqliteBackend) CountEvents(nostr.Filter) (uint32, error) {
|
|
||||||
return 0, errors.New("not supported")
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fiatjaf.com/nostr"
|
|
||||||
"github.com/Masterminds/squirrel"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *SqliteBackend) DeleteEvent(id nostr.ID) error {
|
|
||||||
_, err := squirrel.Delete(s.tmpl("{{.Prefix}}events")).Where(squirrel.Eq{"id": id.Hex()}).RunWith(s.db).Exec()
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
-125
@@ -1,125 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"text/template"
|
|
||||||
|
|
||||||
"fiatjaf.com/nostr/eventstore"
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ eventstore.Store = (*SqliteBackend)(nil)
|
|
||||||
|
|
||||||
type SqliteBackend struct {
|
|
||||||
db *sql.DB
|
|
||||||
Path string
|
|
||||||
Prefix string
|
|
||||||
FTSAvailable bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SqliteBackend) Close() {
|
|
||||||
if s.db != nil {
|
|
||||||
s.db.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SqliteBackend) Init() error {
|
|
||||||
if s.Path == "" {
|
|
||||||
return fmt.Errorf("missing Path")
|
|
||||||
}
|
|
||||||
|
|
||||||
var err error
|
|
||||||
s.db, err = sql.Open("sqlite3", s.Path+"?_journal_mode=WAL&_sync=NORMAL&_cache_size=1000&_foreign_keys=true")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to open database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create tables and indexes
|
|
||||||
if err := s.createSchema(); err != nil {
|
|
||||||
return fmt.Errorf("failed to create schema: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SqliteBackend) tmpl(t string) string {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
err := template.Must(template.New("schema").Parse(t)).Execute(&buf, s)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SqliteBackend) createSchema() error {
|
|
||||||
// Create basic schema first
|
|
||||||
basicSchema := s.tmpl(`
|
|
||||||
CREATE TABLE IF NOT EXISTS {{.Prefix}}events (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
kind INTEGER NOT NULL,
|
|
||||||
pubkey TEXT NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
tags TEXT NOT NULL,
|
|
||||||
sig TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS {{.Prefix}}idx_events_created_at ON {{.Prefix}}events(created_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS {{.Prefix}}idx_events_kind ON {{.Prefix}}events(kind);
|
|
||||||
CREATE INDEX IF NOT EXISTS {{.Prefix}}idx_events_pubkey ON {{.Prefix}}events(pubkey);
|
|
||||||
CREATE INDEX IF NOT EXISTS {{.Prefix}}idx_events_kind_pubkey ON {{.Prefix}}events(kind, pubkey);
|
|
||||||
CREATE INDEX IF NOT EXISTS {{.Prefix}}idx_events_kind_pubkey_created_at ON {{.Prefix}}events(kind, pubkey, created_at DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS {{.Prefix}}event_tags (
|
|
||||||
event_id TEXT NOT NULL,
|
|
||||||
key TEXT NOT NULL,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
FOREIGN KEY (event_id) REFERENCES {{.Prefix}}events(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS {{.Prefix}}idx_event_tags_event_id ON {{.Prefix}}event_tags(event_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS {{.Prefix}}idx_event_tags_key ON {{.Prefix}}event_tags(key);
|
|
||||||
CREATE INDEX IF NOT EXISTS {{.Prefix}}idx_event_tags_key_value ON {{.Prefix}}event_tags(key, value);
|
|
||||||
`)
|
|
||||||
|
|
||||||
if _, err := s.db.Exec(basicSchema); err != nil {
|
|
||||||
return fmt.Errorf("failed to create schema: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to create FTS5 schema - if it fails, continue without it
|
|
||||||
ftsSchema := `
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS {{.Prefix}}events_fts USING fts5(
|
|
||||||
content,
|
|
||||||
content='{{.Prefix}}events',
|
|
||||||
content_rowid='rowid'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS {{.Prefix}}events_ai AFTER INSERT ON {{.Prefix}}events BEGIN
|
|
||||||
INSERT INTO {{.Prefix}}events_fts(rowid, content) VALUES (new.rowid, new.content);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS {{.Prefix}}events_ad AFTER DELETE ON {{.Prefix}}events BEGIN
|
|
||||||
INSERT INTO {{.Prefix}}events_fts({{.Prefix}}events_fts, rowid, content)
|
|
||||||
VALUES('delete', old.rowid, old.content);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS {{.Prefix}}events_au AFTER UPDATE ON {{.Prefix}}events BEGIN
|
|
||||||
INSERT INTO {{.Prefix}}events_fts({{.Prefix}}events_fts, rowid, content)
|
|
||||||
VALUES('delete', old.rowid, old.content);
|
|
||||||
INSERT INTO {{.Prefix}}events_fts(rowid, content)
|
|
||||||
VALUES (new.rowid, new.content);
|
|
||||||
END;
|
|
||||||
`
|
|
||||||
|
|
||||||
if _, err := s.db.Exec(ftsSchema); err != nil {
|
|
||||||
// FTS5 not available, continue without full-text search
|
|
||||||
s.FTSAvailable = false
|
|
||||||
} else {
|
|
||||||
s.FTSAvailable = true
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
-146
@@ -1,146 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"iter"
|
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
|
||||||
"github.com/Masterminds/squirrel"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *SqliteBackend) QueryEvents(filter nostr.Filter, maxLimit int) iter.Seq[nostr.Event] {
|
|
||||||
return func(yield func(nostr.Event) bool) {
|
|
||||||
if filter.LimitZero {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := maxLimit
|
|
||||||
if filter.Limit > 0 && filter.Limit < limit {
|
|
||||||
limit = filter.Limit
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := s.buildSelectQuery(filter, limit).RunWith(s.db).Query()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var evt nostr.Event
|
|
||||||
var idStr, pubkeyStr, sigStr, tagsStr string
|
|
||||||
var createdAt int64
|
|
||||||
var kind int
|
|
||||||
|
|
||||||
err := rows.Scan(&idStr, &createdAt, &kind, &pubkeyStr, &evt.Content, &tagsStr, &sigStr)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse ID
|
|
||||||
if id, err := nostr.IDFromHex(idStr); err == nil {
|
|
||||||
evt.ID = id
|
|
||||||
} else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse PubKey
|
|
||||||
if pubkey, err := nostr.PubKeyFromHex(pubkeyStr); err == nil {
|
|
||||||
evt.PubKey = pubkey
|
|
||||||
} else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse Signature
|
|
||||||
if sigBytes, err := hex.DecodeString(sigStr); err == nil && len(sigBytes) == 64 {
|
|
||||||
copy(evt.Sig[:], sigBytes)
|
|
||||||
} else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set other fields
|
|
||||||
evt.CreatedAt = nostr.Timestamp(createdAt)
|
|
||||||
evt.Kind = nostr.Kind(kind)
|
|
||||||
|
|
||||||
// Parse Tags
|
|
||||||
if err := json.Unmarshal([]byte(tagsStr), &evt.Tags); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !yield(evt) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SqliteBackend) buildSelectQuery(filter nostr.Filter, limit int) squirrel.SelectBuilder {
|
|
||||||
qb := squirrel.Select("id", "created_at", "kind", "pubkey", "content", "tags", "sig").
|
|
||||||
From(s.tmpl("{{.Prefix}}events")).
|
|
||||||
OrderBy("created_at DESC")
|
|
||||||
|
|
||||||
// Handle search with FTS (if available)
|
|
||||||
if filter.Search != "" && s.FTSAvailable {
|
|
||||||
qb = qb.Join(s.tmpl("{{.Prefix}}events_fts ON {{.Prefix}}events.rowid = {{.Prefix}}events_fts.rowid")).
|
|
||||||
Where(squirrel.Eq{"events_fts": filter.Search})
|
|
||||||
} else if filter.Search != "" {
|
|
||||||
// Fallback to LIKE search if FTS not available
|
|
||||||
qb = qb.Where(squirrel.Like{"content": "%" + filter.Search + "%"})
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(filter.IDs) > 0 {
|
|
||||||
idStrs := make([]interface{}, len(filter.IDs))
|
|
||||||
for i, id := range filter.IDs {
|
|
||||||
idStrs[i] = id.Hex()
|
|
||||||
}
|
|
||||||
qb = qb.Where(squirrel.Eq{"id": idStrs})
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(filter.Authors) > 0 {
|
|
||||||
authorStrs := make([]interface{}, len(filter.Authors))
|
|
||||||
for i, author := range filter.Authors {
|
|
||||||
authorStrs[i] = author.Hex()
|
|
||||||
}
|
|
||||||
qb = qb.Where(squirrel.Eq{"pubkey": authorStrs})
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(filter.Kinds) > 0 {
|
|
||||||
kindInts := make([]interface{}, len(filter.Kinds))
|
|
||||||
for i, kind := range filter.Kinds {
|
|
||||||
kindInts[i] = int(kind)
|
|
||||||
}
|
|
||||||
qb = qb.Where(squirrel.Eq{"kind": kindInts})
|
|
||||||
}
|
|
||||||
|
|
||||||
if filter.Since != 0 {
|
|
||||||
qb = qb.Where(squirrel.GtOrEq{"created_at": filter.Since})
|
|
||||||
}
|
|
||||||
|
|
||||||
if filter.Until != 0 {
|
|
||||||
qb = qb.Where(squirrel.LtOrEq{"created_at": filter.Until})
|
|
||||||
}
|
|
||||||
|
|
||||||
for tagKey, tagValues := range filter.Tags {
|
|
||||||
if len(tagValues) > 0 && len(tagKey) == 1 {
|
|
||||||
tagValueInterfaces := make([]interface{}, len(tagValues))
|
|
||||||
for i, tagValue := range tagValues {
|
|
||||||
tagValueInterfaces[i] = tagValue
|
|
||||||
}
|
|
||||||
|
|
||||||
subQuery := squirrel.Select("event_id").
|
|
||||||
From(s.tmpl("{{.Prefix}}event_tags")).
|
|
||||||
Where(squirrel.Eq{"key": tagKey}).
|
|
||||||
Where(squirrel.Eq{"value": tagValueInterfaces})
|
|
||||||
|
|
||||||
subQuerySql, subQueryArgs, _ := subQuery.ToSql()
|
|
||||||
qb = qb.Where("id IN ("+subQuerySql+")", subQueryArgs...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add limit
|
|
||||||
if limit > 0 {
|
|
||||||
qb = qb.Limit(uint64(limit))
|
|
||||||
}
|
|
||||||
|
|
||||||
return qb
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
|
||||||
"fiatjaf.com/nostr/eventstore"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *SqliteBackend) ReplaceEvent(evt nostr.Event) error {
|
|
||||||
filter := nostr.Filter{Kinds: []nostr.Kind{evt.Kind}, Authors: []nostr.PubKey{evt.PubKey}}
|
|
||||||
if evt.Kind.IsAddressable() {
|
|
||||||
filter.Tags = nostr.TagMap{"d": []string{evt.Tags.GetD()}}
|
|
||||||
}
|
|
||||||
|
|
||||||
shouldStore := true
|
|
||||||
for previous := range s.QueryEvents(filter, 1) {
|
|
||||||
if previous.CreatedAt <= evt.CreatedAt {
|
|
||||||
if err := s.DeleteEvent(previous.ID); err != nil {
|
|
||||||
return fmt.Errorf("failed to delete event for replacing: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
shouldStore = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if shouldStore {
|
|
||||||
if err := s.SaveEvent(evt); err != nil && err != eventstore.ErrDupEvent {
|
|
||||||
return fmt.Errorf("failed to save: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
|
||||||
"fiatjaf.com/nostr/eventstore"
|
|
||||||
"github.com/Masterminds/squirrel"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *SqliteBackend) SaveEvent(evt nostr.Event) error {
|
|
||||||
// Check if event already exists
|
|
||||||
var existingID string
|
|
||||||
qb := squirrel.Select("id").From(s.tmpl("{{.Prefix}}events")).Where(squirrel.Eq{"id": evt.ID.Hex()})
|
|
||||||
err := qb.RunWith(s.db).QueryRow().Scan(&existingID)
|
|
||||||
if err == nil {
|
|
||||||
// Event already exists
|
|
||||||
return eventstore.ErrDupEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serialize tags to JSON
|
|
||||||
tagsJSON, err := json.Marshal(evt.Tags)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to marshal tags: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert the event
|
|
||||||
insertQb := squirrel.Insert(s.tmpl("{{.Prefix}}events")).
|
|
||||||
Columns("id", "created_at", "kind", "pubkey", "content", "tags", "sig").
|
|
||||||
Values(
|
|
||||||
evt.ID.Hex(),
|
|
||||||
int64(evt.CreatedAt),
|
|
||||||
int(evt.Kind),
|
|
||||||
evt.PubKey.Hex(),
|
|
||||||
evt.Content,
|
|
||||||
string(tagsJSON),
|
|
||||||
hex.EncodeToString(evt.Sig[:]),
|
|
||||||
)
|
|
||||||
|
|
||||||
_, err = insertQb.RunWith(s.db).Exec()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to save event '%s': %w", evt.ID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert single-letter tags into event_tags table
|
|
||||||
for _, tag := range evt.Tags {
|
|
||||||
if len(tag) >= 2 && len(tag[0]) == 1 {
|
|
||||||
tagQb := squirrel.Insert(s.tmpl("{{.Prefix}}event_tags")).
|
|
||||||
Columns("event_id", "key", "value").
|
|
||||||
Values(evt.ID.Hex(), tag[0], tag[1])
|
|
||||||
|
|
||||||
_, err := tagQb.RunWith(s.db).Exec()
|
|
||||||
if err != nil {
|
|
||||||
// Log error but don't fail the entire save operation
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSqliteFlow(t *testing.T) {
|
|
||||||
os.RemoveAll("/tmp/sqlitetest.db")
|
|
||||||
|
|
||||||
sb := &SqliteBackend{
|
|
||||||
Path: "/tmp/sqlitetest.db",
|
|
||||||
Prefix: "prefix",
|
|
||||||
}
|
|
||||||
err := sb.Init()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
defer sb.Close()
|
|
||||||
|
|
||||||
willDelete := make([]nostr.Event, 0, 3)
|
|
||||||
|
|
||||||
sk := nostr.MustSecretKeyFromHex("0000000000000000000000000000000000000000000000000000000000000001")
|
|
||||||
|
|
||||||
for i, content := range []string{
|
|
||||||
"good morning mr paper maker",
|
|
||||||
"good night",
|
|
||||||
"I'll see you again in the paper house",
|
|
||||||
"tonight we dine in my house",
|
|
||||||
"the paper in this house if very good, mr",
|
|
||||||
} {
|
|
||||||
evt := nostr.Event{
|
|
||||||
Content: content,
|
|
||||||
Tags: nostr.Tags{},
|
|
||||||
Kind: 1,
|
|
||||||
CreatedAt: nostr.Now(),
|
|
||||||
PubKey: sk.Public(),
|
|
||||||
}
|
|
||||||
evt.ID = evt.GetID()
|
|
||||||
// For testing, we'll skip actual signing and just set a dummy signature
|
|
||||||
// In real usage, you'd call evt.Sign(sk)
|
|
||||||
|
|
||||||
err := sb.SaveEvent(evt)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
if i%2 == 0 {
|
|
||||||
willDelete = append(willDelete, evt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test search functionality (if FTS5 is available)
|
|
||||||
if sb.FTSAvailable {
|
|
||||||
n := 0
|
|
||||||
for range sb.QueryEvents(nostr.Filter{Search: "good"}, 400) {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
assert.Equal(t, 3, n)
|
|
||||||
} else {
|
|
||||||
// With LIKE fallback, should still work but might be less precise
|
|
||||||
n := 0
|
|
||||||
for range sb.QueryEvents(nostr.Filter{Search: "good"}, 400) {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
assert.Equal(t, 3, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete some events
|
|
||||||
for _, evt := range willDelete {
|
|
||||||
err := sb.DeleteEvent(evt.ID)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test search after deletion
|
|
||||||
if sb.FTSAvailable {
|
|
||||||
n := 0
|
|
||||||
for res := range sb.QueryEvents(nostr.Filter{Search: "good"}, 400) {
|
|
||||||
n++
|
|
||||||
assert.Equal(t, res.Content, "good night")
|
|
||||||
assert.Equal(t, sk.Public(), res.PubKey)
|
|
||||||
}
|
|
||||||
assert.Equal(t, 1, n)
|
|
||||||
} else {
|
|
||||||
// With LIKE fallback, should still work
|
|
||||||
n := 0
|
|
||||||
for res := range sb.QueryEvents(nostr.Filter{Search: "good"}, 400) {
|
|
||||||
n++
|
|
||||||
assert.Equal(t, res.Content, "good night")
|
|
||||||
assert.Equal(t, sk.Public(), res.PubKey)
|
|
||||||
}
|
|
||||||
assert.Equal(t, 1, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test query by kind
|
|
||||||
{
|
|
||||||
n := 0
|
|
||||||
for range sb.QueryEvents(nostr.Filter{Kinds: []nostr.Kind{1}}, 400) {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
assert.Equal(t, 2, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test query by author
|
|
||||||
{
|
|
||||||
n := 0
|
|
||||||
for range sb.QueryEvents(nostr.Filter{Authors: []nostr.PubKey{sk.Public()}}, 400) {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
assert.Equal(t, 2, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+10
-5
@@ -9,24 +9,29 @@ import (
|
|||||||
|
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
"fiatjaf.com/nostr/khatru/blossom"
|
"fiatjaf.com/nostr/khatru/blossom"
|
||||||
|
"github.com/gosimple/slug"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"zooid/sqlite"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func EnableBlossom(instance *Instance) {
|
func EnableBlossom(instance *Instance) {
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
|
|
||||||
if err := fs.MkdirAll(instance.Config.Blossom.Directory, 0755); err != nil {
|
if err := fs.MkdirAll(Env("DATA"), 0755); err != nil {
|
||||||
log.Fatal("🚫 error creating blossom path:", err)
|
log.Fatal("🚫 error creating blossom path:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
store := &sqlite.SqliteBackend{
|
store := &EventStore{
|
||||||
Path: instance.Config.Data.Blossom,
|
Schema: &Schema{
|
||||||
|
Name: slug.Make(instance.Host) + "_blossom__",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
backend := blossom.New(instance.Relay, "https://"+instance.Host)
|
backend := blossom.New(instance.Relay, "https://"+instance.Host)
|
||||||
|
|
||||||
backend.Store = blossom.EventStoreBlobIndexWrapper{Store: store, ServiceURL: "https://" + instance.Host}
|
backend.Store = blossom.EventStoreBlobIndexWrapper{
|
||||||
|
Store: store,
|
||||||
|
ServiceURL: "https://" + instance.Host,
|
||||||
|
}
|
||||||
|
|
||||||
backend.StoreBlob = func(ctx context.Context, sha256 string, ext string, body []byte) error {
|
backend.StoreBlob = func(ctx context.Context, sha256 string, ext string, body []byte) error {
|
||||||
file, err := fs.Create(instance.Config.Blossom.Directory + "/" + sha256)
|
file, err := fs.Create(instance.Config.Blossom.Directory + "/" + sha256)
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package zooid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
db *sql.DB
|
||||||
|
dbOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetDb() *sql.DB {
|
||||||
|
dbOnce.Do(func() {
|
||||||
|
newDb, err := sql.Open("sqlite3", Env("DATA")+"/db?_journal_mode=WAL&_sync=NORMAL&_cache_size=1000&_foreign_keys=true")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Failed to open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db = newDb
|
||||||
|
})
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
+319
@@ -0,0 +1,319 @@
|
|||||||
|
package zooid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"iter"
|
||||||
|
|
||||||
|
"fiatjaf.com/nostr"
|
||||||
|
"fiatjaf.com/nostr/eventstore"
|
||||||
|
"github.com/Masterminds/squirrel"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EventStore struct {
|
||||||
|
Schema *Schema
|
||||||
|
FTSAvailable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ eventstore.Store = (*EventStore)(nil)
|
||||||
|
|
||||||
|
func (events *EventStore) Init() error {
|
||||||
|
// Create basic schema first
|
||||||
|
basicSchema := events.Schema.Render(`
|
||||||
|
CREATE TABLE IF NOT EXISTS {{.Prefix}}__events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
kind INTEGER NOT NULL,
|
||||||
|
pubkey TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
tags TEXT NOT NULL,
|
||||||
|
sig TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS {{.Prefix}}__idx_events_created_at ON {{.Prefix}}__events(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS {{.Prefix}}__idx_events_kind ON {{.Prefix}}__events(kind);
|
||||||
|
CREATE INDEX IF NOT EXISTS {{.Prefix}}__idx_events_pubkey ON {{.Prefix}}__events(pubkey);
|
||||||
|
CREATE INDEX IF NOT EXISTS {{.Prefix}}__idx_events_kind_pubkey ON {{.Prefix}}__events(kind, pubkey);
|
||||||
|
CREATE INDEX IF NOT EXISTS {{.Prefix}}__idx_events_kind_pubkey_created_at ON {{.Prefix}}__events(kind, pubkey, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS {{.Prefix}}__event_tags (
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (event_id) REFERENCES {{.Prefix}}__events(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS {{.Prefix}}__idx_event_tags_event_id ON {{.Prefix}}__event_tags(event_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS {{.Prefix}}__idx_event_tags_key ON {{.Prefix}}__event_tags(key);
|
||||||
|
CREATE INDEX IF NOT EXISTS {{.Prefix}}__idx_event_tags_key_value ON {{.Prefix}}__event_tags(key, value);
|
||||||
|
`)
|
||||||
|
|
||||||
|
if _, err := GetDb().Exec(basicSchema); err != nil {
|
||||||
|
return fmt.Errorf("failed to create schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to create FTS5 schema - if it fails, continue without it
|
||||||
|
ftsSchema := `
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS {{.Prefix}}__events_fts USING fts5(
|
||||||
|
content,
|
||||||
|
content='{{.Prefix}}__events',
|
||||||
|
content_rowid='rowid'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS {{.Prefix}}__events_ai AFTER INSERT ON {{.Prefix}}__events BEGIN
|
||||||
|
INSERT INTO {{.Prefix}}__events_fts(rowid, content) VALUES (new.rowid, new.content);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS {{.Prefix}}__events_ad AFTER DELETE ON {{.Prefix}}__events BEGIN
|
||||||
|
INSERT INTO {{.Prefix}}__events_fts({{.Prefix}}__events_fts, rowid, content)
|
||||||
|
VALUES('delete', old.rowid, old.content);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS {{.Prefix}}__events_au AFTER UPDATE ON {{.Prefix}}__events BEGIN
|
||||||
|
INSERT INTO {{.Prefix}}__events_fts({{.Prefix}}__events_fts, rowid, content)
|
||||||
|
VALUES('delete', old.rowid, old.content);
|
||||||
|
INSERT INTO {{.Prefix}}__events_fts(rowid, content)
|
||||||
|
VALUES (new.rowid, new.content);
|
||||||
|
END;
|
||||||
|
`
|
||||||
|
|
||||||
|
if _, err := GetDb().Exec(ftsSchema); err != nil {
|
||||||
|
// FTS5 not available, continue without full-text search
|
||||||
|
events.FTSAvailable = false
|
||||||
|
} else {
|
||||||
|
events.FTSAvailable = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (events *EventStore) Close() {
|
||||||
|
// Never close the database, since it's a shared resource
|
||||||
|
}
|
||||||
|
|
||||||
|
func (events *EventStore) QueryEvents(filter nostr.Filter, maxLimit int) iter.Seq[nostr.Event] {
|
||||||
|
return func(yield func(nostr.Event) bool) {
|
||||||
|
if filter.LimitZero {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := maxLimit
|
||||||
|
if filter.Limit > 0 && filter.Limit < limit {
|
||||||
|
limit = filter.Limit
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := events.buildSelectQuery(filter, limit).RunWith(GetDb()).Query()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var evt nostr.Event
|
||||||
|
var idStr, pubkeyStr, sigStr, tagsStr string
|
||||||
|
var createdAt int64
|
||||||
|
var kind int
|
||||||
|
|
||||||
|
err := rows.Scan(&idStr, &createdAt, &kind, &pubkeyStr, &evt.Content, &tagsStr, &sigStr)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse ID
|
||||||
|
if id, err := nostr.IDFromHex(idStr); err == nil {
|
||||||
|
evt.ID = id
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse PubKey
|
||||||
|
if pubkey, err := nostr.PubKeyFromHex(pubkeyStr); err == nil {
|
||||||
|
evt.PubKey = pubkey
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse Signature
|
||||||
|
if sigBytes, err := hex.DecodeString(sigStr); err == nil && len(sigBytes) == 64 {
|
||||||
|
copy(evt.Sig[:], sigBytes)
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set other fields
|
||||||
|
evt.CreatedAt = nostr.Timestamp(createdAt)
|
||||||
|
evt.Kind = nostr.Kind(kind)
|
||||||
|
|
||||||
|
// Parse Tags
|
||||||
|
if err := json.Unmarshal([]byte(tagsStr), &evt.Tags); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !yield(evt) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (events *EventStore) buildSelectQuery(filter nostr.Filter, limit int) squirrel.SelectBuilder {
|
||||||
|
qb := squirrel.Select("id", "created_at", "kind", "pubkey", "content", "tags", "sig").
|
||||||
|
From(events.Schema.Prefix("events")).
|
||||||
|
OrderBy("created_at DESC")
|
||||||
|
|
||||||
|
// Handle search with FTS (if available)
|
||||||
|
if filter.Search != "" && events.FTSAvailable {
|
||||||
|
qb = qb.Join(events.Schema.Render("{{.Prefix}}__events_fts ON {{.Prefix}}__events.rowid = {{.Prefix}}__events_fts.rowid")).
|
||||||
|
Where(squirrel.Eq{"events_fts": filter.Search})
|
||||||
|
} else if filter.Search != "" {
|
||||||
|
// Fallback to LIKE search if FTS not available
|
||||||
|
qb = qb.Where(squirrel.Like{"content": "%" + filter.Search + "%"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(filter.IDs) > 0 {
|
||||||
|
idStrs := make([]interface{}, len(filter.IDs))
|
||||||
|
for i, id := range filter.IDs {
|
||||||
|
idStrs[i] = id.Hex()
|
||||||
|
}
|
||||||
|
qb = qb.Where(squirrel.Eq{"id": idStrs})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(filter.Authors) > 0 {
|
||||||
|
authorStrs := make([]interface{}, len(filter.Authors))
|
||||||
|
for i, author := range filter.Authors {
|
||||||
|
authorStrs[i] = author.Hex()
|
||||||
|
}
|
||||||
|
qb = qb.Where(squirrel.Eq{"pubkey": authorStrs})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(filter.Kinds) > 0 {
|
||||||
|
kindInts := make([]interface{}, len(filter.Kinds))
|
||||||
|
for i, kind := range filter.Kinds {
|
||||||
|
kindInts[i] = int(kind)
|
||||||
|
}
|
||||||
|
qb = qb.Where(squirrel.Eq{"kind": kindInts})
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter.Since != 0 {
|
||||||
|
qb = qb.Where(squirrel.GtOrEq{"created_at": filter.Since})
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter.Until != 0 {
|
||||||
|
qb = qb.Where(squirrel.LtOrEq{"created_at": filter.Until})
|
||||||
|
}
|
||||||
|
|
||||||
|
for tagKey, tagValues := range filter.Tags {
|
||||||
|
if len(tagValues) > 0 && len(tagKey) == 1 {
|
||||||
|
tagValueInterfaces := make([]interface{}, len(tagValues))
|
||||||
|
for i, tagValue := range tagValues {
|
||||||
|
tagValueInterfaces[i] = tagValue
|
||||||
|
}
|
||||||
|
|
||||||
|
subQuery := squirrel.Select("event_id").
|
||||||
|
From(events.Schema.Prefix("event_tags")).
|
||||||
|
Where(squirrel.Eq{"key": tagKey}).
|
||||||
|
Where(squirrel.Eq{"value": tagValueInterfaces})
|
||||||
|
|
||||||
|
subQuerySql, subQueryArgs, _ := subQuery.ToSql()
|
||||||
|
qb = qb.Where("id IN ("+subQuerySql+")", subQueryArgs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add limit
|
||||||
|
if limit > 0 {
|
||||||
|
qb = qb.Limit(uint64(limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
return qb
|
||||||
|
}
|
||||||
|
|
||||||
|
func (events *EventStore) DeleteEvent(id nostr.ID) error {
|
||||||
|
_, err := squirrel.Delete(events.Schema.Prefix("events")).Where(squirrel.Eq{"id": id.Hex()}).RunWith(GetDb()).Exec()
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (events *EventStore) SaveEvent(evt nostr.Event) error {
|
||||||
|
// Check if event already exists
|
||||||
|
var existingID string
|
||||||
|
qb := squirrel.Select("id").From(events.Schema.Prefix("events")).Where(squirrel.Eq{"id": evt.ID.Hex()})
|
||||||
|
err := qb.RunWith(GetDb()).QueryRow().Scan(&existingID)
|
||||||
|
if err == nil {
|
||||||
|
return eventstore.ErrDupEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize tags to JSON
|
||||||
|
tagsJSON, err := json.Marshal(evt.Tags)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal tags: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the event
|
||||||
|
insertQb := squirrel.Insert(events.Schema.Prefix("events")).
|
||||||
|
Columns("id", "created_at", "kind", "pubkey", "content", "tags", "sig").
|
||||||
|
Values(
|
||||||
|
evt.ID.Hex(),
|
||||||
|
int64(evt.CreatedAt),
|
||||||
|
int(evt.Kind),
|
||||||
|
evt.PubKey.Hex(),
|
||||||
|
evt.Content,
|
||||||
|
string(tagsJSON),
|
||||||
|
hex.EncodeToString(evt.Sig[:]),
|
||||||
|
)
|
||||||
|
|
||||||
|
_, err = insertQb.RunWith(GetDb()).Exec()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to save event '%s': %w", evt.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert single-letter tags into event_tags table
|
||||||
|
for _, tag := range evt.Tags {
|
||||||
|
if len(tag) >= 2 && len(tag[0]) == 1 {
|
||||||
|
tagQb := squirrel.Insert(events.Schema.Prefix("event_tags")).
|
||||||
|
Columns("event_id", "key", "value").
|
||||||
|
Values(evt.ID.Hex(), tag[0], tag[1])
|
||||||
|
|
||||||
|
_, err := tagQb.RunWith(GetDb()).Exec()
|
||||||
|
if err != nil {
|
||||||
|
// Log error but don't fail the entire save operation
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (events *EventStore) ReplaceEvent(evt nostr.Event) error {
|
||||||
|
filter := nostr.Filter{Kinds: []nostr.Kind{evt.Kind}, Authors: []nostr.PubKey{evt.PubKey}}
|
||||||
|
if evt.Kind.IsAddressable() {
|
||||||
|
filter.Tags = nostr.TagMap{"d": []string{evt.Tags.GetD()}}
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldStore := true
|
||||||
|
for previous := range events.QueryEvents(filter, 1) {
|
||||||
|
if previous.CreatedAt <= evt.CreatedAt {
|
||||||
|
if err := events.DeleteEvent(previous.ID); err != nil {
|
||||||
|
return fmt.Errorf("failed to delete event for replacing: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
shouldStore = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldStore {
|
||||||
|
if err := events.SaveEvent(evt); err != nil && err != eventstore.ErrDupEvent {
|
||||||
|
return fmt.Errorf("failed to save: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (events *EventStore) CountEvents(nostr.Filter) (uint32, error) {
|
||||||
|
return 0, errors.New("COUNT is not supported")
|
||||||
|
}
|
||||||
+4
-4
@@ -12,7 +12,6 @@ import (
|
|||||||
"fiatjaf.com/nostr/eventstore"
|
"fiatjaf.com/nostr/eventstore"
|
||||||
"fiatjaf.com/nostr/khatru"
|
"fiatjaf.com/nostr/khatru"
|
||||||
"github.com/gosimple/slug"
|
"github.com/gosimple/slug"
|
||||||
"zooid/sqlite"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Instance struct {
|
type Instance struct {
|
||||||
@@ -41,9 +40,10 @@ func MakeInstance(hostname string) (*Instance, error) {
|
|||||||
instance := &Instance{
|
instance := &Instance{
|
||||||
Host: hostname,
|
Host: hostname,
|
||||||
Config: config,
|
Config: config,
|
||||||
Events: &sqlite.SqliteBackend{
|
Events: &EventStore{
|
||||||
Path: Env("DATABASE_PATH", "./data.db"),
|
Schema: &Schema{
|
||||||
Prefix: slug.Make(hostname) + "__",
|
Name: slug.Make(hostname),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Relay: khatru.NewRelay(),
|
Relay: khatru.NewRelay(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package zooid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Schema struct {
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Schema) Render(t string) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := template.Must(template.New("schema").Parse(t)).Execute(&buf, s)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Failed to create template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Schema) Prefix(t string) string {
|
||||||
|
return s.Render("{{.Name}}__" + t)
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ func Env(k string, fallback ...string) (v string) {
|
|||||||
envOnce.Do(func() {
|
envOnce.Do(func() {
|
||||||
env = make(map[string]string)
|
env = make(map[string]string)
|
||||||
|
|
||||||
|
env["PORT"] = "3334"
|
||||||
|
env["DATA"] = "./data"
|
||||||
|
|
||||||
for _, item := range os.Environ() {
|
for _, item := range os.Environ() {
|
||||||
parts := strings.SplitN(item, "=", 2)
|
parts := strings.SplitN(item, "=", 2)
|
||||||
env[parts[0]] = parts[1]
|
env[parts[0]] = parts[1]
|
||||||
|
|||||||
Reference in New Issue
Block a user