Add sqlite backend

This commit is contained in:
Jon Staab
2025-09-24 10:32:10 -07:00
parent 9eedeceb6a
commit 91f23cddc9
12 changed files with 556 additions and 13 deletions
+10
View File
@@ -0,0 +1,10 @@
package sqlite
import (
"errors"
"fiatjaf.com/nostr"
)
func (s *SqliteBackend) CountEvents(nostr.Filter) (uint32, error) {
return 0, errors.New("not supported")
}
+13
View File
@@ -0,0 +1,13 @@
package sqlite
import (
"fiatjaf.com/nostr"
)
func (s *SqliteBackend) DeleteEvent(id nostr.ID) error {
s.Lock()
defer s.Unlock()
_, err := s.db.Exec("DELETE FROM events WHERE id = ?", id.Hex())
return err
}
+8
View File
@@ -0,0 +1,8 @@
package sqlite
// Helper functions and constants for the SQLite eventstore
const (
// Database configuration
defaultTimeout = 30 // seconds
)
+113
View File
@@ -0,0 +1,113 @@
package sqlite
import (
"database/sql"
"fmt"
"sync"
"fiatjaf.com/nostr/eventstore"
_ "github.com/mattn/go-sqlite3"
)
var _ eventstore.Store = (*SqliteBackend)(nil)
type SqliteBackend struct {
sync.RWMutex
// Path is where the database will be saved
Path string
db *sql.DB
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) createSchema() error {
// Create basic schema first
basicSchema := `
CREATE TABLE IF NOT EXISTS 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 idx_events_created_at ON events(created_at);
CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);
CREATE INDEX IF NOT EXISTS idx_events_pubkey ON events(pubkey);
CREATE INDEX IF NOT EXISTS idx_events_kind_pubkey ON events(kind, pubkey);
CREATE INDEX IF NOT EXISTS idx_events_kind_pubkey_created_at ON events(kind, pubkey, created_at DESC);
CREATE TABLE IF NOT EXISTS event_tags (
event_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_event_tags_event_id ON event_tags(event_id);
CREATE INDEX IF NOT EXISTS idx_event_tags_key ON event_tags(key);
CREATE INDEX IF NOT EXISTS idx_event_tags_key_value ON 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 events_fts USING fts5(
content,
content='events',
content_rowid='rowid'
);
CREATE TRIGGER IF NOT EXISTS events_ai AFTER INSERT ON events BEGIN
INSERT INTO events_fts(rowid, content) VALUES (new.rowid, new.content);
END;
CREATE TRIGGER IF NOT EXISTS events_ad AFTER DELETE ON events BEGIN
INSERT INTO events_fts(events_fts, rowid, content) VALUES('delete', old.rowid, old.content);
END;
CREATE TRIGGER IF NOT EXISTS events_au AFTER UPDATE ON events BEGIN
INSERT INTO events_fts(events_fts, rowid, content) VALUES('delete', old.rowid, old.content);
INSERT INTO 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
}
+171
View File
@@ -0,0 +1,171 @@
package sqlite
import (
"encoding/hex"
"encoding/json"
"fmt"
"iter"
"strings"
"fiatjaf.com/nostr"
)
func (s *SqliteBackend) QueryEvents(filter nostr.Filter, maxLimit int) iter.Seq[nostr.Event] {
return func(yield func(nostr.Event) bool) {
s.RLock()
defer s.RUnlock()
if filter.LimitZero {
return
}
limit := maxLimit
if filter.Limit > 0 && filter.Limit < limit {
limit = filter.Limit
}
query, args := s.buildSelectQuery(filter, limit)
rows, err := s.db.Query(query, args...)
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) (string, []interface{}) {
var conditions []string
var args []interface{}
var joins []string
baseQuery := "SELECT id, created_at, kind, pubkey, content, tags, sig FROM events"
// Handle search with FTS (if available)
if filter.Search != "" && s.FTSAvailable {
joins = append(joins, "JOIN events_fts ON events.rowid = events_fts.rowid")
conditions = append(conditions, "events_fts MATCH ?")
args = append(args, filter.Search)
} else if filter.Search != "" {
// Fallback to LIKE search if FTS not available
conditions = append(conditions, "content LIKE ?")
args = append(args, "%"+filter.Search+"%")
}
// Add WHERE clause conditions
if len(filter.IDs) > 0 {
placeholders := make([]string, len(filter.IDs))
for i, id := range filter.IDs {
placeholders[i] = "?"
args = append(args, id.Hex())
}
conditions = append(conditions, fmt.Sprintf("id IN (%s)", strings.Join(placeholders, ",")))
}
if len(filter.Authors) > 0 {
placeholders := make([]string, len(filter.Authors))
for i, author := range filter.Authors {
placeholders[i] = "?"
args = append(args, author.Hex())
}
conditions = append(conditions, fmt.Sprintf("pubkey IN (%s)", strings.Join(placeholders, ",")))
}
if len(filter.Kinds) > 0 {
placeholders := make([]string, len(filter.Kinds))
for i, kind := range filter.Kinds {
placeholders[i] = "?"
args = append(args, int(kind))
}
conditions = append(conditions, fmt.Sprintf("kind IN (%s)", strings.Join(placeholders, ",")))
}
if filter.Since != 0 {
conditions = append(conditions, "created_at >= ?")
args = append(args, filter.Since)
}
if filter.Until != 0 {
conditions = append(conditions, "created_at <= ?")
args = append(args, filter.Until)
}
// Handle tags - only filter single-letter tags using event_tags table
for tagKey, tagValues := range filter.Tags {
if len(tagValues) > 0 && len(tagKey) == 1 {
placeholders := make([]string, len(tagValues))
for i, tagValue := range tagValues {
placeholders[i] = "?"
args = append(args, tagValue)
}
conditions = append(conditions, fmt.Sprintf("id IN (SELECT event_id FROM event_tags WHERE key = ? AND value IN (%s))", strings.Join(placeholders, ",")))
args = append(args, tagKey)
}
}
// Build the complete query
if len(joins) > 0 {
baseQuery += " " + strings.Join(joins, " ")
}
if len(conditions) > 0 {
baseQuery += " WHERE " + strings.Join(conditions, " AND ")
}
// Order by created_at DESC for most recent first
baseQuery += " ORDER BY created_at DESC"
// Add limit
if limit > 0 {
baseQuery += " LIMIT ?"
args = append(args, limit)
}
return baseQuery, args
}
+37
View File
@@ -0,0 +1,37 @@
package sqlite
import (
"fmt"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore"
)
func (s *SqliteBackend) ReplaceEvent(evt nostr.Event) error {
s.Lock()
defer s.Unlock()
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
}
+61
View File
@@ -0,0 +1,61 @@
package sqlite
import (
"encoding/hex"
"encoding/json"
"fmt"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/eventstore"
)
func (s *SqliteBackend) SaveEvent(evt nostr.Event) error {
s.Lock()
defer s.Unlock()
// Check if event already exists
var existingID string
err := s.db.QueryRow("SELECT id FROM events WHERE id = ?", evt.ID.Hex()).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
query := `INSERT INTO events (id, created_at, kind, pubkey, content, tags, sig)
VALUES (?, ?, ?, ?, ?, ?, ?)`
_, err = s.db.Exec(query,
evt.ID.Hex(),
int64(evt.CreatedAt),
int(evt.Kind),
evt.PubKey.Hex(),
evt.Content,
string(tagsJSON),
hex.EncodeToString(evt.Sig[:]),
)
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 {
_, err := s.db.Exec("INSERT INTO event_tags (event_id, key, value) VALUES (?, ?, ?)",
evt.ID.Hex(), tag[0], tag[1])
if err != nil {
// Log error but don't fail the entire save operation
continue
}
}
}
return nil
}
+110
View File
@@ -0,0 +1,110 @@
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",
}
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)
}
}