Browse Source

Add database IPC split mode and blob storage abstraction (v0.53.0)

Major refactoring to support running the database as a separate gRPC service:

- Add gRPC database server (cmd/orly-db/) and launcher (cmd/orly-launcher/)
- Add proto definitions for all database operations (proto/orlydb/v1/)
- Add gRPC client implementing database.Database interface
- Abstract Blossom blob storage into database interface (9 methods)
- Remove BBolt database backend (pkg/bbolt/ deleted)
- Hide Blossom tab in UI when blob storage unavailable
- Update documentation for new architecture

Database backends: badger (default), neo4j, wasmdb, grpc

Files modified:
- cmd/orly-db/: New gRPC database server
- cmd/orly-launcher/: Process supervisor for split mode
- proto/orlydb/v1/: Protocol buffer definitions
- pkg/database/grpc/: gRPC client implementation
- pkg/database/blob.go: Badger blob storage implementation
- pkg/database/interface.go: Added blob storage methods
- pkg/database/types.go: Added BlobMetadata, BlobDescriptor types
- pkg/database/factory.go: Removed BBolt, added gRPC backend
- pkg/neo4j/blob.go: Blob storage stubs
- pkg/wasmdb/blob.go: Blob storage stubs
- pkg/blossom/: Refactored to use database.Database interface
- app/config/config.go: Removed BBolt config, added gRPC settings
- CLAUDE.md: Updated documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
main v0.53.0
woikos 4 months ago
parent
commit
34ab56815e
No known key found for this signature in database
  1. 28
      CLAUDE.md
  2. 2
      app/blossom.go
  3. 45
      app/config/config.go
  4. 8
      app/handle-relayinfo.go
  5. 15
      app/main.go
  6. 2
      app/web/dist/bundle.js
  7. 2
      app/web/dist/bundle.js.map
  8. 17
      app/web/src/App.svelte
  9. 29
      cmd/benchmark/relysqlite_wrapper.go
  10. 68
      cmd/orly-db/config.go
  11. 122
      cmd/orly-db/main.go
  12. 731
      cmd/orly-db/service.go
  13. 63
      cmd/orly-launcher/config.go
  14. 109
      cmd/orly-launcher/main.go
  15. 310
      cmd/orly-launcher/supervisor.go
  16. 9
      go.mod
  17. 20
      go.sum
  18. 28
      main.go
  19. 330
      pkg/bbolt/batcher.go
  20. 325
      pkg/bbolt/bbolt.go
  21. 192
      pkg/bbolt/bloom.go
  22. 134
      pkg/bbolt/fetch-event.go
  23. 179
      pkg/bbolt/get-serial-by-id.go
  24. 250
      pkg/bbolt/graph.go
  25. 119
      pkg/bbolt/helpers.go
  26. 66
      pkg/bbolt/identity.go
  27. 306
      pkg/bbolt/import-export.go
  28. 232
      pkg/bbolt/import-minimal.go
  29. 55
      pkg/bbolt/init.go
  30. 81
      pkg/bbolt/logger.go
  31. 62
      pkg/bbolt/markers.go
  32. 287
      pkg/bbolt/query-graph.go
  33. 96
      pkg/bbolt/save-event-bulk.go
  34. 393
      pkg/bbolt/save-event.go
  35. 169
      pkg/bbolt/serial.go
  36. 233
      pkg/bbolt/stubs.go
  37. 4
      pkg/blossom/server.go
  38. 547
      pkg/blossom/storage.go
  39. 569
      pkg/database/blob.go
  40. 34
      pkg/database/factory.go
  41. 875
      pkg/database/grpc/client.go
  42. 20
      pkg/database/grpc/init.go
  43. 11
      pkg/database/interface.go
  44. 26
      pkg/database/types.go
  45. 57
      pkg/neo4j/blob.go
  46. 619
      pkg/proto/orlydb/v1/converters.go
  47. 4907
      pkg/proto/orlydb/v1/service.pb.go
  48. 2976
      pkg/proto/orlydb/v1/service_grpc.pb.go
  49. 1209
      pkg/proto/orlydb/v1/types.pb.go
  50. 3
      pkg/ratelimit/factory.go
  51. 4
      pkg/ratelimit/memory_monitor.go
  52. 2
      pkg/version/version
  53. 61
      pkg/wasmdb/blob.go
  54. 10
      proto/buf.gen.yaml
  55. 10
      proto/buf.yaml
  56. 668
      proto/orlydb/v1/service.proto
  57. 121
      proto/orlydb/v1/types.proto

28
CLAUDE.md

@ -40,7 +40,7 @@ NOSTR_SECRET_KEY=nsec1... ./nurl https://relay.example.com/api/logs/clear @@ -40,7 +40,7 @@ NOSTR_SECRET_KEY=nsec1... ./nurl https://relay.example.com/api/logs/clear
|----------|---------|-------------|
| `ORLY_PORT` | 3334 | Server port |
| `ORLY_LOG_LEVEL` | info | trace/debug/info/warn/error |
| `ORLY_DB_TYPE` | badger | badger/bbolt/neo4j/wasmdb |
| `ORLY_DB_TYPE` | badger | badger/neo4j/wasmdb/grpc |
| `ORLY_POLICY_ENABLED` | false | Enable policy system |
| `ORLY_ACL_MODE` | none | none/follows/managed |
| `ORLY_TLS_DOMAINS` | | Let's Encrypt domains |
@ -67,7 +67,6 @@ app/ @@ -67,7 +67,6 @@ app/
web/ → Svelte frontend (embedded via go:embed)
pkg/
database/ → Database interface + Badger implementation
bbolt/ → BBolt backend (HDD-optimized, B+tree)
neo4j/ → Neo4j backend with WoT extensions
wasmdb/ → WebAssembly IndexedDB backend
protocol/ → Nostr protocol (ws/, auth/, publish/)
@ -151,9 +150,9 @@ Before enabling auth-required on any deployment: @@ -151,9 +150,9 @@ Before enabling auth-required on any deployment:
| Backend | Use Case | Build |
|---------|----------|-------|
| **Badger** (default) | Single-instance, SSD, high performance | Standard |
| **BBolt** | HDD-optimized, large archives, lower memory | `ORLY_DB_TYPE=bbolt` |
| **Neo4j** | Social graph, WoT queries | `ORLY_DB_TYPE=neo4j` |
| **WasmDB** | Browser/WebAssembly | `GOOS=js GOARCH=wasm` |
| **gRPC** | Remote database (IPC split mode) | `ORLY_DB_TYPE=grpc` |
All implement `pkg/database.Database` interface.
@ -178,31 +177,15 @@ ORLY_GC_BATCH_SIZE=5000 @@ -178,31 +177,15 @@ ORLY_GC_BATCH_SIZE=5000
ORLY_MAX_STORAGE_BYTES=107374182400 # 100GB cap
```
**Option 2: Use BBolt for HDD/Low-Memory Deployments**
```bash
ORLY_DB_TYPE=bbolt
# Tune for your HDD
ORLY_BBOLT_BATCH_MAX_EVENTS=10000 # Larger batches for HDD
ORLY_BBOLT_BATCH_MAX_MB=256 # 256MB batch buffer
ORLY_BBOLT_FLUSH_TIMEOUT_SEC=60 # Longer flush interval
ORLY_BBOLT_BLOOM_SIZE_MB=32 # Larger bloom filter
ORLY_BBOLT_MMAP_SIZE_MB=16384 # 16GB mmap (scales with DB size)
```
**Migration Between Backends**
```bash
# Migrate from Badger to BBolt
./orly migrate --from badger --to bbolt
# Migrate from Badger to Neo4j
./orly migrate --from badger --to neo4j
# Migrate with custom target path
./orly migrate --from badger --to bbolt --target-path /mnt/hdd/orly-archive
./orly migrate --from badger --to neo4j --target-path /mnt/ssd/orly-neo4j
```
**BBolt vs Badger Trade-offs:**
- BBolt: Lower memory, HDD-friendly, simpler (B+tree), slower random reads
- Badger: Higher memory, SSD-optimized (LSM), faster concurrent access
## Logging (lol.mleku.dev)
```go
@ -270,7 +253,6 @@ if (isValidNsec(nsec)) { ... } @@ -270,7 +253,6 @@ if (isValidNsec(nsec)) { ... }
## Dependencies
- `github.com/dgraph-io/badger/v4` - Badger DB (LSM, SSD-optimized)
- `go.etcd.io/bbolt` - BBolt DB (B+tree, HDD-optimized)
- `github.com/neo4j/neo4j-go-driver/v5` - Neo4j
- `github.com/gorilla/websocket` - WebSocket
- `github.com/ebitengine/purego` - CGO-free C loading

2
app/blossom.go

@ -14,7 +14,7 @@ import ( @@ -14,7 +14,7 @@ import (
// initializeBlossomServer creates and configures the Blossom blob storage server
func initializeBlossomServer(
ctx context.Context, cfg *config.C, db *database.D,
ctx context.Context, cfg *config.C, db database.Database,
) (*blossom.Server, error) {
// Create blossom server configuration
blossomCfg := &blossom.Config{

45
app/config/config.go

@ -116,16 +116,13 @@ type C struct { @@ -116,16 +116,13 @@ type C struct {
NIP43InviteExpiry time.Duration `env:"ORLY_NIP43_INVITE_EXPIRY" default:"24h" usage:"how long invite codes remain valid"`
// Database configuration
DBType string `env:"ORLY_DB_TYPE" default:"badger" usage:"database backend to use: badger, bbolt, or neo4j"`
DBType string `env:"ORLY_DB_TYPE" default:"badger" usage:"database backend to use: badger, neo4j, or grpc"`
QueryCacheDisabled bool `env:"ORLY_QUERY_CACHE_DISABLED" default:"true" usage:"disable query cache to reduce memory usage (trades memory for query performance)"`
// BBolt configuration (only used when ORLY_DB_TYPE=bbolt)
BboltBatchMaxEvents int `env:"ORLY_BBOLT_BATCH_MAX_EVENTS" default:"5000" usage:"max events before flush (tuned for HDD, only used when ORLY_DB_TYPE=bbolt)"`
BboltBatchMaxMB int `env:"ORLY_BBOLT_BATCH_MAX_MB" default:"128" usage:"max batch size in MB before flush (only used when ORLY_DB_TYPE=bbolt)"`
BboltFlushTimeout int `env:"ORLY_BBOLT_FLUSH_TIMEOUT_SEC" default:"30" usage:"max seconds before flush (only used when ORLY_DB_TYPE=bbolt)"`
BboltBloomSizeMB int `env:"ORLY_BBOLT_BLOOM_SIZE_MB" default:"16" usage:"bloom filter size in MB for edge queries (only used when ORLY_DB_TYPE=bbolt)"`
BboltNoSync bool `env:"ORLY_BBOLT_NO_SYNC" default:"false" usage:"disable fsync for performance (DANGEROUS - data loss risk, only used when ORLY_DB_TYPE=bbolt)"`
BboltMmapSizeMB int `env:"ORLY_BBOLT_MMAP_SIZE_MB" default:"8192" usage:"initial mmap size in MB (only used when ORLY_DB_TYPE=bbolt)"`
// gRPC database client settings (only used when ORLY_DB_TYPE=grpc)
GRPCServerAddress string `env:"ORLY_GRPC_SERVER" usage:"address of remote gRPC database server (only used when ORLY_DB_TYPE=grpc)"`
GRPCConnectTimeout time.Duration `env:"ORLY_GRPC_CONNECT_TIMEOUT" default:"10s" usage:"gRPC connection timeout (only used when ORLY_DB_TYPE=grpc)"`
QueryCacheSizeMB int `env:"ORLY_QUERY_CACHE_SIZE_MB" default:"512" usage:"query cache size in MB (caches database query results for faster REQ responses)"`
QueryCacheMaxAge string `env:"ORLY_QUERY_CACHE_MAX_AGE" default:"5m" usage:"maximum age for cached query results (e.g., 5m, 10m, 1h)"`
@ -625,7 +622,7 @@ func PrintHelp(cfg *C, printer io.Writer) { @@ -625,7 +622,7 @@ func PrintHelp(cfg *C, printer io.Writer) {
orly - ORLY-branded assets
Default location: ~/.config/%s/branding
- migrate: migrate data between database backends
Example: %s migrate --from badger --to bbolt
Example: %s migrate --from badger --to neo4j
- serve: start ephemeral relay with RAM-based storage at /dev/shm/orlyserve
listening on 0.0.0.0:10547 with 'none' ACL mode (open relay)
useful for testing and benchmarking
@ -790,25 +787,6 @@ func (cfg *C) GetGraphConfigValues() ( @@ -790,25 +787,6 @@ func (cfg *C) GetGraphConfigValues() (
cfg.GraphRateLimitRPM
}
// GetBboltConfigValues returns the BBolt database configuration values.
// This avoids circular imports with pkg/bbolt while allowing main.go to construct
// the BBolt-specific configuration.
func (cfg *C) GetBboltConfigValues() (
batchMaxEvents int,
batchMaxBytes int64,
flushTimeoutSec int,
bloomSizeMB int,
noSync bool,
mmapSizeBytes int,
) {
return cfg.BboltBatchMaxEvents,
int64(cfg.BboltBatchMaxMB) * 1024 * 1024,
cfg.BboltFlushTimeout,
cfg.BboltBloomSizeMB,
cfg.BboltNoSync,
cfg.BboltMmapSizeMB * 1024 * 1024
}
// GetNRCConfigValues returns the NRC (Nostr Relay Connect) configuration values.
// This avoids circular imports with pkg/protocol/nrc while allowing main.go to construct
// the NRC bridge configuration.
@ -854,3 +832,14 @@ func (cfg *C) GetFollowsThrottleConfigValues() ( @@ -854,3 +832,14 @@ func (cfg *C) GetFollowsThrottleConfigValues() (
cfg.FollowsThrottlePerEvent,
cfg.FollowsThrottleMaxDelay
}
// GetGRPCConfigValues returns the gRPC database client configuration values.
// This avoids circular imports with pkg/database/grpc while allowing main.go to construct
// the gRPC client configuration.
func (cfg *C) GetGRPCConfigValues() (
serverAddress string,
connectTimeout time.Duration,
) {
return cfg.GRPCServerAddress,
cfg.GRPCConnectTimeout
}

8
app/handle-relayinfo.go

@ -30,6 +30,7 @@ type ExtendedRelayInfo struct { @@ -30,6 +30,7 @@ type ExtendedRelayInfo struct {
Addresses []string `json:"addresses,omitempty"`
GraphQuery *GraphQueryConfig `json:"graph_query,omitempty"`
Theme string `json:"theme,omitempty"`
BlossomEnabled bool `json:"blossom_enabled,omitempty"`
}
// HandleRelayInfo generates and returns a relay information document in JSON
@ -204,17 +205,20 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) { @@ -204,17 +205,20 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
}
}
// Return extended info if we have addresses, graph query support, or custom theme
// Return extended info if we have addresses, graph query support, custom theme, or blossom
theme := s.Config.Theme
if theme != "auto" && theme != "light" && theme != "dark" {
theme = "auto"
}
if len(addresses) > 0 || graphConfig != nil || theme != "auto" {
// Blossom is only available if the server is actually initialized (requires Badger backend)
blossomEnabled := s.blossomServer != nil
if len(addresses) > 0 || graphConfig != nil || theme != "auto" || blossomEnabled {
extInfo := &ExtendedRelayInfo{
T: info,
Addresses: addresses,
GraphQuery: graphConfig,
Theme: theme,
BlossomEnabled: blossomEnabled,
}
if err := json.NewEncoder(w).Encode(extInfo); chk.E(err) {
}

15
app/main.go

@ -416,11 +416,12 @@ func Run( @@ -416,11 +416,12 @@ func Run(
}
}
// Initialize Blossom blob storage server (only for Badger backend)
// MUST be done before UserInterface() which registers routes
if badgerDB, ok := db.(*database.D); ok && cfg.BlossomEnabled {
log.I.F("Badger backend detected, initializing Blossom server...")
if l.blossomServer, err = initializeBlossomServer(ctx, cfg, badgerDB); err != nil {
// Initialize Blossom blob storage server
// Now works with any database backend that implements blob storage methods.
// MUST be done before UserInterface() which registers routes.
if cfg.BlossomEnabled {
log.I.F("initializing Blossom server...")
if l.blossomServer, err = initializeBlossomServer(ctx, cfg, db); err != nil {
log.E.F("failed to initialize blossom server: %v", err)
// Continue without blossom server
} else if l.blossomServer != nil {
@ -428,10 +429,8 @@ func Run( @@ -428,10 +429,8 @@ func Run(
} else {
log.W.F("blossom server initialization returned nil without error")
}
} else if !cfg.BlossomEnabled {
log.I.F("Blossom server disabled via ORLY_BLOSSOM_ENABLED=false")
} else {
log.I.F("Non-Badger backend detected (type: %T), Blossom server not available", db)
log.I.F("Blossom server disabled via ORLY_BLOSSOM_ENABLED=false")
}
// Initialize WireGuard VPN and NIP-46 Bunker (only for Badger backend)

2
app/web/dist/bundle.js vendored

File diff suppressed because one or more lines are too long

2
app/web/dist/bundle.js.map vendored

File diff suppressed because one or more lines are too long

17
app/web/src/App.svelte

@ -131,6 +131,13 @@ @@ -131,6 +131,13 @@
// NRC (Nostr Relay Connect) state
let nrcEnabled = false;
// Blossom (blob storage) state
let blossomEnabled = true; // Default to true for backward compatibility
// Update blossomEnabled when relay info changes
$: if ($relayInfoStore && typeof $relayInfoStore.blossom_enabled === "boolean") {
blossomEnabled = $relayInfoStore.blossom_enabled;
}
// ACL mode
let aclMode = "";
@ -855,7 +862,7 @@ @@ -855,7 +862,7 @@
}
});
// Fetch relay info to get configured theme
// Fetch relay info to get configured theme and feature flags
(async () => {
try {
const relayInfo = await api.fetchRelayInfo();
@ -863,6 +870,10 @@ @@ -863,6 +870,10 @@
configuredTheme = relayInfo.theme;
isDarkTheme = relayInfo.theme === "dark";
}
// Check if blossom is enabled (default to true for backward compatibility)
if (relayInfo && typeof relayInfo.blossom_enabled === "boolean") {
blossomEnabled = relayInfo.blossom_enabled;
}
} catch (e) {
console.log("Could not fetch relay theme config:", e);
}
@ -1860,6 +1871,10 @@ @@ -1860,6 +1871,10 @@
if (tab.id === "curation" && aclMode !== "curating") {
return false;
}
// Hide blossom tab if not enabled
if (tab.id === "blossom" && !blossomEnabled) {
return false;
}
// Debug logging for tab filtering
console.log(`Tab ${tab.id} filter check:`, {
isLoggedIn,

29
cmd/benchmark/relysqlite_wrapper.go

@ -289,6 +289,35 @@ func (w *RelySQLiteWrapper) GetLeastAccessedEvents(limit int, minAgeSec int64) ( @@ -289,6 +289,35 @@ func (w *RelySQLiteWrapper) GetLeastAccessedEvents(limit int, minAgeSec int64) (
return nil, nil
}
// Blob storage stubs (not needed for benchmarking)
func (w *RelySQLiteWrapper) SaveBlob(sha256Hash []byte, data []byte, pubkey []byte, mimeType string, extension string) error {
return fmt.Errorf("not implemented")
}
func (w *RelySQLiteWrapper) GetBlob(sha256Hash []byte) (data []byte, metadata *database.BlobMetadata, err error) {
return nil, nil, fmt.Errorf("not implemented")
}
func (w *RelySQLiteWrapper) HasBlob(sha256Hash []byte) (exists bool, err error) {
return false, fmt.Errorf("not implemented")
}
func (w *RelySQLiteWrapper) DeleteBlob(sha256Hash []byte, pubkey []byte) error {
return fmt.Errorf("not implemented")
}
func (w *RelySQLiteWrapper) ListBlobs(pubkey []byte, since, until int64) ([]*database.BlobDescriptor, error) {
return nil, fmt.Errorf("not implemented")
}
func (w *RelySQLiteWrapper) GetBlobMetadata(sha256Hash []byte) (*database.BlobMetadata, error) {
return nil, fmt.Errorf("not implemented")
}
func (w *RelySQLiteWrapper) GetTotalBlobStorageUsed(pubkey []byte) (totalMB int64, err error) {
return 0, fmt.Errorf("not implemented")
}
func (w *RelySQLiteWrapper) SaveBlobReport(sha256Hash []byte, reportData []byte) error {
return fmt.Errorf("not implemented")
}
func (w *RelySQLiteWrapper) ListAllBlobUserStats() ([]*database.UserBlobStats, error) {
return nil, fmt.Errorf("not implemented")
}
// Helper function to check if a kind is replaceable
func isReplaceableKind(kind int) bool {
return (kind >= 10000 && kind < 20000) || kind == 0 || kind == 3

68
cmd/orly-db/config.go

@ -0,0 +1,68 @@ @@ -0,0 +1,68 @@
package main
import (
"os"
"path/filepath"
"time"
"go-simpler.org/env"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
)
// Config holds the database server configuration.
type Config struct {
// Listen is the gRPC server listen address
Listen string `env:"ORLY_DB_LISTEN" default:"127.0.0.1:50051" usage:"gRPC server listen address"`
// DataDir is the database data directory
DataDir string `env:"ORLY_DATA_DIR" usage:"database data directory"`
// LogLevel is the logging level
LogLevel string `env:"ORLY_DB_LOG_LEVEL" default:"info" usage:"log level (trace, debug, info, warn, error)"`
// Badger configuration
BlockCacheMB int `env:"ORLY_DB_BLOCK_CACHE_MB" default:"1024" usage:"block cache size in MB"`
IndexCacheMB int `env:"ORLY_DB_INDEX_CACHE_MB" default:"512" usage:"index cache size in MB"`
ZSTDLevel int `env:"ORLY_DB_ZSTD_LEVEL" default:"3" usage:"ZSTD compression level (1-19)"`
// Query cache configuration
QueryCacheSizeMB int `env:"ORLY_DB_QUERY_CACHE_SIZE_MB" default:"256" usage:"query cache size in MB"`
QueryCacheMaxAge time.Duration `env:"ORLY_DB_QUERY_CACHE_MAX_AGE" default:"5m" usage:"query cache max age"`
QueryCacheDisabled bool `env:"ORLY_DB_QUERY_CACHE_DISABLED" default:"false" usage:"disable query cache"`
// Serial cache configuration
SerialCachePubkeys int `env:"ORLY_SERIAL_CACHE_PUBKEYS" default:"100000" usage:"serial cache pubkeys capacity"`
SerialCacheEventIds int `env:"ORLY_SERIAL_CACHE_EVENT_IDS" default:"500000" usage:"serial cache event IDs capacity"`
// gRPC server configuration
MaxConcurrentQueries int `env:"ORLY_DB_MAX_CONCURRENT_QUERIES" default:"3" usage:"max concurrent queries"`
StreamBatchSize int `env:"ORLY_DB_STREAM_BATCH_SIZE" default:"100" usage:"events per stream batch"`
}
// loadConfig loads configuration from environment variables.
func loadConfig() *Config {
cfg := &Config{}
if err := env.Load(cfg, nil); chk.E(err) {
log.E.F("failed to load config: %v", err)
os.Exit(1)
}
// Set default data directory if not specified
if cfg.DataDir == "" {
home, err := os.UserHomeDir()
if chk.E(err) {
log.E.F("failed to get home directory: %v", err)
os.Exit(1)
}
cfg.DataDir = filepath.Join(home, ".local", "share", "ORLY")
}
// Ensure data directory exists
if err := os.MkdirAll(cfg.DataDir, 0700); chk.E(err) {
log.E.F("failed to create data directory %s: %v", cfg.DataDir, err)
os.Exit(1)
}
return cfg
}

122
cmd/orly-db/main.go

@ -0,0 +1,122 @@ @@ -0,0 +1,122 @@
// orly-db is a standalone gRPC database server for the ORLY relay.
// It wraps the Badger database implementation and exposes it via gRPC.
package main
import (
"context"
"net"
"os"
"os/signal"
"syscall"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
"lol.mleku.dev"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/database"
orlydbv1 "next.orly.dev/pkg/proto/orlydb/v1"
)
func main() {
cfg := loadConfig()
// Set log level
lol.SetLogLevel(cfg.LogLevel)
log.I.F("orly-db starting with log level: %s", cfg.LogLevel)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Create database configuration
dbCfg := &database.DatabaseConfig{
DataDir: cfg.DataDir,
LogLevel: cfg.LogLevel,
BlockCacheMB: cfg.BlockCacheMB,
IndexCacheMB: cfg.IndexCacheMB,
QueryCacheSizeMB: cfg.QueryCacheSizeMB,
QueryCacheMaxAge: cfg.QueryCacheMaxAge,
QueryCacheDisabled: cfg.QueryCacheDisabled,
SerialCachePubkeys: cfg.SerialCachePubkeys,
SerialCacheEventIds: cfg.SerialCacheEventIds,
ZSTDLevel: cfg.ZSTDLevel,
}
// Initialize database using existing Badger implementation
log.I.F("initializing database at %s", cfg.DataDir)
db, err := database.NewWithConfig(ctx, cancel, dbCfg)
if chk.E(err) {
log.E.F("failed to initialize database: %v", err)
os.Exit(1)
}
// Wait for database to be ready
log.I.F("waiting for database to be ready...")
<-db.Ready()
log.I.F("database ready")
// Create gRPC server with large message sizes for events
grpcServer := grpc.NewServer(
grpc.MaxRecvMsgSize(64<<20), // 64MB
grpc.MaxSendMsgSize(64<<20), // 64MB
)
// Register database service
service := NewDatabaseService(db, cfg)
orlydbv1.RegisterDatabaseServiceServer(grpcServer, service)
// Register reflection for debugging with grpcurl
reflection.Register(grpcServer)
// Start listening
lis, err := net.Listen("tcp", cfg.Listen)
if chk.E(err) {
log.E.F("failed to listen on %s: %v", cfg.Listen, err)
os.Exit(1)
}
log.I.F("gRPC server listening on %s", cfg.Listen)
// Handle graceful shutdown
go func() {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGTERM, syscall.SIGINT)
sig := <-sigs
log.I.F("received signal %v, shutting down...", sig)
// Cancel context to stop all operations
cancel()
// Gracefully stop gRPC server with timeout
stopped := make(chan struct{})
go func() {
grpcServer.GracefulStop()
close(stopped)
}()
select {
case <-stopped:
log.I.F("gRPC server stopped gracefully")
case <-time.After(5 * time.Second):
log.W.F("gRPC graceful stop timed out, forcing stop")
grpcServer.Stop()
}
// Sync and close database
log.I.F("syncing database...")
if err := db.Sync(); chk.E(err) {
log.W.F("failed to sync database: %v", err)
}
log.I.F("closing database...")
if err := db.Close(); chk.E(err) {
log.W.F("failed to close database: %v", err)
}
log.I.F("shutdown complete")
}()
// Serve gRPC
if err := grpcServer.Serve(lis); err != nil {
log.E.F("gRPC server error: %v", err)
}
}

731
cmd/orly-db/service.go

@ -0,0 +1,731 @@ @@ -0,0 +1,731 @@
package main
import (
"context"
"io"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/database"
orlydbv1 "next.orly.dev/pkg/proto/orlydb/v1"
)
// DatabaseService implements the orlydbv1.DatabaseServiceServer interface.
type DatabaseService struct {
orlydbv1.UnimplementedDatabaseServiceServer
db database.Database
cfg *Config
}
// NewDatabaseService creates a new database service.
func NewDatabaseService(db database.Database, cfg *Config) *DatabaseService {
return &DatabaseService{
db: db,
cfg: cfg,
}
}
// === Lifecycle Methods ===
func (s *DatabaseService) GetPath(ctx context.Context, req *orlydbv1.Empty) (*orlydbv1.PathResponse, error) {
return &orlydbv1.PathResponse{Path: s.db.Path()}, nil
}
func (s *DatabaseService) Sync(ctx context.Context, req *orlydbv1.Empty) (*orlydbv1.Empty, error) {
if err := s.db.Sync(); err != nil {
return nil, status.Errorf(codes.Internal, "sync failed: %v", err)
}
return &orlydbv1.Empty{}, nil
}
func (s *DatabaseService) Ready(ctx context.Context, req *orlydbv1.Empty) (*orlydbv1.ReadyResponse, error) {
// Check if ready channel is closed
select {
case <-s.db.Ready():
return &orlydbv1.ReadyResponse{Ready: true}, nil
default:
return &orlydbv1.ReadyResponse{Ready: false}, nil
}
}
func (s *DatabaseService) SetLogLevel(ctx context.Context, req *orlydbv1.SetLogLevelRequest) (*orlydbv1.Empty, error) {
s.db.SetLogLevel(req.Level)
return &orlydbv1.Empty{}, nil
}
// === Event Storage ===
func (s *DatabaseService) SaveEvent(ctx context.Context, req *orlydbv1.SaveEventRequest) (*orlydbv1.SaveEventResponse, error) {
ev := orlydbv1.ProtoToEvent(req.Event)
exists, err := s.db.SaveEvent(ctx, ev)
if err != nil {
return nil, status.Errorf(codes.Internal, "save event failed: %v", err)
}
return &orlydbv1.SaveEventResponse{Exists: exists}, nil
}
func (s *DatabaseService) GetSerialsFromFilter(ctx context.Context, req *orlydbv1.GetSerialsFromFilterRequest) (*orlydbv1.SerialList, error) {
f := orlydbv1.ProtoToFilter(req.Filter)
serials, err := s.db.GetSerialsFromFilter(f)
if err != nil {
return nil, status.Errorf(codes.Internal, "get serials failed: %v", err)
}
return orlydbv1.Uint40sToProto(serials), nil
}
func (s *DatabaseService) WouldReplaceEvent(ctx context.Context, req *orlydbv1.WouldReplaceEventRequest) (*orlydbv1.WouldReplaceEventResponse, error) {
ev := orlydbv1.ProtoToEvent(req.Event)
wouldReplace, replacedSerials, err := s.db.WouldReplaceEvent(ev)
if err != nil {
return nil, status.Errorf(codes.Internal, "would replace check failed: %v", err)
}
resp := &orlydbv1.WouldReplaceEventResponse{
WouldReplace: wouldReplace,
}
for _, ser := range replacedSerials {
resp.ReplacedSerials = append(resp.ReplacedSerials, ser.Get())
}
return resp, nil
}
// === Event Queries (Streaming) ===
func (s *DatabaseService) QueryEvents(req *orlydbv1.QueryEventsRequest, stream orlydbv1.DatabaseService_QueryEventsServer) error {
f := orlydbv1.ProtoToFilter(req.Filter)
events, err := s.db.QueryEvents(stream.Context(), f)
if err != nil {
return status.Errorf(codes.Internal, "query events failed: %v", err)
}
return s.streamEvents(orlydbv1.EventsToProto(events), stream)
}
func (s *DatabaseService) QueryAllVersions(req *orlydbv1.QueryEventsRequest, stream orlydbv1.DatabaseService_QueryAllVersionsServer) error {
f := orlydbv1.ProtoToFilter(req.Filter)
events, err := s.db.QueryAllVersions(stream.Context(), f)
if err != nil {
return status.Errorf(codes.Internal, "query all versions failed: %v", err)
}
return s.streamEvents(orlydbv1.EventsToProto(events), stream)
}
func (s *DatabaseService) QueryEventsWithOptions(req *orlydbv1.QueryEventsWithOptionsRequest, stream orlydbv1.DatabaseService_QueryEventsWithOptionsServer) error {
f := orlydbv1.ProtoToFilter(req.Filter)
events, err := s.db.QueryEventsWithOptions(stream.Context(), f, req.IncludeDeleteEvents, req.ShowAllVersions)
if err != nil {
return status.Errorf(codes.Internal, "query events with options failed: %v", err)
}
return s.streamEvents(orlydbv1.EventsToProto(events), stream)
}
func (s *DatabaseService) QueryDeleteEventsByTargetId(req *orlydbv1.QueryDeleteEventsByTargetIdRequest, stream orlydbv1.DatabaseService_QueryDeleteEventsByTargetIdServer) error {
events, err := s.db.QueryDeleteEventsByTargetId(stream.Context(), req.TargetEventId)
if err != nil {
return status.Errorf(codes.Internal, "query delete events failed: %v", err)
}
return s.streamEvents(orlydbv1.EventsToProto(events), stream)
}
func (s *DatabaseService) QueryForSerials(ctx context.Context, req *orlydbv1.QueryEventsRequest) (*orlydbv1.SerialList, error) {
f := orlydbv1.ProtoToFilter(req.Filter)
serials, err := s.db.QueryForSerials(ctx, f)
if err != nil {
return nil, status.Errorf(codes.Internal, "query for serials failed: %v", err)
}
return orlydbv1.Uint40sToProto(serials), nil
}
func (s *DatabaseService) QueryForIds(ctx context.Context, req *orlydbv1.QueryEventsRequest) (*orlydbv1.IdPkTsList, error) {
f := orlydbv1.ProtoToFilter(req.Filter)
idPkTs, err := s.db.QueryForIds(ctx, f)
if err != nil {
return nil, status.Errorf(codes.Internal, "query for ids failed: %v", err)
}
return orlydbv1.IdPkTsListToProto(idPkTs), nil
}
func (s *DatabaseService) CountEvents(ctx context.Context, req *orlydbv1.QueryEventsRequest) (*orlydbv1.CountEventsResponse, error) {
f := orlydbv1.ProtoToFilter(req.Filter)
count, approximate, err := s.db.CountEvents(ctx, f)
if err != nil {
return nil, status.Errorf(codes.Internal, "count events failed: %v", err)
}
return &orlydbv1.CountEventsResponse{
Count: int32(count),
Approximate: approximate,
}, nil
}
// === Event Retrieval by Serial ===
func (s *DatabaseService) FetchEventBySerial(ctx context.Context, req *orlydbv1.FetchEventBySerialRequest) (*orlydbv1.FetchEventBySerialResponse, error) {
ser := orlydbv1.ProtoToUint40(&orlydbv1.Uint40{Value: req.Serial})
ev, err := s.db.FetchEventBySerial(ser)
if err != nil {
return nil, status.Errorf(codes.Internal, "fetch event by serial failed: %v", err)
}
return &orlydbv1.FetchEventBySerialResponse{
Event: orlydbv1.EventToProto(ev),
Found: ev != nil,
}, nil
}
func (s *DatabaseService) FetchEventsBySerials(ctx context.Context, req *orlydbv1.FetchEventsBySerialRequest) (*orlydbv1.EventMap, error) {
serials := orlydbv1.ProtoToUint40s(&orlydbv1.SerialList{Serials: req.Serials})
events, err := s.db.FetchEventsBySerials(serials)
if err != nil {
return nil, status.Errorf(codes.Internal, "fetch events by serials failed: %v", err)
}
return orlydbv1.EventMapToProto(events), nil
}
func (s *DatabaseService) GetSerialById(ctx context.Context, req *orlydbv1.GetSerialByIdRequest) (*orlydbv1.GetSerialByIdResponse, error) {
ser, err := s.db.GetSerialById(req.Id)
if err != nil {
return nil, status.Errorf(codes.Internal, "get serial by id failed: %v", err)
}
if ser == nil {
return &orlydbv1.GetSerialByIdResponse{Found: false}, nil
}
return &orlydbv1.GetSerialByIdResponse{
Serial: ser.Get(),
Found: true,
}, nil
}
func (s *DatabaseService) GetSerialsByIds(ctx context.Context, req *orlydbv1.GetSerialsByIdsRequest) (*orlydbv1.SerialMap, error) {
// Convert request IDs to tag format
ids := orlydbv1.BytesToTag(req.Ids)
serials, err := s.db.GetSerialsByIds(ids)
if err != nil {
return nil, status.Errorf(codes.Internal, "get serials by ids failed: %v", err)
}
result := &orlydbv1.SerialMap{
Serials: make(map[string]uint64),
}
for k, v := range serials {
if v != nil {
result.Serials[k] = v.Get()
}
}
return result, nil
}
func (s *DatabaseService) GetSerialsByRange(ctx context.Context, req *orlydbv1.GetSerialsByRangeRequest) (*orlydbv1.SerialList, error) {
r := orlydbv1.ProtoToRange(req.Range)
serials, err := s.db.GetSerialsByRange(r)
if err != nil {
return nil, status.Errorf(codes.Internal, "get serials by range failed: %v", err)
}
return orlydbv1.Uint40sToProto(serials), nil
}
func (s *DatabaseService) GetFullIdPubkeyBySerial(ctx context.Context, req *orlydbv1.GetFullIdPubkeyBySerialRequest) (*orlydbv1.IdPkTs, error) {
ser := orlydbv1.ProtoToUint40(&orlydbv1.Uint40{Value: req.Serial})
idPkTs, err := s.db.GetFullIdPubkeyBySerial(ser)
if err != nil {
return nil, status.Errorf(codes.Internal, "get full id pubkey by serial failed: %v", err)
}
return orlydbv1.IdPkTsToProto(idPkTs), nil
}
func (s *DatabaseService) GetFullIdPubkeyBySerials(ctx context.Context, req *orlydbv1.GetFullIdPubkeyBySerialsRequest) (*orlydbv1.IdPkTsList, error) {
serials := orlydbv1.ProtoToUint40s(&orlydbv1.SerialList{Serials: req.Serials})
idPkTs, err := s.db.GetFullIdPubkeyBySerials(serials)
if err != nil {
return nil, status.Errorf(codes.Internal, "get full id pubkey by serials failed: %v", err)
}
return orlydbv1.IdPkTsListToProto(idPkTs), nil
}
// === Event Deletion ===
func (s *DatabaseService) DeleteEvent(ctx context.Context, req *orlydbv1.DeleteEventRequest) (*orlydbv1.Empty, error) {
if err := s.db.DeleteEvent(ctx, req.EventId); err != nil {
return nil, status.Errorf(codes.Internal, "delete event failed: %v", err)
}
return &orlydbv1.Empty{}, nil
}
func (s *DatabaseService) DeleteEventBySerial(ctx context.Context, req *orlydbv1.DeleteEventBySerialRequest) (*orlydbv1.Empty, error) {
ser := orlydbv1.ProtoToUint40(&orlydbv1.Uint40{Value: req.Serial})
ev := orlydbv1.ProtoToEvent(req.Event)
if err := s.db.DeleteEventBySerial(ctx, ser, ev); err != nil {
return nil, status.Errorf(codes.Internal, "delete event by serial failed: %v", err)
}
return &orlydbv1.Empty{}, nil
}
func (s *DatabaseService) DeleteExpired(ctx context.Context, req *orlydbv1.Empty) (*orlydbv1.Empty, error) {
s.db.DeleteExpired()
return &orlydbv1.Empty{}, nil
}
func (s *DatabaseService) ProcessDelete(ctx context.Context, req *orlydbv1.ProcessDeleteRequest) (*orlydbv1.Empty, error) {
ev := orlydbv1.ProtoToEvent(req.Event)
if err := s.db.ProcessDelete(ev, req.Admins); err != nil {
return nil, status.Errorf(codes.Internal, "process delete failed: %v", err)
}
return &orlydbv1.Empty{}, nil
}
func (s *DatabaseService) CheckForDeleted(ctx context.Context, req *orlydbv1.CheckForDeletedRequest) (*orlydbv1.Empty, error) {
ev := orlydbv1.ProtoToEvent(req.Event)
if err := s.db.CheckForDeleted(ev, req.Admins); err != nil {
return nil, status.Errorf(codes.Internal, "check for deleted failed: %v", err)
}
return &orlydbv1.Empty{}, nil
}
// === Import/Export ===
func (s *DatabaseService) Import(stream orlydbv1.DatabaseService_ImportServer) error {
pr, pw := io.Pipe()
// Goroutine to read from gRPC stream and write to pipe
go func() {
defer pw.Close()
for {
chunk, err := stream.Recv()
if err == io.EOF {
return
}
if err != nil {
log.E.F("import stream error: %v", err)
pw.CloseWithError(err)
return
}
if _, err := pw.Write(chunk.Data); chk.E(err) {
return
}
}
}()
// Import from pipe
s.db.Import(pr)
return stream.SendAndClose(&orlydbv1.ImportResponse{
EventsImported: 0, // TODO: Track count
EventsSkipped: 0,
})
}
func (s *DatabaseService) Export(req *orlydbv1.ExportRequest, stream orlydbv1.DatabaseService_ExportServer) error {
pr, pw := io.Pipe()
// Goroutine to export to pipe
go func() {
defer pw.Close()
s.db.Export(stream.Context(), pw, req.Pubkeys...)
}()
// Read from pipe and send to stream
buf := make([]byte, 64*1024) // 64KB chunks
for {
n, err := pr.Read(buf)
if err == io.EOF {
return nil
}
if err != nil {
return status.Errorf(codes.Internal, "export failed: %v", err)
}
if err := stream.Send(&orlydbv1.ExportChunk{Data: buf[:n]}); err != nil {
return err
}
}
}
func (s *DatabaseService) ImportEventsFromStrings(ctx context.Context, req *orlydbv1.ImportEventsFromStringsRequest) (*orlydbv1.ImportResponse, error) {
// Note: We can't pass policy manager over gRPC, so we pass nil
if err := s.db.ImportEventsFromStrings(ctx, req.EventJsons, nil); err != nil {
return nil, status.Errorf(codes.Internal, "import events from strings failed: %v", err)
}
return &orlydbv1.ImportResponse{
EventsImported: int64(len(req.EventJsons)),
}, nil
}
// === Relay Identity ===
func (s *DatabaseService) GetRelayIdentitySecret(ctx context.Context, req *orlydbv1.Empty) (*orlydbv1.GetRelayIdentitySecretResponse, error) {
secret, err := s.db.GetRelayIdentitySecret()
if err != nil {
return nil, status.Errorf(codes.Internal, "get relay identity secret failed: %v", err)
}
return &orlydbv1.GetRelayIdentitySecretResponse{SecretKey: secret}, nil
}
func (s *DatabaseService) SetRelayIdentitySecret(ctx context.Context, req *orlydbv1.SetRelayIdentitySecretRequest) (*orlydbv1.Empty, error) {
if err := s.db.SetRelayIdentitySecret(req.SecretKey); err != nil {
return nil, status.Errorf(codes.Internal, "set relay identity secret failed: %v", err)
}
return &orlydbv1.Empty{}, nil
}
func (s *DatabaseService) GetOrCreateRelayIdentitySecret(ctx context.Context, req *orlydbv1.Empty) (*orlydbv1.GetRelayIdentitySecretResponse, error) {
secret, err := s.db.GetOrCreateRelayIdentitySecret()
if err != nil {
return nil, status.Errorf(codes.Internal, "get or create relay identity secret failed: %v", err)
}
return &orlydbv1.GetRelayIdentitySecretResponse{SecretKey: secret}, nil
}
// === Markers ===
func (s *DatabaseService) SetMarker(ctx context.Context, req *orlydbv1.SetMarkerRequest) (*orlydbv1.Empty, error) {
if err := s.db.SetMarker(req.Key, req.Value); err != nil {
return nil, status.Errorf(codes.Internal, "set marker failed: %v", err)
}
return &orlydbv1.Empty{}, nil
}
func (s *DatabaseService) GetMarker(ctx context.Context, req *orlydbv1.GetMarkerRequest) (*orlydbv1.GetMarkerResponse, error) {
value, err := s.db.GetMarker(req.Key)
if err != nil {
return nil, status.Errorf(codes.Internal, "get marker failed: %v", err)
}
return &orlydbv1.GetMarkerResponse{
Value: value,
Found: value != nil,
}, nil
}
func (s *DatabaseService) HasMarker(ctx context.Context, req *orlydbv1.HasMarkerRequest) (*orlydbv1.HasMarkerResponse, error) {
exists := s.db.HasMarker(req.Key)
return &orlydbv1.HasMarkerResponse{Exists: exists}, nil
}
func (s *DatabaseService) DeleteMarker(ctx context.Context, req *orlydbv1.DeleteMarkerRequest) (*orlydbv1.Empty, error) {
if err := s.db.DeleteMarker(req.Key); err != nil {
return nil, status.Errorf(codes.Internal, "delete marker failed: %v", err)
}
return &orlydbv1.Empty{}, nil
}
// === Subscriptions ===
func (s *DatabaseService) GetSubscription(ctx context.Context, req *orlydbv1.GetSubscriptionRequest) (*orlydbv1.Subscription, error) {
sub, err := s.db.GetSubscription(req.Pubkey)
if err != nil {
return nil, status.Errorf(codes.Internal, "get subscription failed: %v", err)
}
return orlydbv1.SubscriptionToProto(sub, req.Pubkey), nil
}
func (s *DatabaseService) IsSubscriptionActive(ctx context.Context, req *orlydbv1.IsSubscriptionActiveRequest) (*orlydbv1.IsSubscriptionActiveResponse, error) {
active, err := s.db.IsSubscriptionActive(req.Pubkey)
if err != nil {
return nil, status.Errorf(codes.Internal, "is subscription active failed: %v", err)
}
return &orlydbv1.IsSubscriptionActiveResponse{Active: active}, nil
}
func (s *DatabaseService) ExtendSubscription(ctx context.Context, req *orlydbv1.ExtendSubscriptionRequest) (*orlydbv1.Empty, error) {
if err := s.db.ExtendSubscription(req.Pubkey, int(req.Days)); err != nil {
return nil, status.Errorf(codes.Internal, "extend subscription failed: %v", err)
}
return &orlydbv1.Empty{}, nil
}
func (s *DatabaseService) RecordPayment(ctx context.Context, req *orlydbv1.RecordPaymentRequest) (*orlydbv1.Empty, error) {
if err := s.db.RecordPayment(req.Pubkey, req.Amount, req.Invoice, req.Preimage); err != nil {
return nil, status.Errorf(codes.Internal, "record payment failed: %v", err)
}
return &orlydbv1.Empty{}, nil
}
func (s *DatabaseService) GetPaymentHistory(ctx context.Context, req *orlydbv1.GetPaymentHistoryRequest) (*orlydbv1.PaymentList, error) {
payments, err := s.db.GetPaymentHistory(req.Pubkey)
if err != nil {
return nil, status.Errorf(codes.Internal, "get payment history failed: %v", err)
}
return orlydbv1.PaymentListToProto(payments), nil
}
func (s *DatabaseService) ExtendBlossomSubscription(ctx context.Context, req *orlydbv1.ExtendBlossomSubscriptionRequest) (*orlydbv1.Empty, error) {
if err := s.db.ExtendBlossomSubscription(req.Pubkey, req.Tier, req.StorageMb, int(req.DaysExtended)); err != nil {
return nil, status.Errorf(codes.Internal, "extend blossom subscription failed: %v", err)
}
return &orlydbv1.Empty{}, nil
}
func (s *DatabaseService) GetBlossomStorageQuota(ctx context.Context, req *orlydbv1.GetBlossomStorageQuotaRequest) (*orlydbv1.GetBlossomStorageQuotaResponse, error) {
quota, err := s.db.GetBlossomStorageQuota(req.Pubkey)
if err != nil {
return nil, status.Errorf(codes.Internal, "get blossom storage quota failed: %v", err)
}
return &orlydbv1.GetBlossomStorageQuotaResponse{QuotaMb: quota}, nil
}
func (s *DatabaseService) IsFirstTimeUser(ctx context.Context, req *orlydbv1.IsFirstTimeUserRequest) (*orlydbv1.IsFirstTimeUserResponse, error) {
firstTime, err := s.db.IsFirstTimeUser(req.Pubkey)
if err != nil {
return nil, status.Errorf(codes.Internal, "is first time user failed: %v", err)
}
return &orlydbv1.IsFirstTimeUserResponse{FirstTime: firstTime}, nil
}
// === NIP-43 ===
func (s *DatabaseService) AddNIP43Member(ctx context.Context, req *orlydbv1.AddNIP43MemberRequest) (*orlydbv1.Empty, error) {
if err := s.db.AddNIP43Member(req.Pubkey, req.InviteCode); err != nil {
return nil, status.Errorf(codes.Internal, "add NIP-43 member failed: %v", err)
}
return &orlydbv1.Empty{}, nil
}
func (s *DatabaseService) RemoveNIP43Member(ctx context.Context, req *orlydbv1.RemoveNIP43MemberRequest) (*orlydbv1.Empty, error) {
if err := s.db.RemoveNIP43Member(req.Pubkey); err != nil {
return nil, status.Errorf(codes.Internal, "remove NIP-43 member failed: %v", err)
}
return &orlydbv1.Empty{}, nil
}
func (s *DatabaseService) IsNIP43Member(ctx context.Context, req *orlydbv1.IsNIP43MemberRequest) (*orlydbv1.IsNIP43MemberResponse, error) {
isMember, err := s.db.IsNIP43Member(req.Pubkey)
if err != nil {
return nil, status.Errorf(codes.Internal, "is NIP-43 member failed: %v", err)
}
return &orlydbv1.IsNIP43MemberResponse{IsMember: isMember}, nil
}
func (s *DatabaseService) GetNIP43Membership(ctx context.Context, req *orlydbv1.GetNIP43MembershipRequest) (*orlydbv1.NIP43Membership, error) {
membership, err := s.db.GetNIP43Membership(req.Pubkey)
if err != nil {
return nil, status.Errorf(codes.Internal, "get NIP-43 membership failed: %v", err)
}
return orlydbv1.NIP43MembershipToProto(membership), nil
}
func (s *DatabaseService) GetAllNIP43Members(ctx context.Context, req *orlydbv1.Empty) (*orlydbv1.PubkeyList, error) {
members, err := s.db.GetAllNIP43Members()
if err != nil {
return nil, status.Errorf(codes.Internal, "get all NIP-43 members failed: %v", err)
}
return &orlydbv1.PubkeyList{Pubkeys: members}, nil
}
func (s *DatabaseService) StoreInviteCode(ctx context.Context, req *orlydbv1.StoreInviteCodeRequest) (*orlydbv1.Empty, error) {
expiresAt := orlydbv1.TimeFromUnix(req.ExpiresAt)
if err := s.db.StoreInviteCode(req.Code, expiresAt); err != nil {
return nil, status.Errorf(codes.Internal, "store invite code failed: %v", err)
}
return &orlydbv1.Empty{}, nil
}
func (s *DatabaseService) ValidateInviteCode(ctx context.Context, req *orlydbv1.ValidateInviteCodeRequest) (*orlydbv1.ValidateInviteCodeResponse, error) {
valid, err := s.db.ValidateInviteCode(req.Code)
if err != nil {
return nil, status.Errorf(codes.Internal, "validate invite code failed: %v", err)
}
return &orlydbv1.ValidateInviteCodeResponse{Valid: valid}, nil
}
func (s *DatabaseService) DeleteInviteCode(ctx context.Context, req *orlydbv1.DeleteInviteCodeRequest) (*orlydbv1.Empty, error) {
if err := s.db.DeleteInviteCode(req.Code); err != nil {
return nil, status.Errorf(codes.Internal, "delete invite code failed: %v", err)
}
return &orlydbv1.Empty{}, nil
}
func (s *DatabaseService) PublishNIP43MembershipEvent(ctx context.Context, req *orlydbv1.PublishNIP43MembershipEventRequest) (*orlydbv1.Empty, error) {
if err := s.db.PublishNIP43MembershipEvent(int(req.Kind), req.Pubkey); err != nil {
return nil, status.Errorf(codes.Internal, "publish NIP-43 membership event failed: %v", err)
}
return &orlydbv1.Empty{}, nil
}
// === Query Cache ===
func (s *DatabaseService) GetCachedJSON(ctx context.Context, req *orlydbv1.GetCachedJSONRequest) (*orlydbv1.GetCachedJSONResponse, error) {
f := orlydbv1.ProtoToFilter(req.Filter)
jsonItems, found := s.db.GetCachedJSON(f)
return &orlydbv1.GetCachedJSONResponse{
JsonItems: jsonItems,
Found: found,
}, nil
}
func (s *DatabaseService) CacheMarshaledJSON(ctx context.Context, req *orlydbv1.CacheMarshaledJSONRequest) (*orlydbv1.Empty, error) {
f := orlydbv1.ProtoToFilter(req.Filter)
s.db.CacheMarshaledJSON(f, req.JsonItems)
return &orlydbv1.Empty{}, nil
}
func (s *DatabaseService) GetCachedEvents(ctx context.Context, req *orlydbv1.GetCachedEventsRequest) (*orlydbv1.GetCachedEventsResponse, error) {
f := orlydbv1.ProtoToFilter(req.Filter)
events, found := s.db.GetCachedEvents(f)
return &orlydbv1.GetCachedEventsResponse{
Events: orlydbv1.EventsToProto(events),
Found: found,
}, nil
}
func (s *DatabaseService) CacheEvents(ctx context.Context, req *orlydbv1.CacheEventsRequest) (*orlydbv1.Empty, error) {
f := orlydbv1.ProtoToFilter(req.Filter)
events := orlydbv1.ProtoToEvents(req.Events)
s.db.CacheEvents(f, events)
return &orlydbv1.Empty{}, nil
}
func (s *DatabaseService) InvalidateQueryCache(ctx context.Context, req *orlydbv1.Empty) (*orlydbv1.Empty, error) {
s.db.InvalidateQueryCache()
return &orlydbv1.Empty{}, nil
}
// === Access Tracking ===
func (s *DatabaseService) RecordEventAccess(ctx context.Context, req *orlydbv1.RecordEventAccessRequest) (*orlydbv1.Empty, error) {
if err := s.db.RecordEventAccess(req.Serial, req.ConnectionId); err != nil {
return nil, status.Errorf(codes.Internal, "record event access failed: %v", err)
}
return &orlydbv1.Empty{}, nil
}
func (s *DatabaseService) GetEventAccessInfo(ctx context.Context, req *orlydbv1.GetEventAccessInfoRequest) (*orlydbv1.GetEventAccessInfoResponse, error) {
lastAccess, accessCount, err := s.db.GetEventAccessInfo(req.Serial)
if err != nil {
return nil, status.Errorf(codes.Internal, "get event access info failed: %v", err)
}
return &orlydbv1.GetEventAccessInfoResponse{
LastAccess: lastAccess,
AccessCount: accessCount,
}, nil
}
func (s *DatabaseService) GetLeastAccessedEvents(ctx context.Context, req *orlydbv1.GetLeastAccessedEventsRequest) (*orlydbv1.SerialList, error) {
serials, err := s.db.GetLeastAccessedEvents(int(req.Limit), req.MinAgeSec)
if err != nil {
return nil, status.Errorf(codes.Internal, "get least accessed events failed: %v", err)
}
return &orlydbv1.SerialList{Serials: serials}, nil
}
// === Utility ===
func (s *DatabaseService) EventIdsBySerial(ctx context.Context, req *orlydbv1.EventIdsBySerialRequest) (*orlydbv1.EventIdsBySerialResponse, error) {
eventIds, err := s.db.EventIdsBySerial(req.Start, int(req.Count))
if err != nil {
return nil, status.Errorf(codes.Internal, "event ids by serial failed: %v", err)
}
return &orlydbv1.EventIdsBySerialResponse{EventIds: eventIds}, nil
}
func (s *DatabaseService) RunMigrations(ctx context.Context, req *orlydbv1.Empty) (*orlydbv1.Empty, error) {
s.db.RunMigrations()
return &orlydbv1.Empty{}, nil
}
// === Blob Storage (Blossom) ===
func (s *DatabaseService) SaveBlob(ctx context.Context, req *orlydbv1.SaveBlobRequest) (*orlydbv1.Empty, error) {
if err := s.db.SaveBlob(req.Sha256Hash, req.Data, req.Pubkey, req.MimeType, req.Extension); err != nil {
return nil, status.Errorf(codes.Internal, "save blob failed: %v", err)
}
return &orlydbv1.Empty{}, nil
}
func (s *DatabaseService) GetBlob(ctx context.Context, req *orlydbv1.GetBlobRequest) (*orlydbv1.GetBlobResponse, error) {
data, metadata, err := s.db.GetBlob(req.Sha256Hash)
if err != nil {
// Return not found as a response, not an error
return &orlydbv1.GetBlobResponse{Found: false}, nil
}
return &orlydbv1.GetBlobResponse{
Found: true,
Data: data,
Metadata: orlydbv1.BlobMetadataToProto(metadata),
}, nil
}
func (s *DatabaseService) HasBlob(ctx context.Context, req *orlydbv1.HasBlobRequest) (*orlydbv1.HasBlobResponse, error) {
exists, err := s.db.HasBlob(req.Sha256Hash)
if err != nil {
return nil, status.Errorf(codes.Internal, "has blob failed: %v", err)
}
return &orlydbv1.HasBlobResponse{Exists: exists}, nil
}
func (s *DatabaseService) DeleteBlob(ctx context.Context, req *orlydbv1.DeleteBlobRequest) (*orlydbv1.Empty, error) {
if err := s.db.DeleteBlob(req.Sha256Hash, req.Pubkey); err != nil {
return nil, status.Errorf(codes.Internal, "delete blob failed: %v", err)
}
return &orlydbv1.Empty{}, nil
}
func (s *DatabaseService) ListBlobs(ctx context.Context, req *orlydbv1.ListBlobsRequest) (*orlydbv1.ListBlobsResponse, error) {
descriptors, err := s.db.ListBlobs(req.Pubkey, req.Since, req.Until)
if err != nil {
return nil, status.Errorf(codes.Internal, "list blobs failed: %v", err)
}
return &orlydbv1.ListBlobsResponse{
Descriptors: orlydbv1.BlobDescriptorListToProto(descriptors),
}, nil
}
func (s *DatabaseService) GetBlobMetadata(ctx context.Context, req *orlydbv1.GetBlobMetadataRequest) (*orlydbv1.BlobMetadata, error) {
metadata, err := s.db.GetBlobMetadata(req.Sha256Hash)
if err != nil {
return nil, status.Errorf(codes.NotFound, "blob metadata not found: %v", err)
}
return orlydbv1.BlobMetadataToProto(metadata), nil
}
func (s *DatabaseService) GetTotalBlobStorageUsed(ctx context.Context, req *orlydbv1.GetTotalBlobStorageUsedRequest) (*orlydbv1.GetTotalBlobStorageUsedResponse, error) {
totalMB, err := s.db.GetTotalBlobStorageUsed(req.Pubkey)
if err != nil {
return nil, status.Errorf(codes.Internal, "get total blob storage used failed: %v", err)
}
return &orlydbv1.GetTotalBlobStorageUsedResponse{TotalMb: totalMB}, nil
}
func (s *DatabaseService) SaveBlobReport(ctx context.Context, req *orlydbv1.SaveBlobReportRequest) (*orlydbv1.Empty, error) {
if err := s.db.SaveBlobReport(req.Sha256Hash, req.ReportData); err != nil {
return nil, status.Errorf(codes.Internal, "save blob report failed: %v", err)
}
return &orlydbv1.Empty{}, nil
}
func (s *DatabaseService) ListAllBlobUserStats(ctx context.Context, req *orlydbv1.Empty) (*orlydbv1.ListAllBlobUserStatsResponse, error) {
stats, err := s.db.ListAllBlobUserStats()
if err != nil {
return nil, status.Errorf(codes.Internal, "list all blob user stats failed: %v", err)
}
return &orlydbv1.ListAllBlobUserStatsResponse{
Stats: orlydbv1.UserBlobStatsListToProto(stats),
}, nil
}
// === Helper Methods ===
// streamEvents is a helper to stream events in batches.
type eventStreamer interface {
Send(*orlydbv1.EventBatch) error
Context() context.Context
}
func (s *DatabaseService) streamEvents(events []*orlydbv1.Event, stream eventStreamer) error {
batchSize := s.cfg.StreamBatchSize
if batchSize == 0 {
batchSize = 100
}
for i := 0; i < len(events); i += batchSize {
end := i + batchSize
if end > len(events) {
end = len(events)
}
batch := &orlydbv1.EventBatch{
Events: events[i:end],
}
if err := stream.Send(batch); err != nil {
return err
}
}
return nil
}

63
cmd/orly-launcher/config.go

@ -0,0 +1,63 @@ @@ -0,0 +1,63 @@
package main
import (
"os"
"path/filepath"
"time"
"github.com/adrg/xdg"
)
// Config holds the launcher configuration.
type Config struct {
// DBBinary is the path to the orly-db binary
DBBinary string
// RelayBinary is the path to the orly binary
RelayBinary string
// DBListen is the address the database server listens on
DBListen string
// DBReadyTimeout is how long to wait for the database to be ready
DBReadyTimeout time.Duration
// StopTimeout is how long to wait for processes to stop gracefully
StopTimeout time.Duration
// DataDir is the data directory to pass to orly-db
DataDir string
// LogLevel is the log level to use for both processes
LogLevel string
}
func loadConfig() (*Config, error) {
cfg := &Config{
DBBinary: getEnvOrDefault("ORLY_LAUNCHER_DB_BINARY", "orly-db"),
RelayBinary: getEnvOrDefault("ORLY_LAUNCHER_RELAY_BINARY", "orly"),
DBListen: getEnvOrDefault("ORLY_LAUNCHER_DB_LISTEN", "127.0.0.1:50051"),
DBReadyTimeout: parseDuration("ORLY_LAUNCHER_DB_READY_TIMEOUT", 30*time.Second),
StopTimeout: parseDuration("ORLY_LAUNCHER_STOP_TIMEOUT", 30*time.Second), // Increased for DB flush
DataDir: getEnvOrDefault("ORLY_DATA_DIR", filepath.Join(xdg.DataHome, "ORLY")),
LogLevel: getEnvOrDefault("ORLY_LOG_LEVEL", "info"),
}
return cfg, nil
}
func getEnvOrDefault(key, defaultValue string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultValue
}
func parseDuration(key string, defaultValue time.Duration) time.Duration {
if v := os.Getenv(key); v != "" {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return defaultValue
}

109
cmd/orly-launcher/main.go

@ -0,0 +1,109 @@ @@ -0,0 +1,109 @@
// orly-launcher is a process supervisor that manages the database and relay
// processes in split mode. It starts the database server first, waits for it
// to be ready, then starts the relay with the gRPC database backend.
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/version"
)
func main() {
cfg, err := loadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
os.Exit(1)
}
// Handle version request
if len(os.Args) > 1 && (os.Args[1] == "version" || os.Args[1] == "-v" || os.Args[1] == "--version") {
fmt.Println(version.V)
os.Exit(0)
}
// Handle help request
if len(os.Args) > 1 && (os.Args[1] == "help" || os.Args[1] == "-h" || os.Args[1] == "--help") {
printHelp()
os.Exit(0)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
supervisor := NewSupervisor(ctx, cancel, cfg)
// Handle shutdown signals
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigChan
log.I.F("received signal %v, shutting down...", sig)
cancel()
}()
log.I.F("starting orly-launcher %s", version.V)
log.I.F("database binary: %s", cfg.DBBinary)
log.I.F("relay binary: %s", cfg.RelayBinary)
log.I.F("database listen: %s", cfg.DBListen)
if err := supervisor.Start(); chk.E(err) {
fmt.Fprintf(os.Stderr, "failed to start: %v\n", err)
os.Exit(1)
}
// Wait for context cancellation (signal received)
<-ctx.Done()
log.I.F("stopping supervisor...")
if err := supervisor.Stop(); chk.E(err) {
log.E.F("error during shutdown: %v", err)
}
log.I.F("orly-launcher stopped")
}
func printHelp() {
fmt.Printf(`orly-launcher %s
Process supervisor for split-mode deployment of ORLY relay.
Usage: orly-launcher [command]
Commands:
help, -h, --help Show this help
version, -v, --version Show version
Environment Variables:
ORLY_LAUNCHER_DB_BINARY Path to orly-db binary (default: orly-db)
ORLY_LAUNCHER_RELAY_BINARY Path to orly binary (default: orly)
ORLY_LAUNCHER_DB_LISTEN Address for database server (default: 127.0.0.1:50051)
ORLY_LAUNCHER_DB_READY_TIMEOUT Timeout waiting for DB ready (default: 30s)
ORLY_LAUNCHER_STOP_TIMEOUT Timeout for graceful stop (default: 10s)
ORLY_DATA_DIR Data directory (passed to orly-db)
ORLY_DB_LOG_LEVEL Database log level (passed to orly-db)
The launcher will:
1. Start the database server (orly-db)
2. Wait for the database to be ready
3. Start the relay (orly) with ORLY_DB_TYPE=grpc
4. Monitor both processes and restart if they crash
5. On shutdown, stop relay first, then database
Example:
# Start with default binaries in PATH
orly-launcher
# Start with custom binary paths
ORLY_LAUNCHER_DB_BINARY=/opt/orly/orly-db \
ORLY_LAUNCHER_RELAY_BINARY=/opt/orly/orly \
orly-launcher
`, version.V)
}

310
cmd/orly-launcher/supervisor.go

@ -0,0 +1,310 @@ @@ -0,0 +1,310 @@
package main
import (
"context"
"fmt"
"net"
"os"
"os/exec"
"sync"
"syscall"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
)
// Supervisor manages the database and relay processes.
type Supervisor struct {
cfg *Config
ctx context.Context
cancel context.CancelFunc
dbProc *Process
relayProc *Process
wg sync.WaitGroup
mu sync.Mutex
closed bool
}
// Process represents a managed subprocess.
type Process struct {
name string
cmd *exec.Cmd
restarts int
exited chan struct{} // closed when process exits
mu sync.Mutex
}
// NewSupervisor creates a new process supervisor.
func NewSupervisor(ctx context.Context, cancel context.CancelFunc, cfg *Config) *Supervisor {
return &Supervisor{
cfg: cfg,
ctx: ctx,
cancel: cancel,
}
}
// Start starts the database and relay processes.
func (s *Supervisor) Start() error {
// 1. Start database server
if err := s.startDB(); err != nil {
return fmt.Errorf("failed to start database: %w", err)
}
// 2. Wait for DB to be ready (health check on gRPC port)
if err := s.waitForDBReady(s.cfg.DBReadyTimeout); err != nil {
s.stopDB()
return fmt.Errorf("database not ready: %w", err)
}
log.I.F("database is ready")
// 3. Start relay with gRPC backend
if err := s.startRelay(); err != nil {
s.stopDB()
return fmt.Errorf("failed to start relay: %w", err)
}
// 4. Start monitoring goroutines
s.wg.Add(2)
go s.monitorProcess(s.dbProc, "db", s.startDB)
go s.monitorProcess(s.relayProc, "relay", s.startRelay)
return nil
}
// Stop stops all managed processes gracefully.
func (s *Supervisor) Stop() error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil
}
s.closed = true
s.mu.Unlock()
// Stop relay first (it depends on DB)
log.I.F("stopping relay...")
s.stopProcess(s.relayProc, 5*time.Second)
// Stop DB with longer timeout for flush
log.I.F("stopping database...")
s.stopProcess(s.dbProc, s.cfg.StopTimeout)
// Wait for monitor goroutines to exit (they will exit when they see closed=true)
s.wg.Wait()
return nil
}
func (s *Supervisor) startDB() error {
s.mu.Lock()
defer s.mu.Unlock()
// Build environment for database process
env := os.Environ()
env = append(env, fmt.Sprintf("ORLY_DB_LISTEN=%s", s.cfg.DBListen))
env = append(env, fmt.Sprintf("ORLY_DATA_DIR=%s", s.cfg.DataDir))
env = append(env, fmt.Sprintf("ORLY_DB_LOG_LEVEL=%s", s.cfg.LogLevel))
cmd := exec.CommandContext(s.ctx, s.cfg.DBBinary)
cmd.Env = env
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); chk.E(err) {
return err
}
exited := make(chan struct{})
s.dbProc = &Process{
name: "orly-db",
cmd: cmd,
exited: exited,
}
// Start a goroutine to wait for the process and close the exited channel
go func() {
cmd.Wait()
close(exited)
}()
log.I.F("started database server (pid %d)", cmd.Process.Pid)
return nil
}
func (s *Supervisor) startRelay() error {
s.mu.Lock()
defer s.mu.Unlock()
// Build environment for relay process
env := os.Environ()
env = append(env, "ORLY_DB_TYPE=grpc")
env = append(env, fmt.Sprintf("ORLY_GRPC_SERVER=%s", s.cfg.DBListen))
env = append(env, fmt.Sprintf("ORLY_LOG_LEVEL=%s", s.cfg.LogLevel))
cmd := exec.CommandContext(s.ctx, s.cfg.RelayBinary)
cmd.Env = env
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); chk.E(err) {
return err
}
exited := make(chan struct{})
s.relayProc = &Process{
name: "orly",
cmd: cmd,
exited: exited,
}
// Start a goroutine to wait for the process and close the exited channel
go func() {
cmd.Wait()
close(exited)
}()
log.I.F("started relay server (pid %d)", cmd.Process.Pid)
return nil
}
func (s *Supervisor) waitForDBReady(timeout time.Duration) error {
deadline := time.Now().Add(timeout)
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-s.ctx.Done():
return s.ctx.Err()
case <-ticker.C:
if time.Now().After(deadline) {
return fmt.Errorf("timeout waiting for database")
}
// Try to connect to the gRPC port
conn, err := net.DialTimeout("tcp", s.cfg.DBListen, time.Second)
if err == nil {
conn.Close()
return nil // Database is accepting connections
}
}
}
}
func (s *Supervisor) stopDB() {
s.stopProcess(s.dbProc, s.cfg.StopTimeout)
}
func (s *Supervisor) stopProcess(p *Process, timeout time.Duration) {
if p == nil {
return
}
p.mu.Lock()
if p.cmd == nil || p.cmd.Process == nil {
p.mu.Unlock()
return
}
p.mu.Unlock()
// Send SIGTERM for graceful shutdown
if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
// Process may have already exited
log.D.F("%s already exited: %v", p.name, err)
return
}
// Wait for process to exit using the exited channel
select {
case <-p.exited:
log.I.F("%s stopped gracefully", p.name)
case <-time.After(timeout):
log.W.F("%s did not stop in time, killing", p.name)
p.cmd.Process.Kill()
<-p.exited // Wait for the kill to complete
}
}
func (s *Supervisor) monitorProcess(p *Process, procType string, restart func() error) {
defer s.wg.Done()
for {
// Check if we're shutting down
s.mu.Lock()
closed := s.closed
s.mu.Unlock()
if closed {
return
}
select {
case <-s.ctx.Done():
return
default:
}
if p == nil || p.exited == nil {
return
}
// Wait for process to exit
select {
case <-p.exited:
// Process exited
case <-s.ctx.Done():
return
}
// Check again if we're shutting down (process may have been stopped intentionally)
s.mu.Lock()
closed = s.closed
s.mu.Unlock()
if closed {
return
}
// Process exited unexpectedly
p.restarts++
log.W.F("%s exited unexpectedly, restart count: %d", p.name, p.restarts)
// Backoff before restart
backoff := time.Duration(p.restarts) * time.Second
if backoff > 30*time.Second {
backoff = 30 * time.Second
}
select {
case <-s.ctx.Done():
return
case <-time.After(backoff):
}
// Check one more time before restarting
s.mu.Lock()
closed = s.closed
s.mu.Unlock()
if closed {
return
}
if err := restart(); err != nil {
log.E.F("failed to restart %s: %v", p.name, err)
} else {
// Update p to point to the new process
s.mu.Lock()
if procType == "db" {
p = s.dbProc
} else {
p = s.relayProc
}
s.mu.Unlock()
}
}
}

9
go.mod

@ -7,8 +7,6 @@ require ( @@ -7,8 +7,6 @@ require (
github.com/adrg/xdg v0.5.3
github.com/alexflint/go-arg v1.6.1
github.com/aperturerobotics/go-indexeddb v0.2.3
github.com/bits-and-blooms/bloom/v3 v3.7.1
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
github.com/dgraph-io/badger/v4 v4.8.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
@ -24,12 +22,13 @@ require ( @@ -24,12 +22,13 @@ require (
github.com/stretchr/testify v1.11.1
github.com/vertex-lab/nostr-sqlite v0.3.2
go-simpler.org/env v0.12.0
go.etcd.io/bbolt v1.4.3
go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.46.0
golang.org/x/lint v0.0.0-20241112194109-818c5a804067
golang.org/x/term v0.38.0
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.10
honnef.co/go/tools v0.6.1
lol.mleku.dev v1.0.5
lukechampine.com/frand v1.5.1
@ -39,7 +38,6 @@ require ( @@ -39,7 +38,6 @@ require (
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
github.com/alexflint/go-scalar v1.2.0 // indirect
github.com/bits-and-blooms/bitset v1.24.2 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/bytedance/sonic v1.13.1 // indirect
@ -49,6 +47,7 @@ require ( @@ -49,6 +47,7 @@ require (
github.com/coder/websocket v1.8.12 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
@ -88,7 +87,7 @@ require ( @@ -88,7 +87,7 @@ require (
golang.org/x/time v0.7.0 // indirect
golang.org/x/tools v0.40.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/protobuf v1.36.10 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
p256k1.mleku.dev v1.0.3 // indirect

20
go.sum

@ -12,10 +12,6 @@ github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+W @@ -12,10 +12,6 @@ github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+W
github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
github.com/aperturerobotics/go-indexeddb v0.2.3 h1:DfquIk9YEZjWD/lJyBWZWGCtRga43/a96bx0Ulv9VhQ=
github.com/aperturerobotics/go-indexeddb v0.2.3/go.mod h1:JV1XngOCCui7zrMSyRz+Wvz00nUSfotRKZqJzWpl5fQ=
github.com/bits-and-blooms/bitset v1.24.2 h1:M7/NzVbsytmtfHbumG+K2bremQPMJuqv1JD3vOaFxp0=
github.com/bits-and-blooms/bitset v1.24.2/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bloom/v3 v3.7.1 h1:WXovk4TRKZttAMJfoQx6K2DM0zNIt8w+c67UqO+etV0=
github.com/bits-and-blooms/bloom/v3 v3.7.1/go.mod h1:rZzYLLje2dfzXfAkJNxQQHsKurAyK55KUnL43Euk0hU=
github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ=
github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
@ -70,6 +66,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre @@ -70,6 +66,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/flatbuffers v25.9.23+incompatible h1:rGZKv+wOb6QPzIdkM2KxhBZCDrA0DeN6DNmRDrqIsQU=
@ -161,20 +159,20 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= @@ -161,20 +159,20 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg=
github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ=
github.com/vertex-lab/nostr-sqlite v0.3.2 h1:8nZYYIwiKnWLA446qA/wL/Gy+bU0kuaxdLfUyfeTt/E=
github.com/vertex-lab/nostr-sqlite v0.3.2/go.mod h1:5bw1wMgJhSdrumsZAWxqy+P0u1g+q02PnlGQn15dnSM=
go-simpler.org/env v0.12.0 h1:kt/lBts0J1kjWJAnB740goNdvwNxt5emhYngL0Fzufs=
go-simpler.org/env v0.12.0/go.mod h1:cc/5Md9JCUM7LVLtN0HYjPTDcI3Q8TDaPlNTAlDU+WI=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
@ -225,6 +223,12 @@ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeu @@ -225,6 +223,12 @@ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeu
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

28
main.go

@ -27,8 +27,8 @@ import ( @@ -27,8 +27,8 @@ import (
"next.orly.dev/pkg/acl"
"git.mleku.dev/mleku/nostr/crypto/keys"
"git.mleku.dev/mleku/nostr/encoders/bech32encoding"
bboltdb "next.orly.dev/pkg/bbolt" // Import for bbolt factory and type
"next.orly.dev/pkg/database"
_ "next.orly.dev/pkg/database/grpc" // Import for grpc factory registration
neo4jdb "next.orly.dev/pkg/neo4j" // Import for neo4j factory and type
"git.mleku.dev/mleku/nostr/encoders/hex"
"next.orly.dev/pkg/ratelimit"
@ -119,14 +119,14 @@ func main() { @@ -119,14 +119,14 @@ func main() {
fmt.Println("Migrate data between database backends.")
fmt.Println("")
fmt.Println("Options:")
fmt.Println(" --from <type> Source database type (badger, bbolt, neo4j)")
fmt.Println(" --to <type> Destination database type (badger, bbolt, neo4j)")
fmt.Println(" --from <type> Source database type (badger, neo4j)")
fmt.Println(" --to <type> Destination database type (badger, neo4j)")
fmt.Println(" --target-path <path> Optional: destination data directory")
fmt.Println(" (default: $ORLY_DATA_DIR/<type>)")
fmt.Println("")
fmt.Println("Examples:")
fmt.Println(" orly migrate --from badger --to bbolt")
fmt.Println(" orly migrate --from badger --to bbolt --target-path /mnt/hdd/orly-bbolt")
fmt.Println(" orly migrate --from badger --to neo4j")
fmt.Println(" orly migrate --from badger --to neo4j --target-path /mnt/hdd/orly-neo4j")
os.Exit(1)
}
@ -654,10 +654,6 @@ func main() { @@ -654,10 +654,6 @@ func main() {
n4jDB.MaxConcurrentQueries(),
)
log.I.F("rate limiter configured for Neo4j backend (target: %dMB)", targetMB)
} else if _, ok := db.(*bboltdb.B); ok {
// BBolt uses memory-mapped IO, so memory-only limiter is appropriate
limiter = ratelimit.NewMemoryOnlyLimiter(rlConfig)
log.I.F("rate limiter configured for BBolt backend (target: %dMB)", targetMB)
} else {
// For other backends, create a disabled limiter
limiter = ratelimit.NewDisabledLimiter()
@ -781,8 +777,8 @@ func makeDatabaseConfig(cfg *config.C) *database.DatabaseConfig { @@ -781,8 +777,8 @@ func makeDatabaseConfig(cfg *config.C) *database.DatabaseConfig {
neo4jURI, neo4jUser, neo4jPassword,
neo4jMaxConnPoolSize, neo4jFetchSize, neo4jMaxTxRetrySeconds, neo4jQueryResultLimit := cfg.GetDatabaseConfigValues()
// Get BBolt-specific configuration
batchMaxEvents, batchMaxBytes, flushTimeoutSec, bloomSizeMB, noSync, mmapSizeBytes := cfg.GetBboltConfigValues()
// Get gRPC client configuration
grpcServerAddress, grpcConnectTimeout := cfg.GetGRPCConfigValues()
return &database.DatabaseConfig{
DataDir: dataDir,
@ -802,13 +798,9 @@ func makeDatabaseConfig(cfg *config.C) *database.DatabaseConfig { @@ -802,13 +798,9 @@ func makeDatabaseConfig(cfg *config.C) *database.DatabaseConfig {
Neo4jFetchSize: neo4jFetchSize,
Neo4jMaxTxRetrySeconds: neo4jMaxTxRetrySeconds,
Neo4jQueryResultLimit: neo4jQueryResultLimit,
// BBolt-specific settings
BboltBatchMaxEvents: batchMaxEvents,
BboltBatchMaxBytes: batchMaxBytes,
BboltFlushTimeout: time.Duration(flushTimeoutSec) * time.Second,
BboltBloomSizeMB: bloomSizeMB,
BboltNoSync: noSync,
BboltMmapSize: mmapSizeBytes,
// gRPC client settings
GRPCServerAddress: grpcServerAddress,
GRPCConnectTimeout: grpcConnectTimeout,
}
}

330
pkg/bbolt/batcher.go

@ -1,330 +0,0 @@ @@ -1,330 +0,0 @@
//go:build !(js && wasm)
package bbolt
import (
"sync"
"time"
bolt "go.etcd.io/bbolt"
"lol.mleku.dev/chk"
)
// BatchedWrite represents a single write operation
type BatchedWrite struct {
BucketName []byte
Key []byte
Value []byte
IsDelete bool
}
// EventBatch represents a complete event with all its indexes and graph updates
type EventBatch struct {
Serial uint64
EventData []byte // Serialized compact event data
Indexes []BatchedWrite // Index entries
EventVertex *EventVertex // Graph vertex for this event
PubkeyUpdate *PubkeyVertexUpdate // Update to author's pubkey vertex
MentionUpdates []*PubkeyVertexUpdate // Updates to mentioned pubkeys
EdgeKeys []EdgeKey // Edge keys for bloom filter
}
// PubkeyVertexUpdate represents an update to a pubkey's vertex
type PubkeyVertexUpdate struct {
PubkeySerial uint64
AddAuthored uint64 // Event serial to add to authored (0 if none)
AddMention uint64 // Event serial to add to mentions (0 if none)
}
// WriteBatcher accumulates writes and flushes them in batches.
// Optimized for HDD with large batches and periodic flushes.
type WriteBatcher struct {
db *bolt.DB
bloom *EdgeBloomFilter
logger *Logger
mu sync.Mutex
pending []*EventBatch
pendingSize int64
stopped bool
// Configuration
maxEvents int
maxBytes int64
flushPeriod time.Duration
// Channels for coordination
flushCh chan struct{}
shutdownCh chan struct{}
doneCh chan struct{}
// Stats
stats BatcherStats
}
// BatcherStats contains batcher statistics
type BatcherStats struct {
TotalBatches uint64
TotalEvents uint64
TotalBytes uint64
AverageLatencyMs float64
LastFlushTime time.Time
LastFlushDuration time.Duration
}
// NewWriteBatcher creates a new write batcher
func NewWriteBatcher(db *bolt.DB, bloom *EdgeBloomFilter, cfg *BboltConfig, logger *Logger) *WriteBatcher {
wb := &WriteBatcher{
db: db,
bloom: bloom,
logger: logger,
maxEvents: cfg.BatchMaxEvents,
maxBytes: cfg.BatchMaxBytes,
flushPeriod: cfg.BatchFlushTimeout,
pending: make([]*EventBatch, 0, cfg.BatchMaxEvents),
flushCh: make(chan struct{}, 1),
shutdownCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
go wb.flushLoop()
return wb
}
// Add adds an event batch to the pending writes
func (wb *WriteBatcher) Add(batch *EventBatch) error {
wb.mu.Lock()
defer wb.mu.Unlock()
if wb.stopped {
return ErrBatcherStopped
}
wb.pending = append(wb.pending, batch)
wb.pendingSize += int64(len(batch.EventData))
for _, idx := range batch.Indexes {
wb.pendingSize += int64(len(idx.Key) + len(idx.Value))
}
// Check thresholds
if len(wb.pending) >= wb.maxEvents || wb.pendingSize >= wb.maxBytes {
wb.triggerFlush()
}
return nil
}
// triggerFlush signals the flush loop to flush (must be called with lock held)
func (wb *WriteBatcher) triggerFlush() {
select {
case wb.flushCh <- struct{}{}:
default:
// Already a flush pending
}
}
// flushLoop runs the background flush timer
func (wb *WriteBatcher) flushLoop() {
defer close(wb.doneCh)
timer := time.NewTimer(wb.flushPeriod)
defer timer.Stop()
for {
select {
case <-timer.C:
if err := wb.Flush(); err != nil {
wb.logger.Errorf("bbolt: flush error: %v", err)
}
timer.Reset(wb.flushPeriod)
case <-wb.flushCh:
if err := wb.Flush(); err != nil {
wb.logger.Errorf("bbolt: flush error: %v", err)
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(wb.flushPeriod)
case <-wb.shutdownCh:
// Final flush
if err := wb.Flush(); err != nil {
wb.logger.Errorf("bbolt: final flush error: %v", err)
}
return
}
}
}
// Flush writes all pending batches to BBolt
func (wb *WriteBatcher) Flush() error {
wb.mu.Lock()
if len(wb.pending) == 0 {
wb.mu.Unlock()
return nil
}
// Swap out pending slice
toFlush := wb.pending
toFlushSize := wb.pendingSize
wb.pending = make([]*EventBatch, 0, wb.maxEvents)
wb.pendingSize = 0
wb.mu.Unlock()
startTime := time.Now()
// Collect all edge keys for bloom filter update
var allEdgeKeys []EdgeKey
for _, batch := range toFlush {
allEdgeKeys = append(allEdgeKeys, batch.EdgeKeys...)
}
// Update bloom filter first (memory only)
if len(allEdgeKeys) > 0 {
wb.bloom.AddBatch(allEdgeKeys)
}
// Write all batches in a single transaction
err := wb.db.Update(func(tx *bolt.Tx) error {
for _, batch := range toFlush {
// Write compact event data
cmpBucket := tx.Bucket(bucketCmp)
if cmpBucket != nil {
key := makeSerialKey(batch.Serial)
if err := cmpBucket.Put(key, batch.EventData); err != nil {
return err
}
}
// Write all indexes
for _, idx := range batch.Indexes {
bucket := tx.Bucket(idx.BucketName)
if bucket == nil {
continue
}
if idx.IsDelete {
if err := bucket.Delete(idx.Key); err != nil {
return err
}
} else {
if err := bucket.Put(idx.Key, idx.Value); err != nil {
return err
}
}
}
// Write event vertex
if batch.EventVertex != nil {
evBucket := tx.Bucket(bucketEv)
if evBucket != nil {
key := makeSerialKey(batch.Serial)
if err := evBucket.Put(key, batch.EventVertex.Encode()); err != nil {
return err
}
}
}
// Update pubkey vertices
if err := wb.updatePubkeyVertex(tx, batch.PubkeyUpdate); err != nil {
return err
}
for _, mentionUpdate := range batch.MentionUpdates {
if err := wb.updatePubkeyVertex(tx, mentionUpdate); err != nil {
return err
}
}
}
return nil
})
// Update stats
duration := time.Since(startTime)
wb.mu.Lock()
wb.stats.TotalBatches++
wb.stats.TotalEvents += uint64(len(toFlush))
wb.stats.TotalBytes += uint64(toFlushSize)
wb.stats.LastFlushTime = time.Now()
wb.stats.LastFlushDuration = duration
latencyMs := float64(duration.Milliseconds())
wb.stats.AverageLatencyMs = (wb.stats.AverageLatencyMs*float64(wb.stats.TotalBatches-1) + latencyMs) / float64(wb.stats.TotalBatches)
wb.mu.Unlock()
if err == nil {
wb.logger.Debugf("bbolt: flushed %d events (%d bytes) in %v", len(toFlush), toFlushSize, duration)
}
return err
}
// updatePubkeyVertex reads, updates, and writes a pubkey vertex
func (wb *WriteBatcher) updatePubkeyVertex(tx *bolt.Tx, update *PubkeyVertexUpdate) error {
if update == nil {
return nil
}
pvBucket := tx.Bucket(bucketPv)
if pvBucket == nil {
return nil
}
key := makeSerialKey(update.PubkeySerial)
// Load existing vertex or create new
var pv PubkeyVertex
existing := pvBucket.Get(key)
if existing != nil {
if err := pv.Decode(existing); chk.E(err) {
// If decode fails, start fresh
pv = PubkeyVertex{}
}
}
// Apply updates
if update.AddAuthored != 0 {
pv.AddAuthored(update.AddAuthored)
}
if update.AddMention != 0 {
pv.AddMention(update.AddMention)
}
// Write back
return pvBucket.Put(key, pv.Encode())
}
// Shutdown gracefully shuts down the batcher
func (wb *WriteBatcher) Shutdown() error {
wb.mu.Lock()
wb.stopped = true
wb.mu.Unlock()
close(wb.shutdownCh)
<-wb.doneCh
return nil
}
// Stats returns current batcher statistics
func (wb *WriteBatcher) Stats() BatcherStats {
wb.mu.Lock()
defer wb.mu.Unlock()
return wb.stats
}
// PendingCount returns the number of pending events
func (wb *WriteBatcher) PendingCount() int {
wb.mu.Lock()
defer wb.mu.Unlock()
return len(wb.pending)
}
// ErrBatcherStopped is returned when adding to a stopped batcher
var ErrBatcherStopped = &batcherStoppedError{}
type batcherStoppedError struct{}
func (e *batcherStoppedError) Error() string {
return "batcher has been stopped"
}

325
pkg/bbolt/bbolt.go

@ -1,325 +0,0 @@ @@ -1,325 +0,0 @@
//go:build !(js && wasm)
package bbolt
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"time"
bolt "go.etcd.io/bbolt"
"lol.mleku.dev"
"lol.mleku.dev/chk"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/utils/apputil"
)
// Bucket names - map to existing index prefixes but without the 3-byte prefix in keys
var (
bucketEvt = []byte("evt") // Event storage: serial -> compact event data
bucketEid = []byte("eid") // Event ID index
bucketFpc = []byte("fpc") // Full ID/pubkey index
bucketC = []byte("c--") // Created at index
bucketKc = []byte("kc-") // Kind + created index
bucketPc = []byte("pc-") // Pubkey + created index
bucketKpc = []byte("kpc") // Kind + pubkey + created
bucketTc = []byte("tc-") // Tag + created
bucketTkc = []byte("tkc") // Tag + kind + created
bucketTpc = []byte("tpc") // Tag + pubkey + created
bucketTkp = []byte("tkp") // Tag + kind + pubkey + created
bucketWrd = []byte("wrd") // Word search index
bucketExp = []byte("exp") // Expiration index
bucketPks = []byte("pks") // Pubkey hash -> serial
bucketSpk = []byte("spk") // Serial -> pubkey
bucketSei = []byte("sei") // Serial -> event ID
bucketCmp = []byte("cmp") // Compact event storage
bucketEv = []byte("ev") // Event vertices (adjacency list)
bucketPv = []byte("pv") // Pubkey vertices (adjacency list)
bucketMeta = []byte("_meta") // Markers, version, serial counter, bloom filter
)
// All buckets that need to be created on init
var allBuckets = [][]byte{
bucketEvt, bucketEid, bucketFpc, bucketC, bucketKc, bucketPc, bucketKpc,
bucketTc, bucketTkc, bucketTpc, bucketTkp, bucketWrd, bucketExp,
bucketPks, bucketSpk, bucketSei, bucketCmp, bucketEv, bucketPv, bucketMeta,
}
// B implements the database.Database interface using BBolt as the storage backend.
// Optimized for HDD with write batching and adjacency list graph storage.
type B struct {
ctx context.Context
cancel context.CancelFunc
dataDir string
Logger *Logger
db *bolt.DB
ready chan struct{}
// Write batching
batcher *WriteBatcher
// Serial management
serialMu sync.Mutex
nextSerial uint64
nextPubkeySeq uint64
// Edge bloom filter for fast negative lookups
edgeBloom *EdgeBloomFilter
// Configuration
cfg *BboltConfig
}
// BboltConfig holds bbolt-specific configuration
type BboltConfig struct {
DataDir string
LogLevel string
// Batch settings (tuned for 7200rpm HDD)
BatchMaxEvents int // Max events before flush (default: 5000)
BatchMaxBytes int64 // Max bytes before flush (default: 128MB)
BatchFlushTimeout time.Duration // Max time before flush (default: 30s)
// Bloom filter settings
BloomSizeMB int // Bloom filter size in MB (default: 16)
// BBolt settings
NoSync bool // Disable fsync for performance (DANGEROUS)
InitialMmapSize int // Initial mmap size in bytes
}
// Ensure B implements Database interface at compile time
var _ database.Database = (*B)(nil)
// New creates a new BBolt database instance with default configuration.
func New(
ctx context.Context, cancel context.CancelFunc, dataDir, logLevel string,
) (b *B, err error) {
cfg := &BboltConfig{
DataDir: dataDir,
LogLevel: logLevel,
BatchMaxEvents: 5000,
BatchMaxBytes: 128 * 1024 * 1024, // 128MB
BatchFlushTimeout: 30 * time.Second,
BloomSizeMB: 16,
InitialMmapSize: 8 * 1024 * 1024 * 1024, // 8GB
}
return NewWithConfig(ctx, cancel, cfg)
}
// NewWithConfig creates a new BBolt database instance with full configuration.
func NewWithConfig(
ctx context.Context, cancel context.CancelFunc, cfg *BboltConfig,
) (b *B, err error) {
// Apply defaults
if cfg.BatchMaxEvents <= 0 {
cfg.BatchMaxEvents = 5000
}
if cfg.BatchMaxBytes <= 0 {
cfg.BatchMaxBytes = 128 * 1024 * 1024
}
if cfg.BatchFlushTimeout <= 0 {
cfg.BatchFlushTimeout = 30 * time.Second
}
if cfg.BloomSizeMB <= 0 {
cfg.BloomSizeMB = 16
}
if cfg.InitialMmapSize <= 0 {
cfg.InitialMmapSize = 8 * 1024 * 1024 * 1024
}
b = &B{
ctx: ctx,
cancel: cancel,
dataDir: cfg.DataDir,
Logger: NewLogger(lol.GetLogLevel(cfg.LogLevel), cfg.DataDir),
ready: make(chan struct{}),
cfg: cfg,
}
// Ensure the data directory exists
if err = os.MkdirAll(cfg.DataDir, 0755); chk.E(err) {
return
}
if err = apputil.EnsureDir(filepath.Join(cfg.DataDir, "dummy")); chk.E(err) {
return
}
// Open BBolt database
dbPath := filepath.Join(cfg.DataDir, "orly.db")
opts := &bolt.Options{
Timeout: 10 * time.Second,
NoSync: cfg.NoSync,
InitialMmapSize: cfg.InitialMmapSize,
}
if b.db, err = bolt.Open(dbPath, 0600, opts); chk.E(err) {
return
}
// Create all buckets
if err = b.db.Update(func(tx *bolt.Tx) error {
for _, bucket := range allBuckets {
if _, err := tx.CreateBucketIfNotExists(bucket); err != nil {
return err
}
}
return nil
}); chk.E(err) {
return
}
// Initialize serial counters
if err = b.initSerialCounters(); chk.E(err) {
return
}
// Initialize bloom filter
b.edgeBloom, err = NewEdgeBloomFilter(cfg.BloomSizeMB, b.db)
if chk.E(err) {
return
}
// Initialize write batcher
b.batcher = NewWriteBatcher(b.db, b.edgeBloom, cfg, b.Logger)
// Run migrations
b.RunMigrations()
// Start warmup and mark ready
go b.warmup()
// Start background maintenance
go b.backgroundLoop()
return
}
// Path returns the path where the database files are stored.
func (b *B) Path() string { return b.dataDir }
// Init initializes the database with the given path.
func (b *B) Init(path string) error {
b.dataDir = path
return nil
}
// Sync flushes the database buffers to disk.
func (b *B) Sync() error {
// Flush pending writes
if err := b.batcher.Flush(); err != nil {
return err
}
// Persist bloom filter
if err := b.edgeBloom.Persist(b.db); err != nil {
return err
}
// Persist serial counters
if err := b.persistSerialCounters(); err != nil {
return err
}
// Sync BBolt
return b.db.Sync()
}
// Close releases resources and closes the database.
func (b *B) Close() (err error) {
b.Logger.Infof("bbolt: closing database...")
// Stop accepting new writes and flush pending
if b.batcher != nil {
if err = b.batcher.Shutdown(); chk.E(err) {
// Log but continue cleanup
}
}
// Persist bloom filter
if b.edgeBloom != nil {
if err = b.edgeBloom.Persist(b.db); chk.E(err) {
// Log but continue cleanup
}
}
// Persist serial counters
if err = b.persistSerialCounters(); chk.E(err) {
// Log but continue cleanup
}
// Close BBolt database
if b.db != nil {
if err = b.db.Close(); chk.E(err) {
return
}
}
b.Logger.Infof("bbolt: database closed")
return
}
// Wipe deletes all data in the database.
func (b *B) Wipe() error {
return b.db.Update(func(tx *bolt.Tx) error {
for _, bucket := range allBuckets {
if err := tx.DeleteBucket(bucket); err != nil && !errors.Is(err, bolt.ErrBucketNotFound) {
return err
}
if _, err := tx.CreateBucket(bucket); err != nil {
return err
}
}
// Reset serial counters
b.serialMu.Lock()
b.nextSerial = 1
b.nextPubkeySeq = 1
b.serialMu.Unlock()
// Reset bloom filter
b.edgeBloom.Reset()
return nil
})
}
// SetLogLevel changes the logging level.
func (b *B) SetLogLevel(level string) {
b.Logger.SetLogLevel(lol.GetLogLevel(level))
}
// Ready returns a channel that closes when the database is ready to serve requests.
func (b *B) Ready() <-chan struct{} {
return b.ready
}
// warmup performs database warmup operations and closes the ready channel when complete.
func (b *B) warmup() {
defer close(b.ready)
// Give the database time to settle
time.Sleep(1 * time.Second)
b.Logger.Infof("bbolt: database warmup complete, ready to serve requests")
}
// backgroundLoop runs periodic maintenance tasks.
func (b *B) backgroundLoop() {
expirationTicker := time.NewTicker(10 * time.Minute)
bloomPersistTicker := time.NewTicker(5 * time.Minute)
defer expirationTicker.Stop()
defer bloomPersistTicker.Stop()
for {
select {
case <-expirationTicker.C:
b.DeleteExpired()
case <-bloomPersistTicker.C:
if err := b.edgeBloom.Persist(b.db); chk.E(err) {
b.Logger.Warningf("bbolt: failed to persist bloom filter: %v", err)
}
case <-b.ctx.Done():
b.cancel()
return
}
}
}

192
pkg/bbolt/bloom.go

@ -1,192 +0,0 @@ @@ -1,192 +0,0 @@
//go:build !(js && wasm)
package bbolt
import (
"bytes"
"encoding/binary"
"sync"
"github.com/bits-and-blooms/bloom/v3"
bolt "go.etcd.io/bbolt"
"lol.mleku.dev/chk"
)
const bloomFilterKey = "edge_bloom_filter"
// EdgeBloomFilter provides fast negative lookups for edge existence checks.
// Uses a bloom filter to avoid disk seeks when checking if an edge exists.
type EdgeBloomFilter struct {
mu sync.RWMutex
filter *bloom.BloomFilter
// Track if filter has been modified since last persist
dirty bool
}
// NewEdgeBloomFilter creates or loads the edge bloom filter.
// sizeMB is the approximate size in megabytes.
// With 1% false positive rate, 16MB can hold ~10 million edges.
func NewEdgeBloomFilter(sizeMB int, db *bolt.DB) (*EdgeBloomFilter, error) {
ebf := &EdgeBloomFilter{}
// Try to load from database
var loaded bool
err := db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketMeta)
if bucket == nil {
return nil
}
data := bucket.Get([]byte(bloomFilterKey))
if data == nil {
return nil
}
// Deserialize bloom filter
reader := bytes.NewReader(data)
filter := &bloom.BloomFilter{}
if _, err := filter.ReadFrom(reader); err != nil {
return err
}
ebf.filter = filter
loaded = true
return nil
})
if chk.E(err) {
return nil, err
}
if !loaded {
// Create new filter
// Calculate parameters: m bits, k hash functions
// For 1% false positive rate: m/n ≈ 9.6, k ≈ 7
bitsPerMB := 8 * 1024 * 1024
totalBits := uint(sizeMB * bitsPerMB)
// Estimate capacity based on 10 bits per element for 1% FPR
estimatedCapacity := uint(totalBits / 10)
ebf.filter = bloom.NewWithEstimates(estimatedCapacity, 0.01)
}
return ebf, nil
}
// Add adds an edge to the bloom filter.
// An edge is represented by source and destination serials plus edge type.
func (ebf *EdgeBloomFilter) Add(srcSerial, dstSerial uint64, edgeType byte) {
ebf.mu.Lock()
defer ebf.mu.Unlock()
key := ebf.makeKey(srcSerial, dstSerial, edgeType)
ebf.filter.Add(key)
ebf.dirty = true
}
// AddBatch adds multiple edges to the bloom filter.
func (ebf *EdgeBloomFilter) AddBatch(edges []EdgeKey) {
ebf.mu.Lock()
defer ebf.mu.Unlock()
for _, edge := range edges {
key := ebf.makeKey(edge.SrcSerial, edge.DstSerial, edge.EdgeType)
ebf.filter.Add(key)
}
ebf.dirty = true
}
// MayExist checks if an edge might exist.
// Returns false if definitely doesn't exist (no disk access needed).
// Returns true if might exist (need to check disk to confirm).
func (ebf *EdgeBloomFilter) MayExist(srcSerial, dstSerial uint64, edgeType byte) bool {
ebf.mu.RLock()
defer ebf.mu.RUnlock()
key := ebf.makeKey(srcSerial, dstSerial, edgeType)
return ebf.filter.Test(key)
}
// Persist saves the bloom filter to the database.
func (ebf *EdgeBloomFilter) Persist(db *bolt.DB) error {
ebf.mu.Lock()
if !ebf.dirty {
ebf.mu.Unlock()
return nil
}
// Serialize while holding lock
var buf bytes.Buffer
if _, err := ebf.filter.WriteTo(&buf); err != nil {
ebf.mu.Unlock()
return err
}
data := buf.Bytes()
ebf.dirty = false
ebf.mu.Unlock()
// Write to database
return db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketMeta)
if bucket == nil {
return nil
}
return bucket.Put([]byte(bloomFilterKey), data)
})
}
// Reset clears the bloom filter.
func (ebf *EdgeBloomFilter) Reset() {
ebf.mu.Lock()
defer ebf.mu.Unlock()
ebf.filter.ClearAll()
ebf.dirty = true
}
// makeKey creates a unique key for an edge.
func (ebf *EdgeBloomFilter) makeKey(srcSerial, dstSerial uint64, edgeType byte) []byte {
key := make([]byte, 17) // 8 + 8 + 1
binary.BigEndian.PutUint64(key[0:8], srcSerial)
binary.BigEndian.PutUint64(key[8:16], dstSerial)
key[16] = edgeType
return key
}
// Stats returns bloom filter statistics.
func (ebf *EdgeBloomFilter) Stats() BloomStats {
ebf.mu.RLock()
defer ebf.mu.RUnlock()
approxCount := uint64(ebf.filter.ApproximatedSize())
cap := ebf.filter.Cap()
return BloomStats{
ApproxCount: approxCount,
Cap: cap,
}
}
// BloomStats contains bloom filter statistics.
type BloomStats struct {
ApproxCount uint64 // Approximate number of elements
Cap uint // Capacity in bits
}
// EdgeKey represents an edge for batch operations.
type EdgeKey struct {
SrcSerial uint64
DstSerial uint64
EdgeType byte
}
// Edge type constants
const (
EdgeTypeAuthor byte = 0 // Event author relationship
EdgeTypePTag byte = 1 // P-tag reference (event mentions pubkey)
EdgeTypeETag byte = 2 // E-tag reference (event references event)
EdgeTypeFollows byte = 3 // Kind 3 follows relationship
EdgeTypeReaction byte = 4 // Kind 7 reaction
EdgeTypeRepost byte = 5 // Kind 6 repost
EdgeTypeReply byte = 6 // Reply (kind 1 with e-tag)
)

134
pkg/bbolt/fetch-event.go

@ -1,134 +0,0 @@ @@ -1,134 +0,0 @@
//go:build !(js && wasm)
package bbolt
import (
"bytes"
"context"
"errors"
bolt "go.etcd.io/bbolt"
"lol.mleku.dev/chk"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/database/indexes/types"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/filter"
)
// FetchEventBySerial fetches an event by its serial number.
func (b *B) FetchEventBySerial(ser *types.Uint40) (ev *event.E, err error) {
if ser == nil {
return nil, errors.New("bbolt: nil serial")
}
serial := ser.Get()
key := makeSerialKey(serial)
err = b.db.View(func(tx *bolt.Tx) error {
// Get event ID first
seiBucket := tx.Bucket(bucketSei)
var eventId []byte
if seiBucket != nil {
eventId = seiBucket.Get(key)
}
// Try compact event storage first
cmpBucket := tx.Bucket(bucketCmp)
if cmpBucket != nil {
data := cmpBucket.Get(key)
if data != nil && eventId != nil && len(eventId) == 32 {
// Unmarshal compact event
resolver := &bboltSerialResolver{b: b}
ev, err = database.UnmarshalCompactEvent(data, eventId, resolver)
if err == nil {
return nil
}
// Fall through to try legacy format
}
}
// Try legacy event storage
evtBucket := tx.Bucket(bucketEvt)
if evtBucket != nil {
data := evtBucket.Get(key)
if data != nil {
ev = new(event.E)
reader := bytes.NewReader(data)
if err = ev.UnmarshalBinary(reader); err == nil {
return nil
}
}
}
return errors.New("bbolt: event not found")
})
return
}
// FetchEventsBySerials fetches multiple events by their serial numbers.
func (b *B) FetchEventsBySerials(serials []*types.Uint40) (events map[uint64]*event.E, err error) {
events = make(map[uint64]*event.E, len(serials))
err = b.db.View(func(tx *bolt.Tx) error {
cmpBucket := tx.Bucket(bucketCmp)
evtBucket := tx.Bucket(bucketEvt)
seiBucket := tx.Bucket(bucketSei)
resolver := &bboltSerialResolver{b: b}
for _, ser := range serials {
if ser == nil {
continue
}
serial := ser.Get()
key := makeSerialKey(serial)
// Get event ID
var eventId []byte
if seiBucket != nil {
eventId = seiBucket.Get(key)
}
// Try compact event storage first
if cmpBucket != nil {
data := cmpBucket.Get(key)
if data != nil && eventId != nil && len(eventId) == 32 {
ev, e := database.UnmarshalCompactEvent(data, eventId, resolver)
if e == nil {
events[serial] = ev
continue
}
}
}
// Try legacy event storage
if evtBucket != nil {
data := evtBucket.Get(key)
if data != nil {
ev := new(event.E)
reader := bytes.NewReader(data)
if e := ev.UnmarshalBinary(reader); e == nil {
events[serial] = ev
}
}
}
}
return nil
})
return
}
// CountEvents counts events matching a filter.
func (b *B) CountEvents(c context.Context, f *filter.F) (count int, approximate bool, err error) {
// Get serials matching filter
var serials types.Uint40s
if serials, err = b.GetSerialsFromFilter(f); chk.E(err) {
return
}
count = len(serials)
approximate = false
return
}

179
pkg/bbolt/get-serial-by-id.go

@ -1,179 +0,0 @@ @@ -1,179 +0,0 @@
//go:build !(js && wasm)
package bbolt
import (
"bytes"
"errors"
bolt "go.etcd.io/bbolt"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/database/indexes/types"
"next.orly.dev/pkg/interfaces/store"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/tag"
)
// GetSerialById gets the serial for an event ID.
func (b *B) GetSerialById(id []byte) (ser *types.Uint40, err error) {
if len(id) < 8 {
return nil, errors.New("bbolt: invalid event ID length")
}
idHash := hashEventId(id)
err = b.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketEid)
if bucket == nil {
return errors.New("id not found in database")
}
// Scan for matching ID hash prefix
c := bucket.Cursor()
for k, _ := c.Seek(idHash); k != nil && bytes.HasPrefix(k, idHash); k, _ = c.Next() {
// Key format: id_hash(8) | serial(5)
if len(k) >= 13 {
ser = new(types.Uint40)
ser.Set(decodeUint40(k[8:13]))
return nil
}
}
return errors.New("id not found in database")
})
return
}
// GetSerialsByIds gets serials for multiple event IDs.
func (b *B) GetSerialsByIds(ids *tag.T) (serials map[string]*types.Uint40, err error) {
serials = make(map[string]*types.Uint40, ids.Len())
if ids == nil || ids.Len() == 0 {
return
}
err = b.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketEid)
if bucket == nil {
return nil
}
// Iterate over the tag entries using the .T field
for _, id := range ids.T {
if len(id) < 8 {
continue
}
idHash := hashEventId(id)
c := bucket.Cursor()
for k, _ := c.Seek(idHash); k != nil && bytes.HasPrefix(k, idHash); k, _ = c.Next() {
if len(k) >= 13 {
ser := new(types.Uint40)
ser.Set(decodeUint40(k[8:13]))
serials[string(id)] = ser
break
}
}
}
return nil
})
return
}
// GetSerialsByIdsWithFilter gets serials with a filter function.
func (b *B) GetSerialsByIdsWithFilter(ids *tag.T, fn func(ev *event.E, ser *types.Uint40) bool) (serials map[string]*types.Uint40, err error) {
// For now, just call GetSerialsByIds - full implementation would apply filter
return b.GetSerialsByIds(ids)
}
// GetSerialsByRange gets serials within a key range.
func (b *B) GetSerialsByRange(idx database.Range) (serials types.Uint40s, err error) {
if len(idx.Start) < 3 {
return nil, errors.New("bbolt: invalid range start")
}
// Extract bucket name from prefix
bucketName := idx.Start[:3]
startKey := idx.Start[3:]
endKey := idx.End[3:]
err = b.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketName)
if bucket == nil {
return nil
}
c := bucket.Cursor()
for k, _ := c.Seek(startKey); k != nil; k, _ = c.Next() {
// Check if we've passed the end
if len(endKey) > 0 && bytes.Compare(k, endKey) >= 0 {
break
}
// Extract serial from end of key (last 5 bytes)
if len(k) >= 5 {
ser := new(types.Uint40)
ser.Set(decodeUint40(k[len(k)-5:]))
serials = append(serials, ser)
}
}
return nil
})
return
}
// GetFullIdPubkeyBySerial gets full event ID and pubkey by serial.
func (b *B) GetFullIdPubkeyBySerial(ser *types.Uint40) (fidpk *store.IdPkTs, err error) {
if ser == nil {
return nil, errors.New("bbolt: nil serial")
}
serial := ser.Get()
key := makeSerialKey(serial)
err = b.db.View(func(tx *bolt.Tx) error {
// Get full ID/pubkey from fpc bucket
fpcBucket := tx.Bucket(bucketFpc)
if fpcBucket == nil {
return errors.New("bbolt: fpc bucket not found")
}
// Scan for matching serial prefix
c := fpcBucket.Cursor()
for k, _ := c.Seek(key); k != nil && bytes.HasPrefix(k, key); k, _ = c.Next() {
// Key format: serial(5) | id(32) | pubkey_hash(8) | created_at(8)
if len(k) >= 53 {
fidpk = &store.IdPkTs{
Ser: serial,
}
fidpk.Id = make([]byte, 32)
copy(fidpk.Id, k[5:37])
// Pubkey is only hash here, need to look up full pubkey
// For now return what we have
fidpk.Pub = make([]byte, 8)
copy(fidpk.Pub, k[37:45])
fidpk.Ts = int64(decodeUint64(k[45:53]))
return nil
}
}
return errors.New("bbolt: serial not found in fpc index")
})
return
}
// GetFullIdPubkeyBySerials gets full event IDs and pubkeys for multiple serials.
func (b *B) GetFullIdPubkeyBySerials(sers []*types.Uint40) (fidpks []*store.IdPkTs, err error) {
fidpks = make([]*store.IdPkTs, 0, len(sers))
for _, ser := range sers {
fidpk, e := b.GetFullIdPubkeyBySerial(ser)
if e == nil && fidpk != nil {
fidpks = append(fidpks, fidpk)
}
}
return
}

250
pkg/bbolt/graph.go

@ -1,250 +0,0 @@ @@ -1,250 +0,0 @@
//go:build !(js && wasm)
package bbolt
import (
"bytes"
"encoding/binary"
"io"
)
// EventVertex stores the adjacency list for an event.
// Contains the author and all edges to other events/pubkeys.
type EventVertex struct {
AuthorSerial uint64 // Serial of the author pubkey
Kind uint16 // Event kind
PTagSerials []uint64 // Serials of pubkeys mentioned (p-tags)
ETagSerials []uint64 // Serials of events referenced (e-tags)
}
// Encode serializes the EventVertex to bytes.
// Format: author(5) | kind(2) | ptag_count(varint) | [ptag_serials(5)...] | etag_count(varint) | [etag_serials(5)...]
func (ev *EventVertex) Encode() []byte {
// Calculate size
size := 5 + 2 + 2 + len(ev.PTagSerials)*5 + 2 + len(ev.ETagSerials)*5
buf := make([]byte, 0, size)
// Author serial (5 bytes)
authorBuf := make([]byte, 5)
encodeUint40(ev.AuthorSerial, authorBuf)
buf = append(buf, authorBuf...)
// Kind (2 bytes)
kindBuf := make([]byte, 2)
binary.BigEndian.PutUint16(kindBuf, ev.Kind)
buf = append(buf, kindBuf...)
// P-tag count and serials
ptagCountBuf := make([]byte, 2)
binary.BigEndian.PutUint16(ptagCountBuf, uint16(len(ev.PTagSerials)))
buf = append(buf, ptagCountBuf...)
for _, serial := range ev.PTagSerials {
serialBuf := make([]byte, 5)
encodeUint40(serial, serialBuf)
buf = append(buf, serialBuf...)
}
// E-tag count and serials
etagCountBuf := make([]byte, 2)
binary.BigEndian.PutUint16(etagCountBuf, uint16(len(ev.ETagSerials)))
buf = append(buf, etagCountBuf...)
for _, serial := range ev.ETagSerials {
serialBuf := make([]byte, 5)
encodeUint40(serial, serialBuf)
buf = append(buf, serialBuf...)
}
return buf
}
// Decode deserializes bytes into an EventVertex.
func (ev *EventVertex) Decode(data []byte) error {
if len(data) < 9 { // minimum: author(5) + kind(2) + ptag_count(2)
return io.ErrUnexpectedEOF
}
reader := bytes.NewReader(data)
// Author serial
authorBuf := make([]byte, 5)
if _, err := reader.Read(authorBuf); err != nil {
return err
}
ev.AuthorSerial = decodeUint40(authorBuf)
// Kind
kindBuf := make([]byte, 2)
if _, err := reader.Read(kindBuf); err != nil {
return err
}
ev.Kind = binary.BigEndian.Uint16(kindBuf)
// P-tags
ptagCountBuf := make([]byte, 2)
if _, err := reader.Read(ptagCountBuf); err != nil {
return err
}
ptagCount := binary.BigEndian.Uint16(ptagCountBuf)
ev.PTagSerials = make([]uint64, ptagCount)
for i := uint16(0); i < ptagCount; i++ {
serialBuf := make([]byte, 5)
if _, err := reader.Read(serialBuf); err != nil {
return err
}
ev.PTagSerials[i] = decodeUint40(serialBuf)
}
// E-tags
etagCountBuf := make([]byte, 2)
if _, err := reader.Read(etagCountBuf); err != nil {
return err
}
etagCount := binary.BigEndian.Uint16(etagCountBuf)
ev.ETagSerials = make([]uint64, etagCount)
for i := uint16(0); i < etagCount; i++ {
serialBuf := make([]byte, 5)
if _, err := reader.Read(serialBuf); err != nil {
return err
}
ev.ETagSerials[i] = decodeUint40(serialBuf)
}
return nil
}
// PubkeyVertex stores the adjacency list for a pubkey.
// Contains all events authored by or mentioning this pubkey.
type PubkeyVertex struct {
AuthoredEvents []uint64 // Event serials this pubkey authored
MentionedIn []uint64 // Event serials that mention this pubkey (p-tags)
}
// Encode serializes the PubkeyVertex to bytes.
// Format: authored_count(varint) | [serials(5)...] | mentioned_count(varint) | [serials(5)...]
func (pv *PubkeyVertex) Encode() []byte {
size := 2 + len(pv.AuthoredEvents)*5 + 2 + len(pv.MentionedIn)*5
buf := make([]byte, 0, size)
// Authored events
authoredCountBuf := make([]byte, 2)
binary.BigEndian.PutUint16(authoredCountBuf, uint16(len(pv.AuthoredEvents)))
buf = append(buf, authoredCountBuf...)
for _, serial := range pv.AuthoredEvents {
serialBuf := make([]byte, 5)
encodeUint40(serial, serialBuf)
buf = append(buf, serialBuf...)
}
// Mentioned in events
mentionedCountBuf := make([]byte, 2)
binary.BigEndian.PutUint16(mentionedCountBuf, uint16(len(pv.MentionedIn)))
buf = append(buf, mentionedCountBuf...)
for _, serial := range pv.MentionedIn {
serialBuf := make([]byte, 5)
encodeUint40(serial, serialBuf)
buf = append(buf, serialBuf...)
}
return buf
}
// Decode deserializes bytes into a PubkeyVertex.
func (pv *PubkeyVertex) Decode(data []byte) error {
if len(data) < 4 { // minimum: authored_count(2) + mentioned_count(2)
return io.ErrUnexpectedEOF
}
reader := bytes.NewReader(data)
// Authored events
authoredCountBuf := make([]byte, 2)
if _, err := reader.Read(authoredCountBuf); err != nil {
return err
}
authoredCount := binary.BigEndian.Uint16(authoredCountBuf)
pv.AuthoredEvents = make([]uint64, authoredCount)
for i := uint16(0); i < authoredCount; i++ {
serialBuf := make([]byte, 5)
if _, err := reader.Read(serialBuf); err != nil {
return err
}
pv.AuthoredEvents[i] = decodeUint40(serialBuf)
}
// Mentioned in events
mentionedCountBuf := make([]byte, 2)
if _, err := reader.Read(mentionedCountBuf); err != nil {
return err
}
mentionedCount := binary.BigEndian.Uint16(mentionedCountBuf)
pv.MentionedIn = make([]uint64, mentionedCount)
for i := uint16(0); i < mentionedCount; i++ {
serialBuf := make([]byte, 5)
if _, err := reader.Read(serialBuf); err != nil {
return err
}
pv.MentionedIn[i] = decodeUint40(serialBuf)
}
return nil
}
// AddAuthored adds an event serial to the authored list if not already present.
func (pv *PubkeyVertex) AddAuthored(eventSerial uint64) {
for _, s := range pv.AuthoredEvents {
if s == eventSerial {
return
}
}
pv.AuthoredEvents = append(pv.AuthoredEvents, eventSerial)
}
// AddMention adds an event serial to the mentioned list if not already present.
func (pv *PubkeyVertex) AddMention(eventSerial uint64) {
for _, s := range pv.MentionedIn {
if s == eventSerial {
return
}
}
pv.MentionedIn = append(pv.MentionedIn, eventSerial)
}
// RemoveAuthored removes an event serial from the authored list.
func (pv *PubkeyVertex) RemoveAuthored(eventSerial uint64) {
for i, s := range pv.AuthoredEvents {
if s == eventSerial {
pv.AuthoredEvents = append(pv.AuthoredEvents[:i], pv.AuthoredEvents[i+1:]...)
return
}
}
}
// RemoveMention removes an event serial from the mentioned list.
func (pv *PubkeyVertex) RemoveMention(eventSerial uint64) {
for i, s := range pv.MentionedIn {
if s == eventSerial {
pv.MentionedIn = append(pv.MentionedIn[:i], pv.MentionedIn[i+1:]...)
return
}
}
}
// HasAuthored checks if the pubkey authored the given event.
func (pv *PubkeyVertex) HasAuthored(eventSerial uint64) bool {
for _, s := range pv.AuthoredEvents {
if s == eventSerial {
return true
}
}
return false
}
// IsMentionedIn checks if the pubkey is mentioned in the given event.
func (pv *PubkeyVertex) IsMentionedIn(eventSerial uint64) bool {
for _, s := range pv.MentionedIn {
if s == eventSerial {
return true
}
}
return false
}

119
pkg/bbolt/helpers.go

@ -1,119 +0,0 @@ @@ -1,119 +0,0 @@
//go:build !(js && wasm)
package bbolt
import (
"encoding/binary"
)
// encodeUint40 encodes a uint64 as 5 bytes (big-endian, truncated to 40 bits)
func encodeUint40(v uint64, buf []byte) {
buf[0] = byte(v >> 32)
buf[1] = byte(v >> 24)
buf[2] = byte(v >> 16)
buf[3] = byte(v >> 8)
buf[4] = byte(v)
}
// decodeUint40 decodes 5 bytes as uint64
func decodeUint40(buf []byte) uint64 {
return uint64(buf[0])<<32 |
uint64(buf[1])<<24 |
uint64(buf[2])<<16 |
uint64(buf[3])<<8 |
uint64(buf[4])
}
// encodeUint64 encodes a uint64 as 8 bytes (big-endian)
func encodeUint64(v uint64, buf []byte) {
binary.BigEndian.PutUint64(buf, v)
}
// decodeUint64 decodes 8 bytes as uint64
func decodeUint64(buf []byte) uint64 {
return binary.BigEndian.Uint64(buf)
}
// encodeUint32 encodes a uint32 as 4 bytes (big-endian)
func encodeUint32(v uint32, buf []byte) {
binary.BigEndian.PutUint32(buf, v)
}
// decodeUint32 decodes 4 bytes as uint32
func decodeUint32(buf []byte) uint32 {
return binary.BigEndian.Uint32(buf)
}
// encodeUint16 encodes a uint16 as 2 bytes (big-endian)
func encodeUint16(v uint16, buf []byte) {
binary.BigEndian.PutUint16(buf, v)
}
// decodeUint16 decodes 2 bytes as uint16
func decodeUint16(buf []byte) uint16 {
return binary.BigEndian.Uint16(buf)
}
// encodeVarint encodes a uint64 as a variable-length integer
// Returns the number of bytes written
func encodeVarint(v uint64, buf []byte) int {
return binary.PutUvarint(buf, v)
}
// decodeVarint decodes a variable-length integer
// Returns the value and the number of bytes read
func decodeVarint(buf []byte) (uint64, int) {
return binary.Uvarint(buf)
}
// makeSerialKey creates a 5-byte key from a serial number
func makeSerialKey(serial uint64) []byte {
key := make([]byte, 5)
encodeUint40(serial, key)
return key
}
// makePubkeyHashKey creates an 8-byte key from a pubkey hash
func makePubkeyHashKey(hash []byte) []byte {
key := make([]byte, 8)
copy(key, hash[:8])
return key
}
// makeIdHashKey creates an 8-byte key from an event ID hash
func makeIdHashKey(id []byte) []byte {
key := make([]byte, 8)
copy(key, id[:8])
return key
}
// hashPubkey returns the first 8 bytes of a 32-byte pubkey as a hash
func hashPubkey(pubkey []byte) []byte {
if len(pubkey) < 8 {
return pubkey
}
return pubkey[:8]
}
// hashEventId returns the first 8 bytes of a 32-byte event ID as a hash
func hashEventId(id []byte) []byte {
if len(id) < 8 {
return id
}
return id[:8]
}
// concatenate joins multiple byte slices into one
func concatenate(slices ...[]byte) []byte {
var totalLen int
for _, s := range slices {
totalLen += len(s)
}
result := make([]byte, totalLen)
var offset int
for _, s := range slices {
copy(result[offset:], s)
offset += len(s)
}
return result
}

66
pkg/bbolt/identity.go

@ -1,66 +0,0 @@ @@ -1,66 +0,0 @@
//go:build !(js && wasm)
package bbolt
import (
"crypto/rand"
"errors"
bolt "go.etcd.io/bbolt"
)
const identityKey = "relay_identity_secret"
// GetRelayIdentitySecret gets the relay's identity secret key.
func (b *B) GetRelayIdentitySecret() (skb []byte, err error) {
err = b.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketMeta)
if bucket == nil {
return errors.New("bbolt: meta bucket not found")
}
data := bucket.Get([]byte(identityKey))
if data == nil {
return errors.New("bbolt: relay identity not set")
}
skb = make([]byte, len(data))
copy(skb, data)
return nil
})
return
}
// SetRelayIdentitySecret sets the relay's identity secret key.
func (b *B) SetRelayIdentitySecret(skb []byte) error {
if len(skb) != 32 {
return errors.New("bbolt: invalid secret key length (must be 32 bytes)")
}
return b.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketMeta)
if bucket == nil {
return errors.New("bbolt: meta bucket not found")
}
return bucket.Put([]byte(identityKey), skb)
})
}
// GetOrCreateRelayIdentitySecret gets or creates the relay's identity secret.
func (b *B) GetOrCreateRelayIdentitySecret() (skb []byte, err error) {
// Try to get existing secret
skb, err = b.GetRelayIdentitySecret()
if err == nil && len(skb) == 32 {
return skb, nil
}
// Generate new secret
skb = make([]byte, 32)
if _, err = rand.Read(skb); err != nil {
return nil, err
}
// Store it
if err = b.SetRelayIdentitySecret(skb); err != nil {
return nil, err
}
return skb, nil
}

306
pkg/bbolt/import-export.go

@ -1,306 +0,0 @@ @@ -1,306 +0,0 @@
//go:build !(js && wasm)
package bbolt
import (
"bufio"
"context"
"io"
"os"
"runtime/debug"
"strings"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"git.mleku.dev/mleku/nostr/encoders/event"
)
const maxLen = 500000000
// ImportEventsFromReader imports events from an io.Reader containing JSONL data
func (b *B) ImportEventsFromReader(ctx context.Context, rr io.Reader) error {
startTime := time.Now()
log.I.F("bbolt import: starting import operation")
// Store to disk so we can return fast
tmpPath := os.TempDir() + string(os.PathSeparator) + "orly"
os.MkdirAll(tmpPath, 0700)
tmp, err := os.CreateTemp(tmpPath, "")
if chk.E(err) {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // Clean up temp file when done
log.I.F("bbolt import: buffering upload to %s", tmpName)
bufferStart := time.Now()
bytesBuffered, err := io.Copy(tmp, rr)
if chk.E(err) {
return err
}
bufferElapsed := time.Since(bufferStart)
log.I.F("bbolt import: buffered %.2f MB in %v (%.2f MB/sec)",
float64(bytesBuffered)/1024/1024, bufferElapsed.Round(time.Millisecond),
float64(bytesBuffered)/bufferElapsed.Seconds()/1024/1024)
if _, err = tmp.Seek(0, 0); chk.E(err) {
return err
}
count, processErr := b.processJSONLEventsReturningCount(ctx, tmp)
// Close temp file to release resources before index building
tmp.Close()
if processErr != nil {
return processErr
}
// Build indexes after events are stored (minimal import mode)
if count > 0 {
// Force garbage collection to reclaim memory before index building
debug.FreeOSMemory()
log.I.F("bbolt import: building indexes for %d events...", count)
if err := b.BuildIndexes(ctx); err != nil {
log.E.F("bbolt import: failed to build indexes: %v", err)
return err
}
}
totalElapsed := time.Since(startTime)
log.I.F("bbolt import: total operation time: %v", totalElapsed.Round(time.Millisecond))
return nil
}
// ImportEventsFromStrings imports events from a slice of JSON strings with policy filtering
func (b *B) ImportEventsFromStrings(ctx context.Context, eventJSONs []string, policyManager interface {
CheckPolicy(action string, ev *event.E, pubkey []byte, remote string) (bool, error)
}) error {
// Create a reader from the string slice
reader := strings.NewReader(strings.Join(eventJSONs, "\n"))
return b.processJSONLEventsWithPolicy(ctx, reader, policyManager)
}
// processJSONLEvents processes JSONL events from a reader
func (b *B) processJSONLEvents(ctx context.Context, rr io.Reader) error {
_, err := b.processJSONLEventsReturningCount(ctx, rr)
return err
}
// processJSONLEventsReturningCount processes JSONL events and returns the count saved
// This is used by ImportEventsFromReader for migration mode (minimal import without inline indexes)
func (b *B) processJSONLEventsReturningCount(ctx context.Context, rr io.Reader) (int, error) {
// Create a scanner to read the buffer line by line
scan := bufio.NewScanner(rr)
scanBuf := make([]byte, maxLen)
scan.Buffer(scanBuf, maxLen)
// Performance tracking
startTime := time.Now()
lastLogTime := startTime
const logInterval = 5 * time.Second
var count, total, skipped, unmarshalErrors, saveErrors int
for scan.Scan() {
select {
case <-ctx.Done():
log.I.F("bbolt import: context closed after %d events", count)
return count, ctx.Err()
default:
}
line := scan.Bytes()
total += len(line) + 1
if len(line) < 1 {
skipped++
continue
}
ev := event.New()
if _, err := ev.Unmarshal(line); err != nil {
ev.Free()
unmarshalErrors++
log.W.F("bbolt import: failed to unmarshal event: %v", err)
continue
}
// Minimal path for migration: store events only, indexes built later
if err := b.SaveEventMinimal(ev); err != nil {
ev.Free()
saveErrors++
log.W.F("bbolt import: failed to save event: %v", err)
continue
}
ev.Free()
line = nil
count++
// Progress logging every logInterval
if time.Since(lastLogTime) >= logInterval {
elapsed := time.Since(startTime)
eventsPerSec := float64(count) / elapsed.Seconds()
mbPerSec := float64(total) / elapsed.Seconds() / 1024 / 1024
log.I.F("bbolt import: progress %d events saved, %.2f MB read, %.0f events/sec, %.2f MB/sec",
count, float64(total)/1024/1024, eventsPerSec, mbPerSec)
lastLogTime = time.Now()
debug.FreeOSMemory()
}
}
// Flush any remaining batched events
if b.batcher != nil {
b.batcher.Flush()
}
// Final summary
elapsed := time.Since(startTime)
eventsPerSec := float64(count) / elapsed.Seconds()
mbPerSec := float64(total) / elapsed.Seconds() / 1024 / 1024
log.I.F("bbolt import: completed - %d events saved, %.2f MB in %v (%.0f events/sec, %.2f MB/sec)",
count, float64(total)/1024/1024, elapsed.Round(time.Millisecond), eventsPerSec, mbPerSec)
if unmarshalErrors > 0 || saveErrors > 0 || skipped > 0 {
log.I.F("bbolt import: stats - %d unmarshal errors, %d save errors, %d skipped empty lines",
unmarshalErrors, saveErrors, skipped)
}
if err := scan.Err(); err != nil {
return count, err
}
// Clear scanner buffer to help GC
scanBuf = nil
return count, nil
}
// processJSONLEventsWithPolicy processes JSONL events from a reader with optional policy filtering
func (b *B) processJSONLEventsWithPolicy(ctx context.Context, rr io.Reader, policyManager interface {
CheckPolicy(action string, ev *event.E, pubkey []byte, remote string) (bool, error)
}) error {
// Create a scanner to read the buffer line by line
scan := bufio.NewScanner(rr)
scanBuf := make([]byte, maxLen)
scan.Buffer(scanBuf, maxLen)
// Performance tracking
startTime := time.Now()
lastLogTime := startTime
const logInterval = 5 * time.Second
var count, total, skipped, policyRejected, unmarshalErrors, saveErrors int
for scan.Scan() {
select {
case <-ctx.Done():
log.I.F("bbolt import: context closed after %d events", count)
return ctx.Err()
default:
}
line := scan.Bytes()
total += len(line) + 1
if len(line) < 1 {
skipped++
continue
}
ev := event.New()
if _, err := ev.Unmarshal(line); err != nil {
// return the pooled buffer on error
ev.Free()
unmarshalErrors++
log.W.F("bbolt import: failed to unmarshal event: %v", err)
continue
}
// Apply policy checking if policy manager is provided
if policyManager != nil {
// For sync imports, we treat events as coming from system/trusted source
// Use nil pubkey and empty remote to indicate system-level import
allowed, policyErr := policyManager.CheckPolicy("write", ev, nil, "")
if policyErr != nil {
log.W.F("bbolt import: policy check failed for event %x: %v", ev.ID, policyErr)
ev.Free()
policyRejected++
continue
}
if !allowed {
log.D.F("bbolt import: policy rejected event %x during sync import", ev.ID)
ev.Free()
policyRejected++
continue
}
log.D.F("bbolt import: policy allowed event %x during sync import", ev.ID)
// With policy checking, use regular SaveEvent path
if _, err := b.SaveEvent(ctx, ev); err != nil {
ev.Free()
saveErrors++
log.W.F("bbolt import: failed to save event: %v", err)
continue
}
} else {
// Minimal path for migration: store events only, build indexes later
if err := b.SaveEventMinimal(ev); err != nil {
ev.Free()
saveErrors++
log.W.F("bbolt import: failed to save event: %v", err)
continue
}
}
// return the pooled buffer after successful save
ev.Free()
line = nil
count++
// Progress logging every logInterval
if time.Since(lastLogTime) >= logInterval {
elapsed := time.Since(startTime)
eventsPerSec := float64(count) / elapsed.Seconds()
mbPerSec := float64(total) / elapsed.Seconds() / 1024 / 1024
log.I.F("bbolt import: progress %d events saved, %.2f MB read, %.0f events/sec, %.2f MB/sec",
count, float64(total)/1024/1024, eventsPerSec, mbPerSec)
lastLogTime = time.Now()
debug.FreeOSMemory()
}
}
// Flush any remaining batched events
if b.batcher != nil {
b.batcher.Flush()
}
// Final summary
elapsed := time.Since(startTime)
eventsPerSec := float64(count) / elapsed.Seconds()
mbPerSec := float64(total) / elapsed.Seconds() / 1024 / 1024
log.I.F("bbolt import: completed - %d events saved, %.2f MB in %v (%.0f events/sec, %.2f MB/sec)",
count, float64(total)/1024/1024, elapsed.Round(time.Millisecond), eventsPerSec, mbPerSec)
if unmarshalErrors > 0 || saveErrors > 0 || policyRejected > 0 || skipped > 0 {
log.I.F("bbolt import: stats - %d unmarshal errors, %d save errors, %d policy rejected, %d skipped empty lines",
unmarshalErrors, saveErrors, policyRejected, skipped)
}
if err := scan.Err(); err != nil {
return err
}
return nil
}
// Import imports events from a reader (legacy interface).
func (b *B) Import(rr io.Reader) {
ctx := context.Background()
if err := b.ImportEventsFromReader(ctx, rr); err != nil {
log.E.F("bbolt import: error: %v", err)
}
}
// Export exports events to a writer.
func (b *B) Export(c context.Context, w io.Writer, pubkeys ...[]byte) {
// TODO: Implement export functionality
log.W.F("bbolt export: not yet implemented")
}

232
pkg/bbolt/import-minimal.go

@ -1,232 +0,0 @@ @@ -1,232 +0,0 @@
//go:build !(js && wasm)
package bbolt
import (
"bytes"
"context"
"errors"
"runtime/debug"
"sort"
"time"
bolt "go.etcd.io/bbolt"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/database/bufpool"
"git.mleku.dev/mleku/nostr/encoders/event"
)
// SaveEventMinimal stores only the essential event data for fast bulk import.
// It skips all indexes - call BuildIndexes after import completes.
func (b *B) SaveEventMinimal(ev *event.E) error {
if ev == nil {
return errors.New("nil event")
}
// Reject ephemeral events
if ev.Kind >= 20000 && ev.Kind <= 29999 {
return nil
}
// Get the next serial number
serial := b.getNextEventSerial()
// Serialize event in raw binary format (not compact - preserves full pubkey)
// This allows index building to work without pubkey serial resolution
legacyBuf := bufpool.GetMedium()
defer bufpool.PutMedium(legacyBuf)
ev.MarshalBinary(legacyBuf)
eventData := bufpool.CopyBytes(legacyBuf)
// Create minimal batch - only event data and ID mappings
batch := &EventBatch{
Serial: serial,
EventData: eventData,
Indexes: []BatchedWrite{
// Event ID -> Serial (for lookups)
{BucketName: bucketEid, Key: ev.ID[:], Value: makeSerialKey(serial)},
// Serial -> Event ID (for reverse lookups)
{BucketName: bucketSei, Key: makeSerialKey(serial), Value: ev.ID[:]},
},
}
return b.batcher.Add(batch)
}
// BuildIndexes builds all query indexes from stored events.
// Call this after importing events with SaveEventMinimal.
// Processes events in chunks to avoid OOM on large databases.
func (b *B) BuildIndexes(ctx context.Context) error {
log.I.F("bbolt: starting index build...")
startTime := time.Now()
// Force GC before starting to reclaim batch buffer memory
debug.FreeOSMemory()
// Process in small chunks to avoid OOM on memory-constrained systems
// With ~15 indexes per event and ~50 bytes per key, 50k events = ~37.5MB per chunk
const chunkSize = 50000
var totalEvents int
var lastSerial uint64 = 0
var lastLogTime = time.Now()
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// Collect indexes for this chunk
indexesByBucket := make(map[string][][]byte)
var chunkEvents int
var chunkSerial uint64
// Read a chunk of events
err := b.db.View(func(tx *bolt.Tx) error {
cmpBucket := tx.Bucket(bucketCmp)
if cmpBucket == nil {
return errors.New("cmp bucket not found")
}
cursor := cmpBucket.Cursor()
// Seek to start position
var k, v []byte
if lastSerial == 0 {
k, v = cursor.First()
} else {
// Seek past the last processed serial
seekKey := makeSerialKey(lastSerial + 1)
k, v = cursor.Seek(seekKey)
}
for ; k != nil && chunkEvents < chunkSize; k, v = cursor.Next() {
serial := decodeSerialKey(k)
chunkSerial = serial
// Decode event from raw binary format
ev := event.New()
if err := ev.UnmarshalBinary(bytes.NewBuffer(v)); err != nil {
log.W.F("bbolt: failed to unmarshal event at serial %d: %v", serial, err)
continue
}
// Generate indexes for this event
rawIdxs, err := database.GetIndexesForEvent(ev, serial)
if chk.E(err) {
ev.Free()
continue
}
// Group by bucket (first 3 bytes)
for _, idx := range rawIdxs {
if len(idx) < 3 {
continue
}
bucketName := string(idx[:3])
key := idx[3:]
// Skip eid and sei - already stored during import
if bucketName == "eid" || bucketName == "sei" {
continue
}
// Make a copy of the key
keyCopy := make([]byte, len(key))
copy(keyCopy, key)
indexesByBucket[bucketName] = append(indexesByBucket[bucketName], keyCopy)
}
ev.Free()
chunkEvents++
}
return nil
})
if err != nil {
return err
}
// No more events to process
if chunkEvents == 0 {
break
}
totalEvents += chunkEvents
lastSerial = chunkSerial
// Progress logging
if time.Since(lastLogTime) >= 5*time.Second {
log.I.F("bbolt: index build progress: %d events processed", totalEvents)
lastLogTime = time.Now()
}
// Count total keys in this chunk
var totalKeys int
for _, keys := range indexesByBucket {
totalKeys += len(keys)
}
log.I.F("bbolt: writing %d index keys for chunk (%d events)", totalKeys, chunkEvents)
// Write this chunk's indexes
for bucketName, keys := range indexesByBucket {
if len(keys) == 0 {
continue
}
bucketBytes := []byte(bucketName)
// Sort keys for this bucket before writing
sort.Slice(keys, func(i, j int) bool {
return bytes.Compare(keys[i], keys[j]) < 0
})
// Write in batches
const batchSize = 50000
for i := 0; i < len(keys); i += batchSize {
end := i + batchSize
if end > len(keys) {
end = len(keys)
}
batch := keys[i:end]
err := b.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketBytes)
if bucket == nil {
return nil
}
for _, key := range batch {
if err := bucket.Put(key, nil); err != nil {
return err
}
}
return nil
})
if err != nil {
log.E.F("bbolt: failed to write batch for bucket %s: %v", bucketName, err)
return err
}
}
}
// Clear for next chunk and release memory
indexesByBucket = nil
debug.FreeOSMemory()
}
elapsed := time.Since(startTime)
log.I.F("bbolt: index build complete in %v (%d events)", elapsed.Round(time.Second), totalEvents)
return nil
}
// decodeSerialKey decodes a 5-byte serial key to uint64
func decodeSerialKey(b []byte) uint64 {
if len(b) < 5 {
return 0
}
return uint64(b[0])<<32 | uint64(b[1])<<24 | uint64(b[2])<<16 | uint64(b[3])<<8 | uint64(b[4])
}

55
pkg/bbolt/init.go

@ -1,55 +0,0 @@ @@ -1,55 +0,0 @@
//go:build !(js && wasm)
package bbolt
import (
"context"
"time"
"next.orly.dev/pkg/database"
)
func init() {
database.RegisterBboltFactory(newBboltFromConfig)
}
// newBboltFromConfig creates a BBolt database from DatabaseConfig
func newBboltFromConfig(
ctx context.Context,
cancel context.CancelFunc,
cfg *database.DatabaseConfig,
) (database.Database, error) {
// Convert DatabaseConfig to BboltConfig
bboltCfg := &BboltConfig{
DataDir: cfg.DataDir,
LogLevel: cfg.LogLevel,
// Use bbolt-specific settings from DatabaseConfig if present
// These will be added to DatabaseConfig later
BatchMaxEvents: cfg.BboltBatchMaxEvents,
BatchMaxBytes: cfg.BboltBatchMaxBytes,
BatchFlushTimeout: cfg.BboltFlushTimeout,
BloomSizeMB: cfg.BboltBloomSizeMB,
NoSync: cfg.BboltNoSync,
InitialMmapSize: cfg.BboltMmapSize,
}
// Apply defaults if not set
if bboltCfg.BatchMaxEvents <= 0 {
bboltCfg.BatchMaxEvents = 5000
}
if bboltCfg.BatchMaxBytes <= 0 {
bboltCfg.BatchMaxBytes = 128 * 1024 * 1024 // 128MB
}
if bboltCfg.BatchFlushTimeout <= 0 {
bboltCfg.BatchFlushTimeout = 30 * time.Second
}
if bboltCfg.BloomSizeMB <= 0 {
bboltCfg.BloomSizeMB = 16
}
if bboltCfg.InitialMmapSize <= 0 {
bboltCfg.InitialMmapSize = 8 * 1024 * 1024 * 1024 // 8GB
}
return NewWithConfig(ctx, cancel, bboltCfg)
}

81
pkg/bbolt/logger.go

@ -1,81 +0,0 @@ @@ -1,81 +0,0 @@
//go:build !(js && wasm)
package bbolt
import (
"fmt"
"runtime"
"strings"
"go.uber.org/atomic"
"lol.mleku.dev"
"lol.mleku.dev/log"
)
// Logger wraps the lol logger for BBolt
type Logger struct {
Level atomic.Int32
Label string
}
// NewLogger creates a new Logger instance
func NewLogger(level int, dataDir string) *Logger {
l := &Logger{Label: "bbolt"}
l.Level.Store(int32(level))
return l
}
// SetLogLevel updates the log level
func (l *Logger) SetLogLevel(level int) {
l.Level.Store(int32(level))
}
// Tracef logs a trace message
func (l *Logger) Tracef(format string, args ...interface{}) {
if l.Level.Load() >= int32(lol.Trace) {
s := l.Label + ": " + format
txt := fmt.Sprintf(s, args...)
_, file, line, _ := runtime.Caller(2)
log.T.F("%s\n%s:%d", strings.TrimSpace(txt), file, line)
}
}
// Debugf logs a debug message
func (l *Logger) Debugf(format string, args ...interface{}) {
if l.Level.Load() >= int32(lol.Debug) {
s := l.Label + ": " + format
txt := fmt.Sprintf(s, args...)
_, file, line, _ := runtime.Caller(2)
log.D.F("%s\n%s:%d", strings.TrimSpace(txt), file, line)
}
}
// Infof logs an info message
func (l *Logger) Infof(format string, args ...interface{}) {
if l.Level.Load() >= int32(lol.Info) {
s := l.Label + ": " + format
txt := fmt.Sprintf(s, args...)
_, file, line, _ := runtime.Caller(2)
log.I.F("%s\n%s:%d", strings.TrimSpace(txt), file, line)
}
}
// Warningf logs a warning message
func (l *Logger) Warningf(format string, args ...interface{}) {
if l.Level.Load() >= int32(lol.Warn) {
s := l.Label + ": " + format
txt := fmt.Sprintf(s, args...)
_, file, line, _ := runtime.Caller(2)
log.W.F("%s\n%s:%d", strings.TrimSpace(txt), file, line)
}
}
// Errorf logs an error message
func (l *Logger) Errorf(format string, args ...interface{}) {
if l.Level.Load() >= int32(lol.Error) {
s := l.Label + ": " + format
txt := fmt.Sprintf(s, args...)
_, file, line, _ := runtime.Caller(2)
log.E.F("%s\n%s:%d", strings.TrimSpace(txt), file, line)
}
}

62
pkg/bbolt/markers.go

@ -1,62 +0,0 @@ @@ -1,62 +0,0 @@
//go:build !(js && wasm)
package bbolt
import (
bolt "go.etcd.io/bbolt"
)
const markerPrefix = "marker:"
// SetMarker sets a metadata marker.
func (b *B) SetMarker(key string, value []byte) error {
return b.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketMeta)
if bucket == nil {
return nil
}
return bucket.Put([]byte(markerPrefix+key), value)
})
}
// GetMarker gets a metadata marker.
func (b *B) GetMarker(key string) (value []byte, err error) {
err = b.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketMeta)
if bucket == nil {
return nil
}
data := bucket.Get([]byte(markerPrefix + key))
if data != nil {
value = make([]byte, len(data))
copy(value, data)
}
return nil
})
return
}
// HasMarker checks if a marker exists.
func (b *B) HasMarker(key string) bool {
var exists bool
b.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketMeta)
if bucket == nil {
return nil
}
exists = bucket.Get([]byte(markerPrefix+key)) != nil
return nil
})
return exists
}
// DeleteMarker deletes a marker.
func (b *B) DeleteMarker(key string) error {
return b.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketMeta)
if bucket == nil {
return nil
}
return bucket.Delete([]byte(markerPrefix + key))
})
}

287
pkg/bbolt/query-graph.go

@ -1,287 +0,0 @@ @@ -1,287 +0,0 @@
//go:build !(js && wasm)
package bbolt
import (
"bytes"
bolt "go.etcd.io/bbolt"
"next.orly.dev/pkg/database/indexes/types"
)
// EdgeExists checks if an edge exists between two serials.
// Uses bloom filter for fast negative lookups.
func (b *B) EdgeExists(srcSerial, dstSerial uint64, edgeType byte) (bool, error) {
// Fast path: check bloom filter first
if !b.edgeBloom.MayExist(srcSerial, dstSerial, edgeType) {
return false, nil // Definitely doesn't exist
}
// Bloom says maybe - need to verify in adjacency list
return b.verifyEdgeInAdjacencyList(srcSerial, dstSerial, edgeType)
}
// verifyEdgeInAdjacencyList checks the adjacency list for edge existence.
func (b *B) verifyEdgeInAdjacencyList(srcSerial, dstSerial uint64, edgeType byte) (bool, error) {
var exists bool
err := b.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketEv)
if bucket == nil {
return nil
}
key := makeSerialKey(srcSerial)
data := bucket.Get(key)
if data == nil {
return nil
}
vertex := &EventVertex{}
if err := vertex.Decode(data); err != nil {
return err
}
switch edgeType {
case EdgeTypeAuthor:
exists = vertex.AuthorSerial == dstSerial
case EdgeTypePTag:
for _, s := range vertex.PTagSerials {
if s == dstSerial {
exists = true
break
}
}
case EdgeTypeETag:
for _, s := range vertex.ETagSerials {
if s == dstSerial {
exists = true
break
}
}
}
return nil
})
return exists, err
}
// GetEventVertex retrieves the adjacency list for an event.
func (b *B) GetEventVertex(eventSerial uint64) (*EventVertex, error) {
var vertex *EventVertex
err := b.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketEv)
if bucket == nil {
return nil
}
key := makeSerialKey(eventSerial)
data := bucket.Get(key)
if data == nil {
return nil
}
vertex = &EventVertex{}
return vertex.Decode(data)
})
return vertex, err
}
// GetPubkeyVertex retrieves the adjacency list for a pubkey.
func (b *B) GetPubkeyVertex(pubkeySerial uint64) (*PubkeyVertex, error) {
var vertex *PubkeyVertex
err := b.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketPv)
if bucket == nil {
return nil
}
key := makeSerialKey(pubkeySerial)
data := bucket.Get(key)
if data == nil {
return nil
}
vertex = &PubkeyVertex{}
return vertex.Decode(data)
})
return vertex, err
}
// GetEventsAuthoredBy returns event serials authored by a pubkey.
func (b *B) GetEventsAuthoredBy(pubkeySerial uint64) ([]uint64, error) {
vertex, err := b.GetPubkeyVertex(pubkeySerial)
if err != nil || vertex == nil {
return nil, err
}
return vertex.AuthoredEvents, nil
}
// GetEventsMentioning returns event serials that mention a pubkey.
func (b *B) GetEventsMentioning(pubkeySerial uint64) ([]uint64, error) {
vertex, err := b.GetPubkeyVertex(pubkeySerial)
if err != nil || vertex == nil {
return nil, err
}
return vertex.MentionedIn, nil
}
// GetPTagsFromEvent returns pubkey serials tagged in an event.
func (b *B) GetPTagsFromEvent(eventSerial uint64) ([]uint64, error) {
vertex, err := b.GetEventVertex(eventSerial)
if err != nil || vertex == nil {
return nil, err
}
return vertex.PTagSerials, nil
}
// GetETagsFromEvent returns event serials referenced by an event.
func (b *B) GetETagsFromEvent(eventSerial uint64) ([]uint64, error) {
vertex, err := b.GetEventVertex(eventSerial)
if err != nil || vertex == nil {
return nil, err
}
return vertex.ETagSerials, nil
}
// GetFollowsFromPubkeySerial returns the pubkey serials that a user follows.
// This extracts p-tags from the user's kind-3 contact list event.
func (b *B) GetFollowsFromPubkeySerial(pubkeySerial *types.Uint40) ([]*types.Uint40, error) {
if pubkeySerial == nil {
return nil, nil
}
// Find the kind-3 event for this pubkey
contactEventSerial, err := b.FindEventByAuthorAndKind(pubkeySerial.Get(), 3)
if err != nil {
return nil, nil // No kind-3 event found is not an error
}
if contactEventSerial == 0 {
return nil, nil
}
// Get the p-tags from the event vertex
pTagSerials, err := b.GetPTagsFromEvent(contactEventSerial)
if err != nil {
return nil, err
}
// Convert to types.Uint40
result := make([]*types.Uint40, 0, len(pTagSerials))
for _, s := range pTagSerials {
ser := new(types.Uint40)
ser.Set(s)
result = append(result, ser)
}
return result, nil
}
// FindEventByAuthorAndKind finds an event serial by author and kind.
// For replaceable events like kind-3, returns the most recent one.
func (b *B) FindEventByAuthorAndKind(authorSerial uint64, kindNum uint16) (uint64, error) {
var resultSerial uint64
err := b.db.View(func(tx *bolt.Tx) error {
// First, get events authored by this pubkey
pvBucket := tx.Bucket(bucketPv)
if pvBucket == nil {
return nil
}
pvKey := makeSerialKey(authorSerial)
pvData := pvBucket.Get(pvKey)
if pvData == nil {
return nil
}
vertex := &PubkeyVertex{}
if err := vertex.Decode(pvData); err != nil {
return err
}
// Search through authored events for matching kind
evBucket := tx.Bucket(bucketEv)
if evBucket == nil {
return nil
}
var latestTs int64
for _, eventSerial := range vertex.AuthoredEvents {
evKey := makeSerialKey(eventSerial)
evData := evBucket.Get(evKey)
if evData == nil {
continue
}
evVertex := &EventVertex{}
if err := evVertex.Decode(evData); err != nil {
continue
}
if evVertex.Kind == kindNum {
// For replaceable events, we need to check timestamp
// Get event to compare timestamps
fpcBucket := tx.Bucket(bucketFpc)
if fpcBucket != nil {
// Scan for matching serial prefix in fpc bucket
c := fpcBucket.Cursor()
prefix := makeSerialKey(eventSerial)
for k, _ := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, _ = c.Next() {
// Key format: serial(5) | id(32) | pubkey_hash(8) | created_at(8)
if len(k) >= 53 {
ts := int64(decodeUint64(k[45:53]))
if ts > latestTs {
latestTs = ts
resultSerial = eventSerial
}
}
break
}
} else {
// If no fpc bucket, just take the first match
resultSerial = eventSerial
}
}
}
return nil
})
return resultSerial, err
}
// GetReferencingEvents returns event serials that reference a target event via e-tag.
func (b *B) GetReferencingEvents(targetSerial uint64) ([]uint64, error) {
var result []uint64
err := b.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketEv)
if bucket == nil {
return nil
}
// Scan all event vertices looking for e-tag references
// Note: This is O(n) - for production, consider a reverse index
c := bucket.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
vertex := &EventVertex{}
if err := vertex.Decode(v); err != nil {
continue
}
for _, eTagSerial := range vertex.ETagSerials {
if eTagSerial == targetSerial {
eventSerial := decodeUint40(k)
result = append(result, eventSerial)
break
}
}
}
return nil
})
return result, err
}

96
pkg/bbolt/save-event-bulk.go

@ -1,96 +0,0 @@ @@ -1,96 +0,0 @@
//go:build !(js && wasm)
package bbolt
import (
"errors"
"lol.mleku.dev/chk"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/database/bufpool"
"git.mleku.dev/mleku/nostr/encoders/event"
)
// SaveEventForImport saves an event optimized for bulk import.
// It skips duplicate checking, deletion checking, and graph vertex creation
// to maximize import throughput. Use only for trusted data migration.
func (b *B) SaveEventForImport(ev *event.E) error {
if ev == nil {
return errors.New("nil event")
}
// Reject ephemeral events (kinds 20000-29999)
if ev.Kind >= 20000 && ev.Kind <= 29999 {
return nil // silently skip
}
// Get the next serial number
serial := b.getNextEventSerial()
// Generate all indexes using the shared function
rawIdxs, err := database.GetIndexesForEvent(ev, serial)
if chk.E(err) {
return err
}
// Convert raw indexes to BatchedWrites, stripping the 3-byte prefix
batch := &EventBatch{
Serial: serial,
Indexes: make([]BatchedWrite, 0, len(rawIdxs)+1),
}
for _, idx := range rawIdxs {
if len(idx) < 3 {
continue
}
bucketName := idx[:3]
key := idx[3:]
batch.Indexes = append(batch.Indexes, BatchedWrite{
BucketName: bucketName,
Key: key,
Value: nil,
})
}
// Serialize event in compact format (without graph references for import)
resolver := &nullSerialResolver{}
compactData, compactErr := database.MarshalCompactEvent(ev, resolver)
if compactErr != nil {
// Fall back to legacy format
legacyBuf := bufpool.GetMedium()
defer bufpool.PutMedium(legacyBuf)
ev.MarshalBinary(legacyBuf)
compactData = bufpool.CopyBytes(legacyBuf)
}
batch.EventData = compactData
// Store serial -> event ID mapping
batch.Indexes = append(batch.Indexes, BatchedWrite{
BucketName: bucketSei,
Key: makeSerialKey(serial),
Value: ev.ID[:],
})
// Add to batcher (no graph vertex, no pubkey lookups)
return b.batcher.Add(batch)
}
// nullSerialResolver returns 0 for all lookups, used for fast import
// where we don't need pubkey/event serial references in compact format
type nullSerialResolver struct{}
func (r *nullSerialResolver) GetOrCreatePubkeySerial(pubkey []byte) (uint64, error) {
return 0, nil
}
func (r *nullSerialResolver) GetPubkeyBySerial(serial uint64) ([]byte, error) {
return nil, nil
}
func (r *nullSerialResolver) GetEventSerialById(eventID []byte) (uint64, bool, error) {
return 0, false, nil
}
func (r *nullSerialResolver) GetEventIdBySerial(serial uint64) ([]byte, error) {
return nil, nil
}

393
pkg/bbolt/save-event.go

@ -1,393 +0,0 @@ @@ -1,393 +0,0 @@
//go:build !(js && wasm)
package bbolt
import (
"context"
"errors"
"fmt"
"strings"
bolt "go.etcd.io/bbolt"
"lol.mleku.dev/chk"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/database/bufpool"
"next.orly.dev/pkg/database/indexes/types"
"next.orly.dev/pkg/mode"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/filter"
"git.mleku.dev/mleku/nostr/encoders/hex"
"git.mleku.dev/mleku/nostr/encoders/kind"
"git.mleku.dev/mleku/nostr/encoders/tag"
)
// SaveEvent saves an event to the database using the write batcher.
func (b *B) SaveEvent(c context.Context, ev *event.E) (replaced bool, err error) {
if ev == nil {
err = errors.New("nil event")
return
}
// Reject ephemeral events (kinds 20000-29999)
if ev.Kind >= 20000 && ev.Kind <= 29999 {
err = errors.New("blocked: ephemeral events should not be stored")
return
}
// Validate kind 3 (follow list) events have at least one p tag
if ev.Kind == 3 {
hasPTag := false
if ev.Tags != nil {
for _, tag := range *ev.Tags {
if tag != nil && tag.Len() >= 2 {
key := tag.Key()
if len(key) == 1 && key[0] == 'p' {
hasPTag = true
break
}
}
}
}
if !hasPTag {
err = errors.New("blocked: kind 3 follow list events must have at least one p tag")
return
}
}
// Check if the event already exists
var ser *types.Uint40
if ser, err = b.GetSerialById(ev.ID); err == nil && ser != nil {
err = errors.New("blocked: event already exists: " + hex.Enc(ev.ID[:]))
return
}
// If the error is "id not found", we can proceed
if err != nil && strings.Contains(err.Error(), "id not found") {
err = nil
} else if err != nil {
return
}
// Check if the event has been deleted
if !mode.IsOpen() {
if err = b.CheckForDeleted(ev, nil); err != nil {
err = fmt.Errorf("blocked: %s", err.Error())
return
}
}
// Check for replacement
if kind.IsReplaceable(ev.Kind) || kind.IsParameterizedReplaceable(ev.Kind) {
var werr error
if replaced, _, werr = b.WouldReplaceEvent(ev); werr != nil {
if errors.Is(werr, database.ErrOlderThanExisting) {
if kind.IsReplaceable(ev.Kind) {
err = errors.New("blocked: event is older than existing replaceable event")
} else {
err = errors.New("blocked: event is older than existing addressable event")
}
return
}
if errors.Is(werr, database.ErrMissingDTag) {
err = database.ErrMissingDTag
return
}
return
}
}
// Get the next serial number
serial := b.getNextEventSerial()
// Generate all indexes using the shared function
var rawIdxs [][]byte
if rawIdxs, err = database.GetIndexesForEvent(ev, serial); chk.E(err) {
return
}
// Convert raw indexes to BatchedWrites, stripping the 3-byte prefix
// since we use separate buckets
batch := &EventBatch{
Serial: serial,
Indexes: make([]BatchedWrite, 0, len(rawIdxs)),
}
for _, idx := range rawIdxs {
if len(idx) < 3 {
continue
}
// Get bucket name from prefix
bucketName := idx[:3]
key := idx[3:] // Key without prefix
batch.Indexes = append(batch.Indexes, BatchedWrite{
BucketName: bucketName,
Key: key,
Value: nil, // Index entries have empty values
})
}
// Serialize event in compact format
resolver := &bboltSerialResolver{b: b}
compactData, compactErr := database.MarshalCompactEvent(ev, resolver)
if compactErr != nil {
// Fall back to legacy format
legacyBuf := bufpool.GetMedium()
defer bufpool.PutMedium(legacyBuf)
ev.MarshalBinary(legacyBuf)
compactData = bufpool.CopyBytes(legacyBuf)
}
batch.EventData = compactData
// Build event vertex for adjacency list
var authorSerial uint64
err = b.db.Update(func(tx *bolt.Tx) error {
var e error
authorSerial, e = b.getOrCreatePubkeySerial(tx, ev.Pubkey)
return e
})
if chk.E(err) {
return
}
eventVertex := &EventVertex{
AuthorSerial: authorSerial,
Kind: uint16(ev.Kind),
PTagSerials: make([]uint64, 0),
ETagSerials: make([]uint64, 0),
}
// Collect edge keys for bloom filter
edgeKeys := make([]EdgeKey, 0)
// Add author edge to bloom filter
edgeKeys = append(edgeKeys, EdgeKey{
SrcSerial: serial,
DstSerial: authorSerial,
EdgeType: EdgeTypeAuthor,
})
// Set up pubkey vertex update for author
batch.PubkeyUpdate = &PubkeyVertexUpdate{
PubkeySerial: authorSerial,
AddAuthored: serial,
}
// Process p-tags
batch.MentionUpdates = make([]*PubkeyVertexUpdate, 0)
pTags := ev.Tags.GetAll([]byte("p"))
for _, pTag := range pTags {
if pTag.Len() >= 2 {
var ptagPubkey []byte
if ptagPubkey, err = hex.Dec(string(pTag.ValueHex())); err == nil && len(ptagPubkey) == 32 {
var ptagSerial uint64
err = b.db.Update(func(tx *bolt.Tx) error {
var e error
ptagSerial, e = b.getOrCreatePubkeySerial(tx, ptagPubkey)
return e
})
if chk.E(err) {
continue
}
eventVertex.PTagSerials = append(eventVertex.PTagSerials, ptagSerial)
// Add p-tag edge to bloom filter
edgeKeys = append(edgeKeys, EdgeKey{
SrcSerial: serial,
DstSerial: ptagSerial,
EdgeType: EdgeTypePTag,
})
// Add mention update for this pubkey
batch.MentionUpdates = append(batch.MentionUpdates, &PubkeyVertexUpdate{
PubkeySerial: ptagSerial,
AddMention: serial,
})
}
}
}
// Process e-tags
eTags := ev.Tags.GetAll([]byte("e"))
for _, eTag := range eTags {
if eTag.Len() >= 2 {
var targetEventID []byte
if targetEventID, err = hex.Dec(string(eTag.ValueHex())); err != nil || len(targetEventID) != 32 {
continue
}
// Look up the target event's serial
var targetSerial *types.Uint40
if targetSerial, err = b.GetSerialById(targetEventID); err != nil {
err = nil
continue
}
targetSer := targetSerial.Get()
eventVertex.ETagSerials = append(eventVertex.ETagSerials, targetSer)
// Add e-tag edge to bloom filter
edgeKeys = append(edgeKeys, EdgeKey{
SrcSerial: serial,
DstSerial: targetSer,
EdgeType: EdgeTypeETag,
})
}
}
batch.EventVertex = eventVertex
batch.EdgeKeys = edgeKeys
// Store serial -> event ID mapping
batch.Indexes = append(batch.Indexes, BatchedWrite{
BucketName: bucketSei,
Key: makeSerialKey(serial),
Value: ev.ID[:],
})
// Add to batcher
if err = b.batcher.Add(batch); chk.E(err) {
return
}
// Process deletion events
if ev.Kind == kind.Deletion.K {
if err = b.ProcessDelete(ev, nil); chk.E(err) {
b.Logger.Warningf("failed to process deletion for event %x: %v", ev.ID, err)
err = nil
}
}
return
}
// GetSerialsFromFilter returns serials matching a filter.
func (b *B) GetSerialsFromFilter(f *filter.F) (sers types.Uint40s, err error) {
var idxs []database.Range
if idxs, err = database.GetIndexesFromFilter(f); chk.E(err) {
return
}
sers = make(types.Uint40s, 0, len(idxs)*100)
for _, idx := range idxs {
var s types.Uint40s
if s, err = b.GetSerialsByRange(idx); chk.E(err) {
continue
}
sers = append(sers, s...)
}
return
}
// WouldReplaceEvent checks if the event would replace existing events.
func (b *B) WouldReplaceEvent(ev *event.E) (bool, types.Uint40s, error) {
if !(kind.IsReplaceable(ev.Kind) || kind.IsParameterizedReplaceable(ev.Kind)) {
return false, nil, nil
}
var f *filter.F
if kind.IsReplaceable(ev.Kind) {
f = &filter.F{
Authors: tag.NewFromBytesSlice(ev.Pubkey),
Kinds: kind.NewS(kind.New(ev.Kind)),
}
} else {
dTag := ev.Tags.GetFirst([]byte("d"))
if dTag == nil {
return false, nil, database.ErrMissingDTag
}
f = &filter.F{
Authors: tag.NewFromBytesSlice(ev.Pubkey),
Kinds: kind.NewS(kind.New(ev.Kind)),
Tags: tag.NewS(
tag.NewFromAny("d", dTag.Value()),
),
}
}
sers, err := b.GetSerialsFromFilter(f)
if chk.E(err) {
return false, nil, err
}
if len(sers) == 0 {
return false, nil, nil
}
shouldReplace := true
for _, s := range sers {
oldEv, ferr := b.FetchEventBySerial(s)
if chk.E(ferr) {
continue
}
if ev.CreatedAt < oldEv.CreatedAt {
shouldReplace = false
break
}
}
if shouldReplace {
return true, nil, nil
}
return false, nil, database.ErrOlderThanExisting
}
// bboltSerialResolver implements database.SerialResolver for compact event encoding
type bboltSerialResolver struct {
b *B
}
func (r *bboltSerialResolver) GetOrCreatePubkeySerial(pubkey []byte) (serial uint64, err error) {
err = r.b.db.Update(func(tx *bolt.Tx) error {
var e error
serial, e = r.b.getOrCreatePubkeySerial(tx, pubkey)
return e
})
return
}
func (r *bboltSerialResolver) GetPubkeyBySerial(serial uint64) (pubkey []byte, err error) {
r.b.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketSpk)
if bucket == nil {
err = errors.New("bbolt: spk bucket not found")
return nil
}
val := bucket.Get(makeSerialKey(serial))
if val != nil {
pubkey = make([]byte, 32)
copy(pubkey, val)
} else {
err = errors.New("bbolt: pubkey serial not found")
}
return nil
})
return
}
func (r *bboltSerialResolver) GetEventSerialById(eventID []byte) (serial uint64, found bool, err error) {
ser, e := r.b.GetSerialById(eventID)
if e != nil || ser == nil {
return 0, false, nil
}
return ser.Get(), true, nil
}
func (r *bboltSerialResolver) GetEventIdBySerial(serial uint64) (eventID []byte, err error) {
r.b.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketSei)
if bucket == nil {
err = errors.New("bbolt: sei bucket not found")
return nil
}
val := bucket.Get(makeSerialKey(serial))
if val != nil {
eventID = make([]byte, 32)
copy(eventID, val)
} else {
err = errors.New("bbolt: event serial not found")
}
return nil
})
return
}

169
pkg/bbolt/serial.go

@ -1,169 +0,0 @@ @@ -1,169 +0,0 @@
//go:build !(js && wasm)
package bbolt
import (
"encoding/binary"
bolt "go.etcd.io/bbolt"
"lol.mleku.dev/chk"
)
const (
serialCounterKey = "serial_counter"
pubkeySerialCounterKey = "pubkey_serial_counter"
)
// initSerialCounters initializes or loads the serial counters from _meta bucket
func (b *B) initSerialCounters() error {
return b.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketMeta)
if bucket == nil {
return nil
}
// Load event serial counter
val := bucket.Get([]byte(serialCounterKey))
if val == nil {
b.nextSerial = 1
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, 1)
if err := bucket.Put([]byte(serialCounterKey), buf); err != nil {
return err
}
} else {
b.nextSerial = binary.BigEndian.Uint64(val)
}
// Load pubkey serial counter
val = bucket.Get([]byte(pubkeySerialCounterKey))
if val == nil {
b.nextPubkeySeq = 1
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, 1)
if err := bucket.Put([]byte(pubkeySerialCounterKey), buf); err != nil {
return err
}
} else {
b.nextPubkeySeq = binary.BigEndian.Uint64(val)
}
return nil
})
}
// persistSerialCounters saves the current serial counters to disk
func (b *B) persistSerialCounters() error {
b.serialMu.Lock()
eventSerial := b.nextSerial
pubkeySerial := b.nextPubkeySeq
b.serialMu.Unlock()
return b.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket(bucketMeta)
if bucket == nil {
return nil
}
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, eventSerial)
if err := bucket.Put([]byte(serialCounterKey), buf); chk.E(err) {
return err
}
binary.BigEndian.PutUint64(buf, pubkeySerial)
if err := bucket.Put([]byte(pubkeySerialCounterKey), buf); chk.E(err) {
return err
}
return nil
})
}
// getNextEventSerial returns the next event serial number (thread-safe)
func (b *B) getNextEventSerial() uint64 {
b.serialMu.Lock()
defer b.serialMu.Unlock()
serial := b.nextSerial
b.nextSerial++
// Persist every 1000 to reduce disk writes
if b.nextSerial%1000 == 0 {
go func() {
if err := b.persistSerialCounters(); chk.E(err) {
b.Logger.Warningf("bbolt: failed to persist serial counters: %v", err)
}
}()
}
return serial
}
// getNextPubkeySerial returns the next pubkey serial number (thread-safe)
func (b *B) getNextPubkeySerial() uint64 {
b.serialMu.Lock()
defer b.serialMu.Unlock()
serial := b.nextPubkeySeq
b.nextPubkeySeq++
// Persist every 1000 to reduce disk writes
if b.nextPubkeySeq%1000 == 0 {
go func() {
if err := b.persistSerialCounters(); chk.E(err) {
b.Logger.Warningf("bbolt: failed to persist serial counters: %v", err)
}
}()
}
return serial
}
// getOrCreatePubkeySerial gets or creates a serial for a pubkey
func (b *B) getOrCreatePubkeySerial(tx *bolt.Tx, pubkey []byte) (uint64, error) {
pksBucket := tx.Bucket(bucketPks)
spkBucket := tx.Bucket(bucketSpk)
if pksBucket == nil || spkBucket == nil {
return 0, nil
}
// Check if pubkey already has a serial
pubkeyHash := hashPubkey(pubkey)
val := pksBucket.Get(pubkeyHash)
if val != nil {
return decodeUint40(val), nil
}
// Create new serial
serial := b.getNextPubkeySerial()
serialKey := makeSerialKey(serial)
// Store pubkey_hash -> serial
serialBuf := make([]byte, 5)
encodeUint40(serial, serialBuf)
if err := pksBucket.Put(pubkeyHash, serialBuf); err != nil {
return 0, err
}
// Store serial -> full pubkey
fullPubkey := make([]byte, 32)
copy(fullPubkey, pubkey)
if err := spkBucket.Put(serialKey, fullPubkey); err != nil {
return 0, err
}
return serial, nil
}
// getPubkeyBySerial retrieves the full 32-byte pubkey from a serial
func (b *B) getPubkeyBySerial(tx *bolt.Tx, serial uint64) ([]byte, error) {
spkBucket := tx.Bucket(bucketSpk)
if spkBucket == nil {
return nil, nil
}
serialKey := makeSerialKey(serial)
return spkBucket.Get(serialKey), nil
}

233
pkg/bbolt/stubs.go

@ -1,233 +0,0 @@ @@ -1,233 +0,0 @@
//go:build !(js && wasm)
package bbolt
import (
"context"
"errors"
"time"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/database/indexes/types"
"next.orly.dev/pkg/interfaces/store"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/filter"
)
// This file contains stub implementations for interface methods that will be
// implemented fully in later phases. It allows the code to compile while
// we implement the core functionality.
var errNotImplemented = errors.New("bbolt: not implemented yet")
// QueryEvents queries events matching a filter.
func (b *B) QueryEvents(c context.Context, f *filter.F) (evs event.S, err error) {
// Get serials matching filter
var serials types.Uint40s
if serials, err = b.GetSerialsFromFilter(f); err != nil {
return
}
// Fetch events by serials
evs = make(event.S, 0, len(serials))
for _, ser := range serials {
ev, e := b.FetchEventBySerial(ser)
if e == nil && ev != nil {
evs = append(evs, ev)
}
}
return
}
// QueryAllVersions queries all versions of events matching a filter.
func (b *B) QueryAllVersions(c context.Context, f *filter.F) (evs event.S, err error) {
return b.QueryEvents(c, f)
}
// QueryEventsWithOptions queries events with additional options.
func (b *B) QueryEventsWithOptions(c context.Context, f *filter.F, includeDeleteEvents bool, showAllVersions bool) (evs event.S, err error) {
return b.QueryEvents(c, f)
}
// QueryDeleteEventsByTargetId queries delete events targeting a specific event.
func (b *B) QueryDeleteEventsByTargetId(c context.Context, targetEventId []byte) (evs event.S, err error) {
return nil, errNotImplemented
}
// QueryForSerials queries and returns only the serials.
func (b *B) QueryForSerials(c context.Context, f *filter.F) (serials types.Uint40s, err error) {
return b.GetSerialsFromFilter(f)
}
// QueryForIds queries and returns event ID/pubkey/timestamp tuples.
func (b *B) QueryForIds(c context.Context, f *filter.F) (idPkTs []*store.IdPkTs, err error) {
var serials types.Uint40s
if serials, err = b.GetSerialsFromFilter(f); err != nil {
return
}
return b.GetFullIdPubkeyBySerials(serials)
}
// DeleteEvent deletes an event by ID.
func (b *B) DeleteEvent(c context.Context, eid []byte) error {
return errNotImplemented
}
// DeleteEventBySerial deletes an event by serial.
func (b *B) DeleteEventBySerial(c context.Context, ser *types.Uint40, ev *event.E) error {
return errNotImplemented
}
// DeleteExpired deletes expired events.
func (b *B) DeleteExpired() {
// TODO: Implement
}
// ProcessDelete processes a deletion event.
// For migration from other backends, deletions have already been processed,
// so this is a no-op. Full implementation needed for production use.
func (b *B) ProcessDelete(ev *event.E, admins [][]byte) error {
// TODO: Implement full deletion processing for production use
// For now, just return nil to allow migrations to proceed
return nil
}
// CheckForDeleted checks if an event has been deleted.
func (b *B) CheckForDeleted(ev *event.E, admins [][]byte) error {
return nil // Not deleted by default
}
// GetSubscription gets a user's subscription.
func (b *B) GetSubscription(pubkey []byte) (*database.Subscription, error) {
return nil, errNotImplemented
}
// IsSubscriptionActive checks if a subscription is active.
func (b *B) IsSubscriptionActive(pubkey []byte) (bool, error) {
return false, errNotImplemented
}
// ExtendSubscription extends a subscription.
func (b *B) ExtendSubscription(pubkey []byte, days int) error {
return errNotImplemented
}
// RecordPayment records a payment.
func (b *B) RecordPayment(pubkey []byte, amount int64, invoice, preimage string) error {
return errNotImplemented
}
// GetPaymentHistory gets payment history.
func (b *B) GetPaymentHistory(pubkey []byte) ([]database.Payment, error) {
return nil, errNotImplemented
}
// ExtendBlossomSubscription extends a Blossom subscription.
func (b *B) ExtendBlossomSubscription(pubkey []byte, tier string, storageMB int64, daysExtended int) error {
return errNotImplemented
}
// GetBlossomStorageQuota gets Blossom storage quota.
func (b *B) GetBlossomStorageQuota(pubkey []byte) (quotaMB int64, err error) {
return 0, errNotImplemented
}
// IsFirstTimeUser checks if this is a first-time user.
func (b *B) IsFirstTimeUser(pubkey []byte) (bool, error) {
return true, nil
}
// AddNIP43Member adds a NIP-43 member.
func (b *B) AddNIP43Member(pubkey []byte, inviteCode string) error {
return errNotImplemented
}
// RemoveNIP43Member removes a NIP-43 member.
func (b *B) RemoveNIP43Member(pubkey []byte) error {
return errNotImplemented
}
// IsNIP43Member checks if pubkey is a NIP-43 member.
func (b *B) IsNIP43Member(pubkey []byte) (isMember bool, err error) {
return false, errNotImplemented
}
// GetNIP43Membership gets NIP-43 membership details.
func (b *B) GetNIP43Membership(pubkey []byte) (*database.NIP43Membership, error) {
return nil, errNotImplemented
}
// GetAllNIP43Members gets all NIP-43 members.
func (b *B) GetAllNIP43Members() ([][]byte, error) {
return nil, errNotImplemented
}
// StoreInviteCode stores an invite code.
func (b *B) StoreInviteCode(code string, expiresAt time.Time) error {
return errNotImplemented
}
// ValidateInviteCode validates an invite code.
func (b *B) ValidateInviteCode(code string) (valid bool, err error) {
return false, errNotImplemented
}
// DeleteInviteCode deletes an invite code.
func (b *B) DeleteInviteCode(code string) error {
return errNotImplemented
}
// PublishNIP43MembershipEvent publishes a NIP-43 membership event.
func (b *B) PublishNIP43MembershipEvent(kind int, pubkey []byte) error {
return errNotImplemented
}
// RunMigrations runs database migrations.
func (b *B) RunMigrations() {
// TODO: Implement if needed
}
// GetCachedJSON gets cached JSON for a filter (stub - no caching in bbolt).
func (b *B) GetCachedJSON(f *filter.F) ([][]byte, bool) {
return nil, false
}
// CacheMarshaledJSON caches JSON for a filter (stub - no caching in bbolt).
func (b *B) CacheMarshaledJSON(f *filter.F, marshaledJSON [][]byte) {
// No-op: BBolt doesn't use query cache to save RAM
}
// GetCachedEvents gets cached events for a filter (stub - no caching in bbolt).
func (b *B) GetCachedEvents(f *filter.F) (event.S, bool) {
return nil, false
}
// CacheEvents caches events for a filter (stub - no caching in bbolt).
func (b *B) CacheEvents(f *filter.F, events event.S) {
// No-op: BBolt doesn't use query cache to save RAM
}
// InvalidateQueryCache invalidates the query cache (stub - no caching in bbolt).
func (b *B) InvalidateQueryCache() {
// No-op
}
// RecordEventAccess records an event access.
func (b *B) RecordEventAccess(serial uint64, connectionID string) error {
return nil // TODO: Implement if needed
}
// GetEventAccessInfo gets event access information.
func (b *B) GetEventAccessInfo(serial uint64) (lastAccess int64, accessCount uint32, err error) {
return 0, 0, errNotImplemented
}
// GetLeastAccessedEvents gets least accessed events.
func (b *B) GetLeastAccessedEvents(limit int, minAgeSec int64) (serials []uint64, err error) {
return nil, errNotImplemented
}
// EventIdsBySerial gets event IDs by serial range.
func (b *B) EventIdsBySerial(start uint64, count int) (evs []uint64, err error) {
return nil, errNotImplemented
}

4
pkg/blossom/server.go

@ -10,7 +10,7 @@ import ( @@ -10,7 +10,7 @@ import (
// Server provides a Blossom server implementation
type Server struct {
db *database.D
db database.Database
storage *Storage
acl *acl.S
baseURL string
@ -38,7 +38,7 @@ type Config struct { @@ -38,7 +38,7 @@ type Config struct {
}
// NewServer creates a new Blossom server instance
func NewServer(db *database.D, aclRegistry *acl.S, cfg *Config) *Server {
func NewServer(db database.Database, aclRegistry *acl.S, cfg *Config) *Server {
if cfg == nil {
cfg = &Config{
MaxBlobSize: 100 * 1024 * 1024, // 100MB default

547
pkg/blossom/storage.go

@ -1,41 +1,28 @@ @@ -1,41 +1,28 @@
package blossom
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"github.com/dgraph-io/badger/v4"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"lol.mleku.dev/log"
"github.com/minio/sha256-simd"
"next.orly.dev/pkg/database"
"git.mleku.dev/mleku/nostr/encoders/hex"
"next.orly.dev/pkg/utils"
)
const (
// Database key prefixes (metadata and indexes only, blob data stored as files)
prefixBlobMeta = "blob:meta:"
prefixBlobIndex = "blob:index:"
prefixBlobReport = "blob:report:"
"next.orly.dev/pkg/database"
)
// Storage provides blob storage operations
// Storage provides blob storage operations using the database interface.
// This is a thin wrapper that delegates to the database's blob methods.
type Storage struct {
db *database.D
blobDir string // Directory for storing blob files
db database.Database
blobDir string // Directory for storing blob files (for backward compatibility info)
}
// NewStorage creates a new storage instance
func NewStorage(db *database.D) *Storage {
// Derive blob directory from database path
// NewStorage creates a new storage instance using the database interface.
func NewStorage(db database.Database) *Storage {
// Derive blob directory from database path (for informational purposes)
blobDir := filepath.Join(db.Path(), "blossom")
// Ensure blob directory exists
// Ensure blob directory exists (the database implementation handles this,
// but we keep this for backward compatibility with code that checks the directory)
if err := os.MkdirAll(blobDir, 0755); err != nil {
log.E.F("failed to create blob directory %s: %v", blobDir, err)
}
@ -46,482 +33,112 @@ func NewStorage(db *database.D) *Storage { @@ -46,482 +33,112 @@ func NewStorage(db *database.D) *Storage {
}
}
// getBlobPath returns the filesystem path for a blob given its hash and extension
func (s *Storage) getBlobPath(sha256Hex string, ext string) string {
filename := sha256Hex + ext
return filepath.Join(s.blobDir, filename)
}
// SaveBlob stores a blob with its metadata
// SaveBlob stores a blob with its metadata.
// Delegates to the database interface's SaveBlob method.
func (s *Storage) SaveBlob(
sha256Hash []byte, data []byte, pubkey []byte, mimeType string, extension string,
) (err error) {
sha256Hex := hex.Enc(sha256Hash)
// Verify SHA256 matches
calculatedHash := sha256.Sum256(data)
if !utils.FastEqual(calculatedHash[:], sha256Hash) {
err = errorf.E(
"SHA256 mismatch: calculated %x, provided %x",
calculatedHash[:], sha256Hash,
)
return
}
// If extension not provided, infer from MIME type
if extension == "" {
extension = GetExtensionFromMimeType(mimeType)
}
// Create metadata with extension
metadata := NewBlobMetadata(pubkey, mimeType, int64(len(data)))
metadata.Extension = extension
var metaData []byte
if metaData, err = metadata.Serialize(); chk.E(err) {
return
}
// Get blob file path
blobPath := s.getBlobPath(sha256Hex, extension)
// Check if blob file already exists (deduplication)
if _, err = os.Stat(blobPath); err == nil {
// File exists, just update metadata and index
log.D.F("blob file already exists: %s", blobPath)
} else if !os.IsNotExist(err) {
return errorf.E("error checking blob file: %w", err)
} else {
// Write blob data to file
if err = os.WriteFile(blobPath, data, 0644); chk.E(err) {
return errorf.E("failed to write blob file: %w", err)
}
log.D.F("wrote blob file: %s (%d bytes)", blobPath, len(data))
}
// Store metadata and index in database
if err = s.db.Update(func(txn *badger.Txn) error {
// Store metadata
metaKey := prefixBlobMeta + sha256Hex
if err := txn.Set([]byte(metaKey), metaData); err != nil {
return err
}
// Index by pubkey
indexKey := prefixBlobIndex + hex.Enc(pubkey) + ":" + sha256Hex
if err := txn.Set([]byte(indexKey), []byte{1}); err != nil {
return err
}
return nil
}); chk.E(err) {
return
}
log.D.F("saved blob %s (%d bytes) for pubkey %s", sha256Hex, len(data), hex.Enc(pubkey))
return
) error {
return s.db.SaveBlob(sha256Hash, data, pubkey, mimeType, extension)
}
// GetBlob retrieves blob data by SHA256 hash
// GetBlob retrieves blob data by SHA256 hash.
// Returns the data and metadata from the database.
func (s *Storage) GetBlob(sha256Hash []byte) (data []byte, metadata *BlobMetadata, err error) {
sha256Hex := hex.Enc(sha256Hash)
// Get metadata first to get extension
metaKey := prefixBlobMeta + sha256Hex
if err = s.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(metaKey))
data, dbMeta, err := s.db.GetBlob(sha256Hash)
if err != nil {
return err
}
return item.Value(func(val []byte) error {
if metadata, err = DeserializeBlobMetadata(val); err != nil {
return err
return nil, nil, err
}
return nil
})
}); chk.E(err) {
return
// Convert database.BlobMetadata to blossom.BlobMetadata
metadata = &BlobMetadata{
Pubkey: dbMeta.Pubkey,
MimeType: dbMeta.MimeType,
Uploaded: dbMeta.Uploaded,
Size: dbMeta.Size,
Extension: dbMeta.Extension,
}
// Read blob data from file
blobPath := s.getBlobPath(sha256Hex, metadata.Extension)
data, err = os.ReadFile(blobPath)
if err != nil {
if os.IsNotExist(err) {
err = badger.ErrKeyNotFound
}
return
}
return
return data, metadata, nil
}
// HasBlob checks if a blob exists
// HasBlob checks if a blob exists.
func (s *Storage) HasBlob(sha256Hash []byte) (exists bool, err error) {
sha256Hex := hex.Enc(sha256Hash)
// Get metadata to find extension
metaKey := prefixBlobMeta + sha256Hex
var metadata *BlobMetadata
if err = s.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(metaKey))
if err == badger.ErrKeyNotFound {
return badger.ErrKeyNotFound
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
if metadata, err = DeserializeBlobMetadata(val); err != nil {
return err
}
return nil
})
}); err == badger.ErrKeyNotFound {
exists = false
return false, nil
}
if err != nil {
return
}
// Check if file exists
blobPath := s.getBlobPath(sha256Hex, metadata.Extension)
if _, err = os.Stat(blobPath); err == nil {
exists = true
return
}
if os.IsNotExist(err) {
exists = false
err = nil
return
}
return
return s.db.HasBlob(sha256Hash)
}
// DeleteBlob deletes a blob and its metadata
func (s *Storage) DeleteBlob(sha256Hash []byte, pubkey []byte) (err error) {
sha256Hex := hex.Enc(sha256Hash)
// DeleteBlob deletes a blob and its metadata.
func (s *Storage) DeleteBlob(sha256Hash []byte, pubkey []byte) error {
return s.db.DeleteBlob(sha256Hash, pubkey)
}
// Get metadata to find extension
metaKey := prefixBlobMeta + sha256Hex
var metadata *BlobMetadata
if err = s.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(metaKey))
if err == badger.ErrKeyNotFound {
return badger.ErrKeyNotFound
}
// ListBlobs lists all blobs for a given pubkey.
// Returns blob descriptors with time filtering.
func (s *Storage) ListBlobs(pubkey []byte, since, until int64) ([]*BlobDescriptor, error) {
dbDescriptors, err := s.db.ListBlobs(pubkey, since, until)
if err != nil {
return err
}
return item.Value(func(val []byte) error {
if metadata, err = DeserializeBlobMetadata(val); err != nil {
return err
}
return nil
return nil, err
}
// Convert database.BlobDescriptor to blossom.BlobDescriptor
descriptors := make([]*BlobDescriptor, 0, len(dbDescriptors))
for _, d := range dbDescriptors {
descriptors = append(descriptors, &BlobDescriptor{
URL: d.URL,
SHA256: d.SHA256,
Size: d.Size,
Type: d.Type,
Uploaded: d.Uploaded,
NIP94: d.NIP94,
})
}); err == badger.ErrKeyNotFound {
return errorf.E("blob %s not found", sha256Hex)
}
if err != nil {
return
}
blobPath := s.getBlobPath(sha256Hex, metadata.Extension)
indexKey := prefixBlobIndex + hex.Enc(pubkey) + ":" + sha256Hex
if err = s.db.Update(func(txn *badger.Txn) error {
// Delete metadata
if err := txn.Delete([]byte(metaKey)); err != nil {
return err
}
// Delete index entry
if err := txn.Delete([]byte(indexKey)); err != nil {
return err
}
return nil
}); chk.E(err) {
return
}
// Delete blob file
if err = os.Remove(blobPath); err != nil && !os.IsNotExist(err) {
log.E.F("failed to delete blob file %s: %v", blobPath, err)
// Don't fail if file doesn't exist
}
log.D.F("deleted blob %s for pubkey %s", sha256Hex, hex.Enc(pubkey))
return
return descriptors, nil
}
// ListBlobs lists all blobs for a given pubkey
func (s *Storage) ListBlobs(
pubkey []byte, since, until int64,
) (descriptors []*BlobDescriptor, err error) {
pubkeyHex := hex.Enc(pubkey)
prefix := prefixBlobIndex + pubkeyHex + ":"
descriptors = make([]*BlobDescriptor, 0)
if err = s.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte(prefix)
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
key := item.Key()
// Extract SHA256 from key: prefixBlobIndex + pubkeyHex + ":" + sha256Hex
sha256Hex := string(key[len(prefix):])
// Get blob metadata
metaKey := prefixBlobMeta + sha256Hex
metaItem, err := txn.Get([]byte(metaKey))
// GetBlobMetadata retrieves only metadata for a blob.
func (s *Storage) GetBlobMetadata(sha256Hash []byte) (*BlobMetadata, error) {
dbMeta, err := s.db.GetBlobMetadata(sha256Hash)
if err != nil {
continue
}
var metadata *BlobMetadata
if err = metaItem.Value(func(val []byte) error {
if metadata, err = DeserializeBlobMetadata(val); err != nil {
return err
}
return nil
}); err != nil {
continue
}
// Filter by time range
if since > 0 && metadata.Uploaded < since {
continue
}
if until > 0 && metadata.Uploaded > until {
continue
}
// Verify blob file exists
blobPath := s.getBlobPath(sha256Hex, metadata.Extension)
if _, errGet := os.Stat(blobPath); errGet != nil {
continue
}
// Create descriptor (URL will be set by handler)
descriptor := NewBlobDescriptor(
"", // URL will be set by handler
sha256Hex,
metadata.Size,
metadata.MimeType,
metadata.Uploaded,
)
descriptors = append(descriptors, descriptor)
}
return nil
}); chk.E(err) {
return
}
return
return nil, err
}
// Convert database.BlobMetadata to blossom.BlobMetadata
return &BlobMetadata{
Pubkey: dbMeta.Pubkey,
MimeType: dbMeta.MimeType,
Uploaded: dbMeta.Uploaded,
Size: dbMeta.Size,
Extension: dbMeta.Extension,
}, nil
}
// GetTotalStorageUsed calculates total storage used by a pubkey in MB
// GetTotalStorageUsed calculates total storage used by a pubkey in MB.
func (s *Storage) GetTotalStorageUsed(pubkey []byte) (totalMB int64, err error) {
pubkeyHex := hex.Enc(pubkey)
prefix := prefixBlobIndex + pubkeyHex + ":"
totalBytes := int64(0)
if err = s.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte(prefix)
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
key := item.Key()
// Extract SHA256 from key: prefixBlobIndex + pubkeyHex + ":" + sha256Hex
sha256Hex := string(key[len(prefix):])
// Get blob metadata
metaKey := prefixBlobMeta + sha256Hex
metaItem, err := txn.Get([]byte(metaKey))
if err != nil {
continue
}
var metadata *BlobMetadata
if err = metaItem.Value(func(val []byte) error {
if metadata, err = DeserializeBlobMetadata(val); err != nil {
return err
}
return nil
}); err != nil {
continue
}
// Verify blob file exists
blobPath := s.getBlobPath(sha256Hex, metadata.Extension)
if _, errGet := os.Stat(blobPath); errGet != nil {
continue
}
totalBytes += metadata.Size
}
return nil
}); chk.E(err) {
return
}
// Convert bytes to MB (rounding up)
totalMB = (totalBytes + 1024*1024 - 1) / (1024 * 1024)
return
return s.db.GetTotalBlobStorageUsed(pubkey)
}
// SaveReport stores a report for a blob (BUD-09)
func (s *Storage) SaveReport(sha256Hash []byte, reportData []byte) (err error) {
sha256Hex := hex.Enc(sha256Hash)
reportKey := prefixBlobReport + sha256Hex
// Get existing reports
var existingReports [][]byte
if err = s.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(reportKey))
if err == badger.ErrKeyNotFound {
return nil
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
if err = json.Unmarshal(val, &existingReports); err != nil {
return err
}
return nil
})
}); chk.E(err) {
return
}
// Append new report
existingReports = append(existingReports, reportData)
// Store updated reports
var reportsData []byte
if reportsData, err = json.Marshal(existingReports); chk.E(err) {
return
}
if err = s.db.Update(func(txn *badger.Txn) error {
return txn.Set([]byte(reportKey), reportsData)
}); chk.E(err) {
return
}
log.D.F("saved report for blob %s", sha256Hex)
return
// SaveReport stores a report for a blob (BUD-09).
func (s *Storage) SaveReport(sha256Hash []byte, reportData []byte) error {
return s.db.SaveBlobReport(sha256Hash, reportData)
}
// GetBlobMetadata retrieves only metadata for a blob
func (s *Storage) GetBlobMetadata(sha256Hash []byte) (metadata *BlobMetadata, err error) {
sha256Hex := hex.Enc(sha256Hash)
metaKey := prefixBlobMeta + sha256Hex
if err = s.db.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(metaKey))
// ListAllUserStats returns storage statistics for all users who have uploaded blobs.
func (s *Storage) ListAllUserStats() ([]*UserBlobStats, error) {
dbStats, err := s.db.ListAllBlobUserStats()
if err != nil {
return err
}
return item.Value(func(val []byte) error {
if metadata, err = DeserializeBlobMetadata(val); err != nil {
return err
}
return nil
return nil, err
}
// Convert database.UserBlobStats to blossom.UserBlobStats
stats := make([]*UserBlobStats, 0, len(dbStats))
for _, s := range dbStats {
stats = append(stats, &UserBlobStats{
PubkeyHex: s.PubkeyHex,
BlobCount: s.BlobCount,
TotalSizeBytes: s.TotalSizeBytes,
})
}); chk.E(err) {
return
}
return
return stats, nil
}
// UserBlobStats represents storage statistics for a single user
// UserBlobStats represents storage statistics for a single user.
// This mirrors database.UserBlobStats for API compatibility.
type UserBlobStats struct {
PubkeyHex string `json:"pubkey"`
BlobCount int64 `json:"blob_count"`
TotalSizeBytes int64 `json:"total_size_bytes"`
}
// ListAllUserStats returns storage statistics for all users who have uploaded blobs
func (s *Storage) ListAllUserStats() (stats []*UserBlobStats, err error) {
statsMap := make(map[string]*UserBlobStats)
if err = s.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte(prefixBlobIndex)
opts.PrefetchValues = false
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
key := string(it.Item().Key())
// Key format: blob:index:<pubkey-hex>:<sha256-hex>
remainder := key[len(prefixBlobIndex):]
parts := strings.SplitN(remainder, ":", 2)
if len(parts) != 2 {
continue
}
pubkeyHex := parts[0]
sha256Hex := parts[1]
// Get or create stats entry
stat, ok := statsMap[pubkeyHex]
if !ok {
stat = &UserBlobStats{PubkeyHex: pubkeyHex}
statsMap[pubkeyHex] = stat
}
stat.BlobCount++
// Get blob size from metadata
metaKey := prefixBlobMeta + sha256Hex
metaItem, errGet := txn.Get([]byte(metaKey))
if errGet != nil {
continue
}
metaItem.Value(func(val []byte) error {
metadata, errDeser := DeserializeBlobMetadata(val)
if errDeser == nil {
stat.TotalSizeBytes += metadata.Size
}
return nil
})
}
return nil
}); chk.E(err) {
return
}
// Convert map to slice
stats = make([]*UserBlobStats, 0, len(statsMap))
for _, stat := range statsMap {
stats = append(stats, stat)
}
// Sort by total size descending
sort.Slice(stats, func(i, j int) bool {
return stats[i].TotalSizeBytes > stats[j].TotalSizeBytes
})
return
}

569
pkg/database/blob.go

@ -0,0 +1,569 @@ @@ -0,0 +1,569 @@
//go:build !(js && wasm)
package database
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"github.com/minio/sha256-simd"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"lol.mleku.dev/log"
"git.mleku.dev/mleku/nostr/encoders/hex"
)
const (
// Database key prefixes for blob storage (metadata and indexes only, blob data stored as files)
prefixBlobMeta = "blob:meta:"
prefixBlobIndex = "blob:index:"
prefixBlobReport = "blob:report:"
)
// getBlobDir returns the directory for storing blob files
func (d *D) getBlobDir() string {
return filepath.Join(d.dataDir, "blossom")
}
// getBlobPath returns the filesystem path for a blob given its hash and extension
func (d *D) getBlobPath(sha256Hex string, ext string) string {
filename := sha256Hex + ext
return filepath.Join(d.getBlobDir(), filename)
}
// ensureBlobDir ensures the blob directory exists
func (d *D) ensureBlobDir() error {
return os.MkdirAll(d.getBlobDir(), 0755)
}
// SaveBlob stores a blob with its metadata
func (d *D) SaveBlob(
sha256Hash []byte, data []byte, pubkey []byte, mimeType string, extension string,
) (err error) {
sha256Hex := hex.Enc(sha256Hash)
// Verify SHA256 matches
calculatedHash := sha256.Sum256(data)
if !bytesEqual(calculatedHash[:], sha256Hash) {
err = errorf.E(
"SHA256 mismatch: calculated %x, provided %x",
calculatedHash[:], sha256Hash,
)
return
}
// If extension not provided, infer from MIME type
if extension == "" {
extension = getExtensionFromMimeType(mimeType)
}
// Create metadata with extension
metadata := &BlobMetadata{
Pubkey: pubkey,
MimeType: mimeType,
Uploaded: time.Now().Unix(),
Size: int64(len(data)),
Extension: extension,
}
if mimeType == "" {
metadata.MimeType = "application/octet-stream"
}
var metaData []byte
if metaData, err = json.Marshal(metadata); chk.E(err) {
return
}
// Ensure blob directory exists
if err = d.ensureBlobDir(); err != nil {
return errorf.E("failed to create blob directory: %w", err)
}
// Get blob file path
blobPath := d.getBlobPath(sha256Hex, extension)
// Check if blob file already exists (deduplication)
if _, err = os.Stat(blobPath); err == nil {
// File exists, just update metadata and index
log.D.F("blob file already exists: %s", blobPath)
} else if !os.IsNotExist(err) {
return errorf.E("error checking blob file: %w", err)
} else {
// Write blob data to file
if err = os.WriteFile(blobPath, data, 0644); chk.E(err) {
return errorf.E("failed to write blob file: %w", err)
}
log.D.F("wrote blob file: %s (%d bytes)", blobPath, len(data))
}
// Store metadata and index in database
if err = d.Update(func(txn *badger.Txn) error {
// Store metadata
metaKey := prefixBlobMeta + sha256Hex
if err := txn.Set([]byte(metaKey), metaData); err != nil {
return err
}
// Index by pubkey
indexKey := prefixBlobIndex + hex.Enc(pubkey) + ":" + sha256Hex
if err := txn.Set([]byte(indexKey), []byte{1}); err != nil {
return err
}
return nil
}); chk.E(err) {
return
}
log.D.F("saved blob %s (%d bytes) for pubkey %s", sha256Hex, len(data), hex.Enc(pubkey))
return
}
// GetBlob retrieves blob data by SHA256 hash
func (d *D) GetBlob(sha256Hash []byte) (data []byte, metadata *BlobMetadata, err error) {
sha256Hex := hex.Enc(sha256Hash)
// Get metadata first to get extension
metaKey := prefixBlobMeta + sha256Hex
if err = d.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(metaKey))
if err != nil {
return err
}
return item.Value(func(val []byte) error {
metadata = &BlobMetadata{}
if err = json.Unmarshal(val, metadata); err != nil {
return err
}
return nil
})
}); chk.E(err) {
return
}
// Read blob data from file
blobPath := d.getBlobPath(sha256Hex, metadata.Extension)
data, err = os.ReadFile(blobPath)
if err != nil {
if os.IsNotExist(err) {
err = badger.ErrKeyNotFound
}
return
}
return
}
// HasBlob checks if a blob exists
func (d *D) HasBlob(sha256Hash []byte) (exists bool, err error) {
sha256Hex := hex.Enc(sha256Hash)
// Get metadata to find extension
metaKey := prefixBlobMeta + sha256Hex
var metadata *BlobMetadata
if err = d.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(metaKey))
if err == badger.ErrKeyNotFound {
return badger.ErrKeyNotFound
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
metadata = &BlobMetadata{}
if err = json.Unmarshal(val, metadata); err != nil {
return err
}
return nil
})
}); err == badger.ErrKeyNotFound {
exists = false
return false, nil
}
if err != nil {
return
}
// Check if file exists
blobPath := d.getBlobPath(sha256Hex, metadata.Extension)
if _, err = os.Stat(blobPath); err == nil {
exists = true
return
}
if os.IsNotExist(err) {
exists = false
err = nil
return
}
return
}
// DeleteBlob deletes a blob and its metadata
func (d *D) DeleteBlob(sha256Hash []byte, pubkey []byte) (err error) {
sha256Hex := hex.Enc(sha256Hash)
// Get metadata to find extension
metaKey := prefixBlobMeta + sha256Hex
var metadata *BlobMetadata
if err = d.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(metaKey))
if err == badger.ErrKeyNotFound {
return badger.ErrKeyNotFound
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
metadata = &BlobMetadata{}
if err = json.Unmarshal(val, metadata); err != nil {
return err
}
return nil
})
}); err == badger.ErrKeyNotFound {
return errorf.E("blob %s not found", sha256Hex)
}
if err != nil {
return
}
blobPath := d.getBlobPath(sha256Hex, metadata.Extension)
indexKey := prefixBlobIndex + hex.Enc(pubkey) + ":" + sha256Hex
if err = d.Update(func(txn *badger.Txn) error {
// Delete metadata
if err := txn.Delete([]byte(metaKey)); err != nil {
return err
}
// Delete index entry
if err := txn.Delete([]byte(indexKey)); err != nil {
return err
}
return nil
}); chk.E(err) {
return
}
// Delete blob file
if err = os.Remove(blobPath); err != nil && !os.IsNotExist(err) {
log.E.F("failed to delete blob file %s: %v", blobPath, err)
// Don't fail if file doesn't exist
}
log.D.F("deleted blob %s for pubkey %s", sha256Hex, hex.Enc(pubkey))
return
}
// ListBlobs lists all blobs for a given pubkey
func (d *D) ListBlobs(
pubkey []byte, since, until int64,
) (descriptors []*BlobDescriptor, err error) {
pubkeyHex := hex.Enc(pubkey)
prefix := prefixBlobIndex + pubkeyHex + ":"
descriptors = make([]*BlobDescriptor, 0)
if err = d.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte(prefix)
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
key := item.Key()
// Extract SHA256 from key: prefixBlobIndex + pubkeyHex + ":" + sha256Hex
sha256Hex := string(key[len(prefix):])
// Get blob metadata
metaKey := prefixBlobMeta + sha256Hex
metaItem, err := txn.Get([]byte(metaKey))
if err != nil {
continue
}
var metadata *BlobMetadata
if err = metaItem.Value(func(val []byte) error {
metadata = &BlobMetadata{}
if err = json.Unmarshal(val, metadata); err != nil {
return err
}
return nil
}); err != nil {
continue
}
// Filter by time range
if since > 0 && metadata.Uploaded < since {
continue
}
if until > 0 && metadata.Uploaded > until {
continue
}
// Verify blob file exists
blobPath := d.getBlobPath(sha256Hex, metadata.Extension)
if _, errGet := os.Stat(blobPath); errGet != nil {
continue
}
// Create descriptor (URL will be set by handler)
mimeType := metadata.MimeType
if mimeType == "" {
mimeType = "application/octet-stream"
}
descriptor := &BlobDescriptor{
URL: "", // URL will be set by handler
SHA256: sha256Hex,
Size: metadata.Size,
Type: mimeType,
Uploaded: metadata.Uploaded,
}
descriptors = append(descriptors, descriptor)
}
return nil
}); chk.E(err) {
return
}
return
}
// GetBlobMetadata retrieves only metadata for a blob
func (d *D) GetBlobMetadata(sha256Hash []byte) (metadata *BlobMetadata, err error) {
sha256Hex := hex.Enc(sha256Hash)
metaKey := prefixBlobMeta + sha256Hex
if err = d.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(metaKey))
if err != nil {
return err
}
return item.Value(func(val []byte) error {
metadata = &BlobMetadata{}
if err = json.Unmarshal(val, metadata); err != nil {
return err
}
return nil
})
}); chk.E(err) {
return
}
return
}
// GetTotalBlobStorageUsed calculates total storage used by a pubkey in MB
func (d *D) GetTotalBlobStorageUsed(pubkey []byte) (totalMB int64, err error) {
pubkeyHex := hex.Enc(pubkey)
prefix := prefixBlobIndex + pubkeyHex + ":"
totalBytes := int64(0)
if err = d.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte(prefix)
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
key := item.Key()
// Extract SHA256 from key: prefixBlobIndex + pubkeyHex + ":" + sha256Hex
sha256Hex := string(key[len(prefix):])
// Get blob metadata
metaKey := prefixBlobMeta + sha256Hex
metaItem, err := txn.Get([]byte(metaKey))
if err != nil {
continue
}
var metadata *BlobMetadata
if err = metaItem.Value(func(val []byte) error {
metadata = &BlobMetadata{}
if err = json.Unmarshal(val, metadata); err != nil {
return err
}
return nil
}); err != nil {
continue
}
// Verify blob file exists
blobPath := d.getBlobPath(sha256Hex, metadata.Extension)
if _, errGet := os.Stat(blobPath); errGet != nil {
continue
}
totalBytes += metadata.Size
}
return nil
}); chk.E(err) {
return
}
// Convert bytes to MB (rounding up)
totalMB = (totalBytes + 1024*1024 - 1) / (1024 * 1024)
return
}
// SaveBlobReport stores a report for a blob (BUD-09)
func (d *D) SaveBlobReport(sha256Hash []byte, reportData []byte) (err error) {
sha256Hex := hex.Enc(sha256Hash)
reportKey := prefixBlobReport + sha256Hex
// Get existing reports
var existingReports [][]byte
if err = d.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(reportKey))
if err == badger.ErrKeyNotFound {
return nil
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
if err = json.Unmarshal(val, &existingReports); err != nil {
return err
}
return nil
})
}); chk.E(err) {
return
}
// Append new report
existingReports = append(existingReports, reportData)
// Store updated reports
var reportsData []byte
if reportsData, err = json.Marshal(existingReports); chk.E(err) {
return
}
if err = d.Update(func(txn *badger.Txn) error {
return txn.Set([]byte(reportKey), reportsData)
}); chk.E(err) {
return
}
log.D.F("saved report for blob %s", sha256Hex)
return
}
// ListAllBlobUserStats returns storage statistics for all users who have uploaded blobs
func (d *D) ListAllBlobUserStats() (stats []*UserBlobStats, err error) {
statsMap := make(map[string]*UserBlobStats)
if err = d.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte(prefixBlobIndex)
opts.PrefetchValues = false
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
key := string(it.Item().Key())
// Key format: blob:index:<pubkey-hex>:<sha256-hex>
remainder := key[len(prefixBlobIndex):]
parts := strings.SplitN(remainder, ":", 2)
if len(parts) != 2 {
continue
}
pubkeyHex := parts[0]
sha256Hex := parts[1]
// Get or create stats entry
stat, ok := statsMap[pubkeyHex]
if !ok {
stat = &UserBlobStats{PubkeyHex: pubkeyHex}
statsMap[pubkeyHex] = stat
}
stat.BlobCount++
// Get blob size from metadata
metaKey := prefixBlobMeta + sha256Hex
metaItem, errGet := txn.Get([]byte(metaKey))
if errGet != nil {
continue
}
metaItem.Value(func(val []byte) error {
metadata := &BlobMetadata{}
if errDeser := json.Unmarshal(val, metadata); errDeser == nil {
stat.TotalSizeBytes += metadata.Size
}
return nil
})
}
return nil
}); chk.E(err) {
return
}
// Convert map to slice
stats = make([]*UserBlobStats, 0, len(statsMap))
for _, stat := range statsMap {
stats = append(stats, stat)
}
// Sort by total size descending
sort.Slice(stats, func(i, j int) bool {
return stats[i].TotalSizeBytes > stats[j].TotalSizeBytes
})
return
}
// getExtensionFromMimeType returns a file extension for a MIME type
func getExtensionFromMimeType(mimeType string) string {
// Common MIME type to extension mapping
mimeToExt := map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
"image/svg+xml": ".svg",
"image/bmp": ".bmp",
"image/tiff": ".tiff",
"video/mp4": ".mp4",
"video/webm": ".webm",
"video/ogg": ".ogv",
"video/quicktime": ".mov",
"audio/mpeg": ".mp3",
"audio/ogg": ".ogg",
"audio/wav": ".wav",
"audio/webm": ".weba",
"audio/flac": ".flac",
"application/pdf": ".pdf",
"application/zip": ".zip",
"text/plain": ".txt",
"text/html": ".html",
"text/css": ".css",
"text/javascript": ".js",
"application/json": ".json",
}
if ext, ok := mimeToExt[mimeType]; ok {
return ext
}
return "" // No extension for unknown types
}

34
pkg/database/factory.go

@ -42,13 +42,9 @@ type DatabaseConfig struct { @@ -42,13 +42,9 @@ type DatabaseConfig struct {
Neo4jMaxTxRetrySeconds int // ORLY_NEO4J_MAX_TX_RETRY_SEC - max transaction retry time (default: 30)
Neo4jQueryResultLimit int // ORLY_NEO4J_QUERY_RESULT_LIMIT - max results per query (default: 10000, 0=unlimited)
// BBolt-specific settings (optimized for HDD)
BboltBatchMaxEvents int // ORLY_BBOLT_BATCH_MAX_EVENTS - max events per batch (default: 5000)
BboltBatchMaxBytes int64 // ORLY_BBOLT_BATCH_MAX_MB * 1024 * 1024 (default: 128MB)
BboltFlushTimeout time.Duration // ORLY_BBOLT_BATCH_FLUSH_SEC * time.Second (default: 30s)
BboltBloomSizeMB int // ORLY_BBOLT_BLOOM_SIZE_MB - bloom filter size (default: 16MB)
BboltNoSync bool // ORLY_BBOLT_NO_SYNC - disable fsync (DANGEROUS)
BboltMmapSize int // ORLY_BBOLT_MMAP_SIZE_GB * 1024 * 1024 * 1024 (default: 8GB)
// gRPC client settings (for remote database)
GRPCServerAddress string // ORLY_GRPC_SERVER - address of remote gRPC database server
GRPCConnectTimeout time.Duration // ORLY_GRPC_CONNECT_TIMEOUT - connection timeout (default: 10s)
}
// NewDatabase creates a database instance based on the specified type.
@ -92,14 +88,14 @@ func NewDatabaseWithConfig( @@ -92,14 +88,14 @@ func NewDatabaseWithConfig(
return nil, fmt.Errorf("wasmdb database backend not available (import _ \"next.orly.dev/pkg/wasmdb\")")
}
return newWasmDBDatabase(ctx, cancel, cfg)
case "bbolt", "bolt":
// Use the bbolt implementation (B+tree, optimized for HDD)
if newBboltDatabase == nil {
return nil, fmt.Errorf("bbolt database backend not available (import _ \"next.orly.dev/pkg/bbolt\")")
case "grpc":
// Use the gRPC client to connect to a remote database server
if newGRPCDatabase == nil {
return nil, fmt.Errorf("grpc database backend not available (import _ \"next.orly.dev/pkg/database/grpc\")")
}
return newBboltDatabase(ctx, cancel, cfg)
return newGRPCDatabase(ctx, cancel, cfg)
default:
return nil, fmt.Errorf("unsupported database type: %s (supported: badger, neo4j, wasmdb, bbolt)", dbType)
return nil, fmt.Errorf("unsupported database type: %s (supported: badger, neo4j, wasmdb, grpc)", dbType)
}
}
@ -123,12 +119,12 @@ func RegisterWasmDBFactory(factory func(context.Context, context.CancelFunc, *Da @@ -123,12 +119,12 @@ func RegisterWasmDBFactory(factory func(context.Context, context.CancelFunc, *Da
newWasmDBDatabase = factory
}
// newBboltDatabase creates a bbolt database instance
// newGRPCDatabase creates a gRPC client database instance
// This is defined here to avoid import cycles
var newBboltDatabase func(context.Context, context.CancelFunc, *DatabaseConfig) (Database, error)
var newGRPCDatabase func(context.Context, context.CancelFunc, *DatabaseConfig) (Database, error)
// RegisterBboltFactory registers the bbolt database factory
// This is called from the bbolt package's init() function
func RegisterBboltFactory(factory func(context.Context, context.CancelFunc, *DatabaseConfig) (Database, error)) {
newBboltDatabase = factory
// RegisterGRPCFactory registers the gRPC database factory
// This is called from the grpc package's init() function
func RegisterGRPCFactory(factory func(context.Context, context.CancelFunc, *DatabaseConfig) (Database, error)) {
newGRPCDatabase = factory
}

875
pkg/database/grpc/client.go

@ -0,0 +1,875 @@ @@ -0,0 +1,875 @@
// Package grpc provides a gRPC client that implements the database.Database interface.
// This allows the relay to use a remote database server via gRPC.
package grpc
import (
"context"
"io"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/filter"
"git.mleku.dev/mleku/nostr/encoders/tag"
"next.orly.dev/pkg/database"
indextypes "next.orly.dev/pkg/database/indexes/types"
"next.orly.dev/pkg/interfaces/store"
orlydbv1 "next.orly.dev/pkg/proto/orlydb/v1"
)
// Client implements the database.Database interface via gRPC.
type Client struct {
conn *grpc.ClientConn
client orlydbv1.DatabaseServiceClient
ready chan struct{}
path string
}
// Verify Client implements database.Database at compile time.
var _ database.Database = (*Client)(nil)
// ClientConfig holds configuration for the gRPC client.
type ClientConfig struct {
ServerAddress string
ConnectTimeout time.Duration
}
// New creates a new gRPC database client.
func New(ctx context.Context, cfg *ClientConfig) (*Client, error) {
timeout := cfg.ConnectTimeout
if timeout == 0 {
timeout = 10 * time.Second
}
dialCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
conn, err := grpc.DialContext(dialCtx, cfg.ServerAddress,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultCallOptions(
grpc.MaxCallRecvMsgSize(64<<20), // 64MB
grpc.MaxCallSendMsgSize(64<<20), // 64MB
),
)
if err != nil {
return nil, err
}
c := &Client{
conn: conn,
client: orlydbv1.NewDatabaseServiceClient(conn),
ready: make(chan struct{}),
path: "grpc://" + cfg.ServerAddress,
}
// Check if server is ready
go c.waitForReady(ctx)
return c, nil
}
func (c *Client) waitForReady(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
resp, err := c.client.Ready(ctx, &orlydbv1.Empty{})
if err == nil && resp.Ready {
close(c.ready)
log.I.F("gRPC database client connected and ready")
return
}
time.Sleep(100 * time.Millisecond)
}
}
}
// === Lifecycle Methods ===
func (c *Client) Path() string {
return c.path
}
func (c *Client) Init(path string) error {
// Not applicable for remote database
return nil
}
func (c *Client) Sync() error {
_, err := c.client.Sync(context.Background(), &orlydbv1.Empty{})
return err
}
func (c *Client) Close() error {
return c.conn.Close()
}
func (c *Client) Wipe() error {
// Not implemented for remote database (dangerous operation)
return nil
}
func (c *Client) SetLogLevel(level string) {
_, _ = c.client.SetLogLevel(context.Background(), &orlydbv1.SetLogLevelRequest{Level: level})
}
func (c *Client) Ready() <-chan struct{} {
return c.ready
}
// === Event Storage ===
func (c *Client) SaveEvent(ctx context.Context, ev *event.E) (exists bool, err error) {
resp, err := c.client.SaveEvent(ctx, &orlydbv1.SaveEventRequest{
Event: orlydbv1.EventToProto(ev),
})
if err != nil {
return false, err
}
return resp.Exists, nil
}
func (c *Client) GetSerialsFromFilter(f *filter.F) (serials indextypes.Uint40s, err error) {
resp, err := c.client.GetSerialsFromFilter(context.Background(), &orlydbv1.GetSerialsFromFilterRequest{
Filter: orlydbv1.FilterToProto(f),
})
if err != nil {
return nil, err
}
return protoToUint40s(resp), nil
}
func (c *Client) WouldReplaceEvent(ev *event.E) (bool, indextypes.Uint40s, error) {
resp, err := c.client.WouldReplaceEvent(context.Background(), &orlydbv1.WouldReplaceEventRequest{
Event: orlydbv1.EventToProto(ev),
})
if err != nil {
return false, nil, err
}
serials := make(indextypes.Uint40s, 0, len(resp.ReplacedSerials))
for _, s := range resp.ReplacedSerials {
u := &indextypes.Uint40{}
_ = u.Set(s)
serials = append(serials, u)
}
return resp.WouldReplace, serials, nil
}
// === Event Queries ===
func (c *Client) QueryEvents(ctx context.Context, f *filter.F) (evs event.S, err error) {
stream, err := c.client.QueryEvents(ctx, &orlydbv1.QueryEventsRequest{
Filter: orlydbv1.FilterToProto(f),
})
if err != nil {
return nil, err
}
return c.collectStreamedEvents(stream)
}
func (c *Client) QueryAllVersions(ctx context.Context, f *filter.F) (evs event.S, err error) {
stream, err := c.client.QueryAllVersions(ctx, &orlydbv1.QueryEventsRequest{
Filter: orlydbv1.FilterToProto(f),
})
if err != nil {
return nil, err
}
return c.collectStreamedEvents(stream)
}
func (c *Client) QueryEventsWithOptions(ctx context.Context, f *filter.F, includeDeleteEvents bool, showAllVersions bool) (evs event.S, err error) {
stream, err := c.client.QueryEventsWithOptions(ctx, &orlydbv1.QueryEventsWithOptionsRequest{
Filter: orlydbv1.FilterToProto(f),
IncludeDeleteEvents: includeDeleteEvents,
ShowAllVersions: showAllVersions,
})
if err != nil {
return nil, err
}
return c.collectStreamedEvents(stream)
}
func (c *Client) QueryDeleteEventsByTargetId(ctx context.Context, targetEventId []byte) (evs event.S, err error) {
stream, err := c.client.QueryDeleteEventsByTargetId(ctx, &orlydbv1.QueryDeleteEventsByTargetIdRequest{
TargetEventId: targetEventId,
})
if err != nil {
return nil, err
}
return c.collectStreamedEvents(stream)
}
func (c *Client) QueryForSerials(ctx context.Context, f *filter.F) (serials indextypes.Uint40s, err error) {
resp, err := c.client.QueryForSerials(ctx, &orlydbv1.QueryEventsRequest{
Filter: orlydbv1.FilterToProto(f),
})
if err != nil {
return nil, err
}
return protoToUint40s(resp), nil
}
func (c *Client) QueryForIds(ctx context.Context, f *filter.F) (idPkTs []*store.IdPkTs, err error) {
resp, err := c.client.QueryForIds(ctx, &orlydbv1.QueryEventsRequest{
Filter: orlydbv1.FilterToProto(f),
})
if err != nil {
return nil, err
}
return orlydbv1.ProtoToIdPkTsList(resp), nil
}
func (c *Client) CountEvents(ctx context.Context, f *filter.F) (count int, approximate bool, err error) {
resp, err := c.client.CountEvents(ctx, &orlydbv1.QueryEventsRequest{
Filter: orlydbv1.FilterToProto(f),
})
if err != nil {
return 0, false, err
}
return int(resp.Count), resp.Approximate, nil
}
// === Event Retrieval by Serial ===
func (c *Client) FetchEventBySerial(ser *indextypes.Uint40) (ev *event.E, err error) {
resp, err := c.client.FetchEventBySerial(context.Background(), &orlydbv1.FetchEventBySerialRequest{
Serial: ser.Get(),
})
if err != nil {
return nil, err
}
if !resp.Found {
return nil, nil
}
return orlydbv1.ProtoToEvent(resp.Event), nil
}
func (c *Client) FetchEventsBySerials(serials []*indextypes.Uint40) (events map[uint64]*event.E, err error) {
serialList := make([]uint64, 0, len(serials))
for _, s := range serials {
serialList = append(serialList, s.Get())
}
resp, err := c.client.FetchEventsBySerials(context.Background(), &orlydbv1.FetchEventsBySerialRequest{
Serials: serialList,
})
if err != nil {
return nil, err
}
return orlydbv1.ProtoToEventMap(resp), nil
}
func (c *Client) GetSerialById(id []byte) (ser *indextypes.Uint40, err error) {
resp, err := c.client.GetSerialById(context.Background(), &orlydbv1.GetSerialByIdRequest{
Id: id,
})
if err != nil {
return nil, err
}
if !resp.Found {
return nil, nil
}
u := &indextypes.Uint40{}
_ = u.Set(resp.Serial)
return u, nil
}
func (c *Client) GetSerialsByIds(ids *tag.T) (serials map[string]*indextypes.Uint40, err error) {
idList := make([][]byte, 0, len(ids.T))
for _, id := range ids.T {
idList = append(idList, id)
}
resp, err := c.client.GetSerialsByIds(context.Background(), &orlydbv1.GetSerialsByIdsRequest{
Ids: idList,
})
if err != nil {
return nil, err
}
result := make(map[string]*indextypes.Uint40)
for k, v := range resp.Serials {
u := &indextypes.Uint40{}
_ = u.Set(v)
result[k] = u
}
return result, nil
}
func (c *Client) GetSerialsByIdsWithFilter(ids *tag.T, fn func(ev *event.E, ser *indextypes.Uint40) bool) (serials map[string]*indextypes.Uint40, err error) {
// Note: Filter function cannot be passed over gRPC, so we just get all serials
return c.GetSerialsByIds(ids)
}
func (c *Client) GetSerialsByRange(idx database.Range) (serials indextypes.Uint40s, err error) {
resp, err := c.client.GetSerialsByRange(context.Background(), &orlydbv1.GetSerialsByRangeRequest{
Range: orlydbv1.RangeToProto(idx),
})
if err != nil {
return nil, err
}
return protoToUint40s(resp), nil
}
func (c *Client) GetFullIdPubkeyBySerial(ser *indextypes.Uint40) (fidpk *store.IdPkTs, err error) {
resp, err := c.client.GetFullIdPubkeyBySerial(context.Background(), &orlydbv1.GetFullIdPubkeyBySerialRequest{
Serial: ser.Get(),
})
if err != nil {
return nil, err
}
return orlydbv1.ProtoToIdPkTs(resp), nil
}
func (c *Client) GetFullIdPubkeyBySerials(sers []*indextypes.Uint40) (fidpks []*store.IdPkTs, err error) {
serialList := make([]uint64, 0, len(sers))
for _, s := range sers {
serialList = append(serialList, s.Get())
}
resp, err := c.client.GetFullIdPubkeyBySerials(context.Background(), &orlydbv1.GetFullIdPubkeyBySerialsRequest{
Serials: serialList,
})
if err != nil {
return nil, err
}
return orlydbv1.ProtoToIdPkTsList(resp), nil
}
// === Event Deletion ===
func (c *Client) DeleteEvent(ctx context.Context, eid []byte) error {
_, err := c.client.DeleteEvent(ctx, &orlydbv1.DeleteEventRequest{
EventId: eid,
})
return err
}
func (c *Client) DeleteEventBySerial(ctx context.Context, ser *indextypes.Uint40, ev *event.E) error {
_, err := c.client.DeleteEventBySerial(ctx, &orlydbv1.DeleteEventBySerialRequest{
Serial: ser.Get(),
Event: orlydbv1.EventToProto(ev),
})
return err
}
func (c *Client) DeleteExpired() {
_, _ = c.client.DeleteExpired(context.Background(), &orlydbv1.Empty{})
}
func (c *Client) ProcessDelete(ev *event.E, admins [][]byte) error {
_, err := c.client.ProcessDelete(context.Background(), &orlydbv1.ProcessDeleteRequest{
Event: orlydbv1.EventToProto(ev),
Admins: admins,
})
return err
}
func (c *Client) CheckForDeleted(ev *event.E, admins [][]byte) error {
_, err := c.client.CheckForDeleted(context.Background(), &orlydbv1.CheckForDeletedRequest{
Event: orlydbv1.EventToProto(ev),
Admins: admins,
})
return err
}
// === Import/Export ===
func (c *Client) Import(rr io.Reader) {
stream, err := c.client.Import(context.Background())
if chk.E(err) {
return
}
buf := make([]byte, 64*1024)
for {
n, err := rr.Read(buf)
if err == io.EOF {
break
}
if chk.E(err) {
return
}
if err := stream.Send(&orlydbv1.ImportChunk{Data: buf[:n]}); chk.E(err) {
return
}
}
_, _ = stream.CloseAndRecv()
}
func (c *Client) Export(ctx context.Context, w io.Writer, pubkeys ...[]byte) {
stream, err := c.client.Export(ctx, &orlydbv1.ExportRequest{
Pubkeys: pubkeys,
})
if chk.E(err) {
return
}
for {
chunk, err := stream.Recv()
if err == io.EOF {
return
}
if chk.E(err) {
return
}
if _, err := w.Write(chunk.Data); chk.E(err) {
return
}
}
}
func (c *Client) ImportEventsFromReader(ctx context.Context, rr io.Reader) error {
c.Import(rr)
return nil
}
func (c *Client) ImportEventsFromStrings(ctx context.Context, eventJSONs []string, policyManager interface{ CheckPolicy(action string, ev *event.E, pubkey []byte, remote string) (bool, error) }) error {
_, err := c.client.ImportEventsFromStrings(ctx, &orlydbv1.ImportEventsFromStringsRequest{
EventJsons: eventJSONs,
CheckPolicy: policyManager != nil,
})
return err
}
// === Relay Identity ===
func (c *Client) GetRelayIdentitySecret() (skb []byte, err error) {
resp, err := c.client.GetRelayIdentitySecret(context.Background(), &orlydbv1.Empty{})
if err != nil {
return nil, err
}
return resp.SecretKey, nil
}
func (c *Client) SetRelayIdentitySecret(skb []byte) error {
_, err := c.client.SetRelayIdentitySecret(context.Background(), &orlydbv1.SetRelayIdentitySecretRequest{
SecretKey: skb,
})
return err
}
func (c *Client) GetOrCreateRelayIdentitySecret() (skb []byte, err error) {
resp, err := c.client.GetOrCreateRelayIdentitySecret(context.Background(), &orlydbv1.Empty{})
if err != nil {
return nil, err
}
return resp.SecretKey, nil
}
// === Markers ===
func (c *Client) SetMarker(key string, value []byte) error {
_, err := c.client.SetMarker(context.Background(), &orlydbv1.SetMarkerRequest{
Key: key,
Value: value,
})
return err
}
func (c *Client) GetMarker(key string) (value []byte, err error) {
resp, err := c.client.GetMarker(context.Background(), &orlydbv1.GetMarkerRequest{
Key: key,
})
if err != nil {
return nil, err
}
if !resp.Found {
return nil, nil
}
return resp.Value, nil
}
func (c *Client) HasMarker(key string) bool {
resp, err := c.client.HasMarker(context.Background(), &orlydbv1.HasMarkerRequest{
Key: key,
})
if err != nil {
return false
}
return resp.Exists
}
func (c *Client) DeleteMarker(key string) error {
_, err := c.client.DeleteMarker(context.Background(), &orlydbv1.DeleteMarkerRequest{
Key: key,
})
return err
}
// === Subscriptions ===
func (c *Client) GetSubscription(pubkey []byte) (*database.Subscription, error) {
resp, err := c.client.GetSubscription(context.Background(), &orlydbv1.GetSubscriptionRequest{
Pubkey: pubkey,
})
if err != nil {
return nil, err
}
return orlydbv1.ProtoToSubscription(resp), nil
}
func (c *Client) IsSubscriptionActive(pubkey []byte) (bool, error) {
resp, err := c.client.IsSubscriptionActive(context.Background(), &orlydbv1.IsSubscriptionActiveRequest{
Pubkey: pubkey,
})
if err != nil {
return false, err
}
return resp.Active, nil
}
func (c *Client) ExtendSubscription(pubkey []byte, days int) error {
_, err := c.client.ExtendSubscription(context.Background(), &orlydbv1.ExtendSubscriptionRequest{
Pubkey: pubkey,
Days: int32(days),
})
return err
}
func (c *Client) RecordPayment(pubkey []byte, amount int64, invoice, preimage string) error {
_, err := c.client.RecordPayment(context.Background(), &orlydbv1.RecordPaymentRequest{
Pubkey: pubkey,
Amount: amount,
Invoice: invoice,
Preimage: preimage,
})
return err
}
func (c *Client) GetPaymentHistory(pubkey []byte) ([]database.Payment, error) {
resp, err := c.client.GetPaymentHistory(context.Background(), &orlydbv1.GetPaymentHistoryRequest{
Pubkey: pubkey,
})
if err != nil {
return nil, err
}
return orlydbv1.ProtoToPaymentList(resp), nil
}
func (c *Client) ExtendBlossomSubscription(pubkey []byte, tier string, storageMB int64, daysExtended int) error {
_, err := c.client.ExtendBlossomSubscription(context.Background(), &orlydbv1.ExtendBlossomSubscriptionRequest{
Pubkey: pubkey,
Tier: tier,
StorageMb: storageMB,
DaysExtended: int32(daysExtended),
})
return err
}
func (c *Client) GetBlossomStorageQuota(pubkey []byte) (quotaMB int64, err error) {
resp, err := c.client.GetBlossomStorageQuota(context.Background(), &orlydbv1.GetBlossomStorageQuotaRequest{
Pubkey: pubkey,
})
if err != nil {
return 0, err
}
return resp.QuotaMb, nil
}
func (c *Client) IsFirstTimeUser(pubkey []byte) (bool, error) {
resp, err := c.client.IsFirstTimeUser(context.Background(), &orlydbv1.IsFirstTimeUserRequest{
Pubkey: pubkey,
})
if err != nil {
return false, err
}
return resp.FirstTime, nil
}
// === NIP-43 ===
func (c *Client) AddNIP43Member(pubkey []byte, inviteCode string) error {
_, err := c.client.AddNIP43Member(context.Background(), &orlydbv1.AddNIP43MemberRequest{
Pubkey: pubkey,
InviteCode: inviteCode,
})
return err
}
func (c *Client) RemoveNIP43Member(pubkey []byte) error {
_, err := c.client.RemoveNIP43Member(context.Background(), &orlydbv1.RemoveNIP43MemberRequest{
Pubkey: pubkey,
})
return err
}
func (c *Client) IsNIP43Member(pubkey []byte) (isMember bool, err error) {
resp, err := c.client.IsNIP43Member(context.Background(), &orlydbv1.IsNIP43MemberRequest{
Pubkey: pubkey,
})
if err != nil {
return false, err
}
return resp.IsMember, nil
}
func (c *Client) GetNIP43Membership(pubkey []byte) (*database.NIP43Membership, error) {
resp, err := c.client.GetNIP43Membership(context.Background(), &orlydbv1.GetNIP43MembershipRequest{
Pubkey: pubkey,
})
if err != nil {
return nil, err
}
return orlydbv1.ProtoToNIP43Membership(resp), nil
}
func (c *Client) GetAllNIP43Members() ([][]byte, error) {
resp, err := c.client.GetAllNIP43Members(context.Background(), &orlydbv1.Empty{})
if err != nil {
return nil, err
}
return resp.Pubkeys, nil
}
func (c *Client) StoreInviteCode(code string, expiresAt time.Time) error {
_, err := c.client.StoreInviteCode(context.Background(), &orlydbv1.StoreInviteCodeRequest{
Code: code,
ExpiresAt: expiresAt.Unix(),
})
return err
}
func (c *Client) ValidateInviteCode(code string) (valid bool, err error) {
resp, err := c.client.ValidateInviteCode(context.Background(), &orlydbv1.ValidateInviteCodeRequest{
Code: code,
})
if err != nil {
return false, err
}
return resp.Valid, nil
}
func (c *Client) DeleteInviteCode(code string) error {
_, err := c.client.DeleteInviteCode(context.Background(), &orlydbv1.DeleteInviteCodeRequest{
Code: code,
})
return err
}
func (c *Client) PublishNIP43MembershipEvent(kind int, pubkey []byte) error {
_, err := c.client.PublishNIP43MembershipEvent(context.Background(), &orlydbv1.PublishNIP43MembershipEventRequest{
Kind: int32(kind),
Pubkey: pubkey,
})
return err
}
// === Migrations ===
func (c *Client) RunMigrations() {
_, _ = c.client.RunMigrations(context.Background(), &orlydbv1.Empty{})
}
// === Query Cache ===
func (c *Client) GetCachedJSON(f *filter.F) ([][]byte, bool) {
resp, err := c.client.GetCachedJSON(context.Background(), &orlydbv1.GetCachedJSONRequest{
Filter: orlydbv1.FilterToProto(f),
})
if err != nil {
return nil, false
}
return resp.JsonItems, resp.Found
}
func (c *Client) CacheMarshaledJSON(f *filter.F, marshaledJSON [][]byte) {
_, _ = c.client.CacheMarshaledJSON(context.Background(), &orlydbv1.CacheMarshaledJSONRequest{
Filter: orlydbv1.FilterToProto(f),
JsonItems: marshaledJSON,
})
}
func (c *Client) GetCachedEvents(f *filter.F) (event.S, bool) {
resp, err := c.client.GetCachedEvents(context.Background(), &orlydbv1.GetCachedEventsRequest{
Filter: orlydbv1.FilterToProto(f),
})
if err != nil {
return nil, false
}
return orlydbv1.ProtoToEvents(resp.Events), resp.Found
}
func (c *Client) CacheEvents(f *filter.F, events event.S) {
_, _ = c.client.CacheEvents(context.Background(), &orlydbv1.CacheEventsRequest{
Filter: orlydbv1.FilterToProto(f),
Events: orlydbv1.EventsToProto(events),
})
}
func (c *Client) InvalidateQueryCache() {
_, _ = c.client.InvalidateQueryCache(context.Background(), &orlydbv1.Empty{})
}
// === Access Tracking ===
func (c *Client) RecordEventAccess(serial uint64, connectionID string) error {
_, err := c.client.RecordEventAccess(context.Background(), &orlydbv1.RecordEventAccessRequest{
Serial: serial,
ConnectionId: connectionID,
})
return err
}
func (c *Client) GetEventAccessInfo(serial uint64) (lastAccess int64, accessCount uint32, err error) {
resp, err := c.client.GetEventAccessInfo(context.Background(), &orlydbv1.GetEventAccessInfoRequest{
Serial: serial,
})
if err != nil {
return 0, 0, err
}
return resp.LastAccess, resp.AccessCount, nil
}
func (c *Client) GetLeastAccessedEvents(limit int, minAgeSec int64) (serials []uint64, err error) {
resp, err := c.client.GetLeastAccessedEvents(context.Background(), &orlydbv1.GetLeastAccessedEventsRequest{
Limit: int32(limit),
MinAgeSec: minAgeSec,
})
if err != nil {
return nil, err
}
return resp.Serials, nil
}
// === Blob Storage (Blossom) ===
func (c *Client) SaveBlob(sha256Hash []byte, data []byte, pubkey []byte, mimeType string, extension string) error {
_, err := c.client.SaveBlob(context.Background(), &orlydbv1.SaveBlobRequest{
Sha256Hash: sha256Hash,
Data: data,
Pubkey: pubkey,
MimeType: mimeType,
Extension: extension,
})
return err
}
func (c *Client) GetBlob(sha256Hash []byte) (data []byte, metadata *database.BlobMetadata, err error) {
resp, err := c.client.GetBlob(context.Background(), &orlydbv1.GetBlobRequest{
Sha256Hash: sha256Hash,
})
if err != nil {
return nil, nil, err
}
if !resp.Found {
return nil, nil, nil
}
return resp.Data, orlydbv1.ProtoToBlobMetadata(resp.Metadata), nil
}
func (c *Client) HasBlob(sha256Hash []byte) (exists bool, err error) {
resp, err := c.client.HasBlob(context.Background(), &orlydbv1.HasBlobRequest{
Sha256Hash: sha256Hash,
})
if err != nil {
return false, err
}
return resp.Exists, nil
}
func (c *Client) DeleteBlob(sha256Hash []byte, pubkey []byte) error {
_, err := c.client.DeleteBlob(context.Background(), &orlydbv1.DeleteBlobRequest{
Sha256Hash: sha256Hash,
Pubkey: pubkey,
})
return err
}
func (c *Client) ListBlobs(pubkey []byte, since, until int64) ([]*database.BlobDescriptor, error) {
resp, err := c.client.ListBlobs(context.Background(), &orlydbv1.ListBlobsRequest{
Pubkey: pubkey,
Since: since,
Until: until,
})
if err != nil {
return nil, err
}
return orlydbv1.ProtoToBlobDescriptorList(resp.Descriptors), nil
}
func (c *Client) GetBlobMetadata(sha256Hash []byte) (*database.BlobMetadata, error) {
resp, err := c.client.GetBlobMetadata(context.Background(), &orlydbv1.GetBlobMetadataRequest{
Sha256Hash: sha256Hash,
})
if err != nil {
return nil, err
}
return orlydbv1.ProtoToBlobMetadata(resp), nil
}
func (c *Client) GetTotalBlobStorageUsed(pubkey []byte) (totalMB int64, err error) {
resp, err := c.client.GetTotalBlobStorageUsed(context.Background(), &orlydbv1.GetTotalBlobStorageUsedRequest{
Pubkey: pubkey,
})
if err != nil {
return 0, err
}
return resp.TotalMb, nil
}
func (c *Client) SaveBlobReport(sha256Hash []byte, reportData []byte) error {
_, err := c.client.SaveBlobReport(context.Background(), &orlydbv1.SaveBlobReportRequest{
Sha256Hash: sha256Hash,
ReportData: reportData,
})
return err
}
func (c *Client) ListAllBlobUserStats() ([]*database.UserBlobStats, error) {
resp, err := c.client.ListAllBlobUserStats(context.Background(), &orlydbv1.Empty{})
if err != nil {
return nil, err
}
return orlydbv1.ProtoToUserBlobStatsList(resp.Stats), nil
}
// === Utility ===
func (c *Client) EventIdsBySerial(start uint64, count int) (evs []uint64, err error) {
resp, err := c.client.EventIdsBySerial(context.Background(), &orlydbv1.EventIdsBySerialRequest{
Start: start,
Count: int32(count),
})
if err != nil {
return nil, err
}
return resp.EventIds, nil
}
// === Helper Methods ===
type eventStream interface {
Recv() (*orlydbv1.EventBatch, error)
}
func (c *Client) collectStreamedEvents(stream eventStream) (event.S, error) {
var result event.S
for {
batch, err := stream.Recv()
if err == io.EOF {
return result, nil
}
if err != nil {
return nil, err
}
for _, ev := range batch.Events {
result = append(result, orlydbv1.ProtoToEvent(ev))
}
}
}
func protoToUint40s(resp *orlydbv1.SerialList) indextypes.Uint40s {
if resp == nil {
return nil
}
result := make(indextypes.Uint40s, 0, len(resp.Serials))
for _, s := range resp.Serials {
u := &indextypes.Uint40{}
_ = u.Set(s)
result = append(result, u)
}
return result
}

20
pkg/database/grpc/init.go

@ -0,0 +1,20 @@ @@ -0,0 +1,20 @@
package grpc
import (
"context"
"next.orly.dev/pkg/database"
)
func init() {
database.RegisterGRPCFactory(NewFromConfig)
}
// NewFromConfig creates a new gRPC database client from the database config.
func NewFromConfig(ctx context.Context, cancel context.CancelFunc, cfg *database.DatabaseConfig) (database.Database, error) {
clientCfg := &ClientConfig{
ServerAddress: cfg.GRPCServerAddress,
ConnectTimeout: cfg.GRPCConnectTimeout,
}
return New(ctx, clientCfg)
}

11
pkg/database/interface.go

@ -116,4 +116,15 @@ type Database interface { @@ -116,4 +116,15 @@ type Database interface {
// Utility methods
EventIdsBySerial(start uint64, count int) (evs []uint64, err error)
// Blob storage (Blossom)
SaveBlob(sha256Hash []byte, data []byte, pubkey []byte, mimeType string, extension string) error
GetBlob(sha256Hash []byte) (data []byte, metadata *BlobMetadata, err error)
HasBlob(sha256Hash []byte) (exists bool, err error)
DeleteBlob(sha256Hash []byte, pubkey []byte) error
ListBlobs(pubkey []byte, since, until int64) ([]*BlobDescriptor, error)
GetBlobMetadata(sha256Hash []byte) (*BlobMetadata, error)
GetTotalBlobStorageUsed(pubkey []byte) (totalMB int64, err error)
SaveBlobReport(sha256Hash []byte, reportData []byte) error
ListAllBlobUserStats() ([]*UserBlobStats, error)
}

26
pkg/database/types.go

@ -24,3 +24,29 @@ type NIP43Membership struct { @@ -24,3 +24,29 @@ type NIP43Membership struct {
AddedAt time.Time
InviteCode string
}
// BlobMetadata stores metadata about a blob in the database
type BlobMetadata struct {
Pubkey []byte `json:"pubkey"`
MimeType string `json:"mime_type"`
Uploaded int64 `json:"uploaded"`
Size int64 `json:"size"`
Extension string `json:"extension"` // File extension (e.g., ".png", ".pdf")
}
// BlobDescriptor represents a blob descriptor as defined in BUD-02
type BlobDescriptor struct {
URL string `json:"url"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
Type string `json:"type"`
Uploaded int64 `json:"uploaded"`
NIP94 [][]string `json:"nip94,omitempty"`
}
// UserBlobStats represents storage statistics for a single user
type UserBlobStats struct {
PubkeyHex string
BlobCount int64
TotalSizeBytes int64
}

57
pkg/neo4j/blob.go

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
package neo4j
import (
"errors"
"next.orly.dev/pkg/database"
)
// Blob storage methods - Neo4j does not support blob storage
// These are stub implementations that return errors
var errBlobNotSupported = errors.New("blob storage not supported in Neo4j backend")
// SaveBlob stores a blob with its metadata (not supported in Neo4j)
func (n *N) SaveBlob(sha256Hash []byte, data []byte, pubkey []byte, mimeType string, extension string) error {
return errBlobNotSupported
}
// GetBlob retrieves blob data by SHA256 hash (not supported in Neo4j)
func (n *N) GetBlob(sha256Hash []byte) (data []byte, metadata *database.BlobMetadata, err error) {
return nil, nil, errBlobNotSupported
}
// HasBlob checks if a blob exists (not supported in Neo4j)
func (n *N) HasBlob(sha256Hash []byte) (exists bool, err error) {
return false, errBlobNotSupported
}
// DeleteBlob deletes a blob and its metadata (not supported in Neo4j)
func (n *N) DeleteBlob(sha256Hash []byte, pubkey []byte) error {
return errBlobNotSupported
}
// ListBlobs lists all blobs for a given pubkey (not supported in Neo4j)
func (n *N) ListBlobs(pubkey []byte, since, until int64) ([]*database.BlobDescriptor, error) {
return nil, errBlobNotSupported
}
// GetBlobMetadata retrieves only metadata for a blob (not supported in Neo4j)
func (n *N) GetBlobMetadata(sha256Hash []byte) (*database.BlobMetadata, error) {
return nil, errBlobNotSupported
}
// GetTotalBlobStorageUsed calculates total storage used by a pubkey in MB (not supported in Neo4j)
func (n *N) GetTotalBlobStorageUsed(pubkey []byte) (totalMB int64, err error) {
return 0, errBlobNotSupported
}
// SaveBlobReport stores a report for a blob (not supported in Neo4j)
func (n *N) SaveBlobReport(sha256Hash []byte, reportData []byte) error {
return errBlobNotSupported
}
// ListAllBlobUserStats returns storage statistics for all users (not supported in Neo4j)
func (n *N) ListAllBlobUserStats() ([]*database.UserBlobStats, error) {
return nil, errBlobNotSupported
}

619
pkg/proto/orlydb/v1/converters.go

@ -0,0 +1,619 @@ @@ -0,0 +1,619 @@
// Package orlydbv1 provides type converters between proto messages and Go types.
package orlydbv1
import (
"time"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/filter"
"git.mleku.dev/mleku/nostr/encoders/kind"
"git.mleku.dev/mleku/nostr/encoders/tag"
"git.mleku.dev/mleku/nostr/encoders/timestamp"
"next.orly.dev/pkg/database"
indextypes "next.orly.dev/pkg/database/indexes/types"
"next.orly.dev/pkg/interfaces/store"
)
// EventToProto converts a nostr event.E to a proto Event.
// Binary fields (ID, Pubkey, Sig) are copied directly.
// Tags are converted preserving binary values for e/p tags.
func EventToProto(ev *event.E) *Event {
if ev == nil {
return nil
}
pb := &Event{
Id: ev.ID,
Pubkey: ev.Pubkey,
CreatedAt: ev.CreatedAt,
Kind: uint32(ev.Kind),
Content: ev.Content,
Sig: ev.Sig,
}
if ev.Tags != nil {
pb.Tags = make([]*Tag, 0, len(*ev.Tags))
for _, t := range *ev.Tags {
pbTag := &Tag{
Values: make([][]byte, 0, len(t.T)),
}
for _, v := range t.T {
// Copy bytes directly to preserve binary data
val := make([]byte, len(v))
copy(val, v)
pbTag.Values = append(pbTag.Values, val)
}
pb.Tags = append(pb.Tags, pbTag)
}
}
return pb
}
// ProtoToEvent converts a proto Event to a nostr event.E.
func ProtoToEvent(pb *Event) *event.E {
if pb == nil {
return nil
}
ev := event.New()
ev.ID = pb.Id
ev.Pubkey = pb.Pubkey
ev.CreatedAt = pb.CreatedAt
ev.Kind = uint16(pb.Kind)
ev.Content = pb.Content
ev.Sig = pb.Sig
if len(pb.Tags) > 0 {
tags := tag.NewSWithCap(len(pb.Tags))
for _, pbTag := range pb.Tags {
t := tag.NewWithCap(len(pbTag.Values))
for _, v := range pbTag.Values {
t.T = append(t.T, v)
}
*tags = append(*tags, t)
}
ev.Tags = tags
}
return ev
}
// FilterToProto converts a nostr filter.F to a proto Filter.
func FilterToProto(f *filter.F) *Filter {
if f == nil {
return nil
}
pb := &Filter{}
// IDs
if f.Ids != nil && len(f.Ids.T) > 0 {
pb.Ids = make([][]byte, 0, len(f.Ids.T))
for _, id := range f.Ids.T {
pb.Ids = append(pb.Ids, id)
}
}
// Kinds
if f.Kinds != nil && f.Kinds.Len() > 0 {
pb.Kinds = make([]uint32, 0, f.Kinds.Len())
for _, k := range f.Kinds.K {
pb.Kinds = append(pb.Kinds, uint32(k.K))
}
}
// Authors
if f.Authors != nil && len(f.Authors.T) > 0 {
pb.Authors = make([][]byte, 0, len(f.Authors.T))
for _, a := range f.Authors.T {
pb.Authors = append(pb.Authors, a)
}
}
// Tags (e.g., #e, #p, #t)
if f.Tags != nil && len(*f.Tags) > 0 {
pb.Tags = make(map[string]*TagSet)
for _, t := range *f.Tags {
if len(t.T) >= 2 {
key := string(t.T[0])
ts, exists := pb.Tags[key]
if !exists {
ts = &TagSet{}
pb.Tags[key] = ts
}
// Add all values after the key
for _, v := range t.T[1:] {
ts.Values = append(ts.Values, v)
}
}
}
}
// Since
if f.Since != nil {
since := f.Since.I64()
pb.Since = &since
}
// Until
if f.Until != nil {
until := f.Until.I64()
pb.Until = &until
}
// Search
if len(f.Search) > 0 {
pb.Search = f.Search
}
// Limit
if f.Limit != nil {
limit := uint32(*f.Limit)
pb.Limit = &limit
}
return pb
}
// ProtoToFilter converts a proto Filter to a nostr filter.F.
func ProtoToFilter(pb *Filter) *filter.F {
if pb == nil {
return nil
}
f := filter.New()
// IDs
if len(pb.Ids) > 0 {
f.Ids = tag.NewWithCap(len(pb.Ids))
for _, id := range pb.Ids {
f.Ids.T = append(f.Ids.T, id)
}
}
// Kinds
if len(pb.Kinds) > 0 {
kinds := kind.NewWithCap(len(pb.Kinds))
for _, k := range pb.Kinds {
kinds.K = append(kinds.K, kind.New(uint16(k)))
}
f.Kinds = kinds
}
// Authors
if len(pb.Authors) > 0 {
f.Authors = tag.NewWithCap(len(pb.Authors))
for _, a := range pb.Authors {
f.Authors.T = append(f.Authors.T, a)
}
}
// Tags
if len(pb.Tags) > 0 {
tags := tag.NewSWithCap(len(pb.Tags))
for key, ts := range pb.Tags {
for _, v := range ts.Values {
t := tag.NewWithCap(2)
t.T = append(t.T, []byte(key), v)
*tags = append(*tags, t)
}
}
f.Tags = tags
}
// Since
if pb.Since != nil {
f.Since = timestamp.FromUnix(*pb.Since)
}
// Until
if pb.Until != nil {
f.Until = timestamp.FromUnix(*pb.Until)
}
// Search
if len(pb.Search) > 0 {
f.Search = pb.Search
}
// Limit
if pb.Limit != nil {
limit := uint(*pb.Limit)
f.Limit = &limit
}
return f
}
// Uint40ToProto converts a indextypes.Uint40 to a proto Uint40.
func Uint40ToProto(u *indextypes.Uint40) *Uint40 {
if u == nil {
return nil
}
return &Uint40{Value: u.Get()}
}
// ProtoToUint40 converts a proto Uint40 to a indextypes.Uint40.
func ProtoToUint40(pb *Uint40) *indextypes.Uint40 {
if pb == nil {
return nil
}
return newUint40(pb.Value)
}
// Uint40sToProto converts a slice of Uint40s to a SerialList.
func Uint40sToProto(serials indextypes.Uint40s) *SerialList {
result := &SerialList{
Serials: make([]uint64, 0, len(serials)),
}
for _, s := range serials {
result.Serials = append(result.Serials, s.Get())
}
return result
}
// ProtoToUint40s converts a SerialList to a slice of Uint40 pointers.
func ProtoToUint40s(pb *SerialList) []*indextypes.Uint40 {
if pb == nil {
return nil
}
result := make([]*indextypes.Uint40, 0, len(pb.Serials))
for _, s := range pb.Serials {
result = append(result, newUint40(s))
}
return result
}
// IdPkTsToProto converts a store.IdPkTs to a proto IdPkTs.
func IdPkTsToProto(i *store.IdPkTs) *IdPkTs {
if i == nil {
return nil
}
return &IdPkTs{
Id: i.Id,
Pubkey: i.Pub,
Timestamp: i.Ts,
Serial: i.Ser,
}
}
// ProtoToIdPkTs converts a proto IdPkTs to a store.IdPkTs.
func ProtoToIdPkTs(pb *IdPkTs) *store.IdPkTs {
if pb == nil {
return nil
}
return &store.IdPkTs{
Id: pb.Id,
Pub: pb.Pubkey,
Ts: pb.Timestamp,
Ser: pb.Serial,
}
}
// IdPkTsListToProto converts a slice of IdPkTs to a proto IdPkTsList.
func IdPkTsListToProto(items []*store.IdPkTs) *IdPkTsList {
result := &IdPkTsList{
Items: make([]*IdPkTs, 0, len(items)),
}
for _, item := range items {
result.Items = append(result.Items, IdPkTsToProto(item))
}
return result
}
// ProtoToIdPkTsList converts a proto IdPkTsList to a slice of IdPkTs.
func ProtoToIdPkTsList(pb *IdPkTsList) []*store.IdPkTs {
if pb == nil {
return nil
}
result := make([]*store.IdPkTs, 0, len(pb.Items))
for _, item := range pb.Items {
result = append(result, ProtoToIdPkTs(item))
}
return result
}
// SubscriptionToProto converts a database.Subscription to a proto Subscription.
func SubscriptionToProto(s *database.Subscription, pubkey []byte) *Subscription {
if s == nil {
return nil
}
return &Subscription{
Pubkey: pubkey,
TrialEnd: s.TrialEnd.Unix(),
PaidUntil: s.PaidUntil.Unix(),
BlossomLevel: s.BlossomLevel,
BlossomStorageMb: s.BlossomStorage,
}
}
// ProtoToSubscription converts a proto Subscription to a database.Subscription.
func ProtoToSubscription(pb *Subscription) *database.Subscription {
if pb == nil {
return nil
}
return &database.Subscription{
TrialEnd: timeFromUnix(pb.TrialEnd),
PaidUntil: timeFromUnix(pb.PaidUntil),
BlossomLevel: pb.BlossomLevel,
BlossomStorage: pb.BlossomStorageMb,
}
}
// PaymentToProto converts a database.Payment to a proto Payment.
func PaymentToProto(p *database.Payment) *Payment {
return &Payment{
Amount: p.Amount,
Timestamp: p.Timestamp.Unix(),
Invoice: p.Invoice,
Preimage: p.Preimage,
}
}
// ProtoToPayment converts a proto Payment to a database.Payment.
func ProtoToPayment(pb *Payment) *database.Payment {
return &database.Payment{
Amount: pb.Amount,
Timestamp: timeFromUnix(pb.Timestamp),
Invoice: pb.Invoice,
Preimage: pb.Preimage,
}
}
// PaymentListToProto converts a slice of payments to a proto PaymentList.
func PaymentListToProto(payments []database.Payment) *PaymentList {
result := &PaymentList{
Payments: make([]*Payment, 0, len(payments)),
}
for _, p := range payments {
result.Payments = append(result.Payments, PaymentToProto(&p))
}
return result
}
// ProtoToPaymentList converts a proto PaymentList to a slice of payments.
func ProtoToPaymentList(pb *PaymentList) []database.Payment {
if pb == nil {
return nil
}
result := make([]database.Payment, 0, len(pb.Payments))
for _, p := range pb.Payments {
result = append(result, *ProtoToPayment(p))
}
return result
}
// NIP43MembershipToProto converts a database.NIP43Membership to a proto NIP43Membership.
func NIP43MembershipToProto(m *database.NIP43Membership) *NIP43Membership {
if m == nil {
return nil
}
return &NIP43Membership{
Pubkey: m.Pubkey,
AddedAt: m.AddedAt.Unix(),
InviteCode: m.InviteCode,
}
}
// ProtoToNIP43Membership converts a proto NIP43Membership to a database.NIP43Membership.
func ProtoToNIP43Membership(pb *NIP43Membership) *database.NIP43Membership {
if pb == nil {
return nil
}
return &database.NIP43Membership{
Pubkey: pb.Pubkey,
AddedAt: timeFromUnix(pb.AddedAt),
InviteCode: pb.InviteCode,
}
}
// RangeToProto converts a database.Range to a proto Range.
func RangeToProto(r database.Range) *Range {
return &Range{
Prefix: r.Start,
Start: 0,
End: 0,
}
}
// ProtoToRange converts a proto Range to a database.Range.
func ProtoToRange(pb *Range) database.Range {
if pb == nil {
return database.Range{}
}
return database.Range{
Start: pb.Prefix,
End: nil,
}
}
// EventMapToProto converts a map of serial->event to a proto EventMap.
func EventMapToProto(events map[uint64]*event.E) *EventMap {
result := &EventMap{
Events: make(map[uint64]*Event),
}
for serial, ev := range events {
result.Events[serial] = EventToProto(ev)
}
return result
}
// ProtoToEventMap converts a proto EventMap to a map of serial->event.
func ProtoToEventMap(pb *EventMap) map[uint64]*event.E {
if pb == nil {
return nil
}
result := make(map[uint64]*event.E)
for serial, ev := range pb.Events {
result[serial] = ProtoToEvent(ev)
}
return result
}
// EventsToProto converts a slice of events to a slice of proto Events.
func EventsToProto(events event.S) []*Event {
result := make([]*Event, 0, len(events))
for _, ev := range events {
result = append(result, EventToProto(ev))
}
return result
}
// ProtoToEvents converts a slice of proto Events to a slice of events.
func ProtoToEvents(pbs []*Event) event.S {
result := make(event.S, 0, len(pbs))
for _, pb := range pbs {
result = append(result, ProtoToEvent(pb))
}
return result
}
// timeFromUnix converts a unix timestamp to time.Time (unexported).
func timeFromUnix(unix int64) time.Time {
if unix == 0 {
return time.Time{}
}
return time.Unix(unix, 0)
}
// TimeFromUnix converts a unix timestamp to time.Time (exported).
func TimeFromUnix(unix int64) time.Time {
return timeFromUnix(unix)
}
// newUint40 creates a new Uint40 with the given value.
func newUint40(value uint64) *indextypes.Uint40 {
u := &indextypes.Uint40{}
_ = u.Set(value)
return u
}
// BytesToTag converts a slice of byte slices to a tag.T.
func BytesToTag(ids [][]byte) *tag.T {
t := tag.NewWithCap(len(ids))
for _, id := range ids {
t.T = append(t.T, id)
}
return t
}
// === Blob Storage Converters ===
// BlobMetadataToProto converts a database.BlobMetadata to a proto BlobMetadata.
func BlobMetadataToProto(m *database.BlobMetadata) *BlobMetadata {
if m == nil {
return nil
}
return &BlobMetadata{
Pubkey: m.Pubkey,
MimeType: m.MimeType,
Size: m.Size,
Uploaded: m.Uploaded,
Extension: m.Extension,
}
}
// ProtoToBlobMetadata converts a proto BlobMetadata to a database.BlobMetadata.
func ProtoToBlobMetadata(pb *BlobMetadata) *database.BlobMetadata {
if pb == nil {
return nil
}
return &database.BlobMetadata{
Pubkey: pb.Pubkey,
MimeType: pb.MimeType,
Size: pb.Size,
Uploaded: pb.Uploaded,
Extension: pb.Extension,
}
}
// BlobDescriptorToProto converts a database.BlobDescriptor to a proto BlobDescriptor.
// Note: NIP94 field is not transported via gRPC as it's generated at response time.
func BlobDescriptorToProto(d *database.BlobDescriptor) *BlobDescriptor {
if d == nil {
return nil
}
return &BlobDescriptor{
Url: d.URL,
Sha256: d.SHA256,
Size: d.Size,
Type: d.Type,
Uploaded: d.Uploaded,
}
}
// ProtoToBlobDescriptor converts a proto BlobDescriptor to a database.BlobDescriptor.
// Note: NIP94 field is not populated from proto as it's generated at response time.
func ProtoToBlobDescriptor(pb *BlobDescriptor) *database.BlobDescriptor {
if pb == nil {
return nil
}
return &database.BlobDescriptor{
URL: pb.Url,
SHA256: pb.Sha256,
Size: pb.Size,
Type: pb.Type,
Uploaded: pb.Uploaded,
}
}
// BlobDescriptorListToProto converts a slice of BlobDescriptors to proto.
func BlobDescriptorListToProto(descriptors []*database.BlobDescriptor) []*BlobDescriptor {
result := make([]*BlobDescriptor, 0, len(descriptors))
for _, d := range descriptors {
result = append(result, BlobDescriptorToProto(d))
}
return result
}
// ProtoToBlobDescriptorList converts proto BlobDescriptors to a slice.
func ProtoToBlobDescriptorList(pbs []*BlobDescriptor) []*database.BlobDescriptor {
result := make([]*database.BlobDescriptor, 0, len(pbs))
for _, pb := range pbs {
result = append(result, ProtoToBlobDescriptor(pb))
}
return result
}
// UserBlobStatsToProto converts a database.UserBlobStats to a proto UserBlobStats.
func UserBlobStatsToProto(s *database.UserBlobStats) *UserBlobStats {
if s == nil {
return nil
}
return &UserBlobStats{
PubkeyHex: s.PubkeyHex,
BlobCount: s.BlobCount,
TotalSizeBytes: s.TotalSizeBytes,
}
}
// ProtoToUserBlobStats converts a proto UserBlobStats to a database.UserBlobStats.
func ProtoToUserBlobStats(pb *UserBlobStats) *database.UserBlobStats {
if pb == nil {
return nil
}
return &database.UserBlobStats{
PubkeyHex: pb.PubkeyHex,
BlobCount: pb.BlobCount,
TotalSizeBytes: pb.TotalSizeBytes,
}
}
// UserBlobStatsListToProto converts a slice of UserBlobStats to proto.
func UserBlobStatsListToProto(stats []*database.UserBlobStats) []*UserBlobStats {
result := make([]*UserBlobStats, 0, len(stats))
for _, s := range stats {
result = append(result, UserBlobStatsToProto(s))
}
return result
}
// ProtoToUserBlobStatsList converts proto UserBlobStats to a slice.
func ProtoToUserBlobStatsList(pbs []*UserBlobStats) []*database.UserBlobStats {
result := make([]*database.UserBlobStats, 0, len(pbs))
for _, pb := range pbs {
result = append(result, ProtoToUserBlobStats(pb))
}
return result
}

4907
pkg/proto/orlydb/v1/service.pb.go

File diff suppressed because it is too large Load Diff

2976
pkg/proto/orlydb/v1/service_grpc.pb.go

File diff suppressed because it is too large Load Diff

1209
pkg/proto/orlydb/v1/types.pb.go

File diff suppressed because it is too large Load Diff

3
pkg/ratelimit/factory.go

@ -56,8 +56,7 @@ func MonitorFromNeo4jDriver( @@ -56,8 +56,7 @@ func MonitorFromNeo4jDriver(
}
// NewMemoryOnlyLimiter creates a rate limiter that only monitors process memory.
// Useful for database backends that don't have their own load metrics (e.g., BBolt).
// Since BBolt uses memory-mapped IO, memory pressure is still relevant.
// Useful for database backends that don't have their own load metrics.
func NewMemoryOnlyLimiter(config Config) *Limiter {
monitor := NewMemoryMonitor(100 * time.Millisecond)
return NewLimiter(config, monitor)

4
pkg/ratelimit/memory_monitor.go

@ -11,7 +11,7 @@ import ( @@ -11,7 +11,7 @@ import (
)
// MemoryMonitor is a simple load monitor that only tracks process memory.
// Used for database backends that don't have their own load metrics (e.g., BBolt).
// Used for database backends that don't have their own load metrics.
type MemoryMonitor struct {
// Configuration
pollInterval time.Duration
@ -203,7 +203,7 @@ func (m *MemoryMonitor) updateMetrics() { @@ -203,7 +203,7 @@ func (m *MemoryMonitor) updateMetrics() {
WriteLatency: avgWrite,
Timestamp: time.Now(),
InEmergencyMode: m.inEmergency.Load(),
CompactionPending: false, // BBolt doesn't have compaction
CompactionPending: false, // memory-only monitor doesn't track compaction
PhysicalMemoryMB: physicalMemMB,
}
m.mu.Unlock()

2
pkg/version/version

@ -1 +1 @@ @@ -1 +1 @@
v0.52.17
v0.53.0

61
pkg/wasmdb/blob.go

@ -0,0 +1,61 @@ @@ -0,0 +1,61 @@
//go:build js && wasm
package wasmdb
import (
"errors"
"next.orly.dev/pkg/database"
)
// Blob storage methods - WasmDB does not support blob storage
// These are stub implementations that return errors.
// Blob storage is not supported in the browser environment due to
// IndexedDB size limitations and the complexity of binary data handling.
var errBlobNotSupported = errors.New("blob storage not supported in WasmDB backend")
// SaveBlob stores a blob with its metadata (not supported in WasmDB)
func (w *W) SaveBlob(sha256Hash []byte, data []byte, pubkey []byte, mimeType string, extension string) error {
return errBlobNotSupported
}
// GetBlob retrieves blob data by SHA256 hash (not supported in WasmDB)
func (w *W) GetBlob(sha256Hash []byte) (data []byte, metadata *database.BlobMetadata, err error) {
return nil, nil, errBlobNotSupported
}
// HasBlob checks if a blob exists (not supported in WasmDB)
func (w *W) HasBlob(sha256Hash []byte) (exists bool, err error) {
return false, errBlobNotSupported
}
// DeleteBlob deletes a blob and its metadata (not supported in WasmDB)
func (w *W) DeleteBlob(sha256Hash []byte, pubkey []byte) error {
return errBlobNotSupported
}
// ListBlobs lists all blobs for a given pubkey (not supported in WasmDB)
func (w *W) ListBlobs(pubkey []byte, since, until int64) ([]*database.BlobDescriptor, error) {
return nil, errBlobNotSupported
}
// GetBlobMetadata retrieves only metadata for a blob (not supported in WasmDB)
func (w *W) GetBlobMetadata(sha256Hash []byte) (*database.BlobMetadata, error) {
return nil, errBlobNotSupported
}
// GetTotalBlobStorageUsed calculates total storage used by a pubkey in MB (not supported in WasmDB)
func (w *W) GetTotalBlobStorageUsed(pubkey []byte) (totalMB int64, err error) {
return 0, errBlobNotSupported
}
// SaveBlobReport stores a report for a blob (not supported in WasmDB)
func (w *W) SaveBlobReport(sha256Hash []byte, reportData []byte) error {
return errBlobNotSupported
}
// ListAllBlobUserStats returns storage statistics for all users (not supported in WasmDB)
func (w *W) ListAllBlobUserStats() ([]*database.UserBlobStats, error) {
return nil, errBlobNotSupported
}

10
proto/buf.gen.yaml

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
version: v1
plugins:
- plugin: go
out: ../pkg/proto
opt:
- paths=source_relative
- plugin: go-grpc
out: ../pkg/proto
opt:
- paths=source_relative

10
proto/buf.yaml

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
version: v1
deps: []
lint:
use:
- DEFAULT
except:
- PACKAGE_VERSION_SUFFIX
breaking:
use:
- FILE

668
proto/orlydb/v1/service.proto

@ -0,0 +1,668 @@ @@ -0,0 +1,668 @@
syntax = "proto3";
package orlydb.v1;
option go_package = "next.orly.dev/pkg/proto/orlydb/v1;orlydbv1";
import "orlydb/v1/types.proto";
// DatabaseService provides gRPC access to the ORLY database
service DatabaseService {
// === Lifecycle Methods ===
// GetPath returns the database file path
rpc GetPath(Empty) returns (PathResponse);
// Sync flushes buffers to disk
rpc Sync(Empty) returns (Empty);
// Ready returns whether the database is ready to serve requests
rpc Ready(Empty) returns (ReadyResponse);
// SetLogLevel sets the database log level
rpc SetLogLevel(SetLogLevelRequest) returns (Empty);
// === Event Storage ===
// SaveEvent stores an event, returns whether it already existed
rpc SaveEvent(SaveEventRequest) returns (SaveEventResponse);
// GetSerialsFromFilter gets serial numbers matching a filter
rpc GetSerialsFromFilter(GetSerialsFromFilterRequest) returns (SerialList);
// WouldReplaceEvent checks if an event would replace existing ones
rpc WouldReplaceEvent(WouldReplaceEventRequest) returns (WouldReplaceEventResponse);
// === Event Queries (Streaming for large results) ===
// QueryEvents queries events matching a filter (with deletion filtering)
rpc QueryEvents(QueryEventsRequest) returns (stream EventBatch);
// QueryAllVersions queries all versions of replaceable events
rpc QueryAllVersions(QueryEventsRequest) returns (stream EventBatch);
// QueryEventsWithOptions provides full control over query behavior
rpc QueryEventsWithOptions(QueryEventsWithOptionsRequest) returns (stream EventBatch);
// QueryDeleteEventsByTargetId queries deletion events for a target
rpc QueryDeleteEventsByTargetId(QueryDeleteEventsByTargetIdRequest) returns (stream EventBatch);
// QueryForSerials queries just serial numbers matching a filter
rpc QueryForSerials(QueryEventsRequest) returns (SerialList);
// QueryForIds queries ID/Pubkey/Timestamp triplets
rpc QueryForIds(QueryEventsRequest) returns (IdPkTsList);
// CountEvents counts events matching a filter
rpc CountEvents(QueryEventsRequest) returns (CountEventsResponse);
// === Event Retrieval by Serial ===
// FetchEventBySerial fetches a single event by serial
rpc FetchEventBySerial(FetchEventBySerialRequest) returns (FetchEventBySerialResponse);
// FetchEventsBySerials fetches multiple events by serial (batch)
rpc FetchEventsBySerials(FetchEventsBySerialRequest) returns (EventMap);
// GetSerialById gets the serial for a single event ID
rpc GetSerialById(GetSerialByIdRequest) returns (GetSerialByIdResponse);
// GetSerialsByIds gets serials for multiple event IDs
rpc GetSerialsByIds(GetSerialsByIdsRequest) returns (SerialMap);
// GetSerialsByRange gets serials within an index range
rpc GetSerialsByRange(GetSerialsByRangeRequest) returns (SerialList);
// GetFullIdPubkeyBySerial gets full ID/Pubkey/Timestamp for a serial
rpc GetFullIdPubkeyBySerial(GetFullIdPubkeyBySerialRequest) returns (IdPkTs);
// GetFullIdPubkeyBySerials gets full data for multiple serials
rpc GetFullIdPubkeyBySerials(GetFullIdPubkeyBySerialsRequest) returns (IdPkTsList);
// === Event Deletion ===
// DeleteEvent deletes an event by ID
rpc DeleteEvent(DeleteEventRequest) returns (Empty);
// DeleteEventBySerial deletes an event by serial
rpc DeleteEventBySerial(DeleteEventBySerialRequest) returns (Empty);
// DeleteExpired deletes events with expired expiration tags
rpc DeleteExpired(Empty) returns (Empty);
// ProcessDelete processes a deletion event (NIP-09)
rpc ProcessDelete(ProcessDeleteRequest) returns (Empty);
// CheckForDeleted checks if an event was deleted
rpc CheckForDeleted(CheckForDeletedRequest) returns (Empty);
// === Import/Export (Streaming) ===
// Import imports events from a stream
rpc Import(stream ImportChunk) returns (ImportResponse);
// Export exports events to a stream
rpc Export(ExportRequest) returns (stream ExportChunk);
// ImportEventsFromStrings imports events from JSON strings
rpc ImportEventsFromStrings(ImportEventsFromStringsRequest) returns (ImportResponse);
// === Relay Identity ===
// GetRelayIdentitySecret gets the relay's secret key
rpc GetRelayIdentitySecret(Empty) returns (GetRelayIdentitySecretResponse);
// SetRelayIdentitySecret sets the relay's secret key
rpc SetRelayIdentitySecret(SetRelayIdentitySecretRequest) returns (Empty);
// GetOrCreateRelayIdentitySecret gets or creates the relay's secret key
rpc GetOrCreateRelayIdentitySecret(Empty) returns (GetRelayIdentitySecretResponse);
// === Markers (Key-Value Storage) ===
// SetMarker sets a metadata marker
rpc SetMarker(SetMarkerRequest) returns (Empty);
// GetMarker gets a metadata marker
rpc GetMarker(GetMarkerRequest) returns (GetMarkerResponse);
// HasMarker checks if a marker exists
rpc HasMarker(HasMarkerRequest) returns (HasMarkerResponse);
// DeleteMarker deletes a marker
rpc DeleteMarker(DeleteMarkerRequest) returns (Empty);
// === Subscriptions (Payment-Based Access) ===
// GetSubscription gets subscription info for a pubkey
rpc GetSubscription(GetSubscriptionRequest) returns (Subscription);
// IsSubscriptionActive checks if a subscription is active
rpc IsSubscriptionActive(IsSubscriptionActiveRequest) returns (IsSubscriptionActiveResponse);
// ExtendSubscription extends a subscription by days
rpc ExtendSubscription(ExtendSubscriptionRequest) returns (Empty);
// RecordPayment records a payment
rpc RecordPayment(RecordPaymentRequest) returns (Empty);
// GetPaymentHistory gets payment history for a pubkey
rpc GetPaymentHistory(GetPaymentHistoryRequest) returns (PaymentList);
// ExtendBlossomSubscription extends blossom storage subscription
rpc ExtendBlossomSubscription(ExtendBlossomSubscriptionRequest) returns (Empty);
// GetBlossomStorageQuota gets blossom storage quota
rpc GetBlossomStorageQuota(GetBlossomStorageQuotaRequest) returns (GetBlossomStorageQuotaResponse);
// IsFirstTimeUser checks if this is a first-time user
rpc IsFirstTimeUser(IsFirstTimeUserRequest) returns (IsFirstTimeUserResponse);
// === NIP-43 Invite-Based ACL ===
// AddNIP43Member adds a member via invite code
rpc AddNIP43Member(AddNIP43MemberRequest) returns (Empty);
// RemoveNIP43Member removes a member
rpc RemoveNIP43Member(RemoveNIP43MemberRequest) returns (Empty);
// IsNIP43Member checks if a pubkey is a member
rpc IsNIP43Member(IsNIP43MemberRequest) returns (IsNIP43MemberResponse);
// GetNIP43Membership gets membership details
rpc GetNIP43Membership(GetNIP43MembershipRequest) returns (NIP43Membership);
// GetAllNIP43Members gets all members
rpc GetAllNIP43Members(Empty) returns (PubkeyList);
// StoreInviteCode stores an invite code with expiration
rpc StoreInviteCode(StoreInviteCodeRequest) returns (Empty);
// ValidateInviteCode validates an invite code
rpc ValidateInviteCode(ValidateInviteCodeRequest) returns (ValidateInviteCodeResponse);
// DeleteInviteCode deletes an invite code
rpc DeleteInviteCode(DeleteInviteCodeRequest) returns (Empty);
// PublishNIP43MembershipEvent publishes a membership event
rpc PublishNIP43MembershipEvent(PublishNIP43MembershipEventRequest) returns (Empty);
// === Query Cache ===
// GetCachedJSON gets cached JSON for a filter
rpc GetCachedJSON(GetCachedJSONRequest) returns (GetCachedJSONResponse);
// CacheMarshaledJSON caches JSON for a filter
rpc CacheMarshaledJSON(CacheMarshaledJSONRequest) returns (Empty);
// GetCachedEvents gets cached events for a filter
rpc GetCachedEvents(GetCachedEventsRequest) returns (GetCachedEventsResponse);
// CacheEvents caches events for a filter
rpc CacheEvents(CacheEventsRequest) returns (Empty);
// InvalidateQueryCache invalidates the entire query cache
rpc InvalidateQueryCache(Empty) returns (Empty);
// === Access Tracking ===
// RecordEventAccess records an access to an event
rpc RecordEventAccess(RecordEventAccessRequest) returns (Empty);
// GetEventAccessInfo gets access info for an event
rpc GetEventAccessInfo(GetEventAccessInfoRequest) returns (GetEventAccessInfoResponse);
// GetLeastAccessedEvents gets least accessed events for GC
rpc GetLeastAccessedEvents(GetLeastAccessedEventsRequest) returns (SerialList);
// === Blob Storage (Blossom) ===
// SaveBlob stores a blob with its metadata
rpc SaveBlob(SaveBlobRequest) returns (Empty);
// GetBlob retrieves blob data and metadata
rpc GetBlob(GetBlobRequest) returns (GetBlobResponse);
// HasBlob checks if a blob exists
rpc HasBlob(HasBlobRequest) returns (HasBlobResponse);
// DeleteBlob deletes a blob
rpc DeleteBlob(DeleteBlobRequest) returns (Empty);
// ListBlobs lists blobs for a pubkey
rpc ListBlobs(ListBlobsRequest) returns (ListBlobsResponse);
// GetBlobMetadata gets only metadata for a blob
rpc GetBlobMetadata(GetBlobMetadataRequest) returns (BlobMetadata);
// GetTotalBlobStorageUsed gets total storage used by a pubkey in MB
rpc GetTotalBlobStorageUsed(GetTotalBlobStorageUsedRequest) returns (GetTotalBlobStorageUsedResponse);
// SaveBlobReport stores a report for a blob (BUD-09)
rpc SaveBlobReport(SaveBlobReportRequest) returns (Empty);
// ListAllBlobUserStats gets storage statistics for all users
rpc ListAllBlobUserStats(Empty) returns (ListAllBlobUserStatsResponse);
// === Utility ===
// EventIdsBySerial gets event IDs by serial range
rpc EventIdsBySerial(EventIdsBySerialRequest) returns (EventIdsBySerialResponse);
// RunMigrations runs database migrations
rpc RunMigrations(Empty) returns (Empty);
}
// === Request/Response Messages ===
// Lifecycle
message PathResponse {
string path = 1;
}
message ReadyResponse {
bool ready = 1;
}
message SetLogLevelRequest {
string level = 1;
}
// Event Storage
message SaveEventRequest {
Event event = 1;
}
message SaveEventResponse {
bool exists = 1;
}
message GetSerialsFromFilterRequest {
Filter filter = 1;
}
message WouldReplaceEventRequest {
Event event = 1;
}
message WouldReplaceEventResponse {
bool would_replace = 1;
repeated uint64 replaced_serials = 2;
}
// Event Queries
message QueryEventsRequest {
Filter filter = 1;
}
message QueryEventsWithOptionsRequest {
Filter filter = 1;
bool include_delete_events = 2;
bool show_all_versions = 3;
}
message QueryDeleteEventsByTargetIdRequest {
bytes target_event_id = 1;
}
message CountEventsResponse {
int32 count = 1;
bool approximate = 2;
}
// Event Retrieval
message FetchEventBySerialRequest {
uint64 serial = 1;
}
message FetchEventBySerialResponse {
Event event = 1;
bool found = 2;
}
message FetchEventsBySerialRequest {
repeated uint64 serials = 1;
}
message GetSerialByIdRequest {
bytes id = 1;
}
message GetSerialByIdResponse {
uint64 serial = 1;
bool found = 2;
}
message GetSerialsByIdsRequest {
repeated bytes ids = 1;
}
message GetSerialsByRangeRequest {
Range range = 1;
}
message GetFullIdPubkeyBySerialRequest {
uint64 serial = 1;
}
message GetFullIdPubkeyBySerialsRequest {
repeated uint64 serials = 1;
}
// Event Deletion
message DeleteEventRequest {
bytes event_id = 1;
}
message DeleteEventBySerialRequest {
uint64 serial = 1;
Event event = 2;
}
message ProcessDeleteRequest {
Event event = 1;
repeated bytes admins = 2;
}
message CheckForDeletedRequest {
Event event = 1;
repeated bytes admins = 2;
}
// Import/Export
message ImportChunk {
bytes data = 1;
}
message ImportResponse {
int64 events_imported = 1;
int64 events_skipped = 2;
}
message ExportRequest {
repeated bytes pubkeys = 1;
}
message ExportChunk {
bytes data = 1;
}
message ImportEventsFromStringsRequest {
repeated string event_jsons = 1;
bool check_policy = 2;
}
// Relay Identity
message GetRelayIdentitySecretResponse {
bytes secret_key = 1;
}
message SetRelayIdentitySecretRequest {
bytes secret_key = 1;
}
// Markers
message SetMarkerRequest {
string key = 1;
bytes value = 2;
}
message GetMarkerRequest {
string key = 1;
}
message GetMarkerResponse {
bytes value = 1;
bool found = 2;
}
message HasMarkerRequest {
string key = 1;
}
message HasMarkerResponse {
bool exists = 1;
}
message DeleteMarkerRequest {
string key = 1;
}
// Subscriptions
message GetSubscriptionRequest {
bytes pubkey = 1;
}
message IsSubscriptionActiveRequest {
bytes pubkey = 1;
}
message IsSubscriptionActiveResponse {
bool active = 1;
}
message ExtendSubscriptionRequest {
bytes pubkey = 1;
int32 days = 2;
}
message RecordPaymentRequest {
bytes pubkey = 1;
int64 amount = 2;
string invoice = 3;
string preimage = 4;
}
message GetPaymentHistoryRequest {
bytes pubkey = 1;
}
message ExtendBlossomSubscriptionRequest {
bytes pubkey = 1;
string tier = 2;
int64 storage_mb = 3;
int32 days_extended = 4;
}
message GetBlossomStorageQuotaRequest {
bytes pubkey = 1;
}
message GetBlossomStorageQuotaResponse {
int64 quota_mb = 1;
}
message IsFirstTimeUserRequest {
bytes pubkey = 1;
}
message IsFirstTimeUserResponse {
bool first_time = 1;
}
// NIP-43
message AddNIP43MemberRequest {
bytes pubkey = 1;
string invite_code = 2;
}
message RemoveNIP43MemberRequest {
bytes pubkey = 1;
}
message IsNIP43MemberRequest {
bytes pubkey = 1;
}
message IsNIP43MemberResponse {
bool is_member = 1;
}
message GetNIP43MembershipRequest {
bytes pubkey = 1;
}
message StoreInviteCodeRequest {
string code = 1;
int64 expires_at = 2;
}
message ValidateInviteCodeRequest {
string code = 1;
}
message ValidateInviteCodeResponse {
bool valid = 1;
}
message DeleteInviteCodeRequest {
string code = 1;
}
message PublishNIP43MembershipEventRequest {
int32 kind = 1;
bytes pubkey = 2;
}
// Query Cache
message GetCachedJSONRequest {
Filter filter = 1;
}
message GetCachedJSONResponse {
repeated bytes json_items = 1;
bool found = 2;
}
message CacheMarshaledJSONRequest {
Filter filter = 1;
repeated bytes json_items = 2;
}
message GetCachedEventsRequest {
Filter filter = 1;
}
message GetCachedEventsResponse {
repeated Event events = 1;
bool found = 2;
}
message CacheEventsRequest {
Filter filter = 1;
repeated Event events = 2;
}
// Access Tracking
message RecordEventAccessRequest {
uint64 serial = 1;
string connection_id = 2;
}
message GetEventAccessInfoRequest {
uint64 serial = 1;
}
message GetEventAccessInfoResponse {
int64 last_access = 1;
uint32 access_count = 2;
}
message GetLeastAccessedEventsRequest {
int32 limit = 1;
int64 min_age_sec = 2;
}
// Utility
message EventIdsBySerialRequest {
uint64 start = 1;
int32 count = 2;
}
message EventIdsBySerialResponse {
repeated uint64 event_ids = 1;
}
// === Blob Storage Messages ===
message BlobMetadata {
bytes pubkey = 1;
string mime_type = 2;
int64 size = 3;
int64 uploaded = 4; // Unix timestamp
string extension = 5;
}
message BlobDescriptor {
string url = 1;
string sha256 = 2;
int64 size = 3;
string type = 4; // MIME type
int64 uploaded = 5;
}
message SaveBlobRequest {
bytes sha256_hash = 1;
bytes data = 2;
bytes pubkey = 3;
string mime_type = 4;
string extension = 5;
}
message GetBlobRequest {
bytes sha256_hash = 1;
}
message GetBlobResponse {
bool found = 1;
bytes data = 2;
BlobMetadata metadata = 3;
}
message HasBlobRequest {
bytes sha256_hash = 1;
}
message HasBlobResponse {
bool exists = 1;
}
message DeleteBlobRequest {
bytes sha256_hash = 1;
bytes pubkey = 2;
}
message ListBlobsRequest {
bytes pubkey = 1;
int64 since = 2;
int64 until = 3;
}
message ListBlobsResponse {
repeated BlobDescriptor descriptors = 1;
}
message GetBlobMetadataRequest {
bytes sha256_hash = 1;
}
message GetTotalBlobStorageUsedRequest {
bytes pubkey = 1;
}
message GetTotalBlobStorageUsedResponse {
int64 total_mb = 1;
}
message SaveBlobReportRequest {
bytes sha256_hash = 1;
bytes report_data = 2;
}
message UserBlobStats {
string pubkey_hex = 1;
int64 blob_count = 2;
int64 total_size_bytes = 3;
}
message ListAllBlobUserStatsResponse {
repeated UserBlobStats stats = 1;
}

121
proto/orlydb/v1/types.proto

@ -0,0 +1,121 @@ @@ -0,0 +1,121 @@
syntax = "proto3";
package orlydb.v1;
option go_package = "next.orly.dev/pkg/proto/orlydb/v1;orlydbv1";
// Empty is used for requests/responses with no data
message Empty {}
// Event represents a Nostr event
// Binary fields (id, pubkey, sig) are stored as raw bytes for efficiency
message Event {
bytes id = 1; // 32 bytes SHA256 hash
bytes pubkey = 2; // 32 bytes public key
int64 created_at = 3; // UNIX timestamp
uint32 kind = 4; // Event kind (uint16 in Go, promoted to uint32)
repeated Tag tags = 5; // Event tags
bytes content = 6; // Arbitrary content (may be binary)
bytes sig = 7; // 64 bytes Schnorr signature
}
// Tag represents a Nostr tag (array of byte slices)
// Values are bytes to preserve binary data in e/p tags (33-byte format)
message Tag {
repeated bytes values = 1;
}
// TagSet represents a set of tag values for filtering
message TagSet {
repeated bytes values = 1;
}
// Filter represents a Nostr query filter (NIP-01)
message Filter {
repeated bytes ids = 1; // Event IDs to match (32 bytes each)
repeated uint32 kinds = 2; // Kinds to match
repeated bytes authors = 3; // Author pubkeys (32 bytes each)
map<string, TagSet> tags = 4; // Tag filters (#e, #p, #t, etc.)
optional int64 since = 5; // Created after timestamp
optional int64 until = 6; // Created before timestamp
optional bytes search = 7; // Full-text search query (NIP-50)
optional uint32 limit = 8; // Max results
}
// Uint40 represents a 40-bit serial number
// Packed into uint64 with upper 24 bits unused
message Uint40 {
uint64 value = 1;
}
// IdPkTs holds event reference data (ID, Pubkey, Timestamp, Serial)
message IdPkTs {
bytes id = 1; // 32 bytes event ID
bytes pubkey = 2; // 32 bytes author pubkey
int64 timestamp = 3; // Created timestamp
uint64 serial = 4; // Database serial number
}
// Range represents an index range for serial queries
message Range {
bytes prefix = 1; // Index prefix
uint64 start = 2; // Start serial
uint64 end = 3; // End serial
}
// Subscription represents payment-based access control data
message Subscription {
bytes pubkey = 1;
int64 trial_end = 2;
int64 paid_until = 3;
string blossom_level = 4;
int64 blossom_storage_mb = 5;
}
// Payment represents a payment record
message Payment {
int64 amount = 1;
int64 timestamp = 2;
string invoice = 3;
string preimage = 4;
}
// NIP43Membership represents NIP-43 invite-based ACL membership
message NIP43Membership {
bytes pubkey = 1;
int64 added_at = 2;
string invite_code = 3;
}
// EventBatch is used for streaming query results
message EventBatch {
repeated Event events = 1;
}
// SerialList is used for returning lists of serials
message SerialList {
repeated uint64 serials = 1;
}
// IdPkTsList is used for returning lists of IdPkTs
message IdPkTsList {
repeated IdPkTs items = 1;
}
// EventMap maps serial numbers to events
message EventMap {
map<uint64, Event> events = 1;
}
// SerialMap maps string IDs to serial numbers
message SerialMap {
map<string, uint64> serials = 1;
}
// PaymentList is used for returning payment history
message PaymentList {
repeated Payment payments = 1;
}
// PubkeyList is used for returning lists of pubkeys
message PubkeyList {
repeated bytes pubkeys = 1;
}
Loading…
Cancel
Save