Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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): InProcessSyncServer starts 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 through KtorDestination. 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.

BehaviorShared, JVM
Notebooks, notes, pins and history reach another deviceSyncEngineTest.notesNotebooksAndHistoryReachAnotherDevice
A note in the default notebook lands in the other device’s default notebook, which is never synced itselfSyncEngineTest.notesInTheDefaultNotebookLandInTheOtherDevicesDefault
No readable text reaches the serverSyncEngineTest.theServerSeesNoContent
Pulled changes are not queued to be sent backSyncEngineTest.pulledChangesAreNotSentBack
Turning sync on queues what was there before; nothing is queued while it is offSyncEngineTest.turningSyncOnSendsWhatWasThereBefore, SyncEngineTest.syncIsRefusedWhileOff
A synced revision is never replaced by a save in the same minuteSyncEngineTest.aSyncedRevisionIsNeverReplaced
Moves, trash, restore, purge, notebook rename and archiving reach the other deviceSyncEngineTest.movesTrashAndPurgeReachTheOtherDevice
A deleted notebook’s notes, including one added offline elsewhere, move to the default notebookSyncEngineTest.aDeletedNotebooksNotesMoveToTheDefaultNotebook
Edits on both devices keep both versions: one as the note, the other as a conflict copySyncEngineTest.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 titleSyncEngineTest.aDeleteMeetingAnEditAsksTheUserOnTheEditingDevice, SyncEngineTest.aDeleteMeetingAnEditAsksTheUserOnTheDeletingDevice
Changes arrive over several pagesSyncEngineTest.manyChangesArriveOverSeveralPages
Turning sync off forgets the server and keeps the notesSyncEngineTest.turningSyncOffForgetsTheServerButKeepsNotes
A put whose response was lost is not stored twiceSyncFaultTest.aPutWhoseResponseWasLostIsNotStoredTwice
A put lost on the way is sent againSyncFaultTest.aPutLostOnTheWayIsSentAgain
A pull that fails part way keeps the pages it applied and resumes after themSyncFaultTest.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 HTTPSyncEngineOnServerTest, 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 SyncServerExceptionKtorDestinationTest.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 notCloudSyncTest.notesReachAnotherCoreAndItsScreensHearOfThem
NotesCore.sync: the membership and keys outlive a restart, and what was written before it is sentCloudSyncTest.theMembershipOutlivesTheProcess
NotesCore.sync: leaving forgets the group and both keys and keeps the notes; sync is refused while off or when already joinedCloudSyncTest.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 syncsCloudSyncTest.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 themCloudSyncTest.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 keyCloudSyncTest.turningOnCreatesAGroupWithTheShownWordsAsItsRecoveryKey
A code the server refuses is SyncTurnOnException (CODE_REJECTED) and leaves sync offCloudSyncTest.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 failCloudSyncTest.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 keyEnrollmentTest.aScannedDeviceJoinsAndGetsTheNotes
The code carries the server, both ids and both keys, survives surrounding spaces, and stays under 250 charactersEnrollmentTest.theCodeCarriesTheServerTheIdsAndBothKeys
Text that is not a JOTRY1: code, or a damaged one, is refusedEnrollmentTest.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 ownEnrollmentTest.aCodeWhoseKeysDifferFromTheServersIsRefused
A code for another server is refused; one approved already cannot be approved again, and the new device collects its approval onceEnrollmentTest.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 backSyncUiTest.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 textSyncUiTest.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 wordsSyncUiTest.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 refusedRootKeyRotationTest.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 refusedRootKeyRotationTest.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 bothRootKeyRotationTest.aDeviceOfflineAcrossTwoRotationsCatchesUp
Two devices rotating on the same information: one wins, the other forgets the key it made and followsRootKeyRotationTest.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 keyRootKeyRotationTest.aRotationWhoseResponseWasLostIsFinishedByTheNextSync, RootKeyRotationTest.aRotationLostBeforeTheServerSawItIsMadeAgain
A device with no copy of the new key waits ([SyncWaitingForKeyException]) until a member that has it syncs and sends itRootKeyRotationTest.aDeviceWaitsForTheNewKeyUntilAMemberThatHasItSyncs
Nothing rotates without a removal, and a removal after a rotation needs anotherRootKeyRotationTest.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); KtorDestinationTest only checks that the destination reports them.
  • An edit racing a push of the same record (the outbox version check).
  • 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.