damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

Ndb.swift (16610B)


      1 //
      2 //  Ndb.swift
      3 //  damus
      4 //
      5 //  Created by William Casarin on 2023-08-25.
      6 //
      7 
      8 import Foundation
      9 import OSLog
     10 
     11 fileprivate let APPLICATION_GROUP_IDENTIFIER = "group.com.damus"
     12 
     13 enum NdbSearchOrder {
     14     case oldest_first
     15     case newest_first
     16 }
     17 
     18 
     19 enum DatabaseError: Error {
     20     case failed_open
     21 
     22     var errorDescription: String? {
     23         switch self {
     24         case .failed_open:
     25             return "Failed to open database"
     26         }
     27     }
     28 }
     29 
     30 class Ndb {
     31     var ndb: ndb_t
     32     let path: String?
     33     let owns_db: Bool
     34     var generation: Int
     35     private var closed: Bool
     36 
     37     var is_closed: Bool {
     38         self.closed || self.ndb.ndb == nil
     39     }
     40 
     41     static func safemode() -> Ndb? {
     42         guard let path = db_path ?? old_db_path else { return nil }
     43 
     44         // delete the database and start fresh
     45         if Self.db_files_exist(path: path) {
     46             let file_manager = FileManager.default
     47             for db_file in db_files {
     48                 try? file_manager.removeItem(atPath: "\(path)/\(db_file)")
     49             }
     50         }
     51 
     52         guard let ndb = Ndb(path: path) else {
     53             return nil
     54         }
     55 
     56         return ndb
     57     }
     58 
     59     // NostrDB used to be stored on the app container's document directory
     60     static private var old_db_path: String? {
     61         guard let path = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.absoluteString else {
     62             return nil
     63         }
     64         return remove_file_prefix(path)
     65     }
     66 
     67     static var db_path: String? {
     68         // Use the `group.com.damus` container, so that it can be accessible from other targets
     69         // e.g. The notification service extension needs to access Ndb data, which is done through this shared file container.
     70         guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: APPLICATION_GROUP_IDENTIFIER) else {
     71             return nil
     72         }
     73         return remove_file_prefix(containerURL.absoluteString)
     74     }
     75     
     76     static private var db_files: [String] = ["data.mdb", "lock.mdb"]
     77 
     78     static var empty: Ndb {
     79         print("txn: NOSTRDB EMPTY")
     80         return Ndb(ndb: ndb_t(ndb: nil))
     81     }
     82     
     83     static func open(path: String? = nil, owns_db_file: Bool = true) -> ndb_t? {
     84         var ndb_p: OpaquePointer? = nil
     85 
     86         let ingest_threads: Int32 = 4
     87         var mapsize: Int = 1024 * 1024 * 1024 * 32
     88         
     89         if path == nil && owns_db_file {
     90             // `nil` path indicates the default path will be used.
     91             // The default path changed over time, so migrate the database to the new location if needed
     92             do {
     93                 try Self.migrate_db_location_if_needed()
     94             }
     95             catch {
     96                 // If it fails to migrate, the app can still run without serious consequences. Log instead.
     97                 Log.error("Error migrating NostrDB to new file container", for: .storage)
     98             }
     99         }
    100         
    101         guard let db_path = Self.db_path,
    102               owns_db_file || Self.db_files_exist(path: db_path) else {
    103             return nil      // If the caller claims to not own the DB file, and the DB files do not exist, then we should not initialize Ndb
    104         }
    105 
    106         guard let path = path.map(remove_file_prefix) ?? Ndb.db_path else {
    107             return nil
    108         }
    109 
    110         let ok = path.withCString { testdir in
    111             var ok = false
    112             while !ok && mapsize > 1024 * 1024 * 700 {
    113                 var cfg = ndb_config(flags: 0, ingester_threads: ingest_threads, mapsize: mapsize, filter_context: nil, ingest_filter: nil)
    114                 ok = ndb_init(&ndb_p, testdir, &cfg) != 0
    115                 if !ok {
    116                     mapsize /= 2
    117                 }
    118             }
    119             return ok
    120         }
    121 
    122         if !ok {
    123             return nil
    124         }
    125 
    126         return ndb_t(ndb: ndb_p)
    127     }
    128 
    129     init?(path: String? = nil, owns_db_file: Bool = true) {
    130         guard let db = Self.open(path: path, owns_db_file: owns_db_file) else {
    131             return nil
    132         }
    133         
    134         self.generation = 0
    135         self.path = path
    136         self.owns_db = owns_db_file
    137         self.ndb = db
    138         self.closed = false
    139     }
    140     
    141     private static func migrate_db_location_if_needed() throws {
    142         guard let old_db_path, let db_path else {
    143             throw Errors.cannot_find_db_path
    144         }
    145         
    146         let file_manager = FileManager.default
    147         
    148         let old_db_files_exist = Self.db_files_exist(path: old_db_path)
    149         let new_db_files_exist = Self.db_files_exist(path: db_path)
    150         
    151         // Migration rules:
    152         // 1. If DB files exist in the old path but not the new one, move files to the new path
    153         // 2. If files do not exist anywhere, do nothing (let new DB be initialized)
    154         // 3. If files exist in the new path, but not the old one, nothing needs to be done
    155         // 4. If files exist on both, do nothing.
    156         // Scenario 4 likely means that user has downgraded and re-upgraded.
    157         // Although it might make sense to get the most recent DB, it might lead to data loss.
    158         // If we leave both intact, it makes it easier to fix later, as no data loss would occur.
    159         if old_db_files_exist && !new_db_files_exist {
    160             Log.info("Migrating NostrDB to new file location…", for: .storage)
    161             do {
    162                 try db_files.forEach { db_file in
    163                     let old_path = "\(old_db_path)/\(db_file)"
    164                     let new_path = "\(db_path)/\(db_file)"
    165                     try file_manager.moveItem(atPath: old_path, toPath: new_path)
    166                 }
    167                 Log.info("NostrDB files successfully migrated to the new location", for: .storage)
    168             } catch {
    169                 throw Errors.db_file_migration_error
    170             }
    171         }
    172     }
    173     
    174     private static func db_files_exist(path: String) -> Bool {
    175         return db_files.allSatisfy { FileManager.default.fileExists(atPath: "\(path)/\($0)") }
    176     }
    177 
    178     init(ndb: ndb_t) {
    179         self.ndb = ndb
    180         self.generation = 0
    181         self.path = nil
    182         self.owns_db = true
    183         self.closed = false
    184     }
    185     
    186     func close() {
    187         guard !self.is_closed else { return }
    188         self.closed = true
    189         print("txn: CLOSING NOSTRDB")
    190         ndb_destroy(self.ndb.ndb)
    191         self.generation += 1
    192         print("txn: NOSTRDB CLOSED")
    193     }
    194 
    195     func reopen() -> Bool {
    196         guard self.is_closed,
    197               let db = Self.open(path: self.path, owns_db_file: self.owns_db) else {
    198             return false
    199         }
    200         
    201         print("txn: NOSTRDB REOPENED (gen \(generation))")
    202 
    203         self.closed = false
    204         self.ndb = db
    205         return true
    206     }
    207 
    208     func lookup_note_by_key_with_txn<Y>(_ key: NoteKey, txn: NdbTxn<Y>) -> NdbNote? {
    209         var size: Int = 0
    210         guard let note_p = ndb_get_note_by_key(&txn.txn, key, &size) else {
    211             return nil
    212         }
    213         return NdbNote(note: note_p, size: size, owned: false, key: key)
    214     }
    215 
    216     func text_search(query: String, limit: Int = 32, order: NdbSearchOrder = .newest_first) -> [NoteKey] {
    217         guard let txn = NdbTxn(ndb: self) else { return [] }
    218         var results = ndb_text_search_results()
    219         let res = query.withCString { q in
    220             let order = order == .newest_first ? NDB_ORDER_DESCENDING : NDB_ORDER_ASCENDING
    221             var config = ndb_text_search_config(order: order, limit: Int32(limit))
    222             return ndb_text_search(&txn.txn, q, &results, &config)
    223         }
    224 
    225         if res == 0 {
    226             return []
    227         }
    228 
    229         var note_ids = [NoteKey]()
    230         for i in 0..<results.num_results {
    231             // seriously wtf
    232             switch i {
    233             case 0: note_ids.append(results.results.0.key.note_id)
    234             case 1: note_ids.append(results.results.1.key.note_id)
    235             case 2: note_ids.append(results.results.2.key.note_id)
    236             case 3: note_ids.append(results.results.3.key.note_id)
    237             case 4: note_ids.append(results.results.4.key.note_id)
    238             case 5: note_ids.append(results.results.5.key.note_id)
    239             case 6: note_ids.append(results.results.6.key.note_id)
    240             case 7: note_ids.append(results.results.7.key.note_id)
    241             case 8: note_ids.append(results.results.8.key.note_id)
    242             case 9: note_ids.append(results.results.9.key.note_id)
    243             case 10: note_ids.append(results.results.10.key.note_id)
    244             case 11: note_ids.append(results.results.11.key.note_id)
    245             case 12: note_ids.append(results.results.12.key.note_id)
    246             case 13: note_ids.append(results.results.13.key.note_id)
    247             case 14: note_ids.append(results.results.14.key.note_id)
    248             case 15: note_ids.append(results.results.15.key.note_id)
    249             case 16: note_ids.append(results.results.16.key.note_id)
    250             case 17: note_ids.append(results.results.17.key.note_id)
    251             case 18: note_ids.append(results.results.18.key.note_id)
    252             case 19: note_ids.append(results.results.19.key.note_id)
    253             case 20: note_ids.append(results.results.20.key.note_id)
    254             default:
    255                 break
    256             }
    257         }
    258 
    259         return note_ids
    260     }
    261 
    262     func lookup_note_by_key(_ key: NoteKey) -> NdbTxn<NdbNote?>? {
    263         return NdbTxn(ndb: self) { txn in
    264             lookup_note_by_key_with_txn(key, txn: txn)
    265         }
    266     }
    267 
    268     private func lookup_profile_by_key_inner<Y>(_ key: ProfileKey, txn: NdbTxn<Y>) -> ProfileRecord? {
    269         var size: Int = 0
    270         guard let profile_p = ndb_get_profile_by_key(&txn.txn, key, &size) else {
    271             return nil
    272         }
    273 
    274         return profile_flatbuf_to_record(ptr: profile_p, size: size, key: key)
    275     }
    276 
    277     private func profile_flatbuf_to_record(ptr: UnsafeMutableRawPointer, size: Int, key: UInt64) -> ProfileRecord? {
    278         do {
    279             var buf = ByteBuffer(assumingMemoryBound: ptr, capacity: size)
    280             let rec: NdbProfileRecord = try getDebugCheckedRoot(byteBuffer: &buf)
    281             return ProfileRecord(data: rec, key: key)
    282         } catch {
    283             // Handle error appropriately
    284             print("UNUSUAL: \(error)")
    285             return nil
    286         }
    287     }
    288 
    289     private func lookup_note_with_txn_inner<Y>(id: NoteId, txn: NdbTxn<Y>) -> NdbNote? {
    290         return id.id.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> NdbNote? in
    291             var key: UInt64 = 0
    292             var size: Int = 0
    293             guard let baseAddress = ptr.baseAddress,
    294                   let note_p = ndb_get_note_by_id(&txn.txn, baseAddress, &size, &key) else {
    295                 return nil
    296             }
    297             return NdbNote(note: note_p, size: size, owned: false, key: key)
    298         }
    299     }
    300 
    301     private func lookup_profile_with_txn_inner<Y>(pubkey: Pubkey, txn: NdbTxn<Y>) -> ProfileRecord? {
    302         return pubkey.id.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> ProfileRecord? in
    303             var size: Int = 0
    304             var key: UInt64 = 0
    305 
    306             guard let baseAddress = ptr.baseAddress,
    307                   let profile_p = ndb_get_profile_by_pubkey(&txn.txn, baseAddress, &size, &key)
    308             else {
    309                 return nil
    310             }
    311 
    312             return profile_flatbuf_to_record(ptr: profile_p, size: size, key: key)
    313         }
    314     }
    315 
    316     func lookup_profile_by_key_with_txn<Y>(key: ProfileKey, txn: NdbTxn<Y>) -> ProfileRecord? {
    317         lookup_profile_by_key_inner(key, txn: txn)
    318     }
    319 
    320     func lookup_profile_by_key(key: ProfileKey) -> NdbTxn<ProfileRecord?>? {
    321         return NdbTxn(ndb: self) { txn in
    322             lookup_profile_by_key_inner(key, txn: txn)
    323         }
    324     }
    325 
    326     func lookup_note_with_txn<Y>(id: NoteId, txn: NdbTxn<Y>) -> NdbNote? {
    327         lookup_note_with_txn_inner(id: id, txn: txn)
    328     }
    329 
    330     func lookup_profile_key(_ pubkey: Pubkey) -> ProfileKey? {
    331         guard let txn = NdbTxn(ndb: self, with: { txn in
    332             lookup_profile_key_with_txn(pubkey, txn: txn)
    333         }) else {
    334             return nil
    335         }
    336 
    337         return txn.value
    338     }
    339 
    340     func lookup_profile_key_with_txn<Y>(_ pubkey: Pubkey, txn: NdbTxn<Y>) -> ProfileKey? {
    341         return pubkey.id.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> NoteKey? in
    342             guard let p = ptr.baseAddress else { return nil }
    343             let r = ndb_get_profilekey_by_pubkey(&txn.txn, p)
    344             if r == 0 {
    345                 return nil
    346             }
    347             return r
    348         }
    349     }
    350 
    351     func lookup_note_key_with_txn<Y>(_ id: NoteId, txn: NdbTxn<Y>) -> NoteKey? {
    352         guard !closed else { return nil }
    353         return id.id.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> NoteKey? in
    354             guard let p = ptr.baseAddress else {
    355                 return nil
    356             }
    357             let r = ndb_get_notekey_by_id(&txn.txn, p)
    358             if r == 0 {
    359                 return nil
    360             }
    361             return r
    362         }
    363     }
    364 
    365     func lookup_note_key(_ id: NoteId) -> NoteKey? {
    366         guard let txn = NdbTxn(ndb: self, with: { txn in
    367             lookup_note_key_with_txn(id, txn: txn)
    368         }) else {
    369             return nil
    370         }
    371 
    372         return txn.value
    373     }
    374 
    375     func lookup_note(_ id: NoteId, txn_name: String? = nil) -> NdbTxn<NdbNote?>? {
    376         NdbTxn(ndb: self, name: txn_name) { txn in
    377             lookup_note_with_txn_inner(id: id, txn: txn)
    378         }
    379     }
    380 
    381     func lookup_profile(_ pubkey: Pubkey, txn_name: String? = nil) -> NdbTxn<ProfileRecord?>? {
    382         NdbTxn(ndb: self, name: txn_name) { txn in
    383             lookup_profile_with_txn_inner(pubkey: pubkey, txn: txn)
    384         }
    385     }
    386 
    387     func lookup_profile_with_txn<Y>(_ pubkey: Pubkey, txn: NdbTxn<Y>) -> ProfileRecord? {
    388         lookup_profile_with_txn_inner(pubkey: pubkey, txn: txn)
    389     }
    390     
    391     func process_client_event(_ str: String) -> Bool {
    392         guard !self.is_closed else { return false }
    393         return str.withCString { cstr in
    394             return ndb_process_client_event(ndb.ndb, cstr, Int32(str.utf8.count)) != 0
    395         }
    396     }
    397 
    398     func write_profile_last_fetched(pubkey: Pubkey, fetched_at: UInt64) {
    399         guard !closed else { return }
    400         let _ = pubkey.id.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> () in
    401             guard let p = ptr.baseAddress else { return }
    402             ndb_write_last_profile_fetch(ndb.ndb, p, fetched_at)
    403         }
    404     }
    405 
    406     func read_profile_last_fetched<Y>(txn: NdbTxn<Y>, pubkey: Pubkey) -> UInt64? {
    407         guard !closed else { return nil }
    408         return pubkey.id.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> UInt64? in
    409             guard let p = ptr.baseAddress else { return nil }
    410             let res = ndb_read_last_profile_fetch(&txn.txn, p)
    411             if res == 0 {
    412                 return nil
    413             }
    414 
    415             return res
    416         }
    417     }
    418 
    419     func process_event(_ str: String) -> Bool {
    420         guard !is_closed else { return false }
    421         return str.withCString { cstr in
    422             return ndb_process_event(ndb.ndb, cstr, Int32(str.utf8.count)) != 0
    423         }
    424     }
    425 
    426     func process_events(_ str: String) -> Bool {
    427         guard !is_closed else { return false }
    428         return str.withCString { cstr in
    429             return ndb_process_events(ndb.ndb, cstr, str.utf8.count) != 0
    430         }
    431     }
    432 
    433     func search_profile<Y>(_ search: String, limit: Int, txn: NdbTxn<Y>) -> [Pubkey] {
    434         var pks = Array<Pubkey>()
    435 
    436         return search.withCString { q in
    437             var s = ndb_search()
    438             guard ndb_search_profile(&txn.txn, &s, q) != 0 else {
    439                 return pks
    440             }
    441 
    442             defer { ndb_search_profile_end(&s) }
    443             pks.append(Pubkey(Data(bytes: &s.key.pointee.id.0, count: 32)))
    444 
    445             var n = limit
    446             while n > 0 {
    447                 guard ndb_search_profile_next(&s) != 0 else {
    448                     return pks
    449                 }
    450                 pks.append(Pubkey(Data(bytes: &s.key.pointee.id.0, count: 32)))
    451 
    452                 n -= 1
    453             }
    454 
    455             return pks
    456         }
    457     }
    458     
    459     enum Errors: Error {
    460         case cannot_find_db_path
    461         case db_file_migration_error
    462     }
    463 
    464     deinit {
    465         print("txn: Ndb de-init")
    466         self.close()
    467     }
    468 }
    469 
    470 #if DEBUG
    471 func getDebugCheckedRoot<T: FlatBufferObject>(byteBuffer: inout ByteBuffer) throws -> T {
    472     return getRoot(byteBuffer: &byteBuffer)
    473 }
    474 #else
    475 func getDebugCheckedRoot<T: FlatBufferObject>(byteBuffer: inout ByteBuffer) throws -> T {
    476     return getRoot(byteBuffer: &byteBuffer)
    477 }
    478 #endif
    479 
    480 func remove_file_prefix(_ str: String) -> String {
    481     return str.replacingOccurrences(of: "file://", with: "")
    482 }
    483