* xftp: add PostgreSQL backend design spec
* update doc
* adjust styling
* add implementation plan
* refactor: move usedStorage from FileStore to XFTPEnv
* refactor: add getUsedStorage, getFileCount, expiredFiles store functions
* refactor: change file store operations from STM to IO
* refactor: extract FileStoreClass typeclass, move STM impl to Store.STM
* refactor: make XFTPEnv and server polymorphic over FileStoreClass
* feat: add PostgreSQL store skeleton with schema migration
* feat: implement PostgresFileStore operations
* feat: add PostgreSQL INI config, store dispatch, startup validation
* feat: add database import/export CLI commands
* test: add PostgreSQL backend tests
* fix: map ForeignKeyViolation to AUTH in addRecipient
When a file is concurrently deleted while addRecipient runs, the FK
constraint on recipients.sender_id raises ForeignKeyViolation. Previously
this propagated as INTERNAL; now it returns AUTH (file not found).
* fix: only decrement usedStorage for uploaded files on expiration
expireServerFiles unconditionally subtracted file_size from usedStorage
for every expired file, including files that were never uploaded (no
file_path). Since reserve only increments usedStorage during upload,
expiring never-uploaded files caused usedStorage to drift negative.
* fix: handle setFilePath error in receiveServerFile
setFilePath result was discarded with void. If it failed (file deleted
concurrently, or double-upload where file_path IS NULL guard rejected
the second write), the server still reported FROk, incremented stats,
and left usedStorage permanently inflated. Now the error is checked:
on failure, reserved storage is released and AUTH is returned.
* fix: escape double quotes in COPY CSV status field
The status field (e.g. "blocked,reason=spam,notice={...}") is quoted in
CSV for COPY protocol, but embedded double quotes from BlockingInfo
notice (JSON) were not escaped. This could break CSV parsing during
import. Now double quotes are escaped as "" per CSV spec.
* fix: reject upload to blocked file in Postgres setFilePath
In Postgres mode, getFile returns a snapshot TVar for fileStatus. If a
file is blocked between getFile and setFilePath, the stale status check
passes but the upload should be rejected. Added status = 'active' to
the UPDATE WHERE clause so blocked files cannot receive uploads.
* fix: add CHECK constraint on file_size > 0
Prevents negative or zero file_size values at the database level.
Without this, corrupted data from import or direct DB access could
cause incorrect storage accounting (getUsedStorage sums file_size,
and expiredFiles casts to Word32 which wraps negative values).
* fix: check for existing data before database import
importFileStore now checks if the target database already contains
files and aborts with an error. Previously, importing into a non-empty
database would fail mid-COPY on duplicate primary keys, leaving the
database in a partially imported state.
* fix: clean up disk file when setFilePath fails in receiveServerFile
When setFilePath fails (file deleted or blocked concurrently, or
duplicate upload), the uploaded file was left orphaned on disk with
no DB record pointing to it. Now the file is removed on failure,
matching the cleanup in the receiveChunk error path.
* fix: check storeAction result in deleteOrBlockServerFile_
The store action result (deleteFile/blockFile) was discarded with void.
If the DB row was already deleted by a concurrent operation, the
function still decremented usedStorage, causing drift. Now the error
propagates via ExceptT, skipping the usedStorage adjustment.
* fix: check deleteFile result in expireServerFiles
deleteFile result was discarded with void. If a concurrent delete
already removed the file, deleteFile returned AUTH but usedStorage
was still decremented — causing double-decrement drift. Now the
usedStorage adjustment and filesExpired stat only run on success.
* refactor: merge STM store into Store.hs, parameterize server tests
- Move STMFileStore and its FileStoreClass instance from Store/STM.hs
back into Store.hs — the separate file was unnecessary indirection
for the always-present default implementation.
- Parameterize xftpFileTests over store backend using HSpec SpecWith
pattern (following SMP's serverTests approach). The same 11 tests
now run against both memory and PostgreSQL backends via a bracket
parameter, eliminating all *Pg test duplicates.
- Extract shared run* functions (runTestFileChunkDeliveryAddRecipients,
runTestWrongChunkSize, runTestFileChunkExpiration, runTestFileStorageQuota)
from inlined test bodies.
* refactor: clean up per good-code review
- Remove internal helpers from Postgres.hs export list (withDB, withDB',
handleDuplicate, assertUpdated, withLog are not imported externally)
- Replace local isNothing_ with Data.Maybe.isNothing in Env.hs
- Consolidate duplicate/unused imports in XFTPStoreTests.hs
- Add file_path IS NULL and status guards to STM setFilePath, matching
the Postgres implementation semantics
* test: parameterize XFTP server, agent and CLI tests over store backend
- xftpTest/xftpTest2/xftpTest4/xftpTestN now take XFTPTestBracket as
first argument, enabling the same test to run against both memory
and PostgreSQL backends.
- xftpFileTests (server tests), xftpAgentFileTests (agent tests), and
xftpCLIFileTests (CLI tests) are SpecWith-parameterized suites that
receive the bracket from HSpec's before combinator.
- Test.hs runs each parameterized suite twice: once with
xftpMemoryBracket, once with xftpPostgresBracket (CPP-guarded).
- STM-specific tests (store log restore/replay) stay in memory-only
xftpAgentTests. SNI/CORS tests stay in memory-only xftpServerTests.
* refactor: remove dead test wrappers after parameterization
Remove old non-parameterized test wrapper functions that were
superseded by the store-backend-parameterized test suites.
All test bodies (run* and _ functions) are preserved and called
from the parameterized specs. Clean up unused imports.
* feat: add manual tests and guide
* refactor: merge file_size CHECK into initial migration
* refactor: extract rowToFileRec shared by getFile sender/recipient paths
* refactor: parameterize XFTPServerConfig over store type
Embed XFTPStoreConfig s as serverStoreCfg field, matching SMP's
ServerConfig. runXFTPServer and newXFTPServerEnv now take a single
XFTPServerConfig s. Restore verifyCmd local helper structure.
* refactor: minimize diff in tests
Restore xftpServerTests and xftpAgentTests bodies to match master
byte-for-byte (only type signatures change for XFTPTestBracket
parameterization); inline the runTestXXX helpers that were split
on this branch.
* refactor: restore getFile position to match master
* refactor: rename withSTMFile back to withFile
* refactor: close store log inside closeFileStore for STM backend
Move STM store log close responsibility into closeFileStore to
match PostgresFileStore, removing the asymmetry where only PG's
close was self-contained.
STMFileStore holds the log in a TVar populated by newXFTPServerEnv
after readWriteFileStore; stopServer no longer needs the explicit
withFileLog closeStoreLog call. Writes still go through XFTPEnv.storeLog
via withFileLog (unchanged).
* refactor: rename XFTPTestBracket to XFTPTestServer
* fix: move file_size check from PG schema to store log import
* refactor: use SQL-standard type names in XFTP schema
* perf: batch expired file deletions with deleteFiles
* refactor: stream export instead of loading recipients into memory
* refactor: parameterize XFTP store with FSType singleton dispatch
* refactor: minimize diff per review feedback
* refactor: use types over strings, deduplicate parser
* refactor: always parse database store type, fail at startup
* fix compilation without postgresql
* refactor: always parse database store type, fail at startup
33 KiB
XFTP PostgreSQL Backend — Implementation Plan
For agentic workers: REQUIRED: Use superpowers-extended-cc:subagent-driven-development (if subagents available) or superpowers-extended-cc:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add PostgreSQL backend support to xftp-server as an alternative to STM + StoreLog, with bidirectional migration.
Architecture: Introduce FileStoreClass typeclass (IO-based, following QueueStoreClass pattern). Extract current STM store into Store/STM.hs, make Server.hs polymorphic, then add Store/Postgres.hs behind server_postgres CPP flag. usedStorage moves from store to XFTPEnv so the server manages quota tracking externally.
Tech Stack: Haskell, postgresql-simple, STM, fourmolu, cabal with CPP flags
Design spec: plans/2026-03-25-xftp-postgres-backend-design.md
File Structure
Existing files modified:
src/Simplex/FileTransfer/Server/Store.hs— rewritten: becomes typeclass + shared typessrc/Simplex/FileTransfer/Server/Env.hs— polymorphicXFTPEnv s,XFTPStoreConfigGADTsrc/Simplex/FileTransfer/Server.hs— polymorphic overFileStoreClass ssrc/Simplex/FileTransfer/Server/StoreLog.hs— update for IO store functionssrc/Simplex/FileTransfer/Server/Main.hs— INI config, dispatch, CLI commandssimplexmq.cabal— new modulestests/XFTPClient.hs— Postgres test fixturestests/Test.hs— Postgres test group
New files created:
src/Simplex/FileTransfer/Server/Store/STM.hs—STMFileStore(extracted from currentStore.hs)src/Simplex/FileTransfer/Server/Store/Postgres.hs—PostgresFileStore[CPP-guarded]src/Simplex/FileTransfer/Server/Store/Postgres/Config.hs—PostgresFileStoreCfg[CPP-guarded]src/Simplex/FileTransfer/Server/Store/Postgres/Migrations.hs— schema SQL [CPP-guarded]tests/CoreTests/XFTPStoreTests.hs— Postgres store unit tests [CPP-guarded]
Task 1: Move usedStorage from FileStore to XFTPEnv
Files:
-
Modify:
src/Simplex/FileTransfer/Server/Store.hs -
Modify:
src/Simplex/FileTransfer/Server/Env.hs -
Modify:
src/Simplex/FileTransfer/Server.hs -
Step 1: Remove
usedStoragefromFileStoreinStore.hs- Remove
usedStorage :: TVar Int64field fromFileStorerecord (line 47). - Remove
usedStorage <- newTVarIO 0fromnewFileStore(line 75) and drop the field from the record construction (line 76). - In
setFilePath(line 92-97): removemodifyTVar' (usedStorage st) (+ fromIntegral (size fileInfo))— keep onlywriteTVar filePath (Just fPath). Change pattern from\FileRec {fileInfo, filePath}to\FileRec {filePath}(fileInfo is now unused —-Wunused-matcheserror). - In
deleteFile(line 112-119): removemodifyTVar' usedStorage $ subtract (fromIntegral $ size fileInfo). Change outer pattern match fromFileStore {files, recipients, usedStorage}toFileStore {files, recipients}. Change inner pattern fromJust FileRec {fileInfo, recipientIds}toJust FileRec {recipientIds}(fileInfois now unused —-Wunused-matcheserror). - In
blockFile(line 122-127): removewhen deleted $ modifyTVar' usedStorage $ subtract (fromIntegral $ size fileInfo). Change pattern match fromst@FileStore {usedStorage}tost. Thedeletedparameter andfileInfoin the inner pattern become unused — prefix with_or remove from pattern to avoid-Wunused-matches.
- Remove
-
Step 2: Add
usedStoragetoXFTPEnvinEnv.hs- Add
usedStorage :: TVar Int64field toXFTPEnvrecord (betweenstoreandstoreLog, line 93). - In
newXFTPServerEnv(line 112-126): replace lines 117-118:with:used <- countUsedStorage <$> readTVarIO (files store) atomically $ writeTVar (usedStorage store) usedusedStorage <- newTVarIO =<< countUsedStorage <$> readTVarIO (files store) - Add
usedStorageto thepure XFTPEnv {..}construction.
- Add
-
Step 3: Update all
usedStorageaccess sites inServer.hs- Line 552:
us <- asks $ usedStorage . store→us <- asks usedStorage. - Line 569:
us <- asks $ usedStorage . store→us <- asks usedStorage. - Line 639:
usedStart <- readTVarIO $ usedStorage st→usedStart <- readTVarIO =<< asks usedStorage. - Line 647:
usedEnd <- readTVarIO $ usedStorage st→usedEnd <- readTVarIO =<< asks usedStorage. - Line 694:
FileStore {files, usedStorage} <- asks store→ split intoFileStore {files} <- asks storeandusedStorage <- asks usedStorage. - In
deleteOrBlockServerFile_(line 620): aftervoid $ atomically $ storeAction st, add usedStorage adjustment —us <- asks usedStoragethenatomically $ modifyTVar' us $ subtract (fromIntegral $ size fileInfo)when file had a path (checkpathfromreadTVarIO filePathearlier in the function).
- Line 552:
-
Step 4: Build and verify
Run:
cabal build -
Step 5: Run existing tests
Run:
cabal test --test-show-details=streaming --test-option=--match="/XFTP/" -
Step 6: Format and commit
fourmolu -i src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs git add src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs git commit -m "refactor(xftp): move usedStorage from FileStore to XFTPEnv"
Task 2: Add getUsedStorage, getFileCount, expiredFiles functions
Files:
-
Modify:
src/Simplex/FileTransfer/Server/Store.hs -
Modify:
src/Simplex/FileTransfer/Server/Env.hs -
Modify:
src/Simplex/FileTransfer/Server.hs -
Step 1: Add three new functions to
Store.hs- Add to exports:
getUsedStorage,getFileCount,expiredFiles. - Remove
expiredFilePathfrom exports AND delete the function definition (dead code →-Wunused-bindserror). Also remove($>>=)from importSimplex.Messaging.Util (ifM, ($>>=))→Simplex.Messaging.Util (ifM)—$>>=was only used byexpiredFilePath. - Add import:
qualified Data.Map.Strict as M(needed forM.foldl'ingetUsedStorageandM.toListinexpiredFiles). - Implement:
getUsedStorage :: FileStore -> IO Int64 getUsedStorage FileStore {files} = M.foldl' (\acc FileRec {fileInfo = FileInfo {size}} -> acc + fromIntegral size) 0 <$> readTVarIO files getFileCount :: FileStore -> IO Int getFileCount FileStore {files} = M.size <$> readTVarIO files expiredFiles :: FileStore -> Int64 -> Int -> IO [(SenderId, Maybe FilePath, Word32)] expiredFiles FileStore {files} old _limit = do fs <- readTVarIO files fmap catMaybes . forM (M.toList fs) $ \(sId, FileRec {fileInfo = FileInfo {size}, filePath, createdAt = RoundedSystemTime createdAt}) -> if createdAt + fileTimePrecision < old then do path <- readTVarIO filePath pure $ Just (sId, path, size) else pure Nothing - Add imports:
Data.Maybe (catMaybes),Data.Word (Word32)(note:qualified Data.Map.Strict as Malready added in item 3).
- Add to exports:
-
Step 2: Replace
countUsedStorageinEnv.hs- Replace
countUsedStorage <$> readTVarIO (files store)withgetUsedStorage storeinnewXFTPServerEnv. - Remove
countUsedStoragefunction definition and its export. - Remove
qualified Data.Map.Strict as Mimport if no longer used.
- Replace
-
Step 3: Update
restoreServerStatsinServer.hsto usegetFileCountIn
restoreServerStats(line 694-696): replaceFileStore {files} <- asks storeand_filesCount <- M.size <$> readTVarIO fileswithst <- asks storeand_filesCount <- liftIO $ getFileCount st(eliminates theFileStorepattern match —filesbinding no longer needed). -
Step 4: Replace
expireServerFilesiteration inServer.hs- Replace the body of
expireServerFiles(lines 636-660). Removefiles' <- readTVarIO (files st)and theforM_ (M.keys files')loop. - New body: call
expiredFiles st old 10000in a loop. For each(sId, filePath_, fileSize)in returned list: applyitemDelay, remove disk file if present, callatomically $ deleteFile st sId, adjustusedStorageTVar byfileSize, incrementfilesExpiredstat. Loop untilexpiredFilesreturns[]. - Remove
Data.Map.Strictimport from Server.hs if no longer needed (was used forM.sizeandM.keys— now replaced bygetFileCountandexpiredFiles).
- Replace the body of
-
Step 5: Build and verify
Run:
cabal build -
Step 6: Run existing tests
Run:
cabal test --test-show-details=streaming --test-option=--match="/XFTP/" -
Step 7: Format and commit
fourmolu -i src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs git add src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs git commit -m "refactor(xftp): add getUsedStorage, getFileCount, expiredFiles store functions"
Task 3: Change Store.hs functions from STM to IO
Files:
-
Modify:
src/Simplex/FileTransfer/Server/Store.hs -
Modify:
src/Simplex/FileTransfer/Server.hs -
Modify:
src/Simplex/FileTransfer/Server/StoreLog.hs -
Step 1: Change all Store.hs function signatures from STM to IO
For each of:
addFile,setFilePath,addRecipient,getFile,deleteFile,blockFile,deleteRecipient,ackFile:- Change return type from
STM (Either XFTPErrorType ...)toIO (Either XFTPErrorType ...)(orSTM ()toIO ()fordeleteRecipient). - Wrap the function body in
atomically $ do .... - Keep
withFileandnewFileRecas internal STM helpers (called inside theatomicallyblocks).
- Change return type from
-
Step 2: Update Server.hs call sites — remove
atomicallywrappers- Line 563 (
receiveServerFile): changeatomically $ writeTVar filePath (Just fPath)→ addst <- asks storethenvoid $ liftIO $ setFilePath st senderId fPath(design call site #1 —storeis not in scope inreceiveServerFile'sreceivehelper, so bind viaasks;voidavoids-Wunused-do-bindwarning on theEitherresult). - Line 453 (
verifyXFTPTransmission): splitatomically $ verify =<< getFile st party fIdinto:liftIO (getFile st party fId)(IO→M lift), then pattern match on result, usereadTVarIO (fileStatus fr)instead ofreadTVar. - Lines 371, 377 (control port
CPDelete/CPBlock): changeExceptT $ atomically $ getFile fs SFRecipient fileId→ExceptT $ liftIO $ getFile fs SFRecipient fileId(insideunliftIO u $ doblock which runs in M monad —liftIOrequired to lift IO into M). - Line 508 (
addFileincreateFile): theExceptT $ addFile st sId file ts EntityActive—addFileis now IO,ExceptTwraps IO directly. Remove anyatomically. - Line 514 (
addRecipient): same —ExceptT . addRecipient st sIdworks directly in IO. - Line 516 (
retryAdd): change parameter type from(XFTPFileId -> STM (Either XFTPErrorType a))to(XFTPFileId -> IO (Either XFTPErrorType a)). Line 520: changeatomically (add fId)toliftIO (add fId). - Line 605 (
ackFileReception): changeatomically $ deleteRecipient st rId frtoliftIO $ deleteRecipient st rId fr. - Line 620 (
deleteOrBlockServerFile_): change third parameter type from(FileStore -> STM (Either XFTPErrorType ()))to(FileStore -> IO (Either XFTPErrorType ())). Line 626: changevoid $ atomically $ storeAction sttovoid $ liftIO $ storeAction st. expireServerFilesdeletehelper: changeatomically $ deleteFile st sIdtoliftIO $ deleteFile st sId(deleteFile is now IO;liftIOrequired because the helper runs in M monad, not IO).
- Line 563 (
-
Step 3: Update
StoreLog.hs— removeatomicallyfrom replayIn
readFileStore(line 93), functionaddToStore:- Change
atomically (addToStore lr)toaddToStore lr— store functions are now IO. - The
addToStorebody callsaddFile,setFilePath,deleteFile,blockFile,ackFile— all IO now, noatomicallyneeded. - For
AddRecipients:runExceptT $ mapM_ (ExceptT . addRecipient st sId) rcps—addRecipientreturnsIO (Either ...), soExceptT . addRecipient st sIdworks directly.
- Change
-
Step 4: Build and verify
Run:
cabal build -
Step 5: Run existing tests
Run:
cabal test --test-show-details=streaming --test-option=--match="/XFTP/" -
Step 6: Format and commit
fourmolu -i src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/StoreLog.hs git add src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/StoreLog.hs git commit -m "refactor(xftp): change file store operations from STM to IO"
Task 4: Extract FileStoreClass typeclass, move STM impl to Store/STM.hs
Files:
-
Rewrite:
src/Simplex/FileTransfer/Server/Store.hs -
Create:
src/Simplex/FileTransfer/Server/Store/STM.hs -
Modify:
src/Simplex/FileTransfer/Server/StoreLog.hs -
Modify:
src/Simplex/FileTransfer/Server/Env.hs -
Modify:
src/Simplex/FileTransfer/Server.hs -
Modify:
simplexmq.cabal -
Step 1: Create
Store/STM.hs— move all implementation code- Create directory
src/Simplex/FileTransfer/Server/Store/. - Create
src/Simplex/FileTransfer/Server/Store/STM.hs. - Move from
Store.hs:FileStoredata type (rename toSTMFileStore), all function implementations, internal helpers (withFile,newFileRec), all STM-specific imports. - Rename all
FileStorereferences toSTMFileStorein the new file. - Module declaration:
module Simplex.FileTransfer.Server.Store.STMexporting onlySTMFileStore (..)— do NOT export standalone functions (addFile,setFilePath, etc.) to avoid name collisions with the typeclass methods fromStore.hs.
- Create directory
-
Step 2: Rewrite
Store.hsas the typeclass module- Add
{-# LANGUAGE TypeFamilies #-}pragma toStore.hs(required fortype FileStoreConfig sassociated type). - Keep in
Store.hs:FileRec (..),FileRecipient (..),RoundedFileTime,fileTimePrecisiondefinitions and theirStrEncodinginstance. - Add
FileStoreClasstypeclass:class FileStoreClass s where type FileStoreConfig s -- Lifecycle newFileStore :: FileStoreConfig s -> IO s closeFileStore :: s -> IO () -- File operations addFile :: s -> SenderId -> FileInfo -> RoundedFileTime -> ServerEntityStatus -> IO (Either XFTPErrorType ()) setFilePath :: s -> SenderId -> FilePath -> IO (Either XFTPErrorType ()) addRecipient :: s -> SenderId -> FileRecipient -> IO (Either XFTPErrorType ()) getFile :: s -> SFileParty p -> XFTPFileId -> IO (Either XFTPErrorType (FileRec, C.APublicAuthKey)) deleteFile :: s -> SenderId -> IO (Either XFTPErrorType ()) blockFile :: s -> SenderId -> BlockingInfo -> Bool -> IO (Either XFTPErrorType ()) deleteRecipient :: s -> RecipientId -> FileRec -> IO () ackFile :: s -> RecipientId -> IO (Either XFTPErrorType ()) -- Expiration expiredFiles :: s -> Int64 -> Int -> IO [(SenderId, Maybe FilePath, Word32)] -- Stats getUsedStorage :: s -> IO Int64 getFileCount :: s -> IO Int - Do NOT re-export from
Store/STM.hs— this would create a circular module dependency (Store.hs imports Store/STM.hs, Store/STM.hs imports Store.hs). Consumers must importStore.STMdirectly where they needSTMFileStore. - Remove all STM-specific imports that are no longer needed.
- Add
-
Step 3: Add
FileStoreClassinstance inStore/STM.hs- Import
FileStoreClassfromSimplex.FileTransfer.Server.Store. - Inline all implementations directly in the instance body (do NOT delegate to standalone functions — the standalone names collide with typeclass method names, causing ambiguous occurrences for importers):
instance FileStoreClass STMFileStore where type FileStoreConfig STMFileStore = () newFileStore () = do files <- TM.emptyIO recipients <- TM.emptyIO pure STMFileStore {files, recipients} closeFileStore _ = pure () addFile st sId fileInfo createdAt status = atomically $ ... setFilePath st sId fPath = atomically $ ... -- ... (each method's body is the existing function body, inlined) - Remove the standalone top-level function definitions — they are now instance methods. Keep only
withFileandnewFileRecas internal helpers used by the instance methods.
- Import
-
Step 4: Update importers
Env.hs: addimport Simplex.FileTransfer.Server.Store.STM (STMFileStore (..)). ChangeFileStore→STMFileStoreinXFTPEnvtype andnewXFTPServerEnv. Changestore <- newFileStoretostore <- newFileStore ()(typeclass method now takesFileStoreConfig STMFileStorewhich is()). Keepimport Simplex.FileTransfer.Server.StoreforFileRec,FileRecipient,FileStoreClass, etc.Server.hs: addimport Simplex.FileTransfer.Server.Store.STM. ChangeFileStore→STMFileStorein any explicit type annotations. ImportFileStoreClassfromSimplex.FileTransfer.Server.Store.StoreLog.hs: addimport Simplex.FileTransfer.Server.Store.STMto access concreteSTMFileStoretype and store functions used during log replay. ChangeFileStore→STMFileStoreinreadWriteFileStoreandwriteFileStoreparameter types.
-
Step 5: Update cabal file
Add
Simplex.FileTransfer.Server.Store.STMtoexposed-modulesin the!flag(client_library)section, alongside existing XFTP server modules. -
Step 6: Build and verify
Run:
cabal build -
Step 7: Run existing tests
Run:
cabal test --test-show-details=streaming --test-option=--match="/XFTP/" -
Step 8: Format and commit
fourmolu -i src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Store/STM.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/StoreLog.hs git add src/Simplex/FileTransfer/Server/Store.hs src/Simplex/FileTransfer/Server/Store/STM.hs src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/StoreLog.hs simplexmq.cabal git commit -m "refactor(xftp): extract FileStoreClass typeclass, move STM impl to Store.STM"
Task 5: Make XFTPEnv and Server.hs polymorphic over FileStoreClass
Files:
-
Modify:
src/Simplex/FileTransfer/Server/Env.hs -
Modify:
src/Simplex/FileTransfer/Server.hs -
Modify:
src/Simplex/FileTransfer/Server/Main.hs -
Modify:
tests/XFTPClient.hs(if it callsrunXFTPServerBlockingdirectly) -
Step 1: Make
XFTPEnvpolymorphic inEnv.hs- Add
XFTPStoreConfigGADT:data XFTPStoreConfig s where XSCMemory :: Maybe FilePath -> XFTPStoreConfig STMFileStore. - Change
data XFTPEnvtodata XFTPEnv s— fieldstore :: FileStorebecomesstore :: s. - Change
newXFTPServerEnv :: XFTPServerConfig -> IO XFTPEnvtonewXFTPServerEnv :: FileStoreClass s => XFTPStoreConfig s -> XFTPServerConfig -> IO (XFTPEnv s). - Pattern match on
XSCMemory storeLogPathinnewXFTPServerEnvbody. Create store vianewFileStore (), storeLog viamapM (readWriteFileStorest) storeLogPath.
- Add
-
Step 2: Make
Server.hspolymorphic- Change
type M a = ReaderT XFTPEnv IO atotype M s a = ReaderT (XFTPEnv s) IO a. - Add
FileStoreClass s =>constraint to all functions usingM s a. Useforall s.in signatures of functions that havewhere-block bindings withM stype annotations —ScopedTypeVariablesrequires explicitforallto bringsinto scope for inner type signatures (matching SMP'ssmpServer :: forall s. MsgStoreClass s => ...pattern). Full list:xftpServer,processRequest,verifyXFTPTransmission,processXFTPRequestand all itswhere-bound functions (createFile,addRecipients,receiveServerFile,sendServerFile,deleteServerFile,ackFileReception,retryAdd,addFileRetry,addRecipientRetry),deleteServerFile_,blockServerFile,deleteOrBlockServerFile_,expireServerFiles,randomId,getFileId,withFileLog,incFileStat,saveServerStats,restoreServerStats,randomDelay(inside#ifdef slow_serversCPP block). Also updateencodeXftp(line 236) andrunCPClient(line 339) which use explicitReaderT XFTPEnv IOinstead of theMalias — change toReaderT (XFTPEnv s) IO. - Change
runXFTPServerBlockingandrunXFTPServerto takeXFTPStoreConfig sparameter. - Add
closeFileStore storecall to the server shutdown path (in thefinallyblock orstopServerequivalent — after saving stats, before logging "Server stopped"). This ensures Postgres connection pool anddbStoreLogare properly closed. For STM this is a no-op.
- Change
-
Step 3: Update
Main.hsdispatch- In
runServer: constructXSCMemory (enableStoreLog $> storeLogFilePath). - Add dispatch function that calls the updated
runXFTPServer(which createsstartedinternally):run :: FileStoreClass s => XFTPStoreConfig s -> IO () run storeCfg = runXFTPServer storeCfg serverConfig - Call
runwith theXSCMemoryconfig.
- In
-
Step 4: Update test helper if needed
If
tests/XFTPClient.hscallsrunXFTPServerBlockingdirectly, update the call to pass anXSCMemoryconfig. Check thewithXFTPServer/serverBrackethelper. -
Step 5: Build and verify
Run:
cabal build && cabal build test:simplexmq-test -
Step 6: Run existing tests
Run:
cabal test --test-show-details=streaming --test-option=--match="/XFTP/" -
Step 7: Format and commit
fourmolu -i src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/Main.hs git add src/Simplex/FileTransfer/Server/Env.hs src/Simplex/FileTransfer/Server.hs src/Simplex/FileTransfer/Server/Main.hs tests/XFTPClient.hs simplexmq.cabal git commit -m "refactor(xftp): make XFTPEnv and server polymorphic over FileStoreClass"
Task 6: Add Postgres config, migrations, and store skeleton
Files:
-
Create:
src/Simplex/FileTransfer/Server/Store/Postgres/Config.hs -
Create:
src/Simplex/FileTransfer/Server/Store/Postgres/Migrations.hs -
Create:
src/Simplex/FileTransfer/Server/Store/Postgres.hs -
Modify:
src/Simplex/FileTransfer/Server/Env.hs -
Modify:
simplexmq.cabal -
Step 1: Create
Store/Postgres/Config.hsmodule Simplex.FileTransfer.Server.Store.Postgres.Config ( PostgresFileStoreCfg (..), defaultXFTPDBOpts, ) where import Simplex.Messaging.Agent.Store.Postgres.Options (DBOpts (..)) import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation) data PostgresFileStoreCfg = PostgresFileStoreCfg { dbOpts :: DBOpts, dbStoreLogPath :: Maybe FilePath, confirmMigrations :: MigrationConfirmation } defaultXFTPDBOpts :: DBOpts defaultXFTPDBOpts = DBOpts { connstr = "postgresql://xftp@/xftp_server_store", schema = "xftp_server", poolSize = 10, createSchema = False } -
Step 2: Create
Store/Postgres/Migrations.hsFull migration module with
xftpServerMigrations :: [Migration]andm20260325_initialcontaining CREATE TABLE SQL forfilesandrecipientstables plus indexes. Follow SMP'sQueueStore/Postgres/Migrations.hspattern exactly: tuple list →sortOn name . map migration. -
Step 3: Create
Store/Postgres.hswith stub instance- Define
PostgresFileStorewithdbStore :: DBStoreanddbStoreLog :: Maybe (StoreLog 'WriteMode). instance FileStoreClass PostgresFileStorewitherror "not implemented"for all methods exceptnewFileStore(callscreateDBStore+ opensdbStoreLog) andcloseFileStore(closes both).type FileStoreConfig PostgresFileStore = PostgresFileStoreCfg.- Add
withDB,handleDuplicate,assertUpdated,withLoghelpers.
- Define
-
Step 4: Add
XSCDatabaseGADT constructor inEnv.hs(CPP-guarded)#if defined(dbServerPostgres) import Simplex.FileTransfer.Server.Store.Postgres (PostgresFileStore) import Simplex.FileTransfer.Server.Store.Postgres.Config (PostgresFileStoreCfg) #endif data XFTPStoreConfig s where XSCMemory :: Maybe FilePath -> XFTPStoreConfig STMFileStore #if defined(dbServerPostgres) XSCDatabase :: PostgresFileStoreCfg -> XFTPStoreConfig PostgresFileStore #endif -
Step 5: Update cabal
Add to existing
if flag(server_postgres)block:Simplex.FileTransfer.Server.Store.Postgres Simplex.FileTransfer.Server.Store.Postgres.Config Simplex.FileTransfer.Server.Store.Postgres.Migrations -
Step 6: Build both ways
Run:
cabal build && cabal build -fserver_postgres -
Step 7: Format and commit
fourmolu -i src/Simplex/FileTransfer/Server/Store/Postgres.hs src/Simplex/FileTransfer/Server/Store/Postgres/Config.hs src/Simplex/FileTransfer/Server/Env.hs git add src/Simplex/FileTransfer/Server/Store/Postgres.hs src/Simplex/FileTransfer/Server/Store/Postgres/Config.hs src/Simplex/FileTransfer/Server/Store/Postgres/Migrations.hs src/Simplex/FileTransfer/Server/Env.hs simplexmq.cabal git commit -m "feat(xftp): add PostgreSQL store skeleton with schema migration"
Task 7: Implement PostgresFileStore operations
Files:
-
Modify:
src/Simplex/FileTransfer/Server/Store/Postgres.hs -
Step 1: Implement
addFileINSERT INTO files (sender_id, file_size, file_digest, sender_key, file_path, created_at, status) VALUES (?,?,?,?,NULL,?,?). Catch unique violation withhandleDuplicate→DUPLICATE_. CallwithLog "addFile"after. -
Step 2: Implement
getFileFor
SFSender:SELECT ... FROM files WHERE sender_id = ?. ConstructFileRecwithnewTVarIOper TVar field.recipientIds = S.empty. ForSFRecipient:SELECT f.*, r.recipient_key FROM recipients r JOIN files f ON r.sender_id = f.sender_id WHERE r.recipient_id = ?. -
Step 3: Implement
setFilePathUPDATE files SET file_path = ? WHERE sender_id = ? AND file_path IS NULL. UseassertUpdated. CallwithLog "setFilePath". -
Step 4: Implement
addRecipientINSERT INTO recipients (recipient_id, sender_id, recipient_key) VALUES (?,?,?).handleDuplicate→DUPLICATE_. CallwithLog "addRecipient". -
Step 5: Implement
deleteFile,blockFiledeleteFile:DELETE FROM files WHERE sender_id = ?(CASCADE).withLog "deleteFile".blockFile:UPDATE files SET status = ? WHERE sender_id = ?.assertUpdated.withLog "blockFile". -
Step 6: Implement
deleteRecipient,ackFiledeleteRecipient:DELETE FROM recipients WHERE recipient_id = ?.withLog "deleteRecipient".ackFile: same + returnLeft AUTHif 0 rows. -
Step 7: Implement
expiredFiles,getUsedStorage,getFileCountexpiredFiles:SELECT sender_id, file_path, file_size FROM files WHERE created_at + ? < ? LIMIT ?.getUsedStorage:SELECT COALESCE(SUM(file_size), 0) FROM files.getFileCount:SELECT COUNT(*) FROM files. -
Step 8: Add
ToField/FromFieldinstancesFor
RoundedFileTime(Int64 wrapper),ServerEntityStatus(Text via StrEncoding),C.APublicAuthKey(Binary viaencodePubKey/decodePubKey). Check SMP'sQueueStore/Postgres.hsfor existing instances to import. -
Step 9: Wrap mutation operations in
uninterruptibleMask_Operations that combine a DB write with a TVar update (e.g.,
getFileconstructsFileRecwithnewTVarIO) must be wrapped inE.uninterruptibleMask_to prevent async exceptions from leaving inconsistent state. Follow SMP'saddQueue_,deleteStoreQueuepattern. -
Step 10: Build
Run:
cabal build -fserver_postgres -
Step 11: Format and commit
fourmolu -i src/Simplex/FileTransfer/Server/Store/Postgres.hs git add src/Simplex/FileTransfer/Server/Store/Postgres.hs git commit -m "feat(xftp): implement PostgresFileStore operations"
Task 8: Add INI config, Main.hs dispatch, startup validation
Files:
-
Modify:
src/Simplex/FileTransfer/Server/Main.hs -
Modify:
src/Simplex/FileTransfer/Server/Env.hs -
Step 1: Update
iniFileContentinMain.hsAdd to
[STORE_LOG]section:store_files: memory, commented-outdb_connection,db_schema,db_pool_size,db_store_logkeys. Follow SMP'soptDisabled'pattern for commented defaults. -
Step 2: Add
StartOptionsand--confirm-migrationsflagdata StartOptions = StartOptions { confirmMigrations :: MigrationConfirmation }Add to
Startcommand parser with defaultMCConsole. Thread through torunServer. -
Step 3: Add store_files INI parsing and CPP-guarded Postgres dispatch
In
runServer: readstore_filesfrom INI (fromRight "memory" $ lookupValue "STORE_LOG" "store_files" ini). Add"database"branch (CPP-guarded) that constructsPostgresFileStoreCfgusinginiDBOptions ini defaultXFTPDBOptsandenableDbStoreLog'pattern. Non-postgres build:exitError. -
Step 4: Add
XSCDatabasebranch innewXFTPServerEnv(Env.hs)CPP-guarded pattern match on
XSCDatabase dbCfg:newFileStore dbCfg,storeLog = Nothing. -
Step 5: Add startup config validation
Add
checkFileStoreMode(CPP-guarded) beforerun: validate conflicting storeLog file + database mode, missing schema, etc. per design doc. -
Step 6: Build both ways
Run:
cabal build && cabal build -fserver_postgres -
Step 7: Format and commit
fourmolu -i src/Simplex/FileTransfer/Server/Main.hs src/Simplex/FileTransfer/Server/Env.hs git add src/Simplex/FileTransfer/Server/Main.hs src/Simplex/FileTransfer/Server/Env.hs git commit -m "feat(xftp): add PostgreSQL INI config, store dispatch, startup validation"
Task 9: Add database import/export CLI commands
Files:
-
Modify:
src/Simplex/FileTransfer/Server/Main.hs -
Step 1: Add
DatabaseCLI command (CPP-guarded)Add
Database StoreCmd DBOptsconstructor toCliCommand. Adddatabasesubcommand parser withimport/exportsubcommands +dbOptsP defaultXFTPDBOpts. -
Step 2: Implement
importFileStoreToDatabaseconfirmOrExitwith database details.- Create temporary
STMFileStore, replay StoreLog viareadWriteFileStore. - Create
PostgresFileStorewithcreateSchema = True,confirmMigrations = MCYesUp. - Batch-insert files using PostgreSQL COPY protocol. Progress every 10k.
- Batch-insert recipients using COPY protocol.
- Verify counts:
SELECT COUNT(*)— warn on mismatch. - Rename StoreLog to
.bak. - Report counts.
-
Step 3: Implement
exportDatabaseToStoreLogconfirmOrExit. Fail if output file exists.- Create
PostgresFileStorefrom config. - Open StoreLog for writing.
- Fold over file records: write
AddFile(with status),AddRecipients,PutFileper file. - Close StoreLog, report counts.
-
Step 4: Build
Run:
cabal build -fserver_postgres -
Step 5: Format and commit
fourmolu -i src/Simplex/FileTransfer/Server/Main.hs git add src/Simplex/FileTransfer/Server/Main.hs git commit -m "feat(xftp): add database import/export CLI commands"
Task 10: Add Postgres tests
Files:
-
Modify:
tests/XFTPClient.hs -
Modify:
tests/Test.hs -
Create:
tests/CoreTests/XFTPStoreTests.hs -
Step 1: Add test fixtures in
tests/XFTPClient.hstestXFTPStoreDBOpts :: DBOpts testXFTPStoreDBOpts = DBOpts { connstr = "postgresql://test_xftp_server_user@/test_xftp_server_db", schema = "xftp_server_test", poolSize = 10, createSchema = True }Add
testXFTPDBConnectInfo :: ConnectInfomatching the connection string. -
Step 2: Add Postgres server test group in
tests/Test.hsCPP-guarded block that runs existing
xftpServerTestswith Postgres store config, wrapped inpostgressBracket testXFTPDBConnectInfo. ParameterizewithXFTPServerto accept store config if needed. -
Step 3: Create
tests/CoreTests/XFTPStoreTests.hs— unit testsTest
PostgresFileStoreoperations directly:addFile+getFile SFSenderround-trip.addFileduplicate →DUPLICATE_.getFilenonexistent →AUTH.setFilePath+ verifyWHERE file_path IS NULLguard.addRecipient+getFile SFRecipientround-trip.deleteFilecascades recipients.blockFile+ verify status.expiredFilesbatch semantics.getUsedStorage,getFileCountcorrectness.
-
Step 4: Add migration round-trip test
Create
STMFileStorewith test data (files + recipients + blocked status) → export to StoreLog → import to Postgres → export back → compare StoreLog files byte-for-byte. -
Step 5: Build and run tests
cabal build -fserver_postgres test:simplexmq-test cabal test --test-show-details=streaming --test-option=--match="/XFTP/" -fserver_postgres -
Step 6: Format and commit
fourmolu -i tests/CoreTests/XFTPStoreTests.hs tests/XFTPClient.hs git add tests/CoreTests/XFTPStoreTests.hs tests/XFTPClient.hs tests/Test.hs git commit -m "test(xftp): add PostgreSQL backend tests"