an atproto pds written in F# (.NET 9) 馃
pds fsharp giraffe dotnet atproto
at main 7.3 kB view raw
1namespace PDSharp.Core 2 3open System 4open System.IO 5open Microsoft.Data.Sqlite 6open Dapper 7open PDSharp.Core.BlockStore 8open PDSharp.Core.Auth 9open System.Threading.Tasks 10open PDSharp.Core.Config 11 12/// SQLite persistence layer 13module SqliteStore = 14 15 /// Initialize the database schema 16 let initialize (config : AppConfig) = 17 use conn = new SqliteConnection(config.SqliteConnectionString) 18 19 conn.Open() 20 conn.Execute("PRAGMA journal_mode=WAL;") |> ignore 21 22 if config.DisableWalAutoCheckpoint then 23 conn.Execute("PRAGMA wal_autocheckpoint=0;") |> ignore 24 25 // TODO: fast, slightly less safe. Keep default (FULL) for now. 26 // conn.Execute("PRAGMA synchronous=NORMAL;") |> ignore 27 28 conn.Execute( 29 """ 30 CREATE TABLE IF NOT EXISTS blocks ( 31 cid TEXT PRIMARY KEY, 32 data BLOB NOT NULL 33 ); 34 """ 35 ) 36 |> ignore 37 38 39 conn.Execute( 40 """ 41 CREATE TABLE IF NOT EXISTS accounts ( 42 did TEXT PRIMARY KEY, 43 handle TEXT NOT NULL UNIQUE, 44 password_hash TEXT NOT NULL, 45 email TEXT, 46 created_at TEXT NOT NULL 47 ); 48 """ 49 ) 50 |> ignore 51 52 conn.Execute( 53 """ 54 CREATE TABLE IF NOT EXISTS repos ( 55 did TEXT PRIMARY KEY, 56 rev TEXT NOT NULL, 57 mst_root_cid TEXT NOT NULL, 58 head_cid TEXT, 59 collections_json TEXT -- Just store serialized collection map for now 60 ); 61 """ 62 ) 63 |> ignore 64 65 conn.Execute( 66 """ 67 CREATE TABLE IF NOT EXISTS signing_keys ( 68 did TEXT PRIMARY KEY, 69 k TEXT NOT NULL -- Hex encoded private key D 70 ); 71 """ 72 ) 73 |> ignore 74 75 /// DTOs for Sqlite Mapping 76 type RepoRow = { 77 did : string 78 rev : string 79 mst_root_cid : string 80 head_cid : string 81 collections_json : string 82 } 83 84 type BlockRow = { cid : string; data : byte[] } 85 86 /// DTO for account rows with nullable email 87 [<CLIMutable>] 88 type AccountRow = { 89 did : string 90 handle : string 91 password_hash : string 92 email : string // Nullable in DB, null becomes null here 93 created_at : string 94 } 95 96 let private toAccount (row : AccountRow) : Account = { 97 Did = row.did 98 Handle = row.handle 99 PasswordHash = row.password_hash 100 Email = if isNull row.email then None else Some row.email 101 CreatedAt = DateTimeOffset.Parse row.created_at 102 } 103 104 type IRepoStore = 105 abstract member GetRepo : string -> Async<RepoRow option> 106 abstract member SaveRepo : RepoRow -> Async<unit> 107 108 type SqliteBlockStore(connectionString : string) = 109 interface IBlockStore with 110 member _.Put(data : byte[]) = async { 111 let hash = Crypto.sha256 data 112 let cid = Cid.FromHash hash 113 let cidStr = cid.ToString() 114 115 use conn = new SqliteConnection(connectionString) 116 117 let! _ = 118 conn.ExecuteAsync( 119 "INSERT OR IGNORE INTO blocks (cid, data) VALUES (@cid, @data)", 120 {| cid = cidStr; data = data |} 121 ) 122 |> Async.AwaitTask 123 124 return cid 125 } 126 127 member _.Get(cid : Cid) = async { 128 use conn = new SqliteConnection(connectionString) 129 130 let! result = 131 conn.QuerySingleOrDefaultAsync<byte[]>("SELECT data FROM blocks WHERE cid = @cid", {| cid = cid.ToString() |}) 132 |> Async.AwaitTask 133 134 if isNull result then return None else return Some result 135 } 136 137 member _.Has(cid : Cid) = async { 138 use conn = new SqliteConnection(connectionString) 139 140 let! count = 141 conn.ExecuteScalarAsync<int>("SELECT COUNT(1) FROM blocks WHERE cid = @cid", {| cid = cid.ToString() |}) 142 |> Async.AwaitTask 143 144 return count > 0 145 } 146 147 member _.GetAllCidsAndData() = async { 148 use conn = new SqliteConnection(connectionString) 149 let! rows = conn.QueryAsync<BlockRow>("SELECT cid, data FROM blocks") |> Async.AwaitTask 150 151 return 152 rows 153 |> Seq.map (fun r -> (r.cid, r.data)) 154 |> Seq.choose (fun (cidStr, data) -> 155 match Cid.TryParse cidStr with 156 | Some c -> Some(c, data) 157 | None -> None) 158 |> Seq.toList 159 } 160 161 type SqliteAccountStore(connectionString : string) = 162 interface IAccountStore with 163 member _.CreateAccount(account : Account) = async { 164 use conn = new SqliteConnection(connectionString) 165 166 try 167 let emailValue = account.Email |> Option.toObj 168 let createdAtStr = account.CreatedAt.ToString "o" 169 170 let! _ = 171 conn.ExecuteAsync( 172 """ 173 INSERT INTO accounts (did, handle, password_hash, email, created_at) 174 VALUES (@Did, @Handle, @PasswordHash, @Email, @CreatedAt) 175 """, 176 {| 177 Did = account.Did 178 Handle = account.Handle 179 PasswordHash = account.PasswordHash 180 Email = emailValue 181 CreatedAt = createdAtStr 182 |} 183 ) 184 |> Async.AwaitTask 185 186 return Ok() 187 with 188 | :? SqliteException as ex when ex.SqliteErrorCode = 19 -> // Constraint violation 189 return Error "Account already exists (handle or DID taken)" 190 | ex -> return Error ex.Message 191 } 192 193 member _.GetAccountByHandle(handle : string) = async { 194 use conn = new SqliteConnection(connectionString) 195 196 let! result = 197 conn.QuerySingleOrDefaultAsync<AccountRow>( 198 "SELECT * FROM accounts WHERE handle = @handle", 199 {| handle = handle |} 200 ) 201 |> Async.AwaitTask 202 203 if isNull (box result) then 204 return None 205 else 206 return Some(toAccount result) 207 } 208 209 member _.GetAccountByDid(did : string) = async { 210 use conn = new SqliteConnection(connectionString) 211 212 let! result = 213 conn.QuerySingleOrDefaultAsync<AccountRow>("SELECT * FROM accounts WHERE did = @did", {| did = did |}) 214 |> Async.AwaitTask 215 216 if isNull (box result) then 217 return None 218 else 219 return Some(toAccount result) 220 } 221 222 type SqliteRepoStore(connectionString : string) = 223 interface IRepoStore with 224 member _.GetRepo(did : string) : Async<RepoRow option> = async { 225 use conn = new SqliteConnection(connectionString) 226 227 let! result = 228 conn.QuerySingleOrDefaultAsync<RepoRow>("SELECT * FROM repos WHERE did = @did", {| did = did |}) 229 |> Async.AwaitTask 230 231 if isNull (box result) then 232 return None 233 else 234 return Some result 235 } 236 237 member _.SaveRepo(repo : RepoRow) : Async<unit> = async { 238 use conn = new SqliteConnection(connectionString) 239 240 let! _ = 241 conn.ExecuteAsync( 242 """ 243 INSERT INTO repos (did, rev, mst_root_cid, head_cid, collections_json) 244 VALUES (@did, @rev, @mst_root_cid, @head_cid, @collections_json) 245 ON CONFLICT(did) DO UPDATE SET 246 rev = @rev, 247 mst_root_cid = @mst_root_cid, 248 head_cid = @head_cid, 249 collections_json = @collections_json 250 """, 251 repo 252 ) 253 |> Async.AwaitTask 254 255 () 256 }