Sync engine (client)
The client side of cloud sync in app/sharedLogic/src/commonMain/kotlin/apps/siskin/jotry/sync: the outbox triggers, the push and pull of sealed records, merging and the delete-versus-edit decision. Design in sync-system-design.md section 7. Decisions: D-49, D-64, D-65, D-73, D-74, D-75.
Tests are in app/sharedLogic/src/jvmTest/kotlin/apps/siskin/jotry/sync and run on the JVM only: they use real :crypto envelopes, and libsodium has no Android host build. Each test device is a NotesRepository on its own in-memory SQLite (TestDevice), and the devices share one root key, as if enrolled by QR. They sync through a SyncTestServer, and SyncEngineTest and SyncFaultTest run twice:
- against
FakeSyncServer, an in-memory copy of the server’s record rules (D-65); - against the real server (
SyncOnServerTest):InProcessSyncServerstarts the server’s Ktor module in the test’s process on a free port, over the in-memory stores as a development build (D-70), and each device reaches it over HTTP throughKtorDestination. The first device creates the group with a recovery key and every other one is enrolled by QR and unwraps the root key it was sent (HttpSyncGroup), as the apps will.
Either way, calls can fail before the server acts or after it acted with the response lost (FaultPlan).
Turning sync on and joining by QR code from the apps (epic 7) each have a UI test on both platforms, SyncUiTest and SyncUITests, which go as far as the server: they use an in-memory core and an address where nothing listens.
LiveSyncServerTest syncs two devices through a deployed server. It is skipped unless JOTRY_SYNC_SERVER (the server’s root URL) and JOTRY_PROMO_CODE (a code from bin/promo create, D-72) are set, and creates one group per run. It passed against the test server on 2026-10-11.
| Behavior | Shared, JVM |
|---|---|
| Notebooks, notes, pins and history reach another device | SyncEngineTest.notesNotebooksAndHistoryReachAnotherDevice |
| A note in the default notebook lands in the other device’s default notebook, which is never synced itself | SyncEngineTest.notesInTheDefaultNotebookLandInTheOtherDevicesDefault |
| No readable text reaches the server | SyncEngineTest.theServerSeesNoContent |
| Pulled changes are not queued to be sent back | SyncEngineTest.pulledChangesAreNotSentBack |
| Turning sync on queues what was there before; nothing is queued while it is off | SyncEngineTest.turningSyncOnSendsWhatWasThereBefore, SyncEngineTest.syncIsRefusedWhileOff |
| A synced revision is never replaced by a save in the same minute | SyncEngineTest.aSyncedRevisionIsNeverReplaced |
| Moves, trash, restore, purge, notebook rename and archiving reach the other device | SyncEngineTest.movesTrashAndPurgeReachTheOtherDevice |
| A deleted notebook’s notes, including one added offline elsewhere, move to the default notebook | SyncEngineTest.aDeletedNotebooksNotesMoveToTheDefaultNotebook |
| Edits on both devices keep both versions: one as the note, the other as a conflict copy | SyncEngineTest.editsOnBothDevicesKeepBothVersions |
| A delete meeting an edit asks the user, holds the note back until answered, and both answers converge (D-49, D-73); the decision says which side deleted and gives the note’s title | SyncEngineTest.aDeleteMeetingAnEditAsksTheUserOnTheEditingDevice, SyncEngineTest.aDeleteMeetingAnEditAsksTheUserOnTheDeletingDevice |
| Changes arrive over several pages | SyncEngineTest.manyChangesArriveOverSeveralPages |
| Turning sync off forgets the server and keeps the notes | SyncEngineTest.turningSyncOffForgetsTheServerButKeepsNotes |
| A put whose response was lost is not stored twice | SyncFaultTest.aPutWhoseResponseWasLostIsNotStoredTwice |
| A put lost on the way is sent again | SyncFaultTest.aPutLostOnTheWayIsSentAgain |
| A pull that fails part way keeps the pages it applied and resumes after them | SyncFaultTest.aPullThatFailsPartWayResumesAfterTheLastAppliedPage |
| Three devices making random changes through random faults converge, with no unsent changes left (25 seeds; the runs must include faults, decisions and conflict copies) | SyncFaultTest.randomChangesAndFaultsOnThreeDevicesConverge |
| All of the above against the real server, over HTTP | SyncEngineOnServerTest, SyncFaultOnServerTest (in SyncOnServerTest.kt) |
KtorDestination: a put is stored, a stale one is a conflict with the current seq (409) | KtorDestinationTest.aPutIsStoredAndAStaleOneIsAConflict |
KtorDestination: a write that breaks a rule is refused with the server’s code (422) | KtorDestinationTest.aWriteThatBreaksARuleIsRefusedWithItsCode |
KtorDestination: an expired token is renewed by signing in again (401) | KtorDestinationTest.anExpiredTokenIsRenewed |
KtorDestination: a lapsed group may pull but not push (403 read-only), an expired one may not pull (410) | KtorDestinationTest.aLapsedGroupMayPullButNotPush, KtorDestinationTest.anExpiredGroupMayNotPull |
KtorDestination: a removed device is told so (the server’s 404 no-such-device becomes SyncNotAllowedException with reason REMOVED), and an answer that is not the server’s is a SyncServerException | KtorDestinationTest.aRemovedDeviceIsToldSo, KtorDestinationTest.anAnswerThatIsNotTheServersIsAServerError |
KtorDestination: the root key calls map the server’s answers (current id, rotate once to the next id, conflict, no such device, read-only) | KtorDestinationTest.theRootKeyCallsMapTheServersAnswers, KtorDestinationTest.aLapsedGroupMayReadWhereTheRootKeyStandsButNotRotateIt |
NotesCore.sync: notes reach another core through the server, and a pull that changed notes bumps changes (one that brought nothing does not); an edit bumps edits, which the apps sync after, and a pull does not | CloudSyncTest.notesReachAnotherCoreAndItsScreensHearOfThem |
NotesCore.sync: the membership and keys outlive a restart, and what was written before it is sent | CloudSyncTest.theMembershipOutlivesTheProcess |
NotesCore.sync: leaving forgets the group and both keys and keeps the notes; sync is refused while off or when already joined | CloudSyncTest.leavingForgetsTheGroupAndKeysButKeepsNotes, CloudSyncTest.aDeviceCannotJoinTwice |
NotesCore.sync: a removed device is told so, and after turning sync off joins again under a new device id (the server never takes one twice), gets the rotated root key and syncs | CloudSyncTest.aRemovedDeviceIsToldSoAndCanJoinAgainUnderANewId |
NotesCore.sync: a device that moves to another group sends notes it received from the old group’s devices, with keys it makes for them | CloudSyncTest.notesFromAnotherDeviceOfAGroupLeftBehindReachTheNextGroup |
| Turning sync on creates a group with this device as its only member (promo or development purchase, server address with spaces or a trailing slash), sends the notes made before, and the words shown are the recovery key | CloudSyncTest.turningOnCreatesAGroupWithTheShownWordsAsItsRecoveryKey |
A code the server refuses is SyncTurnOnException (CODE_REJECTED) and leaves sync off | CloudSyncTest.aRejectedCodeLeavesSyncOff |
RecoveryKeyDraft asks for three distinct words in order and takes them in any case, with spaces, or as four-letter prefixes; wrong, missing, swapped or three-letter words fail | CloudSyncTest.theDraftAsksForThreeWordsInOrderAndTakesThemLoosely |
| Joining by QR code (D-77), through the real server. A new core shows a code, is not on until a member approves it, then joins, gets the notes and syncs back with its own key | EnrollmentTest.aScannedDeviceJoinsAndGetsTheNotes |
| The code carries the server, both ids and both keys, survives surrounding spaces, and stays under 250 characters | EnrollmentTest.theCodeCarriesTheServerTheIdsAndBothKeys |
Text that is not a JOTRY1: code, or a damaged one, is refused | EnrollmentTest.anythingElseIsNotACode |
A code whose keys differ from the server’s copy of the request is refused (KEYS_DIFFER), as if the server had put in its own | EnrollmentTest.aCodeWhoseKeysDifferFromTheServersIsRefused |
| A code for another server is refused; one approved already cannot be approved again, and the new device collects its approval once | EnrollmentTest.aCodeForAnotherServerOrOneAlreadyUsedIsRefused |
| Apps: joining by QR code, Android and iOS. Join with a QR code waits for a server address, says when the server can’t be reached, offers a new code, and Cancel goes back | SyncUiTest.joiningWithAQrCodeNeedsOnlyTheServerAndSaysWhenItIsUnreachable (Android), SyncUITests.testJoiningWithAQRCodeNeedsOnlyTheServerAndSaysWhenItIsUnreachable (iOS) |
Apps: the build’s server address (jotry.syncServerUrl). Turn on sync starts with it in the server field. iOS tests that type their own address launch with JOTRY_SYNC_SERVER set to empty (a test-only hook, like the export folder) and a separate class checks the default; the Android tests replace the text | SyncUiTest.theBuildsServerAddressIsAlreadyInTheField (Android), SyncDefaultServerUITests.testTheBuildsServerAddressIsAlreadyInTheField (iOS) |
| Apps: turning sync on (epic 7), Android and iOS. Settings → Turn on sync: Continue waits for an address and a code, the 24 words are shown, wrong words are caught before anything is sent, the right ones go to the server, a server that can’t be reached sends the user back to the first step with the same words | SyncUiTest.turningOnShowsTheRecoveryKeyAndChecksThreeWords (Android), SyncUITests.testTurningOnShowsTheRecoveryKeyAndChecksThreeWords (iOS) |
| Root key rotation (D-64, D-74, D-75), three or four devices through the real server. A removed device makes the first remaining device to sync rotate: the group moves to root key 2, every note gets a second note key and every key list opens under root key 2 alone, the other member is sent the key and collects it, records of existing notes are sealed with the new note key (checked in the envelopes on the server) while older ones keep theirs, and the removed device is refused | RootKeyRotationTest.removingADeviceRotatesTheRootKeyAndTheOthersFollow |
A member lists the group’s devices (this one first, then the newest) and removes one as the apps do (CloudSync.devices, CloudSync.removeDevice): it may not remove itself, removing one again finds nothing, every member sees the shorter list, its next sync rotates, the others follow and the removed device is refused | RootKeyRotationTest.aMemberListsTheDevicesAndRemovesOneFromTheApp |
| Devices name themselves with their default, any member renames any device, every member reads the names and when each last synced, the server holds them only sealed, and after a rotation each device seals its own name again under the new key (D-80) | RootKeyRotationTest.devicesNameThemselvesAndTheNamesAreSealedAgainAfterARotation |
| A device offline across two rotations is given the current key and opens key lists written after both | RootKeyRotationTest.aDeviceOfflineAcrossTwoRotationsCatchesUp |
| Two devices rotating on the same information: one wins, the other forgets the key it made and follows | RootKeyRotationTest.twoDevicesRotatingAtOnceHaveOneWinnerAndTheLoserFollows |
| A rotation whose response was lost is finished by the next sync; one lost before the server saw it is made again with a new key | RootKeyRotationTest.aRotationWhoseResponseWasLostIsFinishedByTheNextSync, RootKeyRotationTest.aRotationLostBeforeTheServerSawItIsMadeAgain |
A device with no copy of the new key waits ([SyncWaitingForKeyException]) until a member that has it syncs and sends it | RootKeyRotationTest.aDeviceWaitsForTheNewKeyUntilAMemberThatHasItSyncs |
| Nothing rotates without a removal, and a removal after a rotation needs another | RootKeyRotationTest.noRotationHappensWithoutARemovedDevice, RootKeyRotationTest.aRemovalAfterARotationNeedsAnotherRotation |
| Two devices converge through a deployed server (opt-in) | LiveSyncServerTest.twoDevicesConvergeThroughTheServer |
Not tested: the engine on Android and iOS (it and KtorDestination compile for them, but no test syncs there; the apps’ UI tests stop before the server), and the real server on Postgres (the in-process server uses the in-memory stores, which pass the same store contracts).
Gaps
- Records waiting in
sync_pending(a record whose key or revision has not arrived): the server’s ordering means it never happens in these tests. - Rotation: a removal racing an enrollment, a rotation by a device whose key lists are not all pulled (it rewrites only what it holds and skips lists wrapped under a root key it never had), and the rotation on Android and iOS (the code is shared and compiles for both; the JVM tests run it).
- Two devices of one group that both move to a new group: each sends the notes they share under its own record ids and makes its own key for them.
- How the engine carries on after a group that may not push or pull (
SyncNotAllowedException) or a record the server refuses (PutResult.Refused);KtorDestinationTestonly checks that the destination reports them. - An edit racing a push of the same record (the outbox
versioncheck). - Apps: showing and scanning a QR code (the camera scanners and the drawn codes have no test), turning sync on against a real server from a phone, the status screen (Sync now, last synced, a sync problem, what a removed device is offered), the devices list and Remove (on all three apps; the shared calls are tested), the delete-versus-edit prompt and the “Waiting for you” list (the engine’s side is tested), turning sync off, syncing when the app comes back or a few seconds after an edit, and background sync with its notification (
SyncWorker,BackgroundSync). The status screen has no UI test because UI tests use an in-memory core with no server. - The schema migration from version 3 is checked by
verifySqlDelightMigration, not by a test that migrates a database with notes in it.