You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
PR #3468 fixed critical 0xdead10cc crashes by moving the main NostrDB database to the app's private container and creating periodic snapshots to a shared container for extension access. The tradeoff is doubled storage since the entire database is copied.
This document analyzes 5 approaches to reduce storage overhead while maintaining extension functionality.
Current State
What Extensions Actually Need
Extension
Data Required
Access Pattern
Notification Service
Profiles, Mutelists, Contacts, Settings
Read-only lookup by pubkey
Share Extension
Keypair, Settings, Relay info
Minimal reads, posts to relays
Highlighter Extension
Keypair, Settings
Minimal reads, posts to relays
Current Database Structure (17 Sub-databases)
REQUIRED BY EXTENSIONS:
├── NDB_DB_PROFILE ← Profiles (names, pictures, nip05)
├── NDB_DB_PROFILE_PK ← Profile pubkey index
├── NDB_DB_NOTE ← Events (for mute list items: kind 10000)
├── NDB_DB_NOTE_PUBKEY_KIND ← Author+kind index (to find mute lists)
NOT REQUIRED BY EXTENSIONS:
├── NDB_DB_META ← Note metadata (reply counts, etc.)
├── NDB_DB_NOTE_ID ← Event ID index
├── NDB_DB_NDB_META ← System metadata
├── NDB_DB_PROFILE_SEARCH ← Fulltext search (large)
├── NDB_DB_PROFILE_LAST_FETCH
├── NDB_DB_NOTE_KIND ← Kind index
├── NDB_DB_NOTE_TEXT ← Fulltext search (very large)
├── NDB_DB_NOTE_BLOCKS ← Parsed blocks
├── NDB_DB_NOTE_TAGS ← Tags index
├── NDB_DB_NOTE_PUBKEY ← Author index
├── NDB_DB_NOTE_RELAY_KIND ← Relay+kind index
├── NDB_DB_NOTE_RELAYS ← Note-relay mappings
Estimated Storage Breakdown:
Fulltext search indexes (NOTE_TEXT, PROFILE_SEARCH): ~40-60% of total
Notes and metadata: ~30-40%
Profiles and indexes: ~10-20%
Approach Analysis
Approach 1: Selective LMDB Sub-database Copy
Beads: optimize-storage-ndb-snapshots-hf9
Copy only required LMDB sub-databases instead of all 17 tables.
• Requires C-level nostrdb modifications • Must maintain DB consistency across selected tables • Foreign key-like relationships may break • More complex testing
Complexity
HIGH - New C function ndb_selective_snapshot() needed
Export needed data to JSON files in the shared container.
Aspect
Analysis
Benefits
• SIMPLEST implementation • No database dependencies in extensions • Human-readable/debuggable • Easy to version and migrate • Pure Swift implementation
Risks
• O(n) lookup performance (no indexes) • Memory pressure with large datasets • Must load entire file to access • No ACID guarantees
Complexity
LOW
Storage Savings
~85-95%
Main Thread
Must use background encoding/writing
Data Files:
group.com.damus/extension_data/
├── profiles.json (~500KB-2MB for active users)
├── mute_list.json (~10KB-100KB)
├── contacts.json (~50KB-500KB)
└── metadata.json (~1KB)
PR #3468 fixed 0xdead10cc crashes by copying the entire NostrDB (17 sub-databases) to the shared container every hour. This doubles storage usage when extensions only need ~5% of the data.
What Extensions Actually Access
Extension
Data Accessed
Access Pattern
NotificationService
Profiles, MuteList, Contacts
Read-only lookup by pubkey
ShareExtension
Keypair, Settings
Minimal DB reads
HighlighterExtension
Keypair, Settings
Minimal DB reads
Exact Data Requirements
1. Profiles (for NotificationService)
// Fields used from NdbProfile:
structProfileData{letname:String?letdisplay_name:String?letpicture:String? // For notification avatar
letnip05:String? // For verification badge
}
// Lookup: profiles.lookup(id: pubkey) -> Profile?
// Typical access: 1-10 profiles per notification
// Core data structure:
classContacts{privatevarfriends:Set<Pubkey> // All followed pubkeys
// Used for: notification_only_from_following setting
}
// Source: Kind 3 event with ["p", pubkey] tags
Current Database Size Breakdown (Estimated)
Sub-database
Purpose
Est. Size %
Needed by Extensions
NDB_DB_NOTE_TEXT
Fulltext search
30-40%
No
NDB_DB_PROFILE_SEARCH
Profile search
10-15%
No
NDB_DB_NOTE
All notes
20-30%
No (except mute list event)
NDB_DB_NOTE_BLOCKS
Parsed content
5-10%
No
NDB_DB_META
Note metadata
5-10%
No
NDB_DB_PROFILE
Profile data
5-10%
Yes
NDB_DB_PROFILE_PK
Profile index
1-2%
Yes
Other indexes
Various
5-10%
No
Conclusion: Extensions need ~10-15% of the database, but we copy 100%.
Approach 1: Selective LMDB Sub-database Copy
Beads:optimize-storage-ndb-snapshots-hf9
Concept
Modify nostrdb at the C level to copy only specific LMDB sub-databases instead of the entire environment.
Implementation
C-Level Changes (nostrdb.c)
/// Copy only selected sub-databases to a new LMDB environment/// @param ndb The source nostrdb instance/// @param path Destination path for the selective snapshot/// @param dbs Array of database IDs to copy/// @param num_dbs Number of databases in the array/// @return 0 on success, error code on failureintndb_selective_snapshot(structndb*ndb, constchar*path,
enumndb_dbs*dbs, intnum_dbs) {
MDB_env*dst_env;
MDB_txn*src_txn, *dst_txn;
intrc;
// Create destination environmentrc=mdb_env_create(&dst_env);
if (rc!=0) returnrc;
rc=mdb_env_set_maxdbs(dst_env, num_dbs);
if (rc!=0) { mdb_env_close(dst_env); returnrc; }
// Set mapsize large enough for selective data (default 10MB is too small)// 512MB should be sufficient for profiles + indexesrc=mdb_env_set_mapsize(dst_env, 512ULL*1024*1024);
if (rc!=0) { mdb_env_close(dst_env); returnrc; }
rc=mdb_env_open(dst_env, path, MDB_NOSUBDIR, 0644);
if (rc!=0) { mdb_env_close(dst_env); returnrc; }
// Begin transactionsrc=mdb_txn_begin(ndb->lmdb.env, NULL, MDB_RDONLY, &src_txn);
if (rc!=0) { mdb_env_close(dst_env); returnrc; }
rc=mdb_txn_begin(dst_env, NULL, 0, &dst_txn);
if (rc!=0) {
mdb_txn_abort(src_txn);
mdb_env_close(dst_env);
returnrc;
}
// Copy each selected databasefor (inti=0; i<num_dbs; i++) {
rc=copy_single_db(src_txn, dst_txn, ndb, dbs[i]);
if (rc!=0) break;
}
if (rc==0) {
mdb_txn_commit(dst_txn);
} else {
mdb_txn_abort(dst_txn);
}
mdb_txn_abort(src_txn);
mdb_env_close(dst_env);
returnrc;
}
Swift Interface (Ndb.swift)
extensionNdb{
/// Creates a selective snapshot containing only extension-required data
func selectiveSnapshot(path:String)throws{
// Databases needed by extensions
letrequiredDbs:[ndb_dbs]=[
NDB_DB_PROFILE,
NDB_DB_PROFILE_PK,
NDB_DB_NOTE, // For mute list events only
NDB_DB_NOTE_ID,
NDB_DB_NOTE_PUBKEY_KIND // To query mute lists by kind
]trywithNdb{vardbs= requiredDbs.map{ $0.rawValue }letrc=ndb_selective_snapshot(self.ndb.ndb,
path,&dbs,Int32(dbs.count))guard rc ==0else{throwSnapshotError.selectiveCopyFailed(errno: rc)}}}}
Tradeoffs
Aspect
Assessment
Storage Savings
70-80% reduction
Implementation Complexity
HIGH - Requires C expertise
Code Maintainability
Medium - New C code to maintain
Risk Level
Medium - Must ensure DB consistency
Performance Impact
Good - Native LMDB operations
Extension Code Changes
None - Same LMDB format
Testing Complexity
High - Need to verify all edge cases
Benefits
Maximum storage efficiency while keeping LMDB format
Extensions use identical code paths
ACID guarantees preserved
No serialization/deserialization overhead
Risks
Requires deep LMDB knowledge
Must handle cross-database references correctly
nostrdb version coordination needed
Harder to review (C code)
Complexity Breakdown
New C function: ~150-200 lines
Swift wrapper: ~30 lines
Tests: ~200 lines
Documentation: Required
Verdict
Consider if C changes are acceptable and team has LMDB expertise.
Approach 2: Separate Lightweight Extension Database
Beads:optimize-storage-ndb-snapshots-i6a
Concept
Create a dedicated small database (SQLite recommended) that only stores extension-needed data. Main app writes to both databases.
Implementation
Data Model (ExtensionData.swift)
/// Lightweight data store for extension access
actorExtensionDataStore{privateletdbPath:URLprivatevardb:OpaquePointer?init()throws{guardlet containerURL =FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier:"group.com.damus")else{throwExtensionDataError.containerUnavailable
}self.dbPath = containerURL.appendingPathComponent("extension_data.sqlite")tryopenDatabase()trycreateSchema()}privatefunc createSchema()throws{letschema=""" CREATE TABLE IF NOT EXISTS profiles ( pubkey TEXT PRIMARY KEY, name TEXT, display_name TEXT, picture TEXT, nip05 TEXT, updated_at INTEGER ); CREATE TABLE IF NOT EXISTS mute_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, -- 'user', 'hashtag', 'word', 'thread' value TEXT NOT NULL, expiration INTEGER, UNIQUE(type, value) ); CREATE TABLE IF NOT EXISTS contacts ( pubkey TEXT PRIMARY KEY ); CREATE TABLE IF NOT EXISTS metadata ( key TEXT PRIMARY KEY, value TEXT );"""tryexecute(schema)}}
Profile Sync (ProfileSyncService.swift)
/// Syncs profile updates to extension database
classProfileSyncService{privateletextensionStore:ExtensionDataStoreprivateletndb:Ndb
/// Called when a profile is updated in the main database
func syncProfile(_ pubkey:Pubkey)async{guardlet profile =try? ndb.lookup_profile_and_copy(pubkey)else{return}await extensionStore.upsertProfile(
pubkey: pubkey.hex(),
name: profile.name,
displayName: profile.display_name,
picture: profile.picture,
nip05: profile.nip05
)}
/// Batch sync all profiles (for initial setup or recovery)
func syncAllProfiles()async{
// Query all profile keys from ndb
// Batch insert into extension database
}}
structNotificationExtensionState{letextensionData:ExtensionDataStore // New: lightweight store
// Remove: let ndb: Ndb
init?(){guardlet store =try?ExtensionDataStore()else{returnnil}self.extensionData = store
// ... rest of init
}func lookupProfile(_ pubkey:Pubkey)async->ExtensionProfile?{returnawait extensionData.getProfile(pubkey.hex())}func isMuted(_ pubkey:Pubkey)async->Bool{returnawait extensionData.isMutedUser(pubkey.hex())}}
Tradeoffs
Aspect
Assessment
Storage Savings
85-95% reduction
Implementation Complexity
MEDIUM-HIGH
Code Maintainability
Medium - New subsystem
Risk Level
Medium - Dual-write consistency
Performance Impact
Good - SQLite is fast for small data
Extension Code Changes
Significant - New data access layer
Testing Complexity
Medium - Standard SQLite testing
Benefits
Clear separation of concerns
Extensions get purpose-built data store
Can optimize schema for extension queries
No nostrdb C changes needed
SQLite is well-understood
Risks
Dual-write complexity (must keep both DBs in sync)
Data consistency between databases
Additional codebase to maintain
Schema evolution challenges
Must handle sync failures gracefully
Complexity Breakdown
ExtensionDataStore: ~300 lines
Sync services: ~200 lines
Extension changes: ~100 lines
Tests: ~300 lines
Migration: ~50 lines
Verdict
Good option if willing to maintain separate data layer.
Approach 3: JSON/Plist Data Export
Beads:optimize-storage-ndb-snapshots-w5i
Concept
Export needed data to JSON files in the shared container. Extensions load JSON on startup. Simplest possible implementation.
Implementation
Data Structures (ExtensionData.swift)
/// Codable structure for extension data export
/// Note: contacts uses [String] instead of Set<String> for deterministic JSON ordering.
/// The reader converts this back to Set<String> for O(1) lookup performance.
structExtensionDataExport:Codable{letprofiles:[String:ProfileExport] // pubkey -> profile
letmuteList:MuteListExportletcontacts:[String] // followed pubkeys (sorted for deterministic output)
letexportedAt:Dateletversion:Int=1}structProfileExport:Codable{letname:String?letdisplayName:String?letpicture:String?letnip05:String?}structMuteListExport:Codable{letusers:[MuteItemExport]lethashtags:[MuteItemExport]letwords:[MuteItemExport]letthreads:[MuteItemExport]}structMuteItemExport:Codable{letvalue:Stringletexpiration:Date?}
Exporter (ExtensionDataExporter.swift)
/// Exports extension data to JSON in shared container
actorExtensionDataExporter{privateletndb:Ndbprivateletcontacts:ContactsprivateletmutelistManager:MutelistManagerprivatestaticletexportPath:URL?={FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier:"group.com.damus")?.appendingPathComponent("extension_data.json")}()
/// Export all extension data to JSON
/// Called on snapshot interval (1 hour) or on significant changes
func exportData()asyncthrows{guardlet exportPath =Self.exportPath else{throwExportError.containerUnavailable
}letdata=ExtensionDataExport(
profiles:awaitgatherProfiles(),
muteList:gatherMuteList(),
contacts:gatherContacts(),
exportedAt:Date())letencoder=JSONEncoder()
encoder.dateEncodingStrategy =.iso8601
encoder.outputFormatting =[.sortedKeys] // Deterministic output
letjsonData=try encoder.encode(data)try jsonData.write(to: exportPath, options:.atomic)Log.info("Exported extension data: %d profiles, %d contacts",
for:.storage, data.profiles.count, data.contacts.count)}privatefunc gatherProfiles()async->[String:ProfileExport]{varprofiles:[String:ProfileExport]=[:]
// Export profiles for:
// 1. All contacts (followed users)
// 2. Muted users (need name for "Muted user" display)
// 3. Recent notification senders (mentions from non-followed users)
// - Query recent kind 1/4/7/9735 events that tag our pubkey
// - Extract sender pubkeys from last N events or last 24h
// - This prevents raw pubkey display in notifications
// 4. Users who have interacted with us recently (replies, zaps)
letrelevantPubkeys=gatherRelevantPubkeys()forpubkeyin relevantPubkeys {guardlet profile =try? ndb.lookup_profile_and_copy(pubkey)else{continue}profiles[pubkey.hex()]=ProfileExport(
name: profile.name,
displayName: profile.display_name,
picture: profile.picture,
nip05: profile.nip05
)}return profiles
}
/// Gather all pubkeys whose profiles should be exported
privatefunc gatherRelevantPubkeys()->Set<Pubkey>{varpubkeys=Set<Pubkey>()
// 1. All followed users
pubkeys.formUnion(contacts.get_friend_list())
// 2. Muted users (for display purposes)
foritemin mutelistManager.users {if case .user(let pk, _)= item {
pubkeys.insert(pk)}}
// 3. Recent notification senders (prevents raw pubkey in notifications)
// Query events that mention our pubkey from the last 24 hours
letrecentMentionSenders=queryRecentMentionSenders(
ourPubkey: keypair.pubkey,
since:Date().addingTimeInterval(-24*60*60))
pubkeys.formUnion(recentMentionSenders)return pubkeys
}
/// Query pubkeys of users who mentioned us recently
///
/// Performance expectations:
/// - Uses NDB_DB_NOTE_TAGS index (indexed by tag type + value)
/// - Query: filter by "p" tag = ourPubkey, created_at > since
/// - Expected: O(log n) index lookup + O(k) scan where k = matching events
/// - Typical k: 100-1000 events in 24h for active users
/// - Estimated time: <100ms for most users
///
/// Data source: On-disk LMDB index, not in-memory cache.
/// This runs during export (background thread), not in extension.
privatefunc queryRecentMentionSenders(ourPubkey:Pubkey, since:Date)->[Pubkey]{letfilter=NostrFilter(
pubkeys:[ourPubkey], // Events tagging us
since:UInt32(since.timeIntervalSince1970),
limit:1000 // Cap to prevent runaway queries
)guardlet noteKeys =try? ndb.query(filters:[filter], maxResults:1000)else{return[]}varsenders=Set<Pubkey>()fornoteKeyin noteKeys {try? ndb.lookup_note_by_key(noteKey){ note iniflet note {
senders.insert(note.pubkey)}}}returnArray(senders)}privatefunc gatherMuteList()->MuteListExport{
// Use compactMap to gracefully skip malformed items instead of crashing
returnMuteListExport(
users: mutelistManager.users.compactMap{ item inguard case .user(let pk,let exp)= item else{Log.warning("Unexpected mute item type in users set", for:.storage)returnnil}returnMuteItemExport(value: pk.hex(), expiration: exp)},
hashtags: mutelistManager.hashtags.compactMap{ item inguard case .hashtag(let tag,let exp)= item else{Log.warning("Unexpected mute item type in hashtags set", for:.storage)returnnil}returnMuteItemExport(value: tag.hashtag, expiration: exp)},
words: mutelistManager.words.compactMap{ item inguard case .word(let word,let exp)= item else{Log.warning("Unexpected mute item type in words set", for:.storage)returnnil}returnMuteItemExport(value: word, expiration: exp)},
threads: mutelistManager.threads.compactMap{ item inguard case .thread(let noteId,let exp)= item else{Log.warning("Unexpected mute item type in threads set", for:.storage)returnnil}returnMuteItemExport(value: noteId.hex(), expiration: exp)})}privatefunc gatherContacts()->[String]{
// Return sorted array for deterministic JSON output
return contacts.get_friend_list().map{ $0.hex()}.sorted()}}
Extension Reader (ExtensionDataReader.swift)
/// Reads exported extension data in notification service
structExtensionDataReader{privateletdata:ExtensionDataExport?privateletcontactsSet:Set<String> // O(1) lookup from sorted array
init(){guardlet url =Self.dataURL,let jsonData =try?Data(contentsOf: url)else{self.data =nilself.contactsSet =[]return}letdecoder=JSONDecoder()
decoder.dateDecodingStrategy =.iso8601 // Must match encoder
guardlet decoded =try? decoder.decode(ExtensionDataExport.self, from: jsonData)else{self.data =nilself.contactsSet =[]return}self.data = decoded
self.contactsSet =Set(decoded.contacts) // Convert to Set for O(1) lookup
}privatestaticletdataURL:URL?={FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier:"group.com.damus")?.appendingPathComponent("extension_data.json")}()func profile(for pubkey:String)->ProfileExport?{return data?.profiles[pubkey]}func isUserMuted(_ pubkey:String)->Bool{guardlet muteList = data?.muteList else{returnfalse}return muteList.users.contains{ item in
item.value == pubkey && !item.isExpired
}}func isFollowing(_ pubkey:String)->Bool{return contactsSet.contains(pubkey)}func isWordMuted(_ content:String)->Bool{guardlet muteList = data?.muteList else{returnfalse}letlowercased= content.lowercased()return muteList.words.contains{ item in
!item.isExpired && lowercased.contains(item.value.lowercased())}}}extensionMuteItemExport{varisExpired:Bool{guardlet exp = expiration else{returnfalse}return exp <Date()}}
actorDatabaseSnapshotManager{privateletndb:Ndbprivateletexporter:ExtensionDataExporterfunc performSnapshot()asyncthrows{
// Replace full DB snapshot with JSON export
tryawait exporter.exportData()UserDefaults.standard.set(Date(), forKey:Self.lastSnapshotDateKey)Log.info("Extension data export completed successfully", for:.storage)}}
Tradeoffs
Aspect
Assessment
Storage Savings
90-95% reduction
Implementation Complexity
LOW
Code Maintainability
Easy - Pure Swift, Codable
Risk Level
Low - Well-understood format
Performance Impact
Good for typical sizes
Extension Code Changes
Moderate - New reader class
Testing Complexity
Low - Easy to unit test
Benefits
Simplest implementation - Pure Swift, Codable
Human-readable for debugging
Easy to version and migrate
No database dependencies in extensions
Atomic writes with .atomic option
Easy to test (just JSON files)
Risks
O(n) word mute checking (mitigated by small list size)
Memory pressure with very large datasets (unlikely)
Must reload file if it changes (extensions are short-lived anyway)
Problem: If a non-followed user mentions you, the notification would show a raw pubkey instead of their display name if we only export contacts + muted users.
Solution: Include recent notification senders in the profile export:
Query events that tag our pubkey from the last 24 hours
Extract unique sender pubkeys
Include their profiles in the export
Tradeoff:
Adds ~100-500 more profiles to export (estimated)
Increases export time slightly
Prevents ugly raw pubkey display in notifications
Alternative: If querying recent mentions is expensive:
Fall back to truncated pubkey display (e.g., npub1abc...xyz)
This is acceptable UX for rare cases of mentions from unknown users
Can be implemented as progressive enhancement later
Only copy data that changed since the last snapshot, reducing I/O and storage churn.
Implementation Challenges
The Core Problem
LMDB doesn't expose page-level change tracking. To implement deltas:
// Would need to track changes at application level
classChangeTracker{varmodifiedProfileKeys:Set<ProfileKey>=[]varmodifiedNoteKeys:Set<NoteKey>=[]varlastSnapshotVersion:UInt64=0
// Hook into every write operation
func onProfileWrite(_ key:ProfileKey){
modifiedProfileKeys.insert(key)}}
Delta Application Logic
func applyDelta(basePath:String, deltaPath:String)throws{
// 1. Read delta file
// 2. Open base database
// 3. Apply each change
// 4. Handle deletions
// 5. Verify consistency
// This is complex and error-prone
}
Tradeoffs
Aspect
Assessment
Storage Savings
Variable (0-80% depending on churn)
Implementation Complexity
VERY HIGH
Code Maintainability
Hard - Complex state management
Risk Level
High - Consistency hard to verify
Performance Impact
Variable
Extension Code Changes
Significant
Testing Complexity
Very High
Benefits
Minimal I/O per snapshot
Reduced battery impact
Could enable more frequent updates
Risks
Extremely complex to implement correctly
Corruption recovery is difficult
LMDB doesn't support this natively
Must track every write operation
Merge conflicts possible
Hard to verify correctness
Complexity Breakdown
Change tracking: ~500 lines
Delta generation: ~400 lines
Delta application: ~400 lines
Recovery logic: ~300 lines
Tests: ~800 lines
Total: ~2,400+ lines
Verdict
NOT RECOMMENDED - Complexity far exceeds benefits.
Approach 5: Compressed Snapshots
Beads:optimize-storage-ndb-snapshots-i3j
Concept
Compress the snapshot data before writing to shared container. Extensions decompress before use.