summaryrefslogtreecommitdiffstats
path: root/internal/storage/storage.go
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-02-13 17:41:13 -0800
committerbndw <ben@bdw.to>2026-02-13 17:41:13 -0800
commita6502c0888613bd0377a25e43de8ae306c4de4d7 (patch)
tree7e6b9eaaafbd97d3d0ef5007e392fa7b91e35f6c /internal/storage/storage.go
parentaf30945803d440d1f803c814f4a37a1890494f1d (diff)
feat: add SQLite storage layer with binary-first event persistence
Storage implementation: - Concrete type with constructor (consumer-side interfaces) - Event storage: protobuf + zstd-compressed canonical JSON - Schema: events, deletions, replaceable_events, auth_challenges, rate_limits - WAL mode, STRICT typing, optimized indexes - Methods: StoreEvent, GetEvent, GetEventWithCanonical, DeleteEvent Dependencies: - modernc.org/sqlite v1.45.0 (pure Go SQLite driver) - github.com/klauspost/compress v1.18.4 (zstd compression) 366 lines, 10 tests passing
Diffstat (limited to 'internal/storage/storage.go')
-rw-r--r--internal/storage/storage.go150
1 files changed, 150 insertions, 0 deletions
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
new file mode 100644
index 0000000..64fc4c6
--- /dev/null
+++ b/internal/storage/storage.go
@@ -0,0 +1,150 @@
1package storage
2
3import (
4 "context"
5 "database/sql"
6 "fmt"
7
8 _ "modernc.org/sqlite" // Pure Go SQLite driver
9)
10
11// Storage provides event persistence using SQLite.
12// Consumers should define their own interface based on their needs.
13type Storage struct {
14 db *sql.DB
15}
16
17// New creates a new Storage instance and initializes the database schema.
18// The dbPath should be a file path or ":memory:" for in-memory database.
19func New(dbPath string) (*Storage, error) {
20 db, err := sql.Open("sqlite", dbPath)
21 if err != nil {
22 return nil, fmt.Errorf("failed to open database: %w", err)
23 }
24
25 // Configure SQLite for optimal performance
26 pragmas := []string{
27 "PRAGMA journal_mode=WAL", // Write-Ahead Logging for concurrency
28 "PRAGMA synchronous=NORMAL", // Balance safety and performance
29 "PRAGMA cache_size=10000", // 10000 pages (~40MB cache)
30 "PRAGMA temp_store=MEMORY", // Temp tables in memory
31 "PRAGMA mmap_size=268435456", // 256MB memory-mapped I/O
32 "PRAGMA page_size=4096", // Standard page size
33 "PRAGMA foreign_keys=ON", // Enforce foreign key constraints
34 "PRAGMA busy_timeout=5000", // Wait up to 5s for locks
35 "PRAGMA auto_vacuum=INCREMENTAL", // Reclaim space incrementally
36 }
37
38 for _, pragma := range pragmas {
39 if _, err := db.Exec(pragma); err != nil {
40 db.Close()
41 return nil, fmt.Errorf("failed to set pragma %q: %w", pragma, err)
42 }
43 }
44
45 s := &Storage{db: db}
46
47 // Initialize schema
48 if err := s.initSchema(context.Background()); err != nil {
49 db.Close()
50 return nil, fmt.Errorf("failed to initialize schema: %w", err)
51 }
52
53 return s, nil
54}
55
56// Close closes the database connection.
57func (s *Storage) Close() error {
58 return s.db.Close()
59}
60
61// initSchema creates all necessary tables and indexes.
62func (s *Storage) initSchema(ctx context.Context) error {
63 schema := `
64 -- Main events table
65 CREATE TABLE IF NOT EXISTS events (
66 -- Primary event data
67 id TEXT PRIMARY KEY,
68 event_data BLOB NOT NULL, -- Protobuf binary
69 canonical_json BLOB NOT NULL, -- zstd compressed canonical JSON
70
71 -- Denormalized fields for efficient querying
72 pubkey TEXT NOT NULL,
73 kind INTEGER NOT NULL,
74 created_at INTEGER NOT NULL, -- Unix timestamp
75 content TEXT, -- For full-text search (optional)
76 tags TEXT, -- JSON text for tag queries (use json_* functions)
77 sig TEXT NOT NULL,
78
79 -- Metadata
80 deleted INTEGER DEFAULT 0, -- STRICT mode: use INTEGER for boolean
81 received_at INTEGER DEFAULT (unixepoch())
82 ) STRICT;
83
84 -- Critical indexes for Nostr query patterns
85 CREATE INDEX IF NOT EXISTS idx_pubkey_created
86 ON events(pubkey, created_at DESC)
87 WHERE deleted = 0;
88
89 CREATE INDEX IF NOT EXISTS idx_kind_created
90 ON events(kind, created_at DESC)
91 WHERE deleted = 0;
92
93 CREATE INDEX IF NOT EXISTS idx_created
94 ON events(created_at DESC)
95 WHERE deleted = 0;
96
97 -- For tag queries (#e, #p, etc)
98 CREATE INDEX IF NOT EXISTS idx_tags
99 ON events(tags)
100 WHERE deleted = 0;
101
102 -- Deletion events (NIP-09)
103 CREATE TABLE IF NOT EXISTS deletions (
104 event_id TEXT PRIMARY KEY, -- ID of deletion event
105 deleted_event_id TEXT NOT NULL, -- ID of event being deleted
106 pubkey TEXT NOT NULL, -- Who requested deletion
107 created_at INTEGER NOT NULL,
108 FOREIGN KEY (deleted_event_id) REFERENCES events(id)
109 ) STRICT;
110
111 CREATE INDEX IF NOT EXISTS idx_deleted_event
112 ON deletions(deleted_event_id);
113
114 -- Replaceable events tracking (NIP-16, NIP-33)
115 CREATE TABLE IF NOT EXISTS replaceable_events (
116 kind INTEGER NOT NULL,
117 pubkey TEXT NOT NULL,
118 d_tag TEXT NOT NULL DEFAULT '', -- For parameterized replaceable events (empty string for non-parameterized)
119 current_event_id TEXT NOT NULL,
120 created_at INTEGER NOT NULL,
121 PRIMARY KEY (kind, pubkey, d_tag),
122 FOREIGN KEY (current_event_id) REFERENCES events(id)
123 ) STRICT;
124
125 -- Auth challenges (NIP-42)
126 CREATE TABLE IF NOT EXISTS auth_challenges (
127 challenge TEXT PRIMARY KEY,
128 created_at INTEGER NOT NULL,
129 expires_at INTEGER NOT NULL,
130 used INTEGER DEFAULT 0 -- STRICT mode: use INTEGER for boolean
131 ) STRICT;
132
133 -- Rate limiting
134 CREATE TABLE IF NOT EXISTS rate_limits (
135 pubkey TEXT PRIMARY KEY,
136 event_count INTEGER DEFAULT 0,
137 window_start INTEGER NOT NULL,
138 last_reset INTEGER DEFAULT (unixepoch())
139 ) STRICT;
140 `
141
142 _, err := s.db.ExecContext(ctx, schema)
143 return err
144}
145
146// DB returns the underlying *sql.DB for advanced usage.
147// This allows consumers to execute custom queries if needed.
148func (s *Storage) DB() *sql.DB {
149 return s.db
150}