on master help sign in
Simplify code and improve type safety: add DeliveryStatus, inline result ADTs, factor retryEvictedAcks, type route params as Timestamp
commitb1414f5e28349d125db7bcdb6efe1b06c14e2ec7 authoriko <mail@iko.soy> dateFri 10 Apr 2026, 13:35 (6 months ago) parent3f992e7
13 files changed+83−99
client/ApiClient.fs+6−15 client/App.fs+18−16 client/Store.fs+8−16 client/bin/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.dll client/bin/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.pdb client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.AssemblyInfo.fs+1−1 client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.AssemblyInfoInputs.cache+1−1 client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.dll client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.pdb client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.sourcelink.json+1−1 client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/ref/Skrepka.dll client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/refint/Skrepka.dll server.knot+48−49
client/ApiClient.fs+6−15
@@ -86,9 +86,6 @@ module ApiClient =
8686 | _ -> return text
8787 }
8888
89− let private post httpClient url body token =
90− sendRequest httpClient url body token |> Async.Ignore
91−
9289 let private postJson<'T> httpClient url body token =
9390 async {
9491 let! text = sendRequest httpClient url body token
@@ -107,22 +104,16 @@ module ApiClient =
107104 }
108105
109106 let sendMessage (serverUrl: string) (token: string) (toHex: string) (blobHex: string) (timestamp: int64) =
110− async {
111− let body = JsonSerializer.Serialize({| ``to`` = toHex; encryptedBlob = blobHex; timestamp = timestamp |})
112− return! postJson<SendResult> client $"{serverUrl}/messages" body (Some token)
113− }
107+ let body = JsonSerializer.Serialize({| ``to`` = toHex; encryptedBlob = blobHex; timestamp = timestamp |})
108+ postJson<SendResult> client $"{serverUrl}/messages" body (Some token)
114109
115110 let poll (serverUrl: string) (token: string) (cursor: int64) =
111+ let body = JsonSerializer.Serialize({| cursor = cursor; timeout = 30000 |})
116112 async {
117− let body = JsonSerializer.Serialize({| cursor = cursor; timeout = 30000 |})
118113 let! response = postJson<PollResponse> pollClient $"{serverUrl}/poll" body (Some token)
119− return
120− { response with
121− Events = response.Events |> Array.filter (fun e -> e.EventType = "message") }
114+ return { response with Events = response.Events |> Array.filter (fun e -> e.EventType = "message") }
122115 }
123116
124117 let ackMessages (serverUrl: string) (token: string) (messageIds: string list) =
125− async {
126− let body = JsonSerializer.Serialize({| messageIds = messageIds |})
127− do! post client $"{serverUrl}/messages/ack" body (Some token)
128− }
118+ let body = JsonSerializer.Serialize({| messageIds = messageIds |})
119+ sendRequest client $"{serverUrl}/messages/ack" body (Some token) |> Async.Ignore
client/App.fs+18−16
@@ -13,6 +13,7 @@ module App =
1313
1414 open Store
1515 open Crypto
16+ open ApiClient
1617
1718 // ── Domain Types ──
1819
@@ -66,7 +67,7 @@ module App =
6667 | DoDisconnect
6768 | SetCompose of string
6869 | DoSend
69− | Sent of Result<ApiClient.MessageStatus, string>
70+ | Sent of Result<MessageStatus, string>
7071 | SetNewPubkey of string
7172 | SetNewNickname of string
7273 | DoSaveContact
@@ -132,8 +133,8 @@ module App =
132133 | Identified(id, _) -> { model with Auth = Identified(id, status) }
133134 | NoIdentity -> model
134135
135− let private saveCmd model =
136− [ CmdSaveData { Contacts = model.Contacts; Messages = model.Messages; ServerUrl = model.ServerUrl; PollCursor = model.PollCursor } ]
136+ let private saveCmdMsg model =
137+ CmdSaveData { Contacts = model.Contacts; Messages = model.Messages; ServerUrl = model.ServerUrl; PollCursor = model.PollCursor }
137138
138139 // ── Init ──
139140
@@ -200,13 +201,13 @@ module App =
200201 Page = Chat(pk, "")
201202 Messages = model.Messages |> appendMessage pk outMsg }
202203
203− model', CmdSend(session, pk, compose, id) :: saveCmd model'
204+ model', [ CmdSend(session, pk, compose, id); saveCmdMsg model' ]
204205 | _ -> model, []
205206
206− | Sent(Ok(ApiClient.Delivered | ApiClient.Federated | ApiClient.Queued)) -> model, []
207− | Sent(Ok ApiClient.Rejected) ->
207+ | Sent(Ok(Delivered | Federated | Queued)) -> model, []
208+ | Sent(Ok Rejected) ->
208209 { model with Error = Some "Message rejected by server" }, []
209− | Sent(Ok ApiClient.Unauthorized) ->
210+ | Sent(Ok Unauthorized) ->
210211 { model with Error = Some "Not authorized to deliver message" }, []
211212 | Sent(Error err) -> { model with Error = Some $"Send failed: {err}" }, []
212213
@@ -224,7 +225,7 @@ module App =
224225 [ match trySession model' with
225226 | Some session -> CmdPoll(session, model'.PollCursor)
226227 | None -> ()
227− if not incoming.IsEmpty then yield! saveCmd model' ]
228+ if not incoming.IsEmpty then saveCmdMsg model' ]
228229
229230 | SetNewPubkey pk ->
230231 match model.Page with
@@ -246,7 +247,7 @@ module App =
246247 Contacts = model.Contacts |> upsertContact contact
247248 Page = Conversations }
248249
249− model', saveCmd model'
250+ model', [ saveCmdMsg model' ]
250251 | _ -> model, []
251252
252253 | CopyPubKey ->
@@ -281,7 +282,7 @@ module App =
281282 }
282283 |> Async.Start)
283284
284− let private decryptEvent (privKey: byte[]) (payload: ApiClient.EventPayload) =
285+ let private decryptEvent (privKey: byte[]) (payload: EventPayload) =
285286 try
286287 let blob = Crypto.fromHex payload.EncryptedBlob
287288
@@ -309,7 +310,7 @@ module App =
309310 | CmdConnect(url, identity) ->
310311 asyncCmd (async {
311312 try
312− let! token = ApiClient.authenticate url identity
313+ let! token = authenticate url identity
313314 return AuthOk token
314315 with ex ->
315316 return AuthErr ex.Message
@@ -326,7 +327,7 @@ module App =
326327
327328 let blob = Crypto.encrypt session.Identity.PrivKey recipPub payload
328329 let blobHex = Crypto.toHex blob
329− let! result = ApiClient.sendMessage session.Url session.Token recipientHex blobHex ts
330+ let! result = sendMessage session.Url session.Token recipientHex blobHex ts
330331 return Sent(Ok result.Status)
331332 with ex ->
332333 return Sent(Error ex.Message)
@@ -335,10 +336,10 @@ module App =
335336 | CmdPoll(session, cursor) ->
336337 asyncCmd (async {
337338 try
338− let! response = ApiClient.poll session.Url session.Token cursor
339+ let! response = poll session.Url session.Token cursor
339340 let messages, ackIds, errors =
340341 Array.foldBack
341− (fun (evt: ApiClient.PollEvent) (msgs, acks, errs) ->
342+ (fun (evt: PollEvent) (msgs, acks, errs) ->
342343 match decryptEvent session.Identity.PrivKey evt.Payload with
343344 | Ok msg -> (msg :: msgs, evt.Id :: acks, errs)
344345 | Error err -> (msgs, acks, err :: errs))
@@ -346,7 +347,7 @@ module App =
346347 ([], [], [])
347348
348349 if not ackIds.IsEmpty then
349− do! ApiClient.ackMessages session.Url session.Token ackIds
350+ do! ackMessages session.Url session.Token ackIds
350351
351352 let now = DateTimeOffset.UtcNow.ToString("HH:mm:ss")
352353
@@ -433,11 +434,12 @@ module App =
433434 | None -> ()
434435
435436 match model.Auth with
436− | Identified(_, Offline) | NoIdentity -> Button("Connect", DoConnect)
437+ | Identified(_, Offline) -> Button("Connect", DoConnect)
437438 | Identified(_, Connecting) -> Label("Connecting...").centerTextHorizontal()
438439 | Identified(_, Online _) ->
439440 Button("Disconnect", DoDisconnect)
440441 Button("Go to Conversations", Nav Conversations)
442+ | NoIdentity -> ()
441443 })
442444 .padding(20.)
443445 )
client/Store.fs+8−16
@@ -38,6 +38,12 @@ module Store =
3838 ServerUrl: string
3939 PollCursor: int64 }
4040
41+ let private toChatMessage (d: MessageDoc) : ChatMessage =
42+ { Id = d.Id; Body = d.Body; Timestamp = DateTimeOffset.FromUnixTimeSeconds d.TimestampUnix; IsOutgoing = d.IsOutgoing }
43+
44+ let private toMessageDoc convId (m: ChatMessage) : MessageDoc =
45+ { Id = m.Id; ConversationId = convId; Body = m.Body; TimestampUnix = m.Timestamp.ToUnixTimeSeconds(); IsOutgoing = m.IsOutgoing }
46+
4147 let private openDb () =
4248 new LiteDatabase(Path.Combine(FileSystem.AppDataDirectory, "skrepka.db"))
4349
@@ -80,15 +86,7 @@ module Store =
8086 db.GetCollection<MessageDoc>("messages").FindAll()
8187 |> Seq.groupBy _.ConversationId
8288 |> Seq.map (fun (convId, docs) ->
83− convId,
84− docs
85− |> Seq.map (fun d ->
86− { ChatMessage.Id = d.Id
87− Body = d.Body
88− Timestamp = DateTimeOffset.FromUnixTimeSeconds d.TimestampUnix
89− IsOutgoing = d.IsOutgoing })
90− |> Seq.sortBy _.Timestamp
91− |> Seq.toList)
89+ convId, docs |> Seq.map toChatMessage |> Seq.sortBy _.Timestamp |> Seq.toList)
9290 |> Map.ofSeq
9391
9492 Some
@@ -112,13 +110,7 @@ module Store =
112110 data.Messages
113111 |> Map.iter (fun convId msgs ->
114112 for m in msgs do
115− messages.Upsert
116− { Id = m.Id
117− ConversationId = convId
118− Body = m.Body
119− TimestampUnix = m.Timestamp.ToUnixTimeSeconds()
120− IsOutgoing = m.IsOutgoing }
121− |> ignore)
113+ messages.Upsert(toMessageDoc convId m) |> ignore)
122114
123115 settings.Upsert
124116 { Id = "settings"
client/bin/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.dll

Binary file not shown.

client/bin/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.pdb

Binary file not shown.

client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.AssemblyInfo.fs+1−1
@@ -10,7 +10,7 @@ open System.Reflection
1010[<assembly: System.Reflection.AssemblyCompanyAttribute("Skrepka")>]
1111[<assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")>]
1212[<assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")>]
13−[<assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0+5c34fab63923cdb1b780a7e5a5ce5fae72adf78e")>]
13+[<assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0+3f992e7c76ca611744630935339cabed5d593ef9")>]
1414[<assembly: System.Reflection.AssemblyProductAttribute("Skrepka")>]
1515[<assembly: System.Reflection.AssemblyTitleAttribute("Skrepka")>]
1616[<assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")>]
client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.AssemblyInfoInputs.cache+1−1
@@ -1 +1 @@
1−619a20356f5e49038371f33a93f265cb66ee99cf70d2100710b5c8d26eea4f99
1+808c17c7a8d50977c580708316952c7549166adf51c13e03fa8fcc9b8233a84e
client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.dll

Binary file not shown.

client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.pdb

Binary file not shown.

client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.sourcelink.json+1−1
@@ -1 +1 @@
1−{"documents":{"/Users/iko/Developer/skrepka/*":"https://raw.githubusercontent.com/ilyakooo0/skrepka/5c34fab63923cdb1b780a7e5a5ce5fae72adf78e/*"}}
1+{"documents":{"/Users/iko/Developer/skrepka/*":"https://raw.githubusercontent.com/ilyakooo0/skrepka/3f992e7c76ca611744630935339cabed5d593ef9/*"}}
client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/ref/Skrepka.dll

Binary file not shown.

client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/refint/Skrepka.dll

Binary file not shown.

server.knot+48−49
@@ -71,10 +71,9 @@ type AckedEntry = {messageId: MessageIdHex, toKey: PubkeyHex, ackedAt: Timestamp
7171type AuthResult = Authorized {pubkey: PubkeyHex} | Unauthorized {}
7272type PruneResult = {expiredLocal: [PresenceEntry], peerServers: [ServerName]}
7373type PollResult = PollUnauthorized {} | PollAuthorized {events: [PollEvent], cursor: Timestamp}
74−type VerifyResult = VerifyFailed {} | VerifyOk {peerServers: [ServerName]}
75−type SendResult = SendUnauthorized {} | SendRejected {} | SendDone {status: MessageStatus, remoteServers: [ServerName], fromKey: PubkeyHex}
74+type DeliveryStatus = Text where \s -> s == "delivered" || s == "federated" || s == "queued"
75+type SendResult = SendUnauthorized {} | SendRejected {} | SendDone {status: DeliveryStatus, remoteServers: [ServerName], fromKey: PubkeyHex}
7676type AckResult = AckUnauthorized {} | AckDone {pendingAcks: [AckedEntry], evictedEntries: [AckedEntry]}
77−type LookupResult = LookupUnauthorized {} | LookupDone {foundServer: OptionalServerName}
7877
7978
8079-- ============================================
@@ -107,12 +106,12 @@ route AuthApi where
107106 -> {challenge: Text}
108107 = ReqChallenge
109108 POST {pubkey: PubkeyHex, challenge: Text, signature: SignatureHex} /verify
110− -> {token: Text, expiresAt: Int}
109+ -> {token: Text, expiresAt: Timestamp}
111110 = VerifyAuth
112111
113112route ClientApi where
114− POST {cursor: Int, timeout: Int} /poll headers {authorization: Text}
115− -> {events: [PollEvent], cursor: Int, authorized: Bool}
113+ POST {cursor: Timestamp, timeout: Int} /poll headers {authorization: Text}
114+ -> {events: [PollEvent], cursor: Timestamp, authorized: Bool}
116115 = Poll
117116 /messages
118117 POST {to: PubkeyHex, encryptedBlob: BlobHex, timestamp: Timestamp} / headers {authorization: Text}
@@ -652,6 +651,33 @@ sendAcksAndClean = \pendingAcks servers ->
652651 sendToEach servers (\server ->
653652 sendBatchedAcksToServer (do a <- pendingAcks; where a.originServer == server; yield a) server)
654653
654+retryEvictedAcks = \evictedEntries pendingAcks ->
655+ let evictedToRetry = do
656+ a <- evictedEntries
657+ where none pendingAcks (\a2 -> a2.messageId == a.messageId && a2.toKey == a.toKey)
658+ yield a
659+ in when (count evictedToRetry > 0) (do
660+ let evictedOriginServers = collectPeerServers evictedToRetry (\a -> a.originServer)
661+ sendToEach evictedOriginServers (\server -> do
662+ fetchResult <- fetch (federationScheme ++ server) (FederationAck {
663+ authorization: "Bearer " ++ federationSecret,
664+ messageIds: (do a <- evictedToRetry; where a.originServer == server; yield a.messageId),
665+ originServer: server, fromServer: serverHostN})
666+ case fetchResult of
667+ Err _ -> do
668+ dropped <- atomic do
669+ ackedIds <- *ackedIds
670+ let reinsert = do a <- evictedToRetry; where a.originServer == server; yield a
671+ let combined = union ackedIds reinsert
672+ let excess = count combined - maxAckedIds
673+ let afterEviction = if excess <= 0 then combined
674+ else evictOldestAckedIds combined excess
675+ set *ackedIds = afterEviction
676+ yield excess
677+ when (dropped > 0)
678+ (println ("ackMessages: ackedIds at capacity (" ++ show maxAckedIds ++ "), " ++ show dropped ++ " ack entries permanently lost for " ++ server))
679+ _ -> yield {}))
680+
655681pruneAndRetry = \t -> do
656682 result <- atomic do
657683 set *lastPrunedAt = t
@@ -743,7 +769,7 @@ serve = \req -> case req of
743769 let hasMatch = any challenges (\c -> c.pubkey == pubkey && c.challenge == challenge && c.expiresAt > t)
744770 let sigValid = if hasMatch then verify (bytesFromHex pubkey) (textToBytes challenge) (bytesFromHex signature) else False {}
745771 if not sigValid
746− then yield {pr: pr, verify: VerifyFailed {}}
772+ then yield {pr: pr, verified: False {}, peerServers: []}
747773 else do
748774 set *challenges = do
749775 c <- challenges
@@ -762,20 +788,17 @@ serve = \req -> case req of
762788 let peerServers = if federationEnabled
763789 then collectPeerServers updatedPresence (\p -> p.server)
764790 else []
765− yield {pr: pr, verify: VerifyOk {peerServers: peerServers}}
766− let expiredLocal = case result.verify of
767− VerifyOk _ -> do p <- result.pr.expiredLocal; where not (p.pubkey == pubkey); yield p
768− _ -> result.pr.expiredLocal
791+ yield {pr: pr, verified: True {}, peerServers: peerServers}
792+ let expiredLocal = if result.verified
793+ then do p <- result.pr.expiredLocal; where not (p.pubkey == pubkey); yield p
794+ else result.pr.expiredLocal
769795 fork (do
770− case result.verify of
771− VerifyOk {peerServers} ->
772− when federationEnabled
773− (sendBatchedGossip peerServers [{eventType: "online", pubkey: pubkey, server: serverHostN, timestamp: t, ttl: onlineGossipTtl}] 2)
774− _ -> yield {}
796+ when (result.verified && federationEnabled)
797+ (sendBatchedGossip result.peerServers [{eventType: "online", pubkey: pubkey, server: serverHostN, timestamp: t, ttl: onlineGossipTtl}] 2)
775798 broadcastOfflineGossip t result.pr.peerServers expiredLocal)
776− yield (case result.verify of
777− VerifyFailed _ -> respond {token: "", expiresAt: 0}
778− VerifyOk _ -> respond {token: token, expiresAt: expiry})
799+ yield (if result.verified
800+ then respond {token: token, expiresAt: expiry}
801+ else respond {token: "", expiresAt: 0})
779802
780803 Poll {cursor, timeout, authorization, respond} -> do
781804 t <- now
@@ -881,31 +904,7 @@ serve = \req -> case req of
881904 let originServers = collectPeerServers pendingAcks (\a -> a.originServer)
882905 when (count originServers > 0)
883906 (sendAcksAndClean pendingAcks originServers)
884− let evictedToRetry = do
885− a <- evictedEntries
886− where none pendingAcks (\a2 -> a2.messageId == a.messageId && a2.toKey == a.toKey)
887− yield a
888− when (count evictedToRetry > 0) (do
889− let evictedOriginServers = collectPeerServers evictedToRetry (\a -> a.originServer)
890− sendToEach evictedOriginServers (\server -> do
891− fetchResult <- fetch (federationScheme ++ server) (FederationAck {
892− authorization: "Bearer " ++ federationSecret,
893− messageIds: (do a <- evictedToRetry; where a.originServer == server; yield a.messageId),
894− originServer: server, fromServer: serverHostN})
895− case fetchResult of
896− Err _ -> do
897− dropped <- atomic do
898− ackedIds <- *ackedIds
899− let reinsert = do a <- evictedToRetry; where a.originServer == server; yield a
900− let combined = union ackedIds reinsert
901− let excess = count combined - maxAckedIds
902− let afterEviction = if excess <= 0 then combined
903− else evictOldestAckedIds combined excess
904− set *ackedIds = afterEviction
905− yield excess
906− when (dropped > 0)
907− (println ("ackMessages: ackedIds at capacity (" ++ show maxAckedIds ++ "), " ++ show dropped ++ " ack entries permanently lost for " ++ server))
908− _ -> yield {})))
907+ retryEvictedAcks evictedEntries pendingAcks)
909908 _ -> yield {}
910909 broadcastOfflineGossip t result.pr.peerServers result.pr.expiredLocal)
911910 yield (case result.ack of
@@ -921,7 +920,7 @@ serve = \req -> case req of
921920 let auth = authenticateRequest sessions authorization t
922921 case auth of
923922 Unauthorized {} ->
924− yield {pr: pr, lookup: LookupUnauthorized {}}
923+ yield {pr: pr, authorized: False {}, foundServer: ""}
925924 Authorized _ -> do
926925 presence <- *presence
927926 let valid = do
@@ -933,11 +932,11 @@ serve = \req -> case req of
933932 else if p.server == serverHostN then {server: serverHostN, best: p.expiresAt}
934933 else if p.expiresAt > acc.best then {server: p.server, best: p.expiresAt}
935934 else acc) {server: "", best: 0} valid).server
936− yield {pr: pr, lookup: LookupDone {foundServer: foundServer}}
935+ yield {pr: pr, authorized: True {}, foundServer: foundServer}
937936 forkBroadcastOffline t result.pr
938− yield (case result.lookup of
939− LookupUnauthorized _ -> respond {online: False {}, server: "", authorized: False {}}
940− LookupDone {foundServer} -> respond {online: not (foundServer == ""), server: foundServer, authorized: True {}})
937+ yield (if result.authorized
938+ then respond {online: not (result.foundServer == ""), server: result.foundServer, authorized: True {}}
939+ else respond {online: False {}, server: "", authorized: False {}})
941940
942941 RecvGossip {events, hopsRemaining, fromServer, authorization, respond} -> do
943942 t <- now