wasmdb.go raw
1 //go:build js && wasm
2
3 // Package wasmdb provides a WebAssembly-compatible database implementation
4 // using IndexedDB as the storage backend. It replicates the Badger database's
5 // index schema for full query compatibility.
6 //
7 // This implementation uses aperturerobotics/go-indexeddb (a fork of hack-pad/go-indexeddb)
8 // which provides full IndexedDB bindings with cursor/range support and transaction retry
9 // mechanisms to handle IndexedDB's transaction expiration issues in Go WASM.
10 //
11 // Architecture:
12 // - Each index type (evt, eid, kc-, pc-, etc.) maps to an IndexedDB object store
13 // - Keys are binary-encoded using the same format as the Badger implementation
14 // - Range queries use IndexedDB cursors with KeyRange bounds
15 // - Serial numbers are managed using a dedicated "meta" object store
16 package wasmdb
17
18 import (
19 "context"
20 "encoding/binary"
21 "errors"
22 "fmt"
23 "sync"
24
25 "github.com/aperturerobotics/go-indexeddb/idb"
26 "github.com/hack-pad/safejs"
27 "next.orly.dev/pkg/lol"
28 "next.orly.dev/pkg/lol/chk"
29
30 "next.orly.dev/pkg/nostr/encoders/event"
31 "next.orly.dev/pkg/nostr/encoders/filter"
32 "next.orly.dev/pkg/database"
33 "next.orly.dev/pkg/database/indexes"
34 )
35
36 const (
37 // DatabaseName is the IndexedDB database name
38 DatabaseName = "orly-nostr-relay"
39
40 // DatabaseVersion is incremented when schema changes require migration
41 DatabaseVersion = 1
42
43 // MetaStoreName holds metadata like serial counters
44 MetaStoreName = "meta"
45
46 // EventSerialKey is the key for the event serial counter in meta store
47 EventSerialKey = "event_serial"
48
49 // PubkeySerialKey is the key for the pubkey serial counter in meta store
50 PubkeySerialKey = "pubkey_serial"
51
52 // RelayIdentityKey is the key for the relay identity secret
53 RelayIdentityKey = "relay_identity"
54 )
55
56 // Object store names matching Badger index prefixes
57 var objectStoreNames = []string{
58 MetaStoreName,
59 string(indexes.EventPrefix), // "evt" - full events
60 string(indexes.SmallEventPrefix), // "sev" - small events inline
61 string(indexes.ReplaceableEventPrefix), // "rev" - replaceable events
62 string(indexes.AddressableEventPrefix), // "aev" - addressable events
63 string(indexes.IdPrefix), // "eid" - event ID index
64 string(indexes.FullIdPubkeyPrefix), // "fpc" - full ID + pubkey + timestamp
65 string(indexes.CreatedAtPrefix), // "c--" - created_at index
66 string(indexes.KindPrefix), // "kc-" - kind index
67 string(indexes.PubkeyPrefix), // "pc-" - pubkey index
68 string(indexes.KindPubkeyPrefix), // "kpc" - kind + pubkey index
69 string(indexes.TagPrefix), // "tc-" - tag index
70 string(indexes.TagKindPrefix), // "tkc" - tag + kind index
71 string(indexes.TagPubkeyPrefix), // "tpc" - tag + pubkey index
72 string(indexes.TagKindPubkeyPrefix), // "tkp" - tag + kind + pubkey index
73 string(indexes.WordPrefix), // "wrd" - word search index
74 string(indexes.ExpirationPrefix), // "exp" - expiration index
75 string(indexes.VersionPrefix), // "ver" - schema version
76 string(indexes.PubkeySerialPrefix), // "pks" - pubkey serial index
77 string(indexes.SerialPubkeyPrefix), // "spk" - serial to pubkey
78 string(indexes.EventPubkeyGraphPrefix), // "epg" - event-pubkey graph
79 string(indexes.PubkeyEventGraphPrefix), // "peg" - pubkey-event graph
80 "markers", // metadata key-value storage
81 "subscriptions", // payment subscriptions
82 "nip43", // NIP-43 membership
83 "invites", // invite codes
84 }
85
86 // W implements the database.Database interface using IndexedDB
87 type W struct {
88 ctx context.Context
89 cancel context.CancelFunc
90
91 dataDir string // Not really used in WASM, but kept for interface compatibility
92 Logger *logger
93
94 db *idb.Database
95 dbMu sync.RWMutex
96 ready chan struct{}
97
98 // Serial counters (cached in memory, persisted to IndexedDB)
99 eventSerial uint64
100 pubkeySerial uint64
101 serialMu sync.Mutex
102 }
103
104 // Ensure W implements database.Database interface at compile time
105 var _ database.Database = (*W)(nil)
106
107 // init registers the wasmdb database factory
108 func init() {
109 database.RegisterWasmDBFactory(func(
110 ctx context.Context,
111 cancel context.CancelFunc,
112 cfg *database.DatabaseConfig,
113 ) (database.Database, error) {
114 return NewWithConfig(ctx, cancel, cfg)
115 })
116 }
117
118 // NewWithConfig creates a new IndexedDB-based database instance
119 func NewWithConfig(
120 ctx context.Context, cancel context.CancelFunc, cfg *database.DatabaseConfig,
121 ) (*W, error) {
122 w := &W{
123 ctx: ctx,
124 cancel: cancel,
125 dataDir: cfg.DataDir,
126 Logger: NewLogger(lol.GetLogLevel(cfg.LogLevel)),
127 ready: make(chan struct{}),
128 }
129
130 // Open or create the IndexedDB database
131 if err := w.openDatabase(); err != nil {
132 return nil, fmt.Errorf("failed to open IndexedDB: %w", err)
133 }
134
135 // Load serial counters from storage
136 if err := w.loadSerialCounters(); err != nil {
137 return nil, fmt.Errorf("failed to load serial counters: %w", err)
138 }
139
140 // Start warmup goroutine
141 go w.warmup()
142
143 // Setup shutdown handler
144 go func() {
145 <-w.ctx.Done()
146 w.cancel()
147 w.Close()
148 }()
149
150 return w, nil
151 }
152
153 // New creates a new IndexedDB-based database instance with default configuration
154 func New(
155 ctx context.Context, cancel context.CancelFunc, dataDir, logLevel string,
156 ) (*W, error) {
157 cfg := &database.DatabaseConfig{
158 DataDir: dataDir,
159 LogLevel: logLevel,
160 }
161 return NewWithConfig(ctx, cancel, cfg)
162 }
163
164 // openDatabase opens or creates the IndexedDB database with all required object stores
165 func (w *W) openDatabase() error {
166 w.dbMu.Lock()
167 defer w.dbMu.Unlock()
168
169 // Get the IndexedDB factory (panics if not available)
170 factory := idb.Global()
171
172 // Open the database with upgrade handler
173 openReq, err := factory.Open(w.ctx, DatabaseName, DatabaseVersion, func(db *idb.Database, oldVersion, newVersion uint) error {
174 // This is called when the database needs to be created or upgraded
175 w.Logger.Infof("IndexedDB upgrade: version %d -> %d", oldVersion, newVersion)
176
177 // Create all object stores
178 for _, storeName := range objectStoreNames {
179 // Check if store already exists
180 if !w.hasObjectStore(db, storeName) {
181 // Create object store without auto-increment (we manage keys manually)
182 opts := idb.ObjectStoreOptions{}
183 if _, err := db.CreateObjectStore(storeName, opts); err != nil {
184 return fmt.Errorf("failed to create object store %s: %w", storeName, err)
185 }
186 w.Logger.Debugf("created object store: %s", storeName)
187 }
188 }
189 return nil
190 })
191 if err != nil {
192 return fmt.Errorf("failed to open IndexedDB: %w", err)
193 }
194
195 db, err := openReq.Await(w.ctx)
196 if err != nil {
197 return fmt.Errorf("failed to await IndexedDB open: %w", err)
198 }
199
200 w.db = db
201 return nil
202 }
203
204 // hasObjectStore checks if an object store exists in the database
205 func (w *W) hasObjectStore(db *idb.Database, name string) bool {
206 names, err := db.ObjectStoreNames()
207 if err != nil {
208 return false
209 }
210 for _, n := range names {
211 if n == name {
212 return true
213 }
214 }
215 return false
216 }
217
218 // loadSerialCounters loads the event and pubkey serial counters from IndexedDB
219 func (w *W) loadSerialCounters() error {
220 w.serialMu.Lock()
221 defer w.serialMu.Unlock()
222
223 // Load event serial
224 eventSerialBytes, err := w.getMeta(EventSerialKey)
225 if err != nil {
226 return err
227 }
228 if eventSerialBytes != nil && len(eventSerialBytes) == 8 {
229 w.eventSerial = binary.BigEndian.Uint64(eventSerialBytes)
230 }
231
232 // Load pubkey serial
233 pubkeySerialBytes, err := w.getMeta(PubkeySerialKey)
234 if err != nil {
235 return err
236 }
237 if pubkeySerialBytes != nil && len(pubkeySerialBytes) == 8 {
238 w.pubkeySerial = binary.BigEndian.Uint64(pubkeySerialBytes)
239 }
240
241 w.Logger.Infof("loaded serials: event=%d, pubkey=%d", w.eventSerial, w.pubkeySerial)
242 return nil
243 }
244
245 // getMeta retrieves a value from the meta object store
246 func (w *W) getMeta(key string) ([]byte, error) {
247 tx, err := w.db.Transaction(idb.TransactionReadOnly, MetaStoreName)
248 if err != nil {
249 return nil, err
250 }
251
252 store, err := tx.ObjectStore(MetaStoreName)
253 if err != nil {
254 return nil, err
255 }
256
257 keyVal, err := safejs.ValueOf(key)
258 if err != nil {
259 return nil, err
260 }
261
262 req, err := store.Get(keyVal)
263 if err != nil {
264 return nil, err
265 }
266
267 val, err := req.Await(w.ctx)
268 if err != nil {
269 // Key not found is not an error
270 return nil, nil
271 }
272
273 if val.IsUndefined() || val.IsNull() {
274 return nil, nil
275 }
276
277 // Convert safejs.Value to []byte
278 return safeValueToBytes(val), nil
279 }
280
281 // setMeta stores a value in the meta object store
282 func (w *W) setMeta(key string, value []byte) error {
283 tx, err := w.db.Transaction(idb.TransactionReadWrite, MetaStoreName)
284 if err != nil {
285 return err
286 }
287
288 store, err := tx.ObjectStore(MetaStoreName)
289 if err != nil {
290 return err
291 }
292
293 // Convert value to Uint8Array for IndexedDB storage
294 valueJS := bytesToSafeValue(value)
295
296 // Put with key - using PutKey since we're managing keys
297 keyVal, err := safejs.ValueOf(key)
298 if err != nil {
299 return err
300 }
301
302 _, err = store.PutKey(keyVal, valueJS)
303 if err != nil {
304 return err
305 }
306
307 return tx.Await(w.ctx)
308 }
309
310 // nextEventSerial returns the next event serial number and persists it
311 func (w *W) nextEventSerial() (uint64, error) {
312 w.serialMu.Lock()
313 defer w.serialMu.Unlock()
314
315 w.eventSerial++
316 serial := w.eventSerial
317
318 // Persist to IndexedDB
319 buf := make([]byte, 8)
320 binary.BigEndian.PutUint64(buf, serial)
321 if err := w.setMeta(EventSerialKey, buf); err != nil {
322 return 0, err
323 }
324
325 return serial, nil
326 }
327
328 // nextPubkeySerial returns the next pubkey serial number and persists it
329 func (w *W) nextPubkeySerial() (uint64, error) {
330 w.serialMu.Lock()
331 defer w.serialMu.Unlock()
332
333 w.pubkeySerial++
334 serial := w.pubkeySerial
335
336 // Persist to IndexedDB
337 buf := make([]byte, 8)
338 binary.BigEndian.PutUint64(buf, serial)
339 if err := w.setMeta(PubkeySerialKey, buf); err != nil {
340 return 0, err
341 }
342
343 return serial, nil
344 }
345
346 // warmup performs database warmup and closes the ready channel when complete
347 func (w *W) warmup() {
348 defer close(w.ready)
349 // IndexedDB is ready immediately after opening
350 w.Logger.Infof("IndexedDB database warmup complete, ready to serve requests")
351 }
352
353 // Path returns the database path (not used in WASM)
354 func (w *W) Path() string { return w.dataDir }
355
356 // Init initializes the database (no-op, done in New)
357 func (w *W) Init(path string) error { return nil }
358
359 // Sync flushes pending writes (IndexedDB handles persistence automatically)
360 func (w *W) Sync() error { return nil }
361
362 // Close closes the database
363 func (w *W) Close() error {
364 w.dbMu.Lock()
365 defer w.dbMu.Unlock()
366
367 if w.db != nil {
368 w.db.Close()
369 w.db = nil
370 }
371 return nil
372 }
373
374 // Wipe removes all data and recreates object stores
375 func (w *W) Wipe() error {
376 w.dbMu.Lock()
377 defer w.dbMu.Unlock()
378
379 // Close the current database
380 if w.db != nil {
381 w.db.Close()
382 w.db = nil
383 }
384
385 // Delete the database
386 factory := idb.Global()
387 delReq, err := factory.DeleteDatabase(DatabaseName)
388 if err != nil {
389 return fmt.Errorf("failed to delete IndexedDB: %w", err)
390 }
391 if err := delReq.Await(w.ctx); err != nil {
392 return fmt.Errorf("failed to await IndexedDB delete: %w", err)
393 }
394
395 // Reset serial counters
396 w.serialMu.Lock()
397 w.eventSerial = 0
398 w.pubkeySerial = 0
399 w.serialMu.Unlock()
400
401 // Reopen the database (this will recreate all object stores)
402 w.dbMu.Unlock()
403 err = w.openDatabase()
404 w.dbMu.Lock()
405
406 return err
407 }
408
409 // SetLogLevel sets the logging level
410 func (w *W) SetLogLevel(level string) {
411 w.Logger.SetLogLevel(lol.GetLogLevel(level))
412 }
413
414 // Ready returns a channel that closes when the database is ready
415 func (w *W) Ready() <-chan struct{} { return w.ready }
416
417 // RunMigrations runs database migrations (handled by IndexedDB upgrade)
418 func (w *W) RunMigrations() {}
419
420 // EventIdsBySerial retrieves event IDs by serial range
421 func (w *W) EventIdsBySerial(start uint64, count int) ([]uint64, error) {
422 return nil, errors.New("not implemented")
423 }
424
425 // Query cache methods (simplified for WASM - no caching)
426 func (w *W) GetCachedJSON(f *filter.F) ([][]byte, bool) { return nil, false }
427 func (w *W) CacheMarshaledJSON(f *filter.F, marshaledJSON [][]byte) {}
428 func (w *W) GetCachedEvents(f *filter.F) (event.S, bool) { return nil, false }
429 func (w *W) CacheEvents(f *filter.F, events event.S) {}
430 func (w *W) InvalidateQueryCache() {}
431
432 // Placeholder implementations for remaining interface methods
433 // Query methods are implemented in query-events.go
434 // Delete methods are implemented in delete-event.go
435
436 // Import, Export, and ImportEvents methods are implemented in import-export.go
437
438 func (w *W) GetRelayIdentitySecret() (skb []byte, err error) {
439 return w.getMeta(RelayIdentityKey)
440 }
441
442 func (w *W) SetRelayIdentitySecret(skb []byte) error {
443 return w.setMeta(RelayIdentityKey, skb)
444 }
445
446 func (w *W) GetOrCreateRelayIdentitySecret() (skb []byte, err error) {
447 skb, err = w.GetRelayIdentitySecret()
448 if err != nil {
449 return nil, err
450 }
451 if skb != nil {
452 return skb, nil
453 }
454 // Generate new secret key (32 random bytes)
455 // In WASM, we use crypto.getRandomValues
456 skb = make([]byte, 32)
457 if err := cryptoRandom(skb); err != nil {
458 return nil, err
459 }
460 if err := w.SetRelayIdentitySecret(skb); err != nil {
461 return nil, err
462 }
463 return skb, nil
464 }
465
466 func (w *W) SetMarker(key string, value []byte) error {
467 return w.setStoreValue("markers", key, value)
468 }
469
470 func (w *W) GetMarker(key string) (value []byte, err error) {
471 return w.getStoreValue("markers", key)
472 }
473
474 func (w *W) HasMarker(key string) bool {
475 val, err := w.GetMarker(key)
476 return err == nil && val != nil
477 }
478
479 func (w *W) DeleteMarker(key string) error {
480 return w.deleteStoreValue("markers", key)
481 }
482
483 // Subscription methods are implemented in subscriptions.go
484 // NIP-43 methods are implemented in nip43.go
485
486 // Helper methods for object store operations
487
488 func (w *W) setStoreValue(storeName, key string, value []byte) error {
489 tx, err := w.db.Transaction(idb.TransactionReadWrite, storeName)
490 if err != nil {
491 return err
492 }
493
494 store, err := tx.ObjectStore(storeName)
495 if err != nil {
496 return err
497 }
498
499 keyVal, err := safejs.ValueOf(key)
500 if err != nil {
501 return err
502 }
503
504 valueJS := bytesToSafeValue(value)
505
506 _, err = store.PutKey(keyVal, valueJS)
507 if err != nil {
508 return err
509 }
510
511 return tx.Await(w.ctx)
512 }
513
514 func (w *W) getStoreValue(storeName, key string) ([]byte, error) {
515 tx, err := w.db.Transaction(idb.TransactionReadOnly, storeName)
516 if err != nil {
517 return nil, err
518 }
519
520 store, err := tx.ObjectStore(storeName)
521 if err != nil {
522 return nil, err
523 }
524
525 keyVal, err := safejs.ValueOf(key)
526 if err != nil {
527 return nil, err
528 }
529
530 req, err := store.Get(keyVal)
531 if err != nil {
532 return nil, err
533 }
534
535 val, err := req.Await(w.ctx)
536 if err != nil {
537 return nil, nil
538 }
539
540 if val.IsUndefined() || val.IsNull() {
541 return nil, nil
542 }
543
544 return safeValueToBytes(val), nil
545 }
546
547 func (w *W) deleteStoreValue(storeName, key string) error {
548 tx, err := w.db.Transaction(idb.TransactionReadWrite, storeName)
549 if err != nil {
550 return err
551 }
552
553 store, err := tx.ObjectStore(storeName)
554 if err != nil {
555 return err
556 }
557
558 keyVal, err := safejs.ValueOf(key)
559 if err != nil {
560 return err
561 }
562
563 _, err = store.Delete(keyVal)
564 if err != nil {
565 return err
566 }
567
568 return tx.Await(w.ctx)
569 }
570
571 // Placeholder for unused variable
572 var _ = chk.E
573