an atproto pds written in F# (.NET 9) 馃
pds
fsharp
giraffe
dotnet
atproto
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 }