@@ -1,10 +1,10 @@
1namespace Skrepka
1namespace Skrepka
4open System.IO
5open System.IO
5−open Dapper
6+open Donald
6open Microsoft.Data.Sqlite
7open Microsoft.Data.Sqlite
7−open Dapper.FSharp.SQLite
9module Store =
9module Store =
@@ -41,50 +41,6 @@ module Store =
41 ServerUrl: string
41 ServerUrl: string
42 PollCursor: int64 }
42 PollCursor: int64 }
44− // DB row types matching table columns
50− Photo: byte[] option }
58− Photo: byte[] option }
63− ConversationId: string
82− let private profileTable = table'<ProfileRow> "profile"
83− let private contactTable = table'<ContactRow> "contacts"
84− let private messageTable = table'<MessageRow> "messages"
85− let private settingsTable = table'<SettingsRow> "settings"
86− let private outboxTable = table'<OutboxRow> "outbox"
88 let private log msg = eprintfn $"[skrepka] {msg}"
44 let private log msg = eprintfn $"[skrepka] {msg}"
90 let private appDataDir =
46 let private appDataDir =
@@ -109,78 +65,87 @@ module Store =
109 let private openConn () =
65 let private openConn () =
110 let conn = new SqliteConnection($"Data Source={dbPath}")
66 let conn = new SqliteConnection($"Data Source={dbPath}")
111 conn.Open()
67 conn.Open()
112− conn.Execute("PRAGMA busy_timeout = 5000") |> ignore
68+ conn |> Db.newCommand "PRAGMA busy_timeout = 5000" |> Db.exec
115− type private ByteArrayOptionHandler() =
71+ // ── Readers ──
116− inherit SqlMapper.TypeHandler<byte[] option>()
72+
117−
73+ let private readContact (rd: IDataReader) : Contact =
118− override _.SetValue(p, value) =
74+ { Pubkey = rd.ReadString "Pubkey"
119− p.Value <-
75+ Nickname = rd.ReadString "Nickname"
120− match value with
76+ DisplayName = rd.ReadString "DisplayName"
121− | Some v -> box v
77+ Bio = rd.ReadString "Bio"
122− | None -> box DBNull.Value
78+ Photo = rd.ReadBytesOption "Photo" }
124− override _.Parse(value) =
80+ let private readMessage (rd: IDataReader) : ChatMessage =
125− if value = null || value :? DBNull then
81+ let isOutgoing = rd.ReadBoolean "IsOutgoing"
126− None
82+ let status = rd.ReadInt32 "Status"
84+ { Id = rd.ReadString "Id"
85+ Body = rd.ReadString "Body"
86+ Timestamp = rd.ReadInt64 "TimestampUnix" |> DateTimeOffset.FromUnixTimeMilliseconds
88+ if not isOutgoing then
128− Some(value :?> byte[])
91+ Outgoing(if status = 1 then DeliveryStatus.Delivered else DeliveryStatus.Sent) }
131− OptionTypes.register ()
132− SqlMapper.AddTypeHandler(ByteArrayOptionHandler())
133 use conn = openConn ()
96 use conn = openConn ()
134− conn.Execute("PRAGMA journal_mode=WAL") |> ignore
97+ conn |> Db.newCommand "PRAGMA journal_mode=WAL" |> Db.exec
136− conn.Execute(
99+ conn
137 "CREATE TABLE IF NOT EXISTS profile (Id TEXT PRIMARY KEY, DisplayName TEXT NOT NULL DEFAULT '', Bio TEXT NOT NULL DEFAULT '', Photo BLOB)"
101 "CREATE TABLE IF NOT EXISTS profile (Id TEXT PRIMARY KEY, DisplayName TEXT NOT NULL DEFAULT '', Bio TEXT NOT NULL DEFAULT '', Photo BLOB)"
141− conn.Execute(
104+ conn
142 "CREATE TABLE IF NOT EXISTS contacts (Pubkey TEXT PRIMARY KEY, Nickname TEXT NOT NULL DEFAULT '', DisplayName TEXT NOT NULL DEFAULT '', Bio TEXT NOT NULL DEFAULT '', Photo BLOB)"
106 "CREATE TABLE IF NOT EXISTS contacts (Pubkey TEXT PRIMARY KEY, Nickname TEXT NOT NULL DEFAULT '', DisplayName TEXT NOT NULL DEFAULT '', Bio TEXT NOT NULL DEFAULT '', Photo BLOB)"
146− conn.Execute(
109+ conn
147 "CREATE TABLE IF NOT EXISTS messages (Id TEXT PRIMARY KEY, ConversationId TEXT NOT NULL, Body TEXT NOT NULL DEFAULT '', TimestampUnix INTEGER NOT NULL, IsOutgoing INTEGER NOT NULL DEFAULT 0, Status INTEGER NOT NULL DEFAULT 0)"
111 "CREATE TABLE IF NOT EXISTS messages (Id TEXT PRIMARY KEY, ConversationId TEXT NOT NULL, Body TEXT NOT NULL DEFAULT '', TimestampUnix INTEGER NOT NULL, IsOutgoing INTEGER NOT NULL DEFAULT 0, Status INTEGER NOT NULL DEFAULT 0)"
151− conn.Execute(
114+ conn
152 "CREATE TABLE IF NOT EXISTS settings (Id TEXT PRIMARY KEY, ServerUrl TEXT NOT NULL DEFAULT '', PollCursor INTEGER NOT NULL DEFAULT 0)"
116 "CREATE TABLE IF NOT EXISTS settings (Id TEXT PRIMARY KEY, ServerUrl TEXT NOT NULL DEFAULT '', PollCursor INTEGER NOT NULL DEFAULT 0)"
156− conn.Execute(
119+ conn
157 "CREATE TABLE IF NOT EXISTS outbox (Id INTEGER PRIMARY KEY AUTOINCREMENT, RecipientHex TEXT NOT NULL, EnvelopeJson TEXT NOT NULL, CreatedAt INTEGER NOT NULL)"
121 "CREATE TABLE IF NOT EXISTS outbox (Id INTEGER PRIMARY KEY AUTOINCREMENT, RecipientHex TEXT NOT NULL, EnvelopeJson TEXT NOT NULL, CreatedAt INTEGER NOT NULL)"
161− // Schema versioning
124+ conn
162− conn.Execute("CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
125+ |> Db.newCommand "CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
163− |> ignore
126+ |> Db.exec
165 let dbVersion =
128 let dbVersion =
167− conn.QueryFirstOrDefault<string>("SELECT value FROM schema_meta WHERE key = 'schema_version'")
131+ |> Db.newCommand "SELECT value FROM schema_meta WHERE key = 'schema_version'"
168− |> Option.ofObj
132+ |> Db.querySingle (fun rd -> rd.ReadString "value" |> int)
170 |> Option.defaultValue 0
133 |> Option.defaultValue 0
171 with ex ->
134 with ex ->
172 log $"schema version check: {ex.Message}"
135 log $"schema version check: {ex.Message}"
175 if dbVersion < 2 then
138 if dbVersion < 2 then
176− conn.Execute("CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(ConversationId)")
140+ |> Db.newCommand "CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(ConversationId)"
177− |> ignore
141+ |> Db.exec
179− conn.Execute(
143+ conn
180− "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', @v)",
144+ |> Db.newCommand "INSERT OR REPLACE INTO schema_meta (key, value) VALUES (@key, @value)"
181− {| v = string Constants.schemaVersion |}
145+ |> Db.setParams [ "key", SqlType.String "schema_version"; "value", SqlType.String(string Constants.schemaVersion) ]
185 let private identityPath = Path.Combine(appDataDir, "identity.key")
150 let private identityPath = Path.Combine(appDataDir, "identity.key")
@@ -206,26 +171,21 @@ module Store =
206 File.WriteAllText(identityPath, b64)
171 File.WriteAllText(identityPath, b64)
209 let loadProfile () =
176 let loadProfile () =
212 use conn = openConn ()
179 use conn = openConn ()
216− for p in profileTable do
217− where (p.Id = Constants.profileId)
219− |> conn.SelectAsync<ProfileRow>
224− |> Seq.tryHead
183+ |> Db.newCommand "SELECT DisplayName, Bio, Photo FROM profile WHERE Id = @Id"
225− |> Option.map (fun p ->
184+ |> Db.setParams [ "Id", SqlType.String Constants.profileId ]
185+ |> Db.querySingle (fun rd ->
226− { DisplayName = p.DisplayName
186+ { DisplayName = rd.ReadString "DisplayName"
227− Bio = p.Bio
187+ Bio = rd.ReadString "Bio"
228− Photo = p.Photo })
188+ Photo = rd.ReadBytesOption "Photo" })
229 with ex ->
189 with ex ->
230 log $"loadProfile error: {ex.Message}"
190 log $"loadProfile error: {ex.Message}"
231 return None
191 return None
@@ -236,93 +196,60 @@ module Store =
237 use conn = openConn ()
197 use conn = openConn ()
240− conn.ExecuteAsync(
199+ conn
241− "INSERT OR REPLACE INTO profile (Id, DisplayName, Bio, Photo) VALUES (@Id, @DisplayName, @Bio, @Photo)",
201+ "INSERT OR REPLACE INTO profile (Id, DisplayName, Bio, Photo) VALUES (@Id, @DisplayName, @Bio, @Photo)"
242− {| Id = Constants.profileId
203+ [ "Id", SqlType.String Constants.profileId
243− DisplayName = profile.DisplayName
204+ "DisplayName", SqlType.String profile.DisplayName
244− Bio = profile.Bio
205+ "Bio", SqlType.String profile.Bio
245− Photo = profile.Photo |> Option.defaultValue null |}
207+ (match profile.Photo with
246− )
208+ | Some bytes -> SqlType.Bytes bytes
247− |> Async.AwaitTask
209+ | None -> SqlType.Null) ]
248− |> Async.Ignore
210+ |> Db.exec
249 with ex ->
211 with ex ->
250 log $"saveProfile error: {ex.Message}"
212 log $"saveProfile error: {ex.Message}"
253 let loadData () =
217 let loadData () =
256 use conn = openConn ()
220 use conn = openConn ()
258− let! settings =
222+ let settings =
260− for s in settingsTable do
224+ |> Db.newCommand "SELECT ServerUrl, PollCursor FROM settings WHERE Id = @Id"
261− where (s.Id = Constants.settingsId)
225+ |> Db.setParams [ "Id", SqlType.String Constants.settingsId ]
262− }
226+ |> Db.querySingle (fun rd -> rd.ReadString "ServerUrl", rd.ReadInt64 "PollCursor")
263− |> conn.SelectAsync<SettingsRow>
266− match settings |> Seq.tryHead with
228+ match settings with
267 | None -> return None
229 | None -> return None
268− | Some s ->
230+ | Some(serverUrl, pollCursor) ->
271− for c in contactTable do
274− |> conn.SelectAsync<ContactRow>
277 let contacts =
231 let contacts =
278− contactRows
232+ conn
233+ |> Db.newCommand "SELECT Pubkey, Nickname, DisplayName, Bio, Photo FROM contacts"
234+ |> Db.query readContact
279− |> Seq.map (fun c ->
235+ |> List.map (fun c -> c.Pubkey, c)
280− let contact: Contact =
236+ |> Map.ofList
282− Nickname = c.Nickname
283− DisplayName = c.DisplayName
291− conn.QueryAsync<MessageRow>(
292− $"SELECT Id, ConversationId, Body, TimestampUnix, IsOutgoing, Status FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY ConversationId ORDER BY TimestampUnix DESC) AS rn FROM messages) WHERE rn <= {Constants.maxMessages}"
296 let messages =
238 let messages =
297− messageRows
239+ conn
298− |> Seq.groupBy _.ConversationId
240+ |> Db.newCommand
299− |> Seq.map (fun (convId, rows) ->
241+ $"SELECT Id, Body, TimestampUnix, IsOutgoing, Status, ConversationId FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY ConversationId ORDER BY TimestampUnix DESC) AS rn FROM messages) WHERE rn <= {Constants.maxMessages}"
300− convId,
242+ |> Db.query (fun rd -> rd.ReadString "ConversationId", readMessage rd)
301− rows
243+ |> List.groupBy fst
302− |> Seq.map (fun m ->
244+ |> List.map (fun (convId, pairs) -> convId, pairs |> List.map snd |> List.sortBy _.Timestamp)
303− { Id = m.Id
245+ |> Map.ofList
305− Timestamp = DateTimeOffset.FromUnixTimeMilliseconds m.TimestampUnix
307− if not m.IsOutgoing then
311− if m.Status = 1 then
312− DeliveryStatus.Delivered
316− |> Seq.sortBy _.Timestamp
322 { Contacts = contacts
249 { Contacts = contacts
323 Messages = messages
250 Messages = messages
324− ServerUrl = s.ServerUrl
251+ ServerUrl = serverUrl
325− PollCursor = s.PollCursor }
252+ PollCursor = pollCursor }
326 with ex ->
253 with ex ->
327 log $"loadData error: {ex.Message}"
254 log $"loadData error: {ex.Message}"
328 return None
255 return None
@@ -335,18 +262,19 @@ module Store =
335 use tx = conn.BeginTransaction()
262 use tx = conn.BeginTransaction()
337 for c in data.Contacts.Values do
264 for c in data.Contacts.Values do
339− conn.ExecuteAsync(
265+ conn
340− "INSERT OR REPLACE INTO contacts (Pubkey, Nickname, DisplayName, Bio, Photo) VALUES (@Pubkey, @Nickname, @DisplayName, @Bio, @Photo)",
267+ "INSERT OR REPLACE INTO contacts (Pubkey, Nickname, DisplayName, Bio, Photo) VALUES (@Pubkey, @Nickname, @DisplayName, @Bio, @Photo)"
341− {| Pubkey = c.Pubkey
269+ [ "Pubkey", SqlType.String c.Pubkey
342− Nickname = c.Nickname
270+ "Nickname", SqlType.String c.Nickname
343− DisplayName = c.DisplayName
271+ "DisplayName", SqlType.String c.DisplayName
344− Bio = c.Bio
272+ "Bio", SqlType.String c.Bio
345− Photo = c.Photo |},
273+ "Photo",
346− tx
274+ (match c.Photo with
347− )
275+ | Some bytes -> SqlType.Bytes bytes
348− |> Async.AwaitTask
276+ | None -> SqlType.Null) ]
349− |> Async.Ignore
277+ |> Db.exec
351 for KeyValue(convId, msgs) in data.Messages do
279 for KeyValue(convId, msgs) in data.Messages do
352 for m in msgs do
280 for m in msgs do
@@ -356,55 +284,47 @@ module Store =
356 | Outgoing DeliveryStatus.Delivered -> true, 1
284 | Outgoing DeliveryStatus.Delivered -> true, 1
357 | Outgoing DeliveryStatus.Sent -> true, 0
285 | Outgoing DeliveryStatus.Sent -> true, 0
360− conn.ExecuteAsync(
288+ |> Db.newCommand
361− "INSERT OR REPLACE INTO messages (Id, ConversationId, Body, TimestampUnix, IsOutgoing, Status) VALUES (@Id, @ConversationId, @Body, @TimestampUnix, @IsOutgoing, @Status)",
289+ "INSERT OR REPLACE INTO messages (Id, ConversationId, Body, TimestampUnix, IsOutgoing, Status) VALUES (@Id, @ConversationId, @Body, @TimestampUnix, @IsOutgoing, @Status)"
362− {| Id = m.Id
291+ [ "Id", SqlType.String m.Id
363− ConversationId = convId
292+ "ConversationId", SqlType.String convId
364− Body = m.Body
293+ "Body", SqlType.String m.Body
365− TimestampUnix = m.Timestamp.ToUnixTimeMilliseconds()
294+ "TimestampUnix", SqlType.Int64(m.Timestamp.ToUnixTimeMilliseconds())
366− IsOutgoing = isOutgoing
295+ "IsOutgoing", SqlType.Boolean isOutgoing
367− Status = status |},
296+ "Status", SqlType.Int32 status ]
370− |> Async.AwaitTask
299+ conn
371− |> Async.Ignore
300+ |> Db.newCommand
375− "INSERT OR REPLACE INTO settings (Id, ServerUrl, PollCursor) VALUES (@Id, @ServerUrl, @PollCursor)",
301+ "INSERT OR REPLACE INTO settings (Id, ServerUrl, PollCursor) VALUES (@Id, @ServerUrl, @PollCursor)"
376− {| Id = Constants.settingsId
303+ [ "Id", SqlType.String Constants.settingsId
377− ServerUrl = data.ServerUrl
304+ "ServerUrl", SqlType.String data.ServerUrl
378− PollCursor = data.PollCursor |},
305+ "PollCursor", SqlType.Int64 data.PollCursor ]
384 tx.Commit()
308 tx.Commit()
385 with ex ->
309 with ex ->
386 log $"saveData error: {ex.Message}"
310 log $"saveData error: {ex.Message}"
389 let enqueueOutbox (recipientHex: string) (envelopeJson: string) =
315 let enqueueOutbox (recipientHex: string) (envelopeJson: string) =
392 use conn = openConn ()
318 use conn = openConn ()
395− insert {
321+ |> Db.newCommand
396− for o in outboxTable do
322+ "INSERT INTO outbox (RecipientHex, EnvelopeJson, CreatedAt) VALUES (@RecipientHex, @EnvelopeJson, @CreatedAt)"
397− value
323+ |> Db.setParams
399− RecipientHex = recipientHex
324+ [ "RecipientHex", SqlType.String recipientHex
400− EnvelopeJson = envelopeJson
325+ "EnvelopeJson", SqlType.String envelopeJson
401− CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }
326+ "CreatedAt", SqlType.Int64(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()) ]
408 with ex ->
328 with ex ->
409 log $"enqueueOutbox error: {ex.Message}"
329 log $"enqueueOutbox error: {ex.Message}"
@@ -414,13 +334,13 @@ module Store =
415 use conn = openConn ()
335 use conn = openConn ()
417− let! rows =
337+ return
418− conn.QueryAsync<OutboxRow>(
338+ conn
419− "SELECT Id, RecipientHex, EnvelopeJson, CreatedAt FROM outbox ORDER BY Id LIMIT 1"
339+ |> Db.newCommand "SELECT Id, RecipientHex, EnvelopeJson, CreatedAt FROM outbox ORDER BY Id LIMIT 1"
420− )
340+ |> Db.querySingle (fun rd ->
421− |> Async.AwaitTask
341+ {| Id = rd.ReadInt64 "Id"
422−
342+ RecipientHex = rd.ReadString "RecipientHex"
423− return rows |> Seq.tryHead
343+ EnvelopeJson = rd.ReadString "EnvelopeJson" |})
424 with ex ->
344 with ex ->
425 log $"peekOutbox error: {ex.Message}"
345 log $"peekOutbox error: {ex.Message}"
426 return None
346 return None
@@ -431,14 +351,10 @@ module Store =
432 use conn = openConn ()
352 use conn = openConn ()
435− delete {
355+ |> Db.newCommand "DELETE FROM outbox WHERE Id = @Id"
436− for o in outboxTable do
356+ |> Db.setParams [ "Id", SqlType.Int64 id ]
437− where (o.Id = id)
357+ |> Db.exec
442 with ex ->
358 with ex ->
443 log $"dequeueOutbox error: {ex.Message}"
359 log $"dequeueOutbox error: {ex.Message}"
@@ -448,30 +364,12 @@ module Store =
449 use conn = openConn ()
365 use conn = openConn ()
452− conn.QueryAsync<MessageRow>(
453− "SELECT Id, ConversationId, Body, TimestampUnix, IsOutgoing, Status FROM messages WHERE ConversationId = @convId ORDER BY TimestampUnix",
454− {| convId = conversationId |}
460− |> Seq.map (fun m ->
369+ |> Db.newCommand
461− { Id = m.Id
370+ "SELECT Id, Body, TimestampUnix, IsOutgoing, Status FROM messages WHERE ConversationId = @convId ORDER BY TimestampUnix"
462− Body = m.Body
371+ |> Db.setParams [ "convId", SqlType.String conversationId ]
463− Timestamp = DateTimeOffset.FromUnixTimeMilliseconds m.TimestampUnix
372+ |> Db.query readMessage
465− if not m.IsOutgoing then
469− if m.Status = 1 then
470− DeliveryStatus.Delivered
475 with ex ->
373 with ex ->
476 log $"loadMessagesForConversation error: {ex.Message}"
374 log $"loadMessagesForConversation error: {ex.Message}"
477 return []
375 return []